Repository: omni-us/jsonargparse

Issue:
In python 3.11, add_subclass_arguments with list[Class] is not working.
<!-- If you like this project, please ⭐ star it https://github.com/omni-us/jsonargparse/ -->

## 🐛 Bug report

Hi all, I'm working on a project that utilizes this great library.
However, I ran into a problem with python 3.11.

```yaml
scheduler:
  - class_path: otx.algo.schedulers.warmup_schedulers.LinearWarmupScheduler
    init_args:
      num_warmup_steps: 10
  - class_path: lightning.pytorch.cli.ReduceLROnPlateau
    init_args:
      mode: max
      factor: 0.5
      patience: 1
      monitor: val/accuracy
```
```python
parser.add_subclass_arguments(
            baseclass=(LRScheduler, ReduceLROnPlateau, list[LRSchedulerTypeUnion]),
            nested_key="scheduler",
            **scheduler_kwargs,
        )
```
I've been configuring arguments like the one above in project, which is mainly python 3.10 (& 3.9), but this doesn't work since python 3.11.
The reason for this is that as of python 3.11, `list[Optimizer]` is False in `inspect.isclass()`.
https://github.com/omni-us/jsonargparse/blob/88fc9c8782fd959aa844b9bfa1ab3a324132b842/jsonargparse/_signatures.py#L502-L503
If I comment out that if statement and the raise statement, this works fine.
Do I need to use a different method to use `list[Optimizer]` or `list[Scheduler]`?
This used to work in python 3.9 and 3.10, but a new workaround is needed in 3.11.

I was wondering if could relax that if statement to make it more flexible.


### To reproduce

<!--
Please include a code snippet that reproduces the bug and the output that
running it gives. The following snippet templates might help:

1. Using the CLI function

```python
import jsonargparse

# Here define one or more functions or classes
def func1(param1: int, ...):
    ...

# Run the CLI providing the components
jsonargparse.CLI([func1, ...], error_handler=None)
```

2. Manually constructing a parser

```python
import jsonargparse

parser = jsonargparse.ArgumentParser(error_handler=None)
# Here add to the parser only argument(s) relevant to the problem

# If a yaml config is required, it can be included in the same snippet as follows:
import yaml

parser.add_argument("--config", action=jsonargparse.ActionConfigFile)
config = yaml.safe_dump(
    {
        "key1": "val1",
    }
)

# Preferable that the command line arguments are given to the parse_args call
result = parser.parse_args([f"--config={config}", "--key2=val2", ...])

# If the problem is in the parsed result, print it to stdout
print(parser.dump(result))

# If the problem is in class instantiation
parser.instantiate_classes(result)
```
-->

### Expected behavior

<!-- Describe how the behavior or output of the reproduction snippet should be. -->

### Environment

<!-- Fill in the list below. -->

- jsonargparse version (e.g., 4.8.0):
- Python version (e.g., 3.9):
- How jsonargparse was installed (e.g. `pip install jsonargparse[all]`):
- OS (e.g., Linux):


Discussion:
sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=522) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=522&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=522&resolutions=WONTFIX)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=522&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=522&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=522)


sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=522) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=522&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=522&resolutions=WONTFIX)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=522&resolved=false&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/no-data-16px.png '') No data about Coverage  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=522&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=522)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`1dd2664`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/1dd26644dc50e2ee748e7ccd23b48a3a2042fda3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) to head [(`19c66bf`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/19c66bf8fbea7921c5da55b91f4e22a5a4966e73?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff            @@
##              main      #522   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           21        21           
  Lines         6188      6209   +21     
=========================================
+ Hits          6188      6209   +21     
```

| [Flag](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | Coverage Δ | |
|---|---|---|
| [py3.10](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `87.40% <100.00%> (+0.04%)` | :arrow_up: |
| [py3.10_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.64% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.10_pydantic1](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `49.55% <48.38%> (+0.26%)` | :arrow_up: |
| [py3.10_pydantic2](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `49.33% <48.38%> (+0.26%)` | :arrow_up: |
| [py3.10_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.66% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.11](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `87.38% <100.00%> (+0.04%)` | :arrow_up: |
| [py3.11_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.64% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.11_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.66% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.12](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `?` | |
| [py3.12_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `?` | |
| [py3.12_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `?` | |
| [py3.7](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `87.91% <100.00%> (+0.04%)` | :arrow_up: |
| [py3.7_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.22% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.7_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.25% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.8](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `88.09% <100.00%> (+0.04%)` | :arrow_up: |
| [py3.8_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.38% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.8_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.40% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.9](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `87.94% <100.00%> (+0.04%)` | :arrow_up: |
| [py3.9_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.27% <100.00%> (+<0.01%)` | :arrow_up: |
| [py3.9_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.29% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#carryforward-flags-in-the-pull-request-comment) to find out more.


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/omni-us/jsonargparse/pull/522?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).


Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 16db672..7245b9e 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -34,6 +34,9 @@ Fixed
   (`#523 <https://github.com/omni-us/jsonargparse/pull/523>`__).
 - Failing to parse list of dataclasses with nested optional dataclass (`#527
   <https://github.com/omni-us/jsonargparse/pull/527>`__).
+- List of union of classes not accepted by ``add_subclass_arguments`` in
+  ``python>=3.11`` (`#522
+  <https://github.com/omni-us/jsonargparse/pull/522>`__).
 
 
 v4.29.0 (2024-05-24)
diff --git a/jsonargparse/_actions.py b/jsonargparse/_actions.py
index 43c3199..b0762e9 100644
--- a/jsonargparse/_actions.py
+++ b/jsonargparse/_actions.py
@@ -349,14 +349,9 @@ class _ActionHelpClassPath(Action):
             super().__init__(**kwargs)
 
     def update_init_kwargs(self, kwargs):
-        if get_typehint_origin(self._baseclass) == Union:
-            from ._typehints import ActionTypeHint
+        from ._typehints import get_subclasses_from_type
 
-            self._basename = iter_to_set_str(
-                c.__name__ for c in self._baseclass.__args__ if ActionTypeHint.is_subclass_typehint(c)
-            )
-        else:
-            self._basename = self._baseclass.__name__
+        self._basename = iter_to_set_str(get_subclasses_from_type(self._baseclass))
         kwargs.update(
             {
                 "metavar": "CLASS_PATH_OR_NAME",
diff --git a/jsonargparse/_parameter_resolvers.py b/jsonargparse/_parameter_resolvers.py
index 1d5ab92..7ef82c3 100644
--- a/jsonargparse/_parameter_resolvers.py
+++ b/jsonargparse/_parameter_resolvers.py
@@ -28,6 +28,7 @@ from ._stubs_resolver import get_stub_types
 from ._util import (
     ClassFromFunctionBase,
     get_import_path,
+    get_typehint_origin,
     iter_to_set_str,
     unique,
 )
@@ -74,6 +75,10 @@ class ConditionalDefault(UnknownDefault):
 
 
 def get_parameter_origins(component, parent) -> Optional[str]:
+    from ._typehints import get_subclasses_from_type, sequence_origin_types
+
+    if get_typehint_origin(component) in sequence_origin_types:
+        component = get_subclasses_from_type(component, names=False)
     if isinstance(component, tuple):
         assert parent is None or len(component) == len(parent)
         return iter_to_set_str(get_parameter_origins(c, parent[n] if parent else None) for n, c in enumerate(component))
diff --git a/jsonargparse/_signatures.py b/jsonargparse/_signatures.py
index 133f9c0..adc3494 100644
--- a/jsonargparse/_signatures.py
+++ b/jsonargparse/_signatures.py
@@ -26,9 +26,10 @@ from ._typehints import (
     ActionTypeHint,
     LazyInitBaseClass,
     callable_instances,
+    get_subclasses_from_type,
     is_optional,
 )
-from ._util import get_import_path, get_private_kwargs, iter_to_set_str
+from ._util import get_private_kwargs, iter_to_set_str
 from .typing import register_pydantic_type
 
 __all__ = [
@@ -510,8 +511,8 @@ class SignatureArguments(LoggerProperty):
             raise ValueError("Not allowed for dataclass-like classes.")
         if type(baseclass) is not tuple:
             baseclass = (baseclass,)  # type: ignore[assignment]
-        if not baseclass or not all(inspect.isclass(c) for c in baseclass):
-            raise ValueError(f"Expected 'baseclass' argument to be a class or a tuple of classes: {baseclass}")
+        if not baseclass or not all(ActionTypeHint.is_subclass_typehint(c, also_lists=True) for c in baseclass):
+            raise ValueError(f"Expected 'baseclass' to be a subclass type or a tuple of subclass types: {baseclass}")
 
         doc_group = None
         if len(baseclass) == 1:  # type: ignore[arg-type]
@@ -530,7 +531,7 @@ class SignatureArguments(LoggerProperty):
         if skip is not None:
             skip = {f"{nested_key}.init_args." + s for s in skip}
         param = ParamData(name=nested_key, annotation=Union[baseclass], component=baseclass)
-        str_baseclass = iter_to_set_str(get_import_path(x) for x in baseclass)
+        str_baseclass = iter_to_set_str(get_subclasses_from_type(param.annotation))
         kwargs.update(
             {
                 "metavar": metavar,
diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py
index 06aa92b..e8eb308 100644
--- a/jsonargparse/_typehints.py
+++ b/jsonargparse/_typehints.py
@@ -668,6 +668,22 @@ def is_pathlike(typehint) -> bool:
     return is_subclass(typehint, os.PathLike)
 
 
+def get_subclasses_from_type(typehint, names=True, subclasses=None) -> tuple:
+    if subclasses is None:
+        subclasses = []
+    origin = get_typehint_origin(typehint)
+    if origin == Union or origin in sequence_origin_types:
+        for subtype in typehint.__args__:
+            get_subclasses_from_type(subtype, names, subclasses)
+    elif ActionTypeHint.is_subclass_typehint(typehint, all_subtypes=False):
+        if names:
+            if typehint.__name__ not in subclasses:
+                subclasses.append(typehint.__name__)
+        elif typehint not in subclasses:
+            subclasses.append(typehint)
+    return tuple(subclasses)
+
+
 def raise_unexpected_value(message: str, val: Any = inspect._empty, exception: Optional[Exception] = None) -> NoReturn:
     if val is not inspect._empty:
         message += f". Got value: {val}"
@@ -1105,14 +1121,20 @@ def get_all_subclass_paths(cls: Type) -> List[str]:
         return "._" in class_path
 
     def add_subclasses(cl):
+        if hasattr(cl, "__args__") and get_typehint_origin(cl) in {List, list, Union}:
+            for arg in cl.__args__:
+                add_subclasses(arg)
+            return
         try:
             class_path = get_import_path(cl)
         except (ImportError, AttributeError) as err:  # Attribute is added in case of dot notation imports
             warning(f"Hit failing import with following error: {err}")
             return
-        if is_local(cl) or issubclass(cl, LazyInitBaseClass):
+        if is_local(cl) or is_subclass(cl, LazyInitBaseClass):
             return
         if not (inspect.isabstract(cl) or is_private(class_path)):
+            if class_path in subclass_list:
+                return
             subclass_list.append(class_path)
         for subclass in cl.__subclasses__() if hasattr(cl, "__subclasses__") else []:
             add_subclasses(subclass)
@@ -1124,7 +1146,7 @@ def get_all_subclass_paths(cls: Type) -> List[str]:
 
     if get_typehint_origin(cls) in {Union, Type, type}:
         for arg in cls.__args__:
-            if ActionTypeHint.is_subclass_typehint(arg) and arg not in {object, type}:
+            if ActionTypeHint.is_subclass_typehint(arg, also_lists=True) and arg not in {object, type}:
                 add_subclasses(arg)
     else:
         add_subclasses(cls)


Test patch:
diff --git a/jsonargparse_tests/test_subclasses.py b/jsonargparse_tests/test_subclasses.py
index 95d7420..a3550cb 100644
--- a/jsonargparse_tests/test_subclasses.py
+++ b/jsonargparse_tests/test_subclasses.py
@@ -1267,13 +1267,13 @@ def test_subclass_unresolved_parameters_name_clash(parser):
 def test_add_subclass_failure_not_a_class(parser):
     with pytest.raises(ValueError) as ctx:
         parser.add_subclass_arguments(NAMESPACE_OID, "oid")
-    ctx.match("Expected 'baseclass' argument to be a class or a tuple of classes")
+    ctx.match("Expected 'baseclass' to be a subclass type or a tuple of subclass types")
 
 
 def test_add_subclass_failure_empty_tuple(parser):
     with pytest.raises(ValueError) as ctx:
         parser.add_subclass_arguments((), "cls")
-    ctx.match("Expected 'baseclass' argument to be a class or a tuple of classes")
+    ctx.match("Expected 'baseclass' to be a subclass type or a tuple of subclass types")
 
 
 def test_add_subclass_lazy_default(parser):
@@ -1337,6 +1337,40 @@ def test_add_subclass_not_required_group(parser):
     assert init == Namespace()
 
 
+class ListUnionA:
+    def __init__(self, pa1: int):
+        self.pa1 = pa1
+
+
+class ListUnionB:
+    def __init__(self, pb1: str, pb2: float):
+        self.pb1 = pb1
+        self.pb2 = pb2
+
+
+def test_add_subclass_list_of_union(parser):
+    parser.add_argument("--config", action="config")
+    parser.add_subclass_arguments(
+        baseclass=(ListUnionA, ListUnionB, List[Union[ListUnionA, ListUnionB]]),
+        nested_key="subclass",
+    )
+    config = {
+        "subclass": [
+            {
+                "class_path": f"{__name__}.ListUnionB",
+                "init_args": {
+                    "pb1": "x",
+                    "pb2": 0.5,
+                },
+            }
+        ]
+    }
+    cfg = parser.parse_args([f"--config={config}"])
+    assert cfg.as_dict()["subclass"] == config["subclass"]
+    help_str = get_parser_help(parser)
+    assert "Show the help for the given subclass of {ListUnionA,ListUnionB}" in help_str
+
+
 # instance defaults tests