Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support parsing optionals as positionals #692

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions jsonargparse/_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ def print_help(self, call_args):
if ActionTypeHint.is_callable_typehint(typehint) and hasattr(typehint, "__args__"):
self.sub_add_kwargs["skip"] = {max(0, len(typehint.__args__) - 1)}
subparser.add_class_arguments(val_class, dest, **self.sub_add_kwargs)
subparser._inner_parser = True
remove_actions(subparser, (_HelpAction, _ActionPrintConfig, _ActionConfigLoad))
args = self.get_args_after_opt(parser.args)
if args:
Expand Down
57 changes: 57 additions & 0 deletions jsonargparse/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
__all__ = [
"LoggerProperty",
"null_logger",
"set_parsing_settings",
]

ClassType = TypeVar("ClassType")
Expand Down Expand Up @@ -88,6 +89,62 @@
context_var.reset(token)


parsing_settings = dict(
parse_optionals_as_positionals=False,
)


def set_parsing_settings(*, parse_optionals_as_positionals: Optional[bool] = None) -> None:
"""
Modify settings that affect the parsing behavior.

Args:
parse_optionals_as_positionals: [EXPERIMENTAL] If True, the parser will
take extra positional command line arguments as values for optional
arguments. This means that optional arguments can be given by name
--key=value as usual, but also as positional. The extra positionals
are applied to optionals in the order that they were added to the
parser.
"""
if isinstance(parse_optionals_as_positionals, bool):
parsing_settings["parse_optionals_as_positionals"] = parse_optionals_as_positionals
elif parse_optionals_as_positionals is not None:
raise ValueError(

Check warning on line 112 in jsonargparse/_common.py

View check run for this annotation

Codecov / codecov/patch

jsonargparse/_common.py#L111-L112

Added lines #L111 - L112 were not covered by tests
f"parse_optionals_as_positionals must be a boolean or None, but got {parse_optionals_as_positionals}."
)


def get_parsing_setting(name: str):
if name not in parsing_settings:
raise ValueError(f"Unknown parsing setting {name}.")

Check warning on line 119 in jsonargparse/_common.py

View check run for this annotation

Codecov / codecov/patch

jsonargparse/_common.py#L119

Added line #L119 was not covered by tests
return parsing_settings[name]


def get_optionals_as_positionals_actions(parser, include_positionals=False):
from jsonargparse._actions import ActionConfigFile, _ActionConfigLoad, filter_default_actions
from jsonargparse._completions import ShtabAction

actions = []
for action in filter_default_actions(parser._actions):
if isinstance(action, (_ActionConfigLoad, ActionConfigFile, ShtabAction)):
continue
if action.nargs not in {1, None}:
continue
if not include_positionals and action.option_strings == []:
continue
actions.append(action)

return actions


def supports_optionals_as_positionals(parser):
return (
get_parsing_setting("parse_optionals_as_positionals")
and not parser._subcommands_action
and not getattr(parser, "_inner_parser", False)
)


def is_subclass(cls, class_or_tuple) -> bool:
"""Extension of issubclass that supports non-class arguments."""
try:
Expand Down
20 changes: 20 additions & 0 deletions jsonargparse/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,11 @@
InstantiatorsDictType,
class_instantiators,
debug_mode_active,
get_optionals_as_positionals_actions,
is_dataclass_like,
lenient_check,
parser_context,
supports_optionals_as_positionals,
)
from ._completions import (
argcomplete_namespace,
Expand Down Expand Up @@ -305,6 +307,23 @@

return namespace, args

def _positional_optionals(self, cfg, unk):
if len(unk) == 0 or not supports_optionals_as_positionals(self):
return cfg, unk

for action in get_optionals_as_positionals_actions(self, include_positionals=True):
if action.option_strings == []:
if cfg.get(action.dest) is None:
self._logger.debug(f"Positional argument {action.dest} missing, aborting _positional_optionals")
break

Check warning on line 318 in jsonargparse/_core.py

View check run for this annotation

Codecov / codecov/patch

jsonargparse/_core.py#L317-L318

Added lines #L317 - L318 were not covered by tests
continue

cfg[action.dest] = self._check_value_key(action, unk.pop(0), action.dest, cfg)
if len(unk) == 0:
break

return cfg, unk

def _parse_optional(self, arg_string):
subclass_arg = ActionTypeHint.parse_argv_item(arg_string)
if subclass_arg:
Expand Down Expand Up @@ -432,6 +451,7 @@

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

Expand Down
26 changes: 24 additions & 2 deletions jsonargparse/_formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@
_find_action,
filter_default_actions,
)
from ._common import defaults_cache, parent_parser
from ._common import (
defaults_cache,
get_optionals_as_positionals_actions,
parent_parser,
supports_optionals_as_positionals,
)
from ._completions import ShtabAction
from ._link_arguments import ActionLink
from ._namespace import Namespace, NSKeyError
Expand Down Expand Up @@ -88,15 +93,32 @@ def _get_help_string(self, action: Action) -> str:

def _format_usage(self, *args, **kwargs) -> str:
usage = super()._format_usage(*args, **kwargs)

parser = parent_parser.get()
if parser:
if not parser:
return usage

if supports_optionals_as_positionals(parser):
actions = get_optionals_as_positionals_actions(parser)
if len(actions) > 0:
extra_positionals = ""
for action in reversed(actions):
extra_positionals = f"{action.dest} {extra_positionals}" if extra_positionals else action.dest
if action.dest not in parser.required_args:
extra_positionals = f"[{extra_positionals}]"
note = "note: extra positionals are parsed as optionals in the order shown above."
# TODO: fix wrap formatting of extra positionals
usage = re.sub(r"\n\n$", f" {extra_positionals}\n\n{note}\n\n", usage)

else:
for key in parser.required_args:
try:
default = parser.get_default(key)
except NSKeyError:
default = None
if default is None and f"[--{key} " in usage:
usage = re.sub(f"\\[(--{key} [^\\]]+)]", r"\1", usage, count=1)

return usage

def _format_action_invocation(self, action: Action) -> str:
Expand Down
2 changes: 2 additions & 0 deletions jsonargparse/_typehints.py
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,8 @@ def get_class_parser(val_class, sub_add_kwargs=None, skip_args=0):
for link_kwargs in nested_links.get():
parser.link_arguments(**link_kwargs)

parser._inner_parser = True

return parser

def extra_help(self):
Expand Down
64 changes: 64 additions & 0 deletions jsonargparse_tests/test_parsing_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import re
from typing import Literal, Optional
from unittest.mock import patch

import pytest

from jsonargparse import ActionYesNo, ArgumentError, Namespace, set_parsing_settings
from jsonargparse_tests.conftest import get_parser_help


@pytest.fixture(autouse=True)
def patch_parsing_settings():
with patch.dict("jsonargparse._common.parsing_settings"):
yield


def test_parse_optionals_as_positionals(parser, subtests):
set_parsing_settings(parse_optionals_as_positionals=True)

parser.add_argument("p1", type=Optional[Literal["p1"]])
parser.add_argument("--o1", type=Optional[int])
parser.add_argument("--flag", default=False, nargs=0, action=ActionYesNo)
parser.add_argument("--o2", type=Optional[Literal["o2"]])
parser.add_argument("--o3")

with subtests.test("help"):
help_str = get_parser_help(parser)
assert " p1 [o1 [o2 [o3]]]" in help_str
assert "extra positionals are parsed as optionals in the order shown above" in help_str

with subtests.test("no extra positionals"):
cfg = parser.parse_args(["--o2=o2", "--o1=1", "p1"])
assert cfg == Namespace(p1="p1", o1=1, o2="o2", o3=None, flag=False)

with subtests.test("one extra positional"):
cfg = parser.parse_args(["--o2=o2", "p1", "2"])
assert cfg == Namespace(p1="p1", o1=2, o2="o2", o3=None, flag=False)

with subtests.test("two extra positionals"):
cfg = parser.parse_args(["p1", "3", "o2"])
assert cfg == Namespace(p1="p1", o1=3, o2="o2", o3=None, flag=False)

with subtests.test("three extra positionals"):
cfg = parser.parse_args(["p1", "3", "o2", "v3"])
assert cfg == Namespace(p1="p1", o1=3, o2="o2", o3="v3", flag=False)

with subtests.test("extra positional has precedence"):
# TODO: document positionals precedence over optional is expected behavior
cfg = parser.parse_args(["p1", "3", "o2", "--o1=4"])
assert cfg == Namespace(p1="p1", o1=3, o2="o2", o3=None, flag=False)

with subtests.test("extra positionals invalid values"):
with pytest.raises(ArgumentError) as ex:
parser.parse_args(["p1", "o2", "5"])
assert re.match('Parser key "o1".*Given value: o2', ex.value.message, re.DOTALL)

with pytest.raises(ArgumentError) as ex:
parser.parse_args(["p1", "6", "invalid"])
assert re.match('Parser key "o2".*Given value: invalid', ex.value.message, re.DOTALL)


# TODO: test parse_optionals_as_positionals with subcommands
# TODO: test parse_optionals_as_positionals with inner parsers
# TODO: test parse_optionals_as_positionals with required non-positionals