From 6251ca534426a58fe557d89160d5a60a2933efb0 Mon Sep 17 00:00:00 2001 From: Alexandru Coman <acoman@cloudbasesolutions.com> Date: Mon, 9 Jan 2017 16:02:03 +0200 Subject: [PATCH] Add base data model in order to improve data representation The proposed data model improves the way how the information is structured and allows related entities to to inherit data. --- .gitignore | 55 +++++ .pylintrc | 10 +- hnv_client/common/constant.py | 15 ++ hnv_client/common/exception.py | 7 + hnv_client/common/model.py | 308 ++++++++++++++++++++++++++ hnv_client/tests/common/__init__.py | 0 hnv_client/tests/common/test_model.py | 238 ++++++++++++++++++++ requirements.txt | 3 + 8 files changed, 631 insertions(+), 5 deletions(-) create mode 100644 .gitignore create mode 100644 hnv_client/common/model.py create mode 100644 hnv_client/tests/common/__init__.py create mode 100644 hnv_client/tests/common/test_model.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f3f82ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,55 @@ +*.py[cod] + +# C extensions +*.so + +# Packages +*.egg +*.eggs +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.coverage +.tox +nosetests.xml +.testrepository +.venv +cover + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# Complexity +output/*.html +output/*/index.html + +# Sphinx +doc/build + +# pbr generates these +AUTHORS +ChangeLog + +# Editors +*~ +.*.swp +.*sw? diff --git a/.pylintrc b/.pylintrc index b49244c..a73c8b3 100644 --- a/.pylintrc +++ b/.pylintrc @@ -13,7 +13,7 @@ ignore=CVS # Add files or directories matching the regex patterns to the blacklist. The # regex matches against base names, not paths. -ignore-patterns= +ignore-patterns=test_* # Pickle collected data for later comparisons. persistent=yes @@ -166,12 +166,12 @@ ignore-mixin-members=yes # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis. It # supports qualified module names, as well as Unix pattern matching. -ignored-modules=hnv_client.tests +ignored-modules= # List of class names for which member attributes should not be checked (useful # for classes with dynamically set attributes). This supports the use of # qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local +ignored-classes=optparse.Values,thread._local,_thread._local, hnv_client.common.model.Model # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E1101 when accessed. Python regular @@ -366,7 +366,7 @@ analyse-fallback-blocks=no [DESIGN] # Maximum number of arguments for function / method -max-args=5 +max-args=15 # Argument names that match this expression will be ignored. Default to name # with leading underscore @@ -391,7 +391,7 @@ max-parents=7 max-attributes=7 # Minimum number of public methods for a class (see R0903). -min-public-methods=2 +min-public-methods=1 # Maximum number of public methods for a class (see R0904). max-public-methods=20 diff --git a/hnv_client/common/constant.py b/hnv_client/common/constant.py index e69de29..c4dd331 100644 --- a/hnv_client/common/constant.py +++ b/hnv_client/common/constant.py @@ -0,0 +1,15 @@ +# Copyright 2017 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""Shared constants across the bcbio-nextgen-vm project.""" diff --git a/hnv_client/common/exception.py b/hnv_client/common/exception.py index c13a68a..fc89e27 100644 --- a/hnv_client/common/exception.py +++ b/hnv_client/common/exception.py @@ -51,6 +51,13 @@ class HNVException(Exception): super(HNVException, self).__init__(message) +class DataProcessingError(HNVException): + + """Base exception class for data processing related errors.""" + + template = "The provided information is incomplete or invalid." + + class NotFound(HNVException): """The required object is not available in container.""" diff --git a/hnv_client/common/model.py b/hnv_client/common/model.py new file mode 100644 index 0000000..02e0a6b --- /dev/null +++ b/hnv_client/common/model.py @@ -0,0 +1,308 @@ +# Copyright 2017 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +"""This module contains data models used across the project.""" + +# pylint: disable=protected-access + +import copy + +from oslo_log import log as logging +import six + +from hnv_client.common import exception + +LOG = logging.getLogger(__name__) + + +class _FieldDescriptor(object): + + """Descriptor for all the available fields for a model. + + Fields are exposed as descriptors in order to control access to the + underlying raw data. + """ + + def __init__(self, field): + self._field = field + self._attribute = field.key + + @property + def field(self): + """Expose the received field object.""" + return self._field + + def __get__(self, instance, instance_type=None): + if instance is not None: + return instance._data.get(self._attribute) + return self._field + + def __set__(self, instance, value): + if instance.provision_done: + if self._field.is_read_only: + raise TypeError("%r does not support item assignment" % + self._field.name) + + # Update the value of the field + instance._data[self._attribute] = value + # Keep track of the changes + instance._changes[self._attribute] = value + + +class Field(object): + + """Meta information regarding the data components. + + :param name: The name of the current piece of information. + :param key: The internal name for the current piece of + information. + :param default: Default value for the current field. + (default: `None`) + :param is_required: Whether the current piece of information is required + for the container object or can be missing. + (default: `False`) + :param is_property: Whether the current piece of information is a + propriety of the model. (default: `True`) + :param is_read_only: Whether the current piece of information can + be updated. (Default: `False`) + """ + + def __init__(self, name, key, default=None, is_required=False, + is_property=True, is_read_only=False): + self._name = name + self._key = key + self._default = default + self._is_required = is_required + self._is_property = is_property + self._is_read_only = is_read_only + + @property + def name(self): + """The name of the current field.""" + return self._name + + @property + def key(self): + """The internal name of the current field.""" + return self._key + + @property + def default(self): + """Default value for the current field.""" + return self._default + + @property + def is_required(self): + """Whether the current field is required or can be missing.""" + return self._is_required + + @property + def is_property(self): + """Whether the current field is a model property.""" + return self._is_property + + @property + def is_read_only(self): + """Whether the current field can be updated.""" + return self._is_read_only + + def add_to_class(self, model_class): + """Replace the `Field` attribute with a named `_FieldDescriptor`. + + .. note:: + This method is called during construction of the `Model`. + """ + model_class._meta.add_field(self) + setattr(model_class, self.name, _FieldDescriptor(self)) + + +class _ModelOptions(object): + + """Container for all the model options. + + .. note:: + The current object will be created by the model metaclass. + """ + + def __init__(self, cls): + self._model_class = cls + self._name = cls.__name__ + + self._fields = {} + self._defaults = {} + self._default_callables = {} + + @property + def fields(self): + """All the available fields for the current model.""" + return self._fields + + def add_field(self, field): + """Add the received field to the model.""" + self.remove_field(field.name) + self._fields[field.name] = field + + if field.default is not None: + if six.callable(field.default): + self._default_callables[field.key] = field.default + else: + self._defaults[field.key] = field.default + + def remove_field(self, field_name): + """Remove the field with the received field name from model.""" + field = self._fields.pop(field_name, None) + if field is not None and field.default is not None: + if six.callable(field.default): + self._default_callables.pop(field.name, None) + else: + self._defaults.pop(field.name, None) + + def get_defaults(self): + """Get a dictionary that contains all the available defaults.""" + defaults = self._defaults.copy() + for field_name, default in self._default_callables.items(): + defaults[field_name] = default() + return defaults + + +class _BaseModel(type): + + """Metaclass used for properly setting up a new model.""" + + def __new__(mcs, name, bases, attrs): + # The inherit is made by deep copying the underlying field into + # the attributes of the new model. + for base in bases: + for key, attribute in base.__dict__.items(): + if key not in attrs and isinstance(attribute, + _FieldDescriptor): + attrs[key] = copy.deepcopy(attribute.field) + + # Initialize the new class and set the magic attributes + cls = super(_BaseModel, mcs).__new__(mcs, name, bases, attrs) + + # Create the _ModelOptions object and inject it in the new class + setattr(cls, "_meta", _ModelOptions(cls)) + + # Get all the available fields for the current model. + for name, field in list(cls.__dict__.items()): + if isinstance(field, Field) and not name.startswith("_"): + field.add_to_class(cls) + + # Create string representation for the current model before finalizing + setattr(cls, '__str__', lambda self: '%s' % cls.__name__) + return cls + + +@six.add_metaclass(_BaseModel) +class Model(object): + + """Container for meta information regarding the data structure.""" + + def __init__(self, **fields): + self._data = self._meta.get_defaults() + self._changes = {} + self._provision_done = False + + for field in self._meta.fields.values(): + value = fields.pop(field.name, None) + if field.key not in self._data or value: + setattr(self, field.name, value) + + if fields: + LOG.debug("Unrecognized fields: %r", fields) + + self._provision_done = True + + def __eq__(self, other): + if not isinstance(other, self.__class__): + return False + + my_properties = self.dump().get("properties", {}) + other_properties = other.dump().get("properties", {}) + return my_properties == other_properties + + def __ne__(self, other): + return not self.__eq__(other) + + @classmethod + def from_raw_data(cls, raw_data): + """Create a new model using raw API response.""" + content = {} + properties = raw_data.pop("properties", {}) + for field_name, field in cls._meta.fields.items(): + if field.is_property: + value = properties.pop(field.key, None) + else: + value = raw_data.pop(field.key, None) + content[field_name] = value + + if raw_data: + LOG.debug("Unrecognized fields: %r", raw_data) + if properties: + LOG.debug("Unrecognized properties: %r", properties) + + return cls(**content) + + @property + def provision_done(self): + """Whether the creation of the model is complete.""" + return self._provision_done + + def validate(self): + """Check if the current model was properly created.""" + for field_name, field in self._meta.fields.items(): + if field.is_required and self._data.get(field.name) is None: + raise exception.DataProcessingError( + "The required field %(field)r is missing.", + field=field_name) + + def update(self, fields=None): + """Update the value of one or more fields.""" + for field_name, field in self._meta.fields.items(): + if field_name in fields: + self._changes[field.key] = fields[field_name] + self._data.update(self._changes) + + def commit(self, wait=False, timeout=None): + """Apply all the changes on the current model.""" + # pylint: disable=unused-argument + self._data.update(self._changes) + self._changes.clear() + + def dump(self, include_read_only=True): + """Create a dictionary with the content of the current model.""" + content = {} + for field in self._meta.fields.values(): + if field.is_read_only and not include_read_only: + continue + + value = self._data.get(field.key) + if isinstance(value, Model): + # The raw content of the model is required + value = value.dump() + + if not field.is_required and value is None: + # The value of this field is not relevant + continue + + if field.is_property: + # The current field is a property and its value should + # be stored into the `properties` key. + properties = content.setdefault("properties", {}) + properties[field.key] = value + else: + content[field.key] = value + + return content diff --git a/hnv_client/tests/common/__init__.py b/hnv_client/tests/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hnv_client/tests/common/test_model.py b/hnv_client/tests/common/test_model.py new file mode 100644 index 0000000..be33129 --- /dev/null +++ b/hnv_client/tests/common/test_model.py @@ -0,0 +1,238 @@ +# Copyright 2017 Cloudbase Solutions Srl +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +# pylint: disable=protected-access, missing-docstring + +import unittest + +try: + import unittest.mock as mock +except ImportError: + import mock + +from hnv_client.common import exception +from hnv_client.common import model + + +class TestFieldDescriptor(unittest.TestCase): + + def test_field_access(self): + instance = mock.Mock() + field = mock.Mock() + field_descriptor = model._FieldDescriptor(field) + + self.assertIs(field, field_descriptor.__get__(None)) + + field_descriptor.__get__(instance) + instance._data.get.assert_called_once_with(field.key) + + def test_set_field(self): + instance = mock.MagicMock() + instance._changes = {} + + field = mock.Mock() + field.is_read_only = False + field_descriptor = model._FieldDescriptor(field) + field_descriptor.__set__(instance, mock.sentinel.value) + + self.assertIs(instance._changes[field.key], mock.sentinel.value) + + def test_set_read_only_field(self): + instance = mock.MagicMock() + + field = mock.Mock() + field.is_read_only = True + field_descriptor = model._FieldDescriptor(field) + with self.assertRaises(TypeError): + field_descriptor.__set__(instance, mock.sentinel.value) + + def test_field_property(self): + field = mock.Mock() + field_descriptor = model._FieldDescriptor(field) + + self.assertIs(field_descriptor.field, field) + + +class TestField(unittest.TestCase): + + def test_properties(self): + field = model.Field(name=mock.sentinel.name, + key=mock.sentinel.name, + default=mock.sentinel.default, + is_required=mock.sentinel.is_required, + is_property=mock.sentinel.is_property, + is_read_only=mock.sentinel.is_read_only) + + self.assertIs(field.name, mock.sentinel.name) + self.assertIs(field.default, mock.sentinel.default) + self.assertIs(field.is_required, mock.sentinel.is_required) + self.assertIs(field.is_property, mock.sentinel.is_property) + self.assertIs(field.is_read_only, mock.sentinel.is_read_only) + + @mock.patch("hnv_client.common.model._FieldDescriptor") + def test_add_to_class(self, mock_field_descriptor): + field = model.Field(name="test_add_to_class", key="test") + model_class = mock.Mock() + + field.add_to_class(model_class) + + mock_field_descriptor.assert_called_once_with(field) + self.assertIsNotNone(getattr(model_class, "test_add_to_class")) + + +class TestModelOptions(unittest.TestCase): + + def test_initialization(self): + mock.sentinel.cls.__name__ = mock.sentinel.cls.name + model_options = model._ModelOptions(cls=mock.sentinel.cls) + + self.assertIs(model_options._model_class, mock.sentinel.cls) + self.assertEqual(model_options._name, mock.sentinel.cls.name) + + @mock.patch("six.callable") + @mock.patch("hnv_client.common.model._ModelOptions.remove_field") + def _test_add_field(self, mock_remove_field, mock_callable, + callable_default): + model_options = model._ModelOptions(self.__class__) + test_field = model.Field(name=mock.sentinel.name, + key=mock.sentinel.key, + is_required=False, + default=mock.sentinel.default) + mock_callable.return_value = callable_default + + model_options.add_field(test_field) + + mock_remove_field.assert_called_once_with(mock.sentinel.name) + self.assertIs(model_options._fields[test_field.name], test_field) + if callable_default: + self.assertIs(model_options._default_callables[test_field.key], + mock.sentinel.default) + else: + self.assertIs(model_options._defaults[test_field.key], + mock.sentinel.default) + + def test_add_field(self): + self._test_add_field(callable_default=False) + self._test_add_field(callable_default=True) + + @mock.patch("six.callable") + def _test_remove_field(self, mock_callable, callable_default): + mock_callable.return_value = callable_default + model_options = model._ModelOptions(self.__class__) + test_field = model.Field(name=mock.sentinel.name, + key="test_field", + is_required=False, + default=mock.sentinel.default) + model_options.add_field(test_field) + + model_options.remove_field(test_field.name) + + self.assertNotIn(test_field.name, model_options._fields) + if callable_default: + self.assertNotIn(test_field.name, + model_options._default_callables) + else: + self.assertNotIn(test_field.name, model_options._defaults) + + def test_remove_field(self): + self._test_remove_field(callable_default=False) + self._test_remove_field(callable_default=True) + + def test_get_defaults(self): + test_field = model.Field( + name=mock.sentinel.name, key=mock.sentinel.key, + is_required=False, default=lambda: mock.sentinel.default) + model_options = model._ModelOptions(self.__class__) + model_options.add_field(test_field) + + defaults = model_options.get_defaults() + + self.assertEqual(defaults, {mock.sentinel.key: mock.sentinel.default}) + + +class TestBaseModel(unittest.TestCase): + + def test_create_model(self): + + class _Test(model.Model): + field1 = model.Field(name="field1", key="field1", default=1) + + self.assertTrue(hasattr(_Test, "_meta")) + self.assertEqual(_Test().field1, 1) + + def test_inherit_fields(self): + + class _TestBase(model.Model): + field1 = model.Field(name="field1", key="key1", + default=1) + field2 = model.Field(name="field2", key="key2") + + class _Test(_TestBase): + field2 = model.Field(name="field2", key="key2", + default=2) + + class _FinalTest(_Test): + field3 = model.Field(name="field3", key="key3", + is_required=True) + + final_test = _FinalTest(field3=3) + self.assertEqual(final_test.field1, 1) + self.assertEqual(final_test.field2, 2) + self.assertEqual(final_test.field3, 3) + + +class TestModel(unittest.TestCase): + + class _Test(model.Model): + field1 = model.Field(name="field1", key="key1", + is_required=True, + is_property=False) + field2 = model.Field(name="field2", key="key2", + is_required=False, + is_property=False) + field3 = model.Field(name="field3", key="key3", + is_required=True, + is_property=True) + + def setUp(self): + self._raw_data = {"key1": 1, "key2": 2, "properties": {"key3": 3}} + + def test_model_eq(self): + test = self._Test(field1=1, field3=2) + test2 = self._Test(field1=1, field3=3) + self.assertFalse(test == self) + self.assertTrue(test == test) + self.assertFalse(test == test2) + + def test_validate(self): + test = self._Test(field1=1, key="test_validate") + self.assertRaises(exception.DataProcessingError, test.validate) + + def test_update(self): + test = self._Test(field1="change_me", field2="change_me") + test.update({"field1": mock.sentinel.field1, + "field2": mock.sentinel.field2}) + + self.assertIs(test.field1, mock.sentinel.field1) + self.assertIs(test.field2, mock.sentinel.field2) + + def test_dump(self): + test = self._Test(field1=1, field2=2, field3=3) + self.assertEqual(test.dump(), self._raw_data) + + def test_from_raw_data(self): + test = self._Test.from_raw_data(self._raw_data) + self.assertEqual(test.field1, 1) + self.assertEqual(test.field2, 2) + self.assertEqual(test.field3, 3) diff --git a/requirements.txt b/requirements.txt index d7ab7fb..141ddae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ pbr>=1.8 six>=1.7.0 +oslo.config!=3.18.0,>=3.14.0 # Apache-2.0 +oslo.i18n>=2.1.0 # Apache-2.0 +oslo.log>=3.11.0 # Apache-2.0 requests requests_ntlm