# GONet_Wizard/commands/smart_parser.py
"""
Smart argparse Wrapper for Classified Parse Errors
==================================================
This module defines a custom :class:`argparse.ArgumentParser` subclass that
replaces argparse's default ``SystemExit``-based error handling with structured,
typed exceptions.
The parser classifies parse failures into broad categories (e.g. missing
required arguments vs. unknown arguments) and preserves contextual information
such as the original ``argv`` and the inferred command token sequence. This
enables higher-level CLI logic to decide how to handle parse failures, including
routing specific cases to GUI-backed workflows.
Constants
---------
:data:`_CURRENT_ARGV`
Process-local copy of the current argument vector, used as a fallback for
subparsers that are not explicitly initialized with an ``argv`` reference.
Functions
---------
:func:`.set_current_argv`
Store the current argument vector for later retrieval during parse errors.
:func:`._guess_cmd_tokens`
Extract a best-effort command token sequence from an argument vector.
Classes
-------
:class:`.SmartArgumentParser`
Subclass of :class:`argparse.ArgumentParser` that raises
:class:`~GONet_Wizard.commands.argparse_errors.CliParseError` instead of
exiting the process.
"""
from __future__ import annotations
import argparse
from typing import Optional, Sequence
from GONet_Wizard.commands.argparse_errors import CliParseError, ParseErrorKind
_CURRENT_ARGV: list[str] = []
[docs]
def set_current_argv(argv: Sequence[str]) -> None:
"""
Store the current argument vector for parse-time error handling.
This function records a process-local copy of ``argv`` so that subparsers
created by argparse (which do not receive custom constructor arguments) can
still access the full invocation context when reporting parse errors.
Parameters
----------
argv : :class:`~typing.Sequence` of :class:`str`
Argument vector representing the current CLI invocation (typically
``sys.argv[1:]``).
Returns
-------
None
This function does not return a value.
Raises
------
None
"""
global _CURRENT_ARGV
_CURRENT_ARGV = list(argv)
def _guess_cmd_tokens(argv: Sequence[str]) -> list[str]:
"""
Extract a command token sequence from an argument vector.
Tokens are collected from the start of ``argv`` until an option-like token
(beginning with ``"-"``) is encountered. The result represents a best-effort
approximation of the intended command path.
Parameters
----------
argv : :class:`~typing.Sequence` of :class:`str`
Argument vector to inspect.
Returns
-------
list of str
Ordered list of command tokens (e.g., ``["show"]`` or
``["connect", "snap"]``).
Raises
------
None
"""
toks: list[str] = []
for t in argv:
if t.startswith("-"):
break
toks.append(t)
return toks
def _looks_like_negative_numeric_token(token: str) -> bool:
"""Return True when ``token`` looks like a negative numeric value.
``argparse`` normally treats strings beginning with ``-`` as option-like
tokens. Values such as ``-90,180`` are therefore ambiguous when passed as
the value for an option like ``--angles``. This helper identifies the
numeric-looking cases that should be interpreted as values rather than as
option names.
"""
if not token.startswith("-") or token.startswith("--"):
return False
body = token[1:]
return bool(body) and (body[0].isdigit() or body[0] == ".")
def _action_consumes_single_value(action: argparse.Action) -> bool:
"""Return True when an argparse action expects a single option value."""
nargs = getattr(action, "nargs", None)
return nargs is None or nargs == "?" or nargs == 1
def _collect_option_actions(parser: argparse.ArgumentParser) -> dict[str, argparse.Action]:
"""Collect option-string to action mappings from a parser tree."""
option_actions: dict[str, argparse.Action] = {}
for action in parser._actions:
for option_string in getattr(action, "option_strings", []):
option_actions[option_string] = action
if isinstance(action, argparse._SubParsersAction):
for subparser in action.choices.values():
option_actions.update(_collect_option_actions(subparser))
return option_actions
[docs]
def normalize_negative_option_values(
parser: argparse.ArgumentParser,
argv: Sequence[str],
) -> list[str]:
"""Normalize negative numeric option values before argparse sees them.
``argparse`` accepts negative numeric values for options in many simple
cases, but values containing punctuation, such as ``-90,180``, can be
mistaken for an option token. Rewriting ``--angles -90,180`` as
``--angles=-90,180`` removes the ambiguity while preserving the user's
intended value.
"""
option_actions = _collect_option_actions(parser)
normalized: list[str] = []
i = 0
raw = list(argv)
while i < len(raw):
token = raw[i]
if token.startswith("--") and "=" not in token:
action = option_actions.get(token)
next_token = raw[i + 1] if i + 1 < len(raw) else None
if (
action is not None
and _action_consumes_single_value(action)
and next_token is not None
and _looks_like_negative_numeric_token(next_token)
):
normalized.append(f"{token}={next_token}")
i += 2
continue
normalized.append(token)
i += 1
return normalized
[docs]
class SmartArgumentParser(argparse.ArgumentParser):
"""
Argument parser that raises structured exceptions on parse failure.
This class overrides argparse's default error and exit behavior to raise
:class:`~GONet_Wizard.commands.argparse_errors.CliParseError` instead of
calling ``sys.exit``. Each error is classified into a
:class:`~GONet_Wizard.commands.argparse_errors.ParseErrorKind` and includes
contextual information about the original invocation.
Attributes
----------
_argv : :class:`~typing.Optional` of :class:`~typing.Sequence` of :class:`str`
Argument vector explicitly associated with this parser instance, if
provided at construction time.
"""
def __init__(self, *args, argv: Optional[Sequence[str]] = None, **kwargs):
"""
Initialize the smart argument parser.
Parameters
----------
*args
Positional arguments forwarded to
:class:`argparse.ArgumentParser`.
argv : :class:`~typing.Optional` of :class:`~typing.Sequence` of
:class:`str`, optional
Argument vector to associate with this parser instance for error
reporting.
**kwargs
Keyword arguments forwarded to
:class:`argparse.ArgumentParser`.
Returns
-------
None
"""
super().__init__(*args, **kwargs)
self._argv = list(argv) if argv is not None else None
def _effective_argv(self) -> list[str]:
"""
Return the argument vector to use for error reporting.
If the parser instance was initialized with an explicit ``argv``, that
value is used. Otherwise, the process-local fallback set via
:func:`.set_current_argv` is returned.
Returns
-------
list of str
Argument vector associated with the current parse context.
Raises
------
None
"""
return list(self._argv) if self._argv is not None else list(_CURRENT_ARGV)
[docs]
def parse_args(self, args=None, namespace=None):
"""Parse arguments after normalizing ambiguous negative option values.
This preserves standard argparse behavior while allowing invocations such
as ``--angles -90,90`` to work as users expect.
"""
if args is not None:
args = normalize_negative_option_values(self, args)
return super().parse_args(args, namespace)
[docs]
def error(self, message: str) -> None:
"""
Handle an argparse parsing error.
This method classifies the failure based on the error message and raises
a :class:`~GONet_Wizard.commands.argparse_errors.CliParseError` containing
the classification and contextual information.
Parameters
----------
message : :class:`str`
Error message produced by argparse.
Returns
-------
None
Raises
------
:class:`~GONet_Wizard.commands.argparse_errors.CliParseError`
Always raised to signal a classified parse failure.
"""
argv = self._effective_argv()
cmd_tokens = _guess_cmd_tokens(argv)
msg_l = (message or "").lower()
if "the following arguments are required" in msg_l:
kind = ParseErrorKind.MISSING_REQUIRED
elif "unrecognized arguments:" in msg_l:
kind = ParseErrorKind.UNKNOWN_ARGS
else:
kind = ParseErrorKind.OTHER
raise CliParseError(
kind=kind,
message=message,
argv=tuple(argv),
cmd_tokens=tuple(cmd_tokens),
)
[docs]
def exit(self, status: int = 0, message: Optional[str] = None) -> None:
"""
Intercept argparse exit calls.
This override ensures that any attempt by argparse to terminate the
process results in a :class:`~GONet_Wizard.commands.argparse_errors.CliParseError`
instead.
Parameters
----------
status : :class:`int`, optional
Exit status requested by argparse.
message : :class:`~typing.Optional` of :class:`str`, optional
Optional message associated with the exit.
Returns
-------
None
Raises
------
:class:`~GONet_Wizard.commands.argparse_errors.CliParseError`
Always raised to prevent process termination.
"""
argv = self._effective_argv()
raise CliParseError(
kind=ParseErrorKind.OTHER,
message=message or "",
argv=tuple(argv),
cmd_tokens=tuple(_guess_cmd_tokens(argv)),
)