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


Comments:

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