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
8 changes: 5 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,11 @@ Naming follows the attribute path:
- JSON/TOML: nested objects/tables

`Group(prefix=...)` overrides only the CLI/env segment for that group;
config section names always follow the attribute path. Reusing the
same Group instance in two places raises `ArgclassError` (instantiate
a separate Group per attribute).
config section names always follow the attribute path. Class-body
Group/subparser instances are prototypes: every Parser instance works
on its own copies, so one Group instance may be bound to several
attributes (each binding is an independent copy). Only cyclic group
trees raise `ArgclassError`.

### Subcommands

Expand Down
5 changes: 4 additions & 1 deletion argclass/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,10 @@ def __call__(
values: str | Any | None,
option_string: str | None = None,
) -> None:
if not self._result:
# ``None`` is the not-yet-parsed sentinel; an empty dict is a
# valid cached result (truthiness would re-parse it and, on a
# second invocation, try Path() on the mapping in values).
if self._result is None:
filenames: Sequence[Path] = list(self.search_paths)
if values:
filenames = [Path(values)] + list(filenames)
Expand Down
40 changes: 23 additions & 17 deletions argclass/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from collections.abc import Iterable, Mapping

from .exceptions import ConfigurationError
from .types import TEXT_TRUE_VALUES
from .utils import own_section_items

try:
Expand Down Expand Up @@ -166,29 +167,31 @@ class INIDefaultsParser(AbstractDefaultsParser):
when requested via get_value() with appropriate ValueKind.
"""

# Values considered as True for boolean conversion
BOOL_TRUE_VALUES = frozenset(
(
"true",
"yes",
"1",
"on",
"enable",
"enabled",
"t",
"y",
)
)
# Values considered as True for boolean conversion. Aliased to the
# shared constant so this set and ``argclass.parse_bool`` cannot
# drift apart; kept as a class attribute so subclasses can still
# override it.
BOOL_TRUE_VALUES = TEXT_TRUE_VALUES

def parse(self) -> Mapping[str, Any]:
parser = configparser.ConfigParser(
allow_no_value=True,
strict=self._strict,
)

filenames = self._filter_readable_paths()
loaded = parser.read(filenames)
self._loaded_files = tuple(Path(f) for f in loaded)
loaded: list[Path] = []
for path in self._filter_readable_paths():
# Mirror the JSON/TOML parsers: in non-strict mode a
# malformed file is skipped (best effort), in strict mode
# the error propagates.
try:
read_ok = parser.read([path])
except (configparser.Error, OSError):
if self._strict:
raise
continue
loaded.extend(Path(f) for f in read_ok)
self._loaded_files = tuple(loaded)

result: dict[str, Any] = dict(
parser.items(parser.default_section, raw=True),
Expand Down Expand Up @@ -275,7 +278,10 @@ def parse(self) -> Mapping[str, Any]:
if isinstance(data, dict):
result.update(data)
loaded_files.append(path)
except OSError:
# TOMLDecodeError subclasses ValueError in both stdlib
# tomllib and the tomli fallback, so a malformed file is
# skipped in non-strict mode just like JSON/INI.
except (OSError, ValueError):
if self._strict:
raise

Expand Down
30 changes: 21 additions & 9 deletions argclass/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -537,27 +537,36 @@ def EnumArgument(
Returns:
TypedArgument instance.
"""
# Map accepted spellings to members explicitly instead of relying
# on ``str.upper()`` round-trips, which break for enums whose
# member names are not fully uppercase (``Fast`` -> ``FAST`` ->
# KeyError). ``__members__`` includes aliases (e.g. ``WARN`` for
# ``WARNING``) so they keep working as inputs; ``choices`` lists
# canonical names only.
members = enum_class.__members__
if lowercase:
name_map = {n.lower(): m for n, m in members.items()}
choices = tuple(e.name.lower() for e in enum_class)
else:
name_map = dict(members)
choices = tuple(e.name for e in enum_class)

if default is not None:
if isinstance(default, enum_class):
pass # Valid enum member
elif isinstance(default, str):
# Validate string is a valid enum member name
check_name = default.upper() if lowercase else default
valid_names = tuple(e.name for e in enum_class)
if check_name not in valid_names:
member = members.get(default)
if member is None and lowercase:
member = name_map.get(default.lower())
if member is None:
raise EnumValueError(
f"default {default!r} is not a valid {enum_class.__name__} "
f"member",
enum_class=enum_class,
valid_values=valid_names,
valid_values=tuple(e.name for e in enum_class),
)
# Convert string default to enum member
default = enum_class[check_name]
default = member
else:
raise EnumValueError(
f"default must be {enum_class.__name__} member or string, "
Expand All @@ -574,9 +583,12 @@ def converter(x: Any) -> Any:
# Handle existing enum members
if isinstance(x, enum_class):
return x.value if use_value else x
# Convert string to enum
name = x.upper() if lowercase else x
member = enum_class[name]
# Convert string to enum member via the explicit name map
member = name_map.get(x.lower() if lowercase else x)
if member is None:
# Same failure mode as enum_class[x]; parse_args wraps
# converter errors into TypeConversionError.
raise KeyError(x)
return member.value if use_value else member

return TypedArgument(
Expand Down
Loading
Loading