Repository: omni-us/jsonargparse

Issue:
Optional class instances
Hello! I would like to ask what is a good way to handle optional class instances? For example, instantiating a validation dataset. In the original Python code, it would look like this:
```python
if args.val_dataset is not None:
    # instantiate the validation set class
```
However, `add_class_arguments()` doesn't support optional instances. To work around this, I'm currently using `add_subclass_arguments(required=False)`, but I find this approach suboptimal. Is there a better way to handle situations like this in `jsonargparse`? Thank you.

Hints from issue comments:
@mauvilsa: The only way is to make a subclass. Why exactly do you find it suboptimal?@hadipash: Because the class I would like to instantiate is known in advance and is not a subclass. Moreover, running `python train.py --help` will output this
```
--valid.dataset.help CLASS_PATH_OR_NAME
                        Show the help for the given subclass of ImageVideoDataset and exit.
--valid.dataset CONFIG | CLASS_PATH_OR_NAME | .INIT_ARG_NAME VALUE
                        One or more arguments specifying "class_path" and "init_args" for any subclass of ImageVideoDataset. (type: Optional[ImageVideoDataset], default: null, known
                        subclasses: moviegen.dataset.ImageVideoDataset)
```
instead of the list of arguments. I can use `python train.py --valid.dataset.help ImageVideoDataset` to see them, but it's less intuitive in my opinion.

It will be nice if `add_class_arguments()` and `add_function_arguments()` supported `required` parameter as well. Thank you!@mauvilsa: > It will be nice if add_class_arguments() and add_function_arguments() supported required parameter as well. Thank you!

The way these work is that they add a group of arguments corresponding to the parameters of the function/class. But all added arguments are independent so they will always be part of the parsed namespace. It is not possible to have an optional group in argparse.

> Because the class I would like to instantiate is known in advance and is not a subclass.

Since v4.29.0 it is possible to provide to a subclass without providing `class_path`. In such a case the base class is used. So at least in a config file the content and individual command line arguments can be the same as for `add_class_arguments`. Though, `--print_config`/`dump`/`save` would have `class_path` and `init_args`.

> Moreover, running python train.py --help will output this

Somewhat related I have been working on https://github.com/omni-us/jsonargparse/issues/287#issuecomment-2433109024. My idea is that in the future there wouldn't be a difference between subclasses and dataclasses. A part of this that I was planning is to make the class path for help optional, and if not given, then the help for the base class is printed, i.e. it would look like
```
--valid.dataset.help [CLASS_PATH_OR_NAME]
```
and running `cli.py --valid.dataset.help` would give the help.

I do agree that it is not ideal even with these possible improvements. But note that inevitably these automatic creation of parsers from signatures will have some limitations and compromises.@hadipash: Thank you for your detailed reply. `jsonargparse` significantly simplifies parsers creation and reduces boilerplate code. So, its minor limitations are not a big trade-off. 👍

Solution patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index ea3e4678..6dfdf67c 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -20,6 +20,9 @@ Changed
 - Argument groups created from dataclass-like that have zero configurable
   arguments no longer adds a config loader (`#634
   <https://github.com/omni-us/jsonargparse/pull/634>`__).
+- The ``CLASS_PATH_OR_NAME`` for subclass help is now optional and if not given
+  the help of the base class is printed (`#628
+  <https://github.com/omni-us/jsonargparse/pull/628>`__).
 
 Deprecated
 ^^^^^^^^^^
@@ -29,6 +32,7 @@ Deprecated
 
 
 v4.34.1 (2024-12-02)
+--------------------
 
 Fixed
 ^^^^^
diff --git a/jsonargparse/_actions.py b/jsonargparse/_actions.py
index 8190c9d2..42327c31 100644
--- a/jsonargparse/_actions.py
+++ b/jsonargparse/_actions.py
@@ -20,6 +20,7 @@
     change_to_path_dir,
     default_config_option_help,
     get_import_path,
+    get_typehint_origin,
     import_object,
     indent_text,
     iter_to_set_str,
@@ -346,8 +347,12 @@ def __init__(self, typehint=None, **kwargs):
             super().__init__(**kwargs)
 
     def update_init_kwargs(self, kwargs):
-        from ._typehints import get_subclass_names
+        from ._typehints import get_optional_arg, get_subclass_names, get_unaliased_type
 
+        typehint = get_unaliased_type(get_optional_arg(self._typehint))
+        if get_typehint_origin(typehint) is not Union:
+            assert "nargs" not in kwargs
+            kwargs["nargs"] = "?"
         self._basename = iter_to_set_str(get_subclass_names(self._typehint, callable_return=True))
         kwargs.update(
             {
@@ -376,7 +381,10 @@ def print_help(self, call_args):
         try:
             typehint = get_unaliased_type(get_optional_arg(self._typehint))
             baseclasses = get_subclass_types(typehint, callable_return=True)
-            val_class = import_object(resolve_class_path_by_name(typehint, value))
+            if self.nargs == "?" and value is None:
+                val_class = typehint
+            else:
+                val_class = import_object(resolve_class_path_by_name(typehint, value))
         except Exception as ex:
             raise TypeError(f"{option_string}: {ex}") from ex
         if not any(is_subclass(val_class, b) for b in baseclasses):
diff --git a/jsonargparse/_link_arguments.py b/jsonargparse/_link_arguments.py
index 0e8aaa33..cfc2b014 100644
--- a/jsonargparse/_link_arguments.py
+++ b/jsonargparse/_link_arguments.py
@@ -204,7 +204,7 @@ def __init__(
         help_str: Optional[str]
         if is_target_subclass and not valid_target_leaf:
             type_attr = None
-            help_str = f"Use --{self.target[1].dest}.help CLASS_PATH for details."
+            help_str = f"Use --{self.target[1].dest}.help for details."
         else:
             type_attr = getattr(self.target[1], "_typehint", self.target[1].type)
             help_str = self.target[1].help


Test patch:
diff --git a/jsonargparse_tests/test_subclasses.py b/jsonargparse_tests/test_subclasses.py
index d47b6e2c..a29e3431 100644
--- a/jsonargparse_tests/test_subclasses.py
+++ b/jsonargparse_tests/test_subclasses.py
@@ -548,6 +548,9 @@ def test_subclass_nested_help(parser):
     help_str = get_parse_args_stdout(parser, [f"--op.help={__name__}.Nested", "--op.cal.help=TextCalendar"])
     assert "Help for --op.cal.help=calendar.TextCalendar" in help_str
     assert "--op.cal.firstweekday" in help_str
+    help_str = get_parse_args_stdout(parser, [f"--op.help={__name__}.Nested", "--op.cal.help"])
+    assert "Help for --op.cal.help=calendar.Calendar" in help_str
+    assert "--op.cal.firstweekday" in help_str
 
     with pytest.raises(ArgumentError) as ctx:
         parser.parse_args([f"--op.help={__name__}.Nested", "--op.p1=1"])
@@ -1346,7 +1349,7 @@ def test_add_subclass_required_group(parser):
     parser.add_subclass_arguments(Calendar, "cal", required=True)
     pytest.raises(ArgumentError, lambda: parser.parse_args([]))
     help_str = get_parser_help(parser)
-    assert "[-h] [--cal.help CLASS_PATH_OR_NAME] --cal " in help_str
+    assert "[-h] [--cal.help [CLASS_PATH_OR_NAME]] --cal " in help_str
 
 
 def test_add_subclass_not_required_group(parser):