Repository: omni-us/jsonargparse
Pull request #513 (2024-05-28 05:08:18)

Allow providing a function with return type a class in class_path
## What does this PR do?

Simplifies cases like https://github.com/Lightning-AI/pytorch-lightning/discussions/13613

## Before submitting

- [x] Did you read the [contributing guideline](https://github.com/omni-us/jsonargparse/blob/main/CONTRIBUTING.rst)?
- [x] Did you update **the documentation**? (readme and public docstrings)
- [x] Did you write **unit tests** such that there is 100% coverage on related code? (required for bug fixes and new features)
- [x] Did you verify that new and existing **tests pass locally**?
- [x] Did you make sure that all changes preserve **backward compatibility**?
- [x] Did you update **the CHANGELOG**? (not for typos, docs, test updates, or minor internal changes/refactors)


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=513) **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=513&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=513&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=513&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=513&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=513)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) Report
Attention: Patch coverage is `86.95652%` with `3 lines` in your changes are missing coverage. Please review.
> Project coverage is 99.74%. Comparing base [(`4166d0a`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/4166d0a3fc0feb7d935b625aca1a07e49631bf52?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) to head [(`12cb607`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/12cb607dac78b180abdba1f7d532aa65053d9e34?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).

| [Files](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | Patch % | Lines |
|---|---|---|
| [jsonargparse/\_postponed\_annotations.py](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513?src=pr&el=tree&filepath=jsonargparse%2F_postponed_annotations.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#diff-anNvbmFyZ3BhcnNlL19wb3N0cG9uZWRfYW5ub3RhdGlvbnMucHk=) | 75.00% | [3 Missing :warning: ](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513?src=pr&el=tree&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     #513      +/-   ##
===========================================
- Coverage   100.00%   99.74%   -0.26%     
===========================================
  Files           21       21              
  Lines         6150     6172      +22     
===========================================
+ Hits          6150     6156       +6     
- Misses           0       16      +16     
```

| [Flag](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/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/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `87.27% <82.60%> (-0.02%)` | :arrow_down: |
| [py3.10_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.58% <82.60%> (-0.07%)` | :arrow_down: |
| [py3.10_pydantic1](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `?` | |
| [py3.10_pydantic2](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `?` | |
| [py3.10_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.60% <82.60%> (-0.07%)` | :arrow_down: |
| [py3.11](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `?` | |
| [py3.11_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `?` | |
| [py3.11_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.60% <82.60%> (-0.07%)` | :arrow_down: |
| [py3.12](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `87.40% <82.60%> (-0.03%)` | :arrow_down: |
| [py3.12_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.58% <82.60%> (-0.07%)` | :arrow_down: |
| [py3.12_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `98.60% <82.60%> (-0.07%)` | :arrow_down: |
| [py3.7](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `87.83% <86.95%> (-0.01%)` | :arrow_down: |
| [py3.7_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.21% <86.95%> (-0.05%)` | :arrow_down: |
| [py3.7_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.19% <82.60%> (-0.07%)` | :arrow_down: |
| [py3.8](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `?` | |
| [py3.8_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `?` | |
| [py3.8_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `?` | |
| [py3.9](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `?` | |
| [py3.9_all](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.22% <82.60%> (-0.07%)` | :arrow_down: |
| [py3.9_types](https://app.codecov.io/gh/omni-us/jsonargparse/pull/513/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | `99.23% <82.60%> (-0.07%)` | :arrow_down: |

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/513?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).

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=513) **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=513&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=513&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=513&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=513&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarCloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=513)



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 02662c7..5172ae0 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -19,6 +19,9 @@ Added
 ^^^^^
 - Allow adding config argument with ``action="config"`` avoiding need to import
   action class.
+- Allow providing a function with return type a class in ``class_path``
+  (`lightning#13613
+  <https://github.com/Lightning-AI/pytorch-lightning/discussions/13613>`__)
 
 
 v4.29.0 (2024-05-24)
diff --git a/DOCUMENTATION.rst b/DOCUMENTATION.rst
index 9fb6a2b..3468707 100644
--- a/DOCUMENTATION.rst
+++ b/DOCUMENTATION.rst
@@ -1968,6 +1968,12 @@ config file by specifying a path to it; and 3) by default sets a useful
     ``class_path`` and ``init_args`` if the corresponding parameter has type
     ``Any``, or when ``fail_untyped=False`` which defaults to type ``Any``.
 
+.. note::
+
+    It is also possible to provide to ``class_path`` a function that has as return
+    type a class. The accepted ``init_args`` would be the parameters of that
+    function.
+
 .. _sub-classes-command-line:
 
 Command line
diff --git a/jsonargparse/_postponed_annotations.py b/jsonargparse/_postponed_annotations.py
index 11168a0..a8a3ab3 100644
--- a/jsonargparse/_postponed_annotations.py
+++ b/jsonargparse/_postponed_annotations.py
@@ -332,3 +332,17 @@ def evaluate_postponed_annotations(params, component, parent, logger):
                 logger.debug(f"Unable to evaluate type of {param.name} from {component}", exc_info=param_type)
                 continue
             param.annotation = param_type
+
+
+def get_return_type(component, logger):
+    return_type = inspect.signature(component).return_annotation
+    if type_requires_eval(return_type):
+        global_vars = vars(import_module(component.__module__))
+        try:
+            return_type = get_type_hints(component, global_vars)["return"]
+            if isinstance(return_type, ForwardRef):
+                return_type = resolve_forward_refs(return_type.__forward_arg__, global_vars, logger)
+        except Exception as ex:
+            logger.debug(f"Unable to evaluate types for {component}", exc_info=ex)
+            return None
+    return return_type
diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py
index f406264..1e28eca 100644
--- a/jsonargparse/_typehints.py
+++ b/jsonargparse/_typehints.py
@@ -461,12 +461,22 @@ class ActionTypeHint(Action):
             if _is_action_value_list(self):
                 return [
                     adapt_typehints(
-                        v, self._typehint, default=self.default, serialize=True, sub_add_kwargs=sub_add_kwargs
+                        v,
+                        self._typehint,
+                        default=self.default,
+                        serialize=True,
+                        sub_add_kwargs=sub_add_kwargs,
+                        logger=self.logger,
                     )
                     for v in value
                 ]
             return adapt_typehints(
-                value, self._typehint, default=self.default, serialize=True, sub_add_kwargs=sub_add_kwargs
+                value,
+                self._typehint,
+                default=self.default,
+                serialize=True,
+                sub_add_kwargs=sub_add_kwargs,
+                logger=self.logger,
             )
 
     def __call__(self, *args, **kwargs):
@@ -525,6 +535,7 @@ class ActionTypeHint(Action):
                     "prev_val": prev_val,
                     "append": append,
                     "enable_path": enable_path,
+                    "logger": self.logger,
                 }
                 try:
                     with change_to_path_dir(config_path):
@@ -563,7 +574,12 @@ class ActionTypeHint(Action):
         sub_add_kwargs = getattr(self, "sub_add_kwargs", {})
         for num, val in enumerate(value):
             value[num] = adapt_typehints(
-                val, self._typehint, default=self.default, instantiate_classes=True, sub_add_kwargs=sub_add_kwargs
+                val,
+                self._typehint,
+                default=self.default,
+                instantiate_classes=True,
+                sub_add_kwargs=sub_add_kwargs,
+                logger=self.logger,
             )
         return value if islist else value[0]
 
@@ -577,7 +593,10 @@ class ActionTypeHint(Action):
         parser = parent_parser.get()
         parser = type(parser)(exit_on_error=False, logger=parser.logger)
         remove_actions(parser, (ActionConfigFile, _ActionPrintConfig))
-        parser.add_class_arguments(val_class, **kwargs)
+        if inspect.isclass(val_class):
+            parser.add_class_arguments(val_class, **kwargs)
+        else:
+            parser.add_function_arguments(val_class, **kwargs)
 
         if "linked_targets" in kwargs and parser.required_args:
             for key in kwargs["linked_targets"]:
@@ -656,6 +675,7 @@ def adapt_typehints(
     enable_path=False,
     sub_add_kwargs=None,
     default=None,
+    logger=None,
 ):
     if type(val) in {str, bool, int, float} and val == default:
         return val
@@ -667,6 +687,7 @@ def adapt_typehints(
         "append": append,
         "enable_path": enable_path,
         "sub_add_kwargs": sub_add_kwargs or {},
+        "logger": logger,
     }
     subtypehints = getattr(typehint, "__args__", None)
     typehint_origin = get_typehint_origin(typehint) or typehint
@@ -944,7 +965,16 @@ def adapt_typehints(
             val_class = import_object(resolve_class_path_by_name(typehint, val["class_path"]))
             if isinstance(val_class, typehint):
                 return val_class
+            not_subclass = False
             if not is_subclass(val_class, typehint):
+                not_subclass = True
+                if not inspect.isclass(val_class) and callable(val_class):
+                    from ._postponed_annotations import get_return_type
+
+                    return_type = get_return_type(val_class, logger)
+                    if is_subclass(return_type, typehint):
+                        not_subclass = False
+            if not_subclass:
                 raise_unexpected_value(
                     f'Import path {val["class_path"]} does not correspond to a subclass of {typehint}'
                 )


Test patch:
diff --git a/jsonargparse_tests/test_subclasses.py b/jsonargparse_tests/test_subclasses.py
index 80db7b5..95d7420 100644
--- a/jsonargparse_tests/test_subclasses.py
+++ b/jsonargparse_tests/test_subclasses.py
@@ -2,6 +2,7 @@ from __future__ import annotations
 
 import json
 import os
+import sys
 import textwrap
 import warnings
 from calendar import Calendar, HTMLCalendar, TextCalendar
@@ -320,6 +321,77 @@ def test_subclass_in_subcommand_with_global_default_config_file(parser, subparse
     assert cfg.fit.model.foo == 123
 
 
+# function instantiators
+
+
+class ClassMethodInstantiator:
+    def __init__(self, p1: int = 1, p2: bool = False):
+        self.p1 = p1
+        self.p2 = p2
+
+    @classmethod
+    def from_p1(cls, p1: int) -> "ClassMethodInstantiator":
+        return ClassMethodInstantiator(p1)
+
+
+def test_class_method_instantiator(parser):
+    parser.add_argument("--cls", type=ClassMethodInstantiator)
+    cfg = parser.parse_args([f"--cls={__name__}.ClassMethodInstantiator.from_p1", "--cls.p1=2"])
+    assert cfg.cls.class_path == f"{__name__}.ClassMethodInstantiator.from_p1"
+    assert cfg.cls.init_args == Namespace(p1=2)
+    init = parser.instantiate_classes(cfg)
+    assert isinstance(init.cls, ClassMethodInstantiator)
+    assert init.cls.p1 == 2
+    assert init.cls.p2 is False
+
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args([f"--cls={__name__}.ClassMethodInstantiator.from_p1"])
+    ctx.match('Key "p1" is required')
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args([f"--cls={__name__}.ClassMethodInstantiator.from_p1", "--cls.p1=2", "--cls.p3=-"])
+    ctx.match("Key 'p3' is not expected")
+
+
+class FunctionInstantiator:
+    def __init__(self, p1: float = 0.0, p2: str = "-"):
+        self.p1 = p1
+        self.p2 = p2
+
+
+def function_instantiator(p2: str) -> FunctionInstantiator:
+    return FunctionInstantiator(1.0, p2)
+
+
+def test_function_instantiator(parser):
+    parser.add_argument("--cls", type=FunctionInstantiator)
+    cfg = parser.parse_args([f"--cls={__name__}.function_instantiator", "--cls.p2=y"])
+    assert cfg.cls.class_path == f"{__name__}.function_instantiator"
+    assert cfg.cls.init_args == Namespace(p2="y")
+    init = parser.instantiate_classes(cfg)
+    assert isinstance(init.cls, FunctionInstantiator)
+    assert init.cls.p1 == 1.0
+    assert init.cls.p2 == "y"
+
+    with pytest.raises(ArgumentError) as ctx:
+        parser.parse_args([f"--cls={__name__}.function_instantiator", "--cls.p2=y", "--cls.p3=x"])
+    ctx.match("Key 'p3' is not expected")
+
+
+def function_undefined_return(p1: int) -> "Undefined":  # type: ignore[name-defined]  # noqa: F821
+    return FunctionInstantiator(p1, "x")
+
+
+def test_instantiator_undefined_return(parser, logger):
+    parser.logger = logger
+    parser.add_argument("--cls", type=FunctionInstantiator)
+    with capture_logs(logger) as logs, pytest.raises(ArgumentError) as ctx:
+        parser.parse_args([f"--cls={__name__}.function_undefined_return", "--cls.p1=2"])
+    ctx.match("function_undefined_return does not correspond to a subclass of")
+    assert "function_undefined_return does not correspond to a subclass of" in logs.getvalue()
+    if sys.version_info >= (3, 9):
+        assert "Unable to evaluate types for" in logs.getvalue()
+
+
 # importable instances