diff --git a/.github/workflows/branch_off.yml b/.github/workflows/branch_off.yml index 29255ec4ad..24f0019538 100644 --- a/.github/workflows/branch_off.yml +++ b/.github/workflows/branch_off.yml @@ -7,7 +7,7 @@ on: description: 'URL of the bump version PR' value: ${{ jobs.branch-off.outputs.bump_version_pr_url }} env: - PYTHON_VERSION: "3.10" + PYTHON_VERSION: "3.11" permissions: contents: read diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 04492b7cbb..3d6a5bd4c4 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -14,7 +14,7 @@ on: - ".github/workflows/e2e.yml" env: - PYTHON_VERSION: "3.10" + PYTHON_VERSION: "3.11" OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} HATCH_VERSION: "1.16.5" diff --git a/.github/workflows/license_compliance.yml b/.github/workflows/license_compliance.yml index f071017ca0..6c84267dd2 100644 --- a/.github/workflows/license_compliance.yml +++ b/.github/workflows/license_compliance.yml @@ -10,7 +10,7 @@ on: schedule: - cron: "0 0 * * *" # every day at midnight env: - PYTHON_VERSION: "3.10" + PYTHON_VERSION: "3.11" permissions: contents: read @@ -47,9 +47,7 @@ jobs: fail: "Copyleft,Other,Error" # Exclusions in the vanilla distribution must be explicitly motivated # - tqdm is MLP but there are no better alternatives - # - typing_extensions>=4.13.0 has a Python Software Foundation License 2.0 but pip-license-checker does not recognize it - # (https://github.com/pilosus/pip-license-checker/issues/143) - exclude: "(?i)^(tqdm|typing_extensions).*" + exclude: "(?i)^(tqdm).*" # We keep the license inventory on FOSSA - name: Send license report to Fossa diff --git a/.github/workflows/promote_unstable_docs.yml b/.github/workflows/promote_unstable_docs.yml index c2b040b19b..72d6ba755b 100644 --- a/.github/workflows/promote_unstable_docs.yml +++ b/.github/workflows/promote_unstable_docs.yml @@ -9,7 +9,7 @@ on: - "!v1.[0-9]+.[0-9]+" env: - PYTHON_VERSION: "3.10" + PYTHON_VERSION: "3.11" permissions: contents: read diff --git a/.github/workflows/release_notes.yml b/.github/workflows/release_notes.yml index bdede41695..26a38e7177 100644 --- a/.github/workflows/release_notes.yml +++ b/.github/workflows/release_notes.yml @@ -22,7 +22,7 @@ jobs: reno: runs-on: ubuntu-slim env: - PYTHON_VERSION: "3.10" + PYTHON_VERSION: "3.11" steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/slow.yml b/.github/workflows/slow.yml index a4922e80a3..74878271d5 100644 --- a/.github/workflows/slow.yml +++ b/.github/workflows/slow.yml @@ -11,7 +11,7 @@ name: Slow Integration Tests env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PYTHON_VERSION: "3.10" + PYTHON_VERSION: "3.11" HATCH_VERSION: "1.16.5" HAYSTACK_MPS_ENABLED: false HAYSTACK_XPU_ENABLED: false diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index cfb2976638..c684c36bb2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,7 +29,7 @@ env: OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }} AZURE_OPENAI_ENDPOINT: ${{ secrets.AZURE_OPENAI_ENDPOINT }} - PYTHON_VERSION: "3.10" + PYTHON_VERSION: "3.11" # numpy's stubs require a 3.12+ interpreter, so type checking runs above the floor. # See [tool.mypy] in pyproject.toml. MYPY_PYTHON_VERSION: "3.12" diff --git a/AGENTS.md b/AGENTS.md index 7da20a2e2f..fcd9217215 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ hatch run test:integration ### Type checking with mypy hatch run test:types -Type checking targets Python 3.12, above the 3.10 floor in `requires-python`, because numpy's stubs use PEP 695 `type` statements that mypy rejects below 3.12. So mypy will not catch code that breaks on 3.10; the unit test matrix runs on 3.10 and covers that. +Type checking targets Python 3.12, above the 3.11 floor in `requires-python`, because numpy's stubs use PEP 695 `type` statements that mypy rejects below 3.12. So mypy will not catch code that breaks on 3.11; the unit test matrix runs on 3.11 and covers that. To fix type issues, avoid `type: ignore`, casts, or assertions when possible. If they are necessary, explain why. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36a94bf5bd..5eae8dc604 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -188,7 +188,9 @@ This helps maintainers and keeps the project ready for both human and AI contrib ### Setting up your development environment -*To run Haystack tests locally, ensure your development environment uses Python >=3.10 and <3.14.* +*To run Haystack tests locally, ensure your development environment uses Python >=3.11 and <3.16.* +PyTorch does not publish Python 3.15 wheels on PyPI yet, so on 3.15 create the test environment with +`UV_EXTRA_INDEX_URL=https://download.pytorch.org/whl/cpu UV_INDEX_STRATEGY=unsafe-best-match hatch env create test`. Haystack makes heavy use of [Hatch](https://hatch.pypa.io/latest/), a Python project manager that we use to set up the virtual environments, build the project, and publish packages. As you can imagine, the first step towards becoming a @@ -288,10 +290,10 @@ To check for static type errors, run: hatch run test:types ``` -Note that type checking targets Python 3.12, even though Haystack supports 3.10 and above. numpy's +Note that type checking targets Python 3.12, even though Haystack supports 3.11 and above. numpy's type stubs use [PEP 695](https://peps.python.org/pep-0695/) `type` statements, which mypy rejects as a syntax error below 3.12. Support for the older versions is covered by the unit test matrix, which -runs on Python 3.10. +runs on Python 3.11. To format your code and perform linting using Ruff (with automatic fixes), run: ```sh diff --git a/haystack/components/converters/image/image_utils.py b/haystack/components/converters/image/image_utils.py index 0242b0202d..80102bf2be 100644 --- a/haystack/components/converters/image/image_utils.py +++ b/haystack/components/converters/image/image_utils.py @@ -7,9 +7,7 @@ from collections import defaultdict from io import BytesIO from pathlib import Path -from typing import TypedDict, Union - -from typing_extensions import NotRequired +from typing import NotRequired, TypedDict, Union from haystack import logging from haystack.dataclasses import ByteStream, Document diff --git a/haystack/components/converters/multi_file_converter.py b/haystack/components/converters/multi_file_converter.py index b8145fc29d..bbeadd3d0a 100644 --- a/haystack/components/converters/multi_file_converter.py +++ b/haystack/components/converters/multi_file_converter.py @@ -22,7 +22,7 @@ from haystack.dataclasses import ByteStream -class ConverterMimeType(str, Enum): +class ConverterMimeType(str, Enum): # noqa: UP042 # StrEnum would change str(), which __init__() parses CSV = "text/csv" DOCX = "application/vnd.openxmlformats-officedocument.wordprocessingml.document" HTML = "text/html" diff --git a/haystack/components/routers/conditional_router.py b/haystack/components/routers/conditional_router.py index 06d838f3b4..2b7bd8c5e1 100644 --- a/haystack/components/routers/conditional_router.py +++ b/haystack/components/routers/conditional_router.py @@ -5,11 +5,10 @@ import ast import contextlib from collections.abc import Callable, Mapping, Sequence -from typing import Any, TypedDict, get_args, get_origin +from typing import Any, NotRequired, TypedDict, get_args, get_origin from jinja2 import Environment, TemplateSyntaxError from jinja2.nativetypes import NativeEnvironment -from typing_extensions import NotRequired from haystack import component, default_from_dict, default_to_dict, logging from haystack.core.errors import DeserializationError diff --git a/haystack/core/pipeline/base.py b/haystack/core/pipeline/base.py index bf6ecaa1b9..aba5caa322 100644 --- a/haystack/core/pipeline/base.py +++ b/haystack/core/pipeline/base.py @@ -10,10 +10,9 @@ from datetime import datetime from enum import IntEnum from pathlib import Path -from typing import Any, TextIO, TypeVar, Union, get_args +from typing import Any, Self, TextIO, TypeVar, Union, get_args import networkx -from typing_extensions import Self from haystack import logging, tracing from haystack.core.component import Component, InputSocket, OutputSocket, component diff --git a/haystack/core/type_utils.py b/haystack/core/type_utils.py index 5a3512e1ee..20f116b872 100644 --- a/haystack/core/type_utils.py +++ b/haystack/core/type_utils.py @@ -62,9 +62,8 @@ def _resolve_parameter_types(target: Callable, *, include_extras: bool = False) # TypeError is raised for objects that cannot carry annotations, NameError for names that are not # importable at runtime. Either way we fall back to the unresolved annotations. hints = {} - # Non-string annotations are kept as they are written: on Python 3.10 `get_type_hints` widens the annotation - # of a parameter defaulting to `None` into an optional. This was changed in Python 3.11, see - # https://docs.python.org/3/whatsnew/3.11.html#typing. + # Non-string annotations are kept as they are written: with `include_extras=False`, `get_type_hints` strips + # `Annotated` metadata, which would drop markers such as `Variadic` from annotations that need no resolving. return { name: hints.get(name, param.annotation) if isinstance(param.annotation, str) else param.annotation for name, param in parameters.items() diff --git a/haystack/dataclasses/chat_message.py b/haystack/dataclasses/chat_message.py index a9e445db38..37e6772980 100644 --- a/haystack/dataclasses/chat_message.py +++ b/haystack/dataclasses/chat_message.py @@ -36,7 +36,7 @@ def _parse_openai_tool_call_arguments(raw_arguments: Any) -> dict[str, Any]: return json.loads(raw_arguments) if raw_arguments else {} -class ChatRole(str, Enum): +class ChatRole(str, Enum): # noqa: UP042 # StrEnum would change str() of this public enum """ Enumeration representing the roles within a chat. """ diff --git a/haystack/utils/filters.py b/haystack/utils/filters.py index 623c78c3b8..695987d954 100644 --- a/haystack/utils/filters.py +++ b/haystack/utils/filters.py @@ -91,8 +91,7 @@ def _parse_iso_date(value: str) -> datetime | None: try: return datetime.fromisoformat(value) except ValueError: - # Python 3.10's fromisoformat rejects valid ISO 8601 spellings that later versions accept, - # most notably a trailing "Z". + # fromisoformat still rejects some valid ISO 8601 spellings, such as "24:00" for end-of-day midnight. try: return dateutil.parser.isoparse(value) except (ValueError, OverflowError): diff --git a/pyproject.toml b/pyproject.toml index 11a606a89e..c1ccb9171f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "LLM framework to build customizable, production-ready LLM applications. Connect components (models, vector DBs, file converters) to pipelines or agents that can interact with your data." readme = "README.md" license = "Apache-2.0" -requires-python = ">=3.10" +requires-python = ">=3.11" authors = [{ name = "deepset.ai", email = "malte.pietsch@deepset.ai" }] keywords = [ "BERT", @@ -34,11 +34,11 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", "Topic :: Scientific/Engineering :: Artificial Intelligence", ] dependencies = [ @@ -53,7 +53,6 @@ dependencies = [ "pyyaml", "more-itertools", # DocumentSplitter "networkx", # Pipeline graphs - "typing_extensions>=4.7", # Extended typing features (NotRequired, etc.) "httpx", "anyio>=4.14.2", # transitive via httpx and openai; floor for CVE-2026-63374 (GHSA-82r6-8w77-94w6) "numpy", @@ -193,9 +192,9 @@ asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "class" [tool.mypy] -# Deliberately above the 3.10 floor in `requires-python`: numpy's stubs use PEP 695 `type` +# Deliberately above the 3.11 floor in `requires-python`: numpy's stubs use PEP 695 `type` # statements, which mypy rejects as a syntax error below 3.12. The floor stays covered by the -# unit test matrix, which runs on 3.10. +# unit test matrix, which runs on 3.11. python_version = "3.12" check_untyped_defs = true disallow_incomplete_defs = true diff --git a/releasenotes/notes/drop-python-310-add-python-315-4ec6b40f210a6d61.yaml b/releasenotes/notes/drop-python-310-add-python-315-4ec6b40f210a6d61.yaml new file mode 100644 index 0000000000..091addec7e --- /dev/null +++ b/releasenotes/notes/drop-python-310-add-python-315-4ec6b40f210a6d61.yaml @@ -0,0 +1,9 @@ +--- +upgrade: + - | + Dropped support for Python 3.10 since it reaches End of Life (EOL) in October 2026. + The minimum supported Python version is now 3.11. + If you are on Python 3.10, upgrade your Python version before upgrading Haystack. +enhancements: + - | + Haystack now officially supports Python 3.15. diff --git a/test/core/pipeline/features/test_run.py b/test/core/pipeline/features/test_run.py index e77a6cfaf0..b8f74f38f6 100644 --- a/test/core/pipeline/features/test_run.py +++ b/test/core/pipeline/features/test_run.py @@ -1089,7 +1089,7 @@ def pipeline_that_has_multiple_branches_that_merge_into_a_component_with_a_singl ("add_one_again", 1): {"add": None, "value": 7}, ("double", 1): {"value": 3}, ("parity", 1): {"value": 3}, - ("sum", 1): {"values": [3, 6, 8]}, + ("sum", 1): {"values": AnyOrder([3, 6, 8])}, }, ), ], @@ -1751,16 +1751,22 @@ def run(self, query_embedding: list[float]) -> dict[str, list[Document]]: "text_embedder": { "meta": {"model": "mock-model", "usage": {"prompt_tokens": 4, "total_tokens": 4}} }, - "joinerfinal": {"documents": [doc1, doc2]}, + "joinerfinal": {"documents": AnyOrder([doc1, doc2], key=lambda d: d.id)}, }, expected_component_calls={ ("router", 1): {"query": "I'm a legit question"}, ("text_embedder", 1): {"text": "I'm a legit question"}, ("bm25retriever", 1): {"query": "I'm a legit question"}, ("retriever", 1): {"query_embedding": [1.0, 2.0, 3.0]}, - ("joinerhybrid", 1): {"documents": [[doc1], [doc2]], "top_k": None}, - ("ranker", 1): {"query": "I'm a legit question", "documents": [doc1, doc2]}, - ("joinerfinal", 1): {"documents": [[doc1, doc2]], "top_k": None}, + ("joinerhybrid", 1): { + "documents": AnyOrder([[doc1], [doc2]], key=lambda d: d[0].id), + "top_k": None, + }, + ("ranker", 1): { + "query": "I'm a legit question", + "documents": AnyOrder([doc1, doc2], key=lambda d: d.id), + }, + ("joinerfinal", 1): {"documents": [AnyOrder([doc1, doc2], key=lambda d: d.id)], "top_k": None}, }, ), PipelineRunData( @@ -2260,9 +2266,15 @@ def run(self, create_document: bool = False) -> dict[str, list[Document] | None] [ PipelineRunData( inputs={"first_creator": {"create_document": True}, "third_creator": {"create_document": True}}, - expected_outputs={"second_creator": {"noop": None}, "documents_joiner": {"documents": [doc1, doc3]}}, + expected_outputs={ + "second_creator": {"noop": None}, + "documents_joiner": {"documents": AnyOrder([doc1, doc3], key=lambda d: d.id)}, + }, expected_component_calls={ - ("documents_joiner", 1): {"documents": [[doc1], [doc3]], "top_k": None}, + ("documents_joiner", 1): { + "documents": AnyOrder([[doc1], [doc3]], key=lambda d: d[0].id), + "top_k": None, + }, ("first_creator", 1): {"create_document": True}, ("second_creator", 1): {"create_document": False}, ("third_creator", 1): {"create_document": True}, @@ -2270,9 +2282,15 @@ def run(self, create_document: bool = False) -> dict[str, list[Document] | None] ), PipelineRunData( inputs={"first_creator": {"create_document": True}, "second_creator": {"create_document": True}}, - expected_outputs={"third_creator": {"noop": None}, "documents_joiner": {"documents": [doc1, doc2]}}, + expected_outputs={ + "third_creator": {"noop": None}, + "documents_joiner": {"documents": AnyOrder([doc1, doc2], key=lambda d: d.id)}, + }, expected_component_calls={ - ("documents_joiner", 1): {"documents": [[doc1], [doc2]], "top_k": None}, + ("documents_joiner", 1): { + "documents": AnyOrder([[doc1], [doc2]], key=lambda d: d[0].id), + "top_k": None, + }, ("first_creator", 1): {"create_document": True}, ("second_creator", 1): {"create_document": True}, ("third_creator", 1): {"create_document": False}, @@ -2317,9 +2335,15 @@ def run(self, create_document: bool = False) -> dict[str, list[Document] | None] [ PipelineRunData( inputs={"first_creator": {"create_document": True}, "third_creator": {"create_document": True}}, - expected_outputs={"second_creator": {"noop": None}, "documents_joiner": {"documents": [doc1, doc3]}}, + expected_outputs={ + "second_creator": {"noop": None}, + "documents_joiner": {"documents": AnyOrder([doc1, doc3], key=lambda d: d.id)}, + }, expected_component_calls={ - ("documents_joiner", 1): {"documents": [[doc1], [doc3]], "top_k": None}, + ("documents_joiner", 1): { + "documents": AnyOrder([[doc1], [doc3]], key=lambda d: d[0].id), + "top_k": None, + }, ("first_creator", 1): {"create_document": True}, ("second_creator", 1): {"create_document": False}, ("third_creator", 1): {"create_document": True}, @@ -2327,9 +2351,15 @@ def run(self, create_document: bool = False) -> dict[str, list[Document] | None] ), PipelineRunData( inputs={"first_creator": {"create_document": True}, "second_creator": {"create_document": True}}, - expected_outputs={"third_creator": {"noop": None}, "documents_joiner": {"documents": [doc1, doc2]}}, + expected_outputs={ + "third_creator": {"noop": None}, + "documents_joiner": {"documents": AnyOrder([doc1, doc2], key=lambda d: d.id)}, + }, expected_component_calls={ - ("documents_joiner", 1): {"documents": [[doc1], [doc2]], "top_k": None}, + ("documents_joiner", 1): { + "documents": AnyOrder([[doc1], [doc2]], key=lambda d: d[0].id), + "top_k": None, + }, ("first_creator", 1): {"create_document": True}, ("second_creator", 1): {"create_document": True}, ("third_creator", 1): {"create_document": False}, @@ -2352,6 +2382,8 @@ def that_has_an_answer_joiner_variadic_component(): query = "What's Natural Language Processing?" reply1 = "This is a test answer" reply2 = "This is a second test answer" + answer1 = GeneratedAnswer(data=reply1, query=query, documents=[], meta={"all_messages": [reply1]}) + answer2 = GeneratedAnswer(data=reply2, query=query, documents=[], meta={"all_messages": [reply2]}) return ( pipeline, @@ -2361,14 +2393,7 @@ def that_has_an_answer_joiner_variadic_component(): "answer_builder_1": {"query": query, "replies": [reply1]}, "answer_builder_2": {"query": query, "replies": [reply2]}, }, - expected_outputs={ - "answer_joiner": { - "answers": [ - GeneratedAnswer(data=reply1, query=query, documents=[], meta={"all_messages": [reply1]}), - GeneratedAnswer(data=reply2, query=query, documents=[], meta={"all_messages": [reply2]}), - ] - } - }, + expected_outputs={"answer_joiner": {"answers": AnyOrder([answer1, answer2], key=lambda a: a.data)}}, expected_component_calls={ ("answer_builder_1", 1): { "documents": None, @@ -2389,10 +2414,7 @@ def that_has_an_answer_joiner_variadic_component(): "replies": [reply2], }, ("answer_joiner", 1): { - "answers": [ - [GeneratedAnswer(data=reply1, query=query, documents=[], meta={"all_messages": [reply1]})], - [GeneratedAnswer(data=reply2, query=query, documents=[], meta={"all_messages": [reply2]})], - ], + "answers": AnyOrder([[answer1], [answer2]], key=lambda a: a[0].data), "top_k": None, }, }, @@ -2845,6 +2867,10 @@ def __init__(self, items, key=None): def __eq__(self, other): return isinstance(other, list) and sorted(self.items, key=self.key) == sorted(other, key=self.key) + def __iter__(self): + # Lets an AnyOrder be an item of another AnyOrder whose key iterates over its items. + return iter(self.items) + @given("a pipeline that has a string variadic component", target_fixture="pipeline_data") def that_has_a_string_variadic_component(): @@ -4410,7 +4436,9 @@ def pipeline_that_converts_files(): [ PipelineRunData( inputs={"router": {"sources": sources}}, - expected_outputs={"a_joiner": {"documents": expected_csv_docs + expected_splits_docs}}, + expected_outputs={ + "a_joiner": {"documents": AnyOrder(expected_csv_docs + expected_splits_docs, key=lambda d: d.id)} + }, expected_component_calls={ ("router", 1): {"sources": sources, "meta": None}, ("csv_converter", 1): {"sources": [sources[0]], "meta": None, "content_column": None}, @@ -4422,9 +4450,12 @@ def pipeline_that_converts_files(): ), "top_k": None, }, - ("splitter", 1): {"documents": expected_pre_split_docs}, + ("splitter", 1): {"documents": AnyOrder(expected_pre_split_docs, key=lambda d: d.id)}, ("a_joiner", 1): { - "documents": AnyOrder([expected_csv_docs, expected_splits_docs], key=lambda d: d[0].id), + "documents": AnyOrder( + [expected_csv_docs, AnyOrder(expected_splits_docs, key=lambda d: d.id)], + key=lambda docs: min(d.id for d in docs), + ), "top_k": None, }, }, diff --git a/test/test_logging.py b/test/test_logging.py index 15322a5b99..c8c8408d69 100644 --- a/test/test_logging.py +++ b/test/test_logging.py @@ -8,7 +8,7 @@ import os import sys from collections.abc import Callable, Generator -from datetime import datetime, timezone +from datetime import UTC, datetime from pathlib import Path from unittest.mock import ANY @@ -208,7 +208,7 @@ def test_console_rendered_structured_log(self, capfd: CaptureFixture) -> None: output = capfd.readouterr().err # Only check for the minute to be a bit more robust - today = datetime.now(tz=timezone.utc).isoformat(timespec="minutes").replace("+00:00", "") + today = datetime.now(tz=UTC).isoformat(timespec="minutes").replace("+00:00", "") assert today in output log_level = "warning" diff --git a/test/utils/test_filters.py b/test/utils/test_filters.py index 53d6c566c9..e21d7cc0c9 100644 --- a/test/utils/test_filters.py +++ b/test/utils/test_filters.py @@ -2,7 +2,7 @@ # # SPDX-License-Identifier: Apache-2.0 -from datetime import datetime, timezone +from datetime import UTC, datetime import pytest @@ -132,26 +132,26 @@ id="> operator with smaller ISO 8601 datetime Document value", ), pytest.param( - {"field": "meta.date", "operator": ">", "value": datetime(2023, 1, 1, tzinfo=timezone.utc)}, + {"field": "meta.date", "operator": ">", "value": datetime(2023, 1, 1, tzinfo=UTC)}, Document(meta={"date": "2024-01-01T00:00:00+00:00"}), True, id="> operator with datetime filter value and ISO 8601 string Document value", ), pytest.param( {"field": "meta.date", "operator": ">", "value": "2023-01-01T00:00:00+00:00"}, - Document(meta={"date": datetime(2024, 1, 1, tzinfo=timezone.utc)}), + Document(meta={"date": datetime(2024, 1, 1, tzinfo=UTC)}), True, id="> operator with ISO 8601 string filter value and datetime Document value", ), pytest.param( - {"field": "meta.date", "operator": ">", "value": datetime(2023, 1, 1, tzinfo=timezone.utc)}, + {"field": "meta.date", "operator": ">", "value": datetime(2023, 1, 1, tzinfo=UTC)}, Document(meta={"date": datetime(2024, 1, 1)}), True, id="> operator with aware datetime filter value and naive datetime Document value", ), pytest.param( {"field": "meta.date", "operator": ">", "value": datetime(2023, 1, 1)}, - Document(meta={"date": datetime(2024, 1, 1, tzinfo=timezone.utc)}), + Document(meta={"date": datetime(2024, 1, 1, tzinfo=UTC)}), True, id="> operator with naive datetime filter value and aware datetime Document value", ), @@ -542,13 +542,13 @@ id="== operator with equal datetime in different ISO 8601 format", ), pytest.param( - {"field": "meta.date", "operator": "==", "value": datetime(2025, 2, 3, 12, 45, 46, tzinfo=timezone.utc)}, + {"field": "meta.date", "operator": "==", "value": datetime(2025, 2, 3, 12, 45, 46, tzinfo=UTC)}, Document(meta={"date": "2025-02-03T12:45:46Z"}), True, id="== operator with datetime filter value and ISO 8601 string Document value", ), pytest.param( - {"field": "meta.date", "operator": "==", "value": datetime(2025, 2, 3, 12, 45, 46, tzinfo=timezone.utc)}, + {"field": "meta.date", "operator": "==", "value": datetime(2025, 2, 3, 12, 45, 46, tzinfo=UTC)}, Document(meta={"date": datetime(2025, 2, 3, 12, 45, 46)}), True, id="== operator with aware datetime filter value and naive datetime Document value", @@ -651,8 +651,8 @@ def test_document_matches_filter(filters, document, expected_result): [ ("2025-02-03T12:45:46", "2025-02-03T12:45:46+00:00"), ("2025-02-03T12:45:46+00:00", "2025-02-03T12:45:46"), - (datetime(2025, 2, 3, 12, 45, 46), datetime(2025, 2, 3, 12, 45, 46, tzinfo=timezone.utc)), - (datetime(2025, 2, 3, 12, 45, 46, tzinfo=timezone.utc), datetime(2025, 2, 3, 12, 45, 46)), + (datetime(2025, 2, 3, 12, 45, 46), datetime(2025, 2, 3, 12, 45, 46, tzinfo=UTC)), + (datetime(2025, 2, 3, 12, 45, 46, tzinfo=UTC), datetime(2025, 2, 3, 12, 45, 46)), ], ) def test_document_matches_filter_strict_datetime_comparison(operator, expected_result, document_value, filter_value): diff --git a/test/utils/test_type_serialization.py b/test/utils/test_type_serialization.py index a8c5a2e626..99e7436d85 100644 --- a/test/utils/test_type_serialization.py +++ b/test/utils/test_type_serialization.py @@ -236,10 +236,15 @@ def test_output_type_serialization_typing_generic_with_nonetype(): assert serialize_type(Dict[type(None), str]) == "typing.Dict[None, str]" # type: ignore[misc] assert serialize_type(Tuple[int, type(None)]) == "typing.Tuple[int, None]" assert serialize_type(List[type(None)]) == "typing.List[None]" # type: ignore[misc] - # A Union with more than two members that includes None must keep None as well. - assert serialize_type(Union[str, int, None]) == "typing.Union[str, int, None]" - # Optional must still be serialized without a redundant trailing None. - assert serialize_type(Optional[str]) == "typing.Optional[str]" + # A Union with more than two members that includes None must keep None as well, and Optional must still be + # serialized without a redundant trailing None. + # Python 3.14+ represents typing.Union as types.UnionType, so both serialize with PEP 604 syntax. + if sys.version_info >= (3, 14): + assert serialize_type(Union[str, int, None]) == "str | int | None" + assert serialize_type(Optional[str]) == "str | None" + else: + assert serialize_type(Union[str, int, None]) == "typing.Union[str, int, None]" + assert serialize_type(Optional[str]) == "typing.Optional[str]" def test_output_type_round_trip_typing_generic_with_nonetype(): @@ -266,7 +271,12 @@ def test_output_type_serialization_callable_with_parameter_list(): assert serialize_type(Callable[[int, str], bool]) == "typing.Callable[[int, str], bool]" assert serialize_type(Callable[[], int]) == "typing.Callable[[], int]" assert serialize_type(Callable[[int], List[str]]) == "typing.Callable[[int], typing.List[str]]" - assert serialize_type(Callable[[Union[str, int]], bool]) == "typing.Callable[[typing.Union[str, int]], bool]" + expected = ( + "typing.Callable[[str | int], bool]" + if sys.version_info >= (3, 14) + else "typing.Callable[[typing.Union[str, int]], bool]" + ) + assert serialize_type(Callable[[Union[str, int]], bool]) == expected def test_output_type_deserialization_callable_with_parameter_list():