Repository: omni-us/jsonargparse

Issue:
CLI fails with default=None for non-Any typed arguments
## 🐛 Bug report

Hei thanks for this nice tool!
I started to use it for various ML projects and observed the following (not sure if this is a bug though)

If I annotate my function with classes that have default arguments set to None I get errors. 
If I annotate them with any and use the `class_path` pattern it works.


### To reproduce

```yaml
# config.yaml

# config for typing RandomForestClassifier
model:
  n_estimators: 23  # works
  criterion: 'entropy'  # works
  min_samples_split: 4  # works
  max_leaf_nodes: 3  # fails

# config for typing: Any
model:
  class_path: sklearn.ensemble.RandomForestClassifier
  init_args:
    n_estimators: 23  # works
    criterion: 'entropy'  # works
    min_samples_split: 4  # works
    max_leaf_nodes: 3  # works

```

```python
# main.py

# works
from typing import Any
def main(model: Any):
    print(model)

# fails
from sklearn.ensemble import RandomForestClassifier
def main(model: RandomForestClassifier):
    print(model)


if __name__ == "__main__":
    from jsonargparse import auto_cli
    auto_cli(main, as_positional=False)
```

1. Using the CLI function

```
python main.py --config config.yaml
```

Errors with:
```
usage: none-default-fail.py [-h] [--config CONFIG] [--print_config[=flags]] [--model.help [CLASS_PATH_OR_NAME]] --model MODEL
error: Parser key "model":
  Problem with given class_path 'sklearn.ensemble.RandomForestClassifier':
    Validation failed: Key 'max_leaf_nodes' is not expected
```

With typing Any model is created as expected
```
RandomForestClassifier(criterion='entropy', max_leaf_nodes=3,
                       min_samples_split=4, n_estimators=23)

```

### Expected behavior

Both ways should work from my understanding.

### Environment


- jsonargparse version: jsonargparse==4.38.0
- Python version:  Python 3.12.1
- How jsonargparse was installed: pip
- OS:  macOS Sequoia 15.3.2

Discussion:
@mauvilsa: Thank you for reporting!

If you do `auto_cli(main, as_positional=False, fail_untyped=False)` then `RandomForestClassifier` works fine for both configs. I have checked and it seems this has been the behavior since the signatures feature was added. However, in the docs, the description of `fail_untyped` is "Whether to raise exception if a required parameter does not have a type". The current behavior doesn't make sense. I think I will change it.

Solution patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 3a2be82e..997dc531 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -12,6 +12,16 @@ The semantic versioning only considers the public API as described in
 paths are considered internals and can change in minor and patch releases.
 
 
+v4.39.0 (2025-03-??)
+--------------------
+
+Changed
+^^^^^^^
+- Untyped parameters with ``None`` default no longer skipped when
+  ``fail_untyped=True`` (`#697
+  <https://github.com/omni-us/jsonargparse/pull/697>`__).
+
+
 v4.38.0 (2025-03-26)
 --------------------
 
diff --git a/jsonargparse/_signatures.py b/jsonargparse/_signatures.py
index 382073d4..14b63590 100644
--- a/jsonargparse/_signatures.py
+++ b/jsonargparse/_signatures.py
@@ -356,11 +356,7 @@ def _add_signature_parameter(
             default = None
             is_required = False
             is_required_link_target = True
-        if (
-            kind in {kinds.VAR_POSITIONAL, kinds.VAR_KEYWORD}
-            or (not is_required and name[0] == "_")
-            or (annotation == inspect_empty and not is_required and default is None)
-        ):
+        if kind in {kinds.VAR_POSITIONAL, kinds.VAR_KEYWORD} or (not is_required and name[0] == "_"):
             return
         elif skip and name in skip:
             self.logger.debug(skip_message + "Parameter requested to be skipped.")


Test patch:
diff --git a/jsonargparse_tests/test_signatures.py b/jsonargparse_tests/test_signatures.py
index 6645a21a..a45f22db 100644
--- a/jsonargparse_tests/test_signatures.py
+++ b/jsonargparse_tests/test_signatures.py
@@ -95,14 +95,15 @@ def test_add_class_without_nesting(parser):
     parser.add_class_arguments(Class3)
 
     assert "Class3" in parser.groups
-    for key in "c3_a0 c3_a1 c3_a2 c3_a3 c3_a4 c3_a5 c3_a6 c3_a7 c3_a8 c1_a2 c1_a4 c1_a5".split():
+    for key in "c3_a0 c3_a1 c3_a2 c3_a3 c3_a4 c3_a5 c3_a6 c3_a7 c3_a8 c1_a2 c1_a3 c1_a4 c1_a5".split():
         assert _find_action(parser, key) is not None, f"{key} should be in parser but is not"
-    for key in ["c2_a0", "c1_a1", "c1_a3", "c0_a0"]:
+    for key in ["c2_a0", "c1_a1", "c0_a0"]:
         assert _find_action(parser, key) is None, f"{key} should not be in parser but is"
 
     cfg = parser.parse_args(["--c3_a0=0", "--c3_a3=true", "--c3_a4=a"], with_meta=False)
     assert cfg.as_dict() == {
         "c1_a2": 2.0,
+        "c1_a3": None,
         "c1_a4": 4,
         "c1_a5": "five",
         "c3_a0": 0,
@@ -136,12 +137,12 @@ def test_add_class_nested_as_group_false(parser):
     added_args = parser.add_class_arguments(Class3, "g", as_group=False)
 
     assert "g" not in parser.groups
-    assert 12 == len(added_args)
+    assert 13 == len(added_args)
     assert all(a.startswith("g.") for a in added_args)
 
-    for key in "c3_a0 c3_a1 c3_a2 c3_a3 c3_a4 c3_a5 c3_a6 c3_a7 c3_a8 c1_a2 c1_a4 c1_a5".split():
+    for key in "c3_a0 c3_a1 c3_a2 c3_a3 c3_a4 c3_a5 c3_a6 c3_a7 c3_a8 c1_a2 c1_a3 c1_a4 c1_a5".split():
         assert _find_action(parser, f"g.{key}") is not None, f"{key} should be in parser but is not"
-    for key in ["c2_a0", "c1_a1", "c1_a3", "c0_a0"]:
+    for key in ["c2_a0", "c1_a1", "c0_a0"]:
         assert _find_action(parser, f"g.{key}") is None, f"{key} should not be in parser but is"
 
     defaults = parser.get_defaults()
@@ -218,13 +219,13 @@ def test_add_class_nested_with_and_without_parameters(parser):
     assert isinstance(init.group.second, NestedWithoutParams)
 
 
-class NoValidParams:
-    def __init__(self, a0=None):
+class SkippedUnderscoreParam:
+    def __init__(self, _a0=None):
         pass
 
 
-def test_add_class_without_valid_parameters(parser):
-    assert [] == parser.add_class_arguments(NoValidParams)
+def test_add_class_skipped_underscore_parameter(parser):
+    assert [] == parser.add_class_arguments(SkippedUnderscoreParam)
 
 
 class WithNew:
@@ -437,18 +438,17 @@ def test_add_class_unmatched_default_type(parser):
 
 
 class WithMethods:
-    def normal_method(self, a1="1", a2: float = 2.0, a3: bool = False, a4=None):
+    def normal_method(self, a1="1", a2: float = 2.0, a3: bool = False):
         """normal_method short description
 
         Args:
             a1: a1 description
             a2: a2 description
-            a4: a4 description
         """
         return a1
 
     @staticmethod
-    def static_method(a1: str, a2: float = 2.0, a3=None):
+    def static_method(a1: str, a2: float = 2.0, _a3=None):
         return a1
 
 
@@ -472,8 +472,7 @@ def test_add_method_normal_and_static(parser):
 
     for key in ["m.a1", "m.a2", "m.a3", "s.a1", "s.a2"]:
         assert _find_action(parser, key) is not None, f"{key} should be in parser but is not"
-    for key in ["m.a4", "s.a3"]:
-        assert _find_action(parser, key) is None, f"{key} should not be in parser but is"
+    assert _find_action(parser, "s._a3") is None, "s._a3 should not be in parser but is"
 
     cfg = parser.parse_args(["--m.a1=x", "--s.a1=y"], with_meta=False).as_dict()
     assert cfg == {"m": {"a1": "x", "a2": 2.0, "a3": False}, "s": {"a1": "y", "a2": 2.0}}
@@ -526,17 +525,16 @@ def test_add_function_arguments(parser):
 
     assert "func" in parser.groups
 
-    for key in ["a1", "a2", "a3"]:
+    for key in ["a1", "a2", "a3", "a4"]:
         assert _find_action(parser, key) is not None, f"{key} should be in parser but is not"
-    assert _find_action(parser, "a4") is None, "a4 should not be in parser but is"
 
     cfg = parser.parse_args(["--a1=x"], with_meta=False).as_dict()
-    assert cfg == {"a1": "x", "a2": 2.0, "a3": False}
+    assert cfg == {"a1": "x", "a2": 2.0, "a3": False, "a4": None}
     assert "x" == func(**cfg)
 
     if docstring_parser_support:
         assert "func short description" == parser.groups["func"].title
-        for key in ["a1", "a2"]:
+        for key in ["a1", "a2", "a4"]:
             assert f"{key} description" == _find_action(parser, key).help
 
 
@@ -604,6 +602,16 @@ def test_add_function_fail_untyped_false(parser):
     assert Namespace(a1=None, a2=None) == parser.parse_args([])
 
 
+def func_untyped_optional(a1: str, a2=None):
+    return a1
+
+
+def test_add_function_fail_untyped_true_untyped_optional(parser):
+    added_args = parser.add_function_arguments(func_untyped_optional, fail_untyped=True)
+    assert ["a1", "a2"] == added_args
+    assert Namespace(a1="x", a2=None) == parser.parse_args(["--a1=x"])
+
+
 def func_config(a1="1", a2: float = 2.0, a3: bool = False):
     return a1
 
@@ -616,10 +624,10 @@ def test_add_function_group_config(parser, tmp_cwd):
     cfg_path.write_text(json_or_yaml_dump({"a1": "one", "a3": True}))
 
     cfg = parser.parse_args([f"--func={cfg_path}"])
-    assert cfg.func == Namespace(a1="one", a2=2.0, a3=True)
+    assert cfg.func == Namespace(a1="one", a2=2.0, a3=True, a4=None)
 
     cfg = parser.parse_args(['--func={"a1": "ONE"}'])
-    assert cfg.func == Namespace(a1="ONE", a2=2.0, a3=False)
+    assert cfg.func == Namespace(a1="ONE", a2=2.0, a3=False, a4=None)
 
     with pytest.raises(ArgumentError) as ctx:
         parser.parse_args(['--func="""'])
@@ -638,7 +646,7 @@ def test_add_function_group_config_within_config(parser, tmp_cwd):
 
     cfg = parser.parse_args([f"--cfg={cfg_path}"])
     assert str(cfg.func.__path__) == str(subcfg_path)
-    assert strip_meta(cfg.func) == Namespace(a1="one", a2=2.0, a3=True)
+    assert strip_meta(cfg.func) == Namespace(a1="one", a2=2.0, a3=True, a4=None)
 
 
 def func_param_conflict(p1: int, cfg: dict):