Skip to content

Commit e0404fc

Browse files
authored
Merge pull request #47 from mosquito/reuse-allow
Fix 13 audit findings; per-instance group/subparser copies
2 parents b4d7f01 + c043000 commit e0404fc

9 files changed

Lines changed: 1055 additions & 92 deletions

File tree

CLAUDE.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,9 +97,11 @@ Naming follows the attribute path:
9797
- JSON/TOML: nested objects/tables
9898

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

104106
### Subcommands
105107

argclass/actions.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ def __call__(
7070
values: str | Any | None,
7171
option_string: str | None = None,
7272
) -> None:
73-
if not self._result:
73+
# ``None`` is the not-yet-parsed sentinel; an empty dict is a
74+
# valid cached result (truthiness would re-parse it and, on a
75+
# second invocation, try Path() on the mapping in values).
76+
if self._result is None:
7477
filenames: Sequence[Path] = list(self.search_paths)
7578
if values:
7679
filenames = [Path(values)] + list(filenames)

argclass/defaults.py

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from collections.abc import Iterable, Mapping
1212

1313
from .exceptions import ConfigurationError
14+
from .types import TEXT_TRUE_VALUES
1415
from .utils import own_section_items
1516

1617
try:
@@ -166,29 +167,31 @@ class INIDefaultsParser(AbstractDefaultsParser):
166167
when requested via get_value() with appropriate ValueKind.
167168
"""
168169

169-
# Values considered as True for boolean conversion
170-
BOOL_TRUE_VALUES = frozenset(
171-
(
172-
"true",
173-
"yes",
174-
"1",
175-
"on",
176-
"enable",
177-
"enabled",
178-
"t",
179-
"y",
180-
)
181-
)
170+
# Values considered as True for boolean conversion. Aliased to the
171+
# shared constant so this set and ``argclass.parse_bool`` cannot
172+
# drift apart; kept as a class attribute so subclasses can still
173+
# override it.
174+
BOOL_TRUE_VALUES = TEXT_TRUE_VALUES
182175

183176
def parse(self) -> Mapping[str, Any]:
184177
parser = configparser.ConfigParser(
185178
allow_no_value=True,
186179
strict=self._strict,
187180
)
188181

189-
filenames = self._filter_readable_paths()
190-
loaded = parser.read(filenames)
191-
self._loaded_files = tuple(Path(f) for f in loaded)
182+
loaded: list[Path] = []
183+
for path in self._filter_readable_paths():
184+
# Mirror the JSON/TOML parsers: in non-strict mode a
185+
# malformed file is skipped (best effort), in strict mode
186+
# the error propagates.
187+
try:
188+
read_ok = parser.read([path])
189+
except (configparser.Error, OSError):
190+
if self._strict:
191+
raise
192+
continue
193+
loaded.extend(Path(f) for f in read_ok)
194+
self._loaded_files = tuple(loaded)
192195

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

argclass/factory.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -537,27 +537,36 @@ def EnumArgument(
537537
Returns:
538538
TypedArgument instance.
539539
"""
540+
# Map accepted spellings to members explicitly instead of relying
541+
# on ``str.upper()`` round-trips, which break for enums whose
542+
# member names are not fully uppercase (``Fast`` -> ``FAST`` ->
543+
# KeyError). ``__members__`` includes aliases (e.g. ``WARN`` for
544+
# ``WARNING``) so they keep working as inputs; ``choices`` lists
545+
# canonical names only.
546+
members = enum_class.__members__
540547
if lowercase:
548+
name_map = {n.lower(): m for n, m in members.items()}
541549
choices = tuple(e.name.lower() for e in enum_class)
542550
else:
551+
name_map = dict(members)
543552
choices = tuple(e.name for e in enum_class)
544553

545554
if default is not None:
546555
if isinstance(default, enum_class):
547556
pass # Valid enum member
548557
elif isinstance(default, str):
549558
# Validate string is a valid enum member name
550-
check_name = default.upper() if lowercase else default
551-
valid_names = tuple(e.name for e in enum_class)
552-
if check_name not in valid_names:
559+
member = members.get(default)
560+
if member is None and lowercase:
561+
member = name_map.get(default.lower())
562+
if member is None:
553563
raise EnumValueError(
554564
f"default {default!r} is not a valid {enum_class.__name__} "
555565
f"member",
556566
enum_class=enum_class,
557-
valid_values=valid_names,
567+
valid_values=tuple(e.name for e in enum_class),
558568
)
559-
# Convert string default to enum member
560-
default = enum_class[check_name]
569+
default = member
561570
else:
562571
raise EnumValueError(
563572
f"default must be {enum_class.__name__} member or string, "
@@ -574,9 +583,12 @@ def converter(x: Any) -> Any:
574583
# Handle existing enum members
575584
if isinstance(x, enum_class):
576585
return x.value if use_value else x
577-
# Convert string to enum
578-
name = x.upper() if lowercase else x
579-
member = enum_class[name]
586+
# Convert string to enum member via the explicit name map
587+
member = name_map.get(x.lower() if lowercase else x)
588+
if member is None:
589+
# Same failure mode as enum_class[x]; parse_args wraps
590+
# converter errors into TypeConversionError.
591+
raise KeyError(x)
580592
return member.value if use_value else member
581593

582594
return TypedArgument(

0 commit comments

Comments
 (0)