feat: Updates to make Haystack compatible with python 3.14 (#10634)

* start adding python 3.14 support in tests

* start updating tests

* Add reno

* Updates

* Collect tests that should be skipped in python 3.14+

* run tests using python 3.14

* Revert back to 3.10 for testing

* Fix flaky tests

* Skip only spacy tests if python 3.14+

* switch to get_running_loop
This commit is contained in:
Sebastian Husch Lee
2026-03-16 09:22:14 +00:00
committed by GitHub
parent 84930a2728
commit b970a64b77
9 changed files with 186 additions and 90 deletions
+14 -1
View File
@@ -7,10 +7,22 @@
# test environment. Spacy is not installed in the test environment to keep the CI fast.
import os
import sys
import pytest
from haystack import Document, Pipeline
from haystack.components.extractors import NamedEntityAnnotation, NamedEntityExtractor, NamedEntityExtractorBackend
from haystack.components.extractors import (
NamedEntityAnnotation,
NamedEntityExtractor,
NamedEntityExtractorBackend,
)
# spacy uses pydantic.v1 which is not compatible with Python 3.14 (PEP 649 deferred annotations)
skip_spacy = pytest.mark.skipif(
sys.version_info >= (3, 14),
reason="spacy/pydantic.v1 not compatible with Python 3.14",
)
@pytest.fixture
@@ -78,6 +90,7 @@ def test_ner_extractor_hf_backend_private_models(raw_texts, hf_annotations, batc
@pytest.mark.parametrize("batch_size", [1, 3])
@skip_spacy
def test_ner_extractor_spacy_backend(raw_texts, spacy_annotations, batch_size) -> None:
extractor = NamedEntityExtractor(backend=NamedEntityExtractorBackend.SPACY, model="en_core_web_trf")
extractor.warm_up()
@@ -38,6 +38,11 @@ def _is_valid_type(obj: Any) -> bool:
if (origin := get_origin(obj)) and _is_union_type(origin):
return True
# Bare Union type (without parameters) is not a valid type annotation
# Previously handled by inspect.isclass(obj) but in python 3.14 this returns True for typing.Union
if obj == Union:
return False
# Handle normal classes and generic types
return inspect.isclass(obj) or type(obj).__name__ in {"_GenericAlias", "GenericAlias"}
+5 -1
View File
@@ -90,7 +90,7 @@ def _contains_type(container: Any, target: Any) -> bool:
return _safe_get_origin(container) is Union and target in get_args(container)
def _strict_types_are_compatible(sender: Any, receiver: Any) -> bool:
def _strict_types_are_compatible(sender: Any, receiver: Any) -> bool: # noqa: PLR0911
"""
Checks whether the sender type is equal to or a subtype of the receiver type under strict validation.
@@ -116,6 +116,10 @@ def _strict_types_are_compatible(sender: Any, receiver: Any) -> bool:
sender_origin = _safe_get_origin(sender)
receiver_origin = _safe_get_origin(receiver)
# Special case to reject bare-Union types
if (sender_origin is Union and not get_args(sender)) or (receiver_origin is Union and not get_args(receiver)):
return False
if sender_origin is not Union and receiver_origin is Union:
return any(_strict_types_are_compatible(sender, union_arg) for union_arg in get_args(receiver))
@@ -737,7 +737,7 @@ class InMemoryDocumentStore:
:param filters: The filters to apply to the document list.
:returns: A list of Documents that match the given filters.
"""
return await asyncio.get_event_loop().run_in_executor(
return await asyncio.get_running_loop().run_in_executor(
self.executor, lambda: self.filter_documents(filters=filters)
)
@@ -749,7 +749,7 @@ class InMemoryDocumentStore:
If `policy` is set to `DuplicatePolicy.NONE` defaults to `DuplicatePolicy.FAIL`.
"""
return await asyncio.get_event_loop().run_in_executor(
return await asyncio.get_running_loop().run_in_executor(
self.executor, lambda: self.write_documents(documents=documents, policy=policy)
)
@@ -759,7 +759,7 @@ class InMemoryDocumentStore:
:param document_ids: The object_ids to delete.
"""
await asyncio.get_event_loop().run_in_executor(
await asyncio.get_running_loop().run_in_executor(
self.executor, lambda: self.delete_documents(document_ids=document_ids)
)
@@ -775,7 +775,7 @@ class InMemoryDocumentStore:
:param scale_score: Whether to scale the scores of the retrieved documents. Default is False.
:returns: A list of the top_k documents most relevant to the query.
"""
return await asyncio.get_event_loop().run_in_executor(
return await asyncio.get_running_loop().run_in_executor(
self.executor,
lambda: self.bm25_retrieval(query=query, filters=filters, top_k=top_k, scale_score=scale_score),
)
@@ -798,7 +798,7 @@ class InMemoryDocumentStore:
:param return_embedding: Whether to return the embedding of the retrieved Documents. Default is False.
:returns: A list of the top_k documents most relevant to the query.
"""
return await asyncio.get_event_loop().run_in_executor(
return await asyncio.get_running_loop().run_in_executor(
self.executor,
lambda: self.embedding_retrieval(
query_embedding=query_embedding,
+2 -1
View File
@@ -38,6 +38,7 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
dependencies = [
@@ -168,7 +169,7 @@ types = "mypy --install-types --non-interactive --cache-dir=.mypy_cache/ {args:h
template = "test"
extra-dependencies = [
# NamedEntityExtractor
"spacy>=3.8,<3.9",
"spacy>=3.8,<3.9; python_version < '3.14'",
"en-core-web-trf @ https://github.com/explosion/spacy-models/releases/download/en_core_web_trf-3.8.0/en_core_web_trf-3.8.0-py3-none-any.whl",
]
@@ -0,0 +1,5 @@
---
enhancements:
- |
Add support for python 3.14 to Haystack. Haystack already mostly supported python 3.14.
Only minor changes were needed in regards to our type serialization and type checking when handling bare Union types.
+63 -16
View File
@@ -1126,7 +1126,7 @@ def pipeline_that_has_multiple_branches_of_different_lengths_that_merge_into_a_c
("first_addition", 1): {"add": None, "value": 1},
("fourth_addition", 1): {"add": None, "value": 11},
("second_addition", 1): {"add": None, "value": 3},
("sum", 1): {"values": [3, 3, 5]},
("sum", 1): {"values": AnyOrder([3, 3, 5])},
("third_addition", 1): {"add": None, "value": 1},
},
)
@@ -2841,11 +2841,12 @@ def that_has_variadic_component_that_receives_a_conditional_input(pipeline_class
class AnyOrder: # noqa: PLW1641 # Object does not implement `__hash__` method but it's ok
"""List wrapper with order-insensitive equality"""
def __init__(self, items):
def __init__(self, items, key=None):
self.items = items
self.key = key
def __eq__(self, other):
return isinstance(other, list) and sorted(self.items) == sorted(other)
return isinstance(other, list) and sorted(self.items, key=self.key) == sorted(other, key=self.key)
@given("a pipeline that has a string variadic component", target_fixture="pipeline_data")
@@ -4416,11 +4417,16 @@ some,header,row
("txt_converter", 1): {"sources": [sources[1]], "meta": None},
("json_converter", 1): {"sources": [sources[2]], "meta": None},
("b_joiner", 1): {
"documents": [[expected_pre_split_docs[0]], [expected_pre_split_docs[1]]],
"documents": AnyOrder(
[[expected_pre_split_docs[0]], [expected_pre_split_docs[1]]], key=lambda d: d[0].id
),
"top_k": None,
},
("splitter", 1): {"documents": expected_pre_split_docs},
("a_joiner", 1): {"documents": [expected_csv_docs, expected_splits_docs], "top_k": None},
("a_joiner", 1): {
"documents": AnyOrder([expected_csv_docs, expected_splits_docs], key=lambda d: d[0].id),
"top_k": None,
},
},
)
],
@@ -4471,26 +4477,57 @@ def pipeline_that_converts_files_with_three_joiners(pipeline_class):
pipe.connect("page_splitter.documents", "DocumentJoiner_2.documents")
expected_html_doc = Document(content="Some content", meta={"file_type": "html"})
# Need to use ANY for id since not sure how it's calculated in splitter
expected_html_doc_aft_splitter = Document(
id=ANY,
content="Some content",
meta={
"file_type": "html",
"page_number": 1,
"split_id": 0,
"split_idx_start": 0,
"source_id": expected_html_doc.id,
},
)
expected_txt_doc = Document(content=txt_data, meta={"file_type": "txt"})
# Need to use ANY for id since not sure how it's calculated in splitter
expected_txt_doc_aft_splitter = Document(
id=ANY,
content=txt_data,
meta={
"file_type": "txt",
"page_number": 1,
"split_id": 0,
"split_idx_start": 0,
"source_id": expected_txt_doc.id,
},
)
return (
pipe,
[
PipelineRunData(
inputs={"router": {"sources": sources}},
# HTML converter takes longer than TXT Converter and this is why the order of documents is not stable
# for AsyncPipeline. We use [ANY, ANY] to indicate that we do not care about the order here.
# In real usage, if the user cares about the order of documents arriving at a lazy variadic component,
# the user should pick a different component (OutputAdapter) to combine the lists.
expected_outputs={"DocumentJoiner_2": {"documents": [ANY, ANY]}},
expected_outputs={
"DocumentJoiner_2": {
"documents": AnyOrder(
[expected_html_doc_aft_splitter, expected_txt_doc_aft_splitter], key=lambda d: d.content
)
}
},
expected_component_calls={
("router", 1): {"sources": sources, "meta": None},
("html_converter", 1): {"sources": [sources[1]], "meta": None, "extraction_kwargs": None},
("txt_converter", 1): {"sources": [sources[0]], "meta": None},
("joiner", 1): {"documents": [[expected_txt_doc]], "top_k": None},
("DocumentJoiner_1", 1): {"documents": [[expected_html_doc]], "top_k": None},
# Same as above
("DocumentJoiner_2", 1): {"documents": [ANY, ANY], "top_k": None},
("DocumentJoiner_2", 1): {
"documents": AnyOrder(
[[expected_html_doc_aft_splitter], [expected_txt_doc_aft_splitter]],
key=lambda d: d[0].content,
),
"top_k": None,
},
("splitter", 1): {"documents": [expected_txt_doc]},
("page_splitter", 1): {"documents": [expected_html_doc]},
},
@@ -4898,6 +4935,17 @@ def pipeline_that_converts_files_with_three_auto_joiners(pipeline_class):
pipe.connect("splitter.documents", "writer.documents")
pipe.connect("page_splitter.documents", "writer.documents")
expected_txt_document = Document(
id=ANY,
content=txt_data,
meta={"file_type": "txt", "source_id": ANY, "page_number": 1, "split_id": 0, "split_idx_start": 0},
)
expected_html_document = Document(
id=ANY,
content="Some content",
meta={"file_type": "html", "source_id": ANY, "page_number": 1, "split_id": 0, "split_idx_start": 0},
)
return (
pipe,
[
@@ -4908,10 +4956,9 @@ def pipeline_that_converts_files_with_three_auto_joiners(pipeline_class):
("router", 1): {"sources": sources, "meta": None},
("html_converter", 1): {"sources": [sources[1]], "meta": None, "extraction_kwargs": None},
("txt_converter", 1): {"sources": [sources[0]], "meta": None},
# HTML converter takes longer than TXT Converter and this is why the order of documents is not
# stable for AsyncPipeline. We test for [ANY, ANY] here to ensure at least two documents are
# present.
("writer", 1): {"documents": [ANY, ANY]},
("writer", 1): {
"documents": AnyOrder([expected_txt_document, expected_html_document], key=lambda d: d.content)
},
("splitter", 1): {"documents": [Document(content=txt_data, meta={"file_type": "txt"})]},
("page_splitter", 1): {"documents": [Document(content="Some content", meta={"file_type": "html"})]},
},
+58 -44
View File
@@ -2,6 +2,7 @@
#
# SPDX-License-Identifier: Apache-2.0
import sys
from enum import Enum
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Literal, Mapping, Optional, Sequence, Set, Tuple, Union
@@ -363,34 +364,10 @@ asymmetric_cases = generate_strict_asymmetric_cases()
pytest.param(str, "str", id="primitive-types"),
pytest.param(Any, "Any", id="any"),
pytest.param(Class1, "Class1", id="class"),
pytest.param((Optional[int]), "Optional[int]", id="shallow-optional-with-primitive"),
pytest.param((Optional[Any]), "Optional[Any]", id="shallow-optional-with-any"),
pytest.param((Optional[Class1]), "Optional[Class1]", id="shallow-optional-with-class"),
pytest.param((Union[(bool, Class1)]), "Union[bool, Class1]", id="shallow-union"),
pytest.param((List[str]), "List[str]", id="shallow-sequence-of-primitives"),
pytest.param((list[str]), "list[str]", id="shallow-sequence-of-primitives"),
pytest.param((List[Set[Sequence[str]]]), "List[Set[Sequence[str]]]", id="nested-sequence-of-primitives"),
pytest.param((list[set[Sequence[str]]]), "list[set[Sequence[str]]]", id="nested-sequence-of-primitives"),
pytest.param(
(Optional[List[Set[Sequence[str]]]]),
"Optional[List[Set[Sequence[str]]]]",
id="optional-nested-sequence-of-primitives",
),
pytest.param(
(Optional[list[set[Sequence[str]]]]),
"Optional[list[set[Sequence[str]]]]",
id="optional-nested-sequence-of-primitives",
),
pytest.param(
(List[Set[Sequence[Optional[str]]]]),
"List[Set[Sequence[Optional[str]]]]",
id="nested-optional-sequence-of-primitives",
),
pytest.param(
(list[set[Sequence[Optional[str]]]]),
"list[set[Sequence[Optional[str]]]]",
id="nested-optional-sequence-of-primitives",
),
pytest.param((List[Class1]), "List[Class1]", id="shallow-sequence-of-classes"),
pytest.param((list[Class1]), "list[Class1]", id="shallow-sequence-of-classes"),
pytest.param((List[Set[Sequence[Class1]]]), "List[Set[Sequence[Class1]]]", id="nested-sequence-of-classes"),
@@ -432,16 +409,6 @@ asymmetric_cases = generate_strict_asymmetric_cases()
pytest.param((Literal["a", "b", "c"]), "Literal['a', 'b', 'c']", id="string-literal"),
pytest.param((Literal[1, 2, 3]), "Literal[1, 2, 3]", id="primitive-literal"),
pytest.param((Literal[Enum1.TEST1]), "Literal[Enum1.TEST1]", id="enum-literal"),
pytest.param(
(Tuple[(Optional[Literal["a", "b", "c"]], Union[(Path, Dict[(int, Class1)])])]),
"Tuple[Optional[Literal['a', 'b', 'c']], Union[Path, Dict[int, Class1]]]",
id="deeply-nested-complex-type",
),
pytest.param(
(tuple[(Optional[Literal["a", "b", "c"]], Union[(Path, dict[(int, Class1)])])]),
"tuple[Optional[Literal['a', 'b', 'c']], Union[Path, dict[int, Class1]]]",
id="deeply-nested-complex-type",
),
pytest.param((int | str), "int | str", id="pep604-union"),
pytest.param((int | None), "int | None", id="pep604-optional"),
pytest.param((list[int] | None), "list[int] | None", id="pep604-optional-list"),
@@ -455,6 +422,52 @@ def test_type_name(type_, repr_):
assert _type_name(type_) == repr_
if sys.version_info < (3, 14):
@pytest.mark.parametrize(
"type_,repr_",
[
pytest.param((Union[(bool, Class1)]), "Union[bool, Class1]", id="shallow-union"),
pytest.param((Optional[int]), "Optional[int]", id="shallow-optional-with-primitive"),
pytest.param((Optional[Any]), "Optional[Any]", id="shallow-optional-with-any"),
pytest.param((Optional[Class1]), "Optional[Class1]", id="shallow-optional-with-class"),
pytest.param(
(Tuple[(Optional[Literal["a", "b", "c"]], Union[(Path, Dict[(int, Class1)])])]),
"Tuple[Optional[Literal['a', 'b', 'c']], Union[Path, Dict[int, Class1]]]",
id="deeply-nested-complex-type",
),
pytest.param(
(tuple[(Optional[Literal["a", "b", "c"]], Union[(Path, dict[(int, Class1)])])]),
"tuple[Optional[Literal['a', 'b', 'c']], Union[Path, dict[int, Class1]]]",
id="deeply-nested-complex-type",
),
pytest.param(
(Optional[List[Set[Sequence[str]]]]),
"Optional[List[Set[Sequence[str]]]]",
id="optional-nested-sequence-of-primitives",
),
pytest.param(
(Optional[list[set[Sequence[str]]]]),
"Optional[list[set[Sequence[str]]]]",
id="optional-nested-sequence-of-primitives",
),
pytest.param(
(List[Set[Sequence[Optional[str]]]]),
"List[Set[Sequence[Optional[str]]]]",
id="nested-optional-sequence-of-primitives",
),
pytest.param(
(list[set[Sequence[Optional[str]]]]),
"list[set[Sequence[Optional[str]]]]",
id="nested-optional-sequence-of-primitives",
),
],
)
def test_type_name_union_and_optional(type_, repr_):
"""Tests for old typing.Union and typing.Optional types that are converted to builtins in python 3.14+."""
assert _type_name(type_) == repr_
@pytest.mark.parametrize("sender_type, receiver_type", symmetric_cases)
def test_same_types_are_compatible_strict(sender_type, receiver_type):
assert _types_are_compatible(sender_type, receiver_type)[0]
@@ -842,22 +855,23 @@ asymmetric_cases_pep_604 = generate_strict_asymmetric_cases_pep_604()
"list[set[Sequence[str]]] | None",
id="optional-nested-sequence-of-primitives",
),
pytest.param(
(list[set[Sequence[str | None]]]),
"list[set[Sequence[Optional[str]]]]",
id="nested-optional-sequence-of-primitives",
),
pytest.param(
(tuple[Literal["a", "b", "c"] | None, Path | dict[int, Class1]]),
"tuple[Optional[Literal['a', 'b', 'c']], Path | dict[int, Class1]]",
id="deeply-nested-complex-type",
),
],
)
def test_type_name_pep_604(type_, repr_):
assert _type_name(type_) == repr_
def test_type_name_pep_604_explicit():
"""Sometimes the internal str | None can be treated as an Optional instead of a UnionType"""
type_name = _type_name(list[set[Sequence[str | None]]])
assert type_name == "list[set[Sequence[str | None]]]" or type_name == "list[set[Sequence[Optional[str]]]]"
type_name = _type_name(tuple[Literal["a", "b", "c"] | None, Path | dict[int, Class1]])
assert (
type_name == "tuple[Literal['a', 'b', 'c'] | None, Path | dict[int, Class1]]"
or type_name == "tuple[Optional[Literal['a', 'b', 'c']], Path | dict[int, Class1]]"
)
@pytest.mark.parametrize("sender_type, receiver_type", symmetric_cases_pep_604)
def test_same_types_are_compatible_strict_pep_604(sender_type, receiver_type):
assert _types_are_compatible(sender_type, receiver_type)[0]
+29 -22
View File
@@ -2,6 +2,7 @@
#
# SPDX-License-Identifier: Apache-2.0
import sys
import typing
from collections import deque
from types import UnionType
@@ -47,13 +48,6 @@ TYPING_AND_TYPE_TESTS = [
pytest.param("typing.List[dict]", List[dict]),
pytest.param("typing.List[float]", List[float]),
pytest.param("typing.List[bool]", List[bool]),
# typing Optional
pytest.param("typing.Optional", Optional),
pytest.param("typing.Optional[str]", Optional[str]),
pytest.param("typing.Optional[int]", Optional[int]),
pytest.param("typing.Optional[dict]", Optional[dict]),
pytest.param("typing.Optional[float]", Optional[float]),
pytest.param("typing.Optional[bool]", Optional[bool]),
# PEP 604 X | None
pytest.param("str | None", str | None),
pytest.param("int | None", int | None),
@@ -90,13 +84,6 @@ TYPING_AND_TYPE_TESTS = [
pytest.param("typing.Tuple[dict]", Tuple[dict]),
pytest.param("typing.Tuple[float]", Tuple[float]),
pytest.param("typing.Tuple[bool]", Tuple[bool]),
# Union
pytest.param("typing.Union", Union),
pytest.param("typing.Union[str, int]", Union[str, int]),
pytest.param("typing.Union[int, float]", Union[int, float]),
pytest.param("typing.Union[dict, str]", Union[dict, str]),
pytest.param("typing.Union[float, bool]", Union[float, bool]),
pytest.param("typing.Optional[str]", typing.Union[None, str]), # Union with None becomes Optional
# PEP 604 X | Y
pytest.param("str | int", str | int),
pytest.param("int | float", int | float),
@@ -174,13 +161,10 @@ def test_output_builtin_type_deserialization():
def test_output_type_serialization_nested():
# typing
assert serialize_type(List[Union[str, int]]) == "typing.List[typing.Union[str, int]]"
assert serialize_type(List[Optional[str]]) == "typing.List[typing.Optional[str]]"
assert serialize_type(List[Dict[str, int]]) == "typing.List[typing.Dict[str, int]]"
assert serialize_type(typing.List[Dict[str, int]]) == "typing.List[typing.Dict[str, int]]"
# builtins
assert serialize_type(list[Union[str, int]]) == "list[typing.Union[str, int]]"
assert serialize_type(list[Optional[str]]) == "list[typing.Optional[str]]"
assert serialize_type(list[dict[str, int]]) == "list[dict[str, int]]"
assert serialize_type(list[list[int]]) == "list[list[int]]"
assert serialize_type(list[list[list[int]]]) == "list[list[list[int]]]"
@@ -291,10 +275,6 @@ def test_output_type_deserialization_haystack_dataclasses():
def test_output_type_serialization_pep_604():
# PEP 604 allows for union types to be defined with the `|` operator
assert serialize_type(str | int) == "str | int"
assert serialize_type(List[str] | List[int]) == "typing.Union[typing.List[str], typing.List[int]]"
assert (
serialize_type(Dict[str, int] | Dict[int, str]) == "typing.Union[typing.Dict[str, int], typing.Dict[int, str]]"
)
assert serialize_type(str | None) == "str | None"
assert serialize_type(list[str] | None) == "list[str] | None"
assert serialize_type(int | float | str) == "int | float | str"
@@ -370,3 +350,30 @@ def test_build_pep604_union_type():
result = _build_pep604_union_type([list[str], dict[str, int]])
assert result == list[str] | dict[str, int]
if sys.version_info < (3, 14):
def test_type_de_se_union_and_optional():
"""Tests for old typing.Union and typing.Optional types that are converted to builtins in python 3.14+."""
assert serialize_type(List[Union[str, int]]) == "typing.List[typing.Union[str, int]]"
assert serialize_type(List[str] | List[int]) == "typing.Union[typing.List[str], typing.List[int]]"
assert serialize_type(List[Optional[str]]) == "typing.List[typing.Optional[str]]"
assert (
serialize_type(Dict[str, int] | Dict[int, str])
== "typing.Union[typing.Dict[str, int], typing.Dict[int, str]]"
)
assert serialize_type(list[Union[str, int]]) == "list[typing.Union[str, int]]"
assert serialize_type(list[Optional[str]]) == "list[typing.Optional[str]]"
# Union
assert serialize_type(Union) == "typing.Union"
assert serialize_type(Union[str, int]) == "typing.Union[str, int]"
assert serialize_type(Union[int, float]) == "typing.Union[int, float]"
assert serialize_type(Union[dict, str]) == "typing.Union[dict, str]"
assert serialize_type(Union[float, bool]) == "typing.Union[float, bool]"
assert serialize_type(Optional) == "typing.Optional"
assert serialize_type(Optional[str]) == "typing.Optional[str]"
assert serialize_type(Optional[int]) == "typing.Optional[int]"
assert serialize_type(Optional[dict]) == "typing.Optional[dict]"
assert serialize_type(Optional[float]) == "typing.Optional[float]"
assert serialize_type(Optional[bool]) == "typing.Optional[bool]"