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
2 changes: 1 addition & 1 deletion .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@ jobs:
with:
python-version: "3.12"
cache: pip
- run: pip install -e .[all,doc]
- run: pip install -e .[all,shtab,doc]
- name: Run doc tests
run: sphinx-build -M doctest sphinx sphinx/_build sphinx/index.rst

Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ Added
- ``auto_cli`` now supports a ``return_instance`` parameter to instantiate class
components directly instead of exposing methods as subcommands (`#855
<https://github.com/omni-us/jsonargparse/pull/855>`__).
- New ``ArgumentParser.get_completion_script(completion_type)`` public method
to generate completion scripts programmatically, and new
``set_parsing_settings(add_print_completion_argument=...)`` setting and
``JSONARGPARSE_ADD_PRINT_COMPLETION_ARGUMENT`` environment variable to opt-in
automatic addition of ``--print_completion`` when ``shtab`` is installed
(`#859 <https://github.com/omni-us/jsonargparse/pull/859>`__).

Fixed
^^^^^
Expand Down Expand Up @@ -54,6 +60,12 @@ Changed
<https://github.com/omni-us/jsonargparse/pull/843>`__).
- Adding argument giving both ``type`` and ``action`` is now allowed
(`#845 <https://github.com/omni-us/jsonargparse/pull/845>`__).
- Parsers no longer auto-add ``--print_shtab`` by default. Completion script
generation now uses ``shtab-*`` completion types, and when
``get_completion_script`` is used the parser instance is invalidated for
further use. A hidden ``--print_shtab`` argument remains to guide users to
the new opt-in completion setting (`#859
<https://github.com/omni-us/jsonargparse/pull/859>`__).


v4.46.0 (2026-02-02)
Expand Down
60 changes: 51 additions & 9 deletions DOCUMENTATION.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2883,25 +2883,67 @@ automatically by :meth:`parse_args <.ArgumentParser.parse_args>`. The only
requirement is to install shtab either directly or by installing jsonargparse
with the ``shtab`` extra as explained in section :ref:`installation`.

.. note::
There are two ways to generate shell completion scripts when ``shtab`` is
installed: via the :meth:`.ArgumentParser.get_completion_script` method or by
enabling a command-line argument.

Programmatic generation
^^^^^^^^^^^^^^^^^^^^^^^

The :meth:`.ArgumentParser.get_completion_script` method can be used to
generate completion scripts programmatically. The method accepts a
``completion_type`` parameter that specifies the shell. For shtab, use
``shtab-`` followed by the shell name (e.g., ``shtab-bash``, ``shtab-zsh``).

.. testcode::

from jsonargparse import ArgumentParser

parser = ArgumentParser(prog="example")
parser.add_argument("--bool", type=bool)

script = parser.get_completion_script("shtab-bash", preambles=[])
# script now contains the bash completion script

.. warning::

Automatic shtab support is currently experimental and subject to change.
After calling :meth:`.get_completion_script`, the parser instance is
invalidated and cannot be used for parsing arguments. Create a new parser
instance if you need to parse arguments afterward.

Once ``shtab`` is installed, parsers will automatically have the
``--print_shtab`` option that can be used to print the completion script for the
supported shells. For example in linux to enable bash completions for all users,
as root it would be used as:
Command-line argument
^^^^^^^^^^^^^^^^^^^^^

To enable generation of completion scripts via a command-line argument, use
:func:`.set_parsing_settings` with ``add_print_completion_argument=True``. This
adds a ``--print_completion`` argument to top-level parsers (not subparsers).

.. testcode::

from jsonargparse import set_parsing_settings

set_parsing_settings(add_print_completion_argument=True)

With this setting enabled, completion scripts can be generated from the command
line. For example, in Linux to enable bash completions for all users, as root:

.. code-block:: bash

# example.py --print_shtab=bash > /etc/bash_completion.d/example
# example.py --print_completion=shtab-bash > /etc/bash_completion.d/example

Without installing, completion scripts can be tested by sourcing or evaluating
them, for instance:
them:

.. code-block:: bash

$ eval "$(example.py --print_shtab=bash)"
$ eval "$(example.py --print_completion=shtab-bash)"

Without changing python code, it is also possible to add the ``--print_completion``
argument by setting the environment variable
``JSONARGPARSE_ADD_PRINT_COMPLETION_ARGUMENT=true``.

Completion behavior
^^^^^^^^^^^^^^^^^^^

The scripts work both to complete when there are choices, but also gives
instructions to the user for guidance. Take for example the parser:
Expand Down
28 changes: 23 additions & 5 deletions jsonargparse/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,17 @@
parsing_settings = {
"validate_defaults": False,
"parse_optionals_as_positionals": False,
"add_print_completion_argument": False,
"stubs_resolver_allow_py_files": False,
"omegaconf_absolute_to_relative_paths": False,
}


def get_env_var_bool(name: str) -> bool:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for merging!

hmm, wouldn't this be useful also for "store_true" booleans' env vars?

Copy link
Copy Markdown
Member Author

@mauvilsa mauvilsa Mar 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that you mention this, I am thinking of changing the parsing of JSONARGPARSE_ADD_PRINT_COMPLETION_ARGUMENT to be more strict and only allow true or false. Accepting any string can create confusion, like people setting flase, disable, off, etc. and unexpectedly getting a true. For store_true better strict as it is now.

return os.getenv(name, "").lower() not in {"", "false", "no", "0"}


def set_parsing_settings(

Check failure on line 112 in jsonargparse/_common.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&issues=AZytAIFW9jqovX7UMhXy&open=AZytAIFW9jqovX7UMhXy&pullRequest=859
*,
validate_defaults: Optional[bool] = None,
config_read_mode_urls_enabled: Optional[bool] = None,
Expand All @@ -112,13 +117,14 @@
docstring_parse_style: Optional["docstring_parser.DocstringStyle"] = None,
docstring_parse_attribute_docstrings: Optional[bool] = None,
parse_optionals_as_positionals: Optional[bool] = None,
add_print_completion_argument: Optional[bool] = None,
stubs_resolver_allow_py_files: Optional[bool] = None,
omegaconf_absolute_to_relative_paths: Optional[bool] = None,
subclasses_disabled: Optional[list[Union[type, Callable[[type], bool]]]] = None,
subclasses_enabled: Optional[list[Union[type, str]]] = None,
) -> None:
"""
Modify settings that affect the parsing behavior.
Modify global parser settings that affect parser creation and parsing behavior.

Args:
validate_defaults: Whether default values must be valid according to the
Expand All @@ -138,6 +144,9 @@
``--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. By default, this is ``False``.
add_print_completion_argument: If ``True``, top-level parsers
automatically include ``--print_completion`` argument when
``shtab`` is installed.
stubs_resolver_allow_py_files: Whether the stubs resolver should search
in ``.py`` files in addition to ``.pyi`` files.
omegaconf_absolute_to_relative_paths: If ``True``, when loading configs
Expand Down Expand Up @@ -175,6 +184,11 @@
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}.")
# add_print_completion_argument
if isinstance(add_print_completion_argument, bool):
parsing_settings["add_print_completion_argument"] = add_print_completion_argument
elif add_print_completion_argument is not None:
raise ValueError(f"add_print_completion_argument must be a boolean, but got {add_print_completion_argument}.")
# stubs resolver
if isinstance(stubs_resolver_allow_py_files, bool):
parsing_settings["stubs_resolver_allow_py_files"] = stubs_resolver_allow_py_files
Expand All @@ -198,6 +212,10 @@
def get_parsing_setting(name: str):
if name not in parsing_settings:
raise ValueError(f"Unknown parsing setting {name}.")
if name == "add_print_completion_argument":
var_name = "JSONARGPARSE_ADD_PRINT_COMPLETION_ARGUMENT"
if var_name in os.environ:
return get_env_var_bool(var_name)
return parsing_settings[name]


Expand All @@ -218,13 +236,13 @@


def get_optionals_as_positionals_actions(parser, include_positionals=False):
from jsonargparse._actions import ActionConfigFile, _ActionConfigLoad, filter_non_parsing_actions
from jsonargparse._completions import ShtabAction
from jsonargparse._actions import ActionConfigFile, ActionFail, _ActionConfigLoad, filter_non_parsing_actions
from jsonargparse._completions import PrintCompletionAction
from jsonargparse._typehints import ActionTypeHint

actions = []
for action in filter_non_parsing_actions(parser._actions):
if isinstance(action, (_ActionConfigLoad, ActionConfigFile, ShtabAction)):
if isinstance(action, (_ActionConfigLoad, ActionConfigFile, ActionFail, PrintCompletionAction)):
continue
if ActionTypeHint.is_subclass_typehint(action, all_subtypes=False):
continue
Expand Down Expand Up @@ -465,7 +483,7 @@


def debug_mode_active() -> bool:
return os.getenv("JSONARGPARSE_DEBUG", "").lower() not in {"", "false", "no", "0"}
return get_env_var_bool("JSONARGPARSE_DEBUG")


if debug_mode_active():
Expand Down
76 changes: 57 additions & 19 deletions jsonargparse/_completions.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@
from enum import Enum
from importlib.util import find_spec
from subprocess import PIPE, Popen
from typing import Literal, Union
from typing import Literal, Optional, Union

from ._actions import ActionConfigFile, _ActionConfigLoad, _ActionHelpClassPath, remove_actions
from ._actions import ActionConfigFile, ActionFail, _ActionConfigLoad, _ActionHelpClassPath, remove_actions
from ._common import NonParsingAction, get_optionals_as_positionals_actions, get_parsing_setting
from ._parameter_resolvers import get_signature_parameters
from ._typehints import (
Expand All @@ -28,6 +28,11 @@


def handle_completions(parser):
handle_argcomplete_autocomplete(parser)
add_print_completion_argument(parser)


def handle_argcomplete_autocomplete(parser):
if find_spec("argcomplete") and "_ARGCOMPLETE" in os.environ:
import argcomplete

Expand All @@ -36,9 +41,23 @@ def handle_completions(parser):
with parser_context(load_value_mode=parser.parser_mode):
argcomplete.autocomplete(parser)

if find_spec("shtab") and not getattr(parser, "parent_parser", None):
if not any(isinstance(action, ShtabAction) for action in parser._actions):
parser.add_argument("--print_shtab", action=ShtabAction)

def add_print_completion_argument(parser):
if getattr(parser, "parent_parser", None) or not find_spec("shtab"):
return
print_completion_argument = get_parsing_setting("add_print_completion_argument")
if not print_completion_argument and "--print_shtab" not in parser._option_string_actions:
parser.add_argument(
"--print_shtab",
action=ActionFail(
message="%(option)s is no longer supported. Use set_parsing_settings("
"add_print_completion_argument=True) or "
"JSONARGPARSE_ADD_PRINT_COMPLETION_ARGUMENT=true to add --print_completion."
),
help=argparse.SUPPRESS,
)
elif print_completion_argument and "--print_completion" not in parser._option_string_actions:
parser.add_argument("--print_completion", action=PrintCompletionAction)


# argcomplete
Expand Down Expand Up @@ -78,7 +97,7 @@ def argcomplete_warn_redraw_prompt(prefix, message):
shtab_preambles: ContextVar = ContextVar("shtab_preambles")


class ShtabAction(NonParsingAction):
class PrintCompletionAction(NonParsingAction):
def __init__(
self,
option_strings,
Expand All @@ -92,22 +111,38 @@ def __init__(
option_strings=option_strings,
dest=dest,
default=default,
choices=shtab.SUPPORTED_SHELLS,
help="Print shtab shell completion script.",
choices=[f"shtab-{shell}" for shell in shtab.SUPPORTED_SHELLS],
help="Print shell completion script.",
)

def __call__(self, parser, namespace, shell, option_string=None):
import shtab
def __call__(self, parser, namespace, completion_type, option_string=None):
print(parser.get_completion_script(completion_type))
argparse.ArgumentParser.exit(parser, 0)


def get_completion_script(parser, completion_type: str, **kwargs) -> str:
if not completion_type.startswith("shtab-"):
raise ValueError(f"Unsupported completion_type: {completion_type}.")
if not find_spec("shtab"):
raise ValueError(f"shtab package is required for completion type '{completion_type}'.")
return get_shtab_script(parser, completion_type[len("shtab-") :], **kwargs)


def get_shtab_script(parser, shell: str, preambles: Optional[list[str]] = None) -> str:
import shtab

if shell not in shtab.SUPPORTED_SHELLS:
raise ValueError(f"Unsupported completion_type: shtab-{shell}.")

prog = norm_name(parser.prog)
assert prog
prog = norm_name(parser.prog)
assert prog
if not preambles:
preambles = []
if shell == "bash":
preambles = [bash_compgen_typehint.strip().replace("%s", prog)]
with prepare_actions_context(shell, prog, preambles):
shtab_prepare_actions(parser)
print(shtab.complete(parser, shell, preamble="\n".join(preambles)))
parser.exit(0)
if shell == "bash":
preambles += [bash_compgen_typehint.strip().replace("%s", prog)]
with prepare_actions_context(shell, prog, preambles):
shtab_prepare_actions(parser)
return shtab.complete(parser, shell, preamble="\n".join(preambles))


@contextmanager
Expand All @@ -128,7 +163,10 @@ def norm_name(name: str) -> str:


def shtab_prepare_actions(parser) -> None:
remove_actions(parser, (ShtabAction,))
remove_actions(parser, (PrintCompletionAction,))
legacy_action = parser._option_string_actions.get("--print_shtab")
if legacy_action and legacy_action in parser._actions:
parser._actions.remove(legacy_action)
if parser._subcommands_action:
for subparser in parser._subcommands_action._name_parser_map.values():
shtab_prepare_actions(subparser)
Expand Down
27 changes: 27 additions & 0 deletions jsonargparse/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
argcomplete_namespace,
handle_completions,
)
from ._completions import (
get_completion_script as get_completion_script_internal,
)
from ._deprecated import ParserDeprecations, deprecated_skip_check, deprecated_yaml_comments
from ._formatters import DefaultHelpFormatter, get_env_var
from ._jsonnet import ActionJsonnet
Expand Down Expand Up @@ -1065,6 +1068,30 @@ def get_defaults(self, skip_validation: bool = False, **kwargs) -> Namespace:

return cfg

## Completion script methods ##

def _raise_invalidated_by_completion_script(self, *args, **kwargs) -> NoReturn:
raise ValueError(
"Parser instance was invalidated by get_completion_script() and cannot be reused. "
"Create a new parser instance."
)

def _invalidate_by_completion_script(self) -> None:
for name in dir(self):
if name.startswith("_"):
continue
static_attr = inspect.getattr_static(self, name, None)
if inspect.isroutine(static_attr):
attr = getattr(self, name, None)
if inspect.ismethod(attr):
setattr(self, name, self._raise_invalidated_by_completion_script)

def get_completion_script(self, completion_type: str, **kwargs) -> str:
"""Returns shell completion script for a completion type."""
completion_script = get_completion_script_internal(self, completion_type, **kwargs)
self._invalidate_by_completion_script()
return completion_script

## Other methods ##

def error(self, message: str, ex: Optional[Exception] = None) -> NoReturn:
Expand Down
6 changes: 6 additions & 0 deletions jsonargparse_tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,12 @@ def example_parser() -> ArgumentParser:
return parser


@pytest.fixture
def parsing_settings_patch():
with patch.dict("jsonargparse._common.parsing_settings"):
yield


@pytest.fixture
def subclass_behavior(monkeypatch) -> Iterator[None]:
monkeypatch.setattr("jsonargparse._common.subclasses_enabled_types", set())
Expand Down
10 changes: 10 additions & 0 deletions jsonargparse_tests/test_parsing_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,16 @@ def test_optionals_as_positionals_unsupported_arguments(parser):
# stubs_resolver_allow_py_files


def test_set_print_completion_argument_failure():
with pytest.raises(ValueError, match="add_print_completion_argument must be a boolean"):
set_parsing_settings(add_print_completion_argument="invalid")


def test_set_print_completion_argument_success():
set_parsing_settings(add_print_completion_argument=True)
assert get_parsing_setting("add_print_completion_argument")


def test_set_stubs_resolver_allow_py_files_failure():
with pytest.raises(ValueError, match="stubs_resolver_allow_py_files must be a boolean"):
set_parsing_settings(stubs_resolver_allow_py_files="invalid")
Expand Down
Loading
Loading