"""Utility functions for argclass."""
import ast
import configparser
import os
import types
from pathlib import Path
from typing import (
Any,
Dict,
Literal,
Mapping,
Optional,
Tuple,
Type,
Union,
get_args,
get_origin,
get_type_hints,
)
from .exceptions import ComplexTypeError
from .types import (
CONTAINER_TYPES,
TEXT_TRUE_VALUES,
Actions,
Nargs,
NoneType,
UnionClass,
)
def read_ini_configs(
*paths: Union[str, Path],
**kwargs: Any,
) -> Tuple[Mapping[str, Any], Tuple[Path, ...]]:
"""Read configuration from INI files."""
kwargs.setdefault("allow_no_value", True)
kwargs.setdefault("strict", False)
parser = configparser.ConfigParser(**kwargs)
filenames = []
for path in paths:
path_obj = Path(path).expanduser().resolve()
# check the access first, because the parent
# directory may not be readable
if not os.access(path_obj, os.R_OK) or not path_obj.exists():
continue
filenames.append(path_obj)
config_paths = parser.read(filenames)
result: Dict[str, Union[str, Dict[str, str]]] = dict(
parser.items(parser.default_section, raw=True),
)
for section in parser.sections():
result[section] = own_section_items(parser, section)
return result, tuple(map(Path, config_paths))
def own_section_items(
parser: configparser.ConfigParser,
section: str,
) -> Dict[str, str]:
"""Return ``section``'s own keys, excluding cascaded ``[DEFAULT]``.
configparser's public API
(``parser.items(section, raw=True)`` / ``parser[section]``)
inherits keys from ``[DEFAULT]`` into every section. argclass
groups are independent argument namespaces — a top-level
``host = root`` must not leak into ``[inner].host`` when the
group's own default is ``None``.
There's no documented opt-out, so this helper isolates the
one place where we reach into ``parser._sections``. Keeps the
private-attribute risk to a single well-commented call site.
"""
own: Dict[str, str] = dict(
parser._sections[section], # type: ignore[attr-defined]
)
return own
def deep_getattr(name: str, attrs: Dict[str, Any], *bases: Type) -> Any:
"""Get attribute from attrs dict or base classes."""
if name in attrs:
return attrs[name]
for base in bases:
if hasattr(base, name):
return getattr(base, name)
raise KeyError(f"Key {name} was not declared")
def merge_annotations(
annotations: Dict[str, Any],
*bases: Type,
) -> Dict[str, Any]:
"""Merge annotations from base classes following MRO."""
result: Dict[str, Any] = {}
# Walk the full MRO to collect all inherited annotations
for base in bases:
for cls in reversed(base.__mro__):
result.update(getattr(cls, "__annotations__", {}))
result.update(annotations)
return result
def resolve_annotations(cls: type) -> Dict[str, Any]:
"""Resolve annotations for a class, handling stringified annotations.
Uses typing.get_type_hints() to resolve string annotations
(from ``from __future__ import annotations`` or PEP 649).
Falls back to merge_annotations() if resolution fails.
"""
try:
return get_type_hints(cls)
except Exception:
return merge_annotations(
getattr(cls, "__annotations__", {}),
*cls.__mro__[1:],
)
[docs]
def parse_bool(value: str) -> bool:
"""Parse a string to boolean."""
return value.lower() in TEXT_TRUE_VALUES
def nargs_is_list(argument: Any) -> bool:
"""Return True when argparse will treat ``argument`` as a sequence.
Shared between the parser (when applying env defaults) and the
config-emit code (when materialising env vars during a dump walk).
"""
nargs = argument.nargs
if nargs in (Nargs.ONE_OR_MORE, Nargs.ZERO_OR_MORE, "*", "+"):
return True
return isinstance(nargs, int) and nargs >= 1
def coerce_env_default(raw: Any, argument: Any) -> Any:
"""Coerce a raw env-var string into the value argparse would bind.
Mirrors the conversions ``Parser._add_argument`` applies after
reading ``os.getenv``: ``ast.literal_eval`` for sequence-typed
arguments, :func:`parse_bool` for ``store_true``/``store_false``
actions, and ``argument.type`` for the scalar fallback.
Single source of truth for both the parser's env binding and the
dump-time field walker; using it in both places keeps the two
paths from drifting apart.
"""
if not isinstance(raw, str):
return raw
if raw and nargs_is_list(argument):
try:
return list(
map(argument.type or str, ast.literal_eval(raw)),
)
except Exception:
return raw
if argument.action in (
Actions.STORE_TRUE,
Actions.STORE_FALSE,
"store_true",
"store_false",
):
return parse_bool(raw)
type_func = argument.type
if type_func is None:
return raw
try:
already_correct = isinstance(raw, type_func)
except TypeError:
already_correct = False
if already_correct:
return raw
try:
return type_func(raw)
except Exception:
return raw
def _is_union_type(typespec: Any) -> bool:
"""Check if typespec is a Union type (typing.Union or PEP 604)."""
if typespec.__class__ == UnionClass:
return True
# PEP 604: float | None creates types.UnionType in Python 3.10+
return hasattr(types, "UnionType") and isinstance(typespec, types.UnionType)
def unwrap_optional(typespec: Any) -> Optional[Any]:
"""Unwrap Optional[T] to T, return None if not Optional."""
if not _is_union_type(typespec):
return None
union_args = [a for a in typespec.__args__ if a is not NoneType]
if len(union_args) != 1:
raise ComplexTypeError(
"Union types with multiple non-None members "
"cannot be used directly",
typespec=typespec,
hint="Use argclass.Argument() with an explicit "
"converter or type function",
)
return union_args[0]
def _is_container_type(typespec: Any) -> bool:
"""Check if typespec is a container type like list[str], List[str], etc."""
origin = get_origin(typespec)
if origin is None:
return False
# Handle typing.List, typing.Set, etc. and built-in list[str], set[int]
return origin in CONTAINER_TYPES
def _unwrap_container_type(typespec: Any) -> Optional[Tuple[type, type]]:
"""
Unwrap a container type and return (container_origin, element_type).
For list[str] or List[str], returns (list, str).
For set[int] or Set[int], returns (set, int).
Returns None if not a container type.
"""
if not _is_container_type(typespec):
return None
origin = get_origin(typespec)
args = get_args(typespec)
# We know origin is not None because _is_container_type checks this
assert origin is not None
if not args:
# list without type parameter - use str as default
return (origin, str)
# For tuple, we handle specially - just use the first type for now
# (full tuple handling would need nargs=N for Tuple[int, str, bool])
element_type = args[0]
# Handle nested optionals like list[str | None]
optional_inner = unwrap_optional(element_type)
if optional_inner is not None:
element_type = optional_inner
return (origin, element_type)
def unwrap_literal(typespec: Any) -> Optional[Tuple[type, Tuple[Any, ...]]]:
"""
Unwrap Literal[value1, value2, ...] and return (value_type, choices).
For Literal["a", "b", "c"], returns (str, ("a", "b", "c")).
For Literal[1, 2, 3], returns (int, (1, 2, 3)).
Returns None if not a Literal type.
"""
origin = get_origin(typespec)
if origin is not Literal:
return None
args = get_args(typespec)
if not args:
return None
# Determine the common type of all literal values
# All values should have the same type for argparse choices to work
value_type = type(args[0])
return value_type, args