diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index 0c85734..0000000 --- a/.coveragerc +++ /dev/null @@ -1,5 +0,0 @@ -[run] -source = odin - -[report] -omit = */contrib/doc_gen/*,*/contrib/inspect/* diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..86c1fbe --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: timsavage diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d19ca8b..191aed4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,42 +3,11 @@ default_language_version: python: python3.10 repos: -#- repo: https://github.com/pre-commit/pre-commit-hooks -# rev: v4.4.0 -# hooks: -# - id: check-added-large-files -# - id: check-toml -# - id: check-yaml -# args: -# - --unsafe -# - id: end-of-file-fixer -# - id: trailing-whitespace -- repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 - hooks: - - id: pyupgrade - args: - - --py38-plus - - --keep-runtime-typing -- repo: https://github.com/charliermarsh/ruff-pre-commit - rev: v0.0.270 - hooks: - - id: ruff - args: - - --fix - - --exit-non-zero-on-fix -- repo: https://github.com/pycqa/isort - rev: 5.12.0 - hooks: - - id: isort - name: isort (python) - - id: isort - name: isort (cython) - types: [cython] - - id: isort - name: isort (pyi) - types: [pyi] -- repo: https://github.com/psf/black - rev: 23.1.0 - hooks: - - id: black + - repo: https://github.com/astral-sh/ruff-pre-commit + # Ruff version. + rev: v0.2.1 + hooks: + # Run the linter. + - id: ruff + # Run the formatter. + - id: ruff-format diff --git a/HISTORY b/HISTORY index efc9c22..9f31be3 100644 --- a/HISTORY +++ b/HISTORY @@ -1,3 +1,24 @@ +2.10rc1 +======= + +Changes +------- + +- Simplify the internals of the Resource metaclass to make it easier to understand + and maintain. Greater sharing of code between Resource and AnnotatedResource + + This change has not effect on the public API. + + Removes some compatibility code with versions prior to Python 3.8. + +- Support shadowing of fields on a resource. To enabled this feature set the + ``allow_field_shadowing`` meta option to ``True``. This allows for fields to be + overridden on a resource. + + ResourceObjects now includes a shadow_fields listing all fields shadowed by this + resource. + + 2.9 === diff --git a/README.rst b/README.rst index 683e519..c28e51b 100644 --- a/README.rst +++ b/README.rst @@ -21,7 +21,7 @@ Odin also comes with built in serialisation tools for importing and exporting da | | :alt: Python package | +---------+-------------------------------------------------------------------------------------------------------------+ | Quality | .. image:: https://sonarcloud.io/api/project_badges/measure?project=python-odin_odin&metric=sqale_rating | -| | :target: https://sonarcloud.io/dashboard?id=python-odin/odin | +| | :target: https://sonarcloud.io/dashboard?id=python-odin_odin | | | :alt: Maintainability | | | .. image:: https://sonarcloud.io/api/project_badges/measure?project=python-odin_odin&metric=security_rating | | | :target: https://sonarcloud.io/project/security_hotspots | diff --git a/docs/ref/resources/options.rst b/docs/ref/resources/options.rst index 93c2e75..43ebce1 100644 --- a/docs/ref/resources/options.rst +++ b/docs/ref/resources/options.rst @@ -85,7 +85,7 @@ Meta Options to sort fields in the child resource before appending the fields from the parent resource(s). - Settings this option to ``True`` will cause field sorting to happen after all of + Setting this option to ``True`` will cause field sorting to happen after all of the fields have been attached using the default sort method. The default method sorts the fields by the order they are defined. @@ -112,4 +112,10 @@ Meta Options class Meta: user_data = { "custom": "my-custom-value", - } \ No newline at end of file + } + +``allow_field_shadowing`` + Allow fields to be shadow fields with the same name in a parent resource. + + Setting this option to ``True`` will allow fields to be shadowed without an exception + being raised. The default behaviour is to raise an exception if a field is shadowed. diff --git a/poetry.lock b/poetry.lock index 0a310fc..9bca6d2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,10 +1,9 @@ -# This file is automatically @generated by Poetry 1.4.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "alabaster" version = "0.7.13" description = "A configurable sidebar-enabled Sphinx theme" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -16,7 +15,6 @@ files = [ name = "arrow" version = "1.2.3" description = "Better dates & times for Python" -category = "main" optional = true python-versions = ">=3.6" files = [ @@ -31,7 +29,6 @@ python-dateutil = ">=2.7.0" name = "babel" version = "2.12.1" description = "Internationalization utilities" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -44,21 +41,19 @@ pytz = {version = ">=2015.7", markers = "python_version < \"3.9\""} [[package]] name = "certifi" -version = "2023.5.7" +version = "2023.7.22" description = "Python package for providing Mozilla's CA Bundle." -category = "dev" optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.5.7-py3-none-any.whl", hash = "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716"}, - {file = "certifi-2023.5.7.tar.gz", hash = "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7"}, + {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"}, + {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"}, ] [[package]] name = "charset-normalizer" version = "3.1.0" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "dev" optional = false python-versions = ">=3.7.0" files = [ @@ -143,7 +138,6 @@ files = [ name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -155,7 +149,6 @@ files = [ name = "coverage" version = "7.2.7" description = "Code coverage measurement for Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -231,7 +224,6 @@ toml = ["tomli"] name = "docutils" version = "0.20.1" description = "Docutils -- Python Documentation Utilities" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -243,7 +235,6 @@ files = [ name = "exceptiongroup" version = "1.1.1" description = "Backport of PEP 654 (exception groups)" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -258,7 +249,6 @@ test = ["pytest (>=6)"] name = "idna" version = "3.4" description = "Internationalized Domain Names in Applications (IDNA)" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -270,7 +260,6 @@ files = [ name = "imagesize" version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" -category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -282,7 +271,6 @@ files = [ name = "importlib-metadata" version = "6.6.0" description = "Read metadata from Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -302,7 +290,6 @@ testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packag name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -312,14 +299,13 @@ files = [ [[package]] name = "jinja2" -version = "3.1.2" +version = "3.1.3" description = "A very fast and expressive template engine." -category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, - {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, + {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, + {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, ] [package.dependencies] @@ -332,7 +318,6 @@ i18n = ["Babel (>=2.7)"] name = "markdown-it-py" version = "2.2.0" description = "Python port of markdown-it. Markdown parsing, done right!" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -357,7 +342,6 @@ testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] name = "markupsafe" version = "2.1.2" description = "Safely add untrusted strings to HTML/XML markup." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -417,7 +401,6 @@ files = [ name = "mdurl" version = "0.1.2" description = "Markdown URL utilities" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -429,7 +412,6 @@ files = [ name = "msgpack" version = "1.0.5" description = "MessagePack serializer" -category = "main" optional = true python-versions = "*" files = [ @@ -502,7 +484,6 @@ files = [ name = "packaging" version = "23.1" description = "Core utilities for Python packages" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -514,7 +495,6 @@ files = [ name = "pint" version = "0.21.1" description = "Physical quantities module" -category = "main" optional = true python-versions = ">=3.8" files = [ @@ -536,7 +516,6 @@ xarray = ["xarray"] name = "pluggy" version = "1.0.0" description = "plugin and hook calling mechanisms for python" -category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -552,7 +531,6 @@ testing = ["pytest", "pytest-benchmark"] name = "pygments" version = "2.15.1" description = "Pygments is a syntax highlighting package written in Python." -category = "main" optional = false python-versions = ">=3.7" files = [ @@ -567,7 +545,6 @@ plugins = ["importlib-metadata"] name = "pytest" version = "7.3.1" description = "pytest: simple powerful testing with Python" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -590,7 +567,6 @@ testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "no name = "pytest-cov" version = "4.1.0" description = "Pytest plugin for measuring coverage." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -609,7 +585,6 @@ testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtuale name = "python-dateutil" version = "2.8.2" description = "Extensions to the standard Python datetime module" -category = "main" optional = true python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" files = [ @@ -624,7 +599,6 @@ six = ">=1.5" name = "pytz" version = "2023.3" description = "World timezone definitions, modern and historical" -category = "dev" optional = false python-versions = "*" files = [ @@ -636,7 +610,6 @@ files = [ name = "pyyaml" version = "6.0" description = "YAML parser and emitter for Python" -category = "main" optional = true python-versions = ">=3.6" files = [ @@ -686,7 +659,6 @@ files = [ name = "requests" version = "2.31.0" description = "Python HTTP for Humans." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -708,7 +680,6 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] name = "rich" version = "13.3.5" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" -category = "main" optional = true python-versions = ">=3.7.0" files = [ @@ -728,7 +699,6 @@ jupyter = ["ipywidgets (>=7.5.1,<9)"] name = "six" version = "1.16.0" description = "Python 2 and 3 compatibility utilities" -category = "main" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -740,7 +710,6 @@ files = [ name = "snowballstemmer" version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -category = "dev" optional = false python-versions = "*" files = [ @@ -752,7 +721,6 @@ files = [ name = "sphinx" version = "7.0.1" description = "Python documentation generator" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -788,7 +756,6 @@ test = ["cython", "filelock", "html5lib", "pytest (>=4.6)"] name = "sphinxcontrib-applehelp" version = "1.0.4" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -804,7 +771,6 @@ test = ["pytest"] name = "sphinxcontrib-devhelp" version = "1.0.2" description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -820,7 +786,6 @@ test = ["pytest"] name = "sphinxcontrib-htmlhelp" version = "2.0.1" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -836,7 +801,6 @@ test = ["html5lib", "pytest"] name = "sphinxcontrib-jsmath" version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -851,7 +815,6 @@ test = ["flake8", "mypy", "pytest"] name = "sphinxcontrib-qthelp" version = "1.0.3" description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -867,7 +830,6 @@ test = ["pytest"] name = "sphinxcontrib-serializinghtml" version = "1.1.5" description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." -category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -883,7 +845,6 @@ test = ["pytest"] name = "toml" version = "0.10.2" description = "Python Library for Tom's Obvious, Minimal Language" -category = "main" optional = true python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" files = [ @@ -895,7 +856,6 @@ files = [ name = "tomli" version = "2.0.1" description = "A lil' TOML parser" -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -907,7 +867,6 @@ files = [ name = "typing-extensions" version = "4.6.2" description = "Backported and Experimental Type Hints for Python 3.7+" -category = "main" optional = true python-versions = ">=3.7" files = [ @@ -919,7 +878,6 @@ files = [ name = "urllib3" version = "2.0.2" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -937,7 +895,6 @@ zstd = ["zstandard (>=0.18.0)"] name = "zipp" version = "3.15.0" description = "Backport of pathlib-compatible object wrapper for zip files" -category = "dev" optional = false python-versions = ">=3.7" files = [ diff --git a/pyproject.toml b/pyproject.toml index eae2af8..3ebef8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "odin" -version = "2.9.0" +version = "2.10rc1" description = "Data-structure definition/validation/traversal, mapping and serialisation toolkit for Python" authors = ["Tim Savage "] license = "BSD-3-Clause" @@ -55,30 +55,40 @@ pint = ["pint"] arrow = ["arrow"] rich = ["rich"] -[tool.isort] -profile = "black" - [tool.ruff] -select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - # "I", # isort - "C", # flake8-comprehensions - "B", # flake8-bugbear -] +# Same as Black. +line-length = 88 +indent-width = 4 + +# Assume Python 3.8 +target-version = "py38" + +[tool.ruff.lint] +select = ["N", "F", "I", "UP", "PL", "A", "G", "S", "E", "SIM", "B"] ignore = [ - "E501", # line too long, handled by black - "B008", # do not perform function calls in argument defaults - "C901", # too complex + "N818", # Exception name should be named with an Error suffix ] -# Assume Python 3.8. -target-version = "py38" +[tool.ruff.lint.per-file-ignores] +"tests/**.py" = [ + "S101", # asserts allowed in tests... + "ARG", # Unused function args -> fixtures nevertheless are functionally relevant... + "FBT", # Don't care about booleans as positional arguments in tests, e.g. via @pytest.mark.parametrize() + # The below are debateable + "PLR2004", # Magic value used in comparison, ... + "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes +] + +[tool.ruff.lint.pycodestyle] +max-line-length = 117 + +[tool.ruff.format] +# Like Black, use double quotes for strings. +quote-style = "double" +# Like Black, indent with spaces, rather than tabs. +indent-style = "space" +# Like Black, respect magic trailing commas. +skip-magic-trailing-comma = false +# Like Black, automatically detect the appropriate line ending. +line-ending = "auto" -[tool.ruff.per-file-ignores] -"tests/test_adapters.py" = ["F403", "F405"] -"tests/test_codec*.py" = ["F403", "F405"] -"tests/test_fields.py" = ["F403", "F405"] -"tests/test_kitchensink.py" = ["F403", "F405"] -"tests/test_mapping.py" = ["F403", "F405"] diff --git a/src/odin/annotated_resource/__init__.py b/src/odin/annotated_resource/__init__.py index 0a24274..e4803e0 100644 --- a/src/odin/annotated_resource/__init__.py +++ b/src/odin/annotated_resource/__init__.py @@ -11,19 +11,20 @@ utilised unchanged. """ -import copy -from typing import Any, Dict, Iterable, Optional, Tuple, Type, TypeVar + +from typing import Any, Dict, Iterable, Tuple, Type from odin import registration from odin.fields import BaseField from odin.resources import ( - DEFAULT_TYPE_FIELD, + MOT, NotProvided, ResourceBase, ResourceOptions, + _add_parent_fields_to_class, + _new_meta_instance, ) -from ..exceptions import ResourceDefError from .type_resolution import Options, process_attribute __all__ = ( @@ -33,47 +34,6 @@ "AResource", ) -from ..utils import getmeta - -MOT = TypeVar("MOT", bound=ResourceOptions) - - -def _new_meta_instance( - meta_options_type: Type[MOT], - meta_def: Optional[object], - new_class: "AnnotatedResourceType", -) -> MOT: - """Instantiate meta options instance and handle inheritance of required fields.""" - base_meta = getattr(new_class, "_meta", None) - new_meta = meta_options_type(meta_def) - new_class.add_to_class("_meta", new_meta) - - # Namespace is inherited - if base_meta and new_meta.name_space is NotProvided: - new_meta.name_space = base_meta.name_space - if new_meta.name_space is NotProvided: - new_meta.name_space = new_class.__module__ - - # Type field is inherited and default if not provided - if base_meta and new_meta.type_field is NotProvided: - new_meta.type_field = base_meta.type_field - if new_meta.type_field is NotProvided: - new_meta.type_field = DEFAULT_TYPE_FIELD - - # Key field names is inherited - if base_meta and new_meta.key_field_names is None: - new_meta.key_field_names = base_meta.key_field_names - - # Field name format is inherited - if new_meta.field_name_format is NotProvided: - new_meta.field_name_format = base_meta.field_name_format if base_meta else None - - # Field sorting is inherited - if new_meta.field_sorting is NotProvided: - new_meta.field_sorting = base_meta.field_sorting if base_meta else False - - return new_meta - def _iterate_attrs(attrs: Dict[str, Any]) -> Iterable[Tuple[str, BaseField]]: """Iterate through attributes and combine with annotations.""" @@ -90,43 +50,6 @@ def _iterate_attrs(attrs: Dict[str, Any]) -> Iterable[Tuple[str, BaseField]]: yield from attrs.items() -def _add_parent_fields_to_class( - new_class: "AnnotatedResourceType", new_meta: ResourceOptions, parents -): - """Iterate through parent attrs and yield fields.""" - # All the fields of any type declared on this model - local_field_attr_names = {f.attname for f in new_meta.fields} - field_attr_names = set(local_field_attr_names) - - for base, base_meta in ( - (base, getmeta(base)) for base in parents if hasattr(base, "_meta") - ): - # Check for clashes between locally declared fields and those - # on the base classes (we cannot handle shadowed fields at the - # moment). - for field in base_meta.all_fields: - if field.attname in local_field_attr_names: - raise ResourceDefError( - f"Local field {field.attname!r} in class {new_class.__name__!r} " - f"clashes with field of the same name from base class {base.__name__!r}" - ) - - # Clone fields (but filter out fields already inherited) - for field in ( - field for field in base_meta.fields if field.attname not in field_attr_names - ): - field_attr_names.add(field.attname) - new_class.add_to_class(field.attname, copy.deepcopy(field)) - - # Clone any virtual fields - for field in base_meta.virtual_fields: - new_class.add_to_class(field.attname, copy.deepcopy(field)) - - # Add to parents list - new_meta.parents += base_meta.parents - new_meta.parents.append(base) - - class AnnotatedResourceType(type): def __new__( mcs, @@ -175,8 +98,11 @@ def __new__( _add_parent_fields_to_class(new_class, new_meta, parents) # Sort the fields - if not new_meta.field_sorting: - new_meta.fields = sorted(new_meta.fields, key=hash) + if new_meta.field_sorting: + if callable(new_meta.field_sorting): + new_meta.fields = new_meta.field_sorting(new_meta.fields) + else: + new_meta.fields = sorted(new_meta.fields, key=hash) # Give fields an opportunity to do additional operations after the # resource is full populated and ready. diff --git a/src/odin/mapping/__init__.py b/src/odin/mapping/__init__.py index b6f10cc..b5c1289 100644 --- a/src/odin/mapping/__init__.py +++ b/src/odin/mapping/__init__.py @@ -501,7 +501,7 @@ def apply( source_obj, context=None, allow_subclass: bool = False, - mapping_result: MappingResult = None, + mapping_result: Type[MappingResult] = None, ): """ Apply conversion either a single resource or a list of resources using the mapping defined by this class. @@ -515,7 +515,9 @@ def apply( specified is returned. """ - context = context or {} + if context is None: + context = {} + mapping_result = mapping_result or cls.default_mapping_result context.setdefault("_loop_idx", []) @@ -621,11 +623,13 @@ def _apply_rule(self, mapping_rule): except TypeError as ex: raise MappingExecutionError( f"{ex} applying rule {mapping_rule}" - ) from None + ) from ex if to_list: if isinstance(to_values, Iterable): to_values = (list(to_values),) + else: + to_values = (to_values,) else: to_values = force_tuple(to_values) diff --git a/src/odin/resources.py b/src/odin/resources.py index b932256..1e109f6 100644 --- a/src/odin/resources.py +++ b/src/odin/resources.py @@ -43,6 +43,7 @@ class ResourceOptions: "field_name_format", "field_sorting", "user_data", + "allow_field_shadowing", ) def __init__(self, meta): @@ -68,6 +69,7 @@ def __init__(self, meta): ] = NotProvided self.field_sorting: Union[bool, NotProvidedType] = NotProvided self.user_data: Optional[Any] = None + self.allow_field_shadowing: Union[bool, NotProvidedType] = NotProvided self._cache = {} @@ -120,6 +122,32 @@ def contribute_to_class(self, cls, _): if not self.verbose_name_plural: self.verbose_name_plural = self.verbose_name + "s" + def inherit_from(self, base: "ResourceOptions"): + """Inherit options from a base meta options instance.""" + if base: + # Namespace is inherited and default if not provided + if self.name_space is NotProvided: + self.name_space = base.name_space + + # Type field is inherited and default if not provided + if self.type_field is NotProvided: + self.type_field = base.type_field + + # Key field is inherited + if self.key_field_names is None: + self.key_field_names = base.key_field_names + + if self.allow_field_shadowing is NotProvided: + self.allow_field_shadowing = base.allow_field_shadowing if base else False + + # Field name format is inherited + if self.field_name_format is NotProvided: + self.field_name_format = base.field_name_format if base else None + + # Field sorting is inherited + if self.field_sorting is NotProvided: + self.field_sorting = base.field_sorting if base else False + def _add_key_field(self, field): self._key_fields.append(field) @@ -160,6 +188,11 @@ def init_fields(self) -> Sequence[Field]: """Fields used in the resource init.""" return self.fields + @cached_property + def shadow_fields(self) -> Sequence[Field]: + """Fields that are shadowing fields on base classes.""" + return tuple(f for f in self.fields if hasattr(f, "_shadow")) + @cached_property def composite_fields(self) -> Sequence[Field]: """All composite fields.""" @@ -232,6 +265,75 @@ def check(self): """Run checks on meta data to ensure correctness""" +MOT = TypeVar("MOT", bound=ResourceOptions) + + +def _new_meta_instance( + meta_options_type: Type[MOT], + meta_def: Optional[object], + new_class: "ResourceType", +) -> MOT: + """Instantiate meta options instance and handle inheritance of required fields.""" + base_meta = getattr(new_class, "_meta", None) + new_meta = meta_options_type(meta_def) + new_class.add_to_class("_meta", new_meta) + new_meta.inherit_from(base_meta) + + # Namespace is inherited + if new_meta.name_space is NotProvided: + new_meta.name_space = new_class.__module__ + + # Type field is inherited and default if not provided + if new_meta.type_field is NotProvided: + new_meta.type_field = DEFAULT_TYPE_FIELD + + return new_meta + + +def _add_parent_fields_to_class( + new_class: "ResourceType", new_meta: ResourceOptions, parents +): + """Iterate through parent attrs and yield fields.""" + # All the fields of any type declared on this model + field_attr_names = {f.attname for f in new_meta.fields} + added_attr_names = set() + + for base, base_meta in ( + (base, getmeta(base)) for base in parents if hasattr(base, "_meta") + ): + # Check for locally declared fields that are shadowing fields on the base class + shadow_fields = field_attr_names.intersection( + f.attname for f in base_meta.all_fields + ) + if shadow_fields: + if new_meta.allow_field_shadowing: + for field in ( + f for f in base_meta.fields if f.attname in shadow_fields + ): + field._shadow = field + else: + raise Exception( + f"Local field{'s' if len(shadow_fields) > 2 else ''} " + f"{', '.join(repr(f) for f in shadow_fields)} in class {new_meta.name!r} " + f"clashes with field from base class {base.__name__!r}" + ) + + # Clone fields (but filter out fields already inherited) + for field in ( + field for field in base_meta.fields if field.attname not in added_attr_names + ): + added_attr_names.add(field.attname) + new_class.add_to_class(field.attname, copy.deepcopy(field)) + + # Clone any virtual fields + for field in base_meta.virtual_fields: + new_class.add_to_class(field.attname, copy.deepcopy(field)) + + # Add to parents list + new_meta.parents += base_meta.parents + new_meta.parents.append(base) + + class ResourceType(type): """Metaclass for all Resources.""" @@ -256,51 +358,12 @@ def __new__(mcs, name, bases, attrs): return super_new(mcs, name, bases, attrs) # Create the class. - module = attrs.pop("__module__") - new_attrs = {"__module__": module} - - # Required for https://bugs.python.org/issue23722 - class_cell = attrs.pop("__classcell__", None) - if class_cell is not None: - new_attrs["__classcell__"] = class_cell - new_class = super_new(mcs, name, bases, new_attrs) - - attr_meta = attrs.pop("Meta", None) - abstract = getattr(attr_meta, "abstract", False) - if not attr_meta: - meta = getattr(new_class, "Meta", None) - else: - meta = attr_meta - base_meta = getattr(new_class, "_meta", None) + new_class = super_new(mcs, name, bases, {"__module__": attrs.pop("__module__")}) - new_meta = mcs.meta_options(meta) - new_class.add_to_class("_meta", new_meta) - - # Namespace is inherited and default if not provided - if base_meta and new_meta.name_space is NotProvided: - new_meta.name_space = base_meta.name_space - if new_meta.name_space is NotProvided: - new_meta.name_space = module - - # Type field is inherited and default if not provided - if base_meta and new_meta.type_field is NotProvided: - new_meta.type_field = base_meta.type_field - if new_meta.type_field is NotProvided: - new_meta.type_field = DEFAULT_TYPE_FIELD - - # Key field is inherited - if base_meta and new_meta.key_field_names is None: - new_meta.key_field_names = base_meta.key_field_names - - # Field name format is inherited - if new_meta.field_name_format is NotProvided: - new_meta.field_name_format = ( - base_meta.field_name_format if base_meta else None - ) - - # Field sorting is inherited - if new_meta.field_sorting is NotProvided: - new_meta.field_sorting = base_meta.field_sorting if base_meta else False + # Create new meta instance + new_meta = _new_meta_instance( + mcs.meta_options, attrs.pop("Meta", None), new_class + ) # Bail out early if we have already created this class. r = registration.get_resource(new_meta.resource_name) @@ -311,40 +374,7 @@ def __new__(mcs, name, bases, attrs): for obj_name, obj in attrs.items(): new_class.add_to_class(obj_name, obj) - # Sort the fields - if not new_meta.field_sorting: - new_meta.fields = sorted(new_meta.fields, key=hash) - - # All the fields of any type declared on this model - local_field_attnames = {f.attname for f in new_meta.fields} - field_attnames = set(local_field_attnames) - - for base in parents: - try: - base_meta = base._meta - except AttributeError: - # Things without _meta aren't functional models, so they're - # uninteresting parents. - continue - - # Check for clashes between locally declared fields and those - # on the base classes (we cannot handle shadowed fields at the - # moment). - for field in base_meta.all_fields: - if field.attname in local_field_attnames: - raise Exception( - f"Local field {field.attname!r} in class {name!r} clashes with " - f"field of similar name from base class {base.__name__!r}" - ) - for field in base_meta.fields: - if field.attname not in field_attnames: - field_attnames.add(field.attname) - new_class.add_to_class(field.attname, copy.deepcopy(field)) - for field in base_meta.virtual_fields: - new_class.add_to_class(field.attname, copy.deepcopy(field)) - - new_meta.parents += base_meta.parents - new_meta.parents.append(base) + _add_parent_fields_to_class(new_class, new_meta, parents) # Sort the fields if new_meta.field_sorting: @@ -354,12 +384,11 @@ def __new__(mcs, name, bases, attrs): new_meta.fields = sorted(new_meta.fields, key=hash) # If a key_field is defined ensure it exists - if new_meta.key_field_names: - for field_name in new_meta.key_field_names: - if field_name not in new_meta.field_map: - raise AttributeError( - f"Key field `{field_name}` does not exist on this resource." - ) + for field_name in new_meta.key_field_names or (): + if field_name not in new_meta.field_map: + raise AttributeError( + f"Key field `{field_name}` does not exist on this resource." + ) # Give fields an opportunity to do additional operations after the # resource is full populated and ready. @@ -367,7 +396,7 @@ def __new__(mcs, name, bases, attrs): if hasattr(field, "on_resource_ready"): field.on_resource_ready() - if abstract: + if new_meta.abstract: return new_class # Register resource @@ -544,7 +573,7 @@ def clean_fields(self, exclude=None, ignore_not_provided=False): class Resource(ResourceBase, metaclass=ResourceType): - pass + """Resource object""" def resolve_resource_type( @@ -655,9 +684,7 @@ def _resolve_type_from_resource(data, resource): if not resource_type: raise exceptions.ResourceException( - "Incoming resource does not match [{}]".format( - ", ".join(r for r, t in resources) - ) + f"Incoming resource does not match [{', '.join(r for r, t in resources)}]" ) return resource_type diff --git a/tests/resources.py b/tests/resources.py index b2e5bd4..48c51ff 100644 --- a/tests/resources.py +++ b/tests/resources.py @@ -29,6 +29,7 @@ class Meta: class Book(LibraryBook): class Meta: key_field_name = "isbn" + allow_field_shadowing = True title = odin.StringField() isbn = odin.StringField() @@ -54,6 +55,17 @@ def __eq__(self, other): return False +class AltBook(Book): + """A special case of book. + + This is a special case of book that has a limit on the length of the title. + + And to test overriding fields. + """ + + title = odin.StringField(max_length=10) + + class From(enum.Enum): Dumpster = "dumpster" Shop = "shop" diff --git a/tests/resources_annotated.py b/tests/resources_annotated.py index ef0af77..55a257a 100644 --- a/tests/resources_annotated.py +++ b/tests/resources_annotated.py @@ -36,6 +36,7 @@ class Book(LibraryBook): class Meta: namespace = "annotated" key_field_name = "isbn" + allow_field_shadowing = True title: str isbn: str = odin.Options( @@ -70,6 +71,17 @@ def __eq__(self, other): return False +class AltBook(Book): + """A special case of book. + + This is a special case of book that has a limit on the length of the title. + + And to test overriding fields. + """ + + title: str = odin.Options(max_length=10) + + class From(enum.Enum): Dumpster = "dumpster" Shop = "shop" diff --git a/tests/test_annotated_resources.py b/tests/test_annotated_resources.py index 3c5c89c..1e4de2b 100644 --- a/tests/test_annotated_resources.py +++ b/tests/test_annotated_resources.py @@ -10,6 +10,7 @@ InheritedCamelCaseResource, Library, Publisher, + AltBook, ) @@ -32,9 +33,13 @@ def test_fields_are_identified_in_correct_order(self): class TestAnnotatedResource: def test_fields_are_inherited_from_parent_resources(self): - meta = getmeta(IdentifiableBook) + target = getmeta(IdentifiableBook) - assert [f.name for f in meta.fields] == [ + actual = target.fields + + assert [f.name for f in actual] == [ + "id", + "purchased_from", "title", "isbn", "num_pages", @@ -44,8 +49,6 @@ def test_fields_are_inherited_from_parent_resources(self): "published", "authors", "publisher", - "id", - "purchased_from", ] def test_cached_properties_work_as_expected(self): @@ -64,7 +67,15 @@ def test_field_name_format(self): actual = [field.name for field in options.fields] - assert actual == ["fullName", "yearOfBirth", "emailAddress"] + assert actual == ["emailAddress", "fullName", "yearOfBirth"] + + def test_shadow_fields_are_identified(self): + target = getmeta(AltBook) + + actual = target.shadow_fields + + assert isinstance(actual, tuple) + assert [f.name for f in actual] == ["title"] class TestAnnotatedKitchenSink: diff --git a/tests/test_resources.py b/tests/test_resources.py index 91cd3f4..4db2335 100644 --- a/tests/test_resources.py +++ b/tests/test_resources.py @@ -11,7 +11,14 @@ ) from odin.utils import getmeta, snake_to_camel -from .resources import Book, BookProxy, InheritedCamelCaseResource, Library, Subscriber +from .resources import ( + Book, + BookProxy, + InheritedCamelCaseResource, + Library, + Subscriber, + AltBook, +) class Author(odin.Resource): @@ -226,6 +233,14 @@ def test_use_a_reserved_field(self): class InvalidFieldsResource(Resource): fields = odin.StringField() + def test_shadow_fields_are_identified(self): + target = getmeta(AltBook) + + actual = target.shadow_fields + + assert isinstance(actual, tuple) + assert [f.name for f in actual] == ["title"] + class TestConstructionMethods: def test_build_object_graph_empty_dict_no_clean(self): @@ -393,3 +408,13 @@ def test_field_name_format(self): actual = [field.name for field in options.fields] assert actual == ["fullName", "yearOfBirth", "emailAddress"] + + def test_shadowing(self): + actual = AltBook(title="Foo", isbn="123456") + + getmeta(actual) + + book = Book(title="Foo", isbn="123456") + + assert book.isbn == "123456" + assert book.title == "Foo"