Repository: omni-us/jsonargparse
Pull request #692 (2025-03-11 06:09:51)

Support parsing optionals as positionals
## What does this PR do?

Fixes #690

## Before submitting

- [x] Did you read the [contributing guideline](https://github.com/omni-us/jsonargparse/blob/main/CONTRIBUTING.rst)?
- [x] Did you update **the documentation**? (readme and public docstrings)
- [x] Did you write **unit tests** such that there is 100% coverage on related code? (required for bug fixes and new features)
- [x] Did you verify that new and existing **tests pass locally**?
- [x] Did you make sure that all changes preserve **backward compatibility**?
- [x] Did you update **the CHANGELOG**? (not for typos, docs, test updates, or minor internal changes/refactors)


Borda: could we bump the version to an RC or something so I can test it with our CI - https://github.com/Lightning-AI/utilities/pull/371
mauvilsa: > could we bump the version to an RC or something so I can test it with our CI - [Lightning-AI/utilities#371](https://github.com/Lightning-AI/utilities/pull/371)

This looks good https://github.com/Lightning-AI/utilities/blob/9610d5bfa0a639d0820ef525e5cec06b0db71e11/requirements/cli.txt#L1, I think there is no need for an RC.
sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=692) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [2 New issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=692&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=692&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=692&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=692&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=692&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=692)


sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=692) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [2 New issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=692&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=692&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=692&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=692&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=692&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=692)


sonarqubecloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=692) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=692&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=692&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=692&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=692&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=692&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=692)



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 56e71b7..cc40202 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -19,6 +19,8 @@ Added
 ^^^^^
 - Support ``shtab`` completion of ``Literal`` types (`#693
   <https://github.com/omni-us/jsonargparse/pull/693>`__).
+- Support for parsing optionals as positionals (`#692
+  <https://github.com/omni-us/jsonargparse/pull/692>`__).
 
 Changed
 ^^^^^^^
diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst
index e8b15c3..2ee1d30 100644
--- a/DOCUMENTATION.rst
+++ b/DOCUMENTATION.rst
@@ -332,6 +332,53 @@ This can be easily implemented with :func:`.capture_parser` as follows:
     :func:`.auto_cli` is by using :func:`.capture_parser`.
 
 
+Optionals as positionals
+------------------------
+
+It can sometimes be useful to allow optional arguments to be passed both by
+name, such as ``--key=val``, and as positional arguments, such as ``val``. This
+behavior can be enabled by using
+``set_parsing_settings(parse_optionals_as_positionals=True)``. Key points to
+note about this feature are:
+
+- Only optional arguments that accept exactly one value can be passed as
+  positional, i.e., when ``nargs`` is not specified or is set to ``nargs=1``.
+- Optional arguments with subclass types cannot be passed as positional
+  arguments.
+- Optionals are treated as positionals only after the standard positionals and
+  in the order they were added to the parser. The usage section in the help
+  displays the optionals that can be passed as positionals and their order.
+- Optional arguments in parsers with subcommands cannot be passed as
+  positionals. Only the child subparsers, after specifying the subcommand
+  name(s), support this feature.
+
+For instance, consider a parser defined as follows:
+
+.. testcode::
+
+    from jsonargparse import set_parsing_settings
+
+
+    set_parsing_settings(parse_optionals_as_positionals=True)
+
+    parser.add_argument("p1")
+    parser.add_argument("--o2")
+    parser.add_argument("--o3")
+
+The help will display ``p1 [o2 [o3]]`` along with a note indicating that this
+feature is enabled. Name-based parsing, such as ``--o2=val2 --o3=val3 val1``,
+will work as expected. Additionally, the following cases are also valid:
+``--o3=val3 val1 val2`` or ``val1 val2 val3``.
+
+.. note::
+
+    Positional arguments take precedence over optional arguments. This means
+    that if a value is provided both as a positional and as an optional
+    argument, the value from the positional argument will be used, regardless of
+    the order. For example, with the parser above, the command ``val1 val2a
+    --o2=val2b`` would result in ``o2=val1a``.
+
+
 Functions as type
 -----------------
 
diff --git a/jsonargparse/_actions.py b/jsonargparse/_actions.py
index 8c50a42..62ba176 100644
--- a/jsonargparse/_actions.py
+++ b/jsonargparse/_actions.py
@@ -409,6 +409,7 @@ class _ActionHelpClassPath(Action):
         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:
diff --git a/jsonargparse/_common.py b/jsonargparse/_common.py
index 9af90e5..292f1e6 100644
--- a/jsonargparse/_common.py
+++ b/jsonargparse/_common.py
@@ -34,6 +34,7 @@ from ._type_checking import ArgumentParser
 __all__ = [
     "LoggerProperty",
     "null_logger",
+    "set_parsing_settings",
 ]
 
 ClassType = TypeVar("ClassType")
@@ -88,6 +89,63 @@ def parser_context(**kwargs):
             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(f"parse_optionals_as_positionals must be a boolean, 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}.")
+    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
+    from jsonargparse._typehints import ActionTypeHint
+
+    actions = []
+    for action in filter_default_actions(parser._actions):
+        if isinstance(action, (_ActionConfigLoad, ActionConfigFile, ShtabAction)):
+            continue
+        if ActionTypeHint.is_subclass_typehint(action, all_subtypes=False):
+            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:
diff --git a/jsonargparse/_core.py b/jsonargparse/_core.py
index dd247d4..f38bdb6 100644
--- a/jsonargparse/_core.py
+++ b/jsonargparse/_core.py
@@ -41,9 +41,11 @@ from ._common import (
     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,
@@ -307,6 +309,23 @@ class ArgumentParser(ParserDeprecations, ActionsContainer, ArgumentLinking, argp
 
         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
+                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:
@@ -434,6 +453,7 @@ class ArgumentParser(ParserDeprecations, ActionsContainer, ArgumentLinking, argp
 
             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)}')
 
diff --git a/jsonargparse/_formatters.py b/jsonargparse/_formatters.py
index 109d88a..a96d9be 100644
--- a/jsonargparse/_formatters.py
+++ b/jsonargparse/_formatters.py
@@ -23,7 +23,12 @@ from ._actions import (
     _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
@@ -88,8 +93,32 @@ class DefaultHelpFormatter(HelpFormatter):
 
     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
+                    extra_positionals = f"[{extra_positionals}]"
+
+                usage_lines = usage.rstrip().split("\n")
+                last_line = usage_lines[-1] + " " + extra_positionals
+                text_width = self._width - self._current_indent
+                if len(usage_lines) == 1 or len(last_line) <= text_width:
+                    usage_lines[-1] = last_line
+                else:
+                    indent = re.sub(r"^( +)[^ ].*$", r"\1", usage_lines[-1])
+                    usage_lines.append(indent + extra_positionals)
+
+                note = "note: extra positionals are parsed as optionals in the order shown above."
+                usage = "\n".join(usage_lines) + f"\n\n{note}\n\n"
+
+        else:
             for key in parser.required_args:
                 try:
                     default = parser.get_default(key)
@@ -97,6 +126,7 @@ class DefaultHelpFormatter(HelpFormatter):
                     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:
diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py
index 3b52991..8ea4fc1 100644
--- a/jsonargparse/_typehints.py
+++ b/jsonargparse/_typehints.py
@@ -658,6 +658,8 @@ class ActionTypeHint(Action):
         for link_kwargs in nested_links.get():
             parser.link_arguments(**link_kwargs)
 
+        parser._inner_parser = True
+
         return parser
 
     def extra_help(self):


Test patch:
diff --git a/jsonargparse_tests/conftest.py b/jsonargparse_tests/conftest.py
index 735d05b..3610c9e 100644
--- a/jsonargparse_tests/conftest.py
+++ b/jsonargparse_tests/conftest.py
@@ -32,7 +32,7 @@ if docstring_parser_support:
     set_docstring_parse_options(style=DocstringStyle.GOOGLE)
 
 
-columns_env = {"COLUMNS": "200"}
+columns = "200"
 
 is_cpython = platform.python_implementation() == "CPython"
 is_posix = os.name == "posix"
@@ -190,9 +190,9 @@ def source_unavailable():
         yield
 
 
-def get_parser_help(parser: ArgumentParser, strip=False) -> str:
+def get_parser_help(parser: ArgumentParser, strip=False, columns=columns) -> str:
     out = StringIO()
-    with patch.dict(os.environ, columns_env):
+    with patch.dict(os.environ, {"COLUMNS": columns}):
         parser.print_help(out)
     if strip:
         return re.sub("  *", " ", out.getvalue())
@@ -201,7 +201,7 @@ def get_parser_help(parser: ArgumentParser, strip=False) -> str:
 
 def get_parse_args_stdout(parser: ArgumentParser, args: List[str]) -> str:
     out = StringIO()
-    with patch.dict(os.environ, columns_env), redirect_stdout(out), pytest.raises(SystemExit):
+    with patch.dict(os.environ, {"COLUMNS": columns}), redirect_stdout(out), pytest.raises(SystemExit):
         parser.parse_args(args)
     return out.getvalue()
 
diff --git a/jsonargparse_tests/test_parsing_settings.py b/jsonargparse_tests/test_parsing_settings.py
new file mode 100644
index 0000000..380c5dd
--- /dev/null
+++ b/jsonargparse_tests/test_parsing_settings.py
@@ -0,0 +1,154 @@
+import re
+from dataclasses import dataclass
+from typing import Literal, Optional
+from unittest.mock import patch
+
+import pytest
+
+from jsonargparse import ActionYesNo, ArgumentError, Namespace, set_parsing_settings
+from jsonargparse._common import get_parsing_setting
+from jsonargparse_tests.conftest import capture_logs, get_parse_args_stdout, get_parser_help
+from jsonargparse_tests.test_typehints import Optimizer
+
+
+@pytest.fixture(autouse=True)
+def patch_parsing_settings():
+    with patch.dict("jsonargparse._common.parsing_settings"):
+        yield
+
+
+def test_get_parsing_setting_failure():
+    with pytest.raises(ValueError, match="Unknown parsing setting"):
+        get_parsing_setting("unknown_setting")
+
+
+# parse_optionals_as_positionals
+
+
+def test_set_parse_optionals_as_positionals_failure():
+    with pytest.raises(ValueError, match="parse_optionals_as_positionals must be a boolean"):
+        set_parsing_settings(parse_optionals_as_positionals="invalid")
+
+
+def test_parse_optionals_as_positionals_simple(parser, logger, 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"):
+        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)
+
+    parser.logger = logger
+    with subtests.test("unrecognized arguments"):
+        with capture_logs(logger) as logs:
+            with pytest.raises(ArgumentError, match="Unrecognized arguments: --unk=x"):
+                parser.parse_args(["--unk=x"])
+        assert "Positional argument p1 missing, aborting _positional_optionals" in logs.getvalue()
+
+
+def test_parse_optionals_as_positionals_subcommands(parser, subparser, subtests):
+    set_parsing_settings(parse_optionals_as_positionals=True)
+
+    subparser.add_argument("p1", type=Optional[Literal["p1"]])
+    subparser.add_argument("--o1", type=Optional[int])
+    subparser.add_argument("--o2", type=Optional[Literal["o2"]])
+    parser.add_argument("--g1")
+    subcommands = parser.add_subcommands()
+    subcommands.add_subcommand("subcmd", subparser)
+
+    with subtests.test("help global"):
+        help_str = get_parser_help(parser)
+        assert " [g1]" not in help_str
+        assert "extra positionals are parsed as optionals in the order shown above" not in help_str
+
+    with subtests.test("help subcommand"):
+        help_str = get_parse_args_stdout(parser, ["subcmd", "-h"])
+        assert " p1 [o1 [o2]]" 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(["subcmd", "--o2=o2", "--o1=1", "p1"])
+        assert cfg.subcmd == Namespace(p1="p1", o1=1, o2="o2")
+
+    with subtests.test("one extra positional"):
+        cfg = parser.parse_args(["subcmd", "--o2=o2", "p1", "2"])
+        assert cfg.subcmd == Namespace(p1="p1", o1=2, o2="o2")
+
+    with subtests.test("two extra positionals"):
+        cfg = parser.parse_args(["subcmd", "p1", "3", "o2"])
+        assert cfg.subcmd == Namespace(p1="p1", o1=3, o2="o2")
+
+    with subtests.test("extra positionals invalid values"):
+        with pytest.raises(ArgumentError) as ex:
+            parser.parse_args(["subcmd", "p1", "o2", "5"])
+        assert re.match('Parser key "o1".*Given value: o2', ex.value.message, re.DOTALL)
+
+
+def test_optionals_as_positionals_usage_wrap(parser):
+    set_parsing_settings(parse_optionals_as_positionals=True)
+
+    parser.prog = "long_prog_name"
+    parser.add_argument("relatively_long_positional")
+    parser.add_argument("--first_long_optional")
+    parser.add_argument("--second_long_optional")
+
+    help_str = get_parser_help(parser, columns="80")
+    assert "usage: long_prog_name " in help_str
+    assert "                      relatively_long_positional" in help_str
+    assert "                      [first_long_optional [second_long_optional]]" in help_str
+
+
+@dataclass
+class DataOptions:
+    d1: int = 1
+
+
+def test_optionals_as_positionals_unsupported_arguments(parser):
+    set_parsing_settings(parse_optionals_as_positionals=True)
+
+    parser.add_argument("p1", type=Optional[Literal["p1"]])
+    parser.add_argument("--o1", type=Optimizer)
+    parser.add_argument("--o2", type=Optional[int])
+    parser.add_argument("--o3", type=DataOptions)
+    parser.add_argument("--o4.n1", type=float)
+
+    help_str = get_parser_help(parser)
+    assert " p1 [o2 [o3.d1 [o4.n1]]]" in help_str
+
+    help_str = get_parse_args_stdout(parser, ["--o1.help=Adam"])
+    assert "extra positionals are parsed as optionals in the order shown above" not in help_str