Skip to content

Commit 2306799

Browse files
committed
Fix 13 latent bugs found by a full-source audit
Metaclass (parser.py): - Copy a shared Argument() instance before inferring type/nargs/choices: one instance bound to several fields/classes was mutated in place, so `b: str = arg` silently required the int type inferred for `a: int`. - Reject arguments that would shadow methods/properties (parse_args, print_help, user-defined helpers): the metaclass used to replace them with `...` placeholders, silently destroying the parser API. - Reject a bare `serve: Serve` annotation (no instance): it silently became a required CLI argument with type=<Parser class>. - Inherit unannotated arguments, groups, and subparsers: previously a subclass lost every `tags = Argument(...)`, `db = DB()` or `serve = Serve()` declared on its base class. - Preserve inherited plain defaults: `name: str = "x"` used to become a *required* argument in every subclass because the processed class attribute had been replaced with an Ellipsis placeholder. - Wrap each element of Secret(nargs=...) lists in SecretString; only scalar secrets were masked, so repr(list) leaked the values. - Remove dead code in the Optional-unwrap branch. Config parsers (defaults.py, actions.py): - Make strict=False consistent: malformed INI/TOML files are now skipped like JSON ones instead of raising; strict=True still raises. - Alias INIDefaultsParser.BOOL_TRUE_VALUES to types.TEXT_TRUE_VALUES (the two identical sets could drift apart). - ConfigAction caches results via a None sentinel instead of truthiness, so an empty config is parsed once. Factory (factory.py): - EnumArgument(lowercase=True) now uses an explicit name map instead of str.upper() round-trips, fixing enums with non-uppercase member names; enum aliases (e.g. LogLevelEnum.WARN) remain accepted. Docs (pitfalls.md): - Unselected subcommand attributes raise AttributeError; the page claimed they "retain their default values". - Document that Group/subparser attributes are class-level singletons shared by all instances of a Parser class. tests/test_audit_fixes.py covers every fix; 21 of its 38 tests fail against the pre-fix code.
1 parent b4d7f01 commit 2306799

6 files changed

Lines changed: 779 additions & 43 deletions

File tree

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(

argclass/parser.py

Lines changed: 161 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,59 @@ def reserved_name_error(name: str) -> ArgumentDefinitionError:
9191
)
9292

9393

94+
def shadowed_member_error(name: str, member: Any) -> ArgumentDefinitionError:
95+
kind = "property" if isinstance(member, property) else "method"
96+
return ArgumentDefinitionError(
97+
f"Field {name!r} would shadow the {kind} {name!r} defined on a "
98+
f"base class or in the class body; argclass would silently "
99+
f"replace it and break the parser API.",
100+
field_name=name,
101+
hint="Rename the field, or pass the callable explicitly via "
102+
"Argument(default=...) if a callable default is intended.",
103+
)
104+
105+
106+
def _shadowed_api_member(
107+
key: str,
108+
attrs: Mapping[str, Any],
109+
bases: tuple[type, ...],
110+
) -> Any | None:
111+
"""Return the method/property that a member named ``key`` would
112+
clobber, or ``None`` when the name is safe to use.
113+
114+
Registering ``key`` as an argument replaces the class attribute
115+
(the metaclass stores ``...`` placeholders and ``parse_args``
116+
assigns parsed values), so a name that currently resolves to a
117+
callable or property — ``parse_args``, a user-defined helper —
118+
must be rejected instead of silently destroying the API.
119+
Inherited argclass members (arguments, groups, subparsers and
120+
their ``...`` placeholders) and plain data defaults remain
121+
legitimate redefinition targets.
122+
"""
123+
own = attrs.get(key)
124+
if (
125+
own is not None
126+
and not isinstance(own, (TypedArgument, AbstractGroup, AbstractParser))
127+
and (
128+
callable(own)
129+
or isinstance(own, (property, classmethod, staticmethod))
130+
)
131+
):
132+
return own
133+
for base in bases:
134+
if not hasattr(base, key):
135+
continue
136+
value = getattr(base, key)
137+
if value is None or value is Ellipsis:
138+
return None
139+
if isinstance(value, (TypedArgument, AbstractGroup, AbstractParser)):
140+
return None
141+
if callable(value) or isinstance(value, property):
142+
return value
143+
return None
144+
return None
145+
146+
94147
class Meta(ABCMeta):
95148
"""Metaclass for Parser and Group classes."""
96149

@@ -113,9 +166,31 @@ def __new__(
113166
# Annotations defined directly on this class (not inherited).
114167
own_annotations = own_annotation_keys(cls)
115168

116-
arguments = {}
117-
argument_groups = {}
118-
subparsers = {}
169+
# Seed the registries with inherited members so unannotated
170+
# arguments, groups, and subparsers declared on a base class
171+
# survive subclassing (annotated fields are re-collected below
172+
# from the merged annotations either way).
173+
arguments: dict[str, Any] = {}
174+
argument_groups: dict[str, Any] = {}
175+
subparsers: dict[str, Any] = {}
176+
for base in reversed(bases):
177+
arguments.update(getattr(base, "__arguments__", {}))
178+
argument_groups.update(getattr(base, "__argument_groups__", {}))
179+
subparsers.update(getattr(base, "__subparsers__", {}))
180+
181+
# Keys (re)defined by this class itself, as opposed to seeded.
182+
own_keys: set[str] = set()
183+
184+
def classify(key: str, value: Any, into: dict[str, Any]) -> None:
185+
# A redefinition may change category (e.g. an inherited
186+
# argument overridden by a group); drop the stale entry
187+
# from the other registries.
188+
for registry in (arguments, argument_groups, subparsers):
189+
if registry is not into:
190+
registry.pop(key, None)
191+
into[key] = value
192+
own_keys.add(key)
193+
119194
for key, kind in annotations.items():
120195
if key in RESERVED_ATTRIBUTES:
121196
# Inherited internal attributes are skipped; declaring one
@@ -126,13 +201,63 @@ def __new__(
126201
if key.startswith("_"):
127202
continue
128203

204+
# A purely inherited member (not re-annotated and not
205+
# re-assigned here) is already fully processed in the
206+
# seeded registries. Rebuilding it from the annotation
207+
# would lose state the base class attrs no longer carry:
208+
# plain defaults become ``...`` placeholders on the class,
209+
# so a re-build would turn ``name: str = "x"`` into a
210+
# required argument in every subclass.
211+
if (
212+
key not in own_annotations
213+
and key not in attrs
214+
and (
215+
key in arguments
216+
or key in argument_groups
217+
or key in subparsers
218+
)
219+
):
220+
continue
221+
129222
try:
130223
argument = deep_getattr(key, attrs, *bases)
131224
has_explicit_default = True
132225
except KeyError:
133226
argument = None
134227
has_explicit_default = False
135228

229+
shadowed = _shadowed_api_member(key, attrs, bases)
230+
if shadowed is not None:
231+
raise shadowed_member_error(key, shadowed)
232+
233+
# Subparsers are defined by instances, not annotations: a
234+
# bare ``serve: Serve`` (or ``Serve | None``) would
235+
# otherwise fall through and silently become a required
236+
# CLI argument with ``type=Serve``.
237+
parser_kind: type | None = None
238+
if isinstance(kind, type) and issubclass(kind, AbstractParser):
239+
parser_kind = kind
240+
else:
241+
try:
242+
_inner = unwrap_optional(kind)
243+
except ComplexTypeError:
244+
_inner = None
245+
if isinstance(_inner, type) and issubclass(
246+
_inner, AbstractParser
247+
):
248+
parser_kind = _inner
249+
if parser_kind is not None and not isinstance(
250+
argument, AbstractParser
251+
):
252+
raise ArgumentDefinitionError(
253+
f"Subparser field '{key}' is annotated with parser "
254+
f"class {parser_kind.__name__} but no instance is "
255+
f"assigned; an annotation alone cannot define a "
256+
f"subcommand.",
257+
field_name=key,
258+
hint=f"Use '{key} = {parser_kind.__name__}()'.",
259+
)
260+
136261
annotated_group_cls: type[AbstractGroup] | None = None
137262
if isinstance(kind, type) and issubclass(kind, AbstractGroup):
138263
annotated_group_cls = kind
@@ -292,12 +417,15 @@ def __new__(
292417

293418
if isinstance(argument, TypedArgument):
294419
if argument.type is None and argument.converter is None:
420+
# The same Argument() instance may be shared by
421+
# several fields or classes; mutate a private copy
422+
# so this field's inferred type/nargs/choices do
423+
# not leak into the other bindings.
424+
argument = argument.copy()
295425
# First try to unwrap optional
296426
optional_inner = unwrap_optional(kind)
297427
if optional_inner is not None:
298428
kind = optional_inner
299-
if argument.default is None:
300-
argument.default = None
301429

302430
# Handle bool type: set STORE_TRUE/STORE_FALSE action
303431
if kind is bool and argument.action == Actions.default():
@@ -339,9 +467,14 @@ def __new__(
339467
argument.converter = container_type
340468
else:
341469
argument.type = kind
342-
arguments[key] = argument
470+
classify(key, argument, arguments)
343471
elif isinstance(argument, AbstractGroup):
344-
argument_groups[key] = argument
472+
classify(key, argument, argument_groups)
473+
else:
474+
# Only Parser instances can reach this point: the
475+
# block above converts every non-argclass value into
476+
# a TypedArgument.
477+
classify(key, argument, subparsers)
345478

346479
for key, value in attrs.items():
347480
if key in RESERVED_ATTRIBUTES:
@@ -357,15 +490,22 @@ def __new__(
357490
continue
358491

359492
# Skip if already processed from annotations
360-
if key in arguments or key in argument_groups or key in subparsers:
493+
if key in own_keys:
361494
continue
362495

496+
if isinstance(
497+
value, (TypedArgument, AbstractGroup, AbstractParser)
498+
):
499+
shadowed = _shadowed_api_member(key, attrs, bases)
500+
if shadowed is not None:
501+
raise shadowed_member_error(key, shadowed)
502+
363503
if isinstance(value, TypedArgument):
364-
arguments[key] = value
504+
classify(key, value, arguments)
365505
elif isinstance(value, AbstractGroup):
366-
argument_groups[key] = value
506+
classify(key, value, argument_groups)
367507
elif isinstance(value, AbstractParser):
368-
subparsers[key] = value
508+
classify(key, value, subparsers)
369509

370510
setattr(cls, "__arguments__", MappingProxyType(arguments))
371511
setattr(cls, "__argument_groups__", MappingProxyType(argument_groups))
@@ -909,8 +1049,16 @@ def parse_args(
9091049
parsed_value = getattr(parsed_ns, key)
9101050

9111051
if argument is not None:
912-
if argument.secret and isinstance(parsed_value, str):
913-
parsed_value = SecretString(parsed_value)
1052+
if argument.secret:
1053+
if isinstance(parsed_value, str):
1054+
parsed_value = SecretString(parsed_value)
1055+
elif isinstance(parsed_value, (list, tuple)):
1056+
# nargs secrets: wrap each element so a
1057+
# repr of the list cannot leak the values.
1058+
parsed_value = type(parsed_value)(
1059+
SecretString(v) if isinstance(v, str) else v
1060+
for v in parsed_value
1061+
)
9141062
if argument.converter is not None:
9151063
if argument.nargs and parsed_value is None:
9161064
parsed_value = []

0 commit comments

Comments
 (0)