From 073236523df08c8400e32a22028d60b20896ecd1 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Tue, 5 Nov 2024 17:38:37 +0000 Subject: [PATCH 1/2] Support pipe syntax to declare optional fields Fixes #1928 --- docs/persistence.rst | 8 +++++--- elasticsearch_dsl/document_base.py | 14 ++++++++++++++ tests/_async/test_document.py | 31 ++++++++++++++++++++++++++++++ tests/_sync/test_document.py | 31 ++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 3 deletions(-) diff --git a/docs/persistence.rst b/docs/persistence.rst index ab3ffbe4..f505dc6b 100644 --- a/docs/persistence.rst +++ b/docs/persistence.rst @@ -147,9 +147,10 @@ following table: - ``Date(format="yyyy-MM-dd", required=True)`` To type a field as optional, the standard ``Optional`` modifier from the Python -``typing`` package can be used. The ``List`` modifier can be added to a field -to convert it to an array, similar to using the ``multi=True`` argument on the -field object. +``typing`` package can be used. When using Python 3.10 or newer, "pipe" syntax +can also be used, by adding ``| None`` to a type. The ``List`` modifier can be +added to a field to convert it to an array, similar to using the ``multi=True`` +argument on the field object. .. code:: python @@ -157,6 +158,7 @@ field object. class MyDoc(Document): pub_date: Optional[datetime] # same as pub_date = Date() + middle_name: str | None # same as middle_name = Text() authors: List[str] # same as authors = Text(multi=True, required=True) comments: Optional[List[str]] # same as comments = Text(multi=True) diff --git a/elasticsearch_dsl/document_base.py b/elasticsearch_dsl/document_base.py index 83445aec..e71bd689 100644 --- a/elasticsearch_dsl/document_base.py +++ b/elasticsearch_dsl/document_base.py @@ -28,9 +28,15 @@ Tuple, TypeVar, Union, + get_args, overload, ) +try: + from types import UnionType +except ImportError: + UnionType = None + from typing_extensions import dataclass_transform from .exceptions import ValidationException @@ -192,6 +198,14 @@ def __init__(self, name: str, bases: Tuple[type, ...], attrs: Dict[str, Any]): type_ = type_.__args__[0] else: break + if type(type_) is UnionType: + # a union given with the pipe syntax + args = get_args(type_) + if len(args) == 2 and args[1] is type(None): + required = False + type_ = type_.__args__[0] + else: + raise TypeError("Unsupported union") field = None field_args: List[Any] = [] field_kwargs: Dict[str, Any] = {} diff --git a/tests/_async/test_document.py b/tests/_async/test_document.py index 0233ddcc..82568762 100644 --- a/tests/_async/test_document.py +++ b/tests/_async/test_document.py @@ -24,6 +24,7 @@ import codecs import ipaddress import pickle +import sys from datetime import datetime from hashlib import md5 from typing import Any, Dict, List, Optional @@ -783,6 +784,36 @@ class TypedDoc(AsyncDocument): } +@pytest.mark.skipif(sys.version_info < (3, 10), reason="requires Python 3.10") +def test_doc_with_pipe_type_hints() -> None: + with pytest.raises(TypeError): + + class BadlyTypedDoc(AsyncDocument): + s: str + f: str | int | None + + class TypedDoc(AsyncDocument): + s: str + f1: str | None + f2: M[int | None] + f3: M[datetime | None] + + props = TypedDoc._doc_type.mapping.to_dict()["properties"] + assert props == { + "s": {"type": "text"}, + "f1": {"type": "text"}, + "f2": {"type": "integer"}, + "f3": {"type": "date"}, + } + + doc = TypedDoc() + with raises(ValidationException) as exc_info: + doc.full_clean() + assert set(exc_info.value.args[0].keys()) == {"s"} + doc.s = "s" + doc.full_clean() + + def test_instrumented_field() -> None: class Child(InnerDoc): st: M[str] diff --git a/tests/_sync/test_document.py b/tests/_sync/test_document.py index 4ba6992f..33dd9f37 100644 --- a/tests/_sync/test_document.py +++ b/tests/_sync/test_document.py @@ -24,6 +24,7 @@ import codecs import ipaddress import pickle +import sys from datetime import datetime from hashlib import md5 from typing import Any, Dict, List, Optional @@ -783,6 +784,36 @@ class TypedDoc(Document): } +@pytest.mark.skipif(sys.version_info < (3, 10), reason="requires Python 3.10") +def test_doc_with_pipe_type_hints() -> None: + with pytest.raises(TypeError): + + class BadlyTypedDoc(Document): + s: str + f: str | int | None + + class TypedDoc(Document): + s: str + f1: str | None + f2: M[int | None] + f3: M[datetime | None] + + props = TypedDoc._doc_type.mapping.to_dict()["properties"] + assert props == { + "s": {"type": "text"}, + "f1": {"type": "text"}, + "f2": {"type": "integer"}, + "f3": {"type": "date"}, + } + + doc = TypedDoc() + with raises(ValidationException) as exc_info: + doc.full_clean() + assert set(exc_info.value.args[0].keys()) == {"s"} + doc.s = "s" + doc.full_clean() + + def test_instrumented_field() -> None: class Child(InnerDoc): st: M[str] From 85b67ede85da329bd806400a8c5311b4f789d738 Mon Sep 17 00:00:00 2001 From: Miguel Grinberg Date: Tue, 5 Nov 2024 19:02:23 +0000 Subject: [PATCH 2/2] type ignores for pipe syntax in 3.8/3.9 --- elasticsearch_dsl/document_base.py | 2 +- elasticsearch_dsl/response/__init__.py | 2 +- tests/_async/test_document.py | 8 ++++---- tests/_sync/test_document.py | 8 ++++---- utils/templates/response.__init__.py.tpl | 2 +- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/elasticsearch_dsl/document_base.py b/elasticsearch_dsl/document_base.py index e71bd689..a26aff22 100644 --- a/elasticsearch_dsl/document_base.py +++ b/elasticsearch_dsl/document_base.py @@ -33,7 +33,7 @@ ) try: - from types import UnionType + from types import UnionType # type: ignore[attr-defined] except ImportError: UnionType = None diff --git a/elasticsearch_dsl/response/__init__.py b/elasticsearch_dsl/response/__init__.py index 51c24ff9..a711950f 100644 --- a/elasticsearch_dsl/response/__init__.py +++ b/elasticsearch_dsl/response/__init__.py @@ -44,7 +44,7 @@ class Response(AttrDict[Any], Generic[_R]): - """An Elasticsearch response. + """An Elasticsearch _search response. :arg took: (required) :arg timed_out: (required) diff --git a/tests/_async/test_document.py b/tests/_async/test_document.py index 82568762..b00b7822 100644 --- a/tests/_async/test_document.py +++ b/tests/_async/test_document.py @@ -790,13 +790,13 @@ def test_doc_with_pipe_type_hints() -> None: class BadlyTypedDoc(AsyncDocument): s: str - f: str | int | None + f: str | int | None # type: ignore[syntax] class TypedDoc(AsyncDocument): s: str - f1: str | None - f2: M[int | None] - f3: M[datetime | None] + f1: str | None # type: ignore[syntax] + f2: M[int | None] # type: ignore[syntax] + f3: M[datetime | None] # type: ignore[syntax] props = TypedDoc._doc_type.mapping.to_dict()["properties"] assert props == { diff --git a/tests/_sync/test_document.py b/tests/_sync/test_document.py index 33dd9f37..c00ec195 100644 --- a/tests/_sync/test_document.py +++ b/tests/_sync/test_document.py @@ -790,13 +790,13 @@ def test_doc_with_pipe_type_hints() -> None: class BadlyTypedDoc(Document): s: str - f: str | int | None + f: str | int | None # type: ignore[syntax] class TypedDoc(Document): s: str - f1: str | None - f2: M[int | None] - f3: M[datetime | None] + f1: str | None # type: ignore[syntax] + f2: M[int | None] # type: ignore[syntax] + f3: M[datetime | None] # type: ignore[syntax] props = TypedDoc._doc_type.mapping.to_dict()["properties"] assert props == { diff --git a/utils/templates/response.__init__.py.tpl b/utils/templates/response.__init__.py.tpl index 5d5d7bac..a3812616 100644 --- a/utils/templates/response.__init__.py.tpl +++ b/utils/templates/response.__init__.py.tpl @@ -44,7 +44,7 @@ __all__ = ["Response", "AggResponse", "UpdateByQueryResponse", "Hit", "HitMeta"] class Response(AttrDict[Any], Generic[_R]): - """An Elasticsearch _search response. + """An Elasticsearch search response. {% for arg in response.args %} {% for line in arg.doc %}