Repository: omni-us/jsonargparse

Issue:
Callable protocols not being parsed correctly
<!-- If you like this project, please ⭐ star it https://github.com/omni-us/jsonargparse/ -->

## 🐛 Bug report

Even though bugs related with calling protocol inheritance were resolved in this [PR](https://github.com/omni-us/jsonargparse/pull/599) it seem there are still some issues relating to parsing callable protocols.

### To reproduce

```

2. Manually constructing a parser

from typing import Protocol, Sequence

from jsonargparse import ArgumentParser


class CallableInterface(Protocol):
    def __call__(self, items: list[float]) -> list[float]: ...


class ImplementsCallableInterface1:
    def __init__(self, batch_size: int):
        self.batch_size = batch_size

    def __call__(self, items: list[float]) -> list[float]:
        return items


def main(input_args: Sequence[str] | None = None) -> None:
    # parser and config
    parser = ArgumentParser(description="CLI for training models", exit_on_error=False)
    parser.add_argument("--c", type=CallableInterface)
    args = parser.parse_args(input_args)
    print(args)


if __name__ == "__main__":
    main(["--c", "CallableInterface"])
```
results in the following error message:
```
argparse.ArgumentError: Parser key "c":
  Invalid or unsupported input: class=<class 'str'>, method_or_property=__call__
 ```
and asking for the help string gives:  
```
argparse.ArgumentError: --c.help: Class "CallableInterface" is not a subclass of CallableInterface
```
Note that in the [tests](https://github.com/omni-us/jsonargparse/blob/0ccac3ad07e0c9d555177b67d770bc3dad70b675/jsonargparse_tests/test_subclasses.py#L1533) this exact interface, `ImplementsCallableInterface1` is recognized as being a valid instantiation of the `CallableInterface`.

### Expected behavior

The parser should recolonize CallableInterface and be able to instantiate it.

### Environment

<!-- Fill in the list below. -->

- jsonargparse version: 4.34.0
- Python version: 3.11
- How jsonargparse was installed: uv
- OS: macOS


Discussion:
sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=637) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=637&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=637&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=637&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=637&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=637&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=637)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/omni-us/jsonargparse/pull/637?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 100.00%. Comparing base [(`0ccac3a`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/0ccac3ad07e0c9d555177b67d770bc3dad70b675?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) to head [(`0302e2e`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/0302e2e1f9af113260fb899297c04ae34d4441de?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).

:white_check_mark: All tests successful. No failed tests found.

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


```diff
@@            Coverage Diff            @@
##              main      #637   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           22        22           
  Lines         6490      6497    +7     
=========================================
+ Hits          6490      6497    +7     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/omni-us/jsonargparse/pull/637?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).   
:loudspeaker: Have feedback on the report? [Share it here](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).

sonarcloud[bot]: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=637) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 New issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=637&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=637&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=637&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [100.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=637&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=637&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=637)



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index fee10a5..b828de2 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -21,6 +21,8 @@ Fixed
   <https://github.com/omni-us/jsonargparse/pull/625>`__).
 - ``NotRequired`` incorrectly having ``inspect._empty`` as default (`#625
   <https://github.com/omni-us/jsonargparse/pull/625>`__).
+- Callable protocols failing to parse (`#637
+  <https://github.com/omni-us/jsonargparse/pull/637>`__).
 
 
 v4.34.0 (2024-11-08)
diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py
index bc44a2f..2875d02 100644
--- a/jsonargparse/_typehints.py
+++ b/jsonargparse/_typehints.py
@@ -1042,8 +1042,9 @@ def adapt_typehints(
                 prev_val = Namespace(class_path=get_import_path(typehint))  # implicit class_path
         val = subclass_spec_as_namespace(val, prev_val)
         if not is_subclass_spec(val):
+            msg = "Does not implement protocol" if is_protocol(typehint) else "Not a valid subclass of"
             raise_unexpected_value(
-                f"Not a valid subclass of {typehint.__name__}. Got value: {val_input}\n"
+                f"{msg} {typehint.__name__}. Got value: {val_input}\n"
                 "Subclass types expect one of:\n"
                 "- a class path (str)\n"
                 "- a dict with class_path entry\n"
@@ -1054,6 +1055,8 @@ def adapt_typehints(
             val_class = import_object(resolve_class_path_by_name(typehint, val["class_path"]))
             if is_instance_or_supports_protocol(val_class, typehint):
                 return val_class  # importable instance
+            if is_protocol(val_class):
+                raise_unexpected_value(f"Expected an instantiatable class, but {val['class_path']} is a protocol")
             not_subclass = False
             if not is_subclass_or_implements_protocol(val_class, typehint):
                 not_subclass = True
@@ -1064,9 +1067,8 @@ def adapt_typehints(
                     if is_subclass_or_implements_protocol(return_type, typehint):
                         not_subclass = False
             if not_subclass:
-                raise_unexpected_value(
-                    f"Import path {val['class_path']} does not correspond to a subclass of {typehint.__name__}"
-                )
+                msg = "implement protocol" if is_protocol(typehint) else "correspond to a subclass of"
+                raise_unexpected_value(f"Import path {val['class_path']} does not {msg} {typehint.__name__}")
             val["class_path"] = get_import_path(val_class)
             val = adapt_class_type(val, serialize, instantiate_classes, sub_add_kwargs, prev_val=prev_val)
         except (ImportError, AttributeError, AssertionError, ArgumentError) as ex:
@@ -1111,8 +1113,11 @@ def implements_protocol(value, protocol) -> bool:
         if not hasattr(value, name):
             return False
         members += 1
+        try:
+            value_params = get_signature_parameters(value, name)
+        except ValueError:
+            return False
         proto_params = get_signature_parameters(protocol, name)
-        value_params = get_signature_parameters(value, name)
         if [(p.name, p.annotation) for p in proto_params] != [(p.name, p.annotation) for p in value_params]:
             return False
         proto_return = get_return_type(inspect.getattr_static(protocol, name))


Test patch:
diff --git a/jsonargparse_tests/test_subclasses.py b/jsonargparse_tests/test_subclasses.py
index 6dc34c3..0c2abb7 100644
--- a/jsonargparse_tests/test_subclasses.py
+++ b/jsonargparse_tests/test_subclasses.py
@@ -1492,12 +1492,12 @@ def test_parse_implements_protocol(parser):
     assert isinstance(init.cls, ImplementsInterface)
     assert init.cls.batch_size == 5
     assert init.cls.predict([1.0, 2.0]) == [1.0, 2.0]
-    with pytest.raises(ArgumentError) as ctx:
+    with pytest.raises(ArgumentError, match="is a protocol"):
+        parser.parse_args([f"--cls={__name__}.Interface"])
+    with pytest.raises(ArgumentError, match="does not implement protocol"):
         parser.parse_args([f"--cls={__name__}.NotImplementsInterface1"])
-    ctx.match("does not correspond to a subclass of")
-    with pytest.raises(ArgumentError) as ctx:
+    with pytest.raises(ArgumentError, match="Does not implement protocol Interface"):
         parser.parse_args(['--cls={"batch_size": 5}'])
-    ctx.match("Not a valid subclass of Interface")
 
 
 # callable protocol tests
@@ -1507,7 +1507,7 @@ class CallableInterface(Protocol):
     def __call__(self, items: List[float]) -> List[float]: ...
 
 
-class ImplementsCallableInterface1:
+class ImplementsCallableInterface:
     def __init__(self, batch_size: int):
         self.batch_size = batch_size
 
@@ -1533,8 +1533,8 @@ class NotImplementsCallableInterface3:
 @pytest.mark.parametrize(
     "expected, value",
     [
-        (True, ImplementsCallableInterface1),
-        (False, ImplementsCallableInterface1(1)),
+        (True, ImplementsCallableInterface),
+        (False, ImplementsCallableInterface(1)),
         (False, NotImplementsCallableInterface1),
         (False, NotImplementsCallableInterface2),
         (False, NotImplementsCallableInterface3),
@@ -1545,6 +1545,22 @@ def test_implements_callable_protocol(expected, value):
     assert implements_protocol(value, CallableInterface) is expected
 
 
+def test_parse_implements_callable_protocol(parser):
+    parser.add_argument("--cls", type=CallableInterface)
+    cfg = parser.parse_args([f"--cls={__name__}.ImplementsCallableInterface", "--cls.batch_size=7"])
+    assert cfg.cls.class_path == f"{__name__}.ImplementsCallableInterface"
+    assert cfg.cls.init_args == Namespace(batch_size=7)
+    init = parser.instantiate_classes(cfg)
+    assert isinstance(init.cls, ImplementsCallableInterface)
+    assert init.cls([1.0, 2.0]) == [1.0, 2.0]
+    with pytest.raises(ArgumentError, match="is a protocol"):
+        parser.parse_args([f"--cls={__name__}.CallableInterface"])
+    with pytest.raises(ArgumentError, match="does not implement protocol"):
+        parser.parse_args([f"--cls={__name__}.NotImplementsCallableInterface1"])
+    with pytest.raises(ArgumentError, match="Does not implement protocol CallableInterface"):
+        parser.parse_args(['--cls={"batch_size": 7}'])
+
+
 # parameter skip tests