Skip to content

Commit fd453ba

Browse files
authored
Merge pull request #154 from python-odin/development
Release 2.9
2 parents bac2404 + 16fe567 commit fd453ba

6 files changed

Lines changed: 100 additions & 32 deletions

File tree

.github/workflows/tests.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ jobs:
3030
- name: Install dependencies
3131
run: |
3232
python -m pip install --upgrade pip poetry
33+
python -m pip wheel --use-pep517 "pyyaml (==6.0)"
3334
poetry install --all-extras --no-root
3435
3536
- name: Test with pytest

HISTORY

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,16 @@
1+
2.9
2+
===
3+
4+
Changes
5+
-------
6+
7+
- Add support for delayed resolution of types for composite fields. This
8+
allows for tree structures to be defined.
9+
10+
Use ``DictAs.delayed(lambda: CurrentResource)`` to define a composite field that
11+
uses the current resource as the type for the dict.
12+
13+
114
2.8.1
215
=====
316

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
44

55
[tool.poetry]
66
name = "odin"
7-
version = "2.8.1"
7+
version = "2.9.0"
88
description = "Data-structure definition/validation/traversal, mapping and serialisation toolkit for Python"
99
authors = ["Tim Savage <tim@savage.company>"]
1010
license = "BSD-3-Clause"

src/odin/contrib/json_schema/__init__.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
odin.validators.MaxLengthValidator: {},
3636
odin.validators.MinLengthValidator: {},
3737
}
38+
JSON_SCHEMA_METHOD: Final[str] = "as_json_schema"
3839

3940

4041
class JSONSchema:
@@ -108,7 +109,11 @@ def _field_type(
108109
"""Get the type of a field."""
109110

110111
field_type = type(field)
111-
if field_type in FIELD_SCHEMAS:
112+
113+
if method := getattr(field, JSON_SCHEMA_METHOD, None):
114+
type_name, schema = method()
115+
116+
elif field_type in FIELD_SCHEMAS:
112117
type_name, schema = FIELD_SCHEMAS[field_type]
113118

114119
elif isinstance(field, odin.EnumField):
@@ -140,12 +145,15 @@ def _composite_field_to_schema(self, field: odin.CompositeField) -> Dict[str, An
140145
# Handle abstract resources
141146
child_resources = get_child_resources(field.of)
142147
if child_resources:
143-
schema = {
144-
"oneOf": [
145-
self._schema_def(child_resource)
146-
for child_resource in child_resources
147-
]
148-
}
148+
if len(child_resources) == 1:
149+
schema = self._schema_def(child_resources[0])
150+
else:
151+
schema = {
152+
"oneOf": [
153+
self._schema_def(child_resource)
154+
for child_resource in child_resources
155+
]
156+
}
149157
else:
150158
schema = self._schema_def(field.of)
151159

src/odin/fields/composite.py

Lines changed: 55 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
from typing import Any, Iterator, Tuple
1+
"""Composite fields for handling collections of resources."""
2+
import abc
3+
from functools import cached_property
4+
from typing import Any, Callable, Iterator, Tuple
25

36
from odin import bases, exceptions
47
from odin.fields import Field
@@ -16,14 +19,30 @@
1619
)
1720

1821

19-
class CompositeField(Field):
20-
"""
21-
The base class for composite (or fields that contain other resources) eg DictAs/ListOf fields.
22-
"""
22+
class CompositeField(Field, metaclass=abc.ABCMeta):
23+
"""The base class for composite.
24+
25+
Fields that contain other resources eg DictAs/ListOf fields."""
26+
27+
@classmethod
28+
def delayed(cls, resource_callable: Callable[[], Any], **options):
29+
"""Create a delayed resource field.
30+
31+
This is used in the case of tree structures where a resource may reference itself.
32+
33+
This should be used with a lambda function to avoid referencing an incomplete type.
34+
35+
.. code-block:: python
36+
37+
class Category(odin.Resource):
38+
name = odin.StringField()
39+
child_categories = odin.DictAs.delayed(lambda: Category)
2340
24-
def __init__(self, resource, use_container=False, **options):
2541
"""
26-
Initialisation of a CompositeField.
42+
return cls(resource_callable, **options)
43+
44+
def __init__(self, resource, use_container=False, **options):
45+
"""Initialisation of a CompositeField.
2746
2847
:param resource:
2948
: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):
3251
:param options: Additional options passed to :py:class:`odin.fields.Field` super class.
3352
3453
"""
54+
3555
if not hasattr(resource, "_meta"):
36-
raise TypeError(f"{resource!r} is not a valid type for a related field.")
37-
self.of = resource
56+
if callable(resource):
57+
# Delayed resolution of the resource type.
58+
self._of = resource
59+
else:
60+
# Keep this pattern so old behaviour remains.
61+
raise TypeError(
62+
f"{resource!r} is not a valid type for a related field."
63+
)
64+
else:
65+
self._of = resource
3866
self.use_container = use_container
3967

4068
if not options.get("null", False):
41-
options.setdefault("default", lambda: resource())
69+
options.setdefault("default", lambda: self.of())
4270

4371
super().__init__(**options)
4472

73+
@cached_property
74+
def of(self):
75+
"""Return the resource type."""
76+
resource = self._of
77+
if not hasattr(resource, "_meta") and callable(resource):
78+
resource = resource()
79+
if not hasattr(resource, "_meta"):
80+
raise TypeError(
81+
f"{resource!r} is not a valid type for a related field."
82+
)
83+
return resource
84+
4585
def to_python(self, value):
4686
"""Convert raw value to a python value."""
4787
if value is None:
@@ -59,24 +99,16 @@ def validate(self, value):
5999
if value not in EMPTY_VALUES:
60100
value.full_clean()
61101

102+
@abc.abstractmethod
62103
def item_iter_from_object(self, obj):
63-
"""
64-
Return an iterator of items (resource, idx) from composite field.
104+
"""Return an iterator of items (resource, idx) from composite field.
65105
66106
For single items (eg ``DictAs`` will return a list a single item (resource, None))
67-
68-
:param obj:
69-
:return:
70107
"""
71-
raise NotImplementedError()
72108

109+
@abc.abstractmethod
73110
def key_to_python(self, key):
74-
"""
75-
A to python method for the key value.
76-
:param key:
77-
:return:
78-
"""
79-
raise NotImplementedError()
111+
"""A to python method for the key value."""
80112

81113

82114
class DictAs(CompositeField):
@@ -243,7 +275,7 @@ def validate(self, value):
243275
raise exceptions.ValidationError(self.error_messages["empty"], code="empty")
244276

245277
def __iter__(self):
246-
# This does nothing but it does prevent inspections from complaining.
278+
# This does nothing, it does prevent inspections from complaining.
247279
return None # NoQA
248280

249281
def item_iter_from_object(self, obj) -> Iterator[Tuple[str, Any]]:

tests/test_fields_composite.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,24 @@ def assertResourceDictEqual(self, first, second):
5353

5454
# DictAs ##################################################################
5555

56-
def test_dictas_ensure_is_resource(self):
56+
def test_dictas__where_a_resource_is_not_supplied(self):
5757
with pytest.raises(TypeError):
5858
DictAs("an item")
5959

60+
def test_dictas__where_resource_resolution_is_delayed(self):
61+
target = DictAs.delayed(lambda: ExampleResource, null=True)
62+
63+
assert target.of is ExampleResource
64+
assert target.null is True
65+
66+
def test_dictas__where_resource_resolution_is_delayed_but_a_resource_is_not_supplied(
67+
self,
68+
):
69+
target = DictAs.delayed(lambda: "an item", null=True)
70+
71+
with pytest.raises(TypeError):
72+
assert target.of
73+
6074
def test_dictas_1(self):
6175
f = DictAs(ExampleResource)
6276
pytest.raises(ValidationError, f.clean, None)

0 commit comments

Comments
 (0)