diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 7415260..f563273 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -21,6 +21,13 @@ jobs: with: python-version: "3.12" + - name: Verify tag matches __version__ + run: | + tag="${GITHUB_REF_NAME#v}" + ver="$(grep -oP '__version__\s*=\s*"\K[^"]+' src/argdump/__init__.py)" + echo "tag=$tag package_version=$ver" + test "$tag" = "$ver" || { echo "::error::tag $tag != __version__ $ver"; exit 1; } + - name: Install build tools run: pip install build diff --git a/README.md b/README.md index 37151b4..02730e0 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ Serialize and deserialize Python [argparse](https://docs.python.org/3/library/ar ```bash pip install argdump +# or +uv add argdump ``` ## Usage @@ -39,7 +41,7 @@ A JSON Schema for validating serialized output is available at [`docs/schema-v1. ## Features - All standard actions (store, append, count, etc.) -- Subparsers with aliases +- Subparsers with aliases and per-command help text - Mutual exclusion and argument groups - Type converters (builtins, FileType, importable functions) - Choices, defaults, metavar, help text @@ -66,6 +68,10 @@ argdump.load(argdump.dump(parser), strict=False) # type becomes None argdump.load(argdump.dump(parser), strict=True) # raises UnresolvableTypeError ``` +A mutually exclusive group nested inside a named argument group is reconstructed +as a top-level mutex group: exclusivity is still enforced, but in `--help` its +members appear under the default options heading rather than the named group. + ## License MIT \ No newline at end of file diff --git a/docs/schema-v1.json b/docs/schema-v1.json index 3304a3a..a05d7ac 100644 --- a/docs/schema-v1.json +++ b/docs/schema-v1.json @@ -421,6 +421,21 @@ ], "description": "Aliases that can be used instead of the canonical subcommand name. For example, 'co' as an alias for 'checkout'." }, + "subparsers_help": { + "oneOf": [ + { + "type": "object", + "description": "Mapping of subparser name to its help text", + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Help text for each subcommand, shown in the parent parser's list of commands. Keyed by canonical subcommand name." + }, "custom_action_class": { "type": [ "string", diff --git a/pyproject.toml b/pyproject.toml index 0460126..5a60a65 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "argdump" -version = "0.1.2" +dynamic = ["version"] description = "Serialize and deserialize argparse parsers" readme = "README.md" license = "MIT" @@ -12,7 +12,6 @@ dependencies = [] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", @@ -56,5 +55,8 @@ select = ["E", "F", "I", "B", "SIM"] requires = ["hatchling"] build-backend = "hatchling.build" +[tool.hatch.version] +path = "src/argdump/__init__.py" + [tool.hatch.build.targets.wheel] -packages = ["src/argdump"] \ No newline at end of file +packages = ["src/argdump"] diff --git a/src/argdump/__init__.py b/src/argdump/__init__.py index fad847f..f2ee326 100644 --- a/src/argdump/__init__.py +++ b/src/argdump/__init__.py @@ -34,7 +34,7 @@ TypeInfo, ) -__version__ = "0.1.2" +__version__ = "0.1.3" __all__ = [ # Primary API diff --git a/src/argdump/_deserializer.py b/src/argdump/_deserializer.py index 0c36c5e..c1774c9 100644 --- a/src/argdump/_deserializer.py +++ b/src/argdump/_deserializer.py @@ -5,6 +5,7 @@ import argparse import json import sys +from dataclasses import fields from typing import Any, Dict, List, Optional, Set, Type, Union from ._types import UnresolvableTypeError, resolve_type @@ -66,10 +67,28 @@ ActionType.APPEND_CONST, } +# Titles argparse uses for its two auto-created groups, across supported versions. +# "optional arguments" was renamed to "options" in Python 3.10. +_DEFAULT_GROUP_TITLES: Set[Optional[str]] = { + "positional arguments", + "optional arguments", + "options", +} + # --- Type conversion helpers --- +def _known_fields(data: Dict[str, Any], cls: type) -> Dict[str, Any]: + """Drop keys that are not fields of ``cls``. + + Keeps deserialization forward-compatible: a document produced by a newer + argdump (with additional fields) still loads instead of raising TypeError. + """ + valid = {f.name for f in fields(cls)} + return {k: v for k, v in data.items() if k in valid} + + def _action_type_to_argparse( action_type: Union[ActionType, str], ) -> Union[str, Type[argparse.Action]]: @@ -97,10 +116,10 @@ def _convert_action_info(data: Dict[str, Any]) -> ActionInfo: data["action_type"] = ActionType.from_string(data["action_type"]) if data.get("type_info") and isinstance(data["type_info"], dict): - data["type_info"] = TypeInfo(**data["type_info"]) + data["type_info"] = TypeInfo(**_known_fields(data["type_info"], TypeInfo)) if data.get("file_type_info") and isinstance(data["file_type_info"], dict): - data["file_type_info"] = FileTypeInfo(**data["file_type_info"]) + data["file_type_info"] = FileTypeInfo(**_known_fields(data["file_type_info"], FileTypeInfo)) if data.get("subparsers"): data["subparsers"] = { @@ -108,7 +127,7 @@ def _convert_action_info(data: Dict[str, Any]) -> ActionInfo: for name, sub in data["subparsers"].items() } - return ActionInfo(**data) + return ActionInfo(**_known_fields(data, ActionInfo)) def _convert_parser_info(data: Dict[str, Any]) -> ParserInfo: @@ -124,12 +143,16 @@ def _convert_parser_info(data: Dict[str, Any]) -> ParserInfo: groups: List[Any] = data.pop("argument_groups", []) mutex_groups: List[Any] = data.pop("mutually_exclusive_groups", []) - info = ParserInfo(**data) + info = ParserInfo(**_known_fields(data, ParserInfo)) info.actions = [_convert_action_info(a) if isinstance(a, dict) else a for a in actions] - info.argument_groups = [ArgumentGroup(**g) if isinstance(g, dict) else g for g in groups] + info.argument_groups = [ + ArgumentGroup(**_known_fields(g, ArgumentGroup)) if isinstance(g, dict) else g + for g in groups + ] info.mutually_exclusive_groups = [ - MutualExclusionGroup(**g) if isinstance(g, dict) else g for g in mutex_groups + MutualExclusionGroup(**_known_fields(g, MutualExclusionGroup)) if isinstance(g, dict) else g + for g in mutex_groups ] return info @@ -224,9 +247,17 @@ def _add_help_kwarg(kwargs: Dict[str, Any], action_info: ActionInfo) -> None: def _add_metavar_kwarg(kwargs: Dict[str, Any], action_info: ActionInfo) -> None: - """Add metavar to kwargs if present.""" - if action_info.metavar is not None: - kwargs["metavar"] = action_info.metavar + """Add metavar to kwargs if present. + + A multi-value metavar is a tuple in argparse but round-trips through JSON as a + list; restore the tuple so help formatting matches the original. + """ + metavar = action_info.metavar + if metavar is None: + return + if isinstance(metavar, list): + metavar = tuple(metavar) + kwargs["metavar"] = metavar def _add_version_kwarg(kwargs: Dict[str, Any], action_info: ActionInfo) -> None: @@ -265,9 +296,8 @@ def _build_parser_kwargs(info: ParserInfo) -> Dict[str, Any]: if info.formatter_class and info.formatter_class in _FORMATTER_CLASSES: kwargs["formatter_class"] = _FORMATTER_CLASSES[info.formatter_class] - # allow_abbrev (Python 3.5+) - if hasattr(argparse.ArgumentParser, "allow_abbrev"): - kwargs["allow_abbrev"] = info.allow_abbrev + # allow_abbrev (Python 3.5+; always present on the versions we support) + kwargs["allow_abbrev"] = info.allow_abbrev # argument_default if info.argument_default is not None: @@ -321,8 +351,9 @@ def _add_subparsers(parser: argparse.ArgumentParser, action_info: ActionInfo, st if hasattr(subparsers_action, "required"): subparsers_action.required = action_info.subparsers_required - # Get aliases mapping + # Get aliases and per-command help mappings aliases_map = action_info.subparsers_aliases or {} + help_map = action_info.subparsers_help or {} for name, sub_info in action_info.subparsers.items(): sub_parser = deserialize_parser(sub_info, strict=strict) @@ -338,15 +369,23 @@ def _add_subparsers(parser: argparse.ArgumentParser, action_info: ActionInfo, st if name in aliases_map: add_parser_kwargs["aliases"] = aliases_map[name] + # Restore the command's help text (shown in the parent's subcommand list) + if name in help_map: + add_parser_kwargs["help"] = help_map[name] + subparsers_action.add_parser(name, **add_parser_kwargs) def _add_regular_action( - target: Union[argparse.ArgumentParser, argparse._MutuallyExclusiveGroup], + target: Union[ + argparse.ArgumentParser, + argparse._MutuallyExclusiveGroup, + argparse._ArgumentGroup, + ], action_info: ActionInfo, strict: bool, ) -> Optional[argparse.Action]: - """Add a regular (non-special) action to parser or mutex group.""" + """Add a regular (non-special) action to a parser, mutex group, or argument group.""" kwargs = _build_action_kwargs(action_info, strict) if action_info.is_optional: @@ -379,13 +418,42 @@ def deserialize_parser( mutex_membership = _build_mutex_membership(info.mutually_exclusive_groups) mutex_groups = _create_mutex_groups(parser, info.mutually_exclusive_groups) + # Recreate custom argument groups (display-only) and map their members + group_membership = _create_argument_groups(parser, info.argument_groups) + # Add all actions for action_info in info.actions: - _add_action(parser, action_info, mutex_membership, mutex_groups, strict) + _add_action(parser, action_info, mutex_membership, mutex_groups, group_membership, strict) return parser +def _create_argument_groups( + parser: argparse.ArgumentParser, + groups: List[ArgumentGroup], +) -> Dict[str, argparse._ArgumentGroup]: + """Recreate user-defined argument groups and return a dest -> group mapping. + + The two auto-created groups (positionals/optionals) are skipped so their + members fall through to the parser's default placement. Groups are display-only, + so this does not affect parsing behaviour. + """ + # Skip the auto-created positionals/optionals groups. Match against the loading + # parser's own default titles *and* the known historical names, since argparse + # renamed "optional arguments" -> "options" in 3.10: a document serialized on an + # older interpreter must not have that group recreated as a custom one here. + default_titles = {group.title for group in parser._action_groups} | _DEFAULT_GROUP_TITLES + + membership: Dict[str, argparse._ArgumentGroup] = {} + for group in groups: + if group.title in default_titles: + continue + group_obj = parser.add_argument_group(title=group.title, description=group.description) + for dest in group.actions: + membership.setdefault(dest, group_obj) + return membership + + def _build_mutex_membership( groups: List[MutualExclusionGroup], ) -> Dict[str, MutualExclusionGroup]: @@ -413,6 +481,7 @@ def _add_action( action_info: ActionInfo, mutex_membership: Dict[str, MutualExclusionGroup], mutex_groups: Dict[int, argparse._MutuallyExclusiveGroup], + group_membership: Dict[str, argparse._ArgumentGroup], strict: bool, ) -> None: """Add a single action to the parser.""" @@ -430,11 +499,13 @@ def _add_action( _add_subparsers(parser, action_info, strict) return - # Add to mutex group or directly to parser + # Route to (in priority order) mutex group, custom argument group, or the parser. + # Mutex enforcement takes precedence; argument groups only affect help layout. if action_info.dest in mutex_membership: group = mutex_membership[action_info.dest] - mutex_group = mutex_groups[id(group)] - _add_regular_action(mutex_group, action_info, strict) + _add_regular_action(mutex_groups[id(group)], action_info, strict) + elif action_info.dest in group_membership: + _add_regular_action(group_membership[action_info.dest], action_info, strict) else: _add_regular_action(parser, action_info, strict) diff --git a/src/argdump/_serializer.py b/src/argdump/_serializer.py index 1f44a54..1111502 100644 --- a/src/argdump/_serializer.py +++ b/src/argdump/_serializer.py @@ -147,9 +147,17 @@ def _add_subparsers_info( parser_to_names[parser_id] = [] parser_to_names[parser_id].append(name) + # Per-subcommand help text lives on the pseudo-actions, keyed by canonical name + help_by_name: Dict[str, str] = { + choice.dest: choice.help + for choice in getattr(action, "_choices_actions", []) + if getattr(choice, "help", None) is not None + } + # Track which parsers we've already serialized serialized_parsers: Dict[int, str] = {} # parser id -> canonical name - info.subparsers_aliases = {} + aliases_map: Dict[str, List[str]] = {} + help_map: Dict[str, str] = {} for name, subparser in parser_map.items(): parser_id = id(subparser) @@ -166,7 +174,15 @@ def _add_subparsers_info( all_names = parser_to_names[parser_id] aliases = [n for n in all_names if n != name] if aliases: - info.subparsers_aliases[name] = aliases + aliases_map[name] = aliases + + # Record the subcommand's help text (shown in the parent's command list) + if name in help_by_name: + help_map[name] = help_by_name[name] + + # Only attach when non-empty so defaults stay omitted from the output + info.subparsers_aliases = aliases_map or None + info.subparsers_help = help_map or None # --- Parser serialization --- diff --git a/src/argdump/models.py b/src/argdump/models.py index e2971a0..2314d67 100644 --- a/src/argdump/models.py +++ b/src/argdump/models.py @@ -80,6 +80,7 @@ class ActionInfo: subparsers_dest: Optional[str] = None subparsers_required: bool = False subparsers_aliases: Optional[Dict[str, List[str]]] = None # name -> aliases + subparsers_help: Optional[Dict[str, str]] = None # name -> help text custom_action_class: Optional[str] = None @property @@ -97,17 +98,17 @@ def is_positional(self) -> bool: class MutualExclusionGroup: """Mutually exclusive argument group.""" - required: bool - actions: List[str] + required: bool = False + actions: List[str] = field(default_factory=list) @dataclass class ArgumentGroup: """Argument group for help organization.""" - title: Optional[str] - description: Optional[str] - actions: List[str] + title: Optional[str] = None + description: Optional[str] = None + actions: List[str] = field(default_factory=list) @dataclass diff --git a/src/argdump/py.typed b/src/argdump/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py index 64a7c6b..67d7448 100644 --- a/tests/test_deserialization.py +++ b/tests/test_deserialization.py @@ -164,6 +164,18 @@ def test_subparser_aliases(self): assert args.command == "co" assert args.repo == "myrepo" + def test_subparser_help_round_trips(self): + parser = argparse.ArgumentParser(prog="cli") + sub = parser.add_subparsers(dest="cmd") + sub.add_parser("run", help="run the task") + sub.add_parser("build", help="build the project") + + restored = argdump.load(argdump.dump(parser)) + + sp_action = next(a for a in restored._actions if isinstance(a, argparse._SubParsersAction)) + help_by_name = {c.dest: c.help for c in sp_action._choices_actions} + assert help_by_name == {"run": "run the task", "build": "build the project"} + class TestParserSettings: """Test parser configuration reconstruction.""" @@ -192,6 +204,135 @@ def test_exit_on_error(self): restored = argdump.load(argdump.dump(parser)) assert restored.exit_on_error is False + def test_allow_abbrev_false_round_trips(self): + parser = argparse.ArgumentParser(allow_abbrev=False) + parser.add_argument("--verbose", action="store_true") + restored = argdump.load(argdump.dump(parser)) + + assert restored.allow_abbrev is False + # With abbreviation disabled, a prefix must not match the full option + with pytest.raises(SystemExit): + restored.parse_args(["--verb"]) + + def test_allow_abbrev_true_round_trips(self): + parser = argparse.ArgumentParser(allow_abbrev=True) + parser.add_argument("--verbose", action="store_true") + restored = argdump.load(argdump.dump(parser)) + + assert restored.allow_abbrev is True + args = restored.parse_args(["--verb"]) + assert args.verbose is True + + +class TestArgumentGroupReconstruction: + """Test that custom argument groups are recreated on load.""" + + def test_named_group_recreated(self): + parser = argparse.ArgumentParser(prog="g") + group = parser.add_argument_group("Advanced", "advanced options") + group.add_argument("--tune") + group.add_argument("--boost") + parser.add_argument("--normal") + + restored = argdump.load(argdump.dump(parser)) + + advanced = next((g for g in restored._action_groups if g.title == "Advanced"), None) + assert advanced is not None + assert advanced.description == "advanced options" + assert {a.dest for a in advanced._group_actions} == {"tune", "boost"} + + def test_group_members_still_parse(self): + parser = argparse.ArgumentParser() + group = parser.add_argument_group("Group") + group.add_argument("--tune") + parser.add_argument("--normal") + + restored = argdump.load(argdump.dump(parser)) + args = restored.parse_args(["--tune", "x", "--normal", "y"]) + assert args.tune == "x" + assert args.normal == "y" + + def test_no_duplicate_default_groups(self): + parser = argparse.ArgumentParser() + parser.add_argument("pos") + parser.add_argument("--opt") + + restored = argdump.load(argdump.dump(parser)) + titles = [g.title for g in restored._action_groups] + # The two default groups must not be duplicated by reconstruction + for title in titles: + assert titles.count(title) == 1 + + def test_metavar_tuple_round_trips(self): + parser = argparse.ArgumentParser() + parser.add_argument("--coord", nargs=2, metavar=("X", "Y")) + + restored = argdump.load(argdump.dump(parser)) + action = next(a for a in restored._actions if a.dest == "coord") + assert action.metavar == ("X", "Y") + assert isinstance(action.metavar, tuple) + + def test_legacy_default_group_title_not_recreated(self): + """A doc serialized on Python <=3.9 names the optionals group + 'optional arguments'; loading on 3.10+ must not recreate it as a + custom group (argparse renamed it to 'options').""" + data = { + "prog": "legacy", + "actions": [ + {"option_strings": ["-h", "--help"], "dest": "help", "action_type": "help"}, + {"option_strings": ["--foo"], "dest": "foo", "action_type": "store"}, + ], + "argument_groups": [ + {"title": "positional arguments", "actions": []}, + {"title": "optional arguments", "actions": ["help", "foo"]}, + ], + } + restored = argdump.load(data) + # The legacy-named group must not be recreated as a custom group: only the + # two argparse defaults should exist (their exact titles are version-specific). + assert len(restored._action_groups) == 2 + titles = [g.title for g in restored._action_groups] + assert len(titles) == len(set(titles)) # no duplicate group + + +class TestForwardCompatibility: + """Loading tolerates fields added by newer producers.""" + + def test_unknown_parser_field_ignored(self): + data = { + "$schema": "https://niwrap.dev/argdump/schema-v1.json", + "prog": "future", + "future_parser_field": "surprise", + "actions": [ + { + "option_strings": ["--x"], + "dest": "x", + "action_type": "store", + "brand_new_action_field": 123, + } + ], + } + restored = argdump.load(data) + assert restored.prog == "future" + args = restored.parse_args(["--x", "1"]) + assert args.x == "1" + + def test_unknown_nested_field_ignored(self): + data = { + "prog": "p", + "actions": [ + { + "option_strings": ["--n"], + "dest": "n", + "action_type": "store", + "type_info": {"name": "int", "builtin": True, "future": "x"}, + } + ], + } + restored = argdump.load(data) + args = restored.parse_args(["--n", "7"]) + assert args.n == 7 + class TestIntegration: """Integration tests with realistic CLI patterns.""" diff --git a/tests/test_package.py b/tests/test_package.py index 0e37efa..f07932d 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -9,18 +9,15 @@ def test_version_defined(): assert isinstance(argdump.__version__, str) -def test_version_matches_pyproject(): - """Ensure argdump.__version__ matches pyproject.toml.""" - import re - from pathlib import Path - - pyproject_path = Path(__file__).parent.parent / "pyproject.toml" - content = pyproject_path.read_text() +def test_version_matches_metadata(): + """Ensure argdump.__version__ matches the installed package metadata. - match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) - assert match, "Could not find version in pyproject.toml" + The project version is dynamic (read from __init__.py by hatchling), so + __version__ is the single source of truth; this guards the wiring. + """ + from importlib.metadata import version - assert argdump.__version__ == match.group(1) + assert argdump.__version__ == version("argdump") def test_public_api(): @@ -29,3 +26,11 @@ def test_public_api(): actual = {name for name in dir(argdump) if not name.startswith("_")} assert expected <= actual + + +def test_py_typed_marker_present(): + """Package must ship a py.typed marker so downstream type checkers see hints.""" + from pathlib import Path + + marker = Path(argdump.__file__).parent / "py.typed" + assert marker.is_file() diff --git a/tests/test_serialization.py b/tests/test_serialization.py index 3c6c80b..5417111 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -314,6 +314,29 @@ def test_subparser_aliases(self): assert set(aliases.get("checkout", [])) == {"co", "ch"} assert aliases.get("commit", []) == ["ci"] + def test_subparser_help_captured(self): + parser = argparse.ArgumentParser(prog="cli") + subparsers = parser.add_subparsers(dest="command") + subparsers.add_parser("run", help="run the task") + subparsers.add_parser("quiet") # no help + + data = argdump.dump(parser) + subparsers_action = next(a for a in data["actions"] if a["action_type"] == "parsers") + + help_map = subparsers_action.get("subparsers_help", {}) + assert help_map == {"run": "run the task"} + + def test_subparser_help_omitted_when_absent(self): + parser = argparse.ArgumentParser(prog="cli") + subparsers = parser.add_subparsers(dest="command") + subparsers.add_parser("run") + + data = argdump.dump(parser) + subparsers_action = next(a for a in data["actions"] if a["action_type"] == "parsers") + + # No help anywhere -> field omitted entirely + assert "subparsers_help" not in subparsers_action + class TestSpecialValues: """Test special value serialization.""" diff --git a/uv.lock b/uv.lock index 615811b..356bf3f 100644 --- a/uv.lock +++ b/uv.lock @@ -8,7 +8,6 @@ resolution-markers = [ [[package]] name = "argdump" -version = "0.1.2" source = { editable = "." } [package.dev-dependencies]