Skip to content

Commit 5ec21a5

Browse files
committed
Improved error handling in register_argparse_argument_parameter().
1 parent 187b8ec commit 5ec21a5

2 files changed

Lines changed: 63 additions & 14 deletions

File tree

cmd2/argparse_custom.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,9 @@ def set_parser_prog(parser: argparse.ArgumentParser, prog: str) -> None:
356356
# Allow developers to add custom action attributes
357357
############################################################################################################
358358

359-
CUSTOM_ACTION_ATTRIBS: set[str] = set()
359+
# This set should only be edited by calling register_argparse_argument_parameter().
360+
# Do not manually add or remove items.
361+
_CUSTOM_ACTION_ATTRIBS: set[str] = set()
360362

361363

362364
def register_argparse_argument_parameter(
@@ -369,19 +371,35 @@ def register_argparse_argument_parameter(
369371
:param param_name: Name of the parameter. This must be a valid Python identifier.
370372
:param validator: Optional function to validate and/or transform the parameter value.
371373
It accepts the Action instance and the value as arguments.
374+
:raises ValueError: if the parameter name is invalid
375+
:raises KeyError: if the new parameter collides with any existing attributes
372376
"""
373377
if not param_name.isidentifier():
374-
raise KeyError(f"Invalid parameter name '{param_name}' - cannot be used as a python identifier")
378+
raise ValueError(f"Invalid parameter name '{param_name}': must be a valid Python identifier")
375379

380+
if param_name in _CUSTOM_ACTION_ATTRIBS:
381+
raise KeyError(f"Custom parameter '{param_name}' is already registered")
382+
383+
# Ensure we don't hijack standard argparse.Action attributes or existing methods
384+
if hasattr(argparse.Action, param_name):
385+
raise KeyError(f"'{param_name}' conflicts with an existing attribute on argparse.Action")
386+
387+
# Check if accessors already exist (e.g., from manual patching or previous registration)
388+
getter_name = f'get_{param_name}'
389+
setter_name = f'set_{param_name}'
390+
if hasattr(argparse.Action, getter_name) or hasattr(argparse.Action, setter_name):
391+
raise KeyError(f"Accessor methods for '{param_name}' already exist on argparse.Action")
392+
393+
# Check for the prefixed internal attribute name collision (e.g., _cmd2_<param_name>)
376394
attr_name = constants.cmd2_attr_name(param_name)
377-
if param_name in CUSTOM_ACTION_ATTRIBS or hasattr(argparse.Action, attr_name):
378-
raise KeyError(f"Custom parameter '{param_name}' already exists")
395+
if hasattr(argparse.Action, attr_name):
396+
raise KeyError(f"The internal attribute '{attr_name}' already exists on argparse.Action")
379397

380398
def _action_get_custom_parameter(self: argparse.Action) -> Any:
381399
"""Get the custom attribute of an argparse Action."""
382400
return getattr(self, attr_name, None)
383401

384-
setattr(argparse.Action, f'get_{param_name}', _action_get_custom_parameter)
402+
setattr(argparse.Action, getter_name, _action_get_custom_parameter)
385403

386404
def _action_set_custom_parameter(self: argparse.Action, value: Any) -> None:
387405
"""Set the custom attribute of an argparse Action."""
@@ -390,9 +408,9 @@ def _action_set_custom_parameter(self: argparse.Action, value: Any) -> None:
390408

391409
setattr(self, attr_name, value)
392410

393-
setattr(argparse.Action, f'set_{param_name}', _action_set_custom_parameter)
411+
setattr(argparse.Action, setter_name, _action_set_custom_parameter)
394412

395-
CUSTOM_ACTION_ATTRIBS.add(param_name)
413+
_CUSTOM_ACTION_ATTRIBS.add(param_name)
396414

397415

398416
def _validate_completion_callable(self: argparse.Action, value: Any) -> Any:
@@ -526,7 +544,7 @@ def _ActionsContainer_add_argument( # noqa: N802
526544
kwargs['nargs'] = nargs_adjusted
527545

528546
# Extract registered custom keyword arguments
529-
custom_attribs = {keyword: value for keyword, value in kwargs.items() if keyword in CUSTOM_ACTION_ATTRIBS}
547+
custom_attribs = {keyword: value for keyword, value in kwargs.items() if keyword in _CUSTOM_ACTION_ATTRIBS}
530548
for keyword in custom_attribs:
531549
del kwargs[keyword]
532550

tests/test_argparse_custom.py

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -299,17 +299,48 @@ def test_cmd2_attribute_wrapper() -> None:
299299

300300

301301
def test_register_argparse_argument_parameter() -> None:
302-
register_argparse_argument_parameter("test")
303-
assert "test" in argparse_custom.CUSTOM_ACTION_ATTRIBS
302+
# Test successful registration
303+
param_name = "test_unique_param"
304+
register_argparse_argument_parameter(param_name)
304305

305-
expected_err = "already exists"
306-
with pytest.raises(KeyError, match=expected_err):
307-
register_argparse_argument_parameter("test")
306+
assert param_name in argparse_custom._CUSTOM_ACTION_ATTRIBS
307+
assert hasattr(argparse.Action, f'get_{param_name}')
308+
assert hasattr(argparse.Action, f'set_{param_name}')
308309

309-
expected_err = "Invalid parameter name"
310+
# Test duplicate registration
311+
expected_err = "already registered"
310312
with pytest.raises(KeyError, match=expected_err):
313+
register_argparse_argument_parameter(param_name)
314+
315+
# Test invalid identifier
316+
expected_err = "must be a valid Python identifier"
317+
with pytest.raises(ValueError, match=expected_err):
311318
register_argparse_argument_parameter("invalid name")
312319

320+
# Test collision with standard argparse.Action attribute
321+
expected_err = "conflicts with an existing attribute on argparse.Action"
322+
with pytest.raises(KeyError, match=expected_err):
323+
register_argparse_argument_parameter("format_usage")
324+
325+
# Test collision with existing accessor methods
326+
try:
327+
argparse.Action.get_colliding_param = lambda self: None
328+
expected_err = "Accessor methods for 'colliding_param' already exist on argparse.Action"
329+
with pytest.raises(KeyError, match=expected_err):
330+
register_argparse_argument_parameter("colliding_param")
331+
finally:
332+
delattr(argparse.Action, 'get_colliding_param')
333+
334+
# Test collision with internal attribute
335+
try:
336+
attr_name = constants.cmd2_attr_name("internal_collision")
337+
setattr(argparse.Action, attr_name, None)
338+
expected_err = f"The internal attribute '{attr_name}' already exists on argparse.Action"
339+
with pytest.raises(KeyError, match=expected_err):
340+
register_argparse_argument_parameter("internal_collision")
341+
finally:
342+
delattr(argparse.Action, attr_name)
343+
313344

314345
def test_parser_attachment() -> None:
315346
# Attach a parser as a subcommand

0 commit comments

Comments
 (0)