Files
rgl-photo-backup/test_rgl_photo_backup.py
T
bobandClaude Fable 5.1 fbd77fc91b Remember why photos failed and report the pages without one
Each run now writes .failures.json in the backup directory: the last
reason every photo failed and the wiki pages that use it, dropping any
photo that has since arrived. A new --report mode reads the wiki index
and that file and lists every page whose photo is not on disk, with the
reason or "not yet attempted", calling neither flickr nor immich. That is
what tells a wiki editor which photos have gone from flickr and need
replacing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:45:09 +01:00

1606 lines
77 KiB
Python

import contextlib
import importlib.machinery
import importlib.util
import io
import json
import os
import re
import sys
import tempfile
import unittest
import urllib.error
import urllib.parse
_here = os.path.dirname(os.path.abspath(__file__))
_path = os.path.join(_here, "rgl-photo-backup")
_spec = importlib.util.spec_from_file_location(
"rgl_photo_backup", _path,
loader=importlib.machinery.SourceFileLoader("rgl_photo_backup", _path))
rb = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(rb)
class FakeResponse:
"""What urlopen would return: a body, a status and headers."""
def __init__(self, body=b"", status=200, headers=None):
self.body = body if isinstance(body, bytes) else json.dumps(body).encode()
self.status = status
self.headers = headers or {}
self._pos = 0
def read(self, size=-1):
if size is None or size < 0:
data, self._pos = self.body[self._pos:], len(self.body)
else:
data = self.body[self._pos:self._pos + size]
self._pos += len(data)
return data
def close(self):
pass
def __enter__(self):
return self
def __exit__(self, *exc):
pass
def http_error(code, body=b"", headers=None):
return urllib.error.HTTPError("http://test", code, "reason %d" % code,
headers or {}, io.BytesIO(body))
class FakeOpen:
"""Replaces rb.OPEN. Serves scripted responses in order and records
every request so a test can assert on method, url, headers and body."""
def __init__(self, *responses):
self.responses = list(responses)
self.requests = []
def __call__(self, request, timeout=None):
self.requests.append(request)
if not self.responses:
raise AssertionError("unexpected request: %s %s"
% (request.get_method(), request.full_url))
answer = self.responses.pop(0)
if isinstance(answer, Exception):
raise answer
return answer
class TestCase(unittest.TestCase):
"""Silences output and restores the module globals tests move around."""
GLOBALS = ("OPEN", "SLEEP", "ATTEMPTS", "BACKOFF_BASE", "PACE", "PACE_FLOOR",
"PACEHELD", "QUIET", "DEBUG", "HTTP_TIMEOUT", "USER_AGENT", "STATS",
"DOWNLOAD_CAP")
def setUp(self):
self._output = contextlib.ExitStack()
if not os.environ.get("RGL_PHOTO_BACKUP_TEST_VERBOSE"):
self.stdout = self._output.enter_context(
contextlib.redirect_stdout(io.StringIO()))
self.stderr = self._output.enter_context(
contextlib.redirect_stderr(io.StringIO()))
self.addCleanup(self._output.close)
self._saved = {k: getattr(rb, k) for k in self.GLOBALS}
rb.SLEEP = lambda seconds: None
rb.PACE = rb.PACE_FLOOR = 0.0
rb.PACEHELD = 0
rb.STATS = dict.fromkeys(rb.STATS, 0)
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
self.dir = self.tmp.name
def tearDown(self):
for k, v in self._saved.items():
setattr(rb, k, v)
def write(self, name, content):
path = os.path.join(self.dir, name)
mode = "wb" if isinstance(content, bytes) else "w"
with open(path, mode) as fh:
fh.write(content)
return path
def index_entry(param, image, page="", copyright="", licence="", lat="51.5", long="-0.1", name=None):
md = {}
if image:
md["node_image"] = [image]
if page:
md["node_image_url"] = [page]
if copyright:
md["node_image_copyright"] = [copyright]
if licence:
md["node_image_licence"] = [licence]
return {"param": param, "name": name or param.replace("_", " "),
"wgs84_lat": lat, "wgs84_long": long, "has_geodata": 1,
"node_data": {"metadata": md}}
class Index(TestCase):
def test_readindex_tolerates_invalid_utf8(self):
entry = index_entry("Café,_N1", "https://farm1.static.flickr.com/1/123_abc_m.jpg")
raw = json.dumps([entry], ensure_ascii=False).encode("utf-8").replace("\xe9".encode(), b"\xc2")
nodes = rb.readindex(raw)
self.assertEqual(len(nodes), 1)
self.assertIn("�", nodes[0]["param"])
def test_readindex_flattens_fields(self):
raw = json.dumps([index_entry("Pub,_E1", "https://farm1.static.flickr.com/1/123_abc_m.jpg",
page="https://www.flickr.com/photos/x/123/",
copyright="Someone", licence="https://creativecommons.org/licenses/by-sa/2.0/"),
index_entry("Nopic,_E2", "")]).encode()
nodes = rb.readindex(raw)
self.assertEqual(len(nodes), 2)
n = nodes[0]
self.assertEqual(n["param"], "Pub,_E1")
self.assertEqual(n["name"], "Pub, E1")
self.assertEqual(n["image"], "https://farm1.static.flickr.com/1/123_abc_m.jpg")
self.assertEqual(n["page"], "https://www.flickr.com/photos/x/123/")
self.assertEqual(n["copyright"], "Someone")
self.assertEqual(n["licence"], "https://creativecommons.org/licenses/by-sa/2.0/")
self.assertEqual((n["lat"], n["long"]), ("51.5", "-0.1"))
self.assertEqual(n["url"], "https://london.randomness.org.uk/wiki.cgi?Pub%2C_E1")
self.assertEqual(nodes[1]["image"], "")
def test_zero_coordinates_are_kept(self):
raw = json.dumps([index_entry("Null,_Island", "https://x/1_a_m.jpg", lat=0, long=0)]).encode()
node = rb.readindex(raw)[0]
self.assertEqual((node["lat"], node["long"]), ("0", "0"))
def test_missing_coordinates_are_empty(self):
raw = json.dumps([index_entry("Nowhere", "https://x/1_a_m.jpg", lat=None, long=None)]).encode()
node = rb.readindex(raw)[0]
self.assertEqual((node["lat"], node["long"]), ("", ""))
def test_loadindex_fetches(self):
rb.OPEN = FakeOpen(FakeResponse(json.dumps([index_entry("A", "")]).encode()))
self.assertEqual(len(rb.loadindex("http://t/index")), 1)
class PhotoKeys(TestCase):
def key(self, image, page=""):
return rb.photokey({"image": image, "page": page, "param": "X"})
def test_id_from_thumbnail_filename(self):
self.assertEqual(self.key("https://farm4.static.flickr.com/3188/3002966579_97596aea2e_m.jpg"),
("flickr", "3002966579"))
self.assertEqual(self.key("https://live.staticflickr.com/65535/52123_abc_m.jpg"),
("flickr", "52123"))
def test_id_from_page_when_thumbnail_is_odd(self):
self.assertEqual(self.key("https://www.flickr.com/some/other.jpg",
"https://www.flickr.com/photos/kake/3002966579/in/set-1/"),
("flickr", "3002966579"))
self.assertEqual(self.key("https://c1.staticflickr.com/x.jpg",
"https://secure.flickr.com/photos/12345678@N00/98765"),
("flickr", "98765"))
def test_short_link_is_base58(self):
self.assertEqual(rb.base58decode("5zmZkR"), 3002966579)
self.assertEqual(self.key("https://c1.staticflickr.com/x.jpg", "https://flic.kr/p/5zmZkR"),
("flickr", "3002966579"))
def test_non_flickr_hosts_key_on_url_hash(self):
source, key = self.key("https://www.geograph.org.uk/photos/12/34/1234.jpg",
"https://www.geograph.org.uk/photo/1234")
self.assertEqual(source, "url")
self.assertRegex(key, r"^url-[0-9a-f]{16}$")
def test_flickr_cdn_without_id_falls_back_to_url_key(self):
source, key = self.key("https://c1.staticflickr.com/nodigits.jpg")
self.assertEqual(source, "url")
def test_not_a_url_is_none(self):
self.assertIsNone(self.key("not a url"))
self.assertIsNone(self.key(""))
class Grouping(TestCase):
def test_nodes_sharing_a_photo_group_together(self):
nodes = rb.readindex(json.dumps([
index_entry("A,_E1", "https://farm1.static.flickr.com/1/111_a_m.jpg",
page="https://www.flickr.com/photos/x/111/", copyright="P", licence="L"),
index_entry("B,_E2", "https://farm1.static.flickr.com/1/111_a_m.jpg",
page="https://www.flickr.com/photos/x/111/"),
index_entry("C,_E3", "https://farm1.static.flickr.com/1/222_b_m.jpg"),
index_entry("D,_E4", ""),
index_entry("E,_E5", "junk"),
]).encode())
groups, rejected = rb.groupphotos(nodes)
self.assertEqual(sorted(groups), ["111", "222"])
g = groups["111"]
self.assertEqual(g["source"], "flickr")
self.assertEqual(g["id"], "111")
self.assertEqual([n["param"] for n in g["nodes"]], ["A,_E1", "B,_E2"])
self.assertEqual(g["copyright"], "P")
self.assertEqual(g["licence"], "L")
self.assertEqual(g["page"], "https://www.flickr.com/photos/x/111/")
self.assertEqual(len(rejected), 1)
self.assertEqual(rejected[0][0]["param"], "E,_E5")
def test_first_non_empty_copyright_wins(self):
nodes = rb.readindex(json.dumps([
index_entry("A", "https://farm1.static.flickr.com/1/111_a_m.jpg"),
index_entry("B", "https://farm1.static.flickr.com/1/111_a_m.jpg", copyright="Later"),
]).encode())
groups, _ = rb.groupphotos(nodes)
self.assertEqual(groups["111"]["copyright"], "Later")
class Output(TestCase):
def test_say_prints_unless_quiet(self):
rb.QUIET = False
rb.say("hello")
self.assertEqual(self.stdout.getvalue(), "hello\n")
rb.QUIET = True
rb.say("again")
self.assertEqual(self.stdout.getvalue(), "hello\n")
def test_warn_always_reaches_stderr(self):
rb.QUIET = True
rb.warn("careful")
self.assertEqual(self.stderr.getvalue(), "careful\n")
def test_debug_only_when_enabled(self):
rb.DEBUG = False
rb.debug("x")
self.assertEqual(self.stderr.getvalue(), "")
rb.DEBUG = True
rb.debug("y")
self.assertEqual(self.stderr.getvalue(), "debug: y\n")
def test_errors_share_a_base(self):
self.assertTrue(issubclass(rb.PhotoGone, rb.BackupError))
e = rb.HTTPFailure("ctx: HTTP 404", code=404)
self.assertEqual(e.code, 404)
self.assertIsInstance(e, rb.BackupError)
class Config(TestCase):
def test_readyaml_reads_flat_pairs_and_ignores_comments(self):
path = self.write("a.yml", "# note\nkey: 'abc'\nurl: https://immich.example/api \n")
self.assertEqual(rb.readyaml(path), {"key": "abc", "url": "https://immich.example/api"})
def test_readyaml_missing_file_is_empty(self):
self.assertEqual(rb.readyaml(os.path.join(self.dir, "none.yml")), {})
def test_readyaml_unreadable_path_names_it(self):
with self.assertRaises(rb.BackupError) as cm:
rb.readyaml(self.dir) # a directory, not a file
self.assertIn(self.dir, str(cm.exception))
def test_loadflickr_needs_key(self):
path = self.write("f.yml", "secret: s\n")
with self.assertRaises(rb.BackupError):
rb.loadflickr(path)
path = self.write("f2.yml", "key: k\nsecret: s\n")
self.assertEqual(rb.loadflickr(path), "k")
def test_loadimmich_needs_url_and_key_and_strips_slash(self):
path = self.write("i.yml", "url: https://immich.example/api/\nkey: abc\n")
self.assertEqual(rb.loadimmich(path), ("https://immich.example/api", "abc"))
with self.assertRaises(rb.BackupError):
rb.loadimmich(self.write("i2.yml", "url: x\n"))
class Settings(TestCase):
def test_flag_beats_env_beats_file_beats_default(self):
path = self.write("s.yml", "directory: /from/file\npace: 3\nquiet: true\n")
args = rb.parseargs(["--settings", path])
conf = rb.settings(args, environ={})
self.assertEqual(conf["directory"], "/from/file")
self.assertEqual(conf["pace"], 3.0)
self.assertTrue(conf["quiet"])
conf = rb.settings(args, environ={rb.ENV + "PACE": "7"})
self.assertEqual(conf["pace"], 7.0)
args = rb.parseargs(["--settings", path, "--pace", "9", "/cli"])
conf = rb.settings(args, environ={rb.ENV + "PACE": "7"})
self.assertEqual(conf["pace"], 9.0)
self.assertEqual(conf["directory"], "/cli")
def test_defaults(self):
args = rb.parseargs(["--settings", os.path.join(self.dir, "none"), "/d"])
conf = rb.settings(args, environ={})
self.assertEqual(conf["index_url"], rb.INDEX_URL)
self.assertTrue(conf["fetch"] and conf["sync"])
self.assertFalse(conf["dry_run"])
self.assertIsNone(conf["limit"])
self.assertEqual(conf["attempts"], 8)
self.assertEqual(conf["backoff"], 15.0)
self.assertEqual(rb.BACKOFF_CAP, 900.0)
self.assertEqual(conf["flickr_auth"], os.path.expanduser(rb.FLICKR_CONFIG))
def test_defaults_are_literals_not_moving_constants(self):
rb.PACE, rb.ATTEMPTS, rb.USER_AGENT = 99.0, 99, "mangled"
args = rb.parseargs(["--settings", os.path.join(self.dir, "none"), "/d"])
conf = rb.settings(args, environ={})
self.assertEqual(conf["pace"], rb.DEFAULTS["pace"])
self.assertEqual(conf["attempts"], rb.DEFAULTS["attempts"])
self.assertEqual(conf["user_agent"], rb.DEFAULTS["user_agent"])
self.assertEqual((rb.DEFAULTS["pace"], rb.DEFAULTS["attempts"]), (1.0, 8))
def test_debug_is_a_setting(self):
path = self.write("s.yml", "directory: /d\ndebug: true\n")
self.assertEqual(self.stderr.getvalue(), "") # no "unknown setting"
args = rb.parseargs(["--settings", path])
self.assertTrue(rb.settings(args, environ={})["debug"])
args = rb.parseargs(["--settings", os.path.join(self.dir, "none"), "/d"])
self.assertFalse(rb.settings(args, environ={})["debug"])
self.assertTrue(rb.settings(args, environ={rb.ENV + "DEBUG": "yes"})["debug"])
args = rb.parseargs(["--settings", os.path.join(self.dir, "none"), "--debug", "/d"])
self.assertTrue(rb.settings(args, environ={})["debug"])
def test_a_bad_environment_value_is_a_configuration_error(self):
args = rb.parseargs(["--settings", os.path.join(self.dir, "none"), "/d"])
with self.assertRaises(rb.BackupError) as cm:
rb.settings(args, environ={rb.ENV + "ATTEMPTS": "lots"})
self.assertIn("ATTEMPTS", str(cm.exception))
def test_report_flag(self):
base = ["--settings", os.path.join(self.dir, "none"), "/d"]
self.assertFalse(rb.settings(rb.parseargs(base), environ={})["report"])
self.assertTrue(rb.settings(rb.parseargs(base + ["--report"]), environ={})["report"])
def test_phase_flags(self):
base = ["--settings", os.path.join(self.dir, "none"), "/d"]
self.assertFalse(rb.settings(rb.parseargs(base + ["--sync-only"]), environ={})["fetch"])
self.assertFalse(rb.settings(rb.parseargs(base + ["--fetch-only"]), environ={})["sync"])
with self.assertRaises(rb.BackupError):
rb.settings(rb.parseargs(base + ["--fetch-only", "--sync-only"]), environ={})
def test_missing_directory_is_an_error(self):
args = rb.parseargs(["--settings", os.path.join(self.dir, "none")])
with self.assertRaises(rb.BackupError):
rb.settings(args, environ={})
def test_unknown_key_warns_and_wrong_type_fails(self):
path = self.write("s.yml", "directory: /d\nbogus: 1\n")
rb.loadsettings(path)
self.assertIn("bogus", self.stderr.getvalue())
path = self.write("s2.yml", "directory: /d\nlimit: many\n")
with self.assertRaises(rb.BackupError) as cm:
rb.loadsettings(path)
self.assertIn("limit", str(cm.exception))
def test_tilde_expanded_in_paths(self):
path = self.write("s.yml", "directory: ~/photos\n")
self.assertEqual(rb.loadsettings(path)["directory"], os.path.expanduser("~/photos"))
class Retrying(TestCase):
def test_success_returns_body(self):
rb.OPEN = FakeOpen(FakeResponse(b"ok"))
self.assertEqual(rb.fetch("http://t/x", "ctx"), b"ok")
self.assertEqual(rb.OPEN.requests[0].get_header("User-agent"), rb.USER_AGENT)
def test_404_is_not_retried(self):
rb.OPEN = FakeOpen(http_error(404, b"gone"))
with self.assertRaises(rb.HTTPFailure) as cm:
rb.fetch("http://t/x", "ctx")
self.assertEqual(cm.exception.code, 404)
self.assertIn("gone", str(cm.exception))
self.assertEqual(len(rb.OPEN.requests), 1)
def test_5xx_and_429_retry_then_succeed(self):
rb.OPEN = FakeOpen(http_error(503), http_error(429), FakeResponse(b"ok"))
self.assertEqual(rb.fetch("http://t/x", "ctx"), b"ok")
self.assertEqual(len(rb.OPEN.requests), 3)
self.assertEqual(rb.STATS["retries"], 2)
def test_connection_errors_retry(self):
rb.OPEN = FakeOpen(OSError("reset"), FakeResponse(b"ok"))
self.assertEqual(rb.fetch("http://t/x", "ctx"), b"ok")
def test_gives_up_after_attempts(self):
rb.ATTEMPTS = 3
rb.OPEN = FakeOpen(http_error(500), http_error(500), http_error(500))
with self.assertRaises(rb.HTTPFailure) as cm:
rb.fetch("http://t/x", "ctx")
self.assertIn("3 attempts", str(cm.exception))
def test_retry_after_header_is_honoured(self):
waits = []
rb.SLEEP = waits.append
rb.OPEN = FakeOpen(http_error(429, headers={"Retry-After": "12"}), FakeResponse(b"ok"))
rb.fetch("http://t/x", "ctx")
self.assertIn(12.0, waits)
def test_backoff_grows_and_caps(self):
rb.BACKOFF_BASE = 5
self.assertLessEqual(rb.backoff(1), 7.5)
self.assertGreaterEqual(rb.backoff(1), 2.5)
self.assertLessEqual(rb.backoff(20), rb.BACKOFF_CAP * 1.5)
def test_post_sends_body_and_headers(self):
rb.OPEN = FakeOpen(FakeResponse(b"{}"))
rb.fetch("http://t/x", "ctx", headers={"x-api-key": "k"}, data=b"body", method="POST")
req = rb.OPEN.requests[0]
self.assertEqual(req.get_method(), "POST")
self.assertEqual(req.data, b"body")
self.assertEqual(req.get_header("X-api-key"), "k")
def test_download_streams_to_path(self):
rb.OPEN = FakeOpen(FakeResponse(b"x" * 1000, headers={"Content-Type": "image/jpeg"}))
path = os.path.join(self.dir, "p.part")
self.assertEqual(rb.download("http://t/p.jpg", "ctx", path), ("image/jpeg", 1000))
self.assertEqual(os.path.getsize(path), 1000)
def test_pace_sleeps_before_a_flickr_call(self):
waits = []
rb.SLEEP = waits.append
rb.PACE = 2.0
rb.OPEN = FakeOpen(FakeResponse({"stat": "ok"}))
rb.flickrcall("flickr.test.echo", {}, "KEY")
self.assertEqual(waits, [2.0])
def test_429_slows_every_later_flickr_call(self):
waits = []
rb.SLEEP = waits.append
rb.PACE = rb.PACE_FLOOR = 1.0
rb.OPEN = FakeOpen(http_error(429), FakeResponse({"stat": "ok"}),
FakeResponse({"stat": "ok"}))
rb.flickrcall("flickr.test.echo", {}, "KEY")
self.assertEqual(rb.PACE, 2.0)
rb.flickrcall("flickr.test.echo", {}, "KEY")
self.assertEqual(waits[-1], 2.0)
self.assertEqual(rb.PACEHELD, rb.PACE_HOLD - 2)
self.assertIn("pacing", self.stderr.getvalue())
def test_pace_relaxes_back_to_the_configured_floor(self):
rb.PACE_FLOOR = 1.0
rb.PACE = 4.0
rb.OPEN = FakeOpen(*[FakeResponse({"stat": "ok"}) for _ in range(40)])
for _ in range(40):
rb.flickrcall("flickr.test.echo", {}, "KEY")
self.assertEqual(rb.PACE, 1.0)
def test_pace_never_exceeds_the_cap(self):
rb.PACE = rb.PACE_FLOOR = 8.0
rb.OPEN = FakeOpen(http_error(429), FakeResponse({"stat": "ok"}))
rb.flickrcall("flickr.test.echo", {}, "KEY")
self.assertEqual(rb.PACE, rb.PACE_CAP)
def test_429_from_immich_leaves_the_pace_alone(self):
rb.PACE = rb.PACE_FLOOR = 1.0
rb.OPEN = FakeOpen(http_error(429), FakeResponse({"id": "u1"}))
rb.Immich("https://i/api", "k").me()
self.assertEqual(rb.PACE, 1.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):
rb.DOWNLOAD_CAP = 10
rb.OPEN = FakeOpen(FakeResponse(b"x" * 100))
path = os.path.join(self.dir, "p.part")
with self.assertRaises(rb.BackupError) as cm:
rb.download("http://t/p.jpg", "ctx", path)
self.assertIn("10", str(cm.exception))
self.assertEqual(len(rb.OPEN.requests), 1) # not a retryable failure
def test_debug_redacts_the_flickr_key(self):
rb.DEBUG = True
rb.OPEN = FakeOpen(FakeResponse({"stat": "ok"}))
rb.flickrcall("flickr.test.echo", {}, "SECRETKEY")
logged = self.stderr.getvalue()
self.assertIn("api_key=<redacted>", logged)
self.assertNotIn("SECRETKEY", logged)
def flickr_info(photoid="111", licence=5, realname="Real Name"):
return {"stat": "ok", "photo": {
"id": photoid, "license": str(licence), "originalformat": "jpg",
"owner": {"nsid": "12345678@N00", "username": "handle", "realname": realname},
"title": {"_content": "A title"}, "description": {"_content": "Words"},
"dates": {"taken": "2009-07-22 14:03:11", "posted": "1248345600"},
"urls": {"url": [{"type": "photopage",
"_content": "https://www.flickr.com/photos/12345678@N00/%s/" % photoid}]},
}}
def flickr_sizes(*sizes):
return {"stat": "ok", "sizes": {"size": [
{"label": l, "width": w, "height": h, "source": s, "media": "photo"}
for (l, w, h, s) in sizes]}}
class Flickr(TestCase):
def test_call_builds_unsigned_json_request(self):
rb.OPEN = FakeOpen(FakeResponse({"stat": "ok", "x": 1}))
self.assertEqual(rb.flickrcall("flickr.test.echo", {"a": "b"}, "KEY")["x"], 1)
url = rb.OPEN.requests[0].full_url
q = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
self.assertEqual(q["method"], ["flickr.test.echo"])
self.assertEqual(q["api_key"], ["KEY"])
self.assertEqual(q["format"], ["json"])
self.assertEqual(q["nojsoncallback"], ["1"])
self.assertEqual(q["a"], ["b"])
def test_api_failure_raises(self):
rb.OPEN = FakeOpen(FakeResponse({"stat": "fail", "code": 100, "message": "Invalid API Key"}))
with self.assertRaises(rb.BackupError) as cm:
rb.flickrcall("flickr.photos.getInfo", {"photo_id": "1"}, "k")
self.assertIn("Invalid API Key", str(cm.exception))
def test_transient_service_error_is_retried(self):
busy = {"stat": "fail", "code": 105, "message": "Service currently unavailable"}
rb.OPEN = FakeOpen(FakeResponse(busy), FakeResponse(busy),
FakeResponse({"stat": "ok", "x": 1}))
self.assertEqual(rb.flickrcall("flickr.test.echo", {}, "k")["x"], 1)
self.assertEqual(len(rb.OPEN.requests), 3)
self.assertEqual(rb.STATS["retries"], 2)
def test_transient_service_error_gives_up_after_attempts(self):
rb.ATTEMPTS = 2
busy = {"stat": "fail", "code": "106", "message": "Write operation failed"}
rb.OPEN = FakeOpen(FakeResponse(busy), FakeResponse(busy))
with self.assertRaises(rb.BackupError) as cm:
rb.flickrcall("flickr.test.echo", {}, "k")
self.assertIn("Write operation failed", str(cm.exception))
self.assertEqual(len(rb.OPEN.requests), 2)
def test_missing_or_private_photo_is_gone(self):
for code in (1, 2):
rb.OPEN = FakeOpen(FakeResponse({"stat": "fail", "code": code, "message": "nope"}))
with self.assertRaises(rb.PhotoGone):
rb.getinfo("1", "k")
def test_getinfo_flattens(self):
rb.OPEN = FakeOpen(FakeResponse(flickr_info()))
info = rb.getinfo("111", "k")
self.assertEqual(info["id"], "111")
self.assertEqual(info["owner"], "12345678@N00")
self.assertEqual(info["owner_name"], "handle")
self.assertEqual(info["owner_realname"], "Real Name")
self.assertEqual(info["title"], "A title")
self.assertEqual(info["taken"], "2009-07-22 14:03:11")
self.assertEqual(info["posted"], "2009-07-23T10:40:00Z")
self.assertEqual(info["licence_id"], 5)
self.assertEqual(info["page"], "https://www.flickr.com/photos/12345678@N00/111/")
self.assertEqual(info["original_format"], "jpg")
def test_getsizes_normalises_numbers(self):
rb.OPEN = FakeOpen(FakeResponse(flickr_sizes(("Small", "240", "180", "https://x/s.jpg"))))
sizes = rb.getsizes("111", "k")
self.assertEqual(sizes[0]["width"], 240)
self.assertEqual(sizes[0]["label"], "Small")
def test_picksizes_prefers_original_then_area(self):
sizes = [
{"label": "Small", "width": 240, "height": 180, "source": "s", "media": "photo"},
{"label": "Large", "width": 1024, "height": 768, "source": "l", "media": "photo"},
{"label": "Original", "width": 800, "height": 600, "source": "o", "media": "photo"},
{"label": "Video Player", "width": 640, "height": 480, "source": "v", "media": "video"},
]
self.assertEqual([s["label"] for s in rb.picksizes(sizes)], ["Original", "Large", "Small"])
def test_picksizes_empty(self):
self.assertEqual(rb.picksizes([]), [])
def test_extensionfor(self):
self.assertEqual(rb.extensionfor("https://x/1_o.png", ""), ".png")
self.assertEqual(rb.extensionfor("https://x/1_o.jpeg", ""), ".jpg")
self.assertEqual(rb.extensionfor("https://x/1_o", "image/gif"), ".gif")
self.assertEqual(rb.extensionfor("https://x/1_o.weird", "text/html"), ".jpg")
def sidecar(key="111", file="111.jpg", nodes=None, copyright="P", licence="https://creativecommons.org/licenses/by-sa/2.0/", flickr=True):
data = {"key": key, "file": file, "source": "flickr" if flickr else "url",
"rgl": {"copyright": copyright, "licence": licence,
"nodes": nodes or [{"param": "A,_E1", "name": "A, E1",
"url": "https://london.randomness.org.uk/wiki.cgi?A%2C_E1",
"lat": "51.5", "long": "-0.1"}]},
"fetched": "2026-09-09T21:14:00Z"}
if flickr:
data["flickr"] = {"id": key, "owner": "12345678@N00", "owner_name": "handle",
"owner_realname": "Real Name", "title": "A title",
"taken": "2009-07-22 14:03:11", "posted": "2009-07-23T10:40:00Z",
"licence_id": 5, "page": "https://www.flickr.com/photos/12345678@N00/%s/" % key,
"size_label": "Original", "width": 800, "height": 600}
else:
data["page"] = "https://www.geograph.org.uk/photos/1.jpg"
return data
class Sidecars(TestCase):
def test_round_trip_is_atomic(self):
path = rb.sidecarpath(self.dir, "111")
self.assertEqual(path, os.path.join(self.dir, "111.json"))
rb.writejson(path, sidecar())
self.assertEqual(rb.readsidecar(path)["key"], "111")
self.assertFalse(os.path.exists(path + ".part"))
def test_unreadable_sidecar_is_none(self):
self.assertIsNone(rb.readsidecar(os.path.join(self.dir, "none.json")))
self.write("bad.json", "{not json")
self.assertIsNone(rb.readsidecar(os.path.join(self.dir, "bad.json")))
def test_mediapath_finds_the_photo_not_the_sidecar(self):
self.write("111.json", "{}")
self.write("111.part", b"x")
self.assertIsNone(rb.mediapath(self.dir, "111"))
self.write("111.png", b"x")
self.assertEqual(rb.mediapath(self.dir, "111"), os.path.join(self.dir, "111.png"))
def test_iscomplete(self):
self.assertFalse(rb.iscomplete(self.dir, "111"))
rb.writejson(rb.sidecarpath(self.dir, "111"), sidecar())
self.assertFalse(rb.iscomplete(self.dir, "111")) # no file yet
self.write("111.jpg", b"x")
self.assertTrue(rb.iscomplete(self.dir, "111"))
self.write("111.part", b"x")
self.assertFalse(rb.iscomplete(self.dir, "111")) # interrupted refetch
def test_sidenodes_survives_any_shape(self):
self.assertEqual(rb.sidenodes({}), [])
self.assertEqual(rb.sidenodes({"rgl": "nonsense"}), [])
self.assertEqual(rb.sidenodes({"rgl": {"nodes": "nonsense"}}), [])
self.assertEqual(rb.sidenodes({"rgl": {"nodes": ["nonsense"]}}), [])
self.assertEqual(rb.sidenodes(sidecar())[0]["name"], "A, E1")
def test_listsidecars_unreadable_directory_is_an_error(self):
with self.assertRaises(rb.BackupError):
rb.listsidecars(os.path.join(self.dir, "nowhere"))
def test_listsidecars_sorted_and_skips_hash_cache(self):
rb.writejson(rb.sidecarpath(self.dir, "222"), sidecar(key="222", file="222.jpg"))
rb.writejson(rb.sidecarpath(self.dir, "111"), sidecar())
rb.writejson(os.path.join(self.dir, rb.HASHES), {})
rb.writejson(os.path.join(self.dir, rb.FAILURES), {})
self.assertEqual([k for k, _ in rb.listsidecars(self.dir)], ["111", "222"])
def test_utcnow_shape(self):
self.assertRegex(rb.utcnow(), r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ$")
class Licences(TestCase):
def test_label_from_url(self):
self.assertEqual(rb.licencelabel("https://creativecommons.org/licenses/by-nc-sa/2.0/deed.en_GB"), "CC BY-NC-SA 2.0")
self.assertEqual(rb.licencelabel("https://creativecommons.org/licenses/by/2.0/"), "CC BY 2.0")
self.assertEqual(rb.licencelabel("http://creativecommons.org/publicdomain/zero/1.0/"), "CC0 1.0")
self.assertIsNone(rb.licencelabel(""))
self.assertIsNone(rb.licencelabel("https://example.com/terms"))
def test_slug(self):
self.assertEqual(rb.licenceslug("CC BY-NC-SA 2.0"), "cc-by-nc-sa-2.0")
self.assertEqual(rb.licenceslug("All rights reserved"), "all-rights-reserved")
def test_rgl_licence_wins(self):
side = sidecar()
self.assertEqual(rb.licencefor(side),
("CC BY-SA 2.0", "https://creativecommons.org/licenses/by-sa/2.0/", "rgl"))
def test_flickr_fills_a_gap(self):
side = sidecar(licence="")
label, url, origin = rb.licencefor(side)
self.assertEqual((label, origin), ("CC BY-SA 2.0", "flickr"))
self.assertIn("by-sa/2.0", url)
def test_a_licence_url_we_cannot_name_beats_flickr(self):
side = sidecar(licence="https://example.com/our-own-terms")
self.assertEqual(rb.licencefor(side),
(None, "https://example.com/our-own-terms", "wiki"))
self.assertIn("Licence: https://example.com/our-own-terms (from the wiki)",
rb.describeblock(side))
self.assertNotIn("from Flickr", rb.describeblock(side))
self.assertIn("rgl/licence/unknown", rb.tagsfor(side))
def test_unknown_when_neither(self):
side = sidecar(licence="")
side["flickr"]["licence_id"] = None
self.assertEqual(rb.licencefor(side), (None, None, None))
def test_flickr_table_covers_published_ids(self):
self.assertEqual(rb.FLICKR_LICENCES[0][0], "All rights reserved")
self.assertEqual(rb.FLICKR_LICENCES[4][0], "CC BY 2.0")
self.assertEqual(rb.FLICKR_LICENCES[9][0], "CC0 1.0")
self.assertEqual(rb.FLICKR_LICENCES[10][0], "Public Domain Mark 1.0")
class Describing(TestCase):
def test_block_shape(self):
block = rb.describeblock(sidecar())
self.assertEqual(block.splitlines(), [
rb.MARKER,
"Photo: A title",
"Source: https://www.flickr.com/photos/12345678@N00/111/",
"Photographer: P",
"Licence: CC BY-SA 2.0 https://creativecommons.org/licenses/by-sa/2.0/",
"Used on:",
" A, E1 https://london.randomness.org.uk/wiki.cgi?A%2C_E1",
])
def test_photographer_falls_back_to_flickr(self):
side = sidecar(copyright="")
self.assertEqual(rb.photographer(side), "Real Name")
side["flickr"]["owner_realname"] = ""
self.assertEqual(rb.photographer(side), "handle")
def test_licence_from_flickr_is_marked_and_disagreement_noted(self):
side = sidecar(licence="")
self.assertIn("Licence: CC BY-SA 2.0 https://creativecommons.org/licenses/by-sa/2.0/ (from Flickr)",
rb.describeblock(side))
side = sidecar(licence="https://creativecommons.org/licenses/by-nc-sa/2.0/")
block = rb.describeblock(side)
self.assertIn("Licence: CC BY-NC-SA 2.0 https://creativecommons.org/licenses/by-nc-sa/2.0/", block)
self.assertIn("Note: Flickr lists this photo as CC BY-SA 2.0", block)
def test_no_licence_says_unknown(self):
side = sidecar(licence="")
side["flickr"]["licence_id"] = None
self.assertIn("Licence: unknown", rb.describeblock(side))
def test_non_flickr_photo(self):
side = sidecar(flickr=False)
block = rb.describeblock(side)
self.assertNotIn("Photo:", block)
self.assertIn("Source: https://www.geograph.org.uk/photos/1.jpg", block)
def test_several_pages(self):
side = sidecar(nodes=[
{"param": "A", "name": "A", "url": "https://x/A", "lat": "", "long": ""},
{"param": "B", "name": "B", "url": "https://x/B", "lat": "", "long": ""}])
self.assertTrue(rb.describeblock(side).endswith("Used on:\n A https://x/A\n B https://x/B"))
class Merging(TestCase):
block = "%s\nPhoto: x\nUsed on:\n A https://x/A" % rb.MARKER
def test_empty_or_boilerplate_is_written(self):
self.assertEqual(rb.mergedescription("", self.block), (self.block, "write"))
self.assertEqual(rb.mergedescription("OLYMPUS DIGITAL CAMERA", self.block), (self.block, "write"))
def test_existing_text_is_kept_above(self):
text, action = rb.mergedescription("My caption", self.block)
self.assertEqual(action, "append")
self.assertEqual(text, "My caption\n\n" + self.block)
def test_same_block_is_quiet(self):
held = "My caption\n\n" + self.block
self.assertEqual(rb.mergedescription(held, self.block), (held, "same"))
def test_changed_block_is_replaced_in_place(self):
held = "My caption\n\n" + self.block + "\n\nTrailing note"
newblock = self.block + "\n B https://x/B"
text, action = rb.mergedescription(held, newblock)
self.assertEqual(action, "replace")
self.assertEqual(text, "My caption\n\n" + newblock + "\n\nTrailing note")
def test_isboilerplate(self):
self.assertTrue(rb.isboilerplate("SONY DSC"))
self.assertFalse(rb.isboilerplate("The 100 Club"))
class Tags(TestCase):
def test_tags(self):
self.assertEqual(rb.tagsfor(sidecar()),
["rgl", "rgl/licence/cc-by-sa-2.0", "rgl/photographer/P"])
def test_unknown_licence_and_blank_photographer(self):
side = sidecar(licence="", copyright="")
side["flickr"]["licence_id"] = None
side["flickr"]["owner_realname"] = ""
side["flickr"]["owner_name"] = ""
self.assertEqual(rb.tagsfor(side), ["rgl", "rgl/licence/unknown"])
def test_photographer_whitespace_and_slashes(self):
side = sidecar(copyright=" Some / One ")
self.assertIn("rgl/photographer/Some - One", rb.tagsfor(side))
class FetchPhoto(TestCase):
def group(self, key="111", nodes=None):
nodes = nodes or [{"param": "A,_E1", "name": "A, E1",
"url": "https://london.randomness.org.uk/wiki.cgi?A%2C_E1",
"image": "https://farm1.static.flickr.com/1/111_a_m.jpg",
"page": "https://www.flickr.com/photos/x/111/",
"copyright": "P", "licence": "https://creativecommons.org/licenses/by-sa/2.0/",
"lat": "51.5", "long": "-0.1"}]
return {"key": key, "source": "flickr", "id": key, "image": nodes[0]["image"],
"page": nodes[0]["page"], "copyright": "P",
"licence": "https://creativecommons.org/licenses/by-sa/2.0/", "nodes": nodes}
def test_fetches_largest_and_writes_sidecar(self):
rb.OPEN = FakeOpen(
FakeResponse(flickr_info("111")),
FakeResponse(flickr_sizes(("Small", 240, 180, "https://x/111_s.jpg"),
("Original", 800, 600, "https://x/111_o.png"))),
FakeResponse(b"PNGDATA", headers={"Content-Type": "image/png"}))
side, action = rb.fetchphoto(self.dir, self.group(), "k")
self.assertEqual(action, "fetched")
self.assertEqual(side["file"], "111.png")
self.assertEqual(side["flickr"]["size_label"], "Original")
self.assertEqual(side["flickr"]["width"], 800)
self.assertEqual(side["rgl"]["nodes"][0]["param"], "A,_E1")
self.assertNotIn("image", side["rgl"]["nodes"][0])
self.assertTrue(rb.iscomplete(self.dir, "111"))
self.assertEqual(open(os.path.join(self.dir, "111.png"), "rb").read(), b"PNGDATA")
self.assertFalse(os.path.exists(os.path.join(self.dir, "111.part")))
def test_falls_back_when_largest_404s(self):
rb.OPEN = FakeOpen(
FakeResponse(flickr_info("111")),
FakeResponse(flickr_sizes(("Large", 1024, 768, "https://x/111_b.jpg"),
("Original", 800, 600, "https://x/111_o.jpg"))),
http_error(404),
FakeResponse(b"JPG", headers={"Content-Type": "image/jpeg"}))
side, action = rb.fetchphoto(self.dir, self.group(), "k")
self.assertEqual(side["flickr"]["size_label"], "Large")
self.assertIn("111_o.jpg", rb.OPEN.requests[2].full_url)
def test_gone_photo_raises_and_writes_nothing(self):
rb.OPEN = FakeOpen(FakeResponse({"stat": "fail", "code": 1, "message": "Photo not found"}))
with self.assertRaises(rb.PhotoGone):
rb.fetchphoto(self.dir, self.group(), "k")
self.assertEqual(os.listdir(self.dir), [])
def test_complete_photo_is_skipped_without_calls(self):
rb.writejson(rb.sidecarpath(self.dir, "111"), sidecar())
self.write("111.jpg", b"x")
rb.OPEN = FakeOpen()
side, action = rb.fetchphoto(self.dir, self.group(), "k")
self.assertEqual(action, "skipped")
def test_changed_nodes_rewrite_sidecar_only(self):
rb.writejson(rb.sidecarpath(self.dir, "111"), sidecar())
self.write("111.jpg", b"x")
rb.OPEN = FakeOpen()
g = self.group()
g["nodes"].append(dict(g["nodes"][0], param="B,_E2", name="B, E2", url="https://x/B"))
side, action = rb.fetchphoto(self.dir, g, "k")
self.assertEqual(action, "updated")
self.assertEqual(len(rb.readsidecar(rb.sidecarpath(self.dir, "111"))["rgl"]["nodes"]), 2)
self.assertEqual(rb.STATS["rewritten"], 1)
def test_nodes_are_sorted_by_param(self):
rb.OPEN = FakeOpen(
FakeResponse(flickr_info("111")),
FakeResponse(flickr_sizes(("Original", 800, 600, "https://x/111_o.jpg"))),
FakeResponse(b"J", headers={"Content-Type": "image/jpeg"}))
g = self.group()
g["nodes"].insert(0, dict(g["nodes"][0], param="Z,_E9", name="Z, E9"))
side, action = rb.fetchphoto(self.dir, g, "k")
self.assertEqual([n["param"] for n in side["rgl"]["nodes"]], ["A,_E1", "Z,_E9"])
# and the same order the comparison uses, so a re-run rewrites nothing
self.assertEqual(rb.fetchphoto(self.dir, g, "k")[1], "skipped")
def test_downloadany_skips_an_empty_body(self):
rb.OPEN = FakeOpen(FakeResponse(b""),
FakeResponse(b"JPG", headers={"Content-Type": "image/jpeg"}))
candidates = [{"label": "Original", "width": 8, "height": 6, "source": "https://x/o.jpg",
"media": "photo"},
{"label": "Large", "width": 4, "height": 3, "source": "https://x/l.jpg",
"media": "photo"}]
partial = os.path.join(self.dir, "111.part")
pick, ext = rb.downloadany(candidates, partial, "photo 111")
self.assertEqual((pick["label"], ext), ("Large", ".jpg"))
def test_downloadany_with_nothing_but_empty_bodies_fails(self):
rb.OPEN = FakeOpen(FakeResponse(b""))
candidates = [{"label": "Original", "width": 8, "height": 6, "source": "https://x/o.jpg",
"media": "photo"}]
with self.assertRaises(rb.BackupError) as cm:
rb.downloadany(candidates, os.path.join(self.dir, "111.part"), "photo 111")
self.assertIn("empty", str(cm.exception))
def test_partial_file_is_refetched(self):
rb.writejson(rb.sidecarpath(self.dir, "111"), sidecar())
self.write("111.jpg", b"x")
self.write("111.part", b"half")
rb.OPEN = FakeOpen(FakeResponse(flickr_info("111")),
FakeResponse(flickr_sizes(("Original", 800, 600, "https://x/111_o.jpg"))),
FakeResponse(b"FULL", headers={"Content-Type": "image/jpeg"}))
side, action = rb.fetchphoto(self.dir, self.group(), "k")
self.assertEqual(action, "fetched")
self.assertEqual(open(os.path.join(self.dir, "111.jpg"), "rb").read(), b"FULL")
def test_sidecar_without_an_rgl_block_is_rewritten_not_fatal(self):
rb.writejson(rb.sidecarpath(self.dir, "111"), {"key": "111", "file": "111.jpg"})
self.write("111.jpg", b"x")
rb.OPEN = FakeOpen()
side, action = rb.fetchphoto(self.dir, self.group(), "k")
self.assertEqual(action, "updated")
self.assertEqual(len(rb.readsidecar(rb.sidecarpath(self.dir, "111"))["rgl"]["nodes"]), 1)
def test_dry_run_calls_nothing(self):
rb.OPEN = FakeOpen()
side, action = rb.fetchphoto(self.dir, self.group(), "k", dryrun=True)
self.assertEqual(action, "would fetch")
self.assertEqual(os.listdir(self.dir), [])
def test_non_flickr_photo_downloads_image_url(self):
g = self.group(key="url-0123456789abcdef")
g["source"] = "url"
del g["id"]
g["image"] = g["nodes"][0]["image"] = "https://www.geograph.org.uk/p/1.jpg"
g["page"] = "https://www.geograph.org.uk/photo/1"
rb.OPEN = FakeOpen(FakeResponse(b"JPG", headers={"Content-Type": "image/jpeg"}))
side, action = rb.fetchphoto(self.dir, g, "k")
self.assertEqual(side["source"], "url")
self.assertEqual(side["page"], "https://www.geograph.org.uk/photo/1")
self.assertNotIn("flickr", side)
self.assertEqual(side["file"], "url-0123456789abcdef.jpg")
class FetchPhase(TestCase):
def conf(self, **over):
conf = {"directory": self.dir, "index_url": "http://t/index", "limit": None,
"dry_run": False, "quiet": False}
conf.update(over)
return conf
def test_runs_over_index_and_collects_failures(self):
index = [index_entry("A,_E1", "https://farm1.static.flickr.com/1/111_a_m.jpg",
page="https://www.flickr.com/photos/x/111/"),
index_entry("B,_E2", "https://farm1.static.flickr.com/1/222_b_m.jpg",
page="https://www.flickr.com/photos/x/222/"),
index_entry("C,_E3", "junk")]
rb.OPEN = FakeOpen(
FakeResponse(json.dumps(index).encode()),
FakeResponse(flickr_info("111")),
FakeResponse(flickr_sizes(("Original", 8, 6, "https://x/111_o.jpg"))),
FakeResponse(b"J", headers={"Content-Type": "image/jpeg"}),
FakeResponse({"stat": "fail", "code": 1, "message": "Photo not found"}))
failures = rb.fetchphase(self.conf(), "k")
self.assertTrue(rb.iscomplete(self.dir, "111"))
self.assertEqual(rb.STATS["fetched"], 1)
self.assertEqual(len(failures), 2)
reasons = [f[2] for f in failures.items]
self.assertTrue(any("Photo not found" in r for r in reasons), reasons)
self.assertTrue(any("not a url" in r for r in reasons), reasons)
names = [n for f in failures.items for n in f[1]]
self.assertIn("B, E2", names)
def test_limit_stops_early(self):
index = [index_entry("A", "https://farm1.static.flickr.com/1/111_a_m.jpg"),
index_entry("B", "https://farm1.static.flickr.com/1/222_b_m.jpg")]
rb.OPEN = FakeOpen(
FakeResponse(json.dumps(index).encode()),
FakeResponse(flickr_info("111")),
FakeResponse(flickr_sizes(("Original", 8, 6, "https://x/111_o.jpg"))),
FakeResponse(b"J", headers={"Content-Type": "image/jpeg"}))
rb.fetchphase(self.conf(limit=1), "k")
self.assertEqual(rb.STATS["fetched"], 1)
self.assertFalse(os.path.exists(rb.sidecarpath(self.dir, "222")))
def test_dry_run_creates_no_directory(self):
room = os.path.join(self.dir, "not-yet")
rb.OPEN = FakeOpen(FakeResponse(json.dumps(
[index_entry("A", "https://farm1.static.flickr.com/1/111_a_m.jpg")]).encode()))
rb.fetchphase(self.conf(directory=room, dry_run=True), "k")
self.assertFalse(os.path.exists(room))
def test_index_failure_is_fatal(self):
rb.OPEN = FakeOpen(http_error(404))
with self.assertRaises(rb.BackupError):
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)
class PlanAsset(TestCase):
def asset(self, **over):
a = {"id": "a1", "name": "111.jpg", "description": "", "latitude": None,
"longitude": None, "taken": ""}
a.update(over)
return a
def test_fresh_asset_gets_everything(self):
fields = rb.planasset(sidecar(), self.asset())
self.assertEqual(fields["description"], rb.describeblock(sidecar()))
self.assertEqual((fields["latitude"], fields["longitude"]), (51.5, -0.1))
self.assertEqual(fields["dateTimeOriginal"], "2009-07-22T14:03:11Z")
def test_existing_values_are_kept(self):
a = self.asset(description="Mine\n\n" + rb.describeblock(sidecar()),
latitude=1.0, longitude=2.0, taken="2001-01-01T00:00:00.000Z")
self.assertEqual(rb.planasset(sidecar(), a), {})
def test_hand_written_description_is_appended_to(self):
fields = rb.planasset(sidecar(), self.asset(description="Mine", latitude=1.0, longitude=2.0, taken="t"))
self.assertEqual(list(fields), ["description"])
self.assertTrue(fields["description"].startswith("Mine\n\n"))
def test_bad_coordinates_are_skipped(self):
side = sidecar(nodes=[{"param": "A", "name": "A", "url": "u", "lat": "", "long": ""}])
fields = rb.planasset(side, self.asset())
self.assertNotIn("latitude", fields)
def test_flickrtaken(self):
self.assertEqual(rb.flickrtaken(sidecar()), "2009-07-22T14:03:11Z")
self.assertIsNone(rb.flickrtaken(sidecar(flickr=False)))
side = sidecar()
side["flickr"]["taken"] = "garbage"
self.assertIsNone(rb.flickrtaken(side))
def test_takenat(self):
path = self.write("111.jpg", b"x")
os.utime(path, (1000000000, 1000000000))
self.assertEqual(rb.takenat(sidecar(), path), "2009-07-22T14:03:11Z")
self.assertEqual(rb.takenat(sidecar(flickr=False), path), "2001-09-09T01:46:40Z")
side = sidecar()
side["flickr"]["taken"] = "garbage"
self.assertEqual(rb.takenat(side, path), "2001-09-09T01:46:40Z")
class SyncPhase(TestCase):
def setUp(self):
super().setUp()
rb.writejson(rb.sidecarpath(self.dir, "111"), sidecar())
self.write("111.jpg", b"NEW")
rb.writejson(rb.sidecarpath(self.dir, "222"), sidecar(key="222", file="222.jpg", copyright="Q"))
self.write("222.jpg", b"OLD")
self.old = rb.checksum(os.path.join(self.dir, "222.jpg"))
def conf(self, **over):
conf = {"directory": self.dir, "dry_run": False, "quiet": False, "limit": None}
conf.update(over)
return conf
def responses(self):
return [
FakeResponse({"id": "u1"}), # me
FakeResponse({"assets": {"items": [
{"id": "a2", "ownerId": "u1", "checksum": self.old,
"exifInfo": {"description": "Mine", "latitude": 1, "longitude": 2,
"dateTimeOriginal": "t"}}], "nextPage": None}}), # assets
FakeResponse({"id": "a1", "status": "created"}, status=201), # upload 111
FakeResponse(b""), # update a2 (append)
FakeResponse([]), # albums
FakeResponse({"id": "al1"}), # create album
FakeResponse({"assets": []}), # album assets
FakeResponse([{"success": True}, {"success": True}]), # add to album
FakeResponse([{"value": "rgl", "id": "t1"},
{"value": "rgl/licence/cc-by-sa-2.0", "id": "t2"},
{"value": "rgl/photographer/P", "id": "t3"},
{"value": "rgl/photographer/Q", "id": "t4"}]), # tags
FakeResponse([{"success": True}, {"success": True}]), # t1
FakeResponse([{"success": True}, {"success": True}]), # t2
FakeResponse([{"success": True}]), # t3
FakeResponse([{"success": True}]), # t4
FakeResponse({"id": "a1", "originalFileName": "111.jpg",
"exifInfo": {}}), # re-read a1
FakeResponse(b""), # update a1
]
def test_uploads_new_reuses_existing_and_annotates(self):
rb.OPEN = FakeOpen(*self.responses())
failures = rb.syncphase(self.conf(), rb.Immich("https://i/api", "k"))
self.assertEqual(len(failures), 0)
self.assertEqual(rb.STATS["uploaded"], 1)
self.assertEqual(rb.STATS["existing"], 1)
reqs = rb.OPEN.requests
self.assertTrue(reqs[2].full_url.endswith("/assets"))
upd2 = json.loads(reqs[3].data)
self.assertEqual(list(upd2), ["description"])
self.assertTrue(upd2["description"].startswith("Mine\n\n"))
self.assertEqual(json.loads(reqs[5].data)["albumName"], "RGL")
self.assertEqual(sorted(json.loads(reqs[7].data)["ids"]), ["a1", "a2"])
# the uploaded asset is annotated last, against what immich now holds
self.assertEqual((reqs[13].get_method(), reqs[13].full_url),
("GET", "https://i/api/assets/a1"))
upd1 = json.loads(reqs[14].data)
self.assertIn("latitude", upd1)
self.assertEqual(upd1["description"], rb.describeblock(sidecar()))
self.assertTrue(os.path.exists(os.path.join(self.dir, rb.HASHES)))
def test_duplicate_upload_appends_to_what_immich_already_holds(self):
rb.OPEN = FakeOpen(
FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [], "nextPage": None}}),
FakeResponse({"id": "a1", "status": "duplicate"}), # upload 111
FakeResponse([{"albumName": "RGL", "id": "al1", "ownerId": "u1"}]),
FakeResponse({"assets": []}),
FakeResponse([{"success": True}]),
FakeResponse([{"value": "rgl", "id": "t1"},
{"value": "rgl/licence/cc-by-sa-2.0", "id": "t2"},
{"value": "rgl/photographer/P", "id": "t3"}]),
FakeResponse([{"success": True}]),
FakeResponse([{"success": True}]),
FakeResponse([{"success": True}]),
FakeResponse({"id": "a1", "originalFileName": "mine.jpg",
"exifInfo": {"description": "My own words",
"latitude": 1, "longitude": 2,
"dateTimeOriginal": "2001-01-01T00:00:00.000Z"}}),
FakeResponse(b""))
failures = rb.syncphase(self.conf(limit=1), rb.Immich("https://i/api", "k"))
self.assertEqual(len(failures), 0)
self.assertEqual(rb.STATS["uploaded"], 0)
self.assertEqual(rb.STATS["existing"], 1)
last = rb.OPEN.requests[-1]
self.assertEqual((last.get_method(), last.full_url),
("PUT", "https://i/api/assets/a1"))
body = json.loads(last.data)
self.assertEqual(list(body), ["description"])
self.assertTrue(body["description"].startswith("My own words\n\n"))
def test_a_repeated_checksum_is_uploaded_once(self):
self.write("222.jpg", b"NEW") # byte-identical to 111.jpg
rb.OPEN = FakeOpen(
FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [], "nextPage": None}}),
FakeResponse({"id": "a1", "status": "created"}, status=201), # upload 111
FakeResponse([{"albumName": "RGL", "id": "al1", "ownerId": "u1"}]),
FakeResponse({"assets": [{"id": "a1"}]}),
FakeResponse([{"value": "rgl", "id": "t1"},
{"value": "rgl/licence/cc-by-sa-2.0", "id": "t2"},
{"value": "rgl/photographer/Q", "id": "t3"}]),
FakeResponse([{"success": True}]),
FakeResponse([{"success": True}]),
FakeResponse([{"success": True}]),
FakeResponse({"id": "a1", "exifInfo": {}}), # re-read a1
FakeResponse(b""))
failures = rb.syncphase(self.conf(), rb.Immich("https://i/api", "k"))
self.assertEqual(len(failures), 0)
uploads = [r for r in rb.OPEN.requests if r.full_url.endswith("/api/assets")]
self.assertEqual(len(uploads), 1)
self.assertEqual(rb.STATS["uploaded"], 1)
self.assertEqual(rb.STATS["existing"], 1)
def test_second_run_is_quiet(self):
block1 = rb.describeblock(sidecar())
block2 = rb.describeblock(sidecar(key="222", file="222.jpg", copyright="Q"))
new = rb.checksum(os.path.join(self.dir, "111.jpg"))
rb.OPEN = FakeOpen(
FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [
{"id": "a1", "ownerId": "u1", "checksum": new,
"exifInfo": {"description": block1, "latitude": 51.5, "longitude": -0.1, "dateTimeOriginal": "t"}},
{"id": "a2", "ownerId": "u1", "checksum": self.old,
"exifInfo": {"description": "Mine\n\n" + block2, "latitude": 1, "longitude": 2, "dateTimeOriginal": "t"}}],
"nextPage": None}}),
FakeResponse([{"albumName": "RGL", "id": "al1", "ownerId": "u1"}]),
FakeResponse({"assets": [{"id": "a1"}, {"id": "a2"}]}),
FakeResponse([{"value": "rgl", "id": "t1"}, {"value": "rgl/licence/cc-by-sa-2.0", "id": "t2"},
{"value": "rgl/photographer/P", "id": "t3"}, {"value": "rgl/photographer/Q", "id": "t4"}]),
FakeResponse([{"error": "duplicate"}, {"error": "duplicate"}]),
FakeResponse([{"error": "duplicate"}, {"error": "duplicate"}]),
FakeResponse([{"error": "duplicate"}]),
FakeResponse([{"error": "duplicate"}]))
failures = rb.syncphase(self.conf(), rb.Immich("https://i/api", "k"))
self.assertEqual(len(failures), 0)
self.assertEqual(rb.STATS["uploaded"], 0)
self.assertEqual(rb.STATS["updated"], 0)
def test_dry_run_only_reads(self):
rb.OPEN = FakeOpen(FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [], "nextPage": None}}),
FakeResponse([]))
rb.syncphase(self.conf(dry_run=True), rb.Immich("https://i/api", "k"))
self.assertEqual([r.get_method() for r in rb.OPEN.requests], ["GET", "POST", "GET"])
self.assertIn("would upload", self.stdout.getvalue())
self.assertFalse(os.path.exists(os.path.join(self.dir, rb.HASHES)))
def test_dry_run_limit_counts_what_it_looked_at(self):
rb.OPEN = FakeOpen(FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [], "nextPage": None}}),
FakeResponse([]))
rb.syncphase(self.conf(dry_run=True, limit=1), rb.Immich("https://i/api", "k"))
self.assertEqual(self.stdout.getvalue().count("would upload"), 1)
def test_unsupported_file_is_a_failure_not_a_stop(self):
rb.OPEN = FakeOpen(
FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [], "nextPage": None}}),
http_error(400, b'{"message":"unsupported file type"}'), # upload 111
FakeResponse({"id": "a2", "status": "created"}, status=201), # upload 222
FakeResponse([]), FakeResponse({"id": "al1"}), FakeResponse({"assets": []}),
FakeResponse([{"success": True}]),
FakeResponse([{"value": "rgl", "id": "t1"}, {"value": "rgl/licence/cc-by-sa-2.0", "id": "t2"},
{"value": "rgl/photographer/Q", "id": "t4"}]),
FakeResponse([{"success": True}]), FakeResponse([{"success": True}]), FakeResponse([{"success": True}]),
FakeResponse({"id": "a2", "exifInfo": {}}), # re-read a2
FakeResponse(b"")) # update a2
failures = rb.syncphase(self.conf(), rb.Immich("https://i/api", "k"))
self.assertEqual(len(failures), 1)
self.assertIn("unsupported", failures.items[0][2])
self.assertEqual(rb.STATS["uploaded"], 1)
def test_malformed_sidecar_is_a_failure_not_a_stop(self):
room = os.path.join(self.dir, "other")
os.mkdir(room)
rb.writejson(os.path.join(room, "333.json"), {"key": "333"})
rb.OPEN = FakeOpen(
FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [], "nextPage": None}}),
FakeResponse([]), FakeResponse({"id": "al1"}), FakeResponse({"assets": []}))
failures = rb.syncphase(self.conf(directory=room), rb.Immich("https://i/api", "k"))
self.assertEqual(len(failures), 1)
self.assertIn("no file", failures.items[0][2])
def test_bad_key_is_fatal_before_writes(self):
rb.OPEN = FakeOpen(http_error(401))
with self.assertRaises(rb.BackupError):
rb.syncphase(self.conf(), rb.Immich("https://i/api", "k"))
def test_album_stage_failure_is_recorded(self):
rb.ATTEMPTS = 1
rb.OPEN = FakeOpen(
FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [], "nextPage": None}}),
FakeResponse({"id": "a1", "status": "created"}, status=201), # upload 111
http_error(500), # albums
FakeResponse({"id": "a1", "exifInfo": {}}), # re-read a1
FakeResponse(b"")) # update a1
failures = rb.syncphase(self.conf(limit=1), rb.Immich("https://i/api", "k"))
self.assertEqual(len(failures), 1)
reason = failures.items[0][2]
self.assertTrue("albums" in reason or "HTTP 500" in reason)
class Run(TestCase):
def conf(self, **over):
conf = {"directory": self.dir, "flickr_auth": self.write("f.yml", "key: FK\n"),
"immich_auth": self.write("i.yml", "url: https://i/api\nkey: IK\n"),
"index_url": "http://t/index", "fetch": True, "sync": True,
"dry_run": False, "report": False, "limit": None, "pace": 0, "quiet": False,
"attempts": 2, "backoff": 1, "timeout": 5, "user_agent": "ua", "debug": False}
conf.update(over)
return conf
def test_applysettings(self):
rb.applysettings(self.conf(pace=3, quiet=True, debug=True))
self.assertEqual(rb.PACE, 3)
self.assertTrue(rb.QUIET)
self.assertTrue(rb.DEBUG)
self.assertEqual(rb.ATTEMPTS, 2)
def test_fetch_only_never_reads_immich_config(self):
rb.OPEN = FakeOpen(FakeResponse(b"[]"))
conf = self.conf(sync=False, immich_auth=os.path.join(self.dir, "missing.yml"))
self.assertEqual(rb.run(conf), 0)
def test_sync_only_never_reads_flickr_config(self):
rb.OPEN = FakeOpen(FakeResponse({"id": "u1"}),
FakeResponse({"assets": {"items": [], "nextPage": None}}),
FakeResponse([]), FakeResponse({"id": "al1"}), FakeResponse({"assets": []}),
FakeResponse([]))
conf = self.conf(fetch=False, flickr_auth=os.path.join(self.dir, "missing.yml"))
self.assertEqual(rb.run(conf), 0)
def test_summary_counts_rewritten_sidecars(self):
rb.STATS["rewritten"] = 3
rb.summary(rb.Failures())
self.assertIn("rewritten 3", self.stdout.getvalue())
def test_failures_give_exit_1_and_are_summarised(self):
rb.OPEN = FakeOpen(FakeResponse(json.dumps([index_entry("A,_E1", "junk")]).encode()))
self.assertEqual(rb.run(self.conf(sync=False)), 1)
self.assertIn("failed 1", self.stdout.getvalue())
self.assertIn("A, E1", self.stderr.getvalue())
def test_quiet_failures_are_printed_once_with_a_count(self):
rb.OPEN = FakeOpen(FakeResponse(json.dumps([index_entry("A,_E1", "junk")]).encode()))
self.assertEqual(rb.run(self.conf(sync=False, quiet=True)), 1)
self.assertEqual(self.stdout.getvalue(), "")
self.assertEqual(self.stderr.getvalue().count("A, E1"), 1)
self.assertTrue(self.stderr.getvalue().rstrip("\n").endswith("1 failed"))
def test_config_error_gives_exit_2(self):
conf = self.conf(sync=False, flickr_auth=os.path.join(self.dir, "missing.yml"))
self.assertEqual(rb.run(conf), 2)
self.assertIn("key", self.stderr.getvalue())
def test_quiet_clean_run_prints_nothing(self):
rb.OPEN = FakeOpen(FakeResponse(b"[]"))
self.assertEqual(rb.run(self.conf(sync=False, quiet=True)), 0)
self.assertEqual(self.stdout.getvalue(), "")
self.assertEqual(self.stderr.getvalue(), "")
def test_main_wires_args(self):
rb.OPEN = FakeOpen(FakeResponse(b"[]"))
settingsfile = self.write("s.yml", "flickr_auth: %s\n" % self.write("f.yml", "key: FK\n"))
self.assertEqual(rb.main(["--settings", settingsfile, "--fetch-only", self.dir]), 0)
def test_main_reports_bad_settings(self):
self.assertEqual(rb.main(["--settings", os.path.join(self.dir, "none")]), 2)
self.assertIn("directory", self.stderr.getvalue())
def instead_of_run(self, boom):
original = rb.run
self.addCleanup(setattr, rb, "run", original)
def raiser(conf):
raise boom
rb.run = raiser
def test_unexpected_error_is_reported_not_traced(self):
self.instead_of_run(RuntimeError("something odd"))
self.assertEqual(rb.main(["--settings", os.path.join(self.dir, "none"), self.dir]), 1)
self.assertIn("something odd", self.stderr.getvalue())
def test_interrupt_exits_130(self):
self.instead_of_run(KeyboardInterrupt())
self.assertEqual(rb.main(["--settings", os.path.join(self.dir, "none"), self.dir]), 130)
def test_unreadable_settings_file_gives_exit_2(self):
self.assertEqual(rb.main(["--settings", self.dir, self.dir]), 2)
self.assertIn(self.dir, self.stderr.getvalue())
class FailureRecords(TestCase):
def failures(self, *items):
f = rb.Failures()
for key, names, reason in items:
f.add(key, [{"name": n} for n in names], reason)
return f
def test_missing_file_is_empty(self):
self.assertEqual(rb.loadfailures(self.dir), {})
def test_records_merge_and_drop_completed(self):
rb.recordfailures(self.dir, self.failures(("111", ["A, E1"], "gone"),
("222", ["B, E2"], "HTTP 404")))
held = rb.loadfailures(self.dir)
self.assertEqual(sorted(held), ["111", "222"])
self.assertEqual(held["111"]["reason"], "gone")
self.assertEqual(held["111"]["pages"], ["A, E1"])
self.assertRegex(held["111"]["when"], r"^\d{4}-")
# 111 later succeeds; 333 newly fails; 222 untouched this run but still missing
rb.writejson(rb.sidecarpath(self.dir, "111"), sidecar())
self.write("111.jpg", b"x")
rb.recordfailures(self.dir, self.failures(("333", ["C, E3"], "timeout")))
held = rb.loadfailures(self.dir)
self.assertEqual(sorted(held), ["222", "333"])
def test_unreadable_file_is_treated_as_empty(self):
self.write(rb.FAILURES, "{not json")
self.assertEqual(rb.loadfailures(self.dir), {})
class Report(TestCase):
def conf(self, **over):
conf = {"directory": self.dir, "index_url": "http://t/index", "report": True,
"fetch": True, "sync": True, "dry_run": False, "limit": None, "quiet": False,
"flickr_auth": os.path.join(self.dir, "missing.yml"),
"immich_auth": os.path.join(self.dir, "missing.yml"),
"pace": 0, "attempts": 2, "backoff": 1, "timeout": 5, "user_agent": "ua",
"debug": False}
conf.update(over)
return conf
def index(self):
return json.dumps([
index_entry("A,_E1", "https://farm1.static.flickr.com/1/111_a_m.jpg",
page="https://www.flickr.com/photos/x/111/"),
index_entry("B,_E2", "https://farm1.static.flickr.com/1/222_b_m.jpg",
page="https://www.flickr.com/photos/x/222/"),
index_entry("C,_E3", "https://farm1.static.flickr.com/1/333_c_m.jpg"),
index_entry("D,_E4", "junk"),
]).encode()
def test_lists_missing_photos_with_pages_and_reasons(self):
rb.writejson(rb.sidecarpath(self.dir, "111"), sidecar())
self.write("111.jpg", b"x")
rb.writejson(os.path.join(self.dir, rb.FAILURES),
{"222": {"reason": "photo 222: HTTP 404 Not Found", "pages": ["B, E2"],
"when": "2026-09-13T10:00:00Z"}})
rb.OPEN = FakeOpen(FakeResponse(self.index()))
self.assertEqual(rb.run(self.conf()), 0)
out = self.stdout.getvalue()
self.assertNotIn("111", out)
self.assertIn("222: photo 222: HTTP 404 Not Found (2026-09-13)", out)
self.assertIn(" B, E2 https://london.randomness.org.uk/wiki.cgi?B%2C_E2", out)
self.assertIn("333: not yet attempted", out)
self.assertIn(" C, E3 ", out)
self.assertIn("D,_E4: node_image is not a url", out)
self.assertIn("2 of 3 photos missing, 1 node with an unusable image", out)
self.assertEqual(len(rb.OPEN.requests), 1) # the index only
def test_report_prints_even_when_quiet(self):
rb.OPEN = FakeOpen(FakeResponse(self.index()))
rb.run(self.conf(quiet=True))
self.assertIn("3 of 3 photos missing", self.stdout.getvalue())
def test_report_needs_no_credentials(self):
rb.OPEN = FakeOpen(FakeResponse(b"[]"))
self.assertEqual(rb.run(self.conf()), 0)
self.assertIn("0 of 0 photos missing", self.stdout.getvalue())
class RunRecordsFailures(TestCase):
def conf(self, **over):
conf = {"directory": self.dir, "flickr_auth": self.write("f.yml", "key: FK\n"),
"immich_auth": self.write("i.yml", "url: https://i/api\nkey: IK\n"),
"index_url": "http://t/index", "fetch": True, "sync": False, "report": False,
"dry_run": False, "limit": None, "pace": 0, "quiet": False,
"attempts": 2, "backoff": 1, "timeout": 5, "user_agent": "ua", "debug": False}
conf.update(over)
return conf
def test_failed_run_writes_the_failures_file(self):
index = [index_entry("A,_E1", "https://farm1.static.flickr.com/1/111_a_m.jpg")]
rb.OPEN = FakeOpen(FakeResponse(json.dumps(index).encode()),
FakeResponse({"stat": "fail", "code": 1, "message": "Photo not found"}))
self.assertEqual(rb.run(self.conf()), 1)
held = rb.loadfailures(self.dir)
self.assertIn("111", held)
self.assertIn("Photo not found", held["111"]["reason"])
self.assertEqual(held["111"]["pages"], ["A, E1"])
def test_dry_run_writes_nothing(self):
index = [index_entry("A,_E1", "https://farm1.static.flickr.com/1/111_a_m.jpg")]
rb.OPEN = FakeOpen(FakeResponse(json.dumps(index).encode()))
rb.run(self.conf(dry_run=True))
self.assertFalse(os.path.exists(os.path.join(self.dir, rb.FAILURES)))