Repository: deepset-ai/haystack

Issue:
deserialize_callable does not work with all fully qualified import paths
**Describe the bug**
When I try to import from a fully qualified import path where not all sub_directories are packages, the import fails.

Imagine the following directory structure:
```
src/
└── package_name/
    ├── __init__.py
    └── sub_directory/
        └── python_file.py
```

Using `deserialize_callable("package_name.sub_directory.python_file.my_function)` does not work.


**Error message**
Error that was thrown (if available)

**Expected behavior**
A clear and concise description of what you expected to happen.

**Additional context**
Add any other context about the problem here, like document types / preprocessing steps / settings of reader etc.

**To Reproduce**
Steps to reproduce the behavior

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

**System:**
 - OS:
 - GPU/CPU:
 - Haystack version (commit or version number):
 - DocumentStore:
 - Reader:
 - Retriever:

Hints from issue comments:

Solution patch:
diff --git a/haystack/components/preprocessors/sentence_tokenizer.py b/haystack/components/preprocessors/sentence_tokenizer.py
index 9619b851fc..2cb77347d3 100644
--- a/haystack/components/preprocessors/sentence_tokenizer.py
+++ b/haystack/components/preprocessors/sentence_tokenizer.py
@@ -9,7 +9,7 @@
 from haystack import logging
 from haystack.lazy_imports import LazyImport
 
-with LazyImport("Run 'pip install nltk'") as nltk_imports:
+with LazyImport("Run 'pip install nltk>=3.9.1'") as nltk_imports:
     import nltk
 
 logger = logging.getLogger(__name__)
diff --git a/haystack/dataclasses/answer.py b/haystack/dataclasses/answer.py
index edf0092f55..4ba447f8b4 100644
--- a/haystack/dataclasses/answer.py
+++ b/haystack/dataclasses/answer.py
@@ -3,6 +3,7 @@
 # SPDX-License-Identifier: Apache-2.0
 
 import io
+import warnings
 from dataclasses import asdict, dataclass, field
 from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
 
@@ -98,6 +99,10 @@ class ExtractedTableAnswer:
     context_cells: List["Cell"] = field(default_factory=list)
     meta: Dict[str, Any] = field(default_factory=dict)
 
+    def __post_init__(self):
+        msg = "The `ExtractedTableAnswer` dataclass is deprecated and will be removed in Haystack 2.11.0."
+        warnings.warn(msg, DeprecationWarning)
+
     @dataclass
     class Cell:
         row: int
diff --git a/haystack/dataclasses/document.py b/haystack/dataclasses/document.py
index aed3596411..e29effccd3 100644
--- a/haystack/dataclasses/document.py
+++ b/haystack/dataclasses/document.py
@@ -4,6 +4,7 @@
 
 import hashlib
 import io
+import warnings
 from dataclasses import asdict, dataclass, field, fields
 from typing import Any, Dict, List, Optional
 
@@ -114,6 +115,10 @@ def __post_init__(self):
         # Generate an id only if not explicitly set
         self.id = self.id or self._create_id()
 
+        if self.dataframe is not None:
+            msg = "The `dataframe` field is deprecated and will be removed in Haystack 2.11.0."
+            warnings.warn(msg, DeprecationWarning)
+
     def _create_id(self):
         """
         Creates a hash of the given content that acts as the document's ID.
diff --git a/haystack/testing/callable_serialization/random_callable.py b/haystack/testing/callable_serialization/random_callable.py
new file mode 100644
index 0000000000..42702ec7e2
--- /dev/null
+++ b/haystack/testing/callable_serialization/random_callable.py
@@ -0,0 +1,10 @@
+# SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
+#
+# SPDX-License-Identifier: Apache-2.0
+
+
+def callable_to_deserialize(hello: str) -> str:
+    """
+    A function to test callable deserialization.
+    """
+    return f"{hello}, world!"
diff --git a/haystack/utils/callable_serialization.py b/haystack/utils/callable_serialization.py
index 3e4f947e8c..a0f51de048 100644
--- a/haystack/utils/callable_serialization.py
+++ b/haystack/utils/callable_serialization.py
@@ -3,7 +3,7 @@
 # SPDX-License-Identifier: Apache-2.0
 
 import inspect
-from typing import Callable
+from typing import Any, Callable
 
 from haystack.core.errors import DeserializationError, SerializationError
 from haystack.utils.type_serialization import thread_safe_import
@@ -50,26 +50,31 @@ def deserialize_callable(callable_handle: str) -> Callable:
     :return: The callable
     :raises DeserializationError: If the callable cannot be found
     """
-    module_name, *attribute_chain = callable_handle.split(".")
+    parts = callable_handle.split(".")
 
-    try:
-        current = thread_safe_import(module_name)
-    except Exception as e:
-        raise DeserializationError(f"Could not locate the module: {module_name}") from e
-
-    for attr in attribute_chain:
+    for i in range(len(parts), 0, -1):
+        module_name = ".".join(parts[:i])
         try:
-            attr_value = getattr(current, attr)
-        except AttributeError as e:
-            raise DeserializationError(f"Could not find attribute '{attr}' in {current.__name__}") from e
+            mod: Any = thread_safe_import(module_name)
+        except Exception:
+            # keep reducing i until we find a valid module import
+            continue
+
+        attr_value = mod
+        for part in parts[i:]:
+            try:
+                attr_value = getattr(attr_value, part)
+            except AttributeError as e:
+                raise DeserializationError(f"Could not find attribute '{part}' in {attr_value.__name__}") from e
 
         # when the attribute is a classmethod, we need the underlying function
         if isinstance(attr_value, (classmethod, staticmethod)):
             attr_value = attr_value.__func__
 
-        current = attr_value
+        if not callable(attr_value):
+            raise DeserializationError(f"The final attribute is not callable: {attr_value}")
 
-    if not callable(current):
-        raise DeserializationError(f"The final attribute is not callable: {current}")
+        return attr_value
 
-    return current
+    # Fallback if we never find anything
+    raise DeserializationError(f"Could not import '{callable_handle}' as a module or callable.")
diff --git a/pyproject.toml b/pyproject.toml
index 1606df3fbf..9a5f15070b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -112,7 +112,7 @@ extra-dependencies = [
   "openpyxl",                         # XLSXToDocument
   "tabulate",                         # XLSXToDocument
 
-  "nltk", # NLTKDocumentSplitter
+  "nltk>=3.9.1", # NLTKDocumentSplitter
 
   # OpenAPI
   "jsonref",  # OpenAPIServiceConnector, OpenAPIServiceToFunctions
diff --git a/releasenotes/notes/deprecate-dataframe-ExtractedTableAnswer-3d85649a4a7e222f.yaml b/releasenotes/notes/deprecate-dataframe-ExtractedTableAnswer-3d85649a4a7e222f.yaml
new file mode 100644
index 0000000000..25318985f1
--- /dev/null
+++ b/releasenotes/notes/deprecate-dataframe-ExtractedTableAnswer-3d85649a4a7e222f.yaml
@@ -0,0 +1,6 @@
+---
+deprecations:
+  - |
+    The `ExtractedTableAnswer` dataclass and the `dataframe` field in the `Document` dataclass are deprecated and
+    will be removed in Haystack 2.11.0.
+    Check out the GitHub discussion for motivation and details: https://github.com/deepset-ai/haystack/discussions/8688
diff --git a/releasenotes/notes/fix-callable-deserialization-5a3ef204a8d07616.yaml b/releasenotes/notes/fix-callable-deserialization-5a3ef204a8d07616.yaml
new file mode 100644
index 0000000000..0993428fad
--- /dev/null
+++ b/releasenotes/notes/fix-callable-deserialization-5a3ef204a8d07616.yaml
@@ -0,0 +1,4 @@
+---
+fixes:
+  - |
+    Callable deserialization now works for all fully qualified import paths.


Test patch:
diff --git a/test/utils/test_callable_serialization.py b/test/utils/test_callable_serialization.py
index 4f75ddd0ad..2176d8c742 100644
--- a/test/utils/test_callable_serialization.py
+++ b/test/utils/test_callable_serialization.py
@@ -1,10 +1,12 @@
 # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
 #
 # SPDX-License-Identifier: Apache-2.0
+
 import pytest
 import requests
 from haystack.core.errors import DeserializationError, SerializationError
 from haystack.components.generators.utils import print_streaming_chunk
+from haystack.testing.callable_serialization.random_callable import callable_to_deserialize
 from haystack.utils import serialize_callable, deserialize_callable
 
 
@@ -40,6 +42,13 @@ def test_callable_serialization_non_local():
     assert result == "requests.api.get"
 
 
+def test_fully_qualified_import_deserialization():
+    func = deserialize_callable("haystack.testing.callable_serialization.random_callable.callable_to_deserialize")
+
+    assert func is callable_to_deserialize
+    assert func("Hello") == "Hello, world!"
+
+
 def test_callable_serialization_instance_methods_fail():
     with pytest.raises(SerializationError):
         serialize_callable(TestClass.my_method)