Repository: deepset-ai/haystack

Issue:
`component.set_input_types()` and `component.set_input_type()` let's user set non-existing inputs
**Describe the bug**

If a user calls `component.set_input_types()` or `component.set_input_type()` in a Component's init method the inputs will be added even if the Component doesn't define a `run` method that accepts `**kwargs`.

**Expected behavior**

Setting an input in a Component init that has a `run` that doesn't accept `**kwargs` must fail.

**To Reproduce**

This sould fail:
```
@component
class Foo:
    def __init__(self):
        component.set_input_types(self, y=int)

    @component.output_types(output=int)
    def run(self, x: int):
        return {"output": x}


print(Foo())
# <__main__.Foo object at 0x106190500>
# Inputs:
#   - y: int
#   - x: int
# Outputs:
#   - output: int
```

This should fail too:
```
@component
class Bar:
    def __init__(self):
        component.set_input_type(self, "y", int)

    @component.output_types(output=int)
    def run(self, x: int):
        return {"output": x}

print(Bar())
# <__main__.Bar object at 0x13998ea80>
# Inputs:
#   - y: int
#   - x: int
# Outputs:
#   - output: int
```

This is correct instead:
```
@component
class Baz:
    def __init__(self):
        component.set_input_type(self, "y", int)

    @component.output_types(output=int)
    def run(self, x: int, **kwargs):
        return {"output": x + kwargs.get("y")}

print(Baz())
# <__main__.Baz object at 0x13998ec30>
# Inputs:
#   - y: int
#   - x: int
# Outputs:
#   - output: int
```



Discussion:
coveralls: ## Pull Request Test Coverage Report for [Build 10830265720](https://coveralls.io/builds/69760048)


### Details

* **0** of **0**   changed or added relevant lines in **0** files are covered.
* **7** unchanged lines in **3** files lost coverage.
* Overall coverage increased (+**0.03%**) to **90.34%**

---


|  Files with Coverage Reduction | New Missed Lines | % |
| :-----|--------------|--: |
| [components/builders/chat_prompt_builder.py](https://coveralls.io/builds/69760048/source?filename=components%2Fbuilders%2Fchat_prompt_builder.py#L232) | 1 | 98.46% |
| [core/component/component.py](https://coveralls.io/builds/69760048/source?filename=core%2Fcomponent%2Fcomponent.py#L331) | 1 | 98.76% |
| [core/component/sockets.py](https://coveralls.io/builds/69760048/source?filename=core%2Fcomponent%2Fsockets.py#L122) | 5 | 86.05% |
<!-- | **Total:** | **7** |  | -->

|  Totals | [![Coverage Status](https://coveralls.io/builds/69760048/badge)](https://coveralls.io/builds/69760048) |
| :-- | --: |
| Change from base [Build 10827600883](https://coveralls.io/builds/69756769): |  0.03% |
| Covered Lines: | 7201 |
| Relevant Lines: | 7971 |

---
##### 💛  - [Coveralls](https://coveralls.io)


Patch:
diff --git a/haystack/components/audio/whisper_local.py b/haystack/components/audio/whisper_local.py
index 846da079..79ac83b1 100644
--- a/haystack/components/audio/whisper_local.py
+++ b/haystack/components/audio/whisper_local.py
@@ -48,7 +48,7 @@ class LocalWhisperTranscriber:
 
     whisper = LocalWhisperTranscriber(model="small")
     whisper.warm_up()
-    transcription = whisper.run(audio_files=["path/to/audio/file"])
+    transcription = whisper.run(sources=["path/to/audio/file"])
     ```
     """
 
@@ -119,7 +119,7 @@ class LocalWhisperTranscriber:
         [Whisper API documentation](https://platform.openai.com/docs/guides/speech-to-text) and the official Whisper
         [GitHup repo](https://github.com/openai/whisper).
 
-        :param audio_files:
+        :param sources:
             A list of paths or binary streams to transcribe.
 
         :returns: A dictionary with the following keys:
@@ -147,7 +147,7 @@ class LocalWhisperTranscriber:
         [Whisper API documentation](https://platform.openai.com/docs/guides/speech-to-text) and the official Whisper
         [github repo](https://github.com/openai/whisper).
 
-        :param audio_files:
+        :param sources:
             A list of paths or binary streams to transcribe.
         :returns:
             A list of Documents, one for each file.
@@ -168,7 +168,7 @@ class LocalWhisperTranscriber:
         [Whisper API documentation](https://platform.openai.com/docs/guides/speech-to-text) and the official Whisper
         [github repo](https://github.com/openai/whisper).
 
-        :param audio_files:
+        :param sources:
             A list of paths or binary streams to transcribe.
         :returns:
             A dictionary mapping 'file_path' to 'transcription'.
diff --git a/haystack/components/builders/chat_prompt_builder.py b/haystack/components/builders/chat_prompt_builder.py
index 68f9a89c..a46101b0 100644
--- a/haystack/components/builders/chat_prompt_builder.py
+++ b/haystack/components/builders/chat_prompt_builder.py
@@ -133,8 +133,6 @@ class ChatPromptBuilder:
                     variables += list(template_variables)
 
         # setup inputs
-        static_input_slots = {"template": Optional[str], "template_variables": Optional[Dict[str, Any]]}
-        component.set_input_types(self, **static_input_slots)
         for var in variables:
             if var in self.required_variables:
                 component.set_input_type(self, var, Any)
diff --git a/haystack/components/builders/prompt_builder.py b/haystack/components/builders/prompt_builder.py
index b9899c92..3cb29e12 100644
--- a/haystack/components/builders/prompt_builder.py
+++ b/haystack/components/builders/prompt_builder.py
@@ -177,8 +177,6 @@ class PromptBuilder:
         variables = variables or []
 
         # setup inputs
-        static_input_slots = {"template": Optional[str], "template_variables": Optional[Dict[str, Any]]}
-        component.set_input_types(self, **static_input_slots)
         for var in variables:
             if var in self.required_variables:
                 component.set_input_type(self, var, Any)
diff --git a/haystack/components/evaluators/context_relevance.py b/haystack/components/evaluators/context_relevance.py
index a35323e8..c60bd0bd 100644
--- a/haystack/components/evaluators/context_relevance.py
+++ b/haystack/components/evaluators/context_relevance.py
@@ -161,7 +161,7 @@ class ContextRelevanceEvaluator(LLMEvaluator):
         )
 
     @component.output_types(score=float, results=List[Dict[str, Any]])
-    def run(self, questions: List[str], contexts: List[List[str]]) -> Dict[str, Any]:
+    def run(self, **inputs) -> Dict[str, Any]:
         """
         Run the LLM evaluator.
 
@@ -174,7 +174,7 @@ class ContextRelevanceEvaluator(LLMEvaluator):
                 - `score`: Mean context relevance score over all the provided input questions.
                 - `results`: A list of dictionaries with `relevant_statements` and `score` for each input context.
         """
-        result = super(ContextRelevanceEvaluator, self).run(questions=questions, contexts=contexts)
+        result = super(ContextRelevanceEvaluator, self).run(**inputs)
 
         for idx, res in enumerate(result["results"]):
             if res is None:
diff --git a/haystack/components/evaluators/faithfulness.py b/haystack/components/evaluators/faithfulness.py
index 5cdfceaf..8daf9dc0 100644
--- a/haystack/components/evaluators/faithfulness.py
+++ b/haystack/components/evaluators/faithfulness.py
@@ -151,7 +151,7 @@ class FaithfulnessEvaluator(LLMEvaluator):
         )
 
     @component.output_types(individual_scores=List[int], score=float, results=List[Dict[str, Any]])
-    def run(self, questions: List[str], contexts: List[List[str]], predicted_answers: List[str]) -> Dict[str, Any]:
+    def run(self, **inputs) -> Dict[str, Any]:
         """
         Run the LLM evaluator.
 
@@ -167,9 +167,7 @@ class FaithfulnessEvaluator(LLMEvaluator):
                 - `individual_scores`: A list of faithfulness scores for each input answer.
                 - `results`: A list of dictionaries with `statements` and `statement_scores` for each input answer.
         """
-        result = super(FaithfulnessEvaluator, self).run(
-            questions=questions, contexts=contexts, predicted_answers=predicted_answers
-        )
+        result = super(FaithfulnessEvaluator, self).run(**inputs)
 
         # calculate average statement faithfulness score per query
         for idx, res in enumerate(result["results"]):
diff --git a/haystack/core/component/component.py b/haystack/core/component/component.py
index e5036554..6da831b9 100644
--- a/haystack/core/component/component.py
+++ b/haystack/core/component/component.py
@@ -211,21 +211,32 @@ class ComponentMeta(type):
     @staticmethod
     def _parse_and_set_input_sockets(component_cls: Type, instance: Any):
         def inner(method, sockets):
+            from inspect import Parameter
+
             run_signature = inspect.signature(method)
 
-            # First is 'self' and it doesn't matter.
-            for param in list(run_signature.parameters)[1:]:
-                if run_signature.parameters[param].kind not in (
-                    inspect.Parameter.VAR_POSITIONAL,
-                    inspect.Parameter.VAR_KEYWORD,
-                ):  # ignore variable args
-                    socket_kwargs = {"name": param, "type": run_signature.parameters[param].annotation}
-                    if run_signature.parameters[param].default != inspect.Parameter.empty:
-                        socket_kwargs["default_value"] = run_signature.parameters[param].default
-                    sockets[param] = InputSocket(**socket_kwargs)
+            for param_name, param_info in run_signature.parameters.items():
+                if param_name == "self" or param_info.kind in (Parameter.VAR_POSITIONAL, Parameter.VAR_KEYWORD):
+                    continue
+
+                socket_kwargs = {"name": param_name, "type": param_info.annotation}
+                if param_info.default != Parameter.empty:
+                    socket_kwargs["default_value"] = param_info.default
+
+                new_socket = InputSocket(**socket_kwargs)
+
+                # Also ensure that new sockets don't override existing ones.
+                existing_socket = sockets.get(param_name)
+                if existing_socket is not None and existing_socket != new_socket:
+                    raise ComponentError(
+                        "set_input_types()/set_input_type() cannot override the parameters of the 'run' method"
+                    )
+
+                sockets[param_name] = new_socket
+
+            return run_signature
 
         # Create the sockets if set_input_types() wasn't called in the constructor.
-        # If it was called and there are some parameters also in the `run()` method, these take precedence.
         if not hasattr(instance, "__haystack_input__"):
             instance.__haystack_input__ = Sockets(instance, {}, InputSocket)
 
@@ -239,9 +250,10 @@ class ComponentMeta(type):
 
             # Can't use the sockets from above as they might contain
             # values set with set_input_types().
-            inner(getattr(component_cls, "run"), run_sockets)
-            inner(async_run, async_run_sockets)
-            if async_run_sockets != run_sockets:
+            run_sig = inner(getattr(component_cls, "run"), run_sockets)
+            async_run_sig = inner(async_run, async_run_sockets)
+
+            if async_run_sockets != run_sockets or run_sig != async_run_sig:
                 raise ComponentError("Parameters of 'run' and 'async_run' methods must be the same")
 
     def __call__(cls, *args, **kwargs):
@@ -301,13 +313,26 @@ def _component_repr(component: Component) -> str:
     It prints the component name and the input/output sockets.
     """
     result = object.__repr__(component)
-    if pipeline := getattr(component, "__haystack_added_to_pipeline__"):
+    if pipeline := getattr(component, "__haystack_added_to_pipeline__", None):
         # This Component has been added in a Pipeline, let's get the name from there.
         result += f"\n{pipeline.get_component_name(component)}"
 
     # We're explicitly ignoring the type here because we're sure that the component
     # has the __haystack_input__ and __haystack_output__ attributes at this point
-    return f"{result}\n{component.__haystack_input__}\n{component.__haystack_output__}"  # type: ignore[attr-defined]
+    return (
+        f'{result}\n{getattr(component, "__haystack_input__", "<invalid_input_sockets>")}'
+        f'\n{getattr(component, "__haystack_output__", "<invalid_output_sockets>")}'
+    )
+
+
+def _component_run_has_kwargs(component_cls: Type) -> bool:
+    run_method = getattr(component_cls, "run", None)
+    if run_method is None:
+        return False
+    else:
+        return any(
+            param.kind == inspect.Parameter.VAR_KEYWORD for param in inspect.signature(run_method).parameters.values()
+        )
 
 
 class _Component:
@@ -339,11 +364,18 @@ class _Component:
         """
         Add a single input socket to the component instance.
 
+        Replaces any existing input socket with the same name.
+
         :param instance: Component instance where the input type will be added.
         :param name: name of the input socket.
         :param type: type of the input socket.
         :param default: default value of the input socket, defaults to _empty
         """
+        if not _component_run_has_kwargs(instance.__class__):
+            raise ComponentError(
+                "Cannot set input types on a component that doesn't have a kwargs parameter in the 'run' method"
+            )
+
         if not hasattr(instance, "__haystack_input__"):
             instance.__haystack_input__ = Sockets(instance, {}, InputSocket)
         instance.__haystack_input__[name] = InputSocket(name=name, type=type, default_value=default)
@@ -389,6 +421,11 @@ class _Component:
         parameter mandatory as specified in `set_input_types`.
 
         """
+        if not _component_run_has_kwargs(instance.__class__):
+            raise ComponentError(
+                "Cannot set input types on a component that doesn't have a kwargs parameter in the 'run' method"
+            )
+
         instance.__haystack_input__ = Sockets(
             instance, {name: InputSocket(name=name, type=type_) for name, type_ in types.items()}, InputSocket
         )
diff --git a/haystack/core/component/sockets.py b/haystack/core/component/sockets.py
index 22289ae9..2d84db2b 100644
--- a/haystack/core/component/sockets.py
+++ b/haystack/core/component/sockets.py
@@ -2,7 +2,7 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
-from typing import Dict, Type, Union
+from typing import Dict, Optional, Type, Union
 
 from haystack import logging
 from haystack.core.type_utils import _type_name
@@ -100,6 +100,24 @@ class Sockets:
         self._sockets_dict[key] = socket
         self.__dict__[key] = socket
 
+    def __contains__(self, key: str) -> bool:
+        return key in self._sockets_dict
+
+    def get(
+        self, key: str, default: Optional[Union[InputSocket, OutputSocket]] = None
+    ) -> Optional[Union[InputSocket, OutputSocket]]:
+        """
+        Get a socket from the Sockets object.
+
+        :param key:
+            The name of the socket to get.
+        :param default:
+            The value to return if the key is not found.
+        :returns:
+            The socket with the given key or `default` if the key is not found.
+        """
+        return self._sockets_dict.get(key, default)
+
     def _component_name(self) -> str:
         if pipeline := getattr(self._component, "__haystack_added_to_pipeline__"):
             # This Component has been added in a Pipeline, let's get the name from there.
diff --git a/releasenotes/notes/component-set-input-type-constraints-0b1130c1b49d9648.yaml b/releasenotes/notes/component-set-input-type-constraints-0b1130c1b49d9648.yaml
new file mode 100644
index 00000000..fa7c3bc0
--- /dev/null
+++ b/releasenotes/notes/component-set-input-type-constraints-0b1130c1b49d9648.yaml
@@ -0,0 +1,5 @@
+---
+fixes:
+  - |
+    Add constraints to `component.set_input_type` and `component.set_input_types` to prevent undefined behaviour
+    when the `run` method does not contain a variadic keyword argument.


Test patch:
diff --git a/test/components/evaluators/test_context_relevance_evaluator.py b/test/components/evaluators/test_context_relevance_evaluator.py
index dd364f8f..dd1e33cc 100644
--- a/test/components/evaluators/test_context_relevance_evaluator.py
+++ b/test/components/evaluators/test_context_relevance_evaluator.py
@@ -183,7 +183,7 @@ class TestContextRelevanceEvaluator:
     def test_run_missing_parameters(self, monkeypatch):
         monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
         component = ContextRelevanceEvaluator()
-        with pytest.raises(TypeError, match="missing 2 required positional arguments"):
+        with pytest.raises(ValueError, match="LLM evaluator expected input parameter"):
             component.run()
 
     def test_run_returns_nan_raise_on_failure_false(self, monkeypatch):
diff --git a/test/components/evaluators/test_faithfulness_evaluator.py b/test/components/evaluators/test_faithfulness_evaluator.py
index 8164ad9c..1150099e 100644
--- a/test/components/evaluators/test_faithfulness_evaluator.py
+++ b/test/components/evaluators/test_faithfulness_evaluator.py
@@ -223,7 +223,7 @@ class TestFaithfulnessEvaluator:
     def test_run_missing_parameters(self, monkeypatch):
         monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
         component = FaithfulnessEvaluator()
-        with pytest.raises(TypeError, match="missing 3 required positional arguments"):
+        with pytest.raises(ValueError, match="LLM evaluator expected input parameter"):
             component.run()
 
     def test_run_returns_nan_raise_on_failure_false(self, monkeypatch):
diff --git a/test/core/component/test_component.py b/test/core/component/test_component.py
index c81fbf8a..93e1104c 100644
--- a/test/core/component/test_component.py
+++ b/test/core/component/test_component.py
@@ -133,7 +133,7 @@ def test_async_run_not_async():
         def async_run(self, value: int):
             return {"value": 1}
 
-    with pytest.raises(ComponentError):
+    with pytest.raises(ComponentError, match=r"must be a coroutine"):
         comp = MockComponent()
 
 
@@ -148,45 +148,117 @@ def test_async_run_not_coroutine():
         async def async_run(self, value: int):
             yield {"value": 1}
 
-    with pytest.raises(ComponentError):
+    with pytest.raises(ComponentError, match=r"must be a coroutine"):
         comp = MockComponent()
 
 
 def test_parameters_mismatch_run_and_async_run():
+    err_msg = r"Parameters of 'run' and 'async_run' methods must be the same"
+
     @component
-    class MockComponent:
+    class MockComponentMismatchingInputTypes:
         @component.output_types(value=int)
         def run(self, value: int):
             return {"value": 1}
 
+        @component.output_types(value=int)
         async def async_run(self, value: str):
-            yield {"value": "1"}
+            return {"value": "1"}
 
-    with pytest.raises(ComponentError):
-        comp = MockComponent()
+    with pytest.raises(ComponentError, match=err_msg):
+        comp = MockComponentMismatchingInputTypes()
+
+    @component
+    class MockComponentMismatchingInputs:
+        @component.output_types(value=int)
+        def run(self, value: int, **kwargs):
+            return {"value": 1}
+
+        @component.output_types(value=int)
+        async def async_run(self, value: int):
+            return {"value": "1"}
+
+    with pytest.raises(ComponentError, match=err_msg):
+        comp = MockComponentMismatchingInputs()
+
+    @component
+    class MockComponentMismatchingInputOrder:
+        @component.output_types(value=int)
+        def run(self, value: int, another: str):
+            return {"value": 1}
+
+        @component.output_types(value=int)
+        async def async_run(self, another: str, value: int):
+            return {"value": "1"}
+
+    with pytest.raises(ComponentError, match=err_msg):
+        comp = MockComponentMismatchingInputOrder()
 
 
 def test_set_input_types():
     @component
     class MockComponent:
-        def __init__(self):
+        def __init__(self, flag: bool):
             component.set_input_types(self, value=Any)
-
-        def to_dict(self):
-            return {}
-
-        @classmethod
-        def from_dict(cls, data):
-            return cls()
+            if flag:
+                component.set_input_type(self, name="another", type=str)
 
         @component.output_types(value=int)
         def run(self, **kwargs):
             return {"value": 1}
 
-    comp = MockComponent()
+    comp = MockComponent(False)
     assert comp.__haystack_input__._sockets_dict == {"value": InputSocket("value", Any)}
     assert comp.run() == {"value": 1}
 
+    comp = MockComponent(True)
+    assert comp.__haystack_input__._sockets_dict == {
+        "value": InputSocket("value", Any),
+        "another": InputSocket("another", str),
+    }
+    assert comp.run() == {"value": 1}
+
+
+def test_set_input_types_no_kwarg():
+    @component
+    class MockComponent:
+        def __init__(self, flag: bool):
+            if flag:
+                component.set_input_type(self, name="another", type=str)
+            else:
+                component.set_input_types(self, value=Any)
+
+        @component.output_types(value=int)
+        def run(self, fini: bool):
+            return {"value": 1}
+
+    with pytest.raises(ComponentError, match=r"doesn't have a kwargs parameter"):
+        comp = MockComponent(False)
+
+    with pytest.raises(ComponentError, match=r"doesn't have a kwargs parameter"):
+        comp = MockComponent(True)
+
+
+def test_set_input_types_overrides_run():
+    @component
+    class MockComponent:
+        def __init__(self, state: bool):
+            if state:
+                component.set_input_type(self, name="fini", type=str)
+            else:
+                component.set_input_types(self, fini=Any)
+
+        @component.output_types(value=int)
+        def run(self, fini: bool, **kwargs):
+            return {"value": 1}
+
+    err_msg = "cannot override the parameters of the 'run' method"
+    with pytest.raises(ComponentError, match=err_msg):
+        comp = MockComponent(False)
+
+    with pytest.raises(ComponentError, match=err_msg):
+        comp = MockComponent(True)
+
 
 def test_set_output_types():
     @component
@@ -254,7 +326,7 @@ def test_output_types_decorator_mismatch_run_async_run():
         async def async_run(self, value: int):
             return {"value": "1"}
 
-    with pytest.raises(ComponentError):
+    with pytest.raises(ComponentError, match=r"Output type specifications .* must be the same"):
         comp = MockComponent()
 
 
@@ -268,7 +340,7 @@ def test_output_types_decorator_missing_async_run():
         async def async_run(self, value: int):
             return {"value": "1"}
 
-    with pytest.raises(ComponentError):
+    with pytest.raises(ComponentError, match=r"Output type specifications .* must be the same"):
         comp = MockComponent()
 
 
diff --git a/test/core/component/test_sockets.py b/test/core/component/test_sockets.py
index d4e70982..14f3b7f1 100644
--- a/test/core/component/test_sockets.py
+++ b/test/core/component/test_sockets.py
@@ -45,3 +45,20 @@ class TestSockets:
         io = Sockets(component=comp, sockets_dict=comp.__haystack_input__._sockets_dict, sockets_io_type=InputSocket)
         res = repr(io)
         assert res == "Inputs:\n  - input_1: int\n  - input_2: int"
+
+    def test_get(self):
+        comp = component_class("SomeComponent", input_types={"input_1": int, "input_2": int})()
+        io = Sockets(component=comp, sockets_dict=comp.__haystack_input__._sockets_dict, sockets_io_type=InputSocket)
+
+        assert io.get("input_1") == comp.__haystack_input__._sockets_dict["input_1"]
+        assert io.get("input_2") == comp.__haystack_input__._sockets_dict["input_2"]
+        assert io.get("invalid") == None
+        assert io.get("invalid", InputSocket("input_2", int)) == InputSocket("input_2", int)
+
+    def test_contains(self):
+        comp = component_class("SomeComponent", input_types={"input_1": int, "input_2": int})()
+        io = Sockets(component=comp, sockets_dict=comp.__haystack_input__._sockets_dict, sockets_io_type=InputSocket)
+
+        assert "input_1" in io
+        assert "input_2" in io
+        assert "invalid" not in io
diff --git a/test/core/pipeline/features/test_run.py b/test/core/pipeline/features/test_run.py
index 4c25927f..8d1bde95 100644
--- a/test/core/pipeline/features/test_run.py
+++ b/test/core/pipeline/features/test_run.py
@@ -848,7 +848,7 @@ def pipeline_that_has_a_component_with_only_default_inputs_as_first_to_run():
     - The second Component has at least one default input
     """
 
-    def fake_generator_run(self, prompt: str, generation_kwargs: Optional[Dict[str, Any]] = None):
+    def fake_generator_run(self, generation_kwargs: Optional[Dict[str, Any]] = None, **kwargs):
         # Simple hack to simulate a model returning a different reply after the
         # the first time it's called
         if getattr(fake_generator_run, "called", False):
@@ -858,7 +858,7 @@ def pipeline_that_has_a_component_with_only_default_inputs_as_first_to_run():
 
     FakeGenerator = component_class(
         "FakeGenerator",
-        input_types={"prompt": str, "generation_kwargs": Optional[Dict[str, Any]]},
+        input_types={"prompt": str},
         output_types={"replies": List[str]},
         extra_fields={"run": fake_generator_run},
     )