Repository: omni-us/jsonargparse

Issue:
CLI does not work with callable component?
<!-- If you like this project, please ⭐ star it https://github.com/omni-us/jsonargparse/ -->

## 🐛 Bug report

CLI currently breaks if I pass in a callable instance instead of a function. It would be nice not to break this abstraction.

### To reproduce
```python
from dataclasses import dataclass

from jsonargparse import CLI


@dataclass
class Foo:
    def __call__(self, x: int):
        return x


if __name__ == "__main__":
    f = Foo()
    CLI(f, as_positional=False)
```

```
❯ python test.py                                
Traceback (most recent call last):
  File "/home/venky/dev/instant-science/name_generator/test.py", line 14, in <module>
    CLI(f, as_positional=False)
  File "/home/venky/dev/instant-science/name_generator/.venv/lib/python3.9/site-packages/jsonargparse/cli.py", line 69, in CLI
    _add_component_to_parser(component, parser, as_positional, fail_untyped, config_help)
  File "/home/venky/dev/instant-science/name_generator/.venv/lib/python3.9/site-packages/jsonargparse/cli.py", line 116, in _add_component_to_parser
    added_args = parser.add_class_arguments(component, **kwargs)
  File "/home/venky/dev/instant-science/name_generator/.venv/lib/python3.9/site-packages/jsonargparse/signatures.py", line 70, in add_class_arguments
    raise ValueError(f'Expected "theclass" parameter to be a class type, got: {theclass}.')
ValueError: Expected "theclass" parameter to be a class type, got: Foo().
```

### Expected behavior

It should work similar to `def foo(x: int)`.

### Environment


- jsonargparse version (e.g., 4.8.0): 4.19
- Python version (e.g., 3.9): 3.9
- How jsonargparse was installed (e.g. `pip install jsonargparse[all]`):  pip install jsonargparse[all]
- OS (e.g., Linux): linux


Discussion:
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=239)

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

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

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


codecov-commenter: # [Codecov](https://codecov.io/gh/omni-us/jsonargparse/pull/239?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) Report
Base: **100.00**% // Head: **100.00**% // No change to project coverage :thumbsup:
> Coverage data is based on head [(`fff06cc`)](https://codecov.io/gh/omni-us/jsonargparse/pull/239?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) compared to base [(`7a184d5`)](https://codecov.io/gh/omni-us/jsonargparse/commit/7a184d527383fcb12c5817e51854cd2d80d9a1bc?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).
> Patch coverage: 100.00% of modified lines in pull request are covered.

:mega: This organization is not using Codecov’s [GitHub App Integration](https://github.com/apps/codecov). We recommend you install it so Codecov can continue to function properly for your repositories. [Learn more](https://about.codecov.io/blog/codecov-is-updating-its-github-integration/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us)

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


```diff
@@            Coverage Diff            @@
##            master      #239   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           20        20           
  Lines         5184      5185    +1     
=========================================
+ Hits          5184      5185    +1     
```

| Flag | Coverage Δ | |
|---|---|---|
| py3.10 | `87.92% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.10_all | `99.88% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.6 | `87.56% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.6_all | `99.63% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.7 | `87.58% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.7_all | `99.80% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.8 | `87.90% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.8_all | `99.84% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.9 | `87.90% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.9_all | `99.86% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#carryforward-flags-in-the-pull-request-comment) to find out more.

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

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/omni-us/jsonargparse/pull/239?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).


Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 58bf380..b5624a2 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -15,6 +15,11 @@ paths are considered internals and can change in minor and patch releases.
 v4.20.0 (2023-01-??)
 --------------------
 
+Added
+^^^^^
+- ``CLI`` support for callable class instances `#238
+  <https://github.com/omni-us/jsonargparse/issues/238>`__.
+
 Fixed
 ^^^^^
 - ``add_subcommands`` fails when parser has required argument and default config
diff --git a/jsonargparse/cli.py b/jsonargparse/cli.py
index 92fbeda..08dfd46 100644
--- a/jsonargparse/cli.py
+++ b/jsonargparse/cli.py
@@ -52,7 +52,10 @@ def CLI(
         module = inspect.getmodule(caller).__name__  # type: ignore
         components = [
             v for v in caller.f_locals.values()
-            if (inspect.isfunction(v) or inspect.isclass(v)) and inspect.getmodule(v).__name__ == module  # type: ignore
+            if (
+                (inspect.isclass(v) or callable(v)) and
+                getattr(inspect.getmodule(v), '__name__', None) == module
+            )
         ]
         if len(components) == 0:
             raise ValueError('Either components argument must be given or there must be at least one '
@@ -61,6 +64,10 @@ def CLI(
     elif not isinstance(components, list):
         components = [components]
 
+    unexpected = [c for c in components if not (inspect.isclass(c) or callable(c))]
+    if unexpected:
+        raise ValueError(f'Unexpected components, not class or function: {unexpected}')
+
     parser = parser_class(default_meta=False, **kwargs)
     parser.add_argument('--config', action=ActionConfigFile, help=config_help)
 
@@ -108,11 +115,7 @@ def get_help_str(component, logger):
 
 def _add_component_to_parser(component, parser, as_positional, fail_untyped, config_help):
     kwargs = dict(as_positional=as_positional, fail_untyped=fail_untyped, sub_configs=True)
-    if inspect.isfunction(component):
-        added_args = parser.add_function_arguments(component, as_group=False, **kwargs)
-        if not parser.description:
-            parser.description = get_help_str(component, parser.logger)
-    else:
+    if inspect.isclass(component):
         added_args = parser.add_class_arguments(component, **kwargs)
         subcommands = parser.add_subcommands(required=True)
         for key in [k for k, v in inspect.getmembers(component) if callable(v) and k[0] != '_']:
@@ -124,12 +127,16 @@ def _add_component_to_parser(component, parser, as_positional, fail_untyped, con
             if not added_subargs:
                 remove_actions(subparser, (ActionConfigFile, _ActionPrintConfig))
             subcommands.add_subcommand(key, subparser, help=get_help_str(getattr(component, key), parser.logger))
+    else:
+        added_args = parser.add_function_arguments(component, as_group=False, **kwargs)
+        if not parser.description:
+            parser.description = get_help_str(component, parser.logger)
     return added_args
 
 
 def _run_component(component, cfg):
     cfg.pop('config', None)
-    if inspect.isfunction(component):
+    if not inspect.isclass(component):
         return component(**cfg)
     subcommand = cfg.pop('subcommand')
     subcommand_cfg = cfg.pop(subcommand, {})
diff --git a/jsonargparse/typehints.py b/jsonargparse/typehints.py
index 76d4ad7..9c1f525 100644
--- a/jsonargparse/typehints.py
+++ b/jsonargparse/typehints.py
@@ -146,11 +146,7 @@ class ActionTypeHint(Action):
                 if sum(subtype_supported) < len(subtype_supported):
                     discard = {typehint.__args__[n] for n, s in enumerate(subtype_supported) if not s}
                     kwargs['logger'].debug(f'Discarding unsupported subtypes {discard} from {typehint}')
-                    orig_typehint = typehint  # deepcopy does not copy ForwardRef
-                    typehint = deepcopy(orig_typehint)
-                    typehint.__args__ = tuple(
-                        orig_typehint.__args__[n] for n, s in enumerate(subtype_supported) if s
-                    )
+                    typehint = Union[tuple(t for t, s in zip(typehint.__args__, subtype_supported) if s)]  # type: ignore
             self._typehint = typehint
             self._enable_path = False if is_optional(typehint, Path) else enable_path
         elif '_typehint' not in kwargs:


Test patch:
diff --git a/jsonargparse_tests/base.py b/jsonargparse_tests/base.py
index dcf3afa..0123342 100644
--- a/jsonargparse_tests/base.py
+++ b/jsonargparse_tests/base.py
@@ -38,6 +38,8 @@ def mock_module(*args):
     __module__ = 'jsonargparse_tests'
     for component in args:
         component.__module__ = __module__
+        if not hasattr(component, '__name__'):
+            component.__name__ = type(component).__name__.lower()
         component.__qualname__ = component.__name__
         if inspect.isclass(component):
             methods = [k for k, v in inspect.getmembers(component) if callable(v) and k[0] != '_']
diff --git a/jsonargparse_tests/test_cli.py b/jsonargparse_tests/test_cli.py
index 93874ab..10daca8 100755
--- a/jsonargparse_tests/test_cli.py
+++ b/jsonargparse_tests/test_cli.py
@@ -2,6 +2,7 @@
 
 import sys
 import unittest
+import unittest.mock
 from contextlib import redirect_stderr, redirect_stdout
 from io import StringIO
 from typing import Optional
@@ -16,6 +17,11 @@ from jsonargparse_tests.base import TempDirTestCase, mock_module
 
 class CLITests(unittest.TestCase):
 
+    def test_unexpected(self):
+        with self.assertRaises(ValueError):
+            CLI(0)
+
+
     def test_single_function_cli(self):
         def function(a1: float):
             return a1
@@ -36,6 +42,16 @@ class CLITests(unittest.TestCase):
         self.assertIn('function CLITests.test_single_function_cli', out.getvalue())
 
 
+    def test_callable_instance(self):
+        class CallableClass:
+            def __call__(self, x: int):
+                return x
+
+        instance = CallableClass()
+        with mock_module(instance):
+            self.assertEqual(3, CLI(instance, as_positional=False, args=['--x=3']))
+
+
     def test_multiple_functions_cli(self):
         def cmd1(a1: int):
             return a1