@@ -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+
453531class 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
508590class 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
517599class 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 )
0 commit comments