Repository: deepset-ai/haystack

Issue:
haystack-ai==2.13.0 missing `py.typed` file, which causes pylance to complain about missing stub file
**Describe the bug**
PEP561 requires `py.typed` file in the package to let it be known that the package has type hints. This package does indeed have type hints. Therefore, it must have a `py.typed` file in the package.

**Error message**
Stub file not found for "haystack.document_stores.in_memory"Pylance[reportMissingTypeStubs](https://github.com/microsoft/pyright/blob/main/docs/configuration.md#reportMissingTypeStubs)

**Expected behavior**
There should be no import errors when importing `haystack`

**Additional context**
Thanks for reading this.

**To Reproduce**
You might need the `Pylance` VSCode extension, and `"python.analysis.typeCheckingMode": "strict"` in `.vscode/settings.json`, and try to import the `haystack` package.

**FAQ Check**
- [x] Have you had a look at [our new FAQ page](https://docs.haystack.deepset.ai/docs/faq)?
- [x] Yes, I have.

**System:**
 - OS: MacOS 15.3
 - GPU/CPU: M2 Max 64GB CPU:12 GPU:40
 - Haystack version (commit or version number): 2.13.0
 - DocumentStore: ?
 - Reader: ?
 - Retriever: ?

Hints from issue comments:
@nicholas-johnson-techxcel: This repo is virtually unusable. If I open the source in its own window, there does not seem to be any errors, but then when imported, you get a tonne of things like this:

```reasons_to_cry
Type of "OpenAIGenerator" is partially unknown
  Type of "OpenAIGenerator" is "Any | ((cls: Unknown) -> Any)"
```

And this makes half the code red and it is impossible to write functioning code which uses the library.@julian-risch: Hello @nicholas-johnson-techxcel and thanks for opening this issue. I'll bring it up in our engineering team tomorrow when we plan our next sprint. We should be able to address it next week.
I had a quick look and the necessary change really seems to be just adding `haystack/py.typed` and then hatch should include it automatically. Feel free to open a pull request!

More context: https://typing.python.org/en/latest/spec/distributing.html#type-information-in-libraries
Related: https://github.com/deepset-ai/haystack/issues/8041#issuecomment-2559973872@nicholas-johnson-techxcel: Me adding my own `.py.typed` file did not solve all of the issues - it still imports some things with unknown types, making half of my project red and making it impossible to tell which parts are real problems and which ones are not. Demoting type checking from strict covers up the errors but then all autocomplete and Intellisense are broken.@nicholas-johnson-techxcel: Is ChatGPT correct in this?

```possible_garbage
	•	Haystack is architected dynamically: Pipelines, nodes, splitters are all “late loaded.”
	•	This design breaks static typing naturally.
	•	Their team hasn’t fully typed their public API yet.
	•	Even PyTorch, Huggingface, TensorFlow have this problem in dynamic parts.

So: even in 2025, without Haystack refactoring, this won’t magically get better.
You have to fix it locally or shim it yourself.
```

If this is the case then we cannot take this company seriously. At this point writing code which is not typesafe is just laughable. Sorry for being blunt about it.@nicholas-johnson-techxcel: Okay I have overhauled the library but it will require a lot of testing. I also am using advanced Python language features so we will have to kiss python 3.9 good bye, and maybe even more.@nicholas-johnson-techxcel: Okay so it's mostly working now, but it's hard to tell since the cookbook is out of date and Cursor AI is not of much help, but I did manage to get a chat to look up some documents working.

Some major issues:
- Why is this implemented with annotators and digging under the hood of Python? Each component could just be a `pydantic.BaseModel` which is extended
- The annotation for run output_types is unnecessary - you can just give it a return type and inspect that return type
- No need to store metadata under the hood
- The issues outlined in this bug report
- run_async functions are a DRY violation, we can just write run_async and wrap it in a blocking function for if we want blocking
- The docstrings do not contain enough info for Cursor AI to be useful when consuming this library

So far I have:
- Fixed most of the user-facing type issues which make the linter angry
- Fixed the broken type inference
- Upgraded the python version - PEP 695 requires 3.12 so I will put that as minimum, although I am using 3.13.2
- Fixed the linter

I have not:
- Fixed the behind the scenes typing issues
- Refactored to use Pydantic and avoid going under the hood completely
- Added better documentation

I had removed the conversion from positional to named params, but then I reverted it because it might be too much of a breaking change, but now I am regretting reverting. It is a mess. Just use named parameters.

If I can mainline my changes, I would recommend going forward:
- Probably only support the latest Python and one minor version before. Unfortunately Python does not polyfill 
- Stronger linter and type-checking enabled in `.vscode/settings.json`
- Pull requests required to fix any typing issues in any code that they touch before they are approved to merge
- Going forward deprecate and remove under the hood modification and avoiding dynamic field assignment
- Each release to check that the VSCode can still infer types and function/class props from the library imports (i.e. import it in another project)
- Have some examples in the repo itself which get updated with any code changes. You could have automated tests which check they still run okay before merging

I will probably make a pull-request in the next few days. I will do my best to make it agreeable but if you want to change it, you can apply revision commits and then merge it, as long as my work is attributed to me and you do not amend my own commit. I a not going to participate in PR feedback because my time is limited.@anakin87: @nicholas-johnson-techxcel I'll take a look at this issue in the next few days.

Thanks for your investigation!

(In case you have a solution, feel free to open a PR.)@anakin87: Hey, @nicholas-johnson-techxcel, I've started the exploration of this topic.

One question, in case you know: even if the typing support is not perfect or complete, what could wrong if we add the `py.typed` file?

Some resources:
- https://typing.python.org/en/latest/spec/distributing.html#packaging-typed-libraries
- Positive mentions 
  - https://github.com/python/typing/discussions/1429
  - https://blog.whtsky.me/tech/2021/dont-forget-py.typed-for-your-typed-python-package/
- Potential issues in other libraries
  - Langfuse: https://github.com/langfuse/langfuse/issues/2169
  - Transformers (a long story): https://github.com/huggingface/transformers/pulls?q=%22py.typed%22+@anakin87: Before introducing py.typed, I plan to test this change on a certain number of python files using Haystack as a library (like our tutorials), checking types.

Solution patch:
diff --git a/haystack/components/converters/multi_file_converter.py b/haystack/components/converters/multi_file_converter.py
index 9a13fad8c2..2fd57373ed 100644
--- a/haystack/components/converters/multi_file_converter.py
+++ b/haystack/components/converters/multi_file_converter.py
@@ -80,24 +80,22 @@ def __init__(self, encoding: str = "utf-8", json_content_key: str = "content") -
         # Create pipeline and add components
         pp = Pipeline()
 
-        # We use type ignore here to avoid type checking errors
-        # This is due to how the run method within the Component protocol is defined
-        pp.add_component("router", router)  # type: ignore[arg-type]
-        pp.add_component("docx", DOCXToDocument(link_format="markdown"))  # type: ignore[arg-type]
+        pp.add_component("router", router)
+        pp.add_component("docx", DOCXToDocument(link_format="markdown"))
         pp.add_component(
             "html",
-            HTMLToDocument(  # type: ignore[arg-type]
+            HTMLToDocument(
                 extraction_kwargs={"output_format": "markdown", "include_tables": True, "include_links": True}
             ),
         )
-        pp.add_component("json", JSONConverter(content_key=self.json_content_key))  # type: ignore[arg-type]
-        pp.add_component("md", TextFileToDocument(encoding=self.encoding))  # type: ignore[arg-type]
-        pp.add_component("text", TextFileToDocument(encoding=self.encoding))  # type: ignore[arg-type]
-        pp.add_component("pdf", PyPDFToDocument())  # type: ignore[arg-type]
-        pp.add_component("pptx", PPTXToDocument())  # type: ignore[arg-type]
-        pp.add_component("xlsx", XLSXToDocument())  # type: ignore[arg-type]
-        pp.add_component("joiner", DocumentJoiner())  # type: ignore[arg-type]
-        pp.add_component("csv", CSVToDocument(encoding=self.encoding))  # type: ignore[arg-type]
+        pp.add_component("json", JSONConverter(content_key=self.json_content_key))
+        pp.add_component("md", TextFileToDocument(encoding=self.encoding))
+        pp.add_component("text", TextFileToDocument(encoding=self.encoding))
+        pp.add_component("pdf", PyPDFToDocument())
+        pp.add_component("pptx", PPTXToDocument())
+        pp.add_component("xlsx", XLSXToDocument())
+        pp.add_component("joiner", DocumentJoiner())
+        pp.add_component("csv", CSVToDocument(encoding=self.encoding))
 
         for mime_type in ConverterMimeType:
             pp.connect(f"router.{mime_type.value}", str(mime_type).lower().rsplit(".", maxsplit=1)[-1])
diff --git a/haystack/components/preprocessors/document_preprocessor.py b/haystack/components/preprocessors/document_preprocessor.py
index 5b6fbf2434..e30d8db11e 100644
--- a/haystack/components/preprocessors/document_preprocessor.py
+++ b/haystack/components/preprocessors/document_preprocessor.py
@@ -127,10 +127,8 @@ def __init__(  # noqa: PLR0913 (too-many-arguments)
         # Build the Pipeline
         pp = Pipeline()
 
-        # We use type ignore here to avoid type checking errors
-        # This is due to how the run method within the Component protocol is defined
-        pp.add_component("splitter", splitter)  # type: ignore[arg-type]
-        pp.add_component("cleaner", cleaner)  # type: ignore[arg-type]
+        pp.add_component("splitter", splitter)
+        pp.add_component("cleaner", cleaner)
 
         # Connect the splitter output to cleaner
         pp.connect("splitter.documents", "cleaner.documents")
diff --git a/haystack/core/component/component.py b/haystack/core/component/component.py
index a2c31a072f..7072589782 100644
--- a/haystack/core/component/component.py
+++ b/haystack/core/component/component.py
@@ -160,12 +160,29 @@ def run(self, **kwargs):
         isinstance(MyComponent, Component)
     """
 
-    # This is the most reliable way to define the protocol for the `run` method.
-    # Defining a method doesn't work as different Components will have different
-    # arguments. Even defining here a method with `**kwargs` doesn't work as the
-    # expected signature must be identical.
-    # This makes most Language Servers and type checkers happy and shows less errors.
-    run: Callable[..., Dict[str, Any]]
+    # The following expression defines a run method compatible with any input signature.
+    # Its type is equivalent to Callable[..., Dict[str, Any]].
+    # See https://typing.python.org/en/latest/spec/callables.html#meaning-of-in-callable.
+    #
+    # Using `run: Callable[..., Dict[str, Any]]` directly leads to type errors: the protocol would expect a settable
+    # attribute `run`, while the actual implementation is a read-only method.
+    # For example:
+    # from haystack import Pipeline, component
+    # @component
+    # class MyComponent:
+    #     @component.output_types(out=str)
+    #     def run(self):
+    #         return {"out": "Hello, world!"}
+    # pipeline = Pipeline()
+    # pipeline.add_component("my_component", MyComponent())
+    #
+    # mypy raises:
+    # error: Argument 2 to "add_component" of "PipelineBase" has incompatible type "MyComponent"; expected "Component"
+    # [arg-type]
+    # note: Protocol member Component.run expected settable variable, got read-only attribute
+
+    def run(self, *args: Any, **kwargs: Any) -> Dict[str, Any]:  # pylint: disable=missing-function-docstring # noqa: D102
+        ...
 
 
 class ComponentMeta(type):
diff --git a/haystack/core/pipeline/async_pipeline.py b/haystack/core/pipeline/async_pipeline.py
index de2584cf4a..9d7b2d9414 100644
--- a/haystack/core/pipeline/async_pipeline.py
+++ b/haystack/core/pipeline/async_pipeline.py
@@ -3,7 +3,6 @@
 # SPDX-License-Identifier: Apache-2.0
 
 import asyncio
-from copy import deepcopy
 from typing import Any, AsyncIterator, Dict, List, Optional, Set
 
 from haystack import logging, tracing
@@ -16,6 +15,7 @@
     ComponentPriority,
     PipelineBase,
 )
+from haystack.core.pipeline.utils import deepcopy_with_fallback
 from haystack.telemetry import pipeline_running
 
 logger = logging.getLogger(__name__)
@@ -58,7 +58,7 @@ async def _run_component_async(  # pylint: disable=too-many-positional-arguments
         with PipelineBase._create_component_span(
             component_name=component_name, instance=instance, inputs=component_inputs, parent_span=parent_span
         ) as span:
-            span.set_content_tag(_COMPONENT_INPUT, deepcopy(component_inputs))
+            span.set_content_tag(_COMPONENT_INPUT, deepcopy_with_fallback(component_inputs))
             logger.info("Running component {component_name}", component_name=component_name)
 
             if getattr(instance, "__haystack_supports_async__", False):
@@ -76,7 +76,7 @@ async def _run_component_async(  # pylint: disable=too-many-positional-arguments
                 raise PipelineRuntimeError.from_invalid_output(component_name, instance.__class__, outputs)
 
             span.set_tag(_COMPONENT_VISITS, component_visits[component_name])
-            span.set_content_tag(_COMPONENT_OUTPUT, deepcopy(outputs))
+            span.set_content_tag(_COMPONENT_OUTPUT, deepcopy_with_fallback(outputs))
 
             return outputs
 
@@ -238,7 +238,7 @@ async def _run_highest_in_isolation(component_name: str) -> AsyncIterator[Dict[s
                         partial_result = finished.result()
                         scheduled_components.discard(finished_component_name)
                         if partial_result:
-                            yield_dict = {finished_component_name: deepcopy(partial_result)}
+                            yield_dict = {finished_component_name: deepcopy_with_fallback(partial_result)}
                             yield yield_dict  # partial outputs
 
                 if component_name in scheduled_components:
@@ -274,7 +274,7 @@ async def _run_highest_in_isolation(component_name: str) -> AsyncIterator[Dict[s
 
                 scheduled_components.remove(component_name)
                 if pruned:
-                    yield {component_name: deepcopy(pruned)}
+                    yield {component_name: deepcopy_with_fallback(pruned)}
 
             async def _schedule_task(component_name: str) -> None:
                 """
@@ -337,7 +337,7 @@ async def _wait_for_one_task_to_complete() -> AsyncIterator[Dict[str, Any]]:
                         partial_result = finished.result()
                         scheduled_components.discard(finished_component_name)
                         if partial_result:
-                            yield {finished_component_name: deepcopy(partial_result)}
+                            yield {finished_component_name: deepcopy_with_fallback(partial_result)}
 
             async def _wait_for_all_tasks_to_complete() -> AsyncIterator[Dict[str, Any]]:
                 """
@@ -350,7 +350,7 @@ async def _wait_for_all_tasks_to_complete() -> AsyncIterator[Dict[str, Any]]:
                         partial_result = finished.result()
                         scheduled_components.discard(finished_component_name)
                         if partial_result:
-                            yield {finished_component_name: deepcopy(partial_result)}
+                            yield {finished_component_name: deepcopy_with_fallback(partial_result)}
 
             # -------------------------------------------------
             # MAIN SCHEDULING LOOP
@@ -428,7 +428,7 @@ async def _wait_for_all_tasks_to_complete() -> AsyncIterator[Dict[str, Any]]:
                 yield partial_res
 
             # 4) Yield final pipeline outputs
-            yield deepcopy(pipeline_outputs)
+            yield deepcopy_with_fallback(pipeline_outputs)
 
     async def run_async(
         self, data: Dict[str, Any], include_outputs_from: Optional[Set[str]] = None, concurrency_limit: int = 4
diff --git a/haystack/core/pipeline/base.py b/haystack/core/pipeline/base.py
index 38a2bc1481..e008d7ce79 100644
--- a/haystack/core/pipeline/base.py
+++ b/haystack/core/pipeline/base.py
@@ -4,7 +4,6 @@
 
 import itertools
 from collections import defaultdict
-from copy import deepcopy
 from datetime import datetime
 from enum import IntEnum
 from pathlib import Path
@@ -33,7 +32,7 @@
     is_any_greedy_socket_ready,
     is_socket_lazy_variadic,
 )
-from haystack.core.pipeline.utils import FIFOPriorityQueue, parse_connect_string
+from haystack.core.pipeline.utils import FIFOPriorityQueue, deepcopy_with_fallback, parse_connect_string
 from haystack.core.serialization import DeserializationCallbacks, component_from_dict, component_to_dict
 from haystack.core.type_utils import _type_name, _types_are_compatible
 from haystack.marshal import Marshaller, YamlMarshaller
@@ -176,7 +175,7 @@ def from_dict(
         :returns:
             Deserialized component.
         """
-        data_copy = deepcopy(data)  # to prevent modification of original data
+        data_copy = deepcopy_with_fallback(data)  # to prevent modification of original data
         metadata = data_copy.get("metadata", {})
         max_runs_per_component = data_copy.get("max_runs_per_component", 100)
         connection_type_validation = data_copy.get("connection_type_validation", True)
@@ -895,7 +894,7 @@ def _prepare_component_input_data(self, data: Dict[str, Any]) -> Dict[str, Dict[
         # deepcopying the inputs prevents the Pipeline run logic from being altered unexpectedly
         # when the same input reference is passed to multiple components.
         for component_name, component_inputs in data.items():
-            data[component_name] = {k: deepcopy(v) for k, v in component_inputs.items()}
+            data[component_name] = {k: deepcopy_with_fallback(v) for k, v in component_inputs.items()}
 
         return data
 
diff --git a/haystack/core/pipeline/pipeline.py b/haystack/core/pipeline/pipeline.py
index 69bbd145ca..b3f90e7a69 100644
--- a/haystack/core/pipeline/pipeline.py
+++ b/haystack/core/pipeline/pipeline.py
@@ -2,7 +2,6 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
-from copy import deepcopy
 from typing import Any, Dict, Mapping, Optional, Set, cast
 
 from haystack import logging, tracing
@@ -15,6 +14,7 @@
     ComponentPriority,
     PipelineBase,
 )
+from haystack.core.pipeline.utils import deepcopy_with_fallback
 from haystack.telemetry import pipeline_running
 
 logger = logging.getLogger(__name__)
@@ -54,7 +54,7 @@ def _run_component(
         ) as span:
             # We deepcopy the inputs otherwise we might lose that information
             # when we delete them in case they're sent to other Components
-            span.set_content_tag(_COMPONENT_INPUT, deepcopy(inputs))
+            span.set_content_tag(_COMPONENT_INPUT, deepcopy_with_fallback(inputs))
             logger.info("Running component {component_name}", component_name=component_name)
             try:
                 component_output = instance.run(**inputs)
@@ -252,7 +252,7 @@ def run(  # noqa: PLR0915, PLR0912
                 )
 
                 if component_pipeline_outputs:
-                    pipeline_outputs[component_name] = deepcopy(component_pipeline_outputs)
+                    pipeline_outputs[component_name] = deepcopy_with_fallback(component_pipeline_outputs)
                 if self._is_queue_stale(priority_queue):
                     priority_queue = self._fill_queue(ordered_component_names, inputs, component_visits)
 
diff --git a/haystack/core/pipeline/utils.py b/haystack/core/pipeline/utils.py
index 0746dc6746..cc05714af1 100644
--- a/haystack/core/pipeline/utils.py
+++ b/haystack/core/pipeline/utils.py
@@ -3,9 +3,57 @@
 # SPDX-License-Identifier: Apache-2.0
 
 import heapq
+from copy import deepcopy
 from itertools import count
 from typing import Any, List, Optional, Tuple
 
+from haystack import logging
+
+logger = logging.getLogger(__name__)
+
+
+def deepcopy_with_fallback(obj: Any, max_depth: Optional[int] = 100) -> Any:
+    """
+    Attempts to create a deep copy of the given object with a safe fallback mechanism.
+
+    If the object is a dictionary, each value is copied individually using this function recursively.
+    If an individual item fails to be copied, the original value is used as a fallback.
+    A maximum recursion depth is enforced to avoid deep or cyclic structures from causing issues.
+
+    :param obj: The object to attempt to deep copy.
+    :param max_depth: The maximum depth to recurse during deep copying. If 0, returns the object as-is.
+    :return: A deep copy of the object if successful; otherwise, the original object.
+    """
+    if max_depth is not None and max_depth <= 0:
+        return obj
+
+    try:
+        return deepcopy(obj)
+    except Exception as e:
+        # If the deepcopy fails we try to deepcopy each individual value if the object is a dictionary
+        next_depth = None if max_depth is None else max_depth - 1
+        if isinstance(obj, dict):
+            logger.info(
+                "Deepcopy failed for object of type '{obj_type}'. Error: {error}. Attempting item-wise copy.",
+                obj_type=type(obj).__name__,
+                error=e,
+            )
+            return {key: deepcopy_with_fallback(value, next_depth) for key, value in obj.items()}
+        elif isinstance(obj, (list, tuple, set)):
+            logger.info(
+                "Deepcopy failed for object of type '{obj_type}'. Error: {error}. Attempting item-wise copy.",
+                obj_type=type(obj).__name__,
+                error=e,
+            )
+            return type(obj)(deepcopy_with_fallback(item, next_depth) for item in obj)
+
+        logger.info(
+            "Deepcopy failed for object of type '{obj_type}'. Error: {error}. Returning original object instead.",
+            obj_type=type(obj).__name__,
+            error=e,
+        )
+        return obj
+
 
 def parse_connect_string(connection: str) -> Tuple[str, Optional[str]]:
     """
diff --git a/haystack/py.typed b/haystack/py.typed
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/haystack/tools/component_tool.py b/haystack/tools/component_tool.py
index 8f20f5e713..1e58459d44 100644
--- a/haystack/tools/component_tool.py
+++ b/haystack/tools/component_tool.py
@@ -2,7 +2,6 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
-from copy import copy, deepcopy
 from dataclasses import fields, is_dataclass
 from inspect import getdoc
 from typing import Any, Callable, Dict, Optional, Union, get_args, get_origin
@@ -419,29 +418,3 @@ def _create_property_schema(self, python_type: Any, description: str, default: A
             schema["default"] = default
 
         return schema
-
-    def __deepcopy__(self, memo: Dict[Any, Any]) -> "ComponentTool":
-        # Jinja2 templates throw an Exception when we deepcopy them (see https://github.com/pallets/jinja/issues/758)
-        # When we use a ComponentTool in a pipeline at runtime, we deepcopy the tool
-        # We overwrite ComponentTool.__deepcopy__ to fix this until a more comprehensive fix is merged.
-        # We track the issue here: https://github.com/deepset-ai/haystack/issues/9011
-        result = copy(self)
-
-        # Add the object to the memo dictionary to handle circular references
-        memo[id(self)] = result
-
-        # Deep copy all attributes with exception handling
-        for key, value in self.__dict__.items():
-            try:
-                # Try to deep copy the attribute
-                setattr(result, key, deepcopy(value, memo))
-            except (TypeError, NotImplementedError):
-                # Fall back to using the original attribute for components that use Jinja2-templates
-                logger.debug(
-                    "deepcopy of ComponentTool {tool_name} failed. Using original attribute '{attribute}' instead.",
-                    tool_name=self.name,
-                    attribute=key,
-                )
-                setattr(result, key, getattr(self, key))
-
-        return result
diff --git a/releasenotes/notes/fix-deepcopy-in-pipeline-run-2b575c7b860f642f.yaml b/releasenotes/notes/fix-deepcopy-in-pipeline-run-2b575c7b860f642f.yaml
new file mode 100644
index 0000000000..315766c0b8
--- /dev/null
+++ b/releasenotes/notes/fix-deepcopy-in-pipeline-run-2b575c7b860f642f.yaml
@@ -0,0 +1,6 @@
+---
+fixes:
+  - |
+    Introduced `deepcopy_with_fallback` to improve robustness in the `Pipeline.run` method.
+    Previously, standard `deepcopy` was used on inputs and outputs to avoid unintended side effects from shared mutable data structures, as these values are accessed in multiple parts of the pipeline. However, certain Python objects cannot be deepcopied, which could lead to runtime errors.
+    The new `deepcopy_with_fallback` function attempts to deep copy an object and safely falls back to the original object if copying fails. This ensures pipeline execution remains stable while maintaining the protective behavior against shared-state modification.
diff --git a/releasenotes/notes/py-typed-724eea7222640e6d.yaml b/releasenotes/notes/py-typed-724eea7222640e6d.yaml
new file mode 100644
index 0000000000..0af15c5a25
--- /dev/null
+++ b/releasenotes/notes/py-typed-724eea7222640e6d.yaml
@@ -0,0 +1,9 @@
+---
+upgrade:
+  - |
+    We've added a `py.typed` file to Haystack to enable type information to be used by downstream projects, in line
+    with PEP 561. This means Haystack's type hints will now be visible to type checkers in projects that depend on it.
+    Haystack is primarily type checked using mypy (not pyright) and, despite our efforts, some type information can
+    be incomplete or unreliable.
+    If you use static type checking in your own project, you may notice some changes: previously, Haystack's types were
+    effectively treated as `Any`, but now actual type information will be available and enforced.


Test patch:
diff --git a/e2e/pipelines/test_evaluation_pipeline.py b/e2e/pipelines/test_evaluation_pipeline.py
index 4500f3ee17..97c5a981db 100644
--- a/e2e/pipelines/test_evaluation_pipeline.py
+++ b/e2e/pipelines/test_evaluation_pipeline.py
@@ -36,14 +36,14 @@ def indexing_pipeline(documents: List[Document]):
     doc_writer = DocumentWriter(document_store=document_store, policy=DuplicatePolicy.SKIP)
     doc_embedder = SentenceTransformersDocumentEmbedder(model=EMBEDDINGS_MODEL, progress_bar=False)
     ingestion_pipe = Pipeline()
-    ingestion_pipe.add_component(instance=doc_embedder, name="doc_embedder")  # type: ignore
-    ingestion_pipe.add_component(instance=doc_writer, name="doc_writer")  # type: ignore
+    ingestion_pipe.add_component(instance=doc_embedder, name="doc_embedder")
+    ingestion_pipe.add_component(instance=doc_writer, name="doc_writer")
     ingestion_pipe.connect("doc_embedder.documents", "doc_writer.documents")
     ingestion_pipe.run({"doc_embedder": {"documents": documents}})
     return document_store
 
 
-def rag_pipeline(document_store: InMemoryDocumentStore, top_k: int):  # type: ignore
+def rag_pipeline(document_store: InMemoryDocumentStore, top_k: int):
     """RAG pipeline"""
     template = [
         ChatMessage.from_system(
@@ -59,11 +59,11 @@ def rag_pipeline(document_store: InMemoryDocumentStore, top_k: int):  # type: ig
         ),
     ]
     rag = Pipeline()
-    rag.add_component("embedder", SentenceTransformersTextEmbedder(model=EMBEDDINGS_MODEL, progress_bar=False))  # type: ignore
-    rag.add_component("retriever", InMemoryEmbeddingRetriever(document_store, top_k=top_k))  # type: ignore
-    rag.add_component("prompt_builder", ChatPromptBuilder(template=template))  # type: ignore
-    rag.add_component("generator", OpenAIChatGenerator(model="gpt-4o-mini"))  # type: ignore
-    rag.add_component("answer_builder", AnswerBuilder())  # type: ignore
+    rag.add_component("embedder", SentenceTransformersTextEmbedder(model=EMBEDDINGS_MODEL, progress_bar=False))
+    rag.add_component("retriever", InMemoryEmbeddingRetriever(document_store, top_k=top_k))
+    rag.add_component("prompt_builder", ChatPromptBuilder(template=template))
+    rag.add_component("generator", OpenAIChatGenerator(model="gpt-4o-mini"))
+    rag.add_component("answer_builder", AnswerBuilder())
     rag.connect("embedder", "retriever.query_embedding")
     rag.connect("retriever", "prompt_builder.documents")
     rag.connect("prompt_builder", "generator")
diff --git a/test/core/pipeline/test_utils.py b/test/core/pipeline/test_utils.py
index e2cc26cac3..976fd2eec0 100644
--- a/test/core/pipeline/test_utils.py
+++ b/test/core/pipeline/test_utils.py
@@ -2,9 +2,16 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
+import logging
 import pytest
 
-from haystack.core.pipeline.utils import parse_connect_string, FIFOPriorityQueue
+from haystack.components.builders.prompt_builder import PromptBuilder
+from haystack.core.pipeline.utils import parse_connect_string, FIFOPriorityQueue, deepcopy_with_fallback
+from haystack.tools import ComponentTool, Tool
+
+
+def get_weather_report(city: str) -> str:
+    return f"Weather report for {city}: 20°C, sunny"
 
 
 def test_parse_connection():
@@ -175,3 +182,80 @@ def test_queue_ordering_parametrized(empty_queue, items):
     sorted_items = sorted(items, key=lambda x: (x[0], items.index(x)))
     for priority, item in sorted_items:
         assert empty_queue.pop() == (priority, item)
+
+
+class TestDeepcopyWithFallback:
+    def test_deepcopy_with_fallback_copyable(self, caplog):
+        tool = Tool(
+            name="weather",
+            description="Get weather report",
+            parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
+            function=get_weather_report,
+        )
+        original = {"tools": tool}
+        with caplog.at_level(logging.INFO):
+            copy = deepcopy_with_fallback(original)
+            assert "Deepcopy failed for object of type" not in caplog.text
+        assert copy["tools"] == original["tools"]
+        # This should be a true copy so changing the name in the copy should not affect the original
+        copy["tools"].name = "copied_tool"
+        assert copy["tools"] != original["tools"]
+
+    def test_deepcopy_with_fallback_not_copyable(self, caplog):
+        problematic_tool = ComponentTool(
+            name="problematic_tool", description="This is a problematic tool.", component=PromptBuilder("{{query}}")
+        )
+        original = {"tools": problematic_tool}
+        with caplog.at_level(logging.INFO):
+            copy = deepcopy_with_fallback(original)
+            assert "Deepcopy failed for object of type 'ComponentTool'" in caplog.text
+        assert copy["tools"] == original["tools"]
+        # Not a true copy but a shallow copy so changing the name in the copy should also affect the original
+        copy["tools"].name = "copied_tool"
+        assert copy["tools"] == original["tools"]
+
+    def test_deepcopy_with_fallback_mixed_copyable_list(self, caplog):
+        tool1 = Tool(
+            name="tool1",
+            description="Get weather report",
+            parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
+            function=get_weather_report,
+        )
+        tool2 = ComponentTool(
+            name="problematic_tool", description="This is a problematic tool.", component=PromptBuilder("{{query}}")
+        )
+        original = {"tools": [tool1, tool2]}
+        with caplog.at_level(logging.INFO):
+            copy = deepcopy_with_fallback(original)
+            assert "Deepcopy failed for object of type 'ComponentTool'" in caplog.text
+        assert copy["tools"][0] == original["tools"][0]
+        # First tool should be a true copy so changing the name in the copy should not affect the original
+        copy["tools"][0].name = "copied_tool1"
+        assert copy["tools"][0] != original["tools"][0]
+        assert copy["tools"][1] == original["tools"][1]
+        # second should be a shallow copy so changing the name in the copy should also affect the original
+        copy["tools"][1].name = "copied_tool2"
+        assert copy["tools"][1] == original["tools"][1]
+
+    def test_deepcopy_with_fallback_mixed_copyable_tuple(self, caplog):
+        tool1 = Tool(
+            name="tool1",
+            description="Get weather report",
+            parameters={"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]},
+            function=get_weather_report,
+        )
+        tool2 = ComponentTool(
+            name="problematic_tool", description="This is a problematic tool.", component=PromptBuilder("{{query}}")
+        )
+        original = {"tools": (tool1, tool2)}
+        with caplog.at_level(logging.INFO):
+            copy = deepcopy_with_fallback(original)
+            assert "Deepcopy failed for object of type 'ComponentTool'" in caplog.text
+        assert copy["tools"][0] == original["tools"][0]
+        # First tool should be a true copy so changing the name in the copy should not affect the original
+        copy["tools"][0].name = "copied_tool1"
+        assert copy["tools"][0] != original["tools"][0]
+        assert copy["tools"][1] == original["tools"][1]
+        # second should be a shallow copy so changing the name in the copy should also affect the original
+        copy["tools"][1].name = "copied_tool2"
+        assert copy["tools"][1] == original["tools"][1]
diff --git a/test/tools/test_component_tool.py b/test/tools/test_component_tool.py
index c8ae49c353..c80bb5f6ae 100644
--- a/test/tools/test_component_tool.py
+++ b/test/tools/test_component_tool.py
@@ -2,19 +2,24 @@
 #
 # SPDX-License-Identifier: Apache-2.0
 
+from unittest.mock import patch
+
 import json
 import os
-from copy import deepcopy
 from dataclasses import dataclass
 from typing import Dict, List
 
 import pytest
 
+from openai.types.chat import ChatCompletion, ChatCompletionMessage
+from openai.types.chat.chat_completion import Choice
+
 from haystack import Pipeline, component
 from haystack.components.builders import PromptBuilder
 from haystack.components.generators.chat import OpenAIChatGenerator
 from haystack.components.tools import ToolInvoker
 from haystack.components.websearch.serper_dev import SerperDevWebSearch
+from haystack.core.pipeline.utils import deepcopy_with_fallback
 from haystack.dataclasses import ChatMessage, ChatRole, Document
 from haystack.tools import ComponentTool
 from haystack.utils.auth import Secret
@@ -643,15 +648,36 @@ def test_deepcopy_with_jinja_based_component(self):
         # When we use a ComponentTool in a pipeline at runtime, we deepcopy the tool
         # We overwrite ComponentTool.__deepcopy__ to fix this in experimental until a more comprehensive fix is merged.
         # We track the issue here: https://github.com/deepset-ai/haystack/issues/9011
-
         builder = PromptBuilder("{{query}}")
-
         tool = ComponentTool(component=builder)
         result = tool.function(query="Hello")
-
-        tool_copy = deepcopy(tool)
-
+        tool_copy = deepcopy_with_fallback(tool)
         result_from_copy = tool_copy.function(query="Hello")
-
         assert "prompt" in result_from_copy
         assert result_from_copy["prompt"] == result["prompt"]
+
+    def test_jinja_based_component_tool_in_pipeline(self, monkeypatch):
+        monkeypatch.setenv("OPENAI_API_KEY", "test-key")
+
+        with patch("openai.resources.chat.completions.Completions.create") as mock_create:
+            mock_create.return_value = ChatCompletion(
+                id="test",
+                model="gpt-4o-mini",
+                object="chat.completion",
+                choices=[
+                    Choice(
+                        finish_reason="length",
+                        index=0,
+                        message=ChatCompletionMessage(role="assistant", content="A response from the model"),
+                    )
+                ],
+                created=1234567890,
+            )
+
+            builder = PromptBuilder("{{query}}")
+            tool = ComponentTool(component=builder)
+            pipeline = Pipeline()
+            pipeline.add_component("llm", OpenAIChatGenerator(model="gpt-4o-mini"))
+            result = pipeline.run({"llm": {"messages": [ChatMessage.from_user(text="Hello")], "tools": [tool]}})
+
+        assert result["llm"]["replies"][0].text == "A response from the model"