Repository: omni-us/jsonargparse
Pull request #216 (2022-12-16 13:52:24)

Added support for getting the import path of variables in modules
## What does this PR do?

As the title says.

## Before submitting

- [x] Did you read the [contributing guideline](https://github.com/omni-us/jsonargparse/blob/master/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=216)

[![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=216&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=216&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=216&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=216&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=216&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=216&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=216&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=216&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=216&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=216&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=216&resolved=false&types=CODE_SMELL) [1 Code Smell](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=216&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=216) 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=216&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=216&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 6e9c8e5..23e37a4 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -20,6 +20,8 @@ Added
 - ``CLI`` now exposes the ``fail_untyped`` parameter.
 - ``bytes`` and ``bytearray`` registered on first use and decodes from standard
   Base64.
+- Support getting the import path of variables in modules, e.g.
+  ``random.randint``.
 
 Fixed
 ^^^^^
diff --git a/jsonargparse/parameter_resolvers.py b/jsonargparse/parameter_resolvers.py
index d64d726..f41602f 100644
--- a/jsonargparse/parameter_resolvers.py
+++ b/jsonargparse/parameter_resolvers.py
@@ -51,7 +51,7 @@ class ConditionalDefault(list):
         return iter_to_set_str(self, sep=', ')
 
 
-def get_parameter_origins(component, parent) -> str:
+def get_parameter_origins(component, parent) -> Optional[str]:
     if isinstance(component, tuple):
         assert parent is None or len(component) == len(parent)
         return iter_to_set_str(
@@ -580,7 +580,7 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
                 subclass_types = get_subclass_types(param.annotation)
                 if not (class_type and subclass_types and is_subclass(class_type, subclass_types)):
                     continue
-                subclass_spec = dict(class_path=get_import_path(class_type), init_args=dict())
+                subclass_spec: dict = dict(class_path=get_import_path(class_type), init_args=dict())
                 for kwarg in node.keywords:
                     if kwarg.arg and ast_is_constant(kwarg.value):
                         subclass_spec['init_args'][kwarg.arg] = ast_get_constant_value(kwarg.value)
diff --git a/jsonargparse/util.py b/jsonargparse/util.py
index 5851923..9616d60 100644
--- a/jsonargparse/util.py
+++ b/jsonargparse/util.py
@@ -12,6 +12,7 @@ from collections import namedtuple
 from contextlib import contextmanager
 from contextvars import ContextVar
 from functools import wraps
+from importlib import import_module
 from types import BuiltinFunctionType, FunctionType, ModuleType
 from typing import Any, Callable, Optional, Tuple, Type, TypeVar, Union
 
@@ -194,17 +195,6 @@ def import_object(name: str):
     return getattr(parent, name_object)
 
 
-def import_module_leaf(name: str):
-    """Similar to __import__(name) but returns the leaf module instead of the root."""
-    if '.' in name:
-        name_parent, name_leaf = name.rsplit('.', 1)
-        parent = __import__(name_parent, fromlist=[name_leaf])
-        module = getattr(parent, name_leaf)
-    else:
-        module = __import__(name)
-    return module
-
-
 unresolvable_import_paths = {}
 
 
@@ -221,29 +211,48 @@ def register_unresolvable_import_paths(*modules: ModuleType):
                 unresolvable_import_paths[val] = f'{module.__name__}.{val.__name__}'
 
 
-def get_import_path(value):
+def get_module_var_path(module_path: str, value: Any) -> Optional[str]:
+    module = import_module(module_path)
+    for name, var in vars(module).items():
+        if var is value:
+            return module_path + '.' + name
+    return None
+
+
+def get_import_path(value: Any) -> Optional[str]:
     """Returns the shortest dot import path for the given object."""
+    path = None
     module_path = getattr(value, '__module__', None)
+    qualname = getattr(value, '__qualname__', '')
+
     if module_path is None:
         path = unresolvable_import_paths.get(value)
-        if not path:
-            raise ValueError(f'Not possible to determine the import path for object {value}.')
-        module_path = path.rsplit('.', 1)[0]
-    else:
-        path = module_path + '.' + value.__qualname__
-    if '.' in module_path:
+        if path:
+            module_path, _ = path.rsplit('.', 1)
+    elif (
+        (not qualname and not inspect.isclass(value)) or
+        (inspect.ismethod(value) and not inspect.isclass(value.__self__))
+    ):
+        path = get_module_var_path(module_path, value)
+    elif qualname:
+        path = module_path + '.' + qualname
+
+    if not path:
+        raise ValueError(f'Not possible to determine the import path for object {value}.')
+
+    if qualname and module_path and '.' in module_path:
         module_parts = module_path.split('.')
         for num in range(len(module_parts)):
             module_path = '.'.join(module_parts[:num+1])
-            module = import_module_leaf(module_path)
-            if '.' in value.__qualname__:
-                obj_name, attr = value.__qualname__.rsplit('.', 1)
+            module = import_module(module_path)
+            if '.' in qualname:
+                obj_name, attr = qualname.rsplit('.', 1)
                 obj = getattr(module, obj_name, None)
                 if getattr(obj, attr, None) is value:
-                    path = module_path + '.' + value.__qualname__
+                    path = module_path + '.' + qualname
                     break
-            elif getattr(module, value.__qualname__, None) is value:
-                path = module_path + '.' + value.__qualname__
+            elif getattr(module, qualname, None) is value:
+                path = module_path + '.' + qualname
                 break
     return path
 


Test patch:
diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py
index bf335be..da66315 100755
--- a/jsonargparse_tests/test_typehints.py
+++ b/jsonargparse_tests/test_typehints.py
@@ -697,6 +697,7 @@ class TypeHintsTests(unittest.TestCase):
         self.assertEqual(parser.dump(cfg), 'callable: time.time\n')
         cfg = parser.parse_args(['--callable=random.randint'])
         self.assertEqual(random.randint, cfg.callable)
+        self.assertEqual(parser.dump(cfg), 'callable: random.randint\n')
         cfg = parser.parse_args(['--callable=jsonargparse.CLI'])
         self.assertEqual(CLI, cfg.callable)
         self.assertEqual(parser.dump(cfg), 'callable: jsonargparse.CLI\n')
diff --git a/jsonargparse_tests/test_util.py b/jsonargparse_tests/test_util.py
index b2e50f0..60539ac 100755
--- a/jsonargparse_tests/test_util.py
+++ b/jsonargparse_tests/test_util.py
@@ -314,6 +314,8 @@ class ImportFunctionsTests(unittest.TestCase):
         self.assertEqual(get_import_path(ArgumentParser.merge_config), 'jsonargparse.ArgumentParser.merge_config')
         from email.mime.base import MIMEBase
         self.assertEqual(get_import_path(MIMEBase), 'email.mime.base.MIMEBase')
+        from dataclasses import MISSING
+        self.assertEqual(get_import_path(MISSING), 'dataclasses.MISSING')
 
 
     def test_register_unresolvable_import_paths(self):