"""Store metaclass and argument classes for argclass."""
import collections
from argparse import Action
from pathlib import Path
from typing import Any, Dict, Iterable, Iterator, Optional, Tuple, Type, Union
from ._actions import (
ConfigAction,
INIConfigAction,
JSONConfigAction,
TOMLConfigAction,
)
from ._types import Actions, ConverterType, Nargs
from ._utils import merge_annotations
class StoreMeta(type):
"""Metaclass that collects and merges annotations from base classes."""
def __new__(
mcs,
name: str,
bases: Tuple[Type["StoreMeta"], ...],
attrs: Dict[str, Any],
) -> "StoreMeta":
# Create the class first to ensure annotations are available
# Python 3.14+ (PEP 649) defers annotation evaluation
cls = super().__new__(mcs, name, bases, attrs)
annotations = merge_annotations(
getattr(cls, "__annotations__", {}),
*bases,
)
setattr(cls, "__annotations__", annotations)
setattr(
cls,
"_fields",
tuple(
filter(
lambda x: not x.startswith("_"),
annotations.keys(),
),
),
)
return cls
[docs]
class Store(metaclass=StoreMeta):
"""Base class for typed storage with field validation."""
_default_value = object()
_fields: Tuple[str, ...]
def __new__(cls, **kwargs: Any) -> "Store":
obj = super().__new__(cls)
type_map: Dict[str, Tuple[Type, Any]] = {}
# Use cls.__annotations__ instead of obj.__annotations__ to avoid
# triggering __getattr__ before _values is initialized (Python 3.14+)
for key, value in cls.__annotations__.items():
if key.startswith("_"):
continue
type_map[key] = (value, getattr(cls, key, cls._default_value))
for key, (value_type, default) in type_map.items():
if default is cls._default_value and key not in kwargs:
raise TypeError(f"required argument {key!r} must be passed")
value = kwargs.get(key, default)
setattr(obj, key, value)
return obj
[docs]
def copy(self, **overrides: Any) -> Any:
kwargs = self.as_dict()
for key, value in overrides.items():
kwargs[key] = value
return self.__class__(**kwargs)
[docs]
def as_dict(self) -> Dict[str, Any]:
# noinspection PyProtectedMember
return {field: getattr(self, field) for field in self._fields}
def __repr__(self) -> str:
items = sorted(self.as_dict().items())
values = ", ".join([f"{k!s}={v!r}" for k, v in items])
return f"<{self.__class__.__name__}: {values}>"
class ArgumentBase(Store):
"""Base class for argument definitions."""
def __init__(self, **kwargs: Any):
self._values = collections.OrderedDict()
# noinspection PyUnresolvedReferences
for key in self._fields:
self._values[key] = kwargs.get(key, getattr(self.__class__, key))
def __getattr__(self, item: str) -> Any:
try:
return self._values[item]
except KeyError as e:
raise AttributeError from e
@property
def is_positional(self) -> bool:
for alias in self.aliases:
if alias.startswith("-"):
return False
return True
def get_kwargs(self) -> Dict[str, Any]:
nargs = self.nargs
if isinstance(nargs, Nargs):
nargs = nargs.value
action = self.action
kwargs = self.as_dict()
if action in (Actions.STORE_TRUE, Actions.STORE_FALSE, Actions.COUNT):
kwargs.pop("type", None)
if isinstance(action, Actions):
action = action.value
kwargs.pop("aliases", None)
kwargs.pop("converter", None)
kwargs.pop("env_var", None)
kwargs.pop("secret", None)
kwargs.update(action=action, nargs=nargs)
return {k: v for k, v in kwargs.items() if v is not None}
[docs]
class TypedArgument(ArgumentBase):
"""Argument with type information."""
action: Union[Actions, Type[Action]] = Actions.default()
aliases: Iterable[str] = frozenset()
choices: Optional[Iterable[str]] = None
const: Optional[Any] = None
converter: Optional[ConverterType] = None
default: Optional[Any] = None
secret: bool = False
env_var: Optional[str] = None
help: Optional[str] = None
metavar: Optional[str] = None
nargs: Optional[Union[int, Nargs]] = None
required: Optional[bool] = None
type: Any = None
@property
def is_nargs(self) -> bool:
if self.nargs is None:
return False
if isinstance(self.nargs, int):
return self.nargs > 1
return True
[docs]
class ConfigArgument(TypedArgument):
"""Argument for configuration file loading."""
search_paths: Optional[Iterable[Union[Path, str]]] = None
action: Type[ConfigAction]
[docs]
class INIConfig(ConfigArgument):
"""Parse INI file and set results as a value."""
action: Type[ConfigAction] = INIConfigAction
[docs]
class JSONConfig(ConfigArgument):
"""Parse JSON file and set results as a value."""
action: Type[ConfigAction] = JSONConfigAction
[docs]
class TOMLConfig(ConfigArgument):
"""Parse TOML file and set results as a value.
Uses stdlib tomllib (Python 3.11+) or tomli package as fallback.
"""
action: Type[ConfigAction] = TOMLConfigAction
class AbstractGroup:
"""Abstract base for argument groups."""
pass
class AbstractParser:
"""Abstract base for parsers."""
__parent__: Union["AbstractParser", None] = None
current_subparsers = () # type: Tuple["AbstractParser", ...]
def _get_chain(self) -> Iterator["AbstractParser"]:
yield self
if self.__parent__ is None:
return
yield from self.__parent__._get_chain()
def __call__(self) -> Any:
raise NotImplementedError()