Skip to content

Commit c5cd330

Browse files
authored
Support parsing optionals as positionals (#692)
1 parent 64b6876 commit c5cd330

File tree

9 files changed

+320
-6
lines changed

9 files changed

+320
-6
lines changed

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Added
1919
^^^^^
2020
- Support ``shtab`` completion of ``Literal`` types (`#693
2121
<https://github.com/omni-us/jsonargparse/pull/693>`__).
22+
- Support for parsing optionals as positionals (`#692
23+
<https://github.com/omni-us/jsonargparse/pull/692>`__).
2224

2325
Changed
2426
^^^^^^^

DOCUMENTATION.rst

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,53 @@ This can be easily implemented with :func:`.capture_parser` as follows:
332332
:func:`.auto_cli` is by using :func:`.capture_parser`.
333333

334334

335+
Optionals as positionals
336+
------------------------
337+
338+
It can sometimes be useful to allow optional arguments to be passed both by
339+
name, such as ``--key=val``, and as positional arguments, such as ``val``. This
340+
behavior can be enabled by using
341+
``set_parsing_settings(parse_optionals_as_positionals=True)``. Key points to
342+
note about this feature are:
343+
344+
- Only optional arguments that accept exactly one value can be passed as
345+
positional, i.e., when ``nargs`` is not specified or is set to ``nargs=1``.
346+
- Optional arguments with subclass types cannot be passed as positional
347+
arguments.
348+
- Optionals are treated as positionals only after the standard positionals and
349+
in the order they were added to the parser. The usage section in the help
350+
displays the optionals that can be passed as positionals and their order.
351+
- Optional arguments in parsers with subcommands cannot be passed as
352+
positionals. Only the child subparsers, after specifying the subcommand
353+
name(s), support this feature.
354+
355+
For instance, consider a parser defined as follows:
356+
357+
.. testcode::
358+
359+
from jsonargparse import set_parsing_settings
360+
361+
362+
set_parsing_settings(parse_optionals_as_positionals=True)
363+
364+
parser.add_argument("p1")
365+
parser.add_argument("--o2")
366+
parser.add_argument("--o3")
367+
368+
The help will display ``p1 [o2 [o3]]`` along with a note indicating that this
369+
feature is enabled. Name-based parsing, such as ``--o2=val2 --o3=val3 val1``,
370+
will work as expected. Additionally, the following cases are also valid:
371+
``--o3=val3 val1 val2`` or ``val1 val2 val3``.
372+
373+
.. note::
374+
375+
Positional arguments take precedence over optional arguments. This means
376+
that if a value is provided both as a positional and as an optional
377+
argument, the value from the positional argument will be used, regardless of
378+
the order. For example, with the parser above, the command ``val1 val2a
379+
--o2=val2b`` would result in ``o2=val1a``.
380+
381+
335382
Functions as type
336383
-----------------
337384

jsonargparse/_actions.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,7 @@ def print_help(self, call_args):
409409
if ActionTypeHint.is_callable_typehint(typehint) and hasattr(typehint, "__args__"):
410410
self.sub_add_kwargs["skip"] = {max(0, len(typehint.__args__) - 1)}
411411
subparser.add_class_arguments(val_class, dest, **self.sub_add_kwargs)
412+
subparser._inner_parser = True
412413
remove_actions(subparser, (_HelpAction, _ActionPrintConfig, _ActionConfigLoad))
413414
args = self.get_args_after_opt(parser.args)
414415
if args:

jsonargparse/_common.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
__all__ = [
3535
"LoggerProperty",
3636
"null_logger",
37+
"set_parsing_settings",
3738
]
3839

3940
ClassType = TypeVar("ClassType")
@@ -88,6 +89,63 @@ def parser_context(**kwargs):
8889
context_var.reset(token)
8990

9091

92+
parsing_settings = dict(
93+
parse_optionals_as_positionals=False,
94+
)
95+
96+
97+
def set_parsing_settings(*, parse_optionals_as_positionals: Optional[bool] = None) -> None:
98+
"""
99+
Modify settings that affect the parsing behavior.
100+
101+
Args:
102+
parse_optionals_as_positionals: [EXPERIMENTAL] If True, the parser will
103+
take extra positional command line arguments as values for optional
104+
arguments. This means that optional arguments can be given by name
105+
--key=value as usual, but also as positional. The extra positionals
106+
are applied to optionals in the order that they were added to the
107+
parser.
108+
"""
109+
if isinstance(parse_optionals_as_positionals, bool):
110+
parsing_settings["parse_optionals_as_positionals"] = parse_optionals_as_positionals
111+
elif parse_optionals_as_positionals is not None:
112+
raise ValueError(f"parse_optionals_as_positionals must be a boolean, but got {parse_optionals_as_positionals}.")
113+
114+
115+
def get_parsing_setting(name: str):
116+
if name not in parsing_settings:
117+
raise ValueError(f"Unknown parsing setting {name}.")
118+
return parsing_settings[name]
119+
120+
121+
def get_optionals_as_positionals_actions(parser, include_positionals=False):
122+
from jsonargparse._actions import ActionConfigFile, _ActionConfigLoad, filter_default_actions
123+
from jsonargparse._completions import ShtabAction
124+
from jsonargparse._typehints import ActionTypeHint
125+
126+
actions = []
127+
for action in filter_default_actions(parser._actions):
128+
if isinstance(action, (_ActionConfigLoad, ActionConfigFile, ShtabAction)):
129+
continue
130+
if ActionTypeHint.is_subclass_typehint(action, all_subtypes=False):
131+
continue
132+
if action.nargs not in {1, None}:
133+
continue
134+
if not include_positionals and action.option_strings == []:
135+
continue
136+
actions.append(action)
137+
138+
return actions
139+
140+
141+
def supports_optionals_as_positionals(parser):
142+
return (
143+
get_parsing_setting("parse_optionals_as_positionals")
144+
and not parser._subcommands_action
145+
and not getattr(parser, "_inner_parser", False)
146+
)
147+
148+
91149
def is_subclass(cls, class_or_tuple) -> bool:
92150
"""Extension of issubclass that supports non-class arguments."""
93151
try:

jsonargparse/_core.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,11 @@
4141
InstantiatorsDictType,
4242
class_instantiators,
4343
debug_mode_active,
44+
get_optionals_as_positionals_actions,
4445
is_dataclass_like,
4546
lenient_check,
4647
parser_context,
48+
supports_optionals_as_positionals,
4749
)
4850
from ._completions import (
4951
argcomplete_namespace,
@@ -307,6 +309,23 @@ def parse_known_args(self, args=None, namespace=None):
307309

308310
return namespace, args
309311

312+
def _positional_optionals(self, cfg, unk):
313+
if len(unk) == 0 or not supports_optionals_as_positionals(self):
314+
return cfg, unk
315+
316+
for action in get_optionals_as_positionals_actions(self, include_positionals=True):
317+
if action.option_strings == []:
318+
if cfg.get(action.dest) is None:
319+
self._logger.debug(f"Positional argument {action.dest} missing, aborting _positional_optionals")
320+
break
321+
continue
322+
323+
cfg[action.dest] = self._check_value_key(action, unk.pop(0), action.dest, cfg)
324+
if len(unk) == 0:
325+
break
326+
327+
return cfg, unk
328+
310329
def _parse_optional(self, arg_string):
311330
subclass_arg = ActionTypeHint.parse_argv_item(arg_string)
312331
if subclass_arg:
@@ -434,6 +453,7 @@ def parse_args( # type: ignore[override]
434453

435454
with _ActionSubCommands.parse_kwargs_context({"env": env, "defaults": defaults}):
436455
cfg, unk = self.parse_known_args(args=args, namespace=cfg)
456+
cfg, unk = self._positional_optionals(cfg, unk)
437457
if unk:
438458
self.error(f'Unrecognized arguments: {" ".join(unk)}')
439459

jsonargparse/_formatters.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,12 @@
2323
_find_action,
2424
filter_default_actions,
2525
)
26-
from ._common import defaults_cache, parent_parser
26+
from ._common import (
27+
defaults_cache,
28+
get_optionals_as_positionals_actions,
29+
parent_parser,
30+
supports_optionals_as_positionals,
31+
)
2732
from ._completions import ShtabAction
2833
from ._link_arguments import ActionLink
2934
from ._namespace import Namespace, NSKeyError
@@ -88,15 +93,40 @@ def _get_help_string(self, action: Action) -> str:
8893

8994
def _format_usage(self, *args, **kwargs) -> str:
9095
usage = super()._format_usage(*args, **kwargs)
96+
9197
parser = parent_parser.get()
92-
if parser:
98+
if not parser:
99+
return usage
100+
101+
if supports_optionals_as_positionals(parser):
102+
actions = get_optionals_as_positionals_actions(parser)
103+
if len(actions) > 0:
104+
extra_positionals = ""
105+
for action in reversed(actions):
106+
extra_positionals = f"{action.dest} {extra_positionals}" if extra_positionals else action.dest
107+
extra_positionals = f"[{extra_positionals}]"
108+
109+
usage_lines = usage.rstrip().split("\n")
110+
last_line = usage_lines[-1] + " " + extra_positionals
111+
text_width = self._width - self._current_indent
112+
if len(usage_lines) == 1 or len(last_line) <= text_width:
113+
usage_lines[-1] = last_line
114+
else:
115+
indent = re.sub(r"^( +)[^ ].*$", r"\1", usage_lines[-1])
116+
usage_lines.append(indent + extra_positionals)
117+
118+
note = "note: extra positionals are parsed as optionals in the order shown above."
119+
usage = "\n".join(usage_lines) + f"\n\n{note}\n\n"
120+
121+
else:
93122
for key in parser.required_args:
94123
try:
95124
default = parser.get_default(key)
96125
except NSKeyError:
97126
default = None
98127
if default is None and f"[--{key} " in usage:
99128
usage = re.sub(f"\\[(--{key} [^\\]]+)]", r"\1", usage, count=1)
129+
100130
return usage
101131

102132
def _format_action_invocation(self, action: Action) -> str:

jsonargparse/_typehints.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,8 @@ def get_class_parser(val_class, sub_add_kwargs=None, skip_args=0):
658658
for link_kwargs in nested_links.get():
659659
parser.link_arguments(**link_kwargs)
660660

661+
parser._inner_parser = True
662+
661663
return parser
662664

663665
def extra_help(self):

jsonargparse_tests/conftest.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
set_docstring_parse_options(style=DocstringStyle.GOOGLE)
3333

3434

35-
columns_env = {"COLUMNS": "200"}
35+
columns = "200"
3636

3737
is_cpython = platform.python_implementation() == "CPython"
3838
is_posix = os.name == "posix"
@@ -190,9 +190,9 @@ def source_unavailable():
190190
yield
191191

192192

193-
def get_parser_help(parser: ArgumentParser, strip=False) -> str:
193+
def get_parser_help(parser: ArgumentParser, strip=False, columns=columns) -> str:
194194
out = StringIO()
195-
with patch.dict(os.environ, columns_env):
195+
with patch.dict(os.environ, {"COLUMNS": columns}):
196196
parser.print_help(out)
197197
if strip:
198198
return re.sub(" *", " ", out.getvalue())
@@ -201,7 +201,7 @@ def get_parser_help(parser: ArgumentParser, strip=False) -> str:
201201

202202
def get_parse_args_stdout(parser: ArgumentParser, args: List[str]) -> str:
203203
out = StringIO()
204-
with patch.dict(os.environ, columns_env), redirect_stdout(out), pytest.raises(SystemExit):
204+
with patch.dict(os.environ, {"COLUMNS": columns}), redirect_stdout(out), pytest.raises(SystemExit):
205205
parser.parse_args(args)
206206
return out.getvalue()
207207

0 commit comments

Comments
 (0)