Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 135 additions & 65 deletions applications/rtkargumentparser.py
Original file line number Diff line number Diff line change
@@ -1,107 +1,175 @@
import re
import argparse
from itk import RTK as rtk
from itk import rtkConfig
import difflib
import inspect
from typing import Optional

__all__ = ["RTKArgumentParser"]

"""RTK application argument parser.

class RTKHelpFormatter(argparse.ArgumentDefaultsHelpFormatter):
def _format_usage(self, usage, actions, groups, prefix=None):
if prefix is None:
prefix = rtk.__version__ + "\n\nusage: "
return super()._format_usage(usage, actions, groups, prefix)
Extends ``argparse.ArgumentParser`` so that the same application can be
invoked from the command line *and* from the Python API via keyword
arguments.

Multi-value arguments (``nargs="+"``) can be passed in three ways:

- **Space-separated (CLI):** ``--opt A B C``
- **Comma-separated (CLI):** ``--opt A,B,C``
- **Python list/tuple (API):** ``app(opt=["A", "B", "C"])``

The Python API works by joining lists into a single comma token
(``"A,B,C"``) before calling ``parse_args``, which splits it back into
``["A", "B", "C"]`` and casts each element to the declared type.
"""


def _make_help_formatter(version):
class Formatter(argparse.ArgumentDefaultsHelpFormatter):
def _format_usage(self, usage, actions, groups, prefix=None):
if prefix is None:
prefix = (version or "") + "\n\nusage: "
return super()._format_usage(usage, actions, groups, prefix)

return Formatter


class RTKArgumentParser(argparse.ArgumentParser):
def __init__(self, description=None, **kwargs):
"""Argument parser for RTK Python applications.

Wraps ``argparse.ArgumentParser`` with:

- Common options added to every application (``--version``, ``--verbose``).
- Support for negative numeric tokens as values (e.g. ``--offset -1 -0.5``).
- Multi-value comma splitting so ``parse_kwargs`` lists round-trip
correctly through ``parse_args``.

Use ``parse_kwargs(**kwargs)`` for the Python API and
``parse_args(argv)`` for the command line.
"""

def __init__(self, description=None, version=None, **kwargs):
super().__init__(description=description, **kwargs)
self.formatter_class = RTKHelpFormatter
self._version = version or rtkConfig.RTK_GLOBAL_VERSION_STRING
self.formatter_class = _make_help_formatter(self._version)
# 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")
# Common options available to all RTK Python applications
self.add_argument("-V", "--version", action="version", version=rtk.__version__)
self.add_argument("-V", "--version", action="version", version=self._version)
self.add_argument(
"-v", "--verbose", help="Verbose execution", action="store_true"
)

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])
def required_dests(self):
"""Return sorted destination names of all required arguments.

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 = [
Used internally by ``apply_signature`` and ``build_usage_examples``
to avoid duplicating the required-actions filter.
"""
return sorted(
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()
"""Apply a compact signature to a callable for help().

Only required kwargs appear in the signature; optional arguments
are captured by **kwargs.
"""
params = [
inspect.Parameter(name=d, kind=inspect.Parameter.KEYWORD_ONLY)
for d in self.required_dests()
]
params.append(inspect.Parameter("kwargs", kind=inspect.Parameter.VAR_KEYWORD))
func.__signature__ = inspect.Signature(params)
return func

def build_usage_examples(self, app_name: Optional[str] = None) -> str:
"""Return a usage examples block for Python help().

Shows both shell-style and Python API examples using only the
required arguments.
"""
name = app_name or self.prog
req = self.required_dests()
shell = name + "(" + " ".join(f"--{d} {d.upper()}" for d in req) + ")"
py = name + "(" + ", ".join(f"{d}={d.upper()}" for d in req) + ")"
return f"Usage:\n • Shell-style: {shell}\n • Python API: {py}\n\n"

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)
"""Parse a token list, with comma-splitting for multi-value options.

For every ``nargs="+"`` argument, this method:
1. Temporarily sets the type to ``str`` so argparse does not choke
on a comma token like ``"1,2,3"``.
2. Calls the base ``argparse.ArgumentParser.parse_args``.
3. Restores the original types (in a ``finally`` block).
4. Splits any single comma token into separate elements and casts
each element back to the original type.

Accepts:
- Token list: ``["--opt", "A", "B", "C"]``
- Comma token: ``["--opt", "A,B,C"]``

Do **not** pass a single string — ``parse_args`` expects a
sequence of tokens, not a shell command string.
"""
neutralized = {}
multi_valued = {}
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:
if getattr(action, "nargs", None) != "+":
continue
caster = neutralized[dest]
# Neutralize all types (including str) so comma tokens like "a,b" or
# "1,2,3" can be split after parsing. The original type is restored below.
cast = action.type or str
multi_valued[dest] = cast
action.type = str
try:
namespace = super().parse_args(args, namespace)
finally:
for action in self._actions:
dest = getattr(action, "dest", None)
if dest in multi_valued:
action.type = multi_valued[dest]
for dest, cast in multi_valued.items():
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]
if not isinstance(val, list):
# Option not supplied on this invocation; leave its default/None intact.
continue
# Case 1: user supplied a single token containing commas (e.g. "1,2,3" or
# "a.nrrd,b.nrrd"). Split on commas, strip whitespace, drop empty pieces.
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, [cast(piece) for piece in pieces])
else:
# Case 2: normal space-separated form (e.g. "1 2 3"). Just cast every token.
setattr(namespace, dest, [cast(piece) for piece in val])
return namespace

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."""
"""Convert Python keyword arguments into a token list and parse them.

Lists and tuples are serialized as a single comma-separated token
(e.g. ``["a", "b"]`` → ``"a,b"``) so they round-trip correctly
through ``parse_args``.

Examples::

app(opt=["A", "B", "C"]) # list → comma token → split back
app(opt=("A", "B")) # tuple, same behavior
app(opt="A,B") # pre-joined string, also works

Scalar values (str, int, float) and boolean flags are passed through
directly. Unknown keyword arguments raise ``TypeError`` with a
fuzzy "Did you mean …?" suggestion.
"""
actions = {
a.dest: a
for a in self._actions
Expand All @@ -122,8 +190,10 @@ def parse_kwargs(self, func_name: Optional[str] = None, **kwargs):
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])
flag = next(
(o for o in action.option_strings if o.startswith("--")),
action.option_strings[0],
)
if isinstance(val, bool):
if val:
argv.append(flag)
Expand Down
73 changes: 73 additions & 0 deletions test/rtk_argument_parser_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import pytest
import itk
from itk import RTK as rtk


@pytest.fixture()
def parser():
p = rtk.RTKArgumentParser()
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_cli_comma_separated(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_cli_space_separated(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_python_api(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_python_api_comma_string(parser):
args = parser.parse_kwargs(
string_single="a", number_single=1, string_many="a,b,c", number_many="1,2,3"
)
assert_args(args)
Loading