Skip to content

Commit 02e418c

Browse files
authored
Add support for Unset sentinel and deprecate skip_none/skip_null in favor skip_unset (#909)
1 parent c9c55fe commit 02e418c

16 files changed

Lines changed: 524 additions & 49 deletions

CHANGELOG.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ Added
2020
- ``FromConfigMixin.from_config`` with ``config_read_mode_fsspec_enabled=True``
2121
now supports fsspec config loading including the resolving of relative paths
2222
(`#918 <https://github.com/omni-us/jsonargparse/pull/918>`__).
23+
- Add :obj:`.Unset` sentinel and ``unset_sentinel`` setting in
24+
``.set_parsing_settings`` to distinguish between arguments not provided and
25+
those explicitly set to ``null`` (`#909
26+
<https://github.com/omni-us/jsonargparse/pull/909>`__).
2327

2428
Fixed
2529
^^^^^
@@ -32,6 +36,16 @@ Changed
3236
- Drop support for Python 3.9. The minimum supported Python version is now
3337
3.10 (`#916 <https://github.com/omni-us/jsonargparse/pull/916>`__).
3438

39+
Deprecated
40+
^^^^^^^^^^
41+
- ``skip_none`` parameter of :meth:`dump <.ArgumentParser.dump>`, :meth:`save
42+
<.ArgumentParser.save>`, and :meth:`validate <.ArgumentParser.validate>` was
43+
deprecated and will be removed in v5.0.0. Use ``skip_unset`` instead (`#909
44+
<https://github.com/omni-us/jsonargparse/pull/909>`__).
45+
- ``skip_null`` flag for ``--print_config`` was deprecated and will be removed
46+
in v5.0.0. Use ``skip_unset`` instead (`#909
47+
<https://github.com/omni-us/jsonargparse/pull/909>`__).
48+
3549

3650
v4.49.0 (2026-05-15)
3751
--------------------

DOCUMENTATION.rst

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,70 @@ form like ``--module.child=...``, the parsing will fail and display the
414414
configured error message.
415415

416416

417+
.. _unset-values:
418+
419+
Unset values
420+
------------
421+
422+
By default, jsonargparse follows argparse behavior: an argument that is not
423+
provided on the command line is given the value ``None`` in the parsed
424+
namespace. This makes it impossible to distinguish between an argument that was
425+
explicitly set to ``None`` (e.g. ``--opt=null``) and one that was simply
426+
omitted.
427+
428+
The :obj:`.Unset` sentinel (enabled via
429+
``set_parsing_settings(unset_sentinel=True)``) addresses this by using a
430+
dedicated sentinel object as the default for arguments that have no explicitly
431+
provided ``default`` value. The three possible states for an argument then
432+
become:
433+
434+
- :obj:`.Unset` – the argument was not provided and no ``default`` was given in
435+
``add_argument``.
436+
- ``None`` – the argument was either explicitly set to ``null``, or its
437+
``add_argument`` call included ``default=None``.
438+
- Any other value – the argument was provided with that value (or defaults to
439+
it).
440+
441+
Example:
442+
443+
.. testcode:: unset-values
444+
445+
from jsonargparse import ArgumentParser, Unset, set_parsing_settings
446+
447+
set_parsing_settings(unset_sentinel=True)
448+
449+
parser = ArgumentParser()
450+
parser.add_argument("--num", type=int | None) # no default given
451+
parser.add_argument("--flag", type=int | None, default=None) # explicit None
452+
453+
cfg = parser.parse_args([])
454+
assert cfg.num is Unset # no default → Unset
455+
assert cfg.flag is None # explicit default=None → None
456+
457+
cfg = parser.parse_args(["--num=null"])
458+
assert cfg.num is None # explicitly set to null
459+
460+
cfg = parser.parse_args(["--num=5"])
461+
assert cfg.num == 5 # provided value
462+
463+
.. testcleanup:: docstrings
464+
465+
set_parsing_settings(unset_sentinel=False)
466+
467+
The ``skip_unset`` parameter of :meth:`dump <.ArgumentParser.dump>`, :meth:`save
468+
<.ArgumentParser.save>`, and :meth:`validate <.ArgumentParser.validate>`
469+
controls whether :obj:`.Unset` entries are excluded. The
470+
``--print_config=skip_unset`` flag does the same for command-line use.
471+
472+
**Relation to** ``argument_default=SUPPRESS``
473+
474+
Argparse's ``argument_default=SUPPRESS`` (and per-argument ``default=SUPPRESS``)
475+
is a complementary mechanism: it causes an unprovided argument to be
476+
**completely absent** from the parsed namespace, i.e. it has no key at all.
477+
These two features play well together and represent different levels of
478+
"absence".
479+
480+
417481
.. _type-hints:
418482

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

12901355
From within Python it is also possible to serialize a config object by using
12911356
either the :meth:`dump <.ArgumentParser.dump>` or :meth:`save

jsonargparse/_actions.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from contextvars import ContextVar
99
from typing import Any
1010

11-
from ._common import Action, NonParsingAction, is_subclass, is_subclasses_disabled, parser_context
11+
from ._common import Action, NonParsingAction, get_parsing_setting, is_subclass, is_subclasses_disabled, parser_context
1212
from ._loaders_dumpers import get_loader_exceptions, load_value
1313
from ._namespace import Namespace
1414
from ._optionals import _get_config_read_mode, ruamel_support
@@ -129,7 +129,7 @@ def apply_config(parser, cfg, dest, value) -> None:
129129
cfg_file = parser.parse_path(value, **kwargs)
130130
cfg_merged = parser.merge_config(cfg_file, cfg)
131131
cfg.__dict__.update(cfg_merged.__dict__)
132-
if cfg.get(dest) is None:
132+
if cfg.get(dest) is get_parsing_setting("unset_sentinel"):
133133
cfg[dest] = []
134134
cfg[dest].append(cfg_path)
135135

@@ -171,14 +171,16 @@ def __init__(
171171
help=(
172172
"Print the configuration after applying all other arguments and exit. The optional "
173173
"flags customizes the output and are one or more keywords separated by comma. The "
174-
"supported flags are:%s skip_default, skip_null."
174+
"supported flags are:%s skip_default, skip_unset."
175175
)
176176
% (" comments," if ruamel_support else ""),
177177
)
178178

179179
def __call__(self, parser, namespace, value, option_string=None):
180-
kwargs = {"subparser": parser, "key": None, "skip_none": False, "skip_validation": False}
181-
valid_flags = {"": None, "skip_default": "skip_default", "skip_null": "skip_none"}
180+
from ._deprecated import deprecated_skip_null, deprecated_valid_flags
181+
182+
kwargs = {"subparser": parser, "key": None, "skip_unset": False, "skip_validation": False}
183+
valid_flags = {"": None, "skip_default": "skip_default", "skip_unset": "skip_unset"} | deprecated_valid_flags
182184
if ruamel_support:
183185
valid_flags["comments"] = "with_comments"
184186
if value is not None:
@@ -187,7 +189,11 @@ def __call__(self, parser, namespace, value, option_string=None):
187189
if len(invalid_flags) > 0:
188190
raise argument_error(f'Invalid option "{invalid_flags[0]}" for {option_string}')
189191
for flag in [f for f in flags if f != ""]:
190-
kwargs[valid_flags[flag]] = True
192+
mapped = valid_flags[flag]
193+
if deprecated_skip_null(flag):
194+
kwargs["skip_unset"] = True
195+
else:
196+
kwargs[mapped] = True
191197
while hasattr(parser, "parent_parser"):
192198
kwargs["key"] = parser.subcommand if kwargs["key"] is None else parser.subcommand + "." + kwargs["key"]
193199
parser = parser.parent_parser

jsonargparse/_common.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from ._type_checking import ActionsContainer, ArgumentParser, docstring_parser
3030

3131
__all__ = [
32+
"Unset",
3233
"set_parsing_settings",
3334
]
3435

@@ -42,6 +43,27 @@
4243
capture_typing_extension_shadows(_UnpackGenericAlias, "_UnpackGenericAlias", unpack_meta_types)
4344

4445

46+
class _UnsetType:
47+
"""Sentinel class for unset argument values."""
48+
49+
_instance = None
50+
_SERIALIZED = "==UNSET=="
51+
52+
def __new__(cls):
53+
if cls._instance is None:
54+
cls._instance = super().__new__(cls)
55+
return cls._instance
56+
57+
def __repr__(self):
58+
return "Unset"
59+
60+
def __bool__(self):
61+
return False
62+
63+
64+
Unset = _UnsetType()
65+
66+
4567
class InstantiatorCallable(Protocol):
4668
def __call__(self, class_type: type[ClassType], *args, **kwargs) -> ClassType:
4769
pass # pragma: no cover
@@ -94,12 +116,13 @@ def parser_context(**kwargs):
94116
context_var.reset(token)
95117

96118

97-
parsing_settings = {
119+
parsing_settings: dict = {
98120
"validate_defaults": False,
99121
"parse_optionals_as_positionals": False,
100122
"add_print_completion_argument": False,
101123
"stubs_resolver_allow_py_files": False,
102124
"omegaconf_absolute_to_relative_paths": False,
125+
"unset_sentinel": None,
103126
}
104127

105128

@@ -122,6 +145,7 @@ def set_parsing_settings(
122145
add_print_completion_argument: bool | None = None,
123146
stubs_resolver_allow_py_files: bool | None = None,
124147
omegaconf_absolute_to_relative_paths: bool | None = None,
148+
unset_sentinel: bool | None = None,
125149
subclasses_disabled: list[type | Callable[[type], bool]] | None = None,
126150
subclasses_enabled: list[type | str] | None = None,
127151
) -> None:
@@ -155,6 +179,12 @@ def set_parsing_settings(
155179
with ``omegaconf+`` parser mode, absolute interpolation paths are
156180
converted to relative. This is only intended for backward
157181
compatibility with ``omegaconf`` parser mode.
182+
unset_sentinel: If ``True``, parsers will use the :obj:`.Unset` sentinel
183+
for arguments that have not been given a value (instead of
184+
``None``). This allows distinguishing between ``None`` as an
185+
explicitly given value and an argument that was not provided at
186+
all. If ``False``, uses ``None`` (the default, argparse-compatible
187+
behavior) unless overridden by ``argument_default``.
158188
subclasses_disabled: List of types or functions, so that when parsing
159189
only the exact type hints (not their subclasses) are accepted.
160190
Descendants of the configured types are also disabled. Functions
@@ -203,6 +233,11 @@ def set_parsing_settings(
203233
raise ValueError(
204234
f"omegaconf_absolute_to_relative_paths must be a boolean, but got {omegaconf_absolute_to_relative_paths}."
205235
)
236+
# unset_sentinel
237+
if isinstance(unset_sentinel, bool):
238+
parsing_settings["unset_sentinel"] = Unset if unset_sentinel else None
239+
elif unset_sentinel is not None:
240+
raise ValueError(f"unset_sentinel must be a boolean, but got {unset_sentinel}.")
206241
# subclass behavior
207242
if subclasses_disabled or subclasses_enabled:
208243
subclass_type_behavior(
@@ -222,7 +257,11 @@ def get_parsing_setting(name: str):
222257

223258

224259
def validate_default(container: ActionsContainer, action: argparse.Action):
225-
if action.default is None or not get_parsing_setting("validate_defaults") or not hasattr(action, "_check_type"):
260+
if (
261+
action.default is get_parsing_setting("unset_sentinel")
262+
or not get_parsing_setting("validate_defaults")
263+
or not hasattr(action, "_check_type")
264+
):
226265
return
227266
try:
228267
from ._core import ArgumentGroup

0 commit comments

Comments
 (0)