diff --git a/src/mirror/__main__.py b/src/mirror/__main__.py index 529ad2a..ed59738 100644 --- a/src/mirror/__main__.py +++ b/src/mirror/__main__.py @@ -24,6 +24,20 @@ def _positive_int(raw: str) -> int: return value +def with_serve(argv: list[str]) -> list[str]: + """``mirror``, ``mirror --host x`` etc. all mean ``serve``. + + A bare informational flag does not: prepending would route ``--version`` into + the ``serve`` subparser, which has no such flag, and the command would die + with "unrecognized arguments" instead of printing a version. Extracted from + main() so the rule can be asserted without starting anything. + """ + informational = ("-h", "--help", "--version") + if not argv or (argv[0].startswith("-") and argv[0] not in informational): + return ["serve", *argv] + return list(argv) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="mirror", @@ -64,6 +78,12 @@ def build_parser() -> argparse.ArgumentParser: choices=["critical", "error", "warning", "info", "debug", "trace"], help="Uvicorn log level (default: %(default)s).", ) + # Repeated from the top level on purpose: `mirror serve --version` is as + # reasonable a thing to type as `mirror --version`, and argparse does not + # inherit flags from the parent parser. + serve.add_argument( + "--version", action="version", version=f"mirror {__version__}", help="Print the version." + ) serve.set_defaults(func=cmd_serve) return parser @@ -127,10 +147,7 @@ def cmd_serve(args: argparse.Namespace) -> int: def main(argv: list[str] | None = None) -> int: parser = build_parser() - argv = list(sys.argv[1:] if argv is None else argv) - if not argv or argv[0].startswith("-"): - # `mirror`, `mirror --host x` etc. all mean `serve`. - argv = ["serve", *argv] + argv = with_serve(list(sys.argv[1:] if argv is None else argv)) args = parser.parse_args(argv) return int(args.func(args)) diff --git a/tests/test_config.py b/tests/test_config.py index 0e7f7cb..a258c04 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -114,6 +114,36 @@ class TestCli: "fd00::/64", ] + @pytest.mark.parametrize( + ("given", "expected"), + [ + ([], ["serve"]), + (["--port", "9999"], ["serve", "--port", "9999"]), + (["--allow-cidr", "10.2.0.0/22"], ["serve", "--allow-cidr", "10.2.0.0/22"]), + (["serve"], ["serve"]), + (["serve", "--version"], ["serve", "--version"]), + (["--version"], ["--version"]), + (["--help"], ["--help"]), + (["-h"], ["-h"]), + ], + ) + def test_only_informational_flags_escape_the_implicit_serve(self, given, expected): + assert entry.with_serve(given) == expected + + @pytest.mark.parametrize("argv", [["--version"], ["serve", "--version"]]) + def test_version_prints_and_exits_cleanly(self, argv, capsys): + with pytest.raises(SystemExit) as caught: + entry.main(argv) + assert caught.value.code == 0 + assert f"mirror {entry.__version__}" in capsys.readouterr().out + + def test_help_lists_the_subcommand_it_is_not_being_rewritten_into(self, capsys): + with pytest.raises(SystemExit): + entry.main(["--help"]) + out = capsys.readouterr().out + assert "usage: mirror" in out + assert "serve" in out + def test_a_bad_allow_cidr_exits_with_a_message_not_a_traceback(self, capsys): # Config is parsed before anything binds or imports, so this cannot stall # on a stray DEBUGPY in the operator's shell.