Skip to content

Commit 3845203

Browse files
committed
converters: Make it possible to have version-specific fields in the API
1 parent 92e84a4 commit 3845203

2 files changed

Lines changed: 230 additions & 27 deletions

File tree

naucse/converters.py

Lines changed: 109 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,33 @@ def _classname(cls):
275275
return f'{cls.__module__}.{cls.__qualname__}'
276276

277277

278-
class Field:
278+
class AbstractField:
279+
"""Descriptor for a Model's attribute that is loaded/dumped to JSON
280+
281+
See Field for the API.
282+
"""
283+
284+
def __get__(self, instance, owner):
285+
"""Debug helper
286+
287+
An initialized model instance should have values for all its fields
288+
in its __dict__.
289+
290+
However, when loading, not all fields have been initialized yet.
291+
Attempts to get the value of such a field will raise an informative
292+
error, rather than return the Field object from the model class.
293+
"""
294+
if instance is None:
295+
# Getting a class attribute -- return this Field
296+
return self
297+
else:
298+
# Getting an instnce attribute -- raise an error
299+
type_name = owner.__name__
300+
raise AttributeError(
301+
f'{self.name!r} of {type_name} object was not yet loaded'
302+
)
303+
304+
class Field(AbstractField):
279305
"""Descriptor for a Model's attribute that is loaded/dumped to JSON
280306
281307
`converter`: Converter to use for the attribute.
@@ -406,26 +432,6 @@ def put_schema_into(self, object_schema, context):
406432
if not context.is_input:
407433
object_schema['additionalProperties'] = False
408434

409-
def __get__(self, instance, owner):
410-
"""Debug helper
411-
412-
An initialized model instance should have values for all its fields
413-
in its __dict__.
414-
415-
However, when loading, not all fields have been initialized yet.
416-
Attempts to get the value of such a field will raise an informative
417-
error, rather than return the Field object from the model class.
418-
"""
419-
if instance is None:
420-
# Getting a class attribute -- return this Field
421-
return self
422-
else:
423-
# Getting an instnce attribute -- raise an error
424-
type_name = owner.__name__
425-
raise AttributeError(
426-
f'{self.name!r} of {type_name} object was not yet loaded'
427-
)
428-
429435
def default_factory(self):
430436
"""Decorate a function that will be called to produce a default value
431437
@@ -450,20 +456,96 @@ def _decorator(func):
450456
return _decorator
451457

452458

459+
class VersionField(AbstractField):
460+
"""Chooses Field based on the API version
461+
462+
`fields` should be a {version introduced: field} mapping.
463+
When loading/dumping/getting schema, VersionField picks the field for
464+
tat version and forwards the operation to it.
465+
For versions before the first specified, the field is not loaded/dumped,
466+
and the instance attribute is set to None.
467+
468+
VersionField adds a "Added/Modified in API version" note to the JSON Schema
469+
description.
470+
471+
Making later fields suitably backwards-compatible is the user's
472+
responsibility.
473+
"""
474+
475+
def __init__(self, fields, name=None):
476+
self.fields = sorted((tuple(k), f) for k, f in fields.items())
477+
self.name = name
478+
479+
def _field_for_context(self, context):
480+
for version, field in reversed(self.fields):
481+
if version <= context.version:
482+
return version, field
483+
return None, None
484+
485+
def __repr__(self):
486+
return f'<{_classname(type(self))} {self.name} ({self.fields})>'
487+
488+
def __set_name__(self, cls, name):
489+
self.name = name
490+
for version, field in self.fields:
491+
set_name = getattr(type(field), '__set_name__', None)
492+
if set_name:
493+
set_name(field, cls, name)
494+
495+
def load_into(self, instance, data, context, **kwargs):
496+
version, field = self._field_for_context(context)
497+
if field:
498+
field.load_into(instance, data, context, **kwargs)
499+
else:
500+
setattr(instance, self.name, None)
501+
502+
def dump_into(self, instance, data, context):
503+
version, field = self._field_for_context(context)
504+
if field:
505+
field.dump_into(instance, data, context)
506+
507+
def put_schema_into(self, object_schema, context):
508+
version, field = self._field_for_context(context)
509+
if field:
510+
field.put_schema_into(object_schema, context)
511+
try:
512+
schema = object_schema['properties'][self.name]
513+
except KeyError:
514+
pass
515+
if version == self.fields[0][0]:
516+
note = 'Added in API version {}.{}'.format(*version)
517+
else:
518+
note = 'Modified in API version {}.{}'.format(*version)
519+
if 'description' in schema:
520+
schema['description'] += '\n\n' + note
521+
else:
522+
schema['description'] = note
523+
524+
def default_factory(self):
525+
raise NotImplementedError('default_factory is not implemented yet')
526+
527+
def after_load(self):
528+
raise NotImplementedError('after_load is not implemented yet')
529+
530+
453531
class ModelConverter(BaseConverter):
454532
"""Converter for a Model, i.e. class with several Fields"""
455533
def __init__(
456534
self, cls, *, slug=None, load_arg_names=(), extra_fields=(),
457535
):
458536
self.cls = cls
459537
self.name = cls.__name__
460-
self.doc = inspect.getdoc(cls).strip()
538+
doc = inspect.getdoc(cls)
539+
if doc:
540+
self.doc = doc.strip()
541+
else:
542+
self.doc = ''
461543
self.fields = {}
462544
self.load_arg_names = load_arg_names
463545
self.slug = slug
464546

465547
for name, field in vars(cls).items():
466-
if name.startswith('__') or not isinstance(field, Field):
548+
if name.startswith('__') or not isinstance(field, AbstractField):
467549
continue
468550
self.fields[name] = field
469551
self.fields.update((f.name, f) for f in extra_fields)
@@ -502,7 +584,7 @@ class LoadContext:
502584
`version` is the API version, as a tuple of ints (major, minor).
503585
"""
504586
def __init__(self, version):
505-
self.version = version
587+
self.version = tuple(version)
506588

507589

508590
class DumpContext:
@@ -511,7 +593,7 @@ class DumpContext:
511593
`version` is the API version, as a tuple of ints (major, minor).
512594
"""
513595
def __init__(self, version):
514-
self.version = version
596+
self.version = tuple(version)
515597

516598

517599
class SchemaContext:
@@ -526,7 +608,7 @@ def __init__(self, *, is_input, version):
526608
self.definition_refs = {}
527609
self.definitions = {}
528610
self.is_input = is_input
529-
self.version = version
611+
self.version = tuple(version)
530612

531613
def get_schema(self, converter):
532614
"""Get schema for the given converter
@@ -616,7 +698,7 @@ def dump(instance, converter=None, *, version):
616698
slug = converter.slug or 'data'
617699
context = DumpContext(version=version)
618700
result = {
619-
'api_version': context.version,
701+
'api_version': list(context.version),
620702
slug: converter.dump(instance, context),
621703
}
622704
result['$schema'] = _get_schema_url(converter, instance)
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import pytest
2+
from jsonschema.exceptions import ValidationError
3+
4+
from naucse.converters import Field, VersionField, load, dump, get_schema
5+
from naucse.converters import register_model, get_converter
6+
7+
8+
class TestModel:
9+
versioned_field = VersionField({
10+
# (Versions are out of order to test that VersionField sorts them)
11+
(0, 1): Field(str, optional=True, doc='Introducing new field'),
12+
(1, 0): Field(int, optional=True, doc="Let's make it an int"),
13+
(0, 5): Field(bool, optional=True, doc="Actually it's a bool"),
14+
(2, 0): Field(int, doc='No longer optional'),
15+
})
16+
17+
register_model(TestModel)
18+
get_converter(TestModel).get_schema_url = lambda *a, **ka: ""
19+
20+
TEST_DATA = {
21+
(0, 1): "a",
22+
(0, 2): "b",
23+
(0, 5): True,
24+
(0, 6): False,
25+
(1, 0): 123,
26+
(2, 1): 456,
27+
}
28+
29+
30+
@pytest.mark.parametrize(
31+
'version',
32+
((0, 0), (0, 1), (0, 2), (0, 5), (0, 6), (1, 0)),
33+
)
34+
def test_load_nothing(version):
35+
result = load(TestModel, {
36+
'api_version': list(version),
37+
'data': {},
38+
})
39+
assert result.versioned_field == None
40+
41+
42+
def test_not_optional():
43+
with pytest.raises(ValidationError):
44+
load(TestModel, {
45+
'api_version': [2, 0],
46+
'data': {},
47+
})
48+
49+
50+
@pytest.mark.parametrize(('version', 'data'), TEST_DATA.items())
51+
def test_load_data(version, data):
52+
result = load(TestModel, {
53+
'api_version': list(version),
54+
'data': {'versioned_field': data},
55+
})
56+
assert result.versioned_field == data
57+
58+
59+
@pytest.mark.parametrize(
60+
('version', 'data'),
61+
(
62+
((0, 0), 123),
63+
((0, 0), "ab"),
64+
((0, 1), True),
65+
((1, 0), "ab"),
66+
((1, 1), "ab"),
67+
),
68+
)
69+
def test_load_wrong_data(version, data):
70+
with pytest.raises(ValidationError):
71+
result = load(TestModel, {
72+
'api_version': (0, 0),
73+
'data': {'versioned_field': 123},
74+
})
75+
76+
77+
@pytest.mark.parametrize(('version', 'data'), TEST_DATA.items())
78+
def test_dump_data(version, data):
79+
data_dict = {
80+
'api_version': list(version),
81+
'data': {'versioned_field': data},
82+
}
83+
result = load(TestModel, data_dict)
84+
assert dump(result, version=version) == {'$schema': '', **data_dict}
85+
86+
87+
@pytest.mark.parametrize(('version', 'data'), TEST_DATA.items())
88+
def test_dump_v0(version, data):
89+
data_dict = {
90+
'api_version': list(version),
91+
'data': {'versioned_field': data},
92+
}
93+
result = load(TestModel, data_dict)
94+
assert dump(result, version=(0, 0)) == {
95+
'$schema': '',
96+
'api_version': [0, 0],
97+
'data': {},
98+
}
99+
100+
101+
@pytest.mark.parametrize(
102+
('version', 'expected'),
103+
{
104+
(0, 0): None,
105+
(0, 1): "Introducing new field\n\nAdded in API version 0.1",
106+
(0, 2): "Introducing new field\n\nAdded in API version 0.1",
107+
(0, 5): "Actually it's a bool\n\nModified in API version 0.5",
108+
(1, 0): "Let's make it an int\n\nModified in API version 1.0",
109+
(1, 1): "Let's make it an int\n\nModified in API version 1.0",
110+
(2, 1): 'No longer optional\n\nModified in API version 2.0',
111+
}.items()
112+
)
113+
def test_doc(version, expected):
114+
schema = get_schema(TestModel, is_input=True, version=version)
115+
print(schema)
116+
properties = schema['properties']['data']['properties']
117+
if expected == None:
118+
assert 'versioned_field' not in properties
119+
else:
120+
doc = properties['versioned_field']['description']
121+
assert doc == expected

0 commit comments

Comments
 (0)