Repository: omni-us/jsonargparse
Pull request #315 (2023-06-30 03:49:42)

Valid import path for classes created with class_from_function
## What does this PR do?

Implements https://github.com/omni-us/jsonargparse/issues/309#issuecomment-1608790206

## 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]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=315)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=315&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=315&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=315&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=315&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=315&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=315&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=315&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=315&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=315&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=315&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=315&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=315&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=315) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=315&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=315&metric=new_duplicated_lines_density&view=list)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/omni-us/jsonargparse/pull/315?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) Report
Patch coverage: **`100.00`**% and no project coverage change.
> Comparison is base [(`c4514d4`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/c4514d49e31b0a5a494273cc647e48f848fe15b4?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) 100.00% compared to head [(`c2a7c99`)](https://app.codecov.io/gh/omni-us/jsonargparse/pull/315?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) 100.00%.

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


```diff
@@            Coverage Diff            @@
##              main      #315   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           21        21           
  Lines         5506      5515    +9     
=========================================
+ Hits          5506      5515    +9     
```

| Flag | Coverage Δ | |
|---|---|---|
| py3.10 | `86.56% <100.00%> (+0.02%)` | :arrow_up: |
| py3.10_all | `98.34% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.10_types | `98.34% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.11 | `86.50% <100.00%> (+0.02%)` | :arrow_up: |
| py3.11_all | `98.33% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.11_types | `98.33% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.6 | `85.93% <100.00%> (+0.02%)` | :arrow_up: |
| py3.6_all | `97.82% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.7 | `87.93% <100.00%> (+0.01%)` | :arrow_up: |
| py3.7_all | `99.48% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.7_types | `99.46% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.8 | `88.14% <100.00%> (+0.01%)` | :arrow_up: |
| py3.8_all | `99.52% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.8_types | `99.51% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.9 | `88.06% <100.00%> (+0.01%)` | :arrow_up: |
| py3.9_all | `99.52% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.9_types | `99.52% <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.

| [Impacted Files](https://app.codecov.io/gh/omni-us/jsonargparse/pull/315?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | Coverage Δ | |
|---|---|---|
| [jsonargparse/\_util.py](https://app.codecov.io/gh/omni-us/jsonargparse/pull/315?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#diff-anNvbmFyZ3BhcnNlL191dGlsLnB5) | `100.00% <100.00%> (ø)` | |


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/omni-us/jsonargparse/pull/315?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](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 cc24e9c..3ddc162 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -15,6 +15,11 @@ paths are considered internals and can change in minor and patch releases.
 v4.23.0 (2023-07-??)
 --------------------
 
+Added
+^^^^^
+- Classes created with ``class_from_function`` now have a valid import path
+  (`#309 <https://github.com/omni-us/jsonargparse/issues/309>`__).
+
 Fixed
 ^^^^^
 - Invalid environment variable names when ``env_prefix`` is derived from
diff --git a/README.rst b/README.rst
index ffa598f..0b00096 100644
--- a/README.rst
+++ b/README.rst
@@ -1502,6 +1502,17 @@ this function, the example above would change to:
     :func:`.class_from_function` requires the input function to have a return
     type annotation that must be the class type it returns.
 
+Classes created with :func:`.class_from_function` can be selected using
+``class_path`` for :ref:`sub-classes`. For example, if
+:func:`.class_from_function` is run in a module ``my_module`` as:
+
+.. testcode:: class_from_function
+
+    class_from_function(instantiate_myclass, name="MyClass")
+
+Then the ``class_path`` for the created class would be ``my_module.MyClass``.
+
+
 Parameter resolvers
 -------------------
 
diff --git a/jsonargparse/_util.py b/jsonargparse/_util.py
index 74b3351..e8ecacf 100644
--- a/jsonargparse/_util.py
+++ b/jsonargparse/_util.py
@@ -366,12 +366,14 @@ ClassType = TypeVar("ClassType")
 def class_from_function(
     func: Callable[..., ClassType],
     func_return: Optional[Type[ClassType]] = None,
+    name: Optional[str] = None,
 ) -> Type[ClassType]:
     """Creates a dynamic class which if instantiated is equivalent to calling func.
 
     Args:
         func: A function that returns an instance of a class.
         func_return: The return type of the function. Required if func does not have a return type annotation.
+        name: The name of the class. Defaults to function name suffixed with "_class".
     """
     if func_return is None:
         func_return = inspect.signature(func).return_annotation
@@ -386,6 +388,14 @@ def class_from_function(
             func_return = inspect.signature(func).return_annotation
             raise ValueError(f"Unable to dereference {func_return}, the return type of {func}: {ex}") from ex
 
+    if not name:
+        name = func.__qualname__.replace(".", "__") + "_class"
+
+    caller_module = inspect.getmodule(inspect.stack()[1][0]) or inspect.getmodule(class_from_function)
+    assert caller_module
+    if hasattr(caller_module, name):
+        raise ValueError(f"{caller_module.__name__} already defines {name!r}, please use a different name")
+
     @wraps(func)
     def __new__(cls, *args, **kwargs):
         return func(*args, **kwargs)
@@ -393,10 +403,13 @@ def class_from_function(
     class ClassFromFunction(func_return, ClassFromFunctionBase):  # type: ignore
         pass
 
+    setattr(caller_module, name, ClassFromFunction)
     ClassFromFunction.wrapped_function = func
     ClassFromFunction.__new__ = __new__  # type: ignore
     ClassFromFunction.__doc__ = func.__doc__
-    ClassFromFunction.__name__ = func.__name__
+    ClassFromFunction.__module__ = caller_module.__name__
+    ClassFromFunction.__name__ = name
+    ClassFromFunction.__qualname__ = name
     return ClassFromFunction
 
 


Test patch:
diff --git a/jsonargparse_tests/test_util.py b/jsonargparse_tests/test_util.py
index 5ef7413..57a1f5d 100644
--- a/jsonargparse_tests/test_util.py
+++ b/jsonargparse_tests/test_util.py
@@ -586,6 +586,15 @@ def test_class_from_function(function, class_type):
     cls = class_from_function(function)
     assert issubclass(cls, class_type)
     assert isinstance(cls(), class_type)
+    module_path, name = get_import_path(cls).rsplit(".", 1)
+    assert module_path == __name__
+    assert cls is globals()[name]
+
+
+def test_class_from_function_name_clash():
+    with pytest.raises(ValueError) as ctx:
+        class_from_function(get_random, name="get_random")
+    ctx.match("already defines 'get_random', please use a different name")
 
 
 def get_unknown() -> "Unknown":  # type: ignore  # noqa: F821