Repository: omni-us/jsonargparse
Pull request #198 (2022-11-24 04:17:35)

Callable with class return type improvements
## What does this PR do?

Fixes and improvements to Callable with class as return type.

- [x] Fix specifying only the class name through command line
- [x] AST resolving of lambda function as default

## 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=198)

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


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=198)

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



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 6eba696..0133b9d 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -15,6 +15,11 @@ paths are considered internals and can change in minor and patch releases.
 v4.18.0 (2022-11-??)
 --------------------
 
+Added
+^^^^^
+- AST resolving for defaults with a class instance or a lambda that returns a
+  class instance.
+
 Fixed
 ^^^^^
 - ``bool`` values should not be accepted by ``int`` or ``float`` types.
@@ -26,6 +31,8 @@ Fixed
 - List append not working for ``default_config_files`` set in a subcommand
   subparser `pytorch-lightning#15256
   <https://github.com/Lightning-AI/lightning/issues/15256>`__.
+- Specifying only the class name through command line not working for
+  ``Callable`` with class return type.
 
 Changed
 ^^^^^^^
diff --git a/README.rst b/README.rst
index 575662d..62568f8 100644
--- a/README.rst
+++ b/README.rst
@@ -584,10 +584,8 @@ string for reading the list from stdin. Example:
     with open('paths.lst', 'w') as f:
         f.write('paths.lst\n')
 
-    from jsonargparse import ArgumentParser
     parser = ArgumentParser()
 
-    import sys
     stdin = sys.stdin
     sys.stdin = open('paths.lst', 'r')
 
@@ -668,7 +666,6 @@ the corresponding parsed values would be ``True`` or ``False``. For example:
 
 .. testsetup:: boolean
 
-    from jsonargparse import ArgumentParser
     parser = ArgumentParser()
 
 .. doctest:: boolean
@@ -684,7 +681,6 @@ straightforward. A couple of examples would be:
 
 .. testsetup:: yes_no
 
-    from jsonargparse import ArgumentParser
     parser = ArgumentParser()
 
 .. testcode:: yes_no
@@ -712,7 +708,6 @@ desired values. For example:
 
 .. testsetup:: enum
 
-    from jsonargparse import ArgumentParser
     parser = ArgumentParser()
 
 .. doctest:: enum
@@ -741,7 +736,6 @@ can be achieved by adding ``+`` as suffix to the argument key, for example:
 
 .. testsetup:: append
 
-    from jsonargparse import ArgumentParser
     from typing import List
     parser = ArgumentParser()
 
@@ -784,7 +778,6 @@ e.g.:
 
 .. testsetup:: dict_items
 
-    from jsonargparse import ArgumentParser
     parser = ArgumentParser()
 
 .. doctest:: dict_items
@@ -813,8 +806,6 @@ option is the import path of a callable object, for example:
 
 .. testsetup:: callable
 
-    import sys
-    from jsonargparse import ArgumentParser, ActionConfigFile
     from typing import Callable, Iterable
     parser = ArgumentParser()
 
@@ -825,13 +816,20 @@ option is the import path of a callable object, for example:
 
 A second option is a class that once instantiated becomes callable:
 
-.. code-block::
+.. testcode:: callable
+
+    class OffsetSum:
+        def __init__(self, offset: int):
+            self.offset = offset
+        def __call__(self, value: int):
+            return self.offset + value
+
+.. testcode:: callable
+    :hide:
+
+    doctest_mock_class_in_main(OffsetSum)
 
-    >>> class OffsetSum:
-    ...     def __init__(self, offset: int):
-    ...         self.offset = offset
-    ...     def __call__(self, value: int):
-    ...         return self.offset + value
+.. doctest:: callable
 
     >>> value = {
     ...     'class_path': '__main__.OffsetSum',
@@ -848,20 +846,31 @@ A second option is a class that once instantiated becomes callable:
     8
 
 The third option is only applicable when the type is a callable that has a class
-as return type. This is useful to support dependency injection for classes that
-require a parameter that is only available after injection. The parser does this
-automatically by providing a function that receives this parameter and returns
-the instance of the class.
+as return type or a ``Union`` including a class. This is useful to support
+dependency injection for classes that require a parameter that is only available
+after injection. The parser supports this automatically by providing a function
+that receives this parameter and returns the instance of the class. Take for
+example the classes:
 
-.. code-block::
+.. testcode:: callable
+
+    class Optimizer:
+        def __init__(self, params: Iterable):
+            self.params = params
+
+    class SGD(Optimizer):
+        def __init__(self, params: Iterable, lr: float):
+            super().__init__(params)
+            self.lr = lr
+
+.. testcode:: callable
+    :hide:
 
-    >>> class Optimizer:
-    ...     pass
+    doctest_mock_class_in_main(SGD)
 
-    >>> class SGD(Optimizer):
-    ...     def __init__(self, params: Iterable, lr: float):
-    ...         self.params = params
-    ...         self.lr = lr
+A possible parser and callable behavior would be:
+
+.. doctest:: callable
 
     >>> value = {
     ...     'class_path': 'SGD',
@@ -870,23 +879,50 @@ the instance of the class.
     ...     }
     ... }
 
-    >>> parser.add_argument('--optimizer', type=Callable[[Iterable], Optimizer])
+    >>> parser.add_argument('--optimizer', type=Callable[[Iterable], Optimizer])  # doctest: +IGNORE_RESULT
     >>> cfg = parser.parse_args(['--optimizer', str(value)])
     >>> cfg.optimizer
     Namespace(class_path='__main__.SGD', init_args=Namespace(lr=0.01))
     >>> init = parser.instantiate_classes(cfg)
-    >>> optim = init.optimizer([1, 2, 3])
-    >>> isinstance(optim, SGD)
+    >>> optimizer = init.optimizer([1, 2, 3])
+    >>> isinstance(optimizer, SGD)
     True
-    >>> optim.params, optim.lr
+    >>> optimizer.params, optimizer.lr
     ([1, 2, 3], 0.01)
 
 .. note::
 
     When the ``Callable`` has a class return type, it is possible to specify the
-    ``class_path`` giving only its name if imported before parsing as explained
+    ``class_path`` giving only its name if imported before parsing, as explained
     in :ref:`sub-classes-command-line`.
 
+If the same type above is used as type hint of a parameter of another class, a
+default can be set using a lambda, for example:
+
+.. testcode:: callable
+
+    class Model:
+        def __init__(
+            self,
+            optimizer: Callable[[Iterable], Optimizer] = lambda p: SGD(p, lr=0.05),
+        ):
+            self.optimizer = optimizer
+
+Then a parser and behavior could be:
+
+.. code-block::
+
+    >>> parser.add_class_arguments(Model, 'model')
+    >>> cfg = parser.get_defaults()
+    >>> cfg.model.optimizer
+    Namespace(class_path='__main__.SGD', init_args=Namespace(lr=0.05))
+    >>> init = parser.instantiate_classes(cfg)
+    >>> optimizer = init.model.optimizer([1, 2, 3])
+    >>> optimizer.params, optimizer.lr
+    ([1, 2, 3], 0.05)
+
+See :ref:`ast-resolver` for limitations of lambda defaults.
+
 
 .. _registering-types:
 
@@ -1151,7 +1187,6 @@ Take for example a class with its init and a method with docstrings as follows:
 
 .. testsetup:: class_method
 
-    import sys
     sys.argv = ['', '--myclass.init.foo={}', '--myclass.method.bar=0']
     class MyBaseClass: pass
 
@@ -1349,7 +1384,6 @@ Take for example the following parsing and instantiation:
 
 .. testsetup:: unresolved
 
-    import sys
     import jsonargparse_tests
     sys.argv = ['', '--myclass=MyClass']
 
@@ -1395,6 +1429,8 @@ like ``super().__init__(*args, **kwargs)``. Thus, it blindly collects the
 incorrect if the code does not follow this pattern. This is why it is only used
 as fallback when the AST resolver fails.
 
+.. _ast-resolver:
+
 AST resolver
 ^^^^^^^^^^^^
 
@@ -1409,7 +1445,14 @@ unrelated to these variables.
 
 .. testsetup:: ast_resolver
 
+    from typing import Callable
     class BaseClass: pass
+    class SomeClass:
+        def __init__(self, **kwargs):
+            pass
+    class ChildClass(BaseClass):
+        def __init__(self, *args, **kwargs):
+            pass
 
 **Cases for statements in functions or methods**
 
@@ -1488,8 +1531,18 @@ unrelated to these variables.
         def __init__(self, *args, **kwargs):
             super(BaseClass, self).__init__(*args, **kwargs)
 
+**Cases for class instance defaults**
+
+.. testcode:: ast_resolver
+
+    # Class instance: only keyword arguments with ``ast.Constant` value
+    class_instance: SomeClass = SomeClass(param=1)
+
+    # Lambda returning class instance: only keyword arguments with ``ast.Constant` value
+    class_instance: Callable[[type], BaseClass] = lambda a: ChildClass(a, param=2.3)
+
 There can be other parameters apart from ``*args`` and ``**kwargs``, thus in the
-cases above the signatures can be for example like ``name(p1: int, k1: str =
+cases above, the signatures can be for example like ``name(p1: int, k1: str =
 'a', **kws)``. Also when internally calling some function or instantiating a
 class, there can be additional parameters. For example in:
 
@@ -1570,7 +1623,6 @@ Then in python:
     import os
     import shutil
     import tempfile
-    from jsonargparse import ArgumentParser
     cwd = os.getcwd()
     tmpdir = tempfile.mkdtemp(prefix='_jsonargparse_doctest_')
     os.chdir(tmpdir)
@@ -1667,7 +1719,7 @@ its name can be seen in the general help ``python tool.py --help``.
 Default values
 --------------
 
-For a parameter that has a class as type it might also be wanted to set a
+For a parameter that has a class as type, it might also be wanted to set a
 default value for it. Special care must be taken when doing this, could be
 considered bad practice and be a good idea to avoid in most cases. The issue is
 that classes are normally mutable. Depending on how the parameter value is used,
@@ -1676,14 +1728,13 @@ what a default value is expected to be and lead to bugs which are difficult to
 debug.
 
 Since there are some legitimate use cases for class instances in defaults, they
-are supported with a particular behavior and recommendations. The first approach
-is using a normal class instance, for example:
+are supported with a particular behavior and recommendations. An example is:
 
-.. testsetup:: lazy_instance
+.. testsetup:: instance_default
 
     from calendar import Calendar
 
-.. testcode:: lazy_instance
+.. testcode:: instance_default
 
     class MyClass:
         def __init__(
@@ -1692,18 +1743,20 @@ is using a normal class instance, for example:
         ):
             self.calendar = calendar
 
-Adding this class to a parser will work without issues. Parsing would also work
-and if not overridden the default class instance will be found in the respective
-key of the config object. If ``--print_config`` is used, the class instance is
-just cast to a string. This means that the generated config file must be
-modified to become a valid input to the parser. Due to the limitations and the
-risk of mutable default this approach is discouraged.
+Adding this class to a parser will work without issues. The :ref:`ast-resolver`
+in limited cases determines how to instantiate the original default. The parsing
+methods would provide a dict with ``class_path`` and ``init_args`` instead of
+the class instance. Furthermore, if
+:py:meth:`.ArgumentParser.instantiate_classes` is used, a new instance of the
+class is created, thereby avoiding issues related to the mutability of the
+default.
 
-The second approach which is the recommended one is to use the special function
+Since the :ref:`ast-resolver` only supports limited cases, or when the source
+code is not available, a second approach is to use the special function
 :func:`.lazy_instance` to instantiate the default. Continuing with the same
-example above this would be:
+example above, this would be:
 
-.. testcode:: lazy_instance
+.. testcode:: instance_default
 
     from jsonargparse import lazy_instance
 
@@ -1714,12 +1767,8 @@ example above this would be:
         ):
             self.calendar = calendar
 
-In this case the default value will still be an instance of ``Calendar``. The
-difference is that the parsing methods would provide a dict with ``class_path``
-and ``init_args`` instead of the class instance. Furthermore, if
-:py:meth:`.ArgumentParser.instantiate_classes` is used a new instance of the
-class is created thereby avoiding issues related to the mutability of the
-default.
+Like this, the parsed default will be a dict with ``class_path`` and
+``init_args``, again avoiding the risk of mutability.
 
 .. note::
 
@@ -1745,7 +1794,6 @@ corresponding yaml structure.
     import shutil
     import tempfile
     from calendar import Calendar
-    from jsonargparse import ArgumentParser
     cwd = os.getcwd()
     tmpdir = tempfile.mkdtemp(prefix='_jsonargparse_doctest_')
     os.chdir(tmpdir)
@@ -1880,7 +1928,6 @@ Take for example a yaml file as:
     with open('example.yaml', 'w') as f:
         f.write(example)
     from dataclasses import dataclass
-    from jsonargparse import ArgumentParser, ActionConfigFile
 
 .. testcleanup:: omegaconf
 
@@ -1941,7 +1988,6 @@ command line arguments, that is:
 .. testsetup:: env
 
     import os
-    from jsonargparse import ArgumentParser
     os.environ['APP_LEV1__OPT1'] = 'from env 1'
     os.environ['APP_LEV1__OPT2'] = 'from env 2'
 
@@ -2218,7 +2264,6 @@ argcomplete compatible tools or for each `individual
 
 .. testsetup:: tab_completion
 
-    import sys
     sys.argv = ['']
 
 .. testcode:: tab_completion
diff --git a/jsonargparse/parameter_resolvers.py b/jsonargparse/parameter_resolvers.py
index 3c47529..b05e152 100644
--- a/jsonargparse/parameter_resolvers.py
+++ b/jsonargparse/parameter_resolvers.py
@@ -10,6 +10,7 @@ from dataclasses import dataclass
 from functools import partial
 from typing import Any, Callable, List, Optional, Tuple, Type, Union
 
+from .optionals import parse_docs
 from .util import (
     ClassFromFunctionBase,
     get_import_path,
@@ -19,7 +20,6 @@ from .util import (
     parse_logger,
     unique,
 )
-from .optionals import parse_docs
 
 
 @dataclass
@@ -83,6 +83,10 @@ def is_classmethod(parent, component) -> bool:
     return parent and isinstance(inspect.getattr_static(parent, component.__name__), classmethod)
 
 
+def is_lambda(value: Any) -> bool:
+    return callable(value) and value.__name__ == '<lambda>'
+
+
 def ast_str(node):
     return getattr(ast, 'unparse', ast.dump)(node)
 
@@ -149,6 +153,16 @@ def ast_get_constant_value(node):
     return getattr(node, ast_constant_attr[node.__class__])
 
 
+def ast_get_name_and_attrs(node) -> List[str]:
+    names = []
+    while isinstance(node, ast.Attribute):
+        names.append(node.attr)
+        node = node.value
+    if isinstance(node, ast.Name):
+        names.append(node.id)
+    return names[::-1]
+
+
 def ast_is_kwargs_pop_or_get(node, value_dump) -> bool:
     return (
         isinstance(node.func, ast.Attribute) and
@@ -169,7 +183,7 @@ def ast_is_super_call(node) -> bool:
     )
 
 
-def ast_is_supported_super_call(node, self_name, logger) -> bool:
+def ast_is_supported_super_call(node, self_name, log_debug) -> bool:
     supported = False
     args = node.func.value.args
     if not args and not node.func.value.keywords:
@@ -189,7 +203,7 @@ def ast_is_supported_super_call(node, self_name, logger) -> bool:
                 supported = True
                 break
     if not supported:
-        logger.debug(f'AST resolver: unsupported super parameters: {ast_str(node)}')
+        log_debug(f'unsupported super parameters: {ast_str(node)}')
     return supported
 
 
@@ -256,7 +270,7 @@ ast_literals = {
 }
 
 
-def get_kwargs_pop_or_get_parameter(node, component, parent, doc_params, logger):
+def get_kwargs_pop_or_get_parameter(node, component, parent, doc_params, log_debug):
     name = ast_get_constant_value(node.args[0])
     if ast_is_constant(node.args[1]):
         default = ast_get_constant_value(node.args[1])
@@ -266,7 +280,7 @@ def get_kwargs_pop_or_get_parameter(node, component, parent, doc_params, logger)
             default = ast_literals[default]()
         else:
             default = None
-            logger.debug(f'AST resolver: unsupported kwargs pop/get default: {ast_str(node)}')
+            log_debug(f'unsupported kwargs pop/get default: {ast_str(node)}')
     return ParamData(
         name=name,
         annotation=inspect._empty,
@@ -279,6 +293,20 @@ def get_kwargs_pop_or_get_parameter(node, component, parent, doc_params, logger)
     )
 
 
+def is_param_subclass_instance_default(param: ParamData) -> bool:
+    from .typehints import ActionTypeHint, get_subclass_types
+    class_types = get_subclass_types(param.annotation)
+    return (
+        (class_types and isinstance(param.default, class_types)) or
+        (
+            is_lambda(param.default) and
+            ActionTypeHint.is_callable_typehint(param.annotation, all_subtypes=False) and
+            param.annotation.__args__ and
+            ActionTypeHint.is_subclass_typehint(param.annotation.__args__[-1], all_subtypes=False)
+        )
+    )
+
+
 def split_args_and_kwargs(params: ParamList) -> Tuple[ParamList, ParamList]:
     args = [p for p in params if p.kind is kinds.POSITIONAL_ONLY]
     kwargs = [p for p in params if p.kind in {kinds.KEYWORD_ONLY, kinds.POSITIONAL_OR_KEYWORD}]
@@ -419,11 +447,13 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
     ):
         super().__init__(**kwargs)
         self.component, self.parent, _ = get_component_and_parent(function_or_class, method_or_property)
-        self.parse_source_tree()
+
+    def log_debug(self, message) -> None:
+        self.logger.debug(f'AST resolver: {message}')
 
     def parse_source_tree(self):
-        """Parses the component's module AST and sets the component and parent nodes."""
-        if self.component is None:
+        """Parses the component's AST and sets the component and parent nodes."""
+        if hasattr(self, 'component_node'):
             return
         try:
             source = textwrap.dedent(inspect.getsource(self.component))
@@ -509,7 +539,7 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
                 else:
                     function_or_class = getattr(container, node.func.attr)
         if not function_or_class:
-            self.logger.debug(f'AST resolver: not supported: {ast_str(node)}')
+            self.log_debug(f'not supported: {ast_str(node)}')
             return None
         return function_or_class, method_or_property
 
@@ -521,7 +551,7 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
             kwarg = ast_get_call_kwarg_with_value(node, value)
             if kwarg:
                 if kwarg.arg:
-                    self.logger.debug(f'AST resolver: kwargs attribute given as keyword parameter not supported: {ast_str(node)}')
+                    self.log_debug(f'kwargs attribute given as keyword parameter not supported: {ast_str(node)}')
                 else:
                     get_param_args = self.get_node_component(node)
                     if get_param_args:
@@ -529,7 +559,54 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
             params = remove_given_parameters(node, params)
         return params
 
+    def replace_param_default_subclass_specs(self, params: List[ParamData]) -> None:
+        params = [p for p in params if is_param_subclass_instance_default(p)]
+        if params:
+            self.parse_source_tree()
+            default_nodes = self.get_default_nodes({p.name for p in params})
+            assert len(params) == len(default_nodes)
+            from .typehints import get_subclass_types
+            for param, default_node in zip(params, default_nodes):
+                lambda_default = is_lambda(param.default)
+                node = default_node
+                num_positionals = 0
+                if lambda_default:
+                    node = default_node.body
+                    num_positionals = len(param.annotation.__args__) - 1
+                class_type = self.get_call_class_type(node)
+                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())
+                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)
+                    else:
+                        subclass_spec.clear()
+                        break
+                if not subclass_spec or len(node.args) - num_positionals > 0:
+                    self.log_debug(f'unsupported class instance default: {ast_str(default_node)}')
+                elif subclass_spec:
+                    if not subclass_spec['init_args']:
+                        del subclass_spec['init_args']
+                    param.default = subclass_spec
+
+    def get_call_class_type(self, node) -> Optional[type]:
+        names = ast_get_name_and_attrs(getattr(node, 'func', None))
+        class_type = self.component.__globals__.get(names[0]) if names else None
+        for name in names[1:]:
+            class_type = getattr(class_type, name, None)
+        return class_type if inspect.isclass(class_type) else None
+
+    def get_default_nodes(self, param_names: set):
+        node = self.component_node.args
+        arg_nodes = getattr(node, 'posonlyargs', []) + node.args
+        default_nodes = [None] * (len(arg_nodes) - len(node.defaults)) + node.defaults
+        default_nodes = [d for n, d in enumerate(default_nodes) if arg_nodes[n].arg in param_names]
+        return default_nodes
+
     def get_parameters_args_and_kwargs(self) -> Tuple[ParamList, ParamList]:
+        self.parse_source_tree()
         args_name = getattr(self.component_node.args.vararg, 'arg', None)
         kwargs_name = getattr(self.component_node.args.kwarg, 'arg', None)
         values_to_find = {}
@@ -547,15 +624,15 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
         for node in [v for k, v in values_found if k == kwargs_name]:
             if isinstance(node, ast.Call):
                 if ast_is_kwargs_pop_or_get(node, kwargs_value_dump):
-                    param = get_kwargs_pop_or_get_parameter(node, self.component, self.parent, self.doc_params, self.logger)
+                    param = get_kwargs_pop_or_get_parameter(node, self.component, self.parent, self.doc_params, self.log_debug)
                     params_list.append([param])
                     continue
                 kwarg = ast_get_call_kwarg_with_value(node, kwargs_value)
                 params = []
                 if kwarg.arg:
-                    self.logger.debug(f'AST resolver: kwargs given as keyword parameter not supported: {ast_str(node)}')
+                    self.log_debug(f'kwargs given as keyword parameter not supported: {ast_str(node)}')
                 elif self.parent and ast_is_super_call(node):
-                    if ast_is_supported_super_call(node, self.self_name, self.logger):
+                    if ast_is_supported_super_call(node, self.self_name, self.log_debug):
                         params = get_mro_parameters(node.func.attr, get_signature_parameters, self.logger)  # type: ignore
                 else:
                     get_param_args = self.get_node_component(node)
@@ -573,7 +650,7 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
                         self.add_node_origins(params, node)
                         params_list.append(params)
                 else:
-                    self.logger.debug(f'AST resolver: unsupported type of assign: {ast_str(node)}')
+                    self.log_debug(f'unsupported type of assign: {ast_str(node)}')
 
         params = group_parameters(params_list)
         return split_args_and_kwargs(params)
@@ -590,7 +667,7 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
             kwargs = visitor.get_parameters_call_attr(attr_name, attr_value)
             if kwargs is not None:
                 return kwargs
-        self.logger.debug(f'AST resolver: did not find use of {self.self_name}.{attr_name} in members of {self.parent}')
+        self.log_debug(f'did not find use of {self.self_name}.{attr_name} in members of {self.parent}')
         return []
 
     def add_node_origins(self, params: ParamList, node) -> None:
@@ -602,6 +679,7 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
                 param.origin = origin
 
     def get_parameters_call_attr(self, attr_name: str, attr_value: ast.AST) -> Optional[ParamList]:
+        self.parse_source_tree()
         values_to_find = {attr_name: attr_value}
         values_found = self.find_values_usage(values_to_find)
         matched = []
@@ -618,6 +696,7 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
         if self.component is None:
             return []
         params, args_idx, kwargs_idx, doc_params = get_signature_parameters_and_indexes(self.component, self.parent, self.logger)
+        self.replace_param_default_subclass_specs(params)
         if args_idx >= 0 or kwargs_idx >= 0:
             self.doc_params = doc_params
             with mro_context(self.parent):
diff --git a/jsonargparse/typehints.py b/jsonargparse/typehints.py
index 0e22099..fd31924 100644
--- a/jsonargparse/typehints.py
+++ b/jsonargparse/typehints.py
@@ -650,20 +650,24 @@ def adapt_typehints(val, typehint, serialize=False, instantiate_classes=False, p
                 val = object_path_serializer(val)
         else:
             try:
+                val_input = val
                 if isinstance(val, str):
-                    val_obj = import_object(val)
+                    class_path = val
+                    return_type = get_callable_return_type(typehint)
+                    if '.' not in val and return_type:
+                        class_path = resolve_class_path_by_name(return_type, val)
+                    val_obj = import_object(class_path)
                     if inspect.isclass(val_obj):
-                        val = Namespace(class_path=val)
+                        val = Namespace(class_path=class_path)
                     elif callable(val_obj):
                         val = val_obj
                     else:
                         raise ImportError(f'Unexpected import object {val_obj}')
                 if isinstance(val, (dict, Namespace, NestedArg)):
-                    val_input = val
                     val = subclass_spec_as_namespace(val, prev_val)
                     if not is_subclass_spec(val):
                         raise ImportError(f'Dict must include a class_path and optionally init_args, but got {val_input}')
-                    val, partial_classes, num_partial_args = get_partial_callable_classes(typehint, val)
+                    val, partial_classes, num_partial_args = adapt_partial_callable_class(typehint, val)
                     val_class = import_object(val['class_path'])
                     if inspect.isclass(val_class) and not (partial_classes or callable_instances(val_class)):
                         raise ImportError(f'Expected {val["class_path"]!r} to be a class that instantiates into callable or a subclass of {partial_classes}.')
@@ -747,23 +751,37 @@ def subclass_spec_as_namespace(val, prev_val=None):
     return val
 
 
-def get_partial_callable_classes(callable_type, subclass_spec):
+def get_callable_return_type(typehint):
+    return_type = None
+    if len(getattr(typehint, '__args__', None) or []) > 1:
+        return_type = typehint.__args__[1]
+    return return_type
+
+
+def get_subclass_types(typehint, callable_return=True):
+    subclass_types = None
+    if callable_return and ActionTypeHint.is_callable_typehint(typehint, all_subtypes=False) and typehint.__args__:
+        typehint = typehint.__args__[-1]
+    if ActionTypeHint.is_subclass_typehint(typehint, all_subtypes=False):
+        if get_typehint_origin(typehint) == Union:
+            subclass_types = tuple(t for t in typehint.__args__ if ActionTypeHint.is_subclass_typehint(t))
+        else:
+            subclass_types = (typehint,)
+    return subclass_types
+
+
+def adapt_partial_callable_class(callable_type, subclass_spec):
     partial_classes = False
     num_partial_args = 0
-    subtypes = getattr(callable_type, '__args__', None)
-    if subtypes and len(subtypes) > 1:
-        return_type = subtypes[-1]
-        return_origin = get_typehint_origin(return_type)
-        subclass_types = tuple(
-            t for t in (return_type.__args__ if return_origin == Union else [return_type])
-            if ActionTypeHint.is_subclass_typehint(t)
-        )
-        class_type = import_object(resolve_class_path_by_name(return_type, subclass_spec['class_path']))
+    return_type = get_callable_return_type(callable_type)
+    if return_type:
+        subclass_types = get_subclass_types(return_type, callable_return=False)
+        class_type = import_object(resolve_class_path_by_name(return_type, subclass_spec.class_path))
         if subclass_types and is_subclass(class_type, subclass_types):
-            subclass_spec = deepcopy(subclass_spec)
+            subclass_spec = subclass_spec.clone()
             subclass_spec['class_path'] = get_import_path(class_type)
             partial_classes = True
-            num_partial_args = len(subtypes)-1
+            num_partial_args = len(callable_type.__args__)-1
     return subclass_spec, partial_classes, num_partial_args
 
 
diff --git a/sphinx/conf.py b/sphinx/conf.py
index e75aeaf..749596e 100644
--- a/sphinx/conf.py
+++ b/sphinx/conf.py
@@ -68,6 +68,13 @@ class CustomOutputChecker(OutputChecker):
 
 doctest.OutputChecker = CustomOutputChecker
 
+doctest_global_setup = '''
+import sys
+from jsonargparse import *
+from jsonargparse.typing import *
+from jsonargparse_tests.base import doctest_mock_class_in_main
+'''
+
 # Add any paths that contain templates here, relative to this directory.
 templates_path = ['_templates']
 


Test patch:
diff --git a/jsonargparse_tests/base.py b/jsonargparse_tests/base.py
index 9f14ae9..e92fba2 100644
--- a/jsonargparse_tests/base.py
+++ b/jsonargparse_tests/base.py
@@ -2,11 +2,13 @@ import inspect
 import os
 import platform
 import shutil
+import sys
 import tempfile
 import unittest
 from contextlib import contextmanager, redirect_stderr
 from importlib.util import find_spec
 from jsonargparse.optionals import docstring_parser_support, set_docstring_parse_options
+from jsonargparse.util import unresolvable_import_paths
 
 
 responses_available = find_spec('responses') is not None
@@ -46,6 +48,12 @@ def mock_module(*args):
         yield __module__
 
 
+def doctest_mock_class_in_main(cls):
+    cls.__module__ = None
+    setattr(sys.modules['__main__'], cls.__name__, cls)
+    unresolvable_import_paths[cls] = f'__main__.{cls.__name__}'
+
+
 @contextmanager
 def suppress_stderr():
     """A context manager that redirects stderr to devnull."""
diff --git a/jsonargparse_tests/test_parameter_resolvers.py b/jsonargparse_tests/test_parameter_resolvers.py
index 4d6dd88..9565ae2 100755
--- a/jsonargparse_tests/test_parameter_resolvers.py
+++ b/jsonargparse_tests/test_parameter_resolvers.py
@@ -1,15 +1,21 @@
 #!/usr/bin/env python3
 
 import calendar
+import inspect
 import logging
 import unittest
+import xml.dom
+from calendar import Calendar
 from contextlib import contextmanager
 from random import shuffle
-from typing import Any, Dict
+from typing import Any, Callable, Dict, List
 from unittest.mock import patch
 from jsonargparse import class_from_function, Namespace
 from jsonargparse.optionals import docstring_parser_support
-from jsonargparse.parameter_resolvers import get_signature_parameters as get_params
+from jsonargparse.parameter_resolvers import (
+    get_signature_parameters as get_params,
+    is_lambda,
+)
 
 
 class ClassA:
@@ -262,6 +268,44 @@ class ClassU5:
     def method1(self, kws: dict):
         pass
 
+class Optimizer:
+    def __init__(self, params: List[int]):
+        self.params = params
+
+class SGD(Optimizer):
+    def __init__(self, params: List[int], lr: float, **kwargs):
+        super().__init__(params, **kwargs)
+        self.lr = lr
+
+class ClassInstanceDefaults:
+    def __init__(
+        self,
+        layers: int,
+        number: float = 0.2,
+        elements: Callable[..., List[int]] = lambda *a: list(a),
+        node: Callable[[], xml.dom.Node] = lambda: xml.dom.Node(),
+        cal1: Calendar = Calendar(firstweekday=1),
+        cal2: Calendar = calendar.TextCalendar(2),
+        opt1: Callable[[List[int]], Optimizer] = lambda p: SGD(p, lr=0.01),
+        opt2: Callable[[List[int]], Optimizer] = lambda p: SGD(p, 0.02),
+        opt3: Callable[[List[int]], Optimizer] = lambda p, lr=0.1: SGD(p, lr=lr),  # type: ignore
+        opt4: Callable[[List[int]], Optimizer] = lambda p: Calendar(firstweekday=3),  # type: ignore
+        **kwargs,
+    ):
+        """
+        Args:
+            layers: help for layers
+            number: help for number
+            elements: help for elements
+            node: help for node
+            cal1: help for cal1
+            cal2: help for cal2
+            opt1: help for opt1
+            opt2: help for opt2
+            opt3: help for opt3
+            opt4: help for opt4
+        """
+
 def function_no_args_no_kwargs(pk1: str, k2: int = 1):
     """
     Args:
@@ -454,6 +498,24 @@ class GetClassParametersTests(unittest.TestCase):
             params = get_params(class_a)
             assert_params(self, params, ['pk1', 'k2'])
 
+    def test_get_params_class_instance_defaults(self):
+        params = get_params(ClassInstanceDefaults)
+        assert_params(self, params, ['layers', 'number', 'elements', 'node', 'cal1', 'cal2', 'opt1', 'opt2', 'opt3', 'opt4'])
+        with self.subTest('unmodified defaults'):
+            self.assertEqual(params[0].default, inspect._empty)
+            self.assertEqual(params[1].default, 0.2)
+            self.assertTrue(is_lambda(params[2].default))
+        with self.subTest('supported defaults'):
+            self.assertEqual(params[3].default, dict(class_path='xml.dom.Node'))
+            self.assertEqual(params[4].default, dict(class_path='calendar.Calendar', init_args=dict(firstweekday=1)))
+            self.assertEqual(params[6].default, dict(class_path=f'{__name__}.SGD', init_args=dict(lr=0.01)))
+        with self.subTest('unsupported defaults'):
+            self.assertIsInstance(params[5].default, calendar.TextCalendar)
+            self.assertTrue(is_lambda(params[7].default))
+            self.assertTrue(is_lambda(params[9].default))
+        with self.subTest('invalid defaults'):
+            self.assertTrue(is_lambda(params[8].default))
+
 
 class GetMethodParametersTests(unittest.TestCase):
 
diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py
index ecbe439..5f1ccc4 100755
--- a/jsonargparse_tests/test_typehints.py
+++ b/jsonargparse_tests/test_typehints.py
@@ -801,6 +801,8 @@ class TypeHintsTests(unittest.TestCase):
             self.assertEqual(optim.params, [4.5, 6.7])
             self.assertEqual(optim.lr, 0.01)
 
+            self.assertEqual(cfg, parser.parse_args(['--optimizer=Adam', '--optimizer.lr=0.01']))
+
             help_str = StringIO()
             parser.print_help(help_str)
             for name in ['Optimizer', 'SGD', 'Adam']: