Repository: omni-us/jsonargparse
Pull request #220 (2022-12-20 20:53:15)

New resolver for getting parameter types from *.pyi files
## What does this PR do?

See title.

## 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=220)

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

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



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 8885520..f4f6c13 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -25,6 +25,7 @@ Added
 - Specific error messages for when an argument link uses as source the target of
   a previous parse link and vice versa `#208
   <https://github.com/omni-us/jsonargparse/issues/208>`__.
+- New resolver that identifies parameter types from stub files ``*.pyi``.
 
 Fixed
 ^^^^^
@@ -33,6 +34,7 @@ Fixed
 - Pure dataclass instance default being considered as a subclass type.
 - Discard ``init_args`` after ``class_path`` change causes error `#205
   <https://github.com/omni-us/jsonargparse/issues/205>`__.
+- ``fail_untyped=False`` not propagated to subclass ``--*.help`` actions.
 - Issues reported by CodeQL.
 
 Changed
@@ -40,6 +42,7 @@ Changed
 - Clearer error message for when an argument link targets a subclass and the
   target key does not have ``init_args`` `pytorch-lightning#16032
   <https://github.com/Lightning-AI/lightning/issues/16032>`__.
+- The ``signatures`` extras now installs the ``typeshed-client`` package.
 
 
 v4.18.0 (2022-11-29)
diff --git a/README.rst b/README.rst
index 84ffac9..01a0af3 100644
--- a/README.rst
+++ b/README.rst
@@ -1587,6 +1587,44 @@ arguments.
     when a case is highly convoluted it could be a symptom that the respective
     code is in need of refactoring.
 
+.. _stubs-resolver:
+
+Stubs resolver
+^^^^^^^^^^^^^^
+
+The stubs resolver makes use of the `typeshed-client
+<https://pypi.org/project/typeshed-client/>`__ package to identify parameters
+and their type hints from stub files ``*.pyi``. To enable this resolver, install
+jsonargparse with the ``signatures`` extras require as explained in section
+:ref:`installation`.
+
+Many of the types defined in stub files use the latest syntax for type hints,
+that is, bitwise or operator ``|`` for unions and generics, e.g.
+``list[<type>]`` instead of ``typing.List[<type>]``, see PEPs `604
+<https://peps.python.org/pep-0604>`__ and `585
+<https://peps.python.org/pep-0585>`__. The types with this new syntax can't be
+evaluated at runtime in Python versions older than ``3.10``. Since jsonargparse
+needs to interpret the types at runtime, these will only be resolved in newer
+versions of Python.
+
+Most of the types in the Python standard library have their types in stubs. An
+example from the standard library would be:
+
+.. doctest:: stubs_resolver
+
+    >>> from random import uniform
+
+    >>> parser = ArgumentParser()
+    >>> parser.add_function_arguments(uniform, 'uniform')  # doctest: +IGNORE_RESULT
+    >>> parser.parse_args(['--uniform.a=0.7', '--uniform.b=3.4'])
+    Namespace(uniform=Namespace(a=0.7, b=3.4))
+
+Without the stubs resolver, the
+:py:meth:`.SignatureArguments.add_function_arguments` call requires the
+``fail_untyped=False`` option. This has the disadvantage that type ``Any`` is
+given to the ``a`` and ``b`` arguments, instead of ``float``. And this means
+that the parser would not fail if given an invalid value, for instance a string.
+
 
 .. _sub-classes:
 
diff --git a/jsonargparse/_stubs_resolver.py b/jsonargparse/_stubs_resolver.py
new file mode 100644
index 0000000..d2910a7
--- /dev/null
+++ b/jsonargparse/_stubs_resolver.py
@@ -0,0 +1,334 @@
+import ast
+import inspect
+import sys
+from contextlib import suppress
+from copy import deepcopy
+from importlib import import_module
+from pathlib import Path
+from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
+
+from .optionals import (
+    import_typeshed_client,
+    typeshed_client_support,
+    typing_extensions_import,
+)
+from .util import unique
+
+if TYPE_CHECKING:  # pragma: no cover
+    import typeshed_client as tc
+else:
+    tc = import_typeshed_client()
+
+
+kinds = inspect._ParameterKind
+
+
+def import_module_or_none(path: str):
+    if path.endswith('.__init__'):
+        path = path[:-9]
+    try:
+        return import_module(path)
+    except ModuleNotFoundError:
+        return None
+
+
+class NamesVisitor(ast.NodeVisitor):
+
+    def visit_Name(self, node: ast.Name) -> None:
+        self.names_found.append(node.id)
+
+    def find(self, node: ast.AST) -> list:
+        self.names_found: List[str] = []
+        self.visit(node)
+        self.names_found = unique(self.names_found)
+        return self.names_found
+
+
+class ImportsVisitor(ast.NodeVisitor):
+
+    def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
+        if node.level:
+            module_path = self.module_path[:-node.level]
+            if node.module:
+                module_path.append(node.module)
+            node = deepcopy(node)
+            node.module = '.'.join(module_path)
+            node.level = 0
+        for alias in node.names:
+            self.imports_found[alias.asname or alias.name] = (node.module, alias.name)
+
+    def find(self, node: ast.AST, module_path: str) -> Dict[str, Tuple[Optional[str], str]]:
+        self.module_path = module_path.split('.')
+        self.imports_found: Dict[str, Tuple[Optional[str], str]] = {}
+        self.visit(node)
+        return self.imports_found
+
+
+def ast_annassign_to_assign(node: ast.AnnAssign) -> ast.Assign:
+    return ast.Assign(
+        targets=[node.target],
+        value=node.value,
+        type_ignores=[],
+        lineno=node.lineno,
+        end_lineno=node.lineno,
+    )
+
+
+class AssignsVisitor(ast.NodeVisitor):
+
+    def visit_Assign(self, node: ast.Assign) -> None:
+        for target in node.targets:
+            if hasattr(target, 'id'):
+                self.assigns_found[target.id] = node
+
+    def visit_AnnAssign(self, node: ast.AnnAssign) -> None:
+        if hasattr(node.target, 'id'):
+            self.assigns_found[node.target.id] = ast_annassign_to_assign(node)
+
+    def find(self, node: ast.AST) -> Dict[str, ast.Assign]:
+        self.assigns_found: Dict[str, ast.Assign] = {}
+        self.visit(node)
+        return self.assigns_found
+
+
+class MethodsVisitor(ast.NodeVisitor):
+
+    method_found: Optional[ast.FunctionDef]
+
+    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
+        if not self.method_found and node.name == self.method_name:
+            self.method_found = node
+
+    def visit_If(self, node: ast.If) -> None:
+        test_ast = ast.parse('___test___ = 0')
+        test_ast.body[0].value = node.test  # type: ignore
+        exec_vars = {'sys': sys}
+        with suppress(Exception):
+            exec(compile(test_ast, filename="<ast>", mode="exec"), exec_vars, exec_vars)
+            if exec_vars['___test___']:
+                node.orelse = []
+            else:
+                node.body = []
+            self.generic_visit(node)
+
+    def find(self, node: ast.AST, method_name: str) -> Optional[ast.FunctionDef]:
+        self.method_name = method_name
+        self.method_found = None
+        self.visit(node)
+        return self.method_found
+
+
+stubs_resolver = None
+
+
+def get_stubs_resolver():
+    global stubs_resolver
+    if not stubs_resolver:
+        search_path = [Path(p) for p in sys.path]
+        search_context = tc.get_search_context(search_path=search_path)
+        stubs_resolver = StubsResolver(search_context=search_context)
+    return stubs_resolver
+
+
+def get_mro_method_parent(parent, method_name):
+    while hasattr(parent, '__dict__') and method_name not in parent.__dict__:
+        try:
+            parent = inspect.getmro(parent)[1]
+        except IndexError:
+            parent = None
+    return None if parent is object else parent
+
+
+def get_source_module(path: str, component) -> tc.ModulePath:
+    if component is None:
+        module_path, name = path.rsplit('.', 1)
+        component = getattr(import_module_or_none(module_path), name, None)
+    if component is not None:
+        module = inspect.getmodule(component)
+        assert module is not None
+        module_path = module.__name__
+        if getattr(module, '__file__', '').endswith('__init__.py'):
+            module_path += '.__init__'
+    return tc.ModulePath(tuple(module_path.split('.')))
+
+
+class StubsResolver(tc.Resolver):
+
+    def __init__(self, search_context = None) -> None:
+        super().__init__(search_context)
+        self._module_ast_cache: Dict[str, Optional[ast.AST]] = {}
+        self._module_assigns_cache: Dict[str, Dict[str, ast.Assign]] = {}
+        self._module_imports_cache: Dict[str, Dict[str, Tuple[Optional[str], str]]] = {}
+
+    def get_imported_info(self, path: str, component=None) -> Optional[tc.ImportedInfo]:
+        resolved = self.get_fully_qualified_name(path)
+        imported_info = None
+        if isinstance(resolved, tc.ImportedInfo):
+            resolved = resolved.info
+        if isinstance(resolved, tc.NameInfo):
+            source_module = get_source_module(path, component)
+            imported_info = tc.ImportedInfo(source_module=source_module, info=resolved)
+        return imported_info
+
+    def get_component_imported_info(self, component, parent) -> Optional[tc.ImportedInfo]:
+        if not parent and inspect.ismethod(component):
+            parent = type(component.__self__)
+            component = getattr(parent, component.__name__)
+        if not parent:
+            return self.get_imported_info(f'{component.__module__}.{component.__name__}', component)
+        parent = get_mro_method_parent(parent, component.__name__)
+        stub_import = parent and self.get_imported_info(f'{parent.__module__}.{parent.__name__}', component)
+        if stub_import and isinstance(stub_import.info.ast, ast.AST):
+            method_ast = MethodsVisitor().find(stub_import.info.ast, component.__name__)
+            if method_ast is None:
+                stub_import = None
+            else:
+                name_info = tc.NameInfo(name=component.__qualname__, is_exported=False, ast=method_ast)
+                stub_import = tc.ImportedInfo(source_module=stub_import.source_module, info=name_info)
+        return stub_import
+
+    def get_aliases(self, imported_info: tc.ImportedInfo):
+        aliases: Dict[str, Tuple[str, Any]] = {}
+        self.add_import_aliases(aliases, imported_info)
+        return aliases
+
+    def get_module_stub_ast(self, module_path: str):
+        if module_path not in self._module_ast_cache:
+            self._module_ast_cache[module_path] = tc.get_stub_ast(module_path, search_context=self.ctx)
+        return self._module_ast_cache[module_path]
+
+    def get_module_stub_assigns(self, module_path: str):
+        if module_path not in self._module_assigns_cache:
+            module_ast = self.get_module_stub_ast(module_path)
+            self._module_assigns_cache[module_path] = AssignsVisitor().find(module_ast)
+        return self._module_assigns_cache[module_path]
+
+    def get_module_stub_imports(self, module_path: str):
+        if module_path not in self._module_imports_cache:
+            module_ast = self.get_module_stub_ast(module_path)
+            self._module_imports_cache[module_path] = ImportsVisitor().find(module_ast, module_path)
+        return self._module_imports_cache[module_path]
+
+    def add_import_aliases(self, aliases, stub_import: tc.ImportedInfo):
+        module_path = '.'.join(stub_import.source_module)
+        module = import_module_or_none(module_path)
+        stub_ast: Optional[ast.AST] = None
+        if isinstance(stub_import.info.ast, (ast.Assign, ast.AnnAssign)):
+            stub_ast = stub_import.info.ast.value
+        elif isinstance(stub_import.info.ast, ast.AST):
+            stub_ast = stub_import.info.ast
+        if stub_ast:
+            self.add_module_aliases(aliases, module_path, module, stub_ast)
+        return module_path, stub_import.info.ast
+
+    def add_module_aliases(self, aliases, module_path, module, node):
+        names = NamesVisitor().find(node) if node else []
+        for name in names:
+            if alias_already_added(aliases, name, module_path):
+                continue
+            source = module_path
+            if name in __builtins__:
+                source = '__builtins__'
+                value = __builtins__[name]
+            elif hasattr(module, name):
+                value = getattr(module, name)
+            elif name in self.get_module_stub_assigns(module_path):
+                value = self.get_module_stub_assigns(module_path)[name]
+                self.add_module_aliases(aliases, module_path, module, value.value)
+            elif name in self.get_module_stub_imports(module_path):
+                imported_module_path, imported_name = self.get_module_stub_imports(module_path)[name]
+                imported_module = import_module_or_none(imported_module_path)
+                if hasattr(imported_module, imported_name):
+                    source = imported_module_path
+                    value = getattr(imported_module, imported_name)
+                else:
+                    stub_import = self.get_imported_info(f'{imported_module_path}.{imported_name}')
+                    source, value = self.add_import_aliases(aliases, stub_import)
+            else:
+                value = NotImplementedError(f'{name!r} from {module_path!r} not in builtins, module or stub')
+            if alias_already_added(aliases, name, source):
+                continue
+            if not alias_is_unique(aliases, name, source, value):
+                value = NotImplementedError(
+                    f'non-unique alias {name!r}: {aliases[name][1]} ({aliases[name][0]}) vs {value} ({source})'
+                )
+            aliases[name] = (source, value)
+
+
+def alias_already_added(aliases, name, source):
+    return name in aliases and aliases[name][0] in {'__builtins__', source}
+
+
+def alias_is_unique(aliases, name, source, value):
+    if name in aliases:
+        src, val = aliases[name]
+        if src != source:
+            return val is value
+    return True
+
+
+def get_arg_type(arg_ast, aliases):
+    type_ast = ast.parse('___arg_type___ = 0')
+    type_ast.body[0].value = arg_ast.annotation
+    exec_vars = {}
+    bad_aliases = {}
+    add_asts = False
+    for name in NamesVisitor().find(arg_ast.annotation):
+        _, value = aliases[name]
+        if isinstance(value, Exception):
+            bad_aliases[name] = value
+        elif isinstance(value, ast.AST):
+            add_asts = True
+        else:
+            exec_vars[name] = value
+    if add_asts:
+        body = []
+        for name, (_, value) in aliases.items():
+            if isinstance(value, ast.AST):
+                body.append(ast.fix_missing_locations(value))
+            elif not isinstance(value, Exception):
+                exec_vars[name] = value
+        type_ast.body = body + type_ast.body
+        if 'TypeAlias' not in exec_vars:
+            type_alias = typing_extensions_import('TypeAlias')
+            if type_alias:
+                exec_vars['TypeAlias'] = type_alias
+    try:
+        exec(compile(type_ast, filename="<ast>", mode="exec"), exec_vars, exec_vars)
+    except NameError as ex:
+        ex_from = None
+        for name, alias_exception in bad_aliases.items():
+            if str(ex) == f"name '{name}' is not defined":
+                ex_from = alias_exception
+                break
+        raise ex from ex_from
+    return exec_vars['___arg_type___']
+
+
+def get_stub_types(params, component, parent, logger) -> Optional[Dict[str, Any]]:
+    if not typeshed_client_support:
+        return None
+    missing_types = {
+        p.name: n for n, p in enumerate(params)
+        if p.kind not in {kinds.VAR_POSITIONAL, kinds.VAR_KEYWORD} and p.annotation == inspect._empty
+    }
+    if not missing_types:
+        return None
+    resolver = get_stubs_resolver()
+    stub_import = resolver.get_component_imported_info(component, parent)
+    if not stub_import:
+        return None
+    known_params = {p.name for p in params}
+    aliases = resolver.get_aliases(stub_import)
+    arg_asts = stub_import.info.ast.args.args + stub_import.info.ast.args.kwonlyargs
+    types = {}
+    for arg_ast in arg_asts[1:] if parent else arg_asts:
+        name = arg_ast.arg
+        if arg_ast.annotation and (name in missing_types or name not in known_params):
+            try:
+                types[name] = get_arg_type(arg_ast, aliases)
+            except Exception as ex:
+                logger.debug(f'Failed to parse type stub for {component.__qualname__!r} parameter {name!r}', exc_info=ex)
+                if name not in known_params:
+                    types[name] = inspect._empty
+    return types
diff --git a/jsonargparse/core.py b/jsonargparse/core.py
index 6bb29e5..ad1d0a0 100644
--- a/jsonargparse/core.py
+++ b/jsonargparse/core.py
@@ -68,7 +68,7 @@ from .optionals import (
     import_fsspec,
     import_jsonnet,
 )
-from .parameter_resolvers import ConditionalDefault
+from .parameter_resolvers import UnknownDefault
 from .signatures import SignatureArguments, is_pure_dataclass
 from .typehints import ActionTypeHint, is_subclass_spec
 from .typing import is_final_class
@@ -964,7 +964,7 @@ class ArgumentParser(ActionsContainer, ArgumentLinking, argparse.ArgumentParser)
             if (
                 action.default != argparse.SUPPRESS and
                 action.dest != argparse.SUPPRESS and
-                not isinstance(action.default, ConditionalDefault)
+                not isinstance(action.default, UnknownDefault)
             ):
                 cfg[action.dest] = recreate_branches(action.default)
 
@@ -1218,6 +1218,7 @@ class ArgumentParser(ActionsContainer, ArgumentLinking, argparse.ArgumentParser)
         cfg: Union[Namespace, Dict[str, Any]],
         parent_key: str = '',
         prev_cfg: Optional[Namespace] = None,
+        skip_fn: Optional[Callable[[Any], bool]] = None,
     ) -> Namespace:
         """Runs _check_value_key on actions present in config."""
         if isinstance(cfg, dict):
@@ -1262,6 +1263,8 @@ class ArgumentParser(ActionsContainer, ArgumentLinking, argparse.ArgumentParser)
 
             action_dest = action.dest if subcommand is None else subcommand+'.'+action.dest
             value = cfg[action_dest]
+            if skip_fn and skip_fn(value):
+                continue
             with lenient_check_context(), load_value_context(self.parser_mode):
                 value = self._check_value_key(action, value, action_dest, prev_cfg)
             if isinstance(action, _ActionConfigLoad):
diff --git a/jsonargparse/optionals.py b/jsonargparse/optionals.py
index 0f376c4..61f3f48 100644
--- a/jsonargparse/optionals.py
+++ b/jsonargparse/optionals.py
@@ -15,6 +15,7 @@ __all__ = [
 
 
 typing_extensions_support = find_spec('typing_extensions') is not None
+typeshed_client_support = find_spec('typeshed_client') is not None
 jsonschema_support = find_spec('jsonschema') is not None
 jsonnet_support = find_spec('_jsonnet') is not None
 url_support = False if any(find_spec(x) is None for x in ['validators', 'requests']) else True
@@ -59,6 +60,14 @@ if not final or not is_compatible_final(final) or 'SPHINX_BUILD' in os.environ:
         return cls
 
 
+def import_typeshed_client():
+    if typeshed_client_support:
+        import typeshed_client
+        return typeshed_client
+    else:
+        return __import__('argparse').Namespace(ImportedInfo=object, ModulePath=object, Resolver=object)
+
+
 class UndefinedException(Exception):
     pass
 
diff --git a/jsonargparse/parameter_resolvers.py b/jsonargparse/parameter_resolvers.py
index c4f3539..7c235bc 100644
--- a/jsonargparse/parameter_resolvers.py
+++ b/jsonargparse/parameter_resolvers.py
@@ -8,8 +8,9 @@ from contextvars import ContextVar
 from copy import deepcopy
 from dataclasses import dataclass
 from functools import partial
-from typing import Any, Callable, List, Optional, Tuple, Type, Union
+from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
 
+from ._stubs_resolver import get_stub_types
 from .optionals import parse_docs
 from .util import (
     ClassFromFunctionBase,
@@ -46,9 +47,20 @@ class SourceNotAvailable(Exception):
     "Raised when the source code for some component is not available."
 
 
-class ConditionalDefault(list):
-    def __repr__(self):
-        return iter_to_set_str(self, sep=', ')
+class UnknownDefault:
+    def __init__(self, resolver: str, data: Any = inspect._empty) -> None:
+        self.resolver = resolver
+        self.data = data
+    def __repr__(self) -> str:
+        value = f"{type(self).__name__.replace('Default', '')}<{self.resolver}>"
+        if self.data != inspect._empty:
+            value = f'{value} {self.data}'
+        return value
+
+
+class ConditionalDefault(UnknownDefault):
+    def __init__(self, resolver: str, data: Any) -> None:
+        super().__init__(resolver, iter_to_set_str(data, sep=', '))
 
 
 def get_parameter_origins(component, parent) -> Optional[str]:
@@ -245,10 +257,10 @@ def get_arg_kind_index(params, kind):
 
 
 def get_signature_parameters_and_indexes(component, parent, logger):
+    signature_source = component
     if is_classmethod(parent, component):
-        params = list(inspect.signature(component.__func__).parameters.values())
-    else:
-        params = list(inspect.signature(component).parameters.values())
+        signature_source = component.__func__
+    params = list(inspect.signature(signature_source).parameters.values())
     if parent:
         params = params[1:]
     args_idx = get_arg_kind_index(params, kinds.VAR_POSITIONAL)
@@ -264,7 +276,26 @@ def get_signature_parameters_and_indexes(component, parent, logger):
             component=component,
             **{a: getattr(param, a) for a in parameter_attributes},
         )
-    return params, args_idx, kwargs_idx, doc_params
+    stubs = get_stub_types(params, signature_source, parent, logger)
+    return params, args_idx, kwargs_idx, doc_params, stubs
+
+
+def add_stub_types(stubs: Optional[Dict[str, Any]], params: ParamList, component) -> None:
+    if not stubs:
+        return
+    for param in params:
+        if param.annotation == inspect._empty and param.name in stubs:
+            param.annotation = stubs[param.name]
+    known_params = {p.name for p in params}
+    for name, stub in stubs.items():
+        if name not in known_params:
+            params.append(ParamData(
+                name=name,
+                annotation=stub,
+                default=UnknownDefault('stubs-resolver'),
+                kind=kinds.KEYWORD_ONLY,
+                component=component,
+            ))
 
 
 ast_literals = {
@@ -361,7 +392,10 @@ def group_parameters(params_list: List[ParamList]) -> ParamList:
             gparam.parent = tuple(p.parent for p in params)
             gparam.component = tuple(p.component for p in params)
             gparam.origin = tuple(p.origin for p in params)
-            gparam.default = ConditionalDefault((p.default for p in params) if len(defaults) > 1 else defaults)
+            gparam.default = ConditionalDefault(
+                'ast-resolver',
+                (p.default for p in params) if len(defaults) > 1 else defaults,
+            )
             if len(types) > 1:
                 gparam.annotation = Union[tuple(types)] if types else inspect._empty
         docs = [p.doc for p in params if p.doc]
@@ -702,13 +736,14 @@ class ParametersVisitor(LoggerProperty, ast.NodeVisitor):
     def get_parameters(self) -> ParamList:
         if self.component is None:
             return []
-        params, args_idx, kwargs_idx, doc_params = get_signature_parameters_and_indexes(self.component, self.parent, self.logger)
+        params, args_idx, kwargs_idx, doc_params, stubs = 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):
                 args, kwargs = self.get_parameters_args_and_kwargs()
             params = replace_args_and_kwargs(params, args, kwargs)
+        add_stub_types(stubs, params, self.component)
         return params
 
 
@@ -718,7 +753,7 @@ def get_parameters_by_assumptions(
     logger: Union[bool, str, dict, logging.Logger] = True,
 ) -> ParamList:
     component, parent, method_name = get_component_and_parent(function_or_class, method_name)
-    params, args_idx, kwargs_idx, _ = get_signature_parameters_and_indexes(component, parent, logger)
+    params, args_idx, kwargs_idx, _, stubs = get_signature_parameters_and_indexes(component, parent, logger)
 
     if parent and (args_idx >= 0 or kwargs_idx >= 0):
         with mro_context(parent):
@@ -728,6 +763,7 @@ def get_parameters_by_assumptions(
             params = replace_args_and_kwargs(params, args, kwargs)
 
     params = replace_args_and_kwargs(params, [], [])
+    add_stub_types(stubs, params, component)
     return params
 
 
diff --git a/jsonargparse/signatures.py b/jsonargparse/signatures.py
index 319ad11..1e3cf03 100644
--- a/jsonargparse/signatures.py
+++ b/jsonargparse/signatures.py
@@ -330,13 +330,13 @@ class SignatureArguments(LoggerProperty):
             kwargs['type'] = annotation
         elif annotation != inspect_empty:
             try:
-                is_subclass_typehint = ActionTypeHint.is_subclass_typehint(annotation)
+                is_subclass_typehint = ActionTypeHint.is_subclass_typehint(annotation, all_subtypes=False)
                 kwargs['type'] = annotation
-                sub_add_kwargs = None
+                sub_add_kwargs: dict = {'fail_untyped': fail_untyped, 'sub_configs': sub_configs}
                 if is_subclass_typehint:
                     prefix = name + '.init_args.'
                     subclass_skip = {s[len(prefix):] for s in skip or [] if s.startswith(prefix)}
-                    sub_add_kwargs = {'fail_untyped': fail_untyped, 'skip': subclass_skip}
+                    sub_add_kwargs['skip'] = subclass_skip
                 args = ActionTypeHint.prepare_add_argument(
                     args=args,
                     kwargs=kwargs,
diff --git a/jsonargparse/typehints.py b/jsonargparse/typehints.py
index 1019370..32d4fcd 100644
--- a/jsonargparse/typehints.py
+++ b/jsonargparse/typehints.py
@@ -339,8 +339,15 @@ class ActionTypeHint(Action):
 
     @staticmethod
     def add_sub_defaults(parser, cfg):
+        def skip_sub_defaults_apply(v):
+            return not (
+                isinstance(v, (str, Namespace)) or
+                is_subclass_spec(v) or
+                (isinstance(v, list) and any(is_subclass_spec(e) for e in v)) or
+                (isinstance(v, dict) and any(is_subclass_spec(e) for e in v.values()))
+            )
         with ActionTypeHint.sub_defaults_context():
-            parser._apply_actions(cfg)
+            parser._apply_actions(cfg, skip_fn=skip_sub_defaults_apply)
 
 
     @staticmethod
diff --git a/setup.cfg b/setup.cfg
index 54cadf2..bc33267 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -22,6 +22,7 @@ all =
     %(reconplogger)s
 signatures =
     docstring-parser>=0.15
+    typeshed-client>=2.1.0
 jsonschema =
     jsonschema>=3.2.0
 jsonnet =
@@ -44,7 +45,9 @@ reconplogger =
     reconplogger>=4.4.0
 test =
     %(test_no_urls)s
+    %(types_pyyaml)s
     responses>=0.12.0
+    types-requests>=2.28.9
 test_no_urls =
     coverage>=4.5.1
 dev =
@@ -58,9 +61,10 @@ dev =
 pylint =
     pylint>=2.15.6
 mypy =
+    %(types_pyyaml)s
     mypy>=0.701
+types_pyyaml =
     types-PyYAML>=6.0.11
-    types-requests>=2.28.9
 doc =
     Sphinx>=1.7.9
     sphinx-rtd-theme>=0.4.3


Test patch:
diff --git a/jsonargparse_tests/base.py b/jsonargparse_tests/base.py
index 88762c5..dcf3afa 100644
--- a/jsonargparse_tests/base.py
+++ b/jsonargparse_tests/base.py
@@ -1,4 +1,5 @@
 import inspect
+import logging
 import os
 import platform
 import shutil
@@ -48,6 +49,12 @@ def mock_module(*args):
         yield __module__
 
 
+def get_debug_level_logger(name):
+    logger = logging.getLogger(name)
+    logger.level = logging.DEBUG
+    return logger
+
+
 def doctest_mock_class_in_main(cls):
     cls.__module__ = None
     setattr(sys.modules['__main__'], cls.__name__, cls)
diff --git a/jsonargparse_tests/test_parameter_resolvers.py b/jsonargparse_tests/test_parameter_resolvers.py
index 9871362..19fdac8 100755
--- a/jsonargparse_tests/test_parameter_resolvers.py
+++ b/jsonargparse_tests/test_parameter_resolvers.py
@@ -2,7 +2,6 @@
 
 import calendar
 import inspect
-import logging
 import unittest
 import xml.dom
 from calendar import Calendar
@@ -15,6 +14,9 @@ from jsonargparse import Namespace, class_from_function
 from jsonargparse.optionals import docstring_parser_support
 from jsonargparse.parameter_resolvers import get_signature_parameters as get_params
 from jsonargparse.parameter_resolvers import is_lambda
+from jsonargparse_tests.base import get_debug_level_logger
+
+logger = get_debug_level_logger(__name__)
 
 
 class ClassA:
@@ -419,10 +421,6 @@ def assert_params(self, params, expected, origins={}):
     self.assertEqual(param_origins, origins)
 
 
-logger = logging.getLogger('test_parameter_resolvers')
-logger.level = logging.DEBUG
-
-
 class GetClassParametersTests(unittest.TestCase):
 
     def test_get_params_class_no_inheritance_unused_kwargs(self):
diff --git a/jsonargparse_tests/test_signatures.py b/jsonargparse_tests/test_signatures.py
index c0364ab..c3e8490 100755
--- a/jsonargparse_tests/test_signatures.py
+++ b/jsonargparse_tests/test_signatures.py
@@ -271,9 +271,9 @@ class SignaturesTests(unittest.TestCase):
             expected += [
                 'help for func (required, type: str)',
                 'help for kmg1 (type: int, default: 1)',
-                'help for kmg2 (type: Union[str, float], default: {-, 2.3})',
-                'help for kmg3 (type: bool, default: {True, False})',
-                'help for kmg4 (type: int, default: 4)',
+                'help for kmg2 (type: Union[str, float], default: Conditional<ast-resolver> {-, 2.3})',
+                'help for kmg3 (type: bool, default: Conditional<ast-resolver> {True, False})',
+                'help for kmg4 (type: int, default: Conditional<ast-resolver> 4)',
             ]
         for value in expected:
             self.assertIn(value, help_str.getvalue())
@@ -966,6 +966,24 @@ class SignaturesTests(unittest.TestCase):
         self.assertEqual(Namespace(a1=None, a2=None), parser.parse_args([]))
 
 
+    def test_fail_untyped_false_subclass_help(self):
+        class Class1:
+            def __init__(self, a1, a2=None):
+                self.a1 = a1
+
+        def func(c1: Union[int, Class1]):
+            return c1
+
+        with mock_module(Class1) as module:
+            parser = ArgumentParser(error_handler=None)
+            parser.add_function_arguments(func, fail_untyped=False)
+
+            help_str = StringIO()
+            with redirect_stdout(help_str), self.assertRaises(SystemExit):
+                parser.parse_args([f'--c1.help={module}.Class1'])
+            self.assertIn('--c1.init_args.a1 A1', help_str.getvalue())
+
+
     @unittest.skipIf(not docstring_parser_support, 'docstring-parser package is required')
     def test_docstring_parse_fail(self):
 
diff --git a/jsonargparse_tests/test_stubs_resolver.py b/jsonargparse_tests/test_stubs_resolver.py
new file mode 100755
index 0000000..6633bcb
--- /dev/null
+++ b/jsonargparse_tests/test_stubs_resolver.py
@@ -0,0 +1,254 @@
+#!/usr/bin/env python3
+
+import inspect
+import sys
+import unittest
+from calendar import Calendar, TextCalendar
+from contextlib import contextmanager
+from email.headerregistry import DateHeader
+from importlib.util import find_spec
+from io import StringIO
+from ipaddress import ip_network
+from random import Random, SystemRandom, uniform
+from tarfile import TarFile
+from typing import Any
+from unittest.mock import patch
+from uuid import UUID, uuid5
+
+import yaml
+
+from jsonargparse import ArgumentParser
+from jsonargparse._stubs_resolver import get_mro_method_parent, get_stubs_resolver
+from jsonargparse.parameter_resolvers import get_signature_parameters as get_params
+from jsonargparse_tests.base import get_debug_level_logger
+
+logger = get_debug_level_logger(__name__)
+torch_available = find_spec('torch')
+
+
+@contextmanager
+def mock_typeshed_client_unavailable():
+    with patch('jsonargparse.parameter_resolvers.add_stub_types'):
+        yield
+
+
+def get_param_types(params):
+    return [(p.name, p.annotation) for p in params]
+
+
+def get_param_names(params):
+    return [p.name for p in params]
+
+
+@unittest.skipIf(not find_spec('typeshed_client'), 'typeshed-client package is required')
+class StubsResolverTests(unittest.TestCase):
+
+    def test_get_mro_method_parent(self):
+        class WithoutParent:
+            ...
+
+        self.assertIs(None, get_mro_method_parent(WithoutParent, '__init__'))
+        self.assertIs(None, get_mro_method_parent(SystemRandom, 'unknown'))
+        self.assertIs(Random, get_mro_method_parent(Random, '__init__'))
+        self.assertIs(Random, get_mro_method_parent(SystemRandom, '__init__'))
+        self.assertIs(Random, get_mro_method_parent(SystemRandom, 'uniform'))
+        self.assertIs(SystemRandom, get_mro_method_parent(SystemRandom, 'getrandbits'))
+
+    @unittest.skipIf(not find_spec('requests'), 'requests package is required')
+    def test_stubs_resolver_get_imported_info(self):
+        resolver = get_stubs_resolver()
+        imported_info = resolver.get_imported_info('requests.api.get')
+        self.assertEqual(imported_info.source_module, ('requests', 'api'))
+        imported_info = resolver.get_imported_info('requests.get')
+        self.assertEqual(imported_info.source_module, ('requests', 'api'))
+
+    def test_get_params_class_without_inheritance(self):
+        params = get_params(Calendar)
+        self.assertEqual([('firstweekday', int)], get_param_types(params))
+        with mock_typeshed_client_unavailable():
+            params = get_params(Calendar)
+        self.assertEqual([('firstweekday', inspect._empty)], get_param_types(params))
+
+    def test_get_params_class_with_inheritance(self):
+        params = get_params(TextCalendar)
+        self.assertEqual([('firstweekday', int)], get_param_types(params))
+        with mock_typeshed_client_unavailable():
+            params = get_params(TextCalendar)
+        self.assertEqual([('firstweekday', inspect._empty)], get_param_types(params))
+
+    def test_get_params_method(self):
+        params = get_params(Random, 'randint')
+        self.assertEqual([('a', int), ('b', int)], get_param_types(params))
+        with mock_typeshed_client_unavailable():
+            params = get_params(Random, 'randint')
+        self.assertEqual([('a', inspect._empty), ('b', inspect._empty)], get_param_types(params))
+
+    def test_get_params_object_instance_method(self):
+        params = get_params(uniform)
+        self.assertEqual([('a', float), ('b', float)], get_param_types(params))
+        with mock_typeshed_client_unavailable():
+            params = get_params(uniform)
+        self.assertEqual([('a', inspect._empty), ('b', inspect._empty)], get_param_types(params))
+
+    def test_get_params_conditional_python_version(self):
+        params = get_params(Random, 'seed')
+        self.assertEqual(['a', 'version'], get_param_names(params))
+        if sys.version_info >= (3, 10):
+            self.assertEqual('int | float | str | bytes | bytearray | None', str(params[0].annotation))
+        else:
+            expected = Any if sys.version_info < (3, 9) else inspect._empty
+            self.assertEqual(expected, params[0].annotation)
+        self.assertEqual(int, params[1].annotation)
+        with mock_typeshed_client_unavailable():
+            params = get_params(Random, 'seed')
+        self.assertEqual([('a', inspect._empty), ('version', inspect._empty)], get_param_types(params))
+
+    @patch('jsonargparse._stubs_resolver.exec')
+    def test_get_params_exec_failure(self, mock_exec):
+        mock_exec.side_effect = NameError('failed')
+        params = get_params(Random, 'seed')
+        self.assertEqual([('a', inspect._empty), ('version', inspect._empty)], get_param_types(params))
+
+    def test_get_params_classmethod(self):
+        params = get_params(TarFile, 'open')
+        expected = ['name', 'mode', 'fileobj', 'bufsize', 'format', 'tarinfo', 'dereference', 'ignore_zeros', 'encoding', 'errors', 'pax_headers', 'debug', 'errorlevel']
+        self.assertEqual(expected, get_param_names(params)[:len(expected)])
+        if sys.version_info >= (3, 10):
+            self.assertTrue(all(p.annotation != inspect._empty for p in params))
+        with mock_typeshed_client_unavailable():
+            params = get_params(TarFile, 'open')
+        self.assertEqual(expected, get_param_names(params)[:len(expected)])
+        self.assertTrue(all(p.annotation == inspect._empty for p in params))
+
+    def test_get_params_staticmethod(self):
+        params = get_params(DateHeader, 'value_parser')
+        self.assertEqual([('value', str)], get_param_types(params))
+        with mock_typeshed_client_unavailable():
+            params = get_params(DateHeader, 'value_parser')
+        self.assertEqual([('value', inspect._empty)], get_param_types(params))
+
+    def test_get_params_function(self):
+        params = get_params(ip_network)
+        self.assertEqual(['address', 'strict'], get_param_names(params))
+        if sys.version_info >= (3, 10):
+            self.assertIn('int | str | bytes | ipaddress.IPv4Address | ', str(params[0].annotation))
+        self.assertEqual(bool, params[1].annotation)
+        with mock_typeshed_client_unavailable():
+            params = get_params(ip_network)
+        self.assertEqual([('address', inspect._empty), ('strict', inspect._empty)], get_param_types(params))
+
+    def test_get_param_relative_import_from_init(self):
+        params = get_params(yaml.safe_load)
+        self.assertEqual(['stream'], get_param_names(params))
+        if sys.version_info >= (3, 10):
+            self.assertNotEqual(params[0].annotation, inspect._empty)
+        else:
+            self.assertEqual(params[0].annotation, inspect._empty)
+        with mock_typeshed_client_unavailable():
+            params = get_params(yaml.safe_load)
+        self.assertEqual(['stream'], get_param_names(params))
+        self.assertEqual(params[0].annotation, inspect._empty)
+
+    def test_get_params_non_unique_alias(self):
+        params = get_params(uuid5)
+        self.assertEqual([('namespace', UUID), ('name', str)], get_param_types(params))
+
+        def alias_is_unique(aliases, name, source, value):
+            if name == 'UUID':
+                aliases[name] = ('module', 'problem')
+            return name != 'UUID'
+
+        with patch('jsonargparse._stubs_resolver.alias_is_unique', alias_is_unique):
+            with self.assertLogs(logger, level='DEBUG') as log:
+                params = get_params(uuid5, logger=logger)
+                self.assertEqual([('namespace', inspect._empty), ('name', str)], get_param_types(params))
+                self.assertIn("non-unique alias 'UUID': problem (module)", log.output[0])
+
+    @unittest.skipIf(not find_spec('requests'), 'requests package is required')
+    def test_get_params_complex_function_requests_get(self):
+        from requests import get
+        with mock_typeshed_client_unavailable():
+            params = get_params(get)
+        expected = ['url', 'params']
+        self.assertEqual(expected, get_param_names(params))
+        self.assertTrue(all(p.annotation == inspect._empty for p in params))
+
+        params = get_params(get)
+        expected += ['data', 'headers', 'cookies', 'files', 'auth', 'timeout', 'allow_redirects', 'proxies', 'hooks', 'stream', 'verify', 'cert', 'json']
+        self.assertEqual(expected, get_param_names(params))
+        if sys.version_info >= (3, 10):
+            self.assertTrue(all(p.annotation != inspect._empty for p in params))
+
+        parser = ArgumentParser(error_handler=None)
+        parser.add_function_arguments(get, fail_untyped=False)
+        self.assertEqual(['url', 'params'], list(parser.get_defaults().keys()))
+        help_str = StringIO()
+        parser.print_help(help_str)
+        self.assertIn('default: Unknown<stubs-resolver>', help_str.getvalue())
+
+    @unittest.skipIf(not torch_available, 'torch package is required')
+    def test_get_params_torch_optimizer(self):
+        import torch.optim  # pylint: disable=import-error
+
+        def skip_stub_inconsistencies(cls, params):
+            # https://github.com/pytorch/pytorch/pull/90216
+            skip = {('Optimizer', 'defaults'), ('SGD', 'maximize'), ('SGD', 'differentiable')}
+            return [p for p in params if (cls.__name__, p.name) not in skip]
+
+        for class_name in [
+            'Optimizer',
+            'Adadelta',
+            'Adagrad',
+            'Adam',
+            'Adamax',
+            'AdamW',
+            'ASGD',
+            'LBFGS',
+            'NAdam',
+            'RAdam',
+            'RMSprop',
+            'Rprop',
+            'SGD',
+            'SparseAdam',
+        ]:
+            with self.subTest(class_name):
+                cls = getattr(torch.optim, class_name)
+                params = get_params(cls)
+                self.assertTrue(all(p.annotation != inspect._empty for p in skip_stub_inconsistencies(cls, params)))
+                with mock_typeshed_client_unavailable():
+                    params = get_params(cls)
+                self.assertTrue(any(p.annotation == inspect._empty for p in params))
+
+    @unittest.skipIf(not torch_available, 'torch package is required')
+    def test_get_params_torch_lr_scheduler(self):
+        import torch.optim.lr_scheduler  # pylint: disable=import-error
+
+        for class_name in [
+            '_LRScheduler',
+            'LambdaLR',
+            'MultiplicativeLR',
+            'StepLR',
+            'MultiStepLR',
+            'ConstantLR',
+            'LinearLR',
+            'ExponentialLR',
+            'ChainedScheduler',
+            'SequentialLR',
+            'CosineAnnealingLR',
+            'ReduceLROnPlateau',
+            'CyclicLR',
+            'CosineAnnealingWarmRestarts',
+            'OneCycleLR',
+            'PolynomialLR',
+        ]:
+            with self.subTest(class_name):
+                cls = getattr(torch.optim.lr_scheduler, class_name)
+                params = get_params(cls)
+                self.assertTrue(all(p.annotation != inspect._empty for p in params))
+                with mock_typeshed_client_unavailable():
+                    params = get_params(cls)
+                self.assertTrue(any(p.annotation == inspect._empty for p in params))
+
+
+if __name__ == '__main__':
+    unittest.main(verbosity=2)