Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/publish-pypi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
15 changes: 15 additions & 0 deletions docs/schema-v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 5 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "argdump"
version = "0.1.2"
dynamic = ["version"]
description = "Serialize and deserialize argparse parsers"
readme = "README.md"
license = "MIT"
Expand All @@ -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",
Expand Down Expand Up @@ -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"]
packages = ["src/argdump"]
2 changes: 1 addition & 1 deletion src/argdump/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
TypeInfo,
)

__version__ = "0.1.2"
__version__ = "0.1.3"

__all__ = [
# Primary API
Expand Down
109 changes: 90 additions & 19 deletions src/argdump/_deserializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]]:
Expand Down Expand Up @@ -97,18 +116,18 @@ 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"] = {
name: _convert_parser_info(sub) if isinstance(sub, dict) else sub
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:
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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."""
Expand All @@ -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)

Expand Down
20 changes: 18 additions & 2 deletions src/argdump/_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 ---
Expand Down
11 changes: 6 additions & 5 deletions src/argdump/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
Empty file added src/argdump/py.typed
Empty file.
Loading
Loading