From ed68c57e30cddca48d9e008e2bd1fe5bb530ef10 Mon Sep 17 00:00:00 2001 From: "Josef M. Gallmetzer" <64498081+galjos@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:20:21 +0200 Subject: [PATCH] fix: resolve abbreviated root options in lazy CLI dispatch The lazy command detector only recognised the exact spellings of the root options, while argparse also accepts any unambiguous prefix. With an abbreviated option such as --logging or --log-fi the detector missed the sub-command, so its sub-parser was registered without arguments and the run either aborted with an unrecognized-arguments error or crashed with an AttributeError. The detector now resolves unambiguous long-option prefixes the same way argparse does. --- PQAnalysis/cli/main.py | 36 +++++++++++++++++++++++++++------- tests/cli/test_main.py | 44 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/PQAnalysis/cli/main.py b/PQAnalysis/cli/main.py index d5b85f67..4d9dd40c 100644 --- a/PQAnalysis/cli/main.py +++ b/PQAnalysis/cli/main.py @@ -103,20 +103,46 @@ +_ROOT_OPTIONS = ( + "--help", + "--version", + "--progress", + "--logging-level", + "--log-file", +) + + + +def _match_root_option(option: str) -> str | None: + """Resolve a possibly abbreviated long root option like argparse does.""" + matches = [ + root_option for root_option in _ROOT_OPTIONS + if root_option.startswith(option) + ] + return matches[0] if len(matches) == 1 else None + + + def _detect_command(arguments: list[str]) -> str | None: """Scan root options to find the first positional CLI command.""" index = 0 while index < len(arguments): argument = arguments[index] - if argument in {"-h", "--help", "--version"}: + option = argument.partition("=")[0] + matched = ( + _match_root_option(option) + if option.startswith("--") else None + ) + + if argument == "-h" or matched in {"--help", "--version"}: return None - if argument == "--logging-level": + if matched == "--logging-level" and option == argument: index += 2 continue - if argument == "--log-file": + if matched == "--log-file" and option == argument: index += 1 if ( index < len(arguments) and @@ -125,10 +151,6 @@ def _detect_command(arguments: list[str]) -> str | None: index += 1 continue - if argument.startswith(("--logging-level=", "--log-file=")): - index += 1 - continue - if argument.startswith("-"): index += 1 continue diff --git a/tests/cli/test_main.py b/tests/cli/test_main.py index 224b4acd..c96bf864 100644 --- a/tests/cli/test_main.py +++ b/tests/cli/test_main.py @@ -22,9 +22,17 @@ (["--logging-level=INFO", "vacf", "input.in"], "vacf"), (["--log-file", "off", "convert", "rdf.dat"], "convert"), (["--log-file=run.log", "vibrations", "input.in"], "vibrations"), + (["--logging", "DEBUG", "rdf", "input.in"], "rdf"), + (["--logging-lev", "DEBUG", "msd", "input.in"], "msd"), + (["--logging=DEBUG", "vacf", "input.in"], "vacf"), + (["--log-fi", "off", "convert", "rdf.dat"], "convert"), + (["--log-fi=run.log", "vibrations", "input.in"], "vibrations"), + (["--pro", "check_momentum", "traj.vel"], "check_momentum"), (["--help"], None), (["--help", "rdf"], None), + (["--he", "rdf"], None), (["--version", "msd"], None), + (["--vers", "msd"], None), ([], None), ], ) @@ -33,6 +41,42 @@ def test_detect_command(arguments, expected): +def test_main_dispatches_with_abbreviated_root_option(monkeypatch): + main_module = import_module("PQAnalysis.cli.main") + received = {} + + class _FakeCLI: + + @classmethod + def add_arguments(cls, parser): + parser.add_argument("input_file") + + @classmethod + def run(cls, args): + received["input_file"] = args.input_file + + monkeypatch.setattr( + main_module, "_load_command", lambda command: _FakeCLI + ) + monkeypatch.setattr( + sys, + "argv", + ["pqanalysis", "--logging", "DEBUG", "--log-fi", "off", "rdf", "in"], + ) + monkeypatch.setattr(argument_parser, "print_header", lambda: None) + + root_logger = argument_parser.logging.getLogger() + original_level = root_logger.level + + try: + main_module.main() + finally: + root_logger.setLevel(original_level) + + assert received == {"input_file": "in"} + + + @pytest.mark.parametrize( ("command", "module_name", "class_name"), [