diff --git a/rgl-photo-backup b/rgl-photo-backup index c2cbea4..db6b509 100755 --- a/rgl-photo-backup +++ b/rgl-photo-backup @@ -1278,5 +1278,74 @@ def syncphase(conf, immich): 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__": - sys.exit(0) + sys.exit(main()) diff --git a/test_rgl_photo_backup.py b/test_rgl_photo_backup.py index eb3f676..dfe4fd8 100644 --- a/test_rgl_photo_backup.py +++ b/test_rgl_photo_backup.py @@ -1364,3 +1364,92 @@ class SyncPhase(TestCase): 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, "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())