Add an immich client that can upload as well as annotate
Carry over immich-sync's client, which lists assets by checksum and drives albums and tags, and add a hand-rolled multipart upload with the checksum header so immich can answer duplicate without receiving the bytes twice, plus a per-asset update for description, date and coordinates. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2 files changed
+392
No files matched your search
@@ -919,5 +919,215 @@ def fetchphase(conf, flickrkey):
|
|||||||
return failures
|
return failures
|
||||||
|
|
||||||
|
|
||||||
|
# immich identifies a file by the base64 of its SHA1
|
||||||
|
def checksum(path):
|
||||||
|
digest = hashlib.sha1()
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
while True:
|
||||||
|
block = fh.read(CHUNK)
|
||||||
|
if not block:
|
||||||
|
break
|
||||||
|
digest.update(block)
|
||||||
|
return base64.b64encode(digest.digest()).decode("ascii")
|
||||||
|
|
||||||
|
#
|
||||||
|
# Hashing the whole backup means reading every byte of it. Remember the
|
||||||
|
# answers, keyed on size and modification time so an edited file is spotted.
|
||||||
|
#
|
||||||
|
class Hashes:
|
||||||
|
def __init__(self, path):
|
||||||
|
self.path = path
|
||||||
|
self.dirty = False
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as fh:
|
||||||
|
self.cache = json.load(fh)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
self.cache = {}
|
||||||
|
|
||||||
|
def of(self, path, key, info):
|
||||||
|
stamp = [info.st_size, info.st_mtime_ns]
|
||||||
|
held = self.cache.get(key)
|
||||||
|
if held and held[:2] == stamp:
|
||||||
|
return held[2]
|
||||||
|
value = checksum(path)
|
||||||
|
self.cache[key] = stamp + [value]
|
||||||
|
self.dirty = True
|
||||||
|
return value
|
||||||
|
|
||||||
|
def save(self):
|
||||||
|
if not self.dirty:
|
||||||
|
return
|
||||||
|
writejson(self.path, self.cache)
|
||||||
|
self.dirty = False
|
||||||
|
|
||||||
|
|
||||||
|
class Immich:
|
||||||
|
def __init__(self, url, key):
|
||||||
|
self.url = url
|
||||||
|
self.key = key
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
def call(self, method, path, body=None, raw=None, contenttype=None):
|
||||||
|
headers = {"x-api-key": self.key, "Accept": "application/json"}
|
||||||
|
if raw is not None:
|
||||||
|
data = raw
|
||||||
|
headers["Content-Type"] = contenttype
|
||||||
|
elif body is not None:
|
||||||
|
data = json.dumps(body).encode("utf-8")
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
else:
|
||||||
|
data = None
|
||||||
|
self.calls += 1
|
||||||
|
try:
|
||||||
|
answer = fetch(self.url + path, "immich %s %s" % (method, path), headers, data, method)
|
||||||
|
except HTTPFailure as e:
|
||||||
|
raise BackupError(str(e))
|
||||||
|
if not answer:
|
||||||
|
return None
|
||||||
|
# A proxy in front of immich can answer 200 with an HTML page
|
||||||
|
try:
|
||||||
|
return json.loads(answer)
|
||||||
|
except ValueError as e:
|
||||||
|
raise BackupError("immich %s %s: response was not json: %s" % (method, path, e))
|
||||||
|
|
||||||
|
def me(self):
|
||||||
|
body = self.call("GET", "/users/me") or {}
|
||||||
|
if "id" not in body:
|
||||||
|
raise BackupError("immich /users/me: no user id in the response")
|
||||||
|
return body
|
||||||
|
|
||||||
|
#
|
||||||
|
# Every asset this key's user owns, keyed by checksum, with the fields
|
||||||
|
# the sync phase compares against. One pass of a thousand at a time is far
|
||||||
|
# cheaper than asking after each photo.
|
||||||
|
#
|
||||||
|
# Archived and trashed assets are left out of a metadata search by
|
||||||
|
# default, but not out of immich's duplicate detection, so without this an
|
||||||
|
# archived asset would be uploaded again and annotated as if it were new.
|
||||||
|
def assets(self, owner, progress=None):
|
||||||
|
found = {}
|
||||||
|
page = 1
|
||||||
|
while page:
|
||||||
|
body = self.call("POST", "/search/metadata",
|
||||||
|
{"size": PAGE, "page": page, "withExif": True,
|
||||||
|
"withArchived": True})
|
||||||
|
result = body.get("assets", {})
|
||||||
|
for asset in result.get("items", []):
|
||||||
|
if asset.get("ownerId") != owner:
|
||||||
|
continue
|
||||||
|
found[asset["checksum"]] = self.state(asset)
|
||||||
|
if progress:
|
||||||
|
progress(len(found))
|
||||||
|
page = result.get("nextPage")
|
||||||
|
page = int(page) if page else None
|
||||||
|
return found
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def state(asset):
|
||||||
|
exif = asset.get("exifInfo") or {}
|
||||||
|
return {
|
||||||
|
"id": asset.get("id"),
|
||||||
|
"name": asset.get("originalFileName", ""),
|
||||||
|
"description": exif.get("description") or "",
|
||||||
|
"latitude": exif.get("latitude"),
|
||||||
|
"longitude": exif.get("longitude"),
|
||||||
|
"taken": exif.get("dateTimeOriginal") or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# One asset as it stands now, for planning against real state rather than
|
||||||
|
# against what an upload response implies.
|
||||||
|
def asset(self, assetid):
|
||||||
|
return self.state(self.call("GET", "/assets/" + assetid) or {})
|
||||||
|
|
||||||
|
def upload(self, path, digest, key, created, modified):
|
||||||
|
boundary = "----rgl-photo-backup-" + uuid.uuid4().hex
|
||||||
|
fields = [("deviceAssetId", key), ("deviceId", DEVICE_ID),
|
||||||
|
("fileCreatedAt", created), ("fileModifiedAt", modified),
|
||||||
|
("isFavorite", "false")]
|
||||||
|
parts = []
|
||||||
|
for name, value in fields:
|
||||||
|
parts.append(("--%s\r\nContent-Disposition: form-data; name=\"%s\"\r\n\r\n%s\r\n"
|
||||||
|
% (boundary, name, value)).encode("utf-8"))
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
content = fh.read()
|
||||||
|
# strip characters that could break out of the quoted filename or add a header line
|
||||||
|
safename = os.path.basename(path).replace('"', "").replace("\r", "").replace("\n", "")
|
||||||
|
parts.append(("--%s\r\nContent-Disposition: form-data; name=\"assetData\"; "
|
||||||
|
"filename=\"%s\"\r\nContent-Type: application/octet-stream\r\n\r\n"
|
||||||
|
% (boundary, safename)).encode("utf-8"))
|
||||||
|
parts.append(content)
|
||||||
|
parts.append(("\r\n--%s--\r\n" % boundary).encode("utf-8"))
|
||||||
|
body = b"".join(parts)
|
||||||
|
headers = {"x-api-key": self.key, "Accept": "application/json",
|
||||||
|
"Content-Type": "multipart/form-data; boundary=" + boundary,
|
||||||
|
"x-immich-checksum": digest}
|
||||||
|
self.calls += 1
|
||||||
|
try:
|
||||||
|
answer = fetch(self.url + "/assets", "immich upload %s" % os.path.basename(path),
|
||||||
|
headers, body, "POST")
|
||||||
|
except HTTPFailure as e:
|
||||||
|
raise BackupError(str(e))
|
||||||
|
try:
|
||||||
|
result = json.loads(answer) if answer else {}
|
||||||
|
except ValueError:
|
||||||
|
result = {}
|
||||||
|
if "id" not in result:
|
||||||
|
raise BackupError("immich upload %s: bad response" % os.path.basename(path))
|
||||||
|
return (result["id"], result.get("status", "created"))
|
||||||
|
|
||||||
|
def update(self, assetid, **fields):
|
||||||
|
self.call("PUT", "/assets/" + assetid, fields)
|
||||||
|
|
||||||
|
def createalbum(self, name, description=""):
|
||||||
|
body = self.call("POST", "/albums",
|
||||||
|
{"albumName": name, "description": description}) or {}
|
||||||
|
if "id" not in body:
|
||||||
|
raise BackupError("immich: creating album %s returned no id" % name)
|
||||||
|
return body["id"]
|
||||||
|
|
||||||
|
def albumassets(self, albumid):
|
||||||
|
album = self.call("GET", "/albums/%s" % albumid) or {}
|
||||||
|
return {a["id"] for a in album.get("assets", [])}
|
||||||
|
|
||||||
|
def addtoalbum(self, albumid, ids):
|
||||||
|
added = 0
|
||||||
|
for start in range(0, len(ids), BATCH):
|
||||||
|
results = self.call("PUT", "/albums/%s/assets" % albumid,
|
||||||
|
{"ids": ids[start:start + BATCH]}) or []
|
||||||
|
added += sum(1 for r in results if r.get("success"))
|
||||||
|
return added
|
||||||
|
|
||||||
|
# Albums shared with this user are listed too, and one of those could
|
||||||
|
# carry the RGL name, so only the ones this user owns count.
|
||||||
|
def albums(self, owner):
|
||||||
|
found = {}
|
||||||
|
for album in self.call("GET", "/albums") or []:
|
||||||
|
name, albumid = album.get("albumName"), album.get("id")
|
||||||
|
if name and albumid and album.get("ownerId") == owner:
|
||||||
|
found[name] = albumid
|
||||||
|
return found
|
||||||
|
|
||||||
|
# Upsert tag names and get their ids back; a slash makes a hierarchy.
|
||||||
|
# Skip any entry missing a value or id rather than raise on a malformed one.
|
||||||
|
def tagids(self, names):
|
||||||
|
ids = {}
|
||||||
|
for t in self.call("PUT", "/tags", {"tags": names}) or []:
|
||||||
|
value, tagid = t.get("value"), t.get("id")
|
||||||
|
if value and tagid:
|
||||||
|
ids[value] = tagid
|
||||||
|
return ids
|
||||||
|
|
||||||
|
# immich reports an asset already carrying the tag as a duplicate, which
|
||||||
|
# is what a second run should see and not a failure
|
||||||
|
def tagassets(self, tagid, ids):
|
||||||
|
tagged = already = 0
|
||||||
|
for start in range(0, len(ids), BATCH):
|
||||||
|
results = self.call("PUT", "/tags/%s/assets" % tagid,
|
||||||
|
{"ids": ids[start:start + BATCH]}) or []
|
||||||
|
tagged += sum(1 for r in results if r.get("success"))
|
||||||
|
already += sum(1 for r in results if r.get("error") == "duplicate")
|
||||||
|
return (tagged, already)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
@@ -429,6 +429,17 @@ class Retrying(TestCase):
|
|||||||
rb.OPEN = FakeOpen(FakeResponse({"stat": "ok"}))
|
rb.OPEN = FakeOpen(FakeResponse({"stat": "ok"}))
|
||||||
rb.flickrcall("flickr.test.echo", {}, "KEY")
|
rb.flickrcall("flickr.test.echo", {}, "KEY")
|
||||||
self.assertEqual(waits, [2.0])
|
self.assertEqual(waits, [2.0])
|
||||||
|
|
||||||
|
def test_pace_leaves_immich_and_the_index_alone(self):
|
||||||
|
waits = []
|
||||||
|
rb.SLEEP = waits.append
|
||||||
|
rb.PACE = 2.0
|
||||||
|
rb.OPEN = FakeOpen(FakeResponse({"id": "u1"}),
|
||||||
|
FakeResponse(json.dumps([index_entry("A", "")]).encode()))
|
||||||
|
rb.Immich("https://i/api", "k").me()
|
||||||
|
rb.loadindex("http://t/index")
|
||||||
|
self.assertEqual(waits, [])
|
||||||
|
|
||||||
def test_download_refuses_an_oversized_body(self):
|
def test_download_refuses_an_oversized_body(self):
|
||||||
rb.DOWNLOAD_CAP = 10
|
rb.DOWNLOAD_CAP = 10
|
||||||
rb.OPEN = FakeOpen(FakeResponse(b"x" * 100))
|
rb.OPEN = FakeOpen(FakeResponse(b"x" * 100))
|
||||||
@@ -932,3 +943,174 @@ class FetchPhase(TestCase):
|
|||||||
rb.OPEN = FakeOpen(http_error(404))
|
rb.OPEN = FakeOpen(http_error(404))
|
||||||
with self.assertRaises(rb.BackupError):
|
with self.assertRaises(rb.BackupError):
|
||||||
rb.fetchphase(self.conf(), "k")
|
rb.fetchphase(self.conf(), "k")
|
||||||
|
|
||||||
|
|
||||||
|
def multipart_fields(request):
|
||||||
|
"""Parse the multipart body FakeOpen captured into {name: (filename, bytes)}."""
|
||||||
|
ctype = request.get_header("Content-type")
|
||||||
|
boundary = ctype.split("boundary=")[1].encode()
|
||||||
|
fields = {}
|
||||||
|
for part in request.data.split(b"--" + boundary)[1:-1]:
|
||||||
|
head, _, body = part.partition(b"\r\n\r\n")
|
||||||
|
name = re.search(rb'name="([^"]+)"', head).group(1).decode()
|
||||||
|
fname = re.search(rb'filename="([^"]+)"', head)
|
||||||
|
fields[name] = (fname.group(1).decode() if fname else None, body.rstrip(b"\r\n"))
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
class ImmichClient(TestCase):
|
||||||
|
def client(self, *responses):
|
||||||
|
rb.OPEN = FakeOpen(*responses)
|
||||||
|
return rb.Immich("https://immich.example/api", "KEY")
|
||||||
|
|
||||||
|
def test_call_sets_headers_and_parses(self):
|
||||||
|
im = self.client(FakeResponse({"id": "u1"}))
|
||||||
|
self.assertEqual(im.me(), {"id": "u1"})
|
||||||
|
req = rb.OPEN.requests[0]
|
||||||
|
self.assertEqual(req.full_url, "https://immich.example/api/users/me")
|
||||||
|
self.assertEqual(req.get_header("X-api-key"), "KEY")
|
||||||
|
|
||||||
|
def test_http_error_becomes_backuperror(self):
|
||||||
|
im = self.client(http_error(401, b"unauthorized"))
|
||||||
|
with self.assertRaises(rb.BackupError) as cm:
|
||||||
|
im.me()
|
||||||
|
self.assertIn("401", str(cm.exception))
|
||||||
|
|
||||||
|
def test_a_body_that_is_not_json_becomes_backuperror(self):
|
||||||
|
im = self.client(FakeResponse(b"<html>proxy says no</html>"))
|
||||||
|
with self.assertRaises(rb.BackupError) as cm:
|
||||||
|
im.me()
|
||||||
|
self.assertIn("/users/me", str(cm.exception))
|
||||||
|
|
||||||
|
def test_me_without_an_id_raises(self):
|
||||||
|
im = self.client(FakeResponse({"email": "someone@example.invalid"}))
|
||||||
|
with self.assertRaises(rb.BackupError):
|
||||||
|
im.me()
|
||||||
|
|
||||||
|
def test_createalbum_without_an_id_raises(self):
|
||||||
|
im = self.client(FakeResponse({"albumName": "RGL"}))
|
||||||
|
with self.assertRaises(rb.BackupError):
|
||||||
|
im.createalbum("RGL")
|
||||||
|
|
||||||
|
def test_assets_pages_and_filters_owner(self):
|
||||||
|
page1 = {"assets": {"items": [
|
||||||
|
{"id": "a1", "ownerId": "u1", "checksum": "c1", "originalFileName": "1.jpg",
|
||||||
|
"exifInfo": {"description": "d", "latitude": 51.5, "longitude": -0.1,
|
||||||
|
"dateTimeOriginal": "2009-07-22T14:03:11.000Z"}},
|
||||||
|
{"id": "a2", "ownerId": "other", "checksum": "c2"}], "nextPage": "2"}}
|
||||||
|
page2 = {"assets": {"items": [
|
||||||
|
{"id": "a3", "ownerId": "u1", "checksum": "c3", "exifInfo": None}], "nextPage": None}}
|
||||||
|
im = self.client(FakeResponse(page1), FakeResponse(page2))
|
||||||
|
assets = im.assets("u1")
|
||||||
|
self.assertEqual(sorted(assets), ["c1", "c3"])
|
||||||
|
self.assertEqual(assets["c1"]["latitude"], 51.5)
|
||||||
|
self.assertEqual(assets["c1"]["taken"], "2009-07-22T14:03:11.000Z")
|
||||||
|
self.assertEqual(assets["c3"]["description"], "")
|
||||||
|
self.assertIsNone(assets["c3"]["latitude"])
|
||||||
|
body = json.loads(rb.OPEN.requests[1].data)
|
||||||
|
self.assertEqual(body["page"], 2)
|
||||||
|
self.assertTrue(body["withExif"])
|
||||||
|
self.assertTrue(body["withArchived"])
|
||||||
|
|
||||||
|
def test_asset_reads_one_in_the_same_shape(self):
|
||||||
|
im = self.client(FakeResponse({
|
||||||
|
"id": "a1", "originalFileName": "111.jpg",
|
||||||
|
"exifInfo": {"description": "Mine", "latitude": 51.5, "longitude": -0.1,
|
||||||
|
"dateTimeOriginal": "2009-07-22T14:03:11.000Z"}}))
|
||||||
|
self.assertEqual(im.asset("a1"), {
|
||||||
|
"id": "a1", "name": "111.jpg", "description": "Mine",
|
||||||
|
"latitude": 51.5, "longitude": -0.1,
|
||||||
|
"taken": "2009-07-22T14:03:11.000Z"})
|
||||||
|
req = rb.OPEN.requests[0]
|
||||||
|
self.assertEqual(req.get_method(), "GET")
|
||||||
|
self.assertTrue(req.full_url.endswith("/assets/a1"))
|
||||||
|
|
||||||
|
def test_asset_without_exif_is_blank_not_missing(self):
|
||||||
|
im = self.client(FakeResponse({"id": "a1", "exifInfo": None}))
|
||||||
|
asset = im.asset("a1")
|
||||||
|
self.assertEqual((asset["description"], asset["taken"]), ("", ""))
|
||||||
|
self.assertIsNone(asset["latitude"])
|
||||||
|
|
||||||
|
def test_upload_is_multipart_with_checksum_header(self):
|
||||||
|
path = self.write("111.jpg", b"JPEGDATA")
|
||||||
|
im = self.client(FakeResponse({"id": "a9", "status": "created"}, status=201))
|
||||||
|
result = im.upload(path, "Q0hFQ0s=", "111", "2009-07-22T14:03:11Z", "2026-09-09T00:00:00Z")
|
||||||
|
self.assertEqual(result, ("a9", "created"))
|
||||||
|
req = rb.OPEN.requests[0]
|
||||||
|
self.assertEqual(req.get_method(), "POST")
|
||||||
|
self.assertTrue(req.full_url.endswith("/assets"))
|
||||||
|
self.assertEqual(req.get_header("X-immich-checksum"), "Q0hFQ0s=")
|
||||||
|
fields = multipart_fields(req)
|
||||||
|
self.assertEqual(fields["assetData"], ("111.jpg", b"JPEGDATA"))
|
||||||
|
self.assertEqual(fields["deviceAssetId"][1], b"111")
|
||||||
|
self.assertEqual(fields["deviceId"][1], rb.DEVICE_ID.encode())
|
||||||
|
self.assertEqual(fields["fileCreatedAt"][1], b"2009-07-22T14:03:11Z")
|
||||||
|
self.assertEqual(fields["fileModifiedAt"][1], b"2026-09-09T00:00:00Z")
|
||||||
|
|
||||||
|
def test_upload_duplicate(self):
|
||||||
|
path = self.write("111.jpg", b"J")
|
||||||
|
im = self.client(FakeResponse({"id": "a1", "status": "duplicate"}))
|
||||||
|
self.assertEqual(im.upload(path, "x", "111", "t", "t"), ("a1", "duplicate"))
|
||||||
|
|
||||||
|
def test_upload_empty_response_raises(self):
|
||||||
|
path = self.write("111.jpg", b"J")
|
||||||
|
im = self.client(FakeResponse(b""))
|
||||||
|
with self.assertRaises(rb.BackupError):
|
||||||
|
im.upload(path, "x", "111", "t", "t")
|
||||||
|
|
||||||
|
def test_update_sends_only_given_fields(self):
|
||||||
|
im = self.client(FakeResponse(b""))
|
||||||
|
im.update("a1", description="d", latitude=51.5, longitude=-0.1)
|
||||||
|
req = rb.OPEN.requests[0]
|
||||||
|
self.assertEqual(req.get_method(), "PUT")
|
||||||
|
self.assertTrue(req.full_url.endswith("/assets/a1"))
|
||||||
|
self.assertEqual(json.loads(req.data), {"description": "d", "latitude": 51.5, "longitude": -0.1})
|
||||||
|
|
||||||
|
def test_albums(self):
|
||||||
|
im = self.client(FakeResponse([{"albumName": "RGL", "id": "al1", "ownerId": "u1"},
|
||||||
|
{"albumName": "RGL", "id": "shared", "ownerId": "u2"},
|
||||||
|
{"albumName": "Nameless"}]),
|
||||||
|
FakeResponse({"id": "al2"}),
|
||||||
|
FakeResponse({"assets": [{"id": "a1"}, {"id": "a2"}]}),
|
||||||
|
FakeResponse([{"id": "a3", "success": True}, {"id": "a1", "success": False, "error": "duplicate"}]))
|
||||||
|
self.assertEqual(im.albums("u1"), {"RGL": "al1"})
|
||||||
|
self.assertEqual(im.createalbum("X"), "al2")
|
||||||
|
self.assertEqual(im.albumassets("al1"), {"a1", "a2"})
|
||||||
|
self.assertEqual(im.addtoalbum("al1", ["a3", "a1"]), 1)
|
||||||
|
|
||||||
|
def test_tags(self):
|
||||||
|
im = self.client(FakeResponse([{"value": "rgl", "id": "t1"}, {"value": "rgl/licence/x", "id": "t2"}]),
|
||||||
|
FakeResponse([{"success": True}, {"success": False, "error": "duplicate"}]))
|
||||||
|
self.assertEqual(im.tagids(["rgl", "rgl/licence/x"]), {"rgl": "t1", "rgl/licence/x": "t2"})
|
||||||
|
self.assertEqual(im.tagassets("t1", ["a1", "a2"]), (1, 1))
|
||||||
|
|
||||||
|
def test_tagids_skips_malformed_entries(self):
|
||||||
|
im = self.client(FakeResponse([{"value": "rgl", "id": "t1"}, {"value": "rgl/broken"}]))
|
||||||
|
self.assertEqual(im.tagids(["rgl", "rgl/broken"]), {"rgl": "t1"})
|
||||||
|
|
||||||
|
def test_batches_of_500(self):
|
||||||
|
ids = ["a%d" % i for i in range(1200)]
|
||||||
|
im = self.client(FakeResponse([{"success": True}] * 500),
|
||||||
|
FakeResponse([{"success": True}] * 500),
|
||||||
|
FakeResponse([{"success": True}] * 200))
|
||||||
|
self.assertEqual(im.addtoalbum("al", ids), 1200)
|
||||||
|
self.assertEqual(len(rb.OPEN.requests), 3)
|
||||||
|
|
||||||
|
|
||||||
|
class Hashing(TestCase):
|
||||||
|
def test_checksum_is_base64_sha1(self):
|
||||||
|
path = self.write("f", b"hello")
|
||||||
|
self.assertEqual(rb.checksum(path), "qvTGHdzF6KLavt4PO0gs2a6pQ00=")
|
||||||
|
|
||||||
|
def test_cache_reuses_until_file_changes(self):
|
||||||
|
path = self.write("f", b"hello")
|
||||||
|
cache = rb.Hashes(os.path.join(self.dir, rb.HASHES))
|
||||||
|
first = cache.of(path, "f", os.stat(path))
|
||||||
|
cache.save()
|
||||||
|
again = rb.Hashes(os.path.join(self.dir, rb.HASHES))
|
||||||
|
self.assertEqual(again.of(path, "f", os.stat(path)), first)
|
||||||
|
self.assertFalse(again.dirty)
|
||||||
|
self.write("f", b"changed")
|
||||||
|
os.utime(path, (1, 1))
|
||||||
|
self.assertNotEqual(again.of(path, "f", os.stat(path)), first)
|
||||||
|
self.assertTrue(again.dirty)
|
||||||
Reference in new issue
Block a user