Skip to content

Commit

Permalink
Merge pull request #154 from python-odin/development
Browse files Browse the repository at this point in the history
Release 2.9
  • Loading branch information
timsavage authored Sep 27, 2023
2 parents bac2404 + 16fe567 commit fd453ba
Show file tree
Hide file tree
Showing 6 changed files with 100 additions and 32 deletions.
1 change: 1 addition & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions HISTORY
Original file line number Diff line number Diff line change
@@ -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
=====

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <[email protected]>"]
license = "BSD-3-Clause"
Expand Down
22 changes: 15 additions & 7 deletions src/odin/contrib/json_schema/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
odin.validators.MaxLengthValidator: {},
odin.validators.MinLengthValidator: {},
}
JSON_SCHEMA_METHOD: Final[str] = "as_json_schema"


class JSONSchema:
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)

Expand Down
78 changes: 55 additions & 23 deletions src/odin/fields/composite.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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]]:
Expand Down
16 changes: 15 additions & 1 deletion tests/test_fields_composite.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down

0 comments on commit fd453ba

Please sign in to comment.