Skip to content

Commit 8040c8a

Browse files
committed
More places where Unset sentinel is needed.
1 parent 83466f0 commit 8040c8a

8 files changed

Lines changed: 130 additions & 36 deletions

File tree

CHANGELOG.rst

Lines changed: 14 additions & 11 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
--------------------
@@ -40,10 +54,6 @@ Added
4054
^^^^^
4155
- Support ``Deque`` and ``FrozenSet`` in type hints (`#905
4256
<https://github.com/omni-us/jsonargparse/pull/905>`__).
43-
- Add :obj:`.Unset` sentinel and ``unset_sentinel`` setting in
44-
``.set_parsing_settings`` to distinguish between arguments not provided and
45-
those explicitly set to ``null`` (`#909
46-
<https://github.com/omni-us/jsonargparse/pull/909>`__).
4757

4858
Fixed
4959
^^^^^
@@ -86,13 +96,6 @@ Deprecated
8696
- ``enable_path`` parameter of ``ActionJsonSchema`` was deprecated and will be
8797
removed in v5.0.0. Use ``sub_config`` instead (`#907
8898
<https://github.com/omni-us/jsonargparse/pull/907>`__).
89-
- ``skip_none`` parameter of :meth:`dump <.ArgumentParser.dump>`, :meth:`save
90-
<.ArgumentParser.save>`, and :meth:`validate <.ArgumentParser.validate>` was
91-
deprecated and will be removed in v5.0.0. Use ``skip_unset`` instead (`#909
92-
<https://github.com/omni-us/jsonargparse/pull/909>`__).
93-
- ``skip_null`` flag for ``--print_config`` was deprecated and will be removed
94-
in v5.0.0. Use ``skip_unset`` instead (`#909
95-
<https://github.com/omni-us/jsonargparse/pull/909>`__).
9699

97100

98101
v4.48.0 (2026-04-10)

jsonargparse/_actions.py

Lines changed: 2 additions & 2 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

jsonargparse/_core.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -772,7 +772,7 @@ def dump(
772772
cfg: The configuration object to dump.
773773
format: The output format: ``yaml``, ``json``, ``json_indented``, ``toml``, ``parser_mode`` or ones added
774774
via :func:`.set_dumper`.
775-
skip_unset: Whether to exclude entries whose value is the configured unset value.
775+
skip_unset: Whether to exclude entries whose value is the configured None/Unset value.
776776
skip_default: Whether to exclude entries whose value is the same as the default.
777777
skip_validation: Whether to skip parser checking.
778778
with_comments: Whether to add help content as comments. Currently only supported for ``format="yaml"``.
@@ -884,7 +884,7 @@ def save(
884884
path: Path to the location where to save config.
885885
format: The output format: ``yaml``, ``json``, ``json_indented``, ``parser_mode`` or ones added via
886886
:func:`.set_dumper`.
887-
skip_unset: Whether to exclude entries whose value is the configured unset value.
887+
skip_unset: Whether to exclude entries whose value is the configured None/Unset value.
888888
skip_validation: Whether to skip parser checking.
889889
overwrite: Whether to overwrite existing files.
890890
multifile: Whether to save multiple config files by using the ``__path__`` metas.
@@ -1146,7 +1146,7 @@ def validate(
11461146
11471147
Args:
11481148
cfg: The configuration object to check.
1149-
skip_unset: Whether to skip checking of values that are the configured unset value.
1149+
skip_unset: Whether to skip checking of values that are the configured None/Unset value.
11501150
skip_required: Whether to skip checking required arguments.
11511151
branch: Base key in case cfg corresponds only to a branch.
11521152
@@ -1258,8 +1258,9 @@ def get_config_files(self, cfg: Namespace) -> list[str]:
12581258
cfg_files = []
12591259
if "__default_config__" in cfg:
12601260
cfg_files.append(cfg["__default_config__"])
1261+
unset_sentinel = get_parsing_setting("unset_sentinel")
12611262
for action in filter_non_parsing_actions(self._actions):
1262-
if isinstance(action, ActionConfigFile) and action.dest in cfg and cfg[action.dest] is not None:
1263+
if isinstance(action, ActionConfigFile) and action.dest in cfg and cfg[action.dest] is not unset_sentinel:
12631264
cfg_files.extend(p for p in cfg[action.dest] if p is not None)
12641265
return cfg_files
12651266

@@ -1389,7 +1390,8 @@ def _check_value_key(
13891390
Raises:
13901391
TypeError: If the value is not valid.
13911392
"""
1392-
if value is None and lenient_check.get():
1393+
unset_sentinel = get_parsing_setting("unset_sentinel")
1394+
if value is unset_sentinel and lenient_check.get():
13931395
return value
13941396
is_subcommand = isinstance(action, ActionSubCommands)
13951397
if is_subcommand and action.choices:

jsonargparse/_instantiation.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
InstantiatorsDictType,
77
applied_instantiation_links,
88
class_instantiators,
9+
get_parsing_setting,
910
is_subclass,
1011
parser_context,
1112
)
@@ -84,6 +85,7 @@ def instantiate(
8485
components = ActionLink.reorder(order, components)
8586

8687
cfg = cfg.clone(with_meta=False)
88+
unset_sentinel = get_parsing_setting("unset_sentinel")
8789
for component in components:
8890
ActionLink.apply_instantiation_links(self, cfg, target=component.dest)
8991
if isinstance(component, ActionTypeHint):
@@ -92,7 +94,7 @@ def instantiate(
9294
except (KeyError, AttributeError):
9395
pass
9496
else:
95-
if value is not None:
97+
if value is not unset_sentinel:
9698
with parser_context(
9799
parent_parser=self,
98100
nested_links=ActionLink.get_nested_links(self, component),

jsonargparse/_loaders_dumpers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,8 @@ def replace_unset(data):
243243
return Unset._SERIALIZED
244244
if isinstance(data, dict):
245245
return {k: replace_unset(v) for k, v in data.items()}
246+
if isinstance(data, list):
247+
return [replace_unset(v) for v in data]
246248
return data
247249

248250

jsonargparse/_signatures.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from ._common import (
1212
LoggerProperty,
1313
get_generic_origin,
14+
get_parsing_setting,
1415
get_unaliased_type,
1516
is_final_class,
1617
is_subclass,
@@ -337,7 +338,8 @@ def _add_signature_parameter(
337338
default = param.default
338339
if default == inspect_empty:
339340
if is_optional(annotation):
340-
default = None
341+
unset_sentinel = get_parsing_setting("unset_sentinel")
342+
default = unset_sentinel if unset_sentinel is not None else None
341343
elif get_typehint_origin(annotation) in not_required_types:
342344
default = SUPPRESS
343345
# Determine argument characteristics based on parameter kind and default value

jsonargparse/_typehints.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
remove_actions,
4848
)
4949
from ._common import (
50+
get_parsing_setting,
5051
get_unaliased_type,
5152
is_generic_class,
5253
is_instance,
@@ -246,6 +247,7 @@ def cached_get_class_parser(*, val_class, sub_add_kwargs, skip_args, parent_pars
246247
freeze(sub_add_kwargs),
247248
freeze(skip_args),
248249
freeze(nested_links),
250+
get_parsing_setting("unset_sentinel"),
249251
)
250252
if cache_key in _cached_class_parsers:
251253
parser = _cached_class_parsers[cache_key]
@@ -622,8 +624,9 @@ def _check_type(self, value, append=False, cfg=None, mode=None):
622624
config_path = None
623625
path_meta = val.pop("__path__", None) if isinstance(val, dict) else None
624626

625-
prev_val = cfg.get(self.dest) if cfg else None
626-
if prev_val is None and not sub_defaults.get() and is_subclass_spec(self.default):
627+
unset_sentinel = get_parsing_setting("unset_sentinel")
628+
prev_val = cfg.get(self.dest) if cfg else unset_sentinel
629+
if prev_val is unset_sentinel and not sub_defaults.get() and is_subclass_spec(self.default):
627630
prev_val = Namespace(class_path=self.default["class_path"])
628631

629632
kwargs = {
@@ -815,6 +818,7 @@ def adapt_typehints(
815818
}
816819
subtypehints = getattr(typehint, "__args__", None)
817820
typehint_origin = get_typehint_origin(typehint) or typehint
821+
unset_sentinel = get_parsing_setting("unset_sentinel")
818822

819823
# Any
820824
if typehint == Any:
@@ -930,7 +934,7 @@ def adapt_typehints(
930934
elif typehint_origin in sequence_origin_types:
931935
if append:
932936
adapt_kwargs.pop("prev_val")
933-
if prev_val is None:
937+
if prev_val is unset_sentinel:
934938
prev_val = []
935939
elif not isinstance(prev_val, list):
936940
try:
@@ -1064,7 +1068,7 @@ def adapt_typehints(
10641068
else:
10651069
raise ImportError(f"Unexpected import object {val_obj}")
10661070
if isinstance(val, (dict, Namespace, NestedArg)):
1067-
if prev_val is None:
1071+
if prev_val is unset_sentinel:
10681072
return_type = get_callable_return_type(typehint)
10691073
if return_type and not inspect.isabstract(return_type):
10701074
with suppress(ValueError):
@@ -1104,7 +1108,7 @@ def adapt_typehints(
11041108
return val
11051109

11061110
prev_implicit_defaults = False
1107-
if prev_val is None and not inspect.isabstract(typehint) and not is_protocol(typehint):
1111+
if prev_val is unset_sentinel and not inspect.isabstract(typehint) and not is_protocol(typehint):
11081112
with suppress(ValueError):
11091113
# implicit prev_val class_path
11101114
prev_val = Namespace(class_path=get_import_path(typehint))
@@ -1123,6 +1127,9 @@ def adapt_typehints(
11231127
val = subclass_spec_as_namespace(val, type_class_path)
11241128
else:
11251129
val = subclass_spec_as_namespace(val, prev_val)
1130+
if val and not is_subclass_spec(val) and "init_args" not in val:
1131+
# implicit val class_path
1132+
val = Namespace(class_path=get_import_path(typehint), init_args=val)
11261133

11271134
if not is_subclass_spec(val):
11281135
msg = "Does not implement protocol" if is_protocol(typehint) else "Not a valid subclass of"

jsonargparse_tests/test_parsing_settings.py

Lines changed: 87 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import re
22
from dataclasses import dataclass
3-
from typing import Literal, Optional
3+
from typing import List, Literal, Optional
44
from unittest.mock import patch
55

66
import pytest
@@ -254,6 +254,14 @@ def test_unset_sentinel_default_is_none(parser):
254254
assert get_parsing_setting("unset_sentinel") is None
255255

256256

257+
def test_unset_sentinel_get_defaults(parser):
258+
set_parsing_settings(unset_sentinel=True)
259+
260+
parser.add_argument("--num", type=int)
261+
defaults = parser.get_defaults()
262+
assert defaults.num is Unset
263+
264+
257265
def test_unset_sentinel_optional_int(parser):
258266
set_parsing_settings(unset_sentinel=True)
259267

@@ -281,15 +289,7 @@ def test_unset_sentinel_explicit_none_default_stays_none(parser):
281289
assert defaults.num is None
282290

283291

284-
def test_unset_sentinel_get_defaults(parser):
285-
set_parsing_settings(unset_sentinel=True)
286-
287-
parser.add_argument("--num", type=int)
288-
defaults = parser.get_defaults()
289-
assert defaults.num is Unset
290-
291-
292-
def test_unset_sentinel_skip_unset_dump(parser):
292+
def test_unset_sentinel_dump_skip_unset(parser):
293293
set_parsing_settings(unset_sentinel=True)
294294

295295
parser.add_argument("--num", type=Optional[int])
@@ -306,6 +306,30 @@ def test_unset_sentinel_skip_unset_dump(parser):
306306
assert json_or_yaml_load(dump_no_skip) == {"num": "==UNSET==", "name": "hello"}
307307

308308

309+
@dataclass
310+
class UnsetListItem:
311+
name: str
312+
value: Optional[int]
313+
314+
315+
def test_unset_sentinel_dump_list_of_dataclasses(parser):
316+
set_parsing_settings(unset_sentinel=True)
317+
318+
parser.add_argument("--records", type=List[UnsetListItem])
319+
320+
cfg = parser.parse_args(['--records=[{"name":"a"},{"name":"b","value":2}]'])
321+
assert cfg.records[0] == Namespace(name="a", value=Unset)
322+
assert cfg.records[1] == Namespace(name="b", value=2)
323+
324+
dump_skip = parser.dump(cfg, skip_unset=True)
325+
loaded_skip = json_or_yaml_load(dump_skip)
326+
assert loaded_skip == {"records": [{"name": "a"}, {"name": "b", "value": 2}]}
327+
328+
dump_no_skip = parser.dump(cfg, skip_unset=False)
329+
loaded_no_skip = json_or_yaml_load(dump_no_skip)
330+
assert loaded_no_skip == {"records": [{"name": "a", "value": "==UNSET=="}, {"name": "b", "value": 2}]}
331+
332+
309333
def test_unset_sentinel_validate_skip_unset(parser):
310334
set_parsing_settings(unset_sentinel=True)
311335

@@ -333,13 +357,51 @@ def test_unset_repr():
333357

334358

335359
def test_unset_bool():
336-
assert not Unset
360+
assert bool(Unset) is False
361+
assert bool(not Unset) is True
337362

338363

339364
def test_unset_is_singleton():
340365
assert _UnsetType() is Unset
341366

342367

368+
# add_function_arguments with unset_sentinel
369+
370+
371+
def test_unset_sentinel_function_arguments_no_default_is_unset(parser):
372+
"""Optional param with no default in function signature should be Unset when unset_sentinel=True."""
373+
set_parsing_settings(unset_sentinel=True)
374+
375+
def my_func(num: Optional[int], name: str = "hello"):
376+
pass # pragma: no cover
377+
378+
parser.add_function_arguments(my_func)
379+
380+
cfg = parser.parse_args([])
381+
assert cfg.num is Unset # no default in signature → Unset
382+
assert cfg.name == "hello" # has default → kept
383+
384+
cfg = parser.parse_args(["--num=null"])
385+
assert cfg.num is None # explicitly set to null → None
386+
387+
cfg = parser.parse_args(["--num=5"])
388+
assert cfg.num == 5
389+
390+
391+
def test_unset_sentinel_function_arguments_explicit_none_default_stays_none(parser):
392+
"""Optional param with explicit default=None in function signature stays None."""
393+
set_parsing_settings(unset_sentinel=True)
394+
395+
def my_func(num: Optional[int] = None, flag: Optional[int] = 0):
396+
pass # pragma: no cover
397+
398+
parser.add_function_arguments(my_func)
399+
400+
cfg = parser.parse_args([])
401+
assert cfg.num is None # explicit default=None → None (not Unset)
402+
assert cfg.flag == 0 # explicit default=0 → 0
403+
404+
343405
# Interaction between Unset and default SUPPRESS
344406

345407

@@ -377,3 +439,17 @@ def test_unset_and_argument_default_suppress(parser):
377439
cfg = parser.parse_args(["--num=7"])
378440
assert cfg.num == 7
379441
assert not hasattr(cfg, "name") # still absent
442+
443+
444+
def test_unset_parse_and_print_config(parser):
445+
set_parsing_settings(unset_sentinel=True)
446+
447+
parser.add_argument("--num", type=int)
448+
parser.add_argument("--name", type=str, default="a")
449+
parser.add_argument("--cfg", action="config")
450+
451+
cfg = parser.parse_args(["--cfg={}"])
452+
assert cfg == Namespace(num=Unset, name="a", cfg=[None])
453+
454+
out = get_parse_args_stdout(parser, ["--print_config"])
455+
assert json_or_yaml_load(out) == {"num": "==UNSET==", "name": "a"}

0 commit comments

Comments
 (0)