diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 64ff976..5001e95 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -5,6 +5,10 @@ # Required version: 2 +sphinx: + # Path to your Sphinx configuration file. + configuration: docs/conf.py + # Set the version of Python and other tools you might need build: os: ubuntu-22.04 diff --git a/HISTORY b/HISTORY index d00642a..da83307 100644 --- a/HISTORY +++ b/HISTORY @@ -1,3 +1,15 @@ +2.11rc3 +======= + +Changes +------- + +- Move the inclusion of the type field (typically `$`) into the resource and not individual codecs. + Ensuring it is defined first. + + This could cause a breaking change to any tests that expect existing field order. + + 2.11rc2 ======= diff --git a/docs/conf.py b/docs/conf.py index 371bafe..9348d28 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -61,9 +61,9 @@ # built documents. # # The short X.Y version. -version = "1.0" +version = "2.0" # The full version, including alpha/beta/rc tags. -release = "1.0" +release = "2.0" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -271,4 +271,4 @@ # texinfo_no_detailmenu = False # Example configuration for intersphinx: refer to the Python standard library. -intersphinx_mapping = {"http://docs.python.org/": None} +intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} diff --git a/pyproject.toml b/pyproject.toml index a6afca8..c06310a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "odin" -version = "2.11rc2" +version = "2.11rc3" description = "Data-structure definition/validation/traversal, mapping and serialisation toolkit for Python" authors = ["Tim Savage "] license = "BSD-3-Clause" diff --git a/src/odin/codecs/dict_codec.py b/src/odin/codecs/dict_codec.py index 1cc1ec5..ed065f9 100644 --- a/src/odin/codecs/dict_codec.py +++ b/src/odin/codecs/dict_codec.py @@ -1,5 +1,4 @@ from odin import ResourceAdapter, bases, resources -from odin.utils import getmeta TYPE_SERIALIZERS = {} @@ -10,12 +9,8 @@ def __init__(self, include_virtual_fields=True, include_type_field=True): self.include_type_field = include_type_field def default(self, o): - if isinstance(o, (resources.ResourceBase, ResourceAdapter)): - meta = getmeta(o) - obj = o.to_dict(self.include_virtual_fields) - if self.include_type_field: - obj[meta.type_field] = meta.resource_name - return obj + if isinstance(o, resources.ResourceBase | ResourceAdapter): + return o.to_dict(self.include_virtual_fields, self.include_type_field) elif isinstance(o, bases.ResourceIterable): return list(o) elif o.__class__ in TYPE_SERIALIZERS: @@ -35,9 +30,11 @@ def encode(self, o): :param d: Dict to load - :param resource: A resource type, resource name or list of resources and names to use as the base for creating a - resource. If a list is supplied the first item will be used if a resource type is not supplied. - :raises ValidationError: During building of the object graph and issues discovered are raised as a ValidationError. + :param resource: A resource type, resource name or list of resources and names to + use as the base for creating a resource. If a list is supplied the first item + will be used if a resource type is not supplied. + :raises ValidationError: During building of the object graph and issues discovered + are raised as a ValidationError. """ @@ -46,8 +43,9 @@ def dump(resource, cls=OdinEncoder, **kwargs): """ Dump a resource structure into a nested :py:class:`dict`. - While a resource includes a *to_dict* method this method is not recursive. The dict codec recursively iterates - through the resource structure to produce a full dict. This is useful for testing for example. + While a resource includes a *to_dict* method this method is not recursive. The dict + codec recursively iterates through the resource structure to produce a full dict. + This is useful for testing for example. :param resource: The root resource to dump :param cls: Encoder class to utilise @@ -66,13 +64,13 @@ def _encode_dict(dct): return {k: _encode(o) for k, o in dct.items()} def _encode(o): - if isinstance(o, (list, tuple)): + if isinstance(o, list | tuple): return _encode_list(o) elif isinstance(o, dict): return _encode_dict(o) else: o = _default(o) - if isinstance(o, (list, tuple, dict)): + if isinstance(o, list | tuple | dict): return _encode(o) return o diff --git a/src/odin/codecs/json_codec.py b/src/odin/codecs/json_codec.py index 2261e6c..cfa87b1 100644 --- a/src/odin/codecs/json_codec.py +++ b/src/odin/codecs/json_codec.py @@ -5,7 +5,6 @@ from odin import ResourceAdapter, bases, resources, serializers from odin.exceptions import CodecDecodeError, CodecEncodeError -from odin.utils import getmeta LIST_TYPES = (bases.ResourceIterable, typing.ValuesView, typing.KeysView) JSON_TYPES = { @@ -18,9 +17,7 @@ class OdinEncoder(json.JSONEncoder): - """ - Encoder for Odin resources. - """ + """Encoder for Odin resources.""" def __init__( self, include_virtual_fields=True, include_type_field=True, *args, **kwargs @@ -30,12 +27,8 @@ def __init__( self.include_type_field = include_type_field def default(self, o): - if isinstance(o, (resources.ResourceBase, ResourceAdapter)): - meta = getmeta(o) - obj = o.to_dict(self.include_virtual_fields) - if self.include_type_field: - obj[meta.type_field] = meta.resource_name - return obj + if isinstance(o, resources.ResourceBase | ResourceAdapter): + return o.to_dict(self.include_virtual_fields, self.include_type_field) elif isinstance(o, LIST_TYPES): return list(o) @@ -53,11 +46,12 @@ def load(fp, resource=None, full_clean=True, default_to_not_supplied=False): See :py:meth:`loads` for more details of the loading operation. :param fp: a file pointer to read JSON data from. - :param resource: A resource type, resource name or list of resources and names to use as the base for creating a - resource. If a list is supplied the first item will be used if a resource type is not supplied. + :param resource: A resource type, resource name or list of resources and names to + use as the base for creating a resource. If a list is supplied the first item + will be used if a resource type is not supplied. :param full_clean: Do a full clean of the object as part of the loading process. - :param default_to_not_supplied: Used for loading partial resources. Any fields not supplied are replaced with - NOT_SUPPLIED. + :param default_to_not_supplied: Used for loading partial resources. Any fields not + supplied are replaced with NOT_SUPPLIED. :returns: A resource object or object graph of resources loaded from file. """ @@ -68,18 +62,21 @@ def loads(s, resource=None, full_clean=True, default_to_not_supplied=False): """ Load from a JSON encoded string. - If a ``resource`` value is supplied it is used as the base resource for the supplied JSON. I one is not supplied a - resource type field ``$`` is used to obtain the type represented by the dictionary. A ``ValidationError`` will be - raised if either of these values are supplied and not compatible. It is valid for a type to be supplied in the file - to be a child object from within the inheritance tree. + If a ``resource`` value is supplied it is used as the base resource for the + supplied JSON. I one is not supplied a resource type field ``$`` is used to obtain + the type represented by the dictionary. A ``ValidationError`` will be raised if + either of these values are supplied and not compatible. It is valid for a type to + be supplied in the file to be a child object from within the inheritance tree. :param s: String to load and parse. - :param resource: A resource type, resource name or list of resources and names to use as the base for creating a - resource. If a list is supplied the first item will be used if a resource type is not supplied. + :param resource: A resource type, resource name or list of resources and names to + use as the base for creating a resource. If a list is supplied the first item + will be used if a resource type is not supplied. :param full_clean: Do a full clean of the object as part of the loading process. - :param default_to_not_supplied: Used for loading partial resources. Any fields not supplied are replaced with - NOT_SUPPLIED. - :returns: A resource object or object graph of resources parsed from supplied string. + :param default_to_not_supplied: Used for loading partial resources. Any fields not + supplied are replaced with NOT_SUPPLIED. + :returns: A resource object or object graph of resources parsed from supplied + string. """ try: @@ -95,7 +92,8 @@ def dump(resource, fp, cls=OdinEncoder, **kwargs): Dump to a JSON encoded file. :param resource: The root resource to dump to a JSON encoded file. - :param cls: Encoder to use serializing to a string; default is the :py:class:`OdinEncoder`. + :param cls: Encoder to use serializing to a string; default is the + :py:class:`OdinEncoder`. :param fp: The file pointer that represents the output file. """ @@ -110,7 +108,8 @@ def dumps(resource, cls=OdinEncoder, **kwargs): Dump to a JSON encoded string. :param resource: The root resource to dump to a JSON encoded file. - :param cls: Encoder to use serializing to a string; default is the :py:class:`OdinEncoder`. + :param cls: Encoder to use serializing to a string; default is the + :py:class:`OdinEncoder`. :returns: JSON encoded string. """ diff --git a/src/odin/codecs/msgpack_codec.py b/src/odin/codecs/msgpack_codec.py index a777d8e..3b5ec90 100644 --- a/src/odin/codecs/msgpack_codec.py +++ b/src/odin/codecs/msgpack_codec.py @@ -1,4 +1,5 @@ """Codec to load/save Message Pack (msgpack) documents.""" + import datetime import uuid from typing import TextIO @@ -11,7 +12,6 @@ ) from None # noqa from odin import ResourceAdapter, bases, resources, serializers -from odin.utils import getmeta TYPE_SERIALIZERS = { datetime.date: serializers.date_iso_format, @@ -31,11 +31,8 @@ def __init__(self, include_virtual_fields: bool = True, *args, **kwargs): self.include_virtual_fields = include_virtual_fields def default(self, o): - if isinstance(o, (resources.ResourceBase, ResourceAdapter)): - meta = getmeta(o) - obj = o.to_dict(self.include_virtual_fields) - obj[meta.type_field] = meta.resource_name - return obj + if isinstance(o, resources.ResourceBase | ResourceAdapter): + return o.to_dict(self.include_virtual_fields, True) elif isinstance(o, bases.ResourceIterable): return list(o) @@ -55,7 +52,8 @@ def load( See :py:meth:`loads` for more details of the loading operation. :param fp: a file pointer to read MessagePack data from. - :param resource: A resource instance or a resource name to use as the base for creating a resource. + :param resource: A resource instance or a resource name to use as the base for + creating a resource. :param full_clean: Do a full clean of the object as part of the loading process. :param default_to_not_supplied: :returns: A resource object or object graph of resources loaded from file. @@ -73,16 +71,20 @@ def loads( ): """Load from a MessagePack encoded string/bytes. - If a ``resource`` value is supplied it is used as the base resource for the supplied MessagePack data. I one is not - supplied a resource type field ``$`` is used to obtain the type represented by the dictionary. A ``ValidationError`` - will be raised if either of these values are supplied and not compatible. It is valid for a type to be supplied in - the file to be a child object from within the inheritance tree. + If a ``resource`` value is supplied it is used as the base resource for the + supplied MessagePack data. I one is not supplied a resource type field ``$`` is + used to obtain the type represented by the dictionary. A ``ValidationError`` + will be raised if either of these values are supplied and not compatible. It is + valid for a type to be supplied in the file to be a child object from within the + inheritance tree. :param s: String to load and parse. - :param resource: A resource instance or a resource name to use as the base for creating a resource. + :param resource: A resource instance or a resource name to use as the base for + creating a resource. :param full_clean: Do a full clean of the object as part of the loading process. :param default_to_not_supplied: - :returns: A resource object or object graph of resources parsed from supplied string. + :returns: A resource object or object graph of resources parsed from supplied + string. """ return resources.build_object_graph( msgpack.loads(s), resource, full_clean, False, default_to_not_supplied @@ -94,13 +96,14 @@ def dump( fp: TextIO, cls=OdinPacker, include_virtual_fields: bool = True, - **kwargs + **kwargs, ): """Dump to a MessagePack encoded file. :param include_virtual_fields: :param resource: The root resource to dump to a MessagePack encoded file. - :param cls: Encoder to use serializing to a string; default is the :py:class:`OdinEncoder`. + :param cls: Encoder to use serializing to a string; default is the + :py:class:`OdinEncoder`. :param fp: The file pointer that represents the output file. """ fp.write(cls(include_virtual_fields, **kwargs).pack(resource)) @@ -110,13 +113,14 @@ def dumps( resource: resources.ResourceBase, cls=OdinPacker, include_virtual_fields: bool = True, - **kwargs + **kwargs, ): """Dump to a MessagePack encoded string. :param include_virtual_fields: :param resource: The root resource to dump to a MessagePack encoded file. - :param cls: Encoder to use serializing to a string; default is the :py:class:`OdinEncoder`. + :param cls: Encoder to use serializing to a string; default is the + :py:class:`OdinEncoder`. :returns: MessagePack encoded string. """ return cls(include_virtual_fields, **kwargs).pack(resource) diff --git a/src/odin/codecs/toml_codec.py b/src/odin/codecs/toml_codec.py index ef873f9..635a3a7 100644 --- a/src/odin/codecs/toml_codec.py +++ b/src/odin/codecs/toml_codec.py @@ -3,7 +3,6 @@ from odin import ResourceAdapter, resources from odin.exceptions import CodecDecodeError from odin.resources import ResourceBase -from odin.utils import getmeta try: import toml @@ -21,17 +20,20 @@ def load(fp, resource=None, full_clean=True, default_to_not_supplied=False): """ Load a resource from a TOML encoded file. - If a ``resource`` value is supplied it is used as the base resource for the supplied YAML. If one is not - supplied, a resource type field ``$`` is used to obtain the type represented by the dictionary. A - ``ValidationError`` will be raised if either of these values are supplied and not compatible. It is valid for a - type to be supplied in the file to be a child object from within the inheritance tree. + If a ``resource`` value is supplied it is used as the base resource for the + supplied YAML. If one is not supplied, a resource type field ``$`` is used to + obtain the type represented by the dictionary. A ``ValidationError`` will be + raised if either of these values are supplied and not compatible. It is valid for a + type to be supplied in the file to be a child object from within the inheritance + tree. :param fp: a file pointer to read TOML data format. - :param resource: A resource type, resource name or list of resources and names to use as the base for creating a - resource. If a list is supplied the first item will be used if a resource type is not supplied. + :param resource: A resource type, resource name or list of resources and names to + use as the base for creating a resource. If a list is supplied the first item + will be used if a resource type is not supplied. :param full_clean: Do a full clean of the object as part of the loading process. - :param default_to_not_supplied: Used for loading partial resources. Any fields not supplied are replaced with - NOT_SUPPLIED. + :param default_to_not_supplied: Used for loading partial resources. Any fields not + supplied are replaced with NOT_SUPPLIED. :returns: A resource object or object graph of resources loaded from file. """ @@ -53,17 +55,19 @@ def load(fp, resource=None, full_clean=True, default_to_not_supplied=False): def loads(s, resource=None, full_clean=True, default_to_not_supplied=False): """Load a resource from a TOML encoded string. - If a ``resource`` value is supplied it is used as the base resource for the supplied YAML. If one is not - supplied, a resource type field ``$`` is used to obtain the type represented by the dictionary. A - ``ValidationError`` will be raised if either of these values are supplied and not compatible. It is valid for a - type to be supplied in the file to be a child object from within the inheritance tree. + If a ``resource`` value is supplied it is used as the base resource for the + supplied YAML. If one is not supplied, a resource type field ``$`` is used to + obtain the type represented by the dictionary. A ``ValidationError`` will be raised + if either of these values are supplied and not compatible. It is valid for a type + to be supplied in the file to be a child object from within the inheritance tree. :param s: a string containing TOML. - :param resource: A resource type, resource name or list of resources and names to use as the base for creating a - resource. If a list is supplied the first item will be used if a resource type is not supplied. + :param resource: A resource type, resource name or list of resources and names to + use as the base for creating a resource. If a list is supplied the first item + will be used if a resource type is not supplied. :param full_clean: Do a full clean of the object as part of the loading process. - :param default_to_not_supplied: Used for loading partial resources. Any fields not supplied are replaced with - NOT_SUPPLIED. + :param default_to_not_supplied: Used for loading partial resources. Any fields not + supplied are replaced with NOT_SUPPLIED. :returns: A resource object or object graph of resources loaded from file. """ @@ -97,11 +101,7 @@ def __init__( self.include_type_field = include_type_field def resource_to_dict(self, v): - resource_dict = v.to_dict(self.include_virtual_fields) - if self.include_type_field: - meta = getmeta(v) - resource_dict[meta.type_field] = meta.resource_name - return resource_dict + return v.to_dict(self.include_virtual_fields, self.include_type_field) def dump_value(self, v): if isinstance(v, ResourceBase | ResourceAdapter): @@ -133,7 +133,8 @@ def dump( :param resource: The root resource to dump to a JSON encoded file. :param fp: The file pointer that represents the output file. - :param encoder: Encoder to use serializing to a string; default is the :py:class:`OdinEncoder`. + :param encoder: Encoder to use serializing to a string; default is the + :py:class:`OdinEncoder`. :param include_virtual_fields: Include virtual fields in the output :param kwargs: Additional keyword arguments for the encoder. """ @@ -154,7 +155,8 @@ def dumps( """Dump to a TOML encoded file. :param resource: The root resource to dump to a JSON encoded file. - :param encoder: Encoder to use serializing to a string; default is the :py:class:`OdinEncoder`. + :param encoder: Encoder to use serializing to a string; default is the + :py:class:`OdinEncoder`. :param include_virtual_fields: Include virtual fields in the output :param kwargs: Additional keyword arguments for the encoder. :returns: TOML encoded string. diff --git a/src/odin/codecs/yaml_codec.py b/src/odin/codecs/yaml_codec.py index cac19f0..99218ba 100644 --- a/src/odin/codecs/yaml_codec.py +++ b/src/odin/codecs/yaml_codec.py @@ -5,7 +5,6 @@ from odin import ResourceAdapter, bases, resources from odin.exceptions import CodecEncodeError -from odin.utils import getmeta try: import yaml @@ -39,10 +38,7 @@ def __init__( self.include_type_field = include_type_field def represent_resource(self, data): - obj = data.to_dict(self.include_virtual_fields) - if self.include_type_field: - meta = getmeta(data) - obj[meta.type_field] = meta.resource_name + obj = data.to_dict(self.include_virtual_fields, self.include_type_field) return self.represent_dict(obj) @@ -59,17 +55,19 @@ def load( ): """Load a resource from a YAML encoded file. - If a ``resource`` value is supplied it is used as the base resource for the supplied YAML. I one is not supplied - a resource type field ``$`` is used to obtain the type represented by the dictionary. A ``ValidationError`` will - be raised if either of these values are supplied and not compatible. It is valid for a type to be supplied in the - file to be a child object from within the inheritance tree. + If a ``resource`` value is supplied it is used as the base resource for the + supplied YAML. I one is not supplied a resource type field ``$`` is used to obtain + the type represented by the dictionary. A ``ValidationError`` will be raised if + either of these values are supplied and not compatible. It is valid for a type to + be supplied in the file to be a child object from within the inheritance tree. :param fp: a file pointer to read YAML data fromat. - :param resource: A resource type, resource name or list of resources and names to use as the base for creating a - resource. If a list is supplied the first item will be used if a resource type is not supplied. + :param resource: A resource type, resource name or list of resources and names to + use as the base for creating a resource. If a list is supplied the first item + will be used if a resource type is not supplied. :param full_clean: Do a full clean of the object as part of the loading process. - :param default_to_not_supplied: Used for loading partial resources. Any fields not supplied are replaced with - NOT_SUPPLIED. + :param default_to_not_supplied: Used for loading partial resources. Any fields not + supplied are replaced with NOT_SUPPLIED. :returns: A resource object or object graph of resources loaded from file. """ @@ -90,7 +88,8 @@ def dump(resource: resources.ResourceBase, fp: TextIO, dumper=OdinDumper, **kwar """Dump to a YAML encoded file. :param resource: The root resource to dump to a YAML encoded file. - :param dumper: Dumper to use serializing to a string; default is the :py:class:`OdinDumper`. + :param dumper: Dumper to use serializing to a string; default is the + :py:class:`OdinDumper`. :param fp: The file pointer that represents the output file. """ @@ -104,7 +103,8 @@ def dumps(resource: resources.ResourceBase, dumper=OdinDumper, **kwargs) -> str: """Dump to a YAML encoded string. :param resource: The root resource to dump to a YAML encoded file. - :param dumper: Dumper to use serializing to a string; default is the :py:class:`OdinDumper`. + :param dumper: Dumper to use serializing to a string; default is the + :py:class:`OdinDumper`. :returns: YAML encoded string. """ diff --git a/src/odin/fields/composite.py b/src/odin/fields/composite.py index 975ef6c..9915e04 100644 --- a/src/odin/fields/composite.py +++ b/src/odin/fields/composite.py @@ -60,9 +60,8 @@ def __init__(self, resource, use_container=False, **options): 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." - ) + msg = f"{resource!r} is not a valid type for a related field." + raise TypeError(msg) else: self._of = resource self.use_container = use_container @@ -79,9 +78,8 @@ def of(self): 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." - ) + msg = f"{resource!r} is not a valid type for a related field." + raise TypeError(msg) return resource def to_python(self, value): diff --git a/src/odin/resources.py b/src/odin/resources.py index cc5a344..b515d4b 100644 --- a/src/odin/resources.py +++ b/src/odin/resources.py @@ -456,7 +456,7 @@ def create_from_dict(cls, d, full_clean=False): """Create a resource instance from a dictionary.""" return create_resource_from_dict(d, cls, full_clean) - def to_dict(self, include_virtual=True): + def to_dict(self, include_virtual: bool = True, include_type_field: bool = False): """Convert this resource into a `dict` of field_name/value pairs. .. note:: @@ -466,10 +466,13 @@ def to_dict(self, include_virtual=True): `dict`. :param include_virtual: Include virtual fields when generating `dict`. + :param include_type_field: Include type field when generating `dict`. """ meta = getmeta(self) fields = meta.all_fields if include_virtual else meta.fields - return {f.name: v for f, v in field_iter_items(self, fields)} + result = {meta.type_field: meta.resource_name} if include_type_field else {} + result.update((f.name, v) for f, v in field_iter_items(self, fields)) + return result def convert_to(self, to_resource, context=None, ignore_fields=None, **field_values): """Convert this resource into a specified resource. diff --git a/src/odin/utils/__init__.py b/src/odin/utils/__init__.py index 4befba9..987cc59 100644 --- a/src/odin/utils/__init__.py +++ b/src/odin/utils/__init__.py @@ -151,9 +151,7 @@ def field_iter(resource, include_virtual: bool = True) -> Iterator: return iter(meta.fields) -def field_iter_items( - resource, fields: Sequence | None = None -) -> Iterator[tuple[str, Any]]: +def field_iter_items(resource, fields: Sequence | None = None) -> Iterator[tuple]: """Return an iterator that yields fields and their values from a resource. :param resource: Resource to iterate over. @@ -168,7 +166,7 @@ def field_iter_items( yield f, f.prepare(f.value_from_object(resource)) -def virtual_field_iter_items(resource) -> Iterator[tuple[str, Any]]: +def virtual_field_iter_items(resource) -> Iterator[tuple]: """Return an iterator that yields virtual fields and their values from a resource. :param resource: Resource to iterate over. @@ -179,7 +177,7 @@ def virtual_field_iter_items(resource) -> Iterator[tuple[str, Any]]: return field_iter_items(resource, meta.virtual_fields) -def attribute_field_iter_items(resource) -> Iterator[tuple[str, Any]]: +def attribute_field_iter_items(resource) -> Iterator[tuple]: """Return an iterator that yields fields and their values from a resource that have the attribute flag. :param resource: Resource to iterate over. @@ -192,7 +190,7 @@ def attribute_field_iter_items(resource) -> Iterator[tuple[str, Any]]: return field_iter_items(resource, getmeta(resource).attribute_fields) -def element_field_iter_items(resource) -> Iterator[tuple[str, Any]]: +def element_field_iter_items(resource) -> Iterator[tuple]: """Return an iterator that yields fields and their values from a resource that do not have the attribute flag. :param resource: Resource to iterate over.