diff --git a/python/online/fxreader/pr34/commands_typed/argparse.py b/python/online/fxreader/pr34/commands_typed/argparse.py index ee09011..c8ffcf6 100644 --- a/python/online/fxreader/pr34/commands_typed/argparse.py +++ b/python/online/fxreader/pr34/commands_typed/argparse.py @@ -11,7 +11,20 @@ from typing import ( def parse_args( parser: argparse.ArgumentParser, args: Optional[list[str]] = None, + stop_at: Optional[list[str]] = None, ) -> tuple[argparse.Namespace, list[str]]: + """Parse args with support for early termination. + + stop_at: list of tokens that act like '--' when encountered. + When any token from stop_at is found in args, parsing stops + at that position: everything from that token onward (inclusive) + goes into the returned remainder list, and the parser only + sees args before it. + + This allows e.g. a main parser with --log-level to stop at + a subcommand name like 'cve', so that 'cve -h' is not consumed + by the main parser. + """ if args is None: args = sys.argv[1:] @@ -20,9 +33,12 @@ def parse_args( for i, o in enumerate(args): if o == '--': argv.extend(args[i + 1 :]) - del args[i:] + break + if stop_at is not None and o in stop_at: + argv.extend(args[i:]) + del args[i:] break return parser.parse_args(args), argv diff --git a/python/online/fxreader/pr34/commands_typed/tests/test_argparse.py b/python/online/fxreader/pr34/commands_typed/tests/test_argparse.py new file mode 100644 index 0000000..e787f40 --- /dev/null +++ b/python/online/fxreader/pr34/commands_typed/tests/test_argparse.py @@ -0,0 +1,85 @@ +import argparse +import unittest + +from ..argparse import parse_args + + +class TestParseArgsDefault(unittest.TestCase): + def test_basic(self) -> None: + parser = argparse.ArgumentParser() + parser.add_argument('--foo', default='bar') + opts, rest = parse_args(parser, ['--foo', 'baz']) + self.assertEqual(opts.foo, 'baz') + self.assertEqual(rest, []) + + def test_double_dash(self) -> None: + parser = argparse.ArgumentParser() + parser.add_argument('--foo', default='bar') + opts, rest = parse_args(parser, ['--foo', 'baz', '--', 'extra1', 'extra2']) + self.assertEqual(opts.foo, 'baz') + self.assertEqual(rest, ['extra1', 'extra2']) + + def test_no_args(self) -> None: + parser = argparse.ArgumentParser() + parser.add_argument('--x', default='1') + opts, rest = parse_args(parser, []) + self.assertEqual(opts.x, '1') + self.assertEqual(rest, []) + + +class TestStopAt(unittest.TestCase): + def test_stop_at_command(self) -> None: + parser = argparse.ArgumentParser() + parser.add_argument('--log-level', default='INFO') + opts, rest = parse_args( + parser, ['--log-level', 'DEBUG', 'cve', '-h'], stop_at=['cve', 'compile'] + ) + self.assertEqual(opts.log_level, 'DEBUG') + self.assertEqual(rest, ['cve', '-h']) + + def test_stop_at_preserves_all_after(self) -> None: + parser = argparse.ArgumentParser() + parser.add_argument('--verbose', action='store_true') + opts, rest = parse_args( + parser, + ['--verbose', 'download', '--progress', '-j', '4'], + stop_at=['download', 'compile'], + ) + self.assertTrue(opts.verbose) + self.assertEqual(rest, ['download', '--progress', '-j', '4']) + + def test_stop_at_first_arg(self) -> None: + parser = argparse.ArgumentParser() + parser.add_argument('--x', default='1') + opts, rest = parse_args(parser, ['cve', 'sync', '--cache-dir', '/tmp'], stop_at=['cve']) + self.assertEqual(opts.x, '1') + self.assertEqual(rest, ['cve', 'sync', '--cache-dir', '/tmp']) + + def test_stop_at_no_match(self) -> None: + parser = argparse.ArgumentParser() + parser.add_argument('--x', default='1') + opts, rest = parse_args(parser, ['--x', '2'], stop_at=['cve']) + self.assertEqual(opts.x, '2') + self.assertEqual(rest, []) + + def test_stop_at_with_double_dash(self) -> None: + """Double dash takes priority over stop_at.""" + parser = argparse.ArgumentParser() + parser.add_argument('--x', default='1') + opts, rest = parse_args(parser, ['--x', '2', '--', 'cve', '-h'], stop_at=['cve']) + self.assertEqual(opts.x, '2') + self.assertEqual(rest, ['cve', '-h']) + + def test_stop_at_help_not_consumed_by_main(self) -> None: + """The key use case: -h after command goes to subcommand, not main.""" + parser = argparse.ArgumentParser() + parser.add_argument('--log-level', default='INFO') + opts, rest = parse_args(parser, ['cve', '-h'], stop_at=['cve']) + self.assertEqual(rest, ['cve', '-h']) + + def test_stop_at_empty_stop_list(self) -> None: + parser = argparse.ArgumentParser() + parser.add_argument('--x', default='1') + opts, rest = parse_args(parser, ['--x', '2'], stop_at=[]) + self.assertEqual(opts.x, '2') + self.assertEqual(rest, []) diff --git a/python/pyproject.common.toml b/python/pyproject.common.toml index a291ae8..e423622 100644 --- a/python/pyproject.common.toml +++ b/python/pyproject.common.toml @@ -9,7 +9,7 @@ classifiers = [ ] name = 'online.fxreader.pr34' -version = '0.1.5.68' +version = '0.1.5.69' dynamic = [] dependencies = [ @@ -86,6 +86,7 @@ modules = [ search_paths = ['.'] test_names = [ 'online.fxreader.pr34.commands_typed.tests', + 'online.fxreader.pr34.commands_typed.tests.test_argparse', ] discovery_paths = ['.']