diff --git a/applications/pctaddnoise/pctaddnoise.py b/applications/pctaddnoise/pctaddnoise.py index 0b347699..b512caff 100644 --- a/applications/pctaddnoise/pctaddnoise.py +++ b/applications/pctaddnoise/pctaddnoise.py @@ -58,9 +58,6 @@ def build_parser(): type=float, ) parser.add_argument("--seed", help="Random seed", type=int) - parser.add_argument( - "--verbose", "-v", help="Verbose execution", default=False, action="store_true" - ) return parser diff --git a/applications/pctargumentparser.py b/applications/pctargumentparser.py index 0fb099e8..8d40e5ce 100644 --- a/applications/pctargumentparser.py +++ b/applications/pctargumentparser.py @@ -1,131 +1,25 @@ -import argparse -import itk -from itk import PCT as pct -import inspect -from typing import Optional -import difflib -import re +from itk import pctConfig +from itk.rtkargumentparser import RTKArgumentParser -__all__ = ["PCTArgumentParser"] - - -class PCTHelpFormatter(argparse.ArgumentDefaultsHelpFormatter): - def _format_usage(self, usage, actions, groups, prefix=None): - if prefix is None: - prefix = pct.__version__ + "\n\nusage: " - return super()._format_usage(usage, actions, groups, prefix) +"""PCT application argument parser. +``PCTArgumentParser`` reuses :class:`RTKArgumentParser` and only overrides +the version string so that ``--version`` reports PCT's version. +""" -class PCTArgumentParser(argparse.ArgumentParser): - def __init__(self, description=None, **kwargs): - super().__init__(description=description, **kwargs) - self.formatter_class = PCTHelpFormatter - # allow negative numeric tokens to be treated as values, not options. This mirrors CPython behavior in python 3.14 - self._negative_number_matcher = re.compile(r"-\.?\d") - self.add_argument("-V", "--version", action="version", version=pct.__version__) - - def build_signature(self) -> inspect.Signature: - """Build a compact Python signature: only required kwargs + **kwargs.""" - required_params = [] - for action in self._actions: - name = getattr(action, "dest", None) - if not name or name in ("help", "version"): - continue - if getattr(action, "required", False): - required_params.append( - inspect.Parameter(name=name, kind=inspect.Parameter.KEYWORD_ONLY) - ) - # Add catch-all for the many optional CLI options to keep help inline - var_kw = inspect.Parameter("kwargs", kind=inspect.Parameter.VAR_KEYWORD) - return inspect.Signature(required_params + [var_kw]) +__all__ = ["PCTArgumentParser"] - def build_usage_examples(self, app_name: Optional[str] = None) -> str: - """Return a Usage examples block for Python help().""" - name = app_name or self.prog - # Collect required destinations - req = [ - a.dest - for a in self._actions - if getattr(a, "required", False) - and a.dest - and a.dest not in ("help", "version") - ] - shell = f"{name}(\"{' '.join([f'--{d} {d.upper()}' for d in req])}\")" - py = f"{name}({', '.join([f'{d}={d.upper()}' for d in req])})" - return "Usage:\n" f" • Shell-style: {shell}\n" f" • Python API: {py}\n\n" - def apply_signature(self, func): - """Apply the built signature to a callable and return it.""" - func.__signature__ = self.build_signature() - return func +class PCTArgumentParser(RTKArgumentParser): + """Argument parser for PCT Python applications. - def parse_args(self, args=None, namespace=None): - """Parse args with optional single-token comma list support for multi-value options. - Supported forms: - --opt A B C (space separated) - --opt A,B,C (single token, comma separated) - """ - neutralized = {} - for action in self._actions: - dest = getattr(action, "dest", None) - if not dest or dest in ("help", "version"): - continue - nargs = getattr(action, "nargs", None) - if nargs != "+": - continue - t = getattr(action, "type", None) - if t and t is not str: - neutralized[dest] = t - action.type = str - namespace = super().parse_args(args, namespace) - for action in self._actions: - dest = getattr(action, "dest", None) - if dest not in neutralized: - continue - caster = neutralized[dest] - val = getattr(namespace, dest, None) - if isinstance(val, list): - # Case 1: user supplied a single token containing commas (e.g. "1,2,3"). - # Split on commas, strip whitespace, drop empty pieces, then cast each element. - if len(val) == 1 and isinstance(val[0], str) and "," in val[0]: - pieces = [s for s in (p.strip() for p in val[0].split(",")) if s] - setattr(namespace, dest, [caster(p) for p in pieces]) - else: - # Case 2: normal space-separated form (e.g. "1 2 3"). Just cast every token. - setattr(namespace, dest, [caster(tk) for tk in val]) - action.type = neutralized[dest] - return namespace + Reuses ``RTKArgumentParser`` and only overrides the version string so that + ``--version`` reports PCT's version. + """ - def parse_kwargs(self, func_name: Optional[str] = None, **kwargs): - """Convert Python kwargs to argv and parse them. - Lists/tuples for multi-value options are serialized as a single comma token.""" - actions = { - a.dest: a - for a in self._actions - if a.dest and a.dest not in ("help", "version") - } - for key in kwargs: - if key not in actions: - matches = difflib.get_close_matches( - key, actions.keys(), n=3, cutoff=0.5 - ) - name = func_name or self.prog or "function" - msg = f"{name}() got an unexpected keyword argument '{key}'" - if matches: - msg += f"\nDid you mean: {', '.join(matches)}?" - else: - msg += f"\nValid arguments are: {', '.join(sorted(actions.keys()))}" - raise TypeError(msg) - argv = [] - for key, val in kwargs.items(): - action = actions[key] - opt_strings = list(action.option_strings) - flag = next((o for o in opt_strings if o.startswith("--")), opt_strings[0]) - if isinstance(val, bool): - if val: - argv.append(flag) - elif isinstance(val, (list, tuple)): - argv += [flag, ",".join(map(str, val))] - else: - argv += [flag, str(val)] - return self.parse_args(argv) + def __init__(self, description=None, version=None, **kwargs): + super().__init__( + description=description, + version=version or pctConfig.PCT_GLOBAL_VERSION_STRING, + **kwargs, + ) diff --git a/applications/pctbinning/pctbinning.py b/applications/pctbinning/pctbinning.py index 08dbf0d7..6b09a072 100644 --- a/applications/pctbinning/pctbinning.py +++ b/applications/pctbinning/pctbinning.py @@ -39,10 +39,6 @@ def build_parser(): help="Particle used for imaging (proton or alpha)", default="proton", ) - parser.add_argument( - "-v", "--verbose", help="Verbose execution", action="store_true", default=False - ) - parser.add_argument( "-s", "--source", help="Source position", type=float, default=0.0 ) diff --git a/applications/pctdoublelut/pctdoublelut.py b/applications/pctdoublelut/pctdoublelut.py index 1ed7189c..7947f6cc 100644 --- a/applications/pctdoublelut/pctdoublelut.py +++ b/applications/pctdoublelut/pctdoublelut.py @@ -54,12 +54,6 @@ def build_parser(): type=float, ) parser.add_argument("--seed", help="Seed for random number generator", type=int) - parser.add_argument( - "--verbose", - "-v", - help="Verbose execution", - action="store_true", - ) return parser diff --git a/applications/pctfdk/pctfdk.py b/applications/pctfdk/pctfdk.py index 23a3075e..681cd221 100644 --- a/applications/pctfdk/pctfdk.py +++ b/applications/pctfdk/pctfdk.py @@ -10,9 +10,6 @@ def build_parser(): description="Reconstruct a 3D volume from a sequence of projections [Feldkamp, David, Kress, 1984]." ) # General - parser.add_argument( - "--verbose", "-v", help="Verbose execution", action="store_true" - ) parser.add_argument( "--geometry", "-g", help="XML geometry file name", type=str, required=True ) diff --git a/applications/pctlomalinda/pctlomalinda.py b/applications/pctlomalinda/pctlomalinda.py index fbb679da..7111bbe2 100644 --- a/applications/pctlomalinda/pctlomalinda.py +++ b/applications/pctlomalinda/pctlomalinda.py @@ -32,9 +32,6 @@ def build_parser(): parser.add_argument( "--max-run", help="Maximum run (exclusive)", default=1e6, type=int ) - parser.add_argument( - "--verbose", "-v", help="Verbose execution", default=False, action="store_true" - ) parser.add_argument( "--ps", help="Name of tree in input phase space", default="PhaseSpace" ) diff --git a/applications/pctpairprotons/pctpairprotons.py b/applications/pctpairprotons/pctpairprotons.py index d9259168..d3af49c6 100644 --- a/applications/pctpairprotons/pctpairprotons.py +++ b/applications/pctpairprotons/pctpairprotons.py @@ -54,9 +54,6 @@ def build_parser(): default=False, action="store_true", ) - parser.add_argument( - "--verbose", "-v", help="Verbose execution", default=False, action="store_true" - ) parser.add_argument( "--psin", help="Name of tree in input phase space", default="PhaseSpace" ) diff --git a/applications/pctweplfit/pctweplfit.py b/applications/pctweplfit/pctweplfit.py index c4b8595e..8aeeff1d 100644 --- a/applications/pctweplfit/pctweplfit.py +++ b/applications/pctweplfit/pctweplfit.py @@ -493,13 +493,6 @@ def build_parser(): action="store_true", ) parser.add_argument("--seed", help="Seed for random number generator", type=int) - parser.add_argument( - "--verbose", - "-v", - help="Verbose execution", - action="store_true", - ) - return parser diff --git a/test/pct_argumentparser_test.py b/test/pct_argumentparser_test.py new file mode 100644 index 00000000..c3ca65fc --- /dev/null +++ b/test/pct_argumentparser_test.py @@ -0,0 +1,100 @@ +import pytest +import itk +from itk import PCT as pct +from itk import RTK as rtk + + +@pytest.fixture() +def parser(): + p = pct.PCTArgumentParser() + p.add_argument("--string-single", type=str, required=True) + p.add_argument("--number-single", type=int, required=True) + p.add_argument("--string-many", type=str, nargs="+", required=True) + p.add_argument("--number-many", type=int, nargs="+", required=True) + return p + + +def assert_args(args): + assert args.string_single == "a" + assert args.number_single == 1 + assert args.string_many == ["a", "b", "c"] + assert args.number_many == [1, 2, 3] + + +def test_parser_inherits_from_rtk(): + assert issubclass(pct.PCTArgumentParser, rtk.RTKArgumentParser) + + +def test_version_flag_uses_pct_version(parser): + assert parser._version == pct.__version__ + + +def test_verbose_flag_inherited(parser): + args = parser.parse_args( + [ + "--string-single", + "a", + "--number-single", + "1", + "--string-many", + "a,b,c", + "--number-many", + "1,2,3", + "-v", + ] + ) + assert args.verbose is True + assert_args(args) + + +def test_pctargumentparser_cli(parser): + args = parser.parse_args( + [ + "--string-single", + "a", + "--number-single", + "1", + "--string-many", + "a,b,c", + "--number-many", + "1,2,3", + ] + ) + assert_args(args) + + +def test_pctargumentparser_cli_commas(parser): + args = parser.parse_args( + [ + "--string-single", + "a", + "--number-single", + "1", + "--string-many", + "a", + "b", + "c", + "--number-many", + "1", + "2", + "3", + ] + ) + assert_args(args) + + +def test_pctargumentparser_python(parser): + args = parser.parse_kwargs( + string_single="a", + number_single=1, + string_many=["a", "b", "c"], + number_many=[1, 2, 3], + ) + assert_args(args) + + +def test_pctargumentparser_python_commas(parser): + args = parser.parse_kwargs( + string_single="a", number_single=1, string_many="a,b,c", number_many="1,2,3" + ) + assert_args(args)