Skip to content
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
11 changes: 11 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ Added
^^^^^
- Support ``Deque`` and ``FrozenSet`` in type hints (`#905
<https://github.com/omni-us/jsonargparse/pull/905>`__).
- Add :obj:`.Unset` sentinel and ``unset_sentinel`` setting in
``.set_parsing_settings`` to distinguish between arguments not provided and
those explicitly set to ``null`` (`#909
<https://github.com/omni-us/jsonargparse/pull/909>`__).

Changed
^^^^^^^
Expand Down Expand Up @@ -56,6 +60,13 @@ Deprecated
- ``enable_path`` parameter of :class:`.ActionJsonSchema` was deprecated and
will be removed in v5.0.0. Use ``sub_config`` instead (`#907
<https://github.com/omni-us/jsonargparse/pull/907>`__).
- ``skip_none`` parameter of :meth:`dump <.ArgumentParser.dump>`, :meth:`save
<.ArgumentParser.save>`, and :meth:`validate <.ArgumentParser.validate>` was
deprecated and will be removed in v5.0.0. Use ``skip_unset`` instead (`#909
<https://github.com/omni-us/jsonargparse/pull/909>`__).
- ``skip_null`` flag for ``--print_config`` was deprecated and will be removed
in v5.0.0. Use ``skip_unset`` instead (`#909
<https://github.com/omni-us/jsonargparse/pull/909>`__).


v4.48.0 (2026-04-10)
Expand Down
68 changes: 67 additions & 1 deletion DOCUMENTATION.rst
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,71 @@ form like ``--module.child=...``, the parsing will fail and display the
configured error message.


.. _unset-values:

Unset values
------------

By default, jsonargparse follows argparse behavior: an argument that is not
provided on the command line is given the value ``None`` in the parsed
namespace. This makes it impossible to distinguish between an argument that was
explicitly set to ``None`` (e.g. ``--opt=null``) and one that was simply
omitted.

The :obj:`.Unset` sentinel (enabled via
``set_parsing_settings(unset_sentinel=True)``) addresses this by using a
dedicated sentinel object as the default for arguments that have no explicitly
provided ``default`` value. The three possible states for an argument then
become:

- :obj:`.Unset` – the argument was not provided and no ``default`` was given in
``add_argument``.
- ``None`` – the argument was either explicitly set to ``null``, or its
``add_argument`` call included ``default=None``.
- Any other value – the argument was provided with that value (or defaults to
it).

Example:

.. testcode:: unset-values

from jsonargparse import ArgumentParser, Unset, set_parsing_settings
from typing import Optional

set_parsing_settings(unset_sentinel=True)

parser = ArgumentParser()
parser.add_argument("--num", type=Optional[int]) # no default given
parser.add_argument("--flag", type=Optional[int], default=None) # explicit None

cfg = parser.parse_args([])
assert cfg.num is Unset # no default → Unset
assert cfg.flag is None # explicit default=None → None

cfg = parser.parse_args(["--num=null"])
assert cfg.num is None # explicitly set to null

cfg = parser.parse_args(["--num=5"])
assert cfg.num == 5 # provided value

.. testcleanup:: docstrings

set_parsing_settings(unset_sentinel=False)

The ``skip_unset`` parameter of :meth:`dump <.ArgumentParser.dump>`, :meth:`save
<.ArgumentParser.save>`, and :meth:`validate <.ArgumentParser.validate>`
controls whether :obj:`.Unset` entries are excluded. The
``--print_config=skip_unset`` flag does the same for command-line use.

**Relation to** ``argument_default=SUPPRESS``

Argparse's ``argument_default=SUPPRESS`` (and per-argument ``default=SUPPRESS``)
is a complementary mechanism: it causes an unprovided argument to be
**completely absent** from the parsed namespace, i.e. it has no key at all.
These two features play well together and represent different levels of
"absence".


.. _type-hints:

Type hints
Expand Down Expand Up @@ -1283,7 +1348,8 @@ with a large set of options to create an initial config file including all
default values. If the `ruamel.yaml <https://pypi.org/project/ruamel.yaml>`__
package is installed, the config can be printed having the help descriptions
content as YAML comments by using ``--print_config=comments``. Another option is
``--print_config=skip_null`` which skips entries whose value is ``null``.
``--print_config=skip_unset`` which skips entries whose value is the configured
unset value (see :ref:`unset-values`).

From within Python it is also possible to serialize a config object by using
either the :meth:`dump <.ArgumentParser.dump>` or :meth:`save
Expand Down
14 changes: 10 additions & 4 deletions jsonargparse/_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,14 +170,16 @@ def __init__(
help=(
"Print the configuration after applying all other arguments and exit. The optional "
"flags customizes the output and are one or more keywords separated by comma. The "
"supported flags are:%s skip_default, skip_null."
"supported flags are:%s skip_default, skip_unset."
)
% (" comments," if ruamel_support else ""),
)

def __call__(self, parser, namespace, value, option_string=None):
kwargs = {"subparser": parser, "key": None, "skip_none": False, "skip_validation": False}
valid_flags = {"": None, "skip_default": "skip_default", "skip_null": "skip_none"}
from ._deprecated import deprecated_skip_null, deprecated_valid_flags

kwargs = {"subparser": parser, "key": None, "skip_unset": False, "skip_validation": False}
valid_flags = {"": None, "skip_default": "skip_default", "skip_unset": "skip_unset"} | deprecated_valid_flags
if ruamel_support:
valid_flags["comments"] = "with_comments"
if value is not None:
Expand All @@ -186,7 +188,11 @@ def __call__(self, parser, namespace, value, option_string=None):
if len(invalid_flags) > 0:
raise argument_error(f'Invalid option "{invalid_flags[0]}" for {option_string}')
for flag in [f for f in flags if f != ""]:
kwargs[valid_flags[flag]] = True
mapped = valid_flags[flag]
if deprecated_skip_null(flag):
kwargs["skip_unset"] = True
else:
kwargs[mapped] = True
while hasattr(parser, "parent_parser"):
kwargs["key"] = parser.subcommand if kwargs["key"] is None else parser.subcommand + "." + kwargs["key"]
parser = parser.parent_parser
Expand Down
43 changes: 41 additions & 2 deletions jsonargparse/_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from ._type_checking import ActionsContainer, ArgumentParser, docstring_parser

__all__ = [
"Unset",
"set_parsing_settings",
]

Expand All @@ -44,6 +45,27 @@
capture_typing_extension_shadows(_UnpackGenericAlias, "_UnpackGenericAlias", unpack_meta_types)


class _UnsetType:
"""Sentinel class for unset argument values."""

_instance = None
_SERIALIZED = "==UNSET=="

def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance

def __repr__(self):
return "Unset"

def __bool__(self):
return False


Unset = _UnsetType()


class InstantiatorCallable(Protocol):
def __call__(self, class_type: type[ClassType], *args, **kwargs) -> ClassType:
pass # pragma: no cover
Expand Down Expand Up @@ -96,12 +118,13 @@ def parser_context(**kwargs):
context_var.reset(token)


parsing_settings = {
parsing_settings: dict = {
"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,
"unset_sentinel": None,
}


Expand All @@ -124,6 +147,7 @@ def set_parsing_settings(
add_print_completion_argument: Optional[bool] = None,
stubs_resolver_allow_py_files: Optional[bool] = None,
omegaconf_absolute_to_relative_paths: Optional[bool] = None,
unset_sentinel: Optional[bool] = None,
subclasses_disabled: Optional[list[Union[type, Callable[[type], bool]]]] = None,
subclasses_enabled: Optional[list[Union[type, str]]] = None,
) -> None:
Expand Down Expand Up @@ -157,6 +181,12 @@ def set_parsing_settings(
with ``omegaconf+`` parser mode, absolute interpolation paths are
converted to relative. This is only intended for backward
compatibility with ``omegaconf`` parser mode.
unset_sentinel: If ``True``, parsers will use the :obj:`.Unset` sentinel
for arguments that have not been given a value (instead of
``None``). This allows distinguishing between ``None`` as an
explicitly given value and an argument that was not provided at
all. If ``False``, uses ``None`` (the default, argparse-compatible
behavior) unless overridden by ``argument_default``.
subclasses_disabled: List of types or functions, so that when parsing
only the exact type hints (not their subclasses) are accepted.
Descendants of the configured types are also disabled. Functions
Expand Down Expand Up @@ -205,6 +235,11 @@ def set_parsing_settings(
raise ValueError(
f"omegaconf_absolute_to_relative_paths must be a boolean, but got {omegaconf_absolute_to_relative_paths}."
)
# unset_sentinel
if isinstance(unset_sentinel, bool):
parsing_settings["unset_sentinel"] = Unset if unset_sentinel else None
elif unset_sentinel is not None:
raise ValueError(f"unset_sentinel must be a boolean, but got {unset_sentinel}.")
# subclass behavior
if subclasses_disabled or subclasses_enabled:
subclass_type_behavior(
Expand All @@ -224,7 +259,11 @@ def get_parsing_setting(name: str):


def validate_default(container: ActionsContainer, action: argparse.Action):
if action.default is None or not get_parsing_setting("validate_defaults") or not hasattr(action, "_check_type"):
if (
action.default is get_parsing_setting("unset_sentinel")
or not get_parsing_setting("validate_defaults")
or not hasattr(action, "_check_type")
):
return
try:
from ._core import ArgumentGroup
Expand Down
Loading
Loading