Wire the phases into a command with a summary and exit status
Load only the credentials the requested phases need, run fetch then sync, and exit 1 when any photo failed so cron mails. A quiet clean run prints nothing; a quiet failing run prints the failure list at the end. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2 files changed
+159
-1
No files matched your search
+70
-1
@@ -1278,5 +1278,74 @@ def syncphase(conf, immich):
|
|||||||
return failures
|
return failures
|
||||||
|
|
||||||
|
|
||||||
|
def applysettings(conf):
|
||||||
|
global PACE, ATTEMPTS, BACKOFF_BASE, HTTP_TIMEOUT, USER_AGENT, QUIET, DEBUG
|
||||||
|
PACE = float(conf["pace"])
|
||||||
|
ATTEMPTS = int(conf["attempts"])
|
||||||
|
BACKOFF_BASE = float(conf["backoff"])
|
||||||
|
HTTP_TIMEOUT = float(conf["timeout"])
|
||||||
|
USER_AGENT = conf["user_agent"]
|
||||||
|
QUIET = bool(conf["quiet"])
|
||||||
|
DEBUG = bool(conf["debug"])
|
||||||
|
|
||||||
|
def summary(failures):
|
||||||
|
say("")
|
||||||
|
say("fetched %d, skipped %d, rewritten %d, uploaded %d, already held %d, "
|
||||||
|
"updated %d, retries %d, failed %d"
|
||||||
|
% (STATS["fetched"], STATS["skipped"], STATS["rewritten"], STATS["uploaded"],
|
||||||
|
STATS["existing"], STATS["updated"], STATS["retries"], len(failures)))
|
||||||
|
if failures and QUIET:
|
||||||
|
warn("%d failed" % len(failures))
|
||||||
|
|
||||||
|
def run(conf):
|
||||||
|
applysettings(conf)
|
||||||
|
failures = Failures()
|
||||||
|
try:
|
||||||
|
flickrkey = loadflickr(conf["flickr_auth"]) if conf["fetch"] else None
|
||||||
|
immich = Immich(*loadimmich(conf["immich_auth"])) if conf["sync"] else None
|
||||||
|
except BackupError as e:
|
||||||
|
error(e)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
if conf["fetch"]:
|
||||||
|
fetched = fetchphase(conf, flickrkey)
|
||||||
|
failures.items.extend(fetched.items)
|
||||||
|
if conf["sync"]:
|
||||||
|
synced = syncphase(conf, immich)
|
||||||
|
failures.items.extend(synced.items)
|
||||||
|
except BackupError as e:
|
||||||
|
error(e)
|
||||||
|
summary(failures)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if failures or not QUIET:
|
||||||
|
summary(failures)
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
#
|
||||||
|
# Under cron a traceback is a mail nobody can act on, so nothing escapes
|
||||||
|
# here: a configuration problem exits 2, an interruption 130, and anything
|
||||||
|
# unforeseen is reported as a failed run.
|
||||||
|
#
|
||||||
|
def main(argv=None):
|
||||||
|
try:
|
||||||
|
conf = settings(parseargs(argv))
|
||||||
|
except BackupError as e:
|
||||||
|
error(e)
|
||||||
|
return 2
|
||||||
|
except OSError as e:
|
||||||
|
error("%s: %s" % (type(e).__name__, e))
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
return run(conf)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
warn("interrupted")
|
||||||
|
return 130
|
||||||
|
except Exception as e:
|
||||||
|
error("%s: %s" % (type(e).__name__, e))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
sys.exit(0)
|
sys.exit(main())
|
||||||
@@ -1364,3 +1364,92 @@ class SyncPhase(TestCase):
|
|||||||
self.assertEqual(len(failures), 1)
|
self.assertEqual(len(failures), 1)
|
||||||
reason = failures.items[0][2]
|
reason = failures.items[0][2]
|
||||||
self.assertTrue("albums" in reason or "HTTP 500" in reason)
|
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, "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())
|
||||||
Reference in new issue
Block a user