|
1 | 1 | """Core parser classes for argclass.""" |
2 | 2 |
|
| 3 | +import copy |
3 | 4 | import os |
4 | 5 | import weakref |
5 | 6 | from abc import ABCMeta |
@@ -568,6 +569,52 @@ def __init__( |
568 | 569 | self._defaults: Mapping[str, Any] = defaults or {} |
569 | 570 |
|
570 | 571 |
|
| 572 | +def _group_reuse_error(path: str) -> ArgclassError: |
| 573 | + return ArgclassError( |
| 574 | + "Group instance is referenced more than once in the parser " |
| 575 | + f"tree (current path: {path}): the group tree must not " |
| 576 | + "contain cycles.", |
| 577 | + hint="Remove the self-reference; a group cannot (directly or " |
| 578 | + "indirectly) contain itself.", |
| 579 | + ) |
| 580 | + |
| 581 | + |
| 582 | +def _clone_group_tree( |
| 583 | + group: Group, |
| 584 | + ancestors: set[int], |
| 585 | + attr_path: tuple[str, ...], |
| 586 | +) -> Group: |
| 587 | + """Copy ``group`` and its nested groups for one parser instance. |
| 588 | +
|
| 589 | + The group instances registered by the metaclass live on the class |
| 590 | + body and would otherwise be shared by every instance of the Parser |
| 591 | + class — a second ``parse_args()`` on another instance would |
| 592 | + overwrite the first one's values. |
| 593 | +
|
| 594 | + Because every occurrence gets its own copy, assigning one Group |
| 595 | + instance to several attributes is fine — each binding becomes an |
| 596 | + independent copy. ``ancestors`` holds the ids along the current |
| 597 | + path only, so the recursion rejects exactly the unclonable case: |
| 598 | + a group that (directly or indirectly) contains itself. |
| 599 | + """ |
| 600 | + if id(group) in ancestors: |
| 601 | + raise _group_reuse_error(".".join(attr_path)) |
| 602 | + ancestors.add(id(group)) |
| 603 | + try: |
| 604 | + clone = copy.copy(group) |
| 605 | + nested: dict[str, Group] = {} |
| 606 | + for child_name, child in group.__argument_groups__.items(): |
| 607 | + child_clone = _clone_group_tree( |
| 608 | + child, ancestors, attr_path + (child_name,) |
| 609 | + ) |
| 610 | + setattr(clone, child_name, child_clone) |
| 611 | + nested[child_name] = child_clone |
| 612 | + clone.__argument_groups__ = MappingProxyType(nested) |
| 613 | + return clone |
| 614 | + finally: |
| 615 | + ancestors.discard(id(group)) |
| 616 | + |
| 617 | + |
571 | 618 | ParserType = TypeVar("ParserType", bound="Parser") |
572 | 619 |
|
573 | 620 |
|
@@ -737,6 +784,43 @@ def __init__( |
737 | 784 | self._parser_kwargs = kwargs |
738 | 785 | self._used_env_vars: set[str] = set() |
739 | 786 | self._used_secret_env_vars: set[str] = set() |
| 787 | + self._materialize_members() |
| 788 | + |
| 789 | + def _materialize_members(self) -> None: |
| 790 | + """Give this parser instance its own copies of the declared |
| 791 | + groups and subparsers. |
| 792 | +
|
| 793 | + The instances collected by the metaclass live on the class |
| 794 | + body and are shared by every instance of this Parser class; |
| 795 | + parsing through them would let a second instance overwrite |
| 796 | + the first one's values. Copying them per instance |
| 797 | + (recursively, for nested groups and subparser trees) makes |
| 798 | + every Parser instance own its parsed state, while the |
| 799 | + class-level prototypes stay pristine. |
| 800 | + """ |
| 801 | + cls = type(self) |
| 802 | + |
| 803 | + ancestors: set[int] = set() |
| 804 | + groups: dict[str, Group] = {} |
| 805 | + for name, group in cls.__argument_groups__.items(): |
| 806 | + clone = _clone_group_tree(group, ancestors, (name,)) |
| 807 | + setattr(self, name, clone) |
| 808 | + groups[name] = clone |
| 809 | + self.__argument_groups__ = MappingProxyType(groups) |
| 810 | + |
| 811 | + subparsers: dict[str, Any] = {} |
| 812 | + for name, subparser in cls.__subparsers__.items(): |
| 813 | + sub_clone = copy.copy(subparser) |
| 814 | + # The shallow copy still references the prototype's own |
| 815 | + # member clones and env-var bookkeeping; rebuild them. |
| 816 | + sub_clone._materialize_members() |
| 817 | + sub_clone._used_env_vars = set(subparser._used_env_vars) |
| 818 | + sub_clone._used_secret_env_vars = set( |
| 819 | + subparser._used_secret_env_vars |
| 820 | + ) |
| 821 | + setattr(self, name, sub_clone) |
| 822 | + subparsers[name] = sub_clone |
| 823 | + self.__subparsers__ = MappingProxyType(subparsers) |
740 | 824 |
|
741 | 825 | @property |
742 | 826 | def current_subparser(self) -> "AbstractParser | None": |
@@ -880,15 +964,11 @@ def _fill_group( |
880 | 964 | destinations: DestinationsType, |
881 | 965 | visited: set[int], |
882 | 966 | ) -> None: |
| 967 | + # Backstop: instance trees are validated at construction time |
| 968 | + # by ``_clone_group_tree``; this catches cycles wired into the |
| 969 | + # per-instance mappings after ``__init__``. |
883 | 970 | if id(group) in visited: |
884 | | - raise ArgclassError( |
885 | | - "Group instance is referenced more than once in the parser " |
886 | | - f"tree (current path: {'.'.join(attr_path)}). Reusing a " |
887 | | - "single Group instance across attributes is not supported " |
888 | | - "because state would be shared between locations.", |
889 | | - hint="Instantiate a new Group for each attribute, or " |
890 | | - "subclass Group to define a dedicated type.", |
891 | | - ) |
| 971 | + raise _group_reuse_error(".".join(attr_path)) |
892 | 972 | visited.add(id(group)) |
893 | 973 |
|
894 | 974 | section = ".".join(attr_path) |
|
0 commit comments