From b73abfd918a63aa5ef20a18107d15d060662599c Mon Sep 17 00:00:00 2001 From: Tim Savage Date: Tue, 27 Jun 2023 22:41:45 +1000 Subject: [PATCH 1/4] Handle when a single item is found. Add a helper to allow for customise how a field is generated. --- src/odin/contrib/json_schema/__init__.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/odin/contrib/json_schema/__init__.py b/src/odin/contrib/json_schema/__init__.py index fe96175..8f36c22 100644 --- a/src/odin/contrib/json_schema/__init__.py +++ b/src/odin/contrib/json_schema/__init__.py @@ -35,6 +35,7 @@ odin.validators.MaxLengthValidator: {}, odin.validators.MinLengthValidator: {}, } +JSON_SCHEMA_METHOD: Final[str] = "as_json_schema" class JSONSchema: @@ -108,7 +109,11 @@ def _field_type( """Get the type of a field.""" field_type = type(field) - if field_type in FIELD_SCHEMAS: + + if method := getattr(field, JSON_SCHEMA_METHOD, None): + type_name, schema = method() + + elif field_type in FIELD_SCHEMAS: type_name, schema = FIELD_SCHEMAS[field_type] elif isinstance(field, odin.EnumField): @@ -140,12 +145,15 @@ def _composite_field_to_schema(self, field: odin.CompositeField) -> Dict[str, An # Handle abstract resources child_resources = get_child_resources(field.of) if child_resources: - schema = { - "oneOf": [ - self._schema_def(child_resource) - for child_resource in child_resources - ] - } + if len(child_resources) == 1: + schema = self._schema_def(child_resources[0]) + else: + schema = { + "oneOf": [ + self._schema_def(child_resource) + for child_resource in child_resources + ] + } else: schema = self._schema_def(field.of) From 2ade58a47e99b6ab914fb84bb326fd5eea2ad34f Mon Sep 17 00:00:00 2001 From: Tim Savage Date: Wed, 27 Sep 2023 23:16:09 +1000 Subject: [PATCH 2/4] Add support for delayed resolution of fields --- src/odin/fields/composite.py | 78 ++++++++++++++++++++++++---------- tests/test_fields_composite.py | 16 ++++++- 2 files changed, 70 insertions(+), 24 deletions(-) diff --git a/src/odin/fields/composite.py b/src/odin/fields/composite.py index e24b23e..9e197bb 100644 --- a/src/odin/fields/composite.py +++ b/src/odin/fields/composite.py @@ -1,4 +1,7 @@ -from typing import Any, Iterator, Tuple +"""Composite fields for handling collections of resources.""" +import abc +from functools import cached_property +from typing import Any, Callable, Iterator, Tuple from odin import bases, exceptions from odin.fields import Field @@ -16,14 +19,30 @@ ) -class CompositeField(Field): - """ - The base class for composite (or fields that contain other resources) eg DictAs/ListOf fields. - """ +class CompositeField(Field, metaclass=abc.ABCMeta): + """The base class for composite. + + Fields that contain other resources eg DictAs/ListOf fields.""" + + @classmethod + def delayed(cls, resource_callable: Callable[[], Any], **options): + """Create a delayed resource field. + + This is used in the case of tree structures where a resource may reference itself. + + This should be used with a lambda function to avoid referencing an incomplete type. + + .. code-block:: python + + class Category(odin.Resource): + name = odin.StringField() + child_categories = odin.DictAs.delayed(lambda: Category) - def __init__(self, resource, use_container=False, **options): """ - Initialisation of a CompositeField. + return cls(resource_callable, **options) + + def __init__(self, resource, use_container=False, **options): + """Initialisation of a CompositeField. :param resource: :param use_container: Special flag for codecs that support containers or just multiple instances of a @@ -32,16 +51,37 @@ def __init__(self, resource, use_container=False, **options): :param options: Additional options passed to :py:class:`odin.fields.Field` super class. """ + if not hasattr(resource, "_meta"): - raise TypeError(f"{resource!r} is not a valid type for a related field.") - self.of = resource + if callable(resource): + # Delayed resolution of the resource type. + self._of = resource + else: + # Keep this pattern so old behaviour remains. + raise TypeError( + f"{resource!r} is not a valid type for a related field." + ) + else: + self._of = resource self.use_container = use_container if not options.get("null", False): - options.setdefault("default", lambda: resource()) + options.setdefault("default", lambda: self.of()) super().__init__(**options) + @cached_property + def of(self): + """Return the resource type.""" + resource = self._of + if not hasattr(resource, "_meta") and callable(resource): + resource = resource() + if not hasattr(resource, "_meta"): + raise TypeError( + f"{resource!r} is not a valid type for a related field." + ) + return resource + def to_python(self, value): """Convert raw value to a python value.""" if value is None: @@ -59,24 +99,16 @@ def validate(self, value): if value not in EMPTY_VALUES: value.full_clean() + @abc.abstractmethod def item_iter_from_object(self, obj): - """ - Return an iterator of items (resource, idx) from composite field. + """Return an iterator of items (resource, idx) from composite field. For single items (eg ``DictAs`` will return a list a single item (resource, None)) - - :param obj: - :return: """ - raise NotImplementedError() + @abc.abstractmethod def key_to_python(self, key): - """ - A to python method for the key value. - :param key: - :return: - """ - raise NotImplementedError() + """A to python method for the key value.""" class DictAs(CompositeField): @@ -243,7 +275,7 @@ def validate(self, value): raise exceptions.ValidationError(self.error_messages["empty"], code="empty") def __iter__(self): - # This does nothing but it does prevent inspections from complaining. + # This does nothing, it does prevent inspections from complaining. return None # NoQA def item_iter_from_object(self, obj) -> Iterator[Tuple[str, Any]]: diff --git a/tests/test_fields_composite.py b/tests/test_fields_composite.py index 489012a..1ae027f 100644 --- a/tests/test_fields_composite.py +++ b/tests/test_fields_composite.py @@ -53,10 +53,24 @@ def assertResourceDictEqual(self, first, second): # DictAs ################################################################## - def test_dictas_ensure_is_resource(self): + def test_dictas__where_a_resource_is_not_supplied(self): with pytest.raises(TypeError): DictAs("an item") + def test_dictas__where_resource_resolution_is_delayed(self): + target = DictAs.delayed(lambda: ExampleResource, null=True) + + assert target.of is ExampleResource + assert target.null is True + + def test_dictas__where_resource_resolution_is_delayed_but_a_resource_is_not_supplied( + self, + ): + target = DictAs.delayed(lambda: "an item", null=True) + + with pytest.raises(TypeError): + assert target.of + def test_dictas_1(self): f = DictAs(ExampleResource) pytest.raises(ValidationError, f.clean, None) From 7b94f6d466f4c3a16ee685e7c6960ef2a1a080db Mon Sep 17 00:00:00 2001 From: Tim Savage Date: Wed, 27 Sep 2023 23:19:36 +1000 Subject: [PATCH 3/4] Bump version and update history --- HISTORY | 13 +++++++++++++ pyproject.toml | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/HISTORY b/HISTORY index 6a255f8..efc9c22 100644 --- a/HISTORY +++ b/HISTORY @@ -1,3 +1,16 @@ +2.9 +=== + +Changes +------- + +- Add support for delayed resolution of types for composite fields. This + allows for tree structures to be defined. + + Use ``DictAs.delayed(lambda: CurrentResource)`` to define a composite field that + uses the current resource as the type for the dict. + + 2.8.1 ===== diff --git a/pyproject.toml b/pyproject.toml index de07a05..eae2af8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "odin" -version = "2.8.1" +version = "2.9.0" description = "Data-structure definition/validation/traversal, mapping and serialisation toolkit for Python" authors = ["Tim Savage "] license = "BSD-3-Clause" From 16fe5674e7fe9a12a5c915c8995573847e50362d Mon Sep 17 00:00:00 2001 From: Tim Savage Date: Wed, 27 Sep 2023 23:30:33 +1000 Subject: [PATCH 4/4] Add suggested workaround for Python 3.12 --- .github/workflows/tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d9a840b..92f153e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -30,6 +30,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip poetry + python -m pip wheel --use-pep517 "pyyaml (==6.0)" poetry install --all-extras --no-root - name: Test with pytest