├── .circleci └── config.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CHANGES.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── codecov.yml ├── docs ├── Makefile └── source │ ├── conf.py │ ├── index.rst │ └── more.rst ├── pytest.ini ├── requirements-dev.txt ├── requirements-docs.txt ├── requirements-test.txt ├── setup.py ├── simple_model ├── __init__.py ├── __version__.py ├── base.py ├── builder.py ├── converters.py ├── exceptions.py ├── fields.py ├── models.py └── utils.py └── tests ├── __init__.py ├── conftest.py ├── test_builder.py ├── test_converters.py ├── test_fields.py ├── test_models.py ├── test_utils.py └── test_version.py /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | workflows: 3 | version: 2 4 | test: 5 | jobs: 6 | - py36 7 | - py37 8 | 9 | jobs: 10 | py36: &test-template 11 | working_directory: ~/repo 12 | docker: 13 | - image: circleci/python:3.6.7 14 | 15 | steps: 16 | - checkout 17 | 18 | - restore_cache: 19 | key: v1-dependencies-{{ checksum "simple_model/__version__.py" }} 20 | 21 | - run: 22 | name: Install test dependencies 23 | command: | 24 | python3 -m venv venv 25 | . venv/bin/activate 26 | pip install -r requirements-test.txt 27 | pip install codecov 28 | 29 | - save_cache: 30 | paths: 31 | - ./venv 32 | key: v1-dependencies-{{ checksum "simple_model/__version__.py" }} 33 | 34 | - run: 35 | name: Linters 36 | command: | 37 | . venv/bin/activate 38 | flake8 simple_model tests --ignore=E501 39 | mypy simple_model 40 | 41 | - run: 42 | name: Run tests 43 | command: | 44 | . venv/bin/activate 45 | pytest 46 | 47 | - run: 48 | name: codecov 49 | command: | 50 | . venv/bin/activate 51 | codecov 52 | 53 | py37: 54 | <<: *test-template 55 | docker: 56 | - image: circleci/python:3.7.3 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | *.py[cod] 3 | *.so 4 | build/ 5 | dist/ 6 | eggs/ 7 | .eggs/ 8 | *.egg-info/ 9 | .coverage 10 | .coverage.* 11 | .cache 12 | coverage.xml 13 | *,cover 14 | *~ 15 | [._]*.s[a-w][a-z] 16 | .mypy_cache 17 | .idea 18 | .pytest_cache 19 | .tox/ 20 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: git@github.com:pre-commit/pre-commit-hooks 3 | rev: v2.2.1 4 | hooks: 5 | - id: debug-statements 6 | - id: trailing-whitespace 7 | - id: check-merge-conflict 8 | - id: flake8 9 | args: ['--exclude=docs/*,*migrations*', '--ignore=E501'] 10 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | ======= 2 | Changes 3 | ======= 4 | 5 | 2.4.3 / 2019-07-04 6 | ================== 7 | 8 | * Fix model.as_dict() when model has enum attribute 9 | 10 | 2.4.2 / 2019-04-26 11 | ================== 12 | 13 | * Fix model field custom validate method on subclass of Model subclasses 14 | 15 | 2.4.1 / 2019-04-23 16 | ================== 17 | 18 | * Fix model inheritance to keep _is_valid out of model fields 19 | 20 | 21 | 2.4.0 / 2019-04-08 22 | ================== 23 | 24 | * Add Model.as_dict() helper function to make it easier to convert models to dict 25 | * Add a first implementation of the lazy validation model (LazyModel) 26 | * Change Model._validation_count (int) to Model._is_valid (bool) to make model validation handling more simple 27 | * Move BaseModel metaclass black magic to a separate module (improve Model code readability) 28 | 29 | 30 | 2.3.3 / 2018-11-29 31 | ================== 32 | 33 | * Fix type hints 34 | 35 | 36 | 2.3.2 / 2018-11-28 37 | ================== 38 | 39 | * Fix model validation to avoid accessing properties unless it's really necessary 40 | 41 | 42 | 2.3.1 / 2018-11-28 43 | ================== 44 | 45 | * Fix type conversion to avoid converting when the field value is a subclass of the expected type 46 | 47 | 48 | 2.3.0 / 2018-10-24 49 | ================== 50 | 51 | * Support typing.Optional on field definitions. Thanks @daneoshiga. 52 | * Minor performance enhancements. Thanks @daneoshiga. 53 | 54 | 55 | 2.2.0 / 2018-10-23 56 | ================== 57 | 58 | * Remove "private" attributes (e.g. `Foo.__bar`) from model fields. Thanks @georgeyk 59 | * Support typing.Union on field definitions. Thanks @daneoshiga. 60 | 61 | 62 | 2.1.2 / 2018-07-22 63 | ================== 64 | 65 | * Add support to Python 3.7 support. 66 | 67 | 68 | 2.1.1 / 2018-04-21 69 | ================== 70 | 71 | * Fix model validation for fields defined by properties with setters 72 | 73 | 74 | 2.1.0 / 2018-04-20 75 | ================== 76 | 77 | * Add support to property (getter and setter) as model fields 78 | * Move ModeField relation to Model from class attributes to Meta 79 | 80 | 81 | 2.0.4 / 2018-04-19 82 | ================== 83 | 84 | * Fix model_many_builder properly using cls argument 85 | 86 | 87 | 2.0.3 / 2018-04-13 88 | ================== 89 | 90 | * Use os.path instead of Pathlib on setup 91 | 92 | 93 | 2.0.2 / 2018-04-10 94 | ================== 95 | 96 | * Fix version extraction 97 | 98 | 99 | 2.0.1 / 2018-04-10 100 | ================== 101 | 102 | * Fix setup path handling when extracting version from changelog file 103 | 104 | 105 | 2.0.0 / 2018-04-10 106 | ================== 107 | 108 | * Move clean responsibility to validation (remove ``clean`` method support) 109 | * Move conversion on validation from field to model 110 | * Remove ``Meta`` class from simple model 111 | * Fix model validation to properly validate fields with values of list of models 112 | * Fix model field converstion for cases when field type is a subclass of Model 113 | * Move conversion to dict to ``simple_model.to_dict`` function (instead of built-in ``dict`` function) 114 | * Return generator on ``simple_model.builder.model_many_builder`` instead of list 115 | * Fix model conversion on fields defined as ``list`` type 116 | * Fix setup.py path handling 117 | 118 | 119 | 1.1.5 / 2018-03-05 120 | ================== 121 | 122 | * Fix fields to be mandatory by default as designed / stated in docs 123 | 124 | 125 | 1.1.4 / 2018-03-05 126 | ================== 127 | 128 | * Fix ``Model.clean()``: call model validate after model cleaning instead of field validate after field cleaning 129 | 130 | 131 | 1.1.3 / 2018-02-27 132 | ================== 133 | 134 | * Fix ``model_many_builder`` to stop raising errors when empty iterable is received as argument 135 | 136 | 137 | 1.1.2 / 2018-02-21 138 | ================== 139 | 140 | * Fix field conversion to only happen when value is not None 141 | * Raise exception when trying to convert field with invalid model type 142 | * Fix model fields to stop including some methods and properties 143 | 144 | 145 | 1.1.1 / 2018-02-15 146 | ================== 147 | 148 | * Fix attribute default value as function so when the model receives the field value the default value is ignored 149 | 150 | 151 | 1.1.0 / 2018-02-15 152 | ================== 153 | 154 | * Fix ``setup.py`` ``long_description`` 155 | * Allow models fields be defined with class attributes without typing 156 | * Fix type conversion on fields using ``typing.List[...]`` 157 | * Bugfix: remove ``Meta`` attribute from model class meta fields 158 | * Fields attributes may receive function as default values. The function is executed 159 | (without passing arguments to it) on model instantiation 160 | 161 | 162 | 1.0.2 / 2018-01-10 163 | ================== 164 | 165 | * Add missing function name to ``__all__`` on ``simple_model.__init__`` 166 | 167 | 168 | 1.0.1 / 2018-01-10 169 | ================== 170 | 171 | * Fix setup.py 172 | 173 | 174 | 1.0.0 / 2018-01-10 175 | ================== 176 | 177 | * Move model field customization to Meta class inside model 178 | * Support field definition using type hints (python 3.6 only) 179 | * Drop support for python 3.4 and 3.5 180 | * Remove ``DynamicModel`` 181 | * Add Changes file and automate versioning from parsing it 182 | * Move main docs to sphinx 183 | * Improve documentation 184 | 185 | 186 | 0.15.0 / 2017-12-19 187 | =================== 188 | 189 | * Use pipenv 190 | * Drop python 3.3 support 191 | 192 | 193 | 0.14.0 / 2017-11-21 194 | =================== 195 | 196 | * Add ``model_many_builder()``. It builds lists of models from data lists 197 | * Fix travis config 198 | 199 | 200 | 0.13.0 / 2017-11-21 201 | =================== 202 | 203 | * Transfrom ``BaseModel.is_empty`` from an instance method to a class method 204 | * Don't raise an exception when ``BaseModel.build_many`` receives empty iterable. Instead returns another empty iterable 205 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Luiz Menezes 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include CHANGES.rst 2 | include README.rst 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: docs 2 | 3 | clean: clean-eggs clean-build clean-docs 4 | @find . -iname '*.pyc' -delete 5 | @find . -iname '*.pyo' -delete 6 | @find . -iname '*~' -delete 7 | @find . -iname '*.swp' -delete 8 | @find . -iname '__pycache__' -delete 9 | 10 | clean-eggs: 11 | @find . -name '*.egg' -print0|xargs -0 rm -rf -- 12 | @rm -rf .eggs/ 13 | 14 | clean-build: 15 | @rm -fr build/ 16 | @rm -fr dist/ 17 | @rm -fr *.egg-info 18 | 19 | clean-docs: 20 | @rm -fr docs/build/ 21 | 22 | docs: 23 | cd docs && make html 24 | 25 | test: 26 | pytest tests/ --cov simple_model 27 | mypy simple_model 28 | 29 | release: 30 | python setup.py upload 31 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Simple Model 3 | ============ 4 | 5 | .. image:: https://badge.fury.io/py/pysimplemodel.svg 6 | :target: https://pypi.org/project/pysimplemodel/ 7 | 8 | .. image:: https://img.shields.io/badge/python-3.6,3.7-blue.svg 9 | :target: https://github.com/lamenezes/simple-model 10 | 11 | .. image:: https://img.shields.io/github/license/lamenezes/simple-model.svg 12 | :target: https://github.com/lamenezes/simple-model/blob/master/LICENSE 13 | 14 | .. image:: https://circleci.com/gh/lamenezes/simple-model.svg?style=shield 15 | :target: https://circleci.com/gh/lamenezes/simple-model 16 | 17 | .. image:: https://codecov.io/gh/lamenezes/simple-model/branch/master/graph/badge.svg 18 | :target: https://codecov.io/gh/lamenezes/simple-model 19 | 20 | 21 | *SimpleModel* offers a simple way to handle data using classes instead of a 22 | plenty of lists and dicts. 23 | 24 | It has simple objectives: 25 | 26 | - Define models and its fields easily using class attributes, type annotations or tuples (whatever suits your needs) 27 | - Support for field validation, cleaning and type conversion 28 | - Easy model conversion to dict 29 | 30 | 31 | Quickstart 32 | ========== 33 | 34 | Installing 35 | ---------- 36 | 37 | Open your favorite shell and run the following command: 38 | 39 | .. code:: shell 40 | 41 | pip install pysimplemodel 42 | 43 | 44 | Example 45 | ------- 46 | 47 | Define your models using type annotations: 48 | 49 | .. code:: python 50 | 51 | from simple_model import Model 52 | 53 | 54 | class Person(Model): 55 | age: int 56 | height: float 57 | is_active: bool = True 58 | name: str 59 | 60 | 61 | Simple model automatically creates an initializer for your model and you all set 62 | to create instances: 63 | 64 | .. code:: python 65 | 66 | >> person = Person(age=18, height=1.67, name='John Doe') 67 | >> person.name 68 | 'John Doe' 69 | 70 | As you have noticed we haven't informed a value for field `is_active`, but the model 71 | was still created. That's because we've set a default value of `True` for it and 72 | the model takes care of assigning it automatically to the field: 73 | 74 | .. code:: python 75 | 76 | >> person.is_active 77 | True 78 | 79 | 80 | Simple model also offers model validation. Empty fields are considered invalid and will 81 | raise errors upon validation. Let's perform some tests using the previous `Person` model: 82 | 83 | .. code:: python 84 | 85 | >> person = Person() 86 | >> print(person.name) 87 | None 88 | >> person.validate() 89 | Traceback (most recent call last): 90 | ... 91 | EmptyField: 'height' field cannot be empty 92 | 93 | Let's say we want the height and age fields to be optional, that can be achieved with 94 | the following piece of code: 95 | 96 | .. code:: python 97 | 98 | from simple_model import Model 99 | 100 | 101 | class Person(Model): 102 | age: int = None 103 | height: float = None 104 | is_active: bool = True 105 | name: str 106 | 107 | 108 | Now let's test it: 109 | 110 | .. code:: python 111 | 112 | >> person = Person(name='Jane Doe', is_active=False) 113 | >> person.is_active 114 | False 115 | >> person.validate() 116 | True 117 | 118 | The last line won't raise an exception which means the model instance is valid! 119 | In case you need the validation to return True or False instead of raising an 120 | exception that's possible by doing the following: 121 | 122 | .. code:: python 123 | 124 | >> person.validate(raise_exception=False) 125 | True 126 | 127 | 128 | You can also add custom validations by writing class methods prefixed by `validate` 129 | followed by the attribute name, e.g. 130 | 131 | .. code:: python 132 | 133 | class Person: 134 | age: int 135 | height: float 136 | name: str 137 | 138 | def validate_age(self, age): 139 | if age < 0 or age > 150: 140 | raise ValidationError('Invalid value for age {!r}'.format(age)) 141 | 142 | return age 143 | 144 | def validate_height(self, height): 145 | if height <= 0: 146 | raise ValidationError('Invalid value for height {!r}'.format(age)) 147 | 148 | return height 149 | 150 | 151 | Let's test it: 152 | 153 | .. code:: python 154 | 155 | >> person = Person(name='John Doe', age=190) 156 | >> person.validate() 157 | Traceback (most recent call last): 158 | ... 159 | ValidationError: Invalid value for age 190 160 | >> other_person = Person(name='Jane Doe', height=-1.67) 161 | >> other_person.validate() 162 | Traceback (most recent call last): 163 | ... 164 | ValidationError: Invalid value for height -1.67 165 | 166 | 167 | It is important to note that models don't validate types. Currently types are used 168 | for field value conversion. 169 | 170 | The `validate` method also supports cleaning the field values by defining custom transformations 171 | in the `validate_` methods: 172 | 173 | .. code:: python 174 | 175 | class Person: 176 | age: int 177 | name: str 178 | 179 | def validate_name(self, name): 180 | return name.strip() 181 | 182 | >>> person = Person(age=18.0, name='John Doe ') 183 | >>> person.name 184 | 'John Doe ' 185 | >> person.age 186 | 18.0 187 | >>> person.validate() 188 | >>> person.name 189 | 'John Doe' 190 | >>> person.age # all attributes are converted to its type before cleaning 191 | 18 # converted from float (18.0) to int (18) 192 | 193 | 194 | Finally, simple model allows you to easily convert your model to dict type using the function `to_dict()`: 195 | 196 | .. code:: python 197 | 198 | >>> to_dict(person) 199 | { 200 | 'age': 18, 201 | 'name': 'John Doe' 202 | } 203 | 204 | 205 | Documentation 206 | ============= 207 | 208 | Docs on simple-model.rtfd.io_ 209 | 210 | .. _simple-model.rtfd.io: https://simple-model.readthedocs.io/en/latest/ 211 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 0.5% 7 | patch: off 8 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = simple-model 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | import simple_model 2 | 3 | extensions = [ 4 | 'sphinx.ext.autodoc', 5 | 'sphinx.ext.doctest', 6 | 'sphinx.ext.todo', 7 | 'sphinx.ext.coverage', 8 | 'sphinx.ext.imgmath', 9 | 'sphinx.ext.ifconfig', 10 | 'sphinx.ext.viewcode', 11 | ] 12 | 13 | templates_path = ['_templates'] 14 | 15 | source_suffix = '.rst' 16 | 17 | master_doc = 'index' 18 | 19 | # General information about the project. 20 | project = simple_model.__title__ 21 | author = simple_model.__author__ 22 | copyright = '2018, {}'.format(author) 23 | 24 | # The version info for the project you're documenting, acts as replacement for 25 | # |version| and |release|, also used in various other places throughout the 26 | # built documents. 27 | # 28 | # The short X.Y version. 29 | version = simple_model.__version__ 30 | # The full version, including alpha/beta/rc tags. 31 | release = version 32 | 33 | # The language for content autogenerated by Sphinx. Refer to documentation 34 | # for a list of supported languages. 35 | # 36 | # This is also used if you do content translation via gettext catalogs. 37 | # Usually you set "language" from the command line for these cases. 38 | language = None 39 | 40 | # List of patterns, relative to source directory, that match files and 41 | # directories to ignore when looking for source files. 42 | # This patterns also effect to html_static_path and html_extra_path 43 | exclude_patterns = [] 44 | 45 | # The name of the Pygments (syntax highlighting) style to use. 46 | pygments_style = 'sphinx' 47 | 48 | # If true, `todo` and `todoList` produce output, else they produce nothing. 49 | todo_include_todos = True 50 | 51 | 52 | # -- Options for HTML output ---------------------------------------------- 53 | 54 | # The theme to use for HTML and HTML Help pages. See the documentation for 55 | # a list of builtin themes. 56 | # 57 | html_theme = 'alabaster' 58 | 59 | # Theme options are theme-specific and customize the look and feel of a theme 60 | # further. For a list of options available for each theme, see the 61 | # documentation. 62 | # 63 | # html_theme_options = {} 64 | 65 | # Add any paths that contain custom static files (such as style sheets) here, 66 | # relative to this directory. They are copied after the builtin static files, 67 | # so a file named "default.css" will overwrite the builtin "default.css". 68 | html_static_path = ['_static'] 69 | 70 | # Custom sidebar templates, must be a dictionary that maps document names 71 | # to template names. 72 | # 73 | # This is required for the alabaster theme 74 | # refs: http://alabaster.readthedocs.io/en/latest/installation.html#sidebars 75 | html_sidebars = { 76 | '**': [ 77 | 'relations.html', # needs 'show_related': True theme option to display 78 | 'searchbox.html', 79 | ] 80 | } 81 | 82 | 83 | # -- Options for HTMLHelp output ------------------------------------------ 84 | 85 | # Output file base name for HTML help builder. 86 | htmlhelp_basename = 'SimpleModeldoc' 87 | 88 | 89 | # -- Options for LaTeX output --------------------------------------------- 90 | 91 | latex_elements = { 92 | # The paper size ('letterpaper' or 'a4paper'). 93 | # 94 | # 'papersize': 'letterpaper', 95 | 96 | # The font size ('10pt', '11pt' or '12pt'). 97 | # 98 | # 'pointsize': '10pt', 99 | 100 | # Additional stuff for the LaTeX preamble. 101 | # 102 | # 'preamble': '', 103 | 104 | # Latex figure (float) alignment 105 | # 106 | # 'figure_align': 'htbp', 107 | } 108 | 109 | # Grouping the document tree into LaTeX files. List of tuples 110 | # (source start file, target name, title, 111 | # author, documentclass [howto, manual, or own class]). 112 | latex_documents = [ 113 | (master_doc, 'SimpleModel.tex', 'Simple Model Documentation', 114 | 'Luiz Menezes', 'manual'), 115 | ] 116 | 117 | 118 | # -- Options for manual page output --------------------------------------- 119 | 120 | # One entry per manual page. List of tuples 121 | # (source start file, name, description, authors, manual section). 122 | man_pages = [ 123 | (master_doc, 'simplemodel', 'Simple Model Documentation', 124 | [author], 1) 125 | ] 126 | 127 | 128 | # -- Options for Texinfo output ------------------------------------------- 129 | 130 | # Grouping the document tree into Texinfo files. List of tuples 131 | # (source start file, target name, title, author, 132 | # dir menu entry, description, category) 133 | texinfo_documents = [ 134 | (master_doc, 'SimpleModel', 'Simple Model Documentation', 135 | author, 'SimpleModel', 'One line description of project.', 136 | 'Miscellaneous'), 137 | ] 138 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | ===================================== 2 | simple model: data handling made easy 3 | ===================================== 4 | 5 | simple model offers a simple way to work with data. It is very common to use 6 | lists and dict to handle data, but your code can get ugly very easily when 7 | building larger applications. 8 | 9 | It allows you to easily define models using classes and perform common tasks 10 | such as data cleaning, validation, type conversion and much more. 11 | 12 | this lib has simple objectives: 13 | 14 | * Define models easily (support type hints) 15 | * Perform validation, cleaning, default values and type conversion 16 | * Convert models to dict 17 | 18 | installing 19 | ========== 20 | 21 | .. code:: bash 22 | 23 | $ pip install pysimplemodel 24 | 25 | 26 | Quite easy. ⚽⬆ 27 | 28 | 29 | basic example 30 | ============= 31 | 32 | To define your models is as simple as stated in the following example: 33 | 34 | .. code:: python 35 | 36 | from simple_model import Model 37 | from simple_model.exceptions import ValidationError 38 | 39 | 40 | class Person(Model): 41 | age: int 42 | is_active: bool = False 43 | name: str 44 | 45 | def validate_age(self, age): 46 | if age < 0 or age > 150: 47 | raise ValidationError('Invalid age') 48 | return age 49 | 50 | def validate_name(self, name): 51 | if len(name) == 0: 52 | raise ValidationError('Invalid name') 53 | return name.strip() 54 | 55 | 56 | .. code:: python 57 | 58 | >>> person = Person(name='John Doe', age=18) 59 | >>> person.name 60 | 'John Doe' 61 | >> print(person.is_active) 62 | False 63 | >>> person.validate() 64 | >>> dict(person) 65 | {'name': 'John Doe', 'age': 18, is_active: False} 66 | >> other_person = Person(name='', age=44) 67 | >> other_person.validate() 68 | Traceback (most recent call last): 69 | ... 70 | ValidationError: Invalid name 71 | 72 | 73 | If you want to understand better other simple model features consult the 74 | following pages simple model's docs. 75 | 76 | 77 | More Docs 78 | ========= 79 | 80 | .. toctree:: 81 | :maxdepth: 2 82 | 83 | more 84 | -------------------------------------------------------------------------------- /docs/source/more.rst: -------------------------------------------------------------------------------- 1 | Customizing the fields on your model 2 | ==================================== 3 | 4 | Models are used to manage business logic, data and rules of applications. 5 | Data is represented by model classes and its fields. The easiest way to 6 | define fields using simple model is by using `type hints`_ syntax: 7 | 8 | .. code-block:: python 9 | 10 | from decimal import Decimal 11 | 12 | from simple_model import Model 13 | 14 | 15 | class Category(Model): 16 | name: str 17 | is_active: bool = False 18 | 19 | 20 | class Product(Model): 21 | title: str 22 | description: str 23 | category: Category 24 | price: Decimal 25 | is_active: bool = False 26 | 27 | 28 | In the example above we create two models ``Category`` with ``name`` 29 | and ``is_active`` fields and ``Product`` with ``title``, ``description`` and 30 | other fields. 31 | 32 | simple model will automtically create custom initializers (``__init__`` methods 33 | or constructors) that receives all the specified fields as parameters. 34 | To create model instances just do the following: 35 | 36 | 37 | .. code-block:: python 38 | 39 | category = Category( 40 | name='clothing', 41 | is_active=True, 42 | ) 43 | product = Product( 44 | title='Pants', 45 | description='Pants are great', 46 | category=category, 47 | price=80, 48 | is_active=True, 49 | ) 50 | 51 | 52 | As shown in the models above it is possible to can easily customize field types 53 | by defining the class attributes using `type hints`_. It is also possible to 54 | provide a default value for each field by setting values on each field on the 55 | model class. 56 | 57 | 58 | **Note:** 59 | 60 | Attributes and methods that starts with "__" (dunder) are considered private 61 | and not included in model. 62 | 63 | For a better understanding, check this `python underscore convention`_ link. 64 | 65 | 66 | Defining fields without explicit types 67 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 68 | 69 | If you don't want to enforce types on your fields it is possible to use 70 | ``typing.Any`` as a field type. This way simple model will ignore any type-related 71 | feature on the declared model: 72 | 73 | .. code-block:: python 74 | 75 | from typing import Any 76 | 77 | from simple_model import Model 78 | 79 | 80 | class Bag(Model): 81 | name: Any 82 | brand: Any 83 | items: Any 84 | 85 | 86 | Validating data on your model 87 | ============================= 88 | 89 | TBD 90 | 91 | Allowing empty values on field validation 92 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 93 | 94 | TBD 95 | 96 | 97 | Converting models to dict 98 | ========================= 99 | 100 | To convert model to dict you should use `to_dict()` 101 | 102 | .. code-block:: python 103 | 104 | from simple_model import Model 105 | from simple_model.converters import to_dict 106 | 107 | class Category(Model): 108 | name: str 109 | is_active: bool = False 110 | 111 | category = Category( 112 | name='clothing', 113 | is_active=True, 114 | ) 115 | 116 | to_dict(category) 117 | 118 | 119 | You can convert models instances to dictionary using the method `.as_dict()`, you must run `.validate()` before conversion. 120 | This way is more recommended because you don't need to import `to_dict` to convert the instance 121 | 122 | .. code-block:: python 123 | 124 | from simple_model import Model 125 | 126 | 127 | class Category(Model): 128 | name: str 129 | is_active: bool = False 130 | 131 | category = Category( 132 | name='clothing', 133 | is_active=True, 134 | ) 135 | 136 | category.validate() 137 | category.as_dict() 138 | 139 | 140 | Creating models instances and classes from dicts 141 | ================================================ 142 | 143 | TBD 144 | 145 | 146 | Model inheritance 147 | ================= 148 | 149 | TBD 150 | 151 | Field conversion and customizing model initialization 152 | ===================================================== 153 | 154 | TBD (``__post_init__``) 155 | 156 | 157 | Building models and model classes dynamically 158 | ============================================= 159 | 160 | TBD 161 | 162 | 163 | FAQ 164 | === 165 | 166 | TBD 167 | 168 | 169 | .. _`type hints`: https://www.python.org/dev/peps/pep-0484/#type-definition-syntax 170 | .. _`python underscore convention`: https://dbader.org/blog/meaning-of-underscores-in-python 171 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testspaths = tests 3 | addopts = -vv --cov=simple_model --cov-report=term-missing 4 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements-test.txt 2 | ipdb 3 | pre-commit 4 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | pysimplemodel 2 | -------------------------------------------------------------------------------- /requirements-test.txt: -------------------------------------------------------------------------------- 1 | flake8 2 | mypy 3 | pytest 4 | pytest-cov 5 | sphinx 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from shutil import rmtree 4 | 5 | from setuptools import setup, find_packages, Command 6 | from simple_model.__version__ import __version__ 7 | 8 | here = os.path.abspath(os.path.dirname(__file__)) 9 | with open(os.path.join(here, 'README.rst')) as f: 10 | readme = '\n' + f.read() 11 | 12 | 13 | class UploadCommand(Command): 14 | """Support setup.py publish.""" 15 | 16 | description = 'Build and publish the package.' 17 | user_options = [] 18 | 19 | @staticmethod 20 | def status(s): 21 | """Prints things in bold.""" 22 | print('\033[1m{0}\033[0m'.format(s)) 23 | 24 | def initialize_options(self): 25 | pass 26 | 27 | def finalize_options(self): 28 | pass 29 | 30 | def run(self): 31 | try: 32 | self.status('Removing previous builds…') 33 | rmtree(os.path.join(here, 'dist')) 34 | except FileNotFoundError: 35 | pass 36 | 37 | self.status('Building Source distribution…') 38 | os.system('{0} setup.py sdist'.format(sys.executable)) 39 | 40 | self.status('Uploading the package to PyPi via Twine…') 41 | os.system('twine upload dist/*') 42 | 43 | self.status('Pushing git tags…') 44 | os.system('git tag v{0}'.format(__version__)) 45 | os.system('git push --tags') 46 | 47 | sys.exit() 48 | 49 | 50 | setup( 51 | name='pysimplemodel', 52 | version=__version__, 53 | description='Data handling made easy', 54 | long_description='\n' + readme, 55 | url='https://github.com/lamenezes/simple-model', 56 | author='Luiz Menezes', 57 | author_email='luiz.menezesf@gmail.com', 58 | packages=find_packages(exclude=['tests']), 59 | license='MIT', 60 | classifiers=[ 61 | 'Development Status :: 5 - Production/Stable', 62 | 'Intended Audience :: Developers', 63 | 'License :: OSI Approved :: MIT License', 64 | 'Programming Language :: Python :: 3', 65 | 'Programming Language :: Python :: 3.6', 66 | 'Programming Language :: Python :: 3.7', 67 | 'Topic :: Software Development :: Libraries', 68 | ], 69 | cmdclass={ 70 | 'upload': UploadCommand, 71 | }, 72 | ) 73 | -------------------------------------------------------------------------------- /simple_model/__init__.py: -------------------------------------------------------------------------------- 1 | from .__version__ import __author__, __title__, __version__ # noqa 2 | from .builder import model_builder, model_many_builder 3 | from .converters import to_dict 4 | from .models import Model 5 | 6 | __all__ = ('__version__', 'Model', 'model_builder', 'model_many_builder', 'to_dict') 7 | -------------------------------------------------------------------------------- /simple_model/__version__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Luiz Menezes' 2 | __title__ = 'simple-model' 3 | __version__ = '2.4.3' 4 | -------------------------------------------------------------------------------- /simple_model/base.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | from .fields import ModelField, Unset 4 | from .utils import is_not_special_object, is_private_attribute 5 | 6 | 7 | class ModelMetaClass(type): 8 | _field_class = ModelField 9 | 10 | @classmethod 11 | def _get_class_attributes(cls, new_class, parents): 12 | attrs = set( 13 | k for k, v in vars(new_class).items() 14 | if not (k[:2] == '__' and k[-2:] == '__') and is_not_special_object(v) and not is_private_attribute(k) 15 | ) 16 | 17 | if not parents: 18 | return attrs 19 | 20 | return cls._get_class_attributes(parents[0], parents[1:]) | attrs 21 | 22 | @classmethod 23 | def _get_fields(cls, attrs, hints): 24 | fields = set(hints) | attrs 25 | fields.discard('Meta') 26 | fields.discard('_is_valid') 27 | return tuple(field for field in fields if not is_private_attribute(field)) 28 | 29 | def __new__(cls, name, bases, attrs, **kwargs): 30 | super_new = super().__new__ 31 | 32 | # do not perform initialization for Model class 33 | # only initialize Model subclasses 34 | parents = [base for base in bases if isinstance(base, ModelMetaClass)] 35 | if not parents: 36 | return super_new(cls, name, bases, attrs) 37 | 38 | new_class = super_new(cls, name, bases, attrs, **kwargs) 39 | meta = type('Meta', (), {}) 40 | 41 | hints = typing.get_type_hints(new_class) 42 | attrs = cls._get_class_attributes(new_class, parents) 43 | assert hints or attrs, '{} model must define class attributes'.format(new_class.__name__) 44 | meta.fields = cls._get_fields(attrs, hints) 45 | meta.descriptors = {} 46 | 47 | for field_name in meta.fields: 48 | field_type = hints.get(field_name) if hints else None 49 | default_value = getattr(new_class, field_name, Unset) 50 | field = ModelField( 51 | model_class=new_class, 52 | name=field_name, 53 | default_value=default_value, 54 | type=field_type, 55 | ) 56 | meta.descriptors[field_name] = field 57 | 58 | new_class._meta = meta 59 | new_class._is_valid = False 60 | 61 | return new_class 62 | -------------------------------------------------------------------------------- /simple_model/builder.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Generator 2 | 3 | from .models import Model 4 | from .utils import camel_case, coerce_to_alpha, snake_case, remove_private_keys 5 | 6 | 7 | def model_class_builder(class_name: str, data: Any) -> type: 8 | keys = data.keys() or ('',) 9 | attrs = {key: None for key in keys} 10 | attrs['__annotations__'] = {key: Any for key in keys} # type: ignore 11 | new_class = type(class_name, (Model,), remove_private_keys(attrs)) 12 | return new_class 13 | 14 | 15 | def model_builder( 16 | data: Any, class_name: str = 'MyModel', cls: type = None, recurse: bool = True, 17 | snake_case_keys: bool = True, alpha_keys: bool = True, 18 | ) -> Model: 19 | 20 | clean_funcs = [] 21 | if snake_case_keys: 22 | clean_funcs.append(snake_case) 23 | 24 | if alpha_keys: 25 | clean_funcs.append(coerce_to_alpha) 26 | 27 | data = {func(key): value for key, value in data.items() for func in clean_funcs} 28 | if not cls: 29 | cls = model_class_builder(class_name, data) 30 | instance = cls(**remove_private_keys(data)) 31 | 32 | if not recurse: 33 | return instance 34 | 35 | for name, descriptor in instance._get_fields(): 36 | value = getattr(instance, name) 37 | if isinstance(value, dict): 38 | value = model_builder(value, camel_case(name)) 39 | elif isinstance(value, (list, tuple)): 40 | value = list(value) 41 | 42 | for i, elem in enumerate(value): 43 | if not isinstance(elem, dict): 44 | continue 45 | value[i] = model_builder(elem, 'NamelessModel') 46 | 47 | field_class = value.__class__ 48 | value = field_class(value) 49 | 50 | setattr(instance, name, value) 51 | 52 | return instance 53 | 54 | 55 | def model_many_builder( 56 | data: list, class_name: str = 'MyModel', cls: type = None, recurse: bool = True, 57 | snake_case_keys: bool = True, alpha_keys: bool = True, 58 | ) -> Generator[Model, None, None]: 59 | 60 | if len(data) == 0: 61 | return 62 | 63 | first = data[0] 64 | cls = cls or model_class_builder(class_name, first) 65 | for element in data: 66 | model = model_builder( 67 | data=element, 68 | class_name=class_name, 69 | cls=cls, 70 | recurse=recurse, 71 | snake_case_keys=snake_case_keys, 72 | alpha_keys=alpha_keys, 73 | ) 74 | yield model 75 | -------------------------------------------------------------------------------- /simple_model/converters.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from .models import BaseModel 4 | 5 | 6 | def to_dict(model: BaseModel): 7 | if not isinstance(model, BaseModel): 8 | raise TypeError('First argument must be of class type simple_model.Model') 9 | 10 | assert model._is_valid, 'model.validate() must be run before conversion' 11 | 12 | d = {} 13 | for field_name, descriptor in model._get_fields(): 14 | value = getattr(model, field_name) 15 | if isinstance(value, Enum): 16 | value = value.value 17 | d[field_name] = descriptor.to_python(value) 18 | 19 | return d 20 | -------------------------------------------------------------------------------- /simple_model/exceptions.py: -------------------------------------------------------------------------------- 1 | class ValidationError(Exception): 2 | pass 3 | 4 | 5 | class EmptyField(ValidationError): 6 | def __init__(self, field_name): 7 | self.field_name = field_name 8 | 9 | def __str__(self) -> str: 10 | return '{!r} field cannot be empty'.format(self.field_name) 11 | -------------------------------------------------------------------------------- /simple_model/fields.py: -------------------------------------------------------------------------------- 1 | from typing import Any, List, Union, Tuple, TypeVar 2 | 3 | from .exceptions import EmptyField 4 | 5 | PARAMETRIZED_GENERICS = (List, Tuple) 6 | 7 | Unset = type('Unset', (), {}) 8 | 9 | 10 | class ModelField: 11 | def __init__(self, model_class, name, default_value=Unset, type=None): 12 | self.model_class = model_class 13 | self.name = name 14 | self._default_value = default_value 15 | self._type = type 16 | self.is_property = isinstance(getattr(model_class, name, None), property) 17 | 18 | try: 19 | self._validate = getattr(model_class, 'validate_{}'.format(name)) 20 | except AttributeError: 21 | self._validate = None 22 | 23 | def __repr__(self): 24 | return (f'ModelField(model_class={self.model_class!r}, name={self.name!r}, ' 25 | f'default_value={self._default_value!r}, type={self._type!r})') 26 | 27 | @property 28 | def default_value(self): 29 | return self._default_value if self._default_value is not Unset else None 30 | 31 | @property 32 | def types(self): 33 | try: 34 | return self._type.__args__ or [] 35 | except AttributeError: 36 | return [self._type] 37 | 38 | @property 39 | def allow_empty(self): 40 | return type(None) in self.types or self._default_value is not Unset 41 | 42 | def _split_class_and_type(self, type_): 43 | try: 44 | return type_.__origin__, type_ 45 | except AttributeError: 46 | return type_, None 47 | 48 | def convert_to_type(self, instance, value, field_class=None): 49 | field_class = field_class or self._type 50 | field_class, field_type = self._split_class_and_type(field_class) 51 | 52 | if not field_class or field_class is Any or value is None or self.is_property: 53 | return value 54 | 55 | if field_class is Union: 56 | assert issubclass(type(value), field_type.__args__), ( 57 | 'Field of type {} received an object of invalid type {}').format( 58 | field_type.__args__, type(value)) 59 | 60 | return value 61 | 62 | if not issubclass(field_class, PARAMETRIZED_GENERICS) and type(value) is field_class: 63 | return value 64 | 65 | from simple_model.models import Model 66 | if issubclass(field_class, Model) and isinstance(value, Model): 67 | assert type(value) is field_class, ('Field of type {} received an object of invalid ' 68 | 'type {}').format(field_class, type(value)) 69 | 70 | if issubclass(field_class, Model): 71 | return field_class(**value) 72 | 73 | if issubclass(field_class, (list, tuple)): 74 | try: 75 | element_field_class = field_type.__args__[0] if field_type.__args__ else None 76 | except AttributeError: 77 | element_field_class = None 78 | 79 | # if iterable value is a type var refrain from casting to avoid converting to a type 80 | # the user may not want , e.g. 81 | # T = TypeVar('T', str, bytes) 82 | # class Model: 83 | # t: T 84 | # what's the correct type to convert here? str? bytes? for now there's no conversion 85 | if isinstance(element_field_class, TypeVar): 86 | element_field_class = None 87 | 88 | iterable_field_class = tuple if issubclass(field_class, tuple) else list 89 | if not element_field_class: 90 | return iterable_field_class(value) 91 | 92 | values = [] 93 | for elem in value: 94 | if not isinstance(elem, element_field_class): 95 | elem = self.convert_to_type(instance, elem, field_class=element_field_class) 96 | values.append(elem) 97 | 98 | return iterable_field_class(values) 99 | 100 | if isinstance(value, field_class): 101 | return value 102 | 103 | return field_class(value) 104 | 105 | def validate(self, instance, value): 106 | if not self.allow_empty and self.model_class.is_empty(value): 107 | raise EmptyField(self.name) 108 | 109 | if isinstance(value, (list, tuple)): 110 | for elem in value: 111 | try: 112 | elem.validate() 113 | except AttributeError: 114 | continue 115 | 116 | if self._validate: 117 | return self._validate(instance, value) 118 | 119 | try: 120 | value.validate() 121 | except AttributeError: 122 | return value 123 | 124 | return value 125 | 126 | def to_python(self, value): 127 | if not value: 128 | return value 129 | 130 | from .converters import to_dict 131 | if not isinstance(value, (list, tuple)): 132 | try: 133 | return to_dict(value) 134 | except (TypeError, ValueError): 135 | return value 136 | 137 | python_value = [] 138 | for elem in value: 139 | try: 140 | elem = to_dict(elem) 141 | except (TypeError, ValueError): 142 | pass 143 | python_value.append(elem) 144 | 145 | value_cls = type(python_value) 146 | return python_value if issubclass(value_cls, list) else value_cls(python_value) 147 | -------------------------------------------------------------------------------- /simple_model/models.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Callable, Iterable, Iterator, Tuple, Union 2 | 3 | from .base import ModelMetaClass 4 | from .exceptions import ValidationError 5 | from .fields import ModelField 6 | from .utils import getkey 7 | 8 | 9 | class BaseModel: 10 | def __init__(self, **kwargs): 11 | for field_name in self._meta.fields: 12 | descriptor = self._meta.descriptors[field_name] 13 | 14 | field_value = kwargs.get(field_name) 15 | default = descriptor.default_value 16 | factory = default if isinstance(default, Callable) else None 17 | field_value = factory() if factory and not field_value else kwargs.get(field_name, default) 18 | 19 | if descriptor and descriptor.is_property: 20 | self.__setattr__(field_name, field_value) 21 | else: 22 | object.__setattr__(self, field_name, field_value) 23 | 24 | self.__post_init__(**kwargs) 25 | 26 | def __post_init__(self, **kwargs): 27 | pass 28 | 29 | def __eq__(self, other: Any) -> bool: 30 | if isinstance(other, Model): 31 | other_fields = list(other._get_fields()) 32 | get_func = getattr 33 | else: 34 | try: 35 | other = dict(other) 36 | except (ValueError, TypeError): 37 | return NotImplemented 38 | other_fields = other 39 | get_func = getkey # type: ignore 40 | 41 | self_fields = list(self._get_fields()) 42 | if len(self_fields) != len(other_fields): 43 | return False 44 | 45 | for name, _ in self_fields: 46 | value = getattr(self, name) 47 | try: 48 | if value != get_func(other, name): 49 | return False 50 | except AttributeError: 51 | return False 52 | 53 | return True 54 | 55 | def __repr__(self) -> str: 56 | attrs = ', '.join( 57 | '{name}={value!r}'.format(name=name, value=getattr(self, name)) 58 | for name, _ in self._get_fields() 59 | ) 60 | return '{class_name}({attrs})'.format(class_name=type(self).__name__, attrs=attrs) 61 | 62 | def __setattr__(self, name, value): 63 | try: 64 | super().__setattr__(name, value) 65 | except AttributeError: 66 | descriptor = self._meta.descriptors.get(name, False) 67 | if descriptor and not descriptor.is_property: 68 | raise # TODO: find/implement a test case for this statement 69 | 70 | def _get_fields(self) -> Iterator[Tuple[str, ModelField]]: 71 | return ( 72 | (field_name, self._meta.descriptors[field_name]) # type: ignore 73 | for field_name in self._meta.fields # type: ignore 74 | ) 75 | 76 | @classmethod 77 | def build_many(cls, source: Iterable) -> list: 78 | if cls.is_empty(source): 79 | return [] 80 | 81 | keys_sets = [d.keys() for d in source] 82 | for key_set in keys_sets: 83 | if key_set ^ keys_sets[0]: 84 | raise ValueError('All elements in source should have the same keys') 85 | 86 | return [cls(**item) for item in source] 87 | 88 | @staticmethod 89 | def is_empty(value: Any) -> bool: 90 | if value == 0 or value is False: 91 | return False 92 | return not bool(value) 93 | 94 | def convert_fields(self): 95 | for name, descriptor in self._get_fields(): 96 | if descriptor.is_property: 97 | continue 98 | 99 | value = object.__getattribute__(self, name) 100 | new_value = descriptor.convert_to_type(self, value) 101 | 102 | object.__setattr__(self, name, new_value) 103 | 104 | def validate(self, raise_exception: bool = True) -> Union[None, bool]: 105 | self.convert_fields() 106 | 107 | for name, descriptor in self._get_fields(): 108 | if descriptor.is_property and descriptor._validate is None: 109 | continue 110 | 111 | value = object.__getattribute__(self, name) 112 | try: 113 | value = descriptor.validate(self, value) 114 | except ValidationError: 115 | self._is_valid = False 116 | if raise_exception: 117 | raise 118 | return False 119 | except Exception: 120 | self._is_valid = False 121 | raise 122 | 123 | try: 124 | object.__setattr__(self, name, value) 125 | except AttributeError: 126 | self.__setattr__(name, value) 127 | 128 | self._is_valid = True 129 | return None if raise_exception else True 130 | 131 | def as_dict(self): 132 | """ 133 | Returns the model as a dict 134 | """ 135 | from .converters import to_dict 136 | return to_dict(self) 137 | 138 | 139 | class Model(BaseModel, metaclass=ModelMetaClass): 140 | pass 141 | 142 | 143 | class LazyModel(BaseModel, metaclass=ModelMetaClass): 144 | """ LazyModel is a short name for LazyValidationModel. Example: 145 | 146 | class FooModel(LazyModel): 147 | foo: 'str' 148 | 149 | 150 | >>> model = FooModel() 151 | >>> model.foo 152 | Traceback (most recent call last): 153 | ... 154 | simple_model.exceptions.EmptyField: 'foo' field cannot be empty 155 | """ 156 | 157 | def __getattribute__(self, name): 158 | meta = object.__getattribute__(self, '_meta') 159 | if name in meta.fields and not self._is_valid: 160 | validate = object.__getattribute__(self, 'validate') 161 | validate() 162 | 163 | return object.__getattribute__(self, name) 164 | 165 | def __setattr__(self, name, value): 166 | meta = object.__getattribute__(self, '_meta') 167 | if name in meta.fields and self._is_valid: 168 | self._is_valid = False 169 | 170 | return super().__setattr__(name, value) 171 | 172 | def as_dict(self): 173 | """ 174 | Returns the model as a dict 175 | """ 176 | if not self._is_valid: 177 | self.validate() 178 | 179 | from .converters import to_dict 180 | return to_dict(self) 181 | -------------------------------------------------------------------------------- /simple_model/utils.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import re 3 | import typing 4 | 5 | NOT_WORD = re.compile(r'\W') 6 | SNAKE_CASE = re.compile('([a-z0-9])([A-Z])') 7 | SNAKE_CASE_AUX = re.compile('(.)([A-Z][a-z]+)') 8 | _PRIVATE_ATTR_RE = re.compile(r'_[\w\d]+__[\w\d]') 9 | 10 | 11 | def capitalize_first(string: str) -> str: 12 | return string[0].upper() + string[1:] if string != '' else string 13 | 14 | 15 | def camel_case(string: str) -> str: 16 | string = capitalize_first(string) 17 | for separator in ('_', '-', ' '): 18 | if separator not in string: 19 | continue 20 | string = ''.join(capitalize_first(substr) for substr in string.split(separator)) 21 | return string 22 | 23 | 24 | def coerce_to_alpha(string: str) -> str: 25 | return NOT_WORD.sub('_', string) 26 | 27 | 28 | def snake_case(string: str) -> str: 29 | aux = SNAKE_CASE_AUX.sub(r'\1_\2', string) 30 | return SNAKE_CASE.sub(r'\1_\2', aux).lower() 31 | 32 | 33 | def is_not_special_object(obj): 34 | return not any(( 35 | inspect.isclass(obj), 36 | inspect.ismethod(obj), 37 | inspect.isfunction(obj), 38 | inspect.isgeneratorfunction(obj), 39 | inspect.isgenerator(obj), 40 | inspect.isroutine(obj), 41 | isinstance(obj, property), 42 | )) 43 | 44 | 45 | def getkey(d: dict, key: typing.Any): 46 | return d[key] 47 | 48 | 49 | def remove_private_keys(d: dict) -> dict: 50 | return { 51 | k: v for k, v in d.items() if not k.startswith('__') 52 | } 53 | 54 | 55 | def is_private_attribute(name): 56 | return _PRIVATE_ATTR_RE.match(name) is not None 57 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lamenezes/simple-model/e89f8e1143c28fa8bfd0286e71cd5fb91b7a2ab7/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import pytest 4 | 5 | from simple_model.exceptions import ValidationError 6 | from simple_model.models import Model 7 | 8 | 9 | class MyModel(Model): 10 | foo: str 11 | bar: typing.Any 12 | baz = None 13 | qux = None 14 | 15 | def validate_foo(self, value): 16 | if len(value) != 3: 17 | raise ValidationError() 18 | return value 19 | 20 | 21 | @pytest.fixture 22 | def model_data(): 23 | return { 24 | 'foo': 'foo', 25 | 'bar': 'bar', 26 | 'baz': '', 27 | 'qux': '', 28 | } 29 | 30 | 31 | @pytest.fixture 32 | def model(model_data): 33 | return MyModel(**model_data) 34 | 35 | 36 | @pytest.fixture 37 | def model2(): 38 | return MyModel(foo='f00', bar='barbar', baz='', qux='') 39 | -------------------------------------------------------------------------------- /tests/test_builder.py: -------------------------------------------------------------------------------- 1 | import typing 2 | 3 | import pytest 4 | 5 | from simple_model.builder import model_builder, model_class_builder, model_many_builder 6 | from simple_model import Model, to_dict 7 | 8 | 9 | def test_model_class_builder(): 10 | Birl = model_class_builder('Birl', {'f': 'foo', 'b': 'bar'}) 11 | birl = Birl() 12 | 13 | assert isinstance(birl, Model) 14 | keys = ('f', 'b') 15 | assert len(Birl._meta.fields) == len(keys) 16 | assert set(Birl._meta.fields) == set(keys) 17 | 18 | assert birl.validate(raise_exception=False) is True 19 | assert to_dict(birl) == {'f': None, 'b': None} 20 | 21 | 22 | def test_model_class_builder_empty_data(): 23 | Birl = model_class_builder('Birl', {}) 24 | birl = Birl() 25 | 26 | assert isinstance(birl, Model) 27 | 28 | 29 | def test_model_builder(): 30 | data = { 31 | 'foo': 'foo', 32 | 'bar': 'bar', 33 | } 34 | birl = model_builder(data, recurse=False) 35 | assert birl.foo == 'foo' 36 | assert birl.bar == 'bar' 37 | assert type(birl).__name__ == 'MyModel' 38 | 39 | 40 | def test_model_builder_class_name(): 41 | data = { 42 | 'foo': 'foo', 43 | 'bar': 'bar', 44 | } 45 | birl = model_builder(data, class_name='Birl', recurse=False) 46 | assert birl.foo == 'foo' 47 | assert birl.bar == 'bar' 48 | assert type(birl).__name__ == 'Birl' 49 | 50 | 51 | def test_model_builder_recurse_false(): 52 | my_model = {'baz': 'baz', 'qux': 'qux'} 53 | data = { 54 | 'foo': 'foo', 55 | 'bar': 'bar', 56 | 'my_model': my_model, 57 | } 58 | birl = model_builder(data, recurse=False) 59 | assert birl.foo == 'foo' 60 | assert birl.bar == 'bar' 61 | assert birl.my_model == my_model 62 | 63 | 64 | def test_model_builder_recurse(): 65 | my_model = {'baz': 'baz', 'qux': 'qux'} 66 | data = { 67 | 'foo': 'foo', 68 | 'bar': 'bar', 69 | 'my_model': my_model, 70 | } 71 | birl = model_builder(data) 72 | assert birl.foo == 'foo' 73 | assert birl.bar == 'bar' 74 | assert birl.my_model.baz == 'baz' 75 | assert birl.my_model.qux == 'qux' 76 | 77 | assert type(birl.my_model).__name__ == 'MyModel' 78 | assert type(birl.my_model) not in (Model, type(birl)) 79 | 80 | 81 | @pytest.mark.parametrize('iterable_class', (tuple, list)) 82 | def test_model_builder_recurse_iterable(iterable_class): 83 | models = iterable_class([{'baz': 'baz', 'qux': 'qux'}, 1, 2]) 84 | data = { 85 | 'foo': 'foo', 86 | 'bar': 'bar', 87 | 'models': models, 88 | } 89 | birl = model_builder(data) 90 | assert birl.foo == 'foo' 91 | assert birl.bar == 'bar' 92 | assert birl.models[0].baz == 'baz' 93 | assert birl.models[0].qux == 'qux' 94 | assert birl.models[1] == 1 95 | assert birl.models[2] == 2 96 | 97 | assert isinstance(birl.models[0], Model) 98 | assert type(birl.models[0]).__name__ == 'NamelessModel' 99 | 100 | 101 | def test_model_builder_data_keys_with_special_characters(): 102 | data = { 103 | 'foo*bar': 'foobar', 104 | 'baz/qux': 'bazqux', 105 | } 106 | birl = model_builder(data) 107 | assert birl.foo_bar == 'foobar' 108 | assert birl.baz_qux == 'bazqux' 109 | 110 | 111 | def test_model_builder_ignore_private_attrs(): 112 | data = { 113 | 'foo': 'foo', 114 | '_bar': 'bar', 115 | '__nope': 'nope', 116 | } 117 | birl = model_builder(data) 118 | assert birl.foo == 'foo' 119 | assert birl._bar == 'bar' 120 | assert hasattr(birl, '__nope') is False 121 | 122 | 123 | def test_model_builder_custom_class(): 124 | data = { 125 | 'foo*bar': 'foobar', 126 | 'baz/qux': 'bazqux', 127 | } 128 | cls = model_class_builder('Model', data) 129 | 130 | birl = model_builder(data, cls=cls) 131 | 132 | assert isinstance(birl, cls) 133 | 134 | 135 | def test_model_many_builder(): 136 | element = { 137 | 'foo*bar': 'foobar', 138 | 'baz/qux': 'bazqux', 139 | } 140 | model_count = 3 141 | data = [element] * model_count 142 | 143 | models = model_many_builder(data) 144 | 145 | assert isinstance(models, typing.Generator) 146 | models = list(models) 147 | assert len(models) == model_count 148 | first = models[0] 149 | for model in models[1:]: 150 | assert isinstance(model, type(first)) 151 | 152 | 153 | def test_model_many_builder_ignore_private_attrs(): 154 | element = { 155 | 'foo': 'foo', 156 | '_bar': 'bar', 157 | '__nope': 'nope', 158 | } 159 | model_count = 3 160 | data = [element] * model_count 161 | 162 | models = list(model_many_builder(data)) 163 | 164 | assert len(models) == model_count 165 | for model in models: 166 | assert model.foo == 'foo' 167 | assert model._bar == 'bar' 168 | assert hasattr(model, '__nope') is False 169 | 170 | 171 | @pytest.mark.parametrize('iterable', ([], ())) 172 | def test_model_many_builder_empty_iterable(iterable): 173 | models = model_many_builder(iterable) 174 | 175 | assert isinstance(models, typing.Generator) 176 | assert len(list(models)) == 0 177 | 178 | 179 | def test_model_many_builder_custom_cls(): 180 | class Foo(Model): 181 | bar: str 182 | 183 | def baz(self): 184 | return True 185 | 186 | data = [{'bar': 1}] * 3 187 | models = list(model_many_builder(data, cls=Foo)) 188 | 189 | assert len(models) == 3 190 | assert all(foo.baz() for foo in models) 191 | -------------------------------------------------------------------------------- /tests/test_converters.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | import pytest 4 | 5 | from simple_model import Model, to_dict 6 | from tests.conftest import MyModel 7 | 8 | 9 | def test_model_to_dict_invalid_argument(): 10 | with pytest.raises(TypeError): 11 | to_dict('') 12 | 13 | 14 | def test_model_to_dict_model_not_validated(model): 15 | with pytest.raises(AssertionError): 16 | to_dict(model) 17 | 18 | 19 | def test_model_to_dict_simple(model): 20 | as_dict_model = { 21 | 'foo': 'foo', 22 | 'bar': 'bar', 23 | 'baz': '', 24 | 'qux': '', 25 | } 26 | model.validate() 27 | 28 | assert to_dict(model) == as_dict_model 29 | 30 | 31 | @pytest.mark.parametrize('iterable', (list, tuple)) 32 | def test_model_to_dict_nested_list(iterable, model, model2): 33 | model_class = type(model) 34 | other_model = model_class(foo='foo', bar=iterable([model, model2]), baz=model) 35 | other_model.validate() 36 | 37 | as_dict = to_dict(other_model) 38 | 39 | expected = { 40 | 'foo': 'foo', 41 | 'bar': [to_dict(model), to_dict(model2)], 42 | 'baz': to_dict(model), 43 | 'qux': None 44 | } 45 | assert as_dict == expected 46 | 47 | 48 | def test_model_to_dict_property(): 49 | class Foo(Model): 50 | a: float 51 | b: float 52 | 53 | @property 54 | def b(self): 55 | return self.a + 0.1 56 | 57 | a = 1.0 58 | expected = { 59 | 'a': a, 60 | 'b': a + 0.1, 61 | } 62 | model = Foo(a=a) 63 | model.validate() 64 | 65 | assert to_dict(model) == expected 66 | 67 | 68 | def test_model_to_dict_inheritance(model_data): 69 | class ChildModel(MyModel): 70 | pass 71 | 72 | model = ChildModel(**model_data) 73 | 74 | as_dict_model = { 75 | 'foo': 'foo', 76 | 'bar': 'bar', 77 | 'baz': '', 78 | 'qux': '', 79 | } 80 | model.validate() 81 | 82 | assert to_dict(model) == as_dict_model 83 | 84 | 85 | def test_model_to_dict_attribute_is_enum(): 86 | class Bar(Enum): 87 | foo = 'foo' 88 | bar = 'bar' 89 | 90 | class FooBar(Model): 91 | foo: str 92 | bar: Bar 93 | 94 | expected_dict = {'foo': 'foo', 'bar': 'bar'} 95 | foo_bar = FooBar(foo='foo', bar=Bar.bar) 96 | foo_bar.validate() 97 | assert foo_bar.as_dict() == expected_dict 98 | -------------------------------------------------------------------------------- /tests/test_fields.py: -------------------------------------------------------------------------------- 1 | import typing 2 | from datetime import datetime 3 | from unittest import mock 4 | 5 | import pytest 6 | 7 | from simple_model.converters import to_dict 8 | from simple_model.exceptions import EmptyField, ValidationError 9 | from simple_model.fields import ModelField, Unset 10 | from simple_model.models import Model 11 | 12 | from .conftest import MyModel 13 | 14 | 15 | class MyValidationError(ValidationError): 16 | pass 17 | 18 | 19 | @pytest.fixture 20 | def model_field(): 21 | return ModelField(MyModel, name='bar', default_value='default', type=str) 22 | 23 | 24 | @pytest.fixture 25 | def empty_model_field(): 26 | return ModelField(MyModel, name='bar', type=str) 27 | 28 | 29 | @pytest.fixture 30 | def typeless_model_field(): 31 | return ModelField(MyModel, name='bar') 32 | 33 | 34 | def test_model_field(model_field): 35 | assert issubclass(model_field.model_class, Model) 36 | assert model_field.name == 'bar' 37 | assert model_field.default_value == 'default' 38 | assert issubclass(str, model_field._type) 39 | 40 | assert repr(model_field.name) in repr(model_field) 41 | assert repr(model_field.default_value) in repr(model_field) 42 | assert repr(model_field.model_class) in repr(model_field) 43 | assert repr(model_field._type) in repr(model_field) 44 | 45 | 46 | def test_empty_model_field_default_value(empty_model_field): 47 | assert empty_model_field._default_value is Unset 48 | assert empty_model_field.default_value is None 49 | 50 | 51 | @pytest.mark.parametrize('value', (1, '1', [2], ['2'])) 52 | def test_model_field_to_python_simple(model_field, value): 53 | assert model_field.to_python(value) == value 54 | 55 | 56 | def test_model_field_to_python_nested(model2, model_field): 57 | model2.validate() 58 | assert model_field.to_python(model2) == to_dict(model2) 59 | 60 | 61 | @pytest.mark.parametrize('iterable', (list, tuple)) 62 | def test_model_field_to_python_nested_iterable(iterable, model_field, model, model2): 63 | model.validate() 64 | model2.validate() 65 | assert model_field.to_python(iterable([model, model2])) == [to_dict(model), to_dict(model2)] 66 | 67 | 68 | @pytest.mark.parametrize('iterable', (list, tuple)) 69 | def test_model_field_to_python_iterable_empty(iterable, model_field, model, model2): 70 | assert model_field.to_python(iterable([])) == iterable() 71 | 72 | 73 | @pytest.mark.parametrize('iterable', (list, tuple)) 74 | def test_model_field_to_python_iterable(iterable, model_field, model, model2): 75 | assert model_field.to_python(iterable([1, 2, 3])) == [1, 2, 3] 76 | 77 | 78 | def test_model_field_validate(model, model_field): 79 | validate = mock.MagicMock(return_value=None) 80 | model_field._validate = validate 81 | 82 | assert model_field.validate(None, None) is None 83 | 84 | 85 | @pytest.mark.parametrize('exception', (ValidationError, EmptyField, MyValidationError)) 86 | def test_model_field_validate_validation_error(model, model_field, exception): 87 | validate = mock.MagicMock(side_effect=exception('foo')) 88 | model_field._validate = validate 89 | 90 | with pytest.raises(exception): 91 | model_field.validate(None, None) 92 | 93 | 94 | @pytest.mark.parametrize('blank_value', (None, '', [], {}, ())) 95 | def test_model_field_validate_empty_field(empty_model_field, blank_value): 96 | empty_model_field.value = blank_value 97 | with pytest.raises(EmptyField): 98 | empty_model_field.validate(None, None) 99 | 100 | 101 | def test_model_field_validate_and_clean(model_field): 102 | model_field._validate = lambda _, s: s.strip() 103 | 104 | assert model_field.validate(None, ' foo ') == 'foo' 105 | 106 | 107 | def test_model_field_validate_and_clean_clean_nested(model): 108 | class MyModel(Model): 109 | foo: str 110 | bar: str 111 | baz: typing.Any 112 | 113 | def validate_foo(self, value): 114 | return value.strip() 115 | 116 | model = MyModel(foo=' foo ', bar='bar', baz='baz') 117 | other_model = MyModel(foo='foo', bar='bar', baz=model) 118 | 119 | other_model.validate() 120 | 121 | assert model.foo == 'foo' 122 | 123 | 124 | def test_model_field_convert_to_type_unset(typeless_model_field): 125 | value = 'valuable' 126 | 127 | assert typeless_model_field.convert_to_type(None, value) is value 128 | 129 | typeless_model_field._type = typing.Any 130 | assert typeless_model_field.convert_to_type(None, value) is value 131 | 132 | assert typeless_model_field.convert_to_type(None, value, field_class=None) is value 133 | 134 | assert typeless_model_field.convert_to_type(None, value, field_class=typing.Any) is value 135 | 136 | 137 | @pytest.mark.parametrize('value', (1, '1')) 138 | def test_model_field_convert_to_type_union(typeless_model_field, value): 139 | assert typeless_model_field.convert_to_type(None, value, field_class=typing.Union[int, str]) is value 140 | 141 | 142 | @pytest.mark.parametrize('value', (1, None)) 143 | def test_model_field_convert_to_type_optional(typeless_model_field, value): 144 | assert typeless_model_field.convert_to_type(None, value, field_class=typing.Optional[int]) is value 145 | 146 | 147 | def test_model_field_convert_to_type_union_invalid(typeless_model_field): 148 | value = {} 149 | with pytest.raises(AssertionError) as exc_info: 150 | typeless_model_field.convert_to_type(None, value, field_class=typing.Union[int, str]) 151 | 152 | assert str(exc_info.value) == "Field of type (, ) received an object of invalid type " 153 | 154 | 155 | def test_model_field_convert_to_type_value_has_correct_type(model_field): 156 | value = 'valuable' 157 | 158 | assert model_field.convert_to_type(None, value) is value 159 | 160 | 161 | def test_model_field_convert_to_type_invalid_model_type(model_field): 162 | model_field._type = Model 163 | value = MyModel() 164 | 165 | with pytest.raises(AssertionError): 166 | model_field.convert_to_type(None, value) 167 | 168 | 169 | def test_model_field_convert_to_type_model_type(model_field): 170 | model_field._type = MyModel 171 | value = {} 172 | 173 | model = model_field.convert_to_type(None, value) 174 | 175 | assert isinstance(model, MyModel) 176 | 177 | 178 | def test_model_field_convert_to_type(model_field): 179 | value = 1 180 | 181 | assert model_field.convert_to_type(None, value) == '1' 182 | 183 | 184 | @pytest.mark.parametrize('iterable_type, iterable_cls', ( 185 | (typing.List, list), 186 | (typing.Tuple, tuple), 187 | )) 188 | def test_model_field_convert_to_type_iterable_without_type(iterable_type, iterable_cls, model_field): 189 | model_field._type = iterable_type 190 | value = iterable_cls([1, 2]) 191 | 192 | assert model_field.convert_to_type(None, value) == value 193 | 194 | 195 | @pytest.mark.parametrize('iterable_type, iterable_cls', ( 196 | (typing.List[str], list), 197 | (typing.Tuple[str], tuple), 198 | )) 199 | def test_model_field_convert_to_type_iterable_typed(iterable_type, iterable_cls, model_field): 200 | model_field._type = iterable_type 201 | value = iterable_cls([1, 2]) 202 | 203 | assert model_field.convert_to_type(None, value) == iterable_cls(['1', '2']) 204 | 205 | 206 | @pytest.mark.parametrize('iterable_type', (list, tuple)) 207 | def test_model_field_convert_to_type_iterable_generic(iterable_type, model_field): 208 | model_field._type = iterable_type 209 | value = iterable_type([1, 2]) 210 | 211 | assert model_field.convert_to_type(None, value) == value 212 | 213 | 214 | @pytest.mark.parametrize('iterable_type, iterable_cls', ( 215 | (typing.List[int], list), 216 | (typing.Tuple[int], tuple), 217 | )) 218 | def test_model_field_convert_to_type_iterable_same_type(iterable_type, iterable_cls, model_field): 219 | model_field._type = iterable_type 220 | value = iterable_cls([1, 2]) 221 | 222 | assert model_field.convert_to_type(None, value) == value 223 | 224 | 225 | def test_model_field_conversion_iterable_with_type_var(model_field): 226 | typevar = typing.TypeVar('typevar', bool, int) 227 | model_field._type = typing.List[typevar] 228 | value = ['foo'] 229 | 230 | assert model_field.convert_to_type(None, value) == value 231 | 232 | 233 | def test_model_field_convert_to_type_do_nothing_if_field_is_already_of_expected_type(model_field): 234 | class MyDateTime(datetime): 235 | pass 236 | 237 | model_field._type = datetime 238 | value = MyDateTime(2016, 6, 6) 239 | 240 | assert model_field.convert_to_type(None, value) is value 241 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import typing 3 | from datetime import datetime 4 | from unittest import mock 5 | 6 | from simple_model import Model, to_dict 7 | from simple_model.exceptions import EmptyField, ValidationError 8 | from simple_model.fields import ModelField 9 | from simple_model.models import LazyModel 10 | 11 | from .conftest import MyModel 12 | 13 | 14 | class FooBarModel(Model): 15 | foo: str 16 | bar: str 17 | 18 | def validate_foo(self, foo): 19 | return foo.strip() 20 | 21 | 22 | class TypedModel(Model): 23 | boolean = True # default 24 | common: typing.Any 25 | empty = None 26 | model: FooBarModel 27 | models: typing.List[FooBarModel] 28 | number: float # type constraint 29 | string: str = 'foobar' # type constraint + default 30 | 31 | 32 | class TypelessModel(Model): 33 | boolean = True 34 | number = 1.0 35 | string = 'foobar' 36 | 37 | 38 | def now(): 39 | return datetime(2019, 6, 9) 40 | 41 | 42 | class FactoryFieldModel(Model): 43 | now: datetime = now 44 | number: float = float 45 | string = 'foobar' 46 | 47 | 48 | @pytest.fixture 49 | def model_clean_validate_foo_data(): 50 | return {'foo': 'foo', 'bar': 'bar'} 51 | 52 | 53 | @pytest.fixture 54 | def model_clean_validate_foo(model_clean_validate_foo_data): 55 | return FooBarModel(**model_clean_validate_foo_data) 56 | 57 | 58 | @pytest.fixture 59 | def nested_model(): 60 | class CleanQuxModel(Model): 61 | foo: str 62 | bar: str 63 | baz: typing.Any = None 64 | qux: str = '' 65 | 66 | def validate_foo(self, value): 67 | if len(value) != 3: 68 | raise ValidationError() 69 | 70 | def validate_qux(self, value): 71 | return value.strip() 72 | 73 | grandma = CleanQuxModel(foo='foo', bar='bar', qux=' qux ') 74 | mother = CleanQuxModel(foo='foo', bar='bar', baz=grandma, qux=' qux ') 75 | child = CleanQuxModel(foo='foo', bar='bar', baz=mother, qux=' qux ') 76 | return child 77 | 78 | 79 | @pytest.fixture 80 | def many_source(): 81 | return ( 82 | {'foo': '1 foo', 'bar': '1 bar', 'qux': '1 qux'}, 83 | {'foo': '2 foo', 'bar': '2 bar', 'qux': '2 qux'}, 84 | {'foo': '3 foo', 'bar': '3 bar', 'qux': '3 qux'}, 85 | ) 86 | 87 | 88 | @pytest.fixture 89 | def typed_model(model_clean_validate_foo): 90 | return TypedModel( 91 | common='common', 92 | number=6.9, 93 | model=model_clean_validate_foo, 94 | models=[model_clean_validate_foo] * 2, 95 | ) 96 | 97 | 98 | def test_base_model(model): 99 | assert model.foo == 'foo' 100 | assert model.bar == 'bar' 101 | 102 | 103 | def test_base_model_repr(model_clean_validate_foo): 104 | assert type(model_clean_validate_foo).__name__ in repr(model_clean_validate_foo) 105 | assert "foo='foo'" in repr(model_clean_validate_foo) 106 | assert "bar='bar'" in repr(model_clean_validate_foo) 107 | 108 | 109 | def test_base_model__get_fields(model_clean_validate_foo): 110 | for name, descriptor in model_clean_validate_foo._get_fields(): 111 | assert name in ('foo', 'bar') 112 | assert getattr(model_clean_validate_foo, name) in ('foo', 'bar') 113 | assert isinstance(descriptor, ModelField) 114 | 115 | 116 | @pytest.mark.parametrize('value', (False, 0, 'foo', 10, {'foo': 'bar'}, [1])) 117 | def test_base_model_is_empty_false(model, value): 118 | assert model.is_empty(value) is False 119 | 120 | 121 | @pytest.mark.parametrize('value', ('', {}, [])) 122 | def test_base_model_is_empty_true(model, value): 123 | assert model.is_empty(value) is True 124 | 125 | 126 | def test_base_model_validate_and_clean(model_clean_validate_foo): 127 | model_clean_validate_foo.foo = ' foo ' 128 | 129 | model_clean_validate_foo.validate() 130 | 131 | assert model_clean_validate_foo.foo == 'foo' 132 | assert model_clean_validate_foo.bar == 'bar' 133 | assert model_clean_validate_foo._is_valid is True 134 | 135 | 136 | def test_base_model_validate_fail(model): 137 | class MyModel(type(model)): 138 | foo: str 139 | bar: str 140 | 141 | def validate_foo(self, value): 142 | if value != 'foo': 143 | raise ValidationError() 144 | 145 | my_model = MyModel(foo='abc', bar='bar') 146 | 147 | assert my_model.validate(raise_exception=False) is False 148 | with pytest.raises(ValidationError): 149 | my_model.validate() 150 | 151 | 152 | def test_base_model___eq___equals(): 153 | model = FooBarModel(foo='foo', bar='bar') 154 | other_model = FooBarModel(foo='foo', bar='bar') 155 | 156 | assert model == model 157 | assert model is model 158 | 159 | assert model == {'foo': 'foo', 'bar': 'bar'} 160 | assert model == (('foo', 'foo'), ('bar', 'bar')) 161 | assert model == [('foo', 'foo'), ('bar', 'bar')] 162 | 163 | assert model is not other_model 164 | assert model == other_model 165 | 166 | 167 | def test_base_model___eq___not_equals(model): 168 | other_model = FooBarModel(foo='bar', bar='foo') 169 | 170 | assert model != other_model 171 | assert model != {} 172 | assert model != () 173 | assert model != [] 174 | assert model != 1 175 | assert model != 'model' 176 | 177 | 178 | def test_base_model___eq___not_equals_same_model(): 179 | model = FooBarModel(foo='bar', bar='foo') 180 | other_model = FooBarModel(foo='fool', bar='bare') 181 | 182 | assert model != other_model 183 | 184 | 185 | def test_base_model___eq___not_equals_different_models_same_field_qty(): 186 | class OtherModel(Model): 187 | baz: str 188 | qux: str 189 | 190 | model = FooBarModel(foo='bar', bar='foo') 191 | other_model = OtherModel(baz='fool', qux='bare') 192 | 193 | assert model != other_model 194 | 195 | 196 | def test_model(model): 197 | assert model.foo == 'foo' 198 | assert model.bar == 'bar' 199 | assert model.baz == '' 200 | assert model.qux == '' 201 | assert 'foo' in repr(model) 202 | 203 | 204 | def test_model__get_fields(model): 205 | for name, descriptor in model._get_fields(): 206 | assert name in model._meta.fields 207 | assert isinstance(descriptor, ModelField) 208 | 209 | 210 | def test_model_fields_without_fields(): 211 | with pytest.raises(AssertionError): 212 | class FieldlessModel(Model): 213 | pass 214 | 215 | 216 | @pytest.mark.parametrize('value', (False, 0)) 217 | def test_model_validate_empty(model, value): 218 | model.bar = value 219 | assert model.validate(raise_exception=False) is True 220 | 221 | 222 | @pytest.mark.parametrize('empty_value', (None, '')) 223 | def test_model_fields_validate_allow_empty_error(empty_value): 224 | with pytest.raises(EmptyField): 225 | MyModel().validate() 226 | 227 | with pytest.raises(EmptyField): 228 | MyModel(foo=empty_value).validate() 229 | 230 | with pytest.raises(EmptyField): 231 | MyModel(bar=empty_value).validate() 232 | 233 | with pytest.raises(EmptyField) as exc: 234 | MyModel(foo=empty_value, bar=empty_value).validate() 235 | 236 | assert 'cannot be empty' in str(exc.value) 237 | 238 | 239 | def test_model_fields_field_validation(model): 240 | assert model.validate() is None 241 | 242 | 243 | def test_model_fields_field_validation_without_raise(model): 244 | model.foo = '' 245 | assert model.validate(raise_exception=False) is False 246 | 247 | 248 | def test_model_fields_field_validation_error(model): 249 | model.foo = 'fo' 250 | with pytest.raises(ValidationError): 251 | model.validate() 252 | 253 | 254 | def test_model_fields_field_validation_error_without_raise(model): 255 | model.foo = 'fo' 256 | assert model.validate(raise_exception=False) is False 257 | 258 | 259 | def test_model_validate_nested(nested_model): 260 | nested_model.baz.foo = '' 261 | assert nested_model.validate(raise_exception=False) is False 262 | 263 | nested_model.baz.foo = 'foo' 264 | nested_model.baz.baz.foo = '' 265 | assert nested_model.validate(raise_exception=False) is False 266 | 267 | 268 | def test_model_validate_and_clean_without_clean_method(model): 269 | for field_name in model._meta.fields: 270 | setattr(model, field_name, field_name) 271 | 272 | model.validate() 273 | 274 | for field_name in model._meta.fields: 275 | assert getattr(model, field_name) == field_name 276 | 277 | 278 | def test_model_validate_and_clean(): 279 | fields = ('foo', 'bar', 'baz') 280 | 281 | class TidyModel(Model): 282 | foo: str 283 | bar: str 284 | baz: str 285 | 286 | def validate_foo(self, foo): 287 | return foo.strip() 288 | 289 | def validate_bar(self, bar): 290 | return bar.strip() 291 | 292 | def validate_baz(self, baz): 293 | return baz.strip() 294 | 295 | model = TidyModel( 296 | foo=' foo ', 297 | bar=' bar ', 298 | baz=' baz ', 299 | ) 300 | model.validate() 301 | 302 | for field_name in fields: 303 | assert getattr(model, field_name) == field_name 304 | 305 | 306 | def test_model_validate_and_clean_nested(nested_model): 307 | nested_model.validate() 308 | 309 | assert nested_model.qux == 'qux' 310 | assert nested_model.baz.qux == 'qux' 311 | assert nested_model.baz.baz.qux == 'qux' 312 | 313 | 314 | def test_model_get_fields_invalid(): 315 | with pytest.raises(AssertionError) as exc: 316 | type('FieldlessModel', (Model,), {}) 317 | 318 | assert 'model must define class attributes' in str(exc.value) 319 | 320 | 321 | def test_model_with_private_attrs(): 322 | class PvtModel(Model): 323 | foo = 'foo' 324 | _cotuba = 'cotuba' 325 | __bar = 'bar' 326 | 327 | def __len__(self): 328 | return 42 329 | 330 | def __private_method(self): 331 | pass 332 | 333 | model = PvtModel() 334 | model.validate() 335 | 336 | assert model.foo == 'foo' 337 | assert model._cotuba == 'cotuba' 338 | assert len(model) == 42 339 | assert hasattr(model, '__bar') is False 340 | assert hasattr(model, '__private_method') is False 341 | 342 | 343 | def test_build_many(many_source): 344 | models = MyModel.build_many(many_source) 345 | 346 | assert len(models) == 3 347 | assert models[0].foo == '1 foo' 348 | assert models[1].bar == '2 bar' 349 | assert models[2].qux == '3 qux' 350 | 351 | 352 | def test_build_many_empty_iterable(): 353 | assert MyModel.build_many([]) == [] 354 | 355 | 356 | def test_build_many_different_items(): 357 | with pytest.raises(ValueError): 358 | MyModel.build_many([{'a': 1}, {'b': 2}]) 359 | 360 | 361 | def test_type_model(typed_model, model_clean_validate_foo): 362 | assert typed_model.number == 6.9 363 | assert typed_model.boolean is True 364 | assert typed_model.string == 'foobar' 365 | assert typed_model.common == 'common' 366 | assert typed_model.model == model_clean_validate_foo 367 | assert typed_model.models == [model_clean_validate_foo] * 2 368 | 369 | 370 | def test_typed_model_type_conversion( 371 | typed_model, model_clean_validate_foo_data, model_clean_validate_foo 372 | ): 373 | typed_model.common = 'wololo' 374 | typed_model.number = '6.9' 375 | typed_model.string = 6.9 376 | typed_model.model = model_clean_validate_foo_data 377 | typed_model.models = [model_clean_validate_foo_data] * 2 378 | 379 | typed_model.convert_fields() 380 | 381 | assert isinstance(typed_model.common, str) 382 | assert isinstance(typed_model.number, float) 383 | assert isinstance(typed_model.string, str) 384 | assert isinstance(typed_model.model, Model) 385 | assert isinstance(typed_model.models, list) 386 | for model in typed_model.models: 387 | assert isinstance(model, Model) 388 | 389 | 390 | def test_model_inheritance_with_meta_fields(model_clean_validate_foo): 391 | class SubTypedModel(TypedModel): 392 | other_string: str 393 | sub: typing.Any = None 394 | 395 | model = SubTypedModel( 396 | common='common', 397 | number=6.9, 398 | model=model_clean_validate_foo, 399 | models=[model_clean_validate_foo] * 2, 400 | other_string='other', 401 | ) 402 | 403 | assert model.boolean 404 | assert model.common 405 | assert model.empty is None 406 | assert model.number 407 | assert model.string 408 | assert model.model 409 | assert model.models 410 | assert model.other_string 411 | assert model.sub is None 412 | 413 | 414 | def test_model_inheritance_without_meta_fields(): 415 | class SuperModel(Model): 416 | foo: str = 'fooz' 417 | bar: str 418 | baz: str = '' 419 | 420 | class SubModel(SuperModel): 421 | foo: str = '' 422 | bar: int 423 | qux: str 424 | 425 | model = SubModel( 426 | bar=10, 427 | qux='qux', 428 | ) 429 | model.validate() 430 | 431 | assert model.foo == '' 432 | assert model.bar 433 | assert model.baz == '' 434 | assert model.qux 435 | 436 | 437 | def test_typeless_model(): 438 | model = TypelessModel() 439 | 440 | assert model.boolean 441 | assert model.number 442 | assert model.string 443 | 444 | 445 | def test_field_factory_model(): 446 | model = FactoryFieldModel(number=6.9) 447 | 448 | assert model.now == now() 449 | assert model.number == 6.9 450 | assert model.string == 'foobar' 451 | 452 | 453 | @pytest.mark.parametrize('default', ('1', 1)) 454 | def test_model_validate_union(default): 455 | class UnionModel(Model): 456 | union: typing.Union[int, str] = default 457 | 458 | union_model = UnionModel() 459 | 460 | assert union_model.validate(raise_exception=False) is True 461 | assert union_model.union == default 462 | assert isinstance(union_model.union, type(default)) 463 | 464 | 465 | @pytest.mark.parametrize('default', (1, None)) 466 | def test_model_validate_optional(default): 467 | class OptionalDefaultModel(Model): 468 | optional: typing.Optional[int] = default 469 | 470 | optional_model = OptionalDefaultModel() 471 | 472 | assert optional_model.validate(raise_exception=False) is True 473 | assert optional_model.optional == default 474 | assert isinstance(optional_model.optional, type(default)) 475 | 476 | 477 | def test_model_validate_optional_without_default(): 478 | class OptionalModel(Model): 479 | optional: typing.Optional[int] 480 | 481 | optional_model = OptionalModel(optional=10) 482 | 483 | assert optional_model.validate(raise_exception=False) is True 484 | assert optional_model.optional == 10 485 | 486 | 487 | def test_model_validate_optional_empty_without_default(): 488 | class OptionalModel(Model): 489 | optional: typing.Optional[int] 490 | 491 | optional_model = OptionalModel() 492 | 493 | assert optional_model.validate(raise_exception=False) is True 494 | assert optional_model.optional is None 495 | 496 | 497 | def test_model_validate_and_clean_invalid_mocked_model(model): 498 | model.validate = mock.Mock(side_effect=ValidationError) 499 | 500 | with pytest.raises(ValidationError): 501 | model.validate() 502 | 503 | 504 | def test_model_validate_and_clean_invalid_model_validate_called_after_clean(model): 505 | class MyModel(Model): 506 | foo: str 507 | bar: str 508 | 509 | def validate_foo(self, foo): 510 | foo = foo.strip() 511 | if len(foo) != 3: 512 | raise ValidationError() 513 | return foo 514 | 515 | model = MyModel(foo='fo ', bar='bar') 516 | with pytest.raises(ValidationError): 517 | model.validate() 518 | 519 | 520 | def test_model_validate_and_clean_model_list(model): 521 | class MyModel(Model): 522 | foo: str 523 | bar: typing.List = [] 524 | 525 | def validate_foo(self, foo): 526 | return foo.strip() 527 | 528 | model = MyModel(foo='foo ') 529 | model2 = MyModel(foo='foo ') 530 | model3 = MyModel( 531 | foo='foo ', 532 | bar=[model, model2], 533 | ) 534 | model3.validate() 535 | 536 | assert model3.foo == 'foo' 537 | assert model2.foo == 'foo' 538 | assert model.foo == 'foo' 539 | 540 | 541 | @pytest.mark.parametrize('empty_value', ('', None)) 542 | def test_wtf_validate(empty_value): 543 | class MyModel(Model): 544 | foo: str = empty_value 545 | 546 | model = MyModel() 547 | assert model.validate() is None # does not raise EmptyField 548 | 549 | 550 | def test_model_validate_and_clean_type_conversion(model): 551 | OtherModel = type(model) 552 | 553 | class TypedModel(Model): 554 | any: typing.Any 555 | iterable: typing.List 556 | model: OtherModel 557 | model_as_dict: OtherModel 558 | models: typing.List[OtherModel] 559 | number: float 560 | numbers: typing.List[float] 561 | string: str 562 | strings: typing.Tuple[str] 563 | string_none: str = None 564 | 565 | class Foo: 566 | def __init__(self, foo): 567 | pass 568 | 569 | model_data = {'foo': 'foo', 'bar': 'bar'} 570 | iterable = ['1', 2, '3'] 571 | model = TypedModel( 572 | any=Foo('toba'), 573 | iterable=list(iterable), 574 | model=OtherModel(**model_data), 575 | model_as_dict=model_data, 576 | models=[model_data], 577 | number='10', 578 | numbers=list(iterable), 579 | string=1, 580 | strings=tuple(iterable), 581 | ) 582 | 583 | model.validate() 584 | assert isinstance(model.any, Foo) 585 | assert isinstance(model.iterable, list) 586 | assert model.iterable == iterable 587 | assert isinstance(model.model, TypedModel._meta.descriptors['model']._type) 588 | assert isinstance(model.models, list) 589 | for elem in model.models: 590 | assert isinstance(elem, TypedModel._meta.descriptors['models']._type.__args__[0]) 591 | assert isinstance(model.number, TypedModel._meta.descriptors['number']._type) 592 | assert isinstance(model.numbers, list) 593 | for elem in model.numbers: 594 | assert isinstance(elem, TypedModel._meta.descriptors['numbers']._type.__args__[0]) 595 | assert isinstance(model.string, TypedModel._meta.descriptors['string']._type) 596 | assert isinstance(model.strings, tuple) 597 | for elem in model.strings: 598 | assert isinstance(elem, TypedModel._meta.descriptors['strings']._type.__args__[0]) 599 | assert model.string_none is None 600 | 601 | 602 | def test_field_conversion_model_type_conflict(model): 603 | OtherModel = type(model) 604 | 605 | class MyModel(Model): 606 | field: OtherModel 607 | 608 | my_model = MyModel(field=model) 609 | invalid_model = MyModel(field=my_model) 610 | with pytest.raises(AssertionError): 611 | invalid_model.validate() 612 | 613 | 614 | def test_field_conversion_list(model): 615 | class ListModel(Model): 616 | elements: list 617 | 618 | iterable = (1, 2, 3) 619 | model = ListModel(elements=iterable) 620 | model.convert_fields() 621 | 622 | assert model.elements == list(iterable) 623 | 624 | 625 | def test_model_property_getter(): 626 | class Foo(Model): 627 | a: float 628 | b: float 629 | c: float 630 | 631 | @property 632 | def c(self): 633 | return self.a + self.b 634 | 635 | @property 636 | def d(self): 637 | return 0 638 | 639 | def validate_c(self, c): 640 | if c < 0: 641 | raise ValidationError() 642 | return c 643 | 644 | a, b = 1.5, 2.7 645 | 646 | foo = Foo(a=a, b=b) 647 | foo.validate() 648 | 649 | assert foo.c == a + b 650 | assert 'c' in foo._meta.fields 651 | assert foo.d == 0 652 | assert 'd' not in foo._meta.fields 653 | 654 | foo = Foo(a=-1, b=-1) 655 | with pytest.raises(ValidationError): 656 | foo.validate() 657 | 658 | 659 | def test_model_property_setter(): 660 | class Foo(Model): 661 | d: str 662 | 663 | @property 664 | def d(self): 665 | return str(self._d) 666 | 667 | @d.setter 668 | def d(self, d): 669 | value = d 670 | if isinstance(d, dict): 671 | value = ', '.join(d.keys()) 672 | self._d = value 673 | 674 | def validate_d(self, d): 675 | if len(d) < 2: 676 | raise ValidationError() 677 | 678 | d += '.' 679 | return d 680 | 681 | foo = Foo(d={'a': 1, 'b': 2}) 682 | assert foo._d == foo.d == 'a, b' 683 | 684 | foo.validate() 685 | assert foo._d == foo.d == 'a, b.' 686 | 687 | foo.d = {'c': 3, 'd': 4} 688 | assert foo._d == foo.d == 'c, d' 689 | 690 | foo.validate() 691 | assert foo._d == foo.d == 'c, d.' 692 | 693 | foo = Foo(d={}) 694 | with pytest.raises(ValidationError): 695 | foo.validate() 696 | 697 | 698 | def test_model_property_is_not_called_on_validation_unless_necessary(): 699 | class Foo(Model): 700 | a: str 701 | b: dict = dict 702 | 703 | @property 704 | def a(self): 705 | return self.b.c 706 | 707 | foo = Foo() 708 | 709 | assert foo.validate() is None 710 | 711 | 712 | def test_model_property_validation(): 713 | class Foo(Model): 714 | a: int 715 | b: dict 716 | 717 | @property 718 | def a(self): 719 | return self.b['c'] 720 | 721 | def validate_a(self, value): 722 | if value <= 0: 723 | raise ValidationError() 724 | return value 725 | 726 | foo = Foo(b={'c': 0}) 727 | 728 | with pytest.raises(ValidationError): 729 | foo.validate() 730 | 731 | 732 | def test_model_ignores_private_attrs(): 733 | class PrivateModel(Model): 734 | pub: int 735 | _protected: str 736 | __private = None 737 | __another_private: str 738 | 739 | model = PrivateModel(pub=1, _protected='bar') 740 | 741 | assert '_PrivateModel__private' not in model._meta.fields 742 | assert '_PrivateModel__another_private' not in model._meta.fields 743 | assert model.validate(raise_exception=False) is True 744 | assert model.pub 745 | assert model._protected 746 | assert model._PrivateModel__private is None 747 | with pytest.raises(AttributeError): 748 | model._PrivateModel__another_private 749 | 750 | 751 | def test_model_as_dict_validated(model): 752 | model.validate() 753 | 754 | d = model.as_dict() 755 | 756 | assert d 757 | assert isinstance(d, dict) 758 | assert d == to_dict(model) 759 | 760 | 761 | def test_model_validate_unexpected_exception(model): 762 | descriptor_mock = mock.Mock() 763 | descriptor_mock.validate.side_effect = Exception 764 | model._get_fields = mock.Mock(return_value=[('foo', descriptor_mock)]) 765 | 766 | with pytest.raises(Exception): 767 | model.validate() 768 | 769 | assert model._is_valid is False 770 | 771 | 772 | def test_lazy_model_auto_validation_on_getattr(): 773 | class FooModel(LazyModel): 774 | foo: str 775 | 776 | lazy_model = FooModel() 777 | 778 | with pytest.raises(EmptyField): 779 | lazy_model.foo 780 | 781 | 782 | def test_lazy_model_auto_invalidation_on_setattr(): 783 | class FooModel(LazyModel): 784 | foo: str 785 | 786 | model = FooModel(foo='bar') 787 | model._is_valid = True 788 | 789 | model.foo = None 790 | assert model._is_valid is False 791 | 792 | 793 | def test_lazy_model_as_dict(): 794 | class FooModel(LazyModel): 795 | foo: str 796 | 797 | model = FooModel(foo='foo') 798 | 799 | assert model.as_dict() 800 | 801 | 802 | def test_model_subclass_inheritance_wont_consider_is_valid_as_field(): 803 | class SubModel(MyModel): 804 | pass 805 | 806 | model = SubModel() 807 | 808 | assert model._is_valid is False 809 | assert '_is_valid' not in model._meta.fields 810 | 811 | 812 | def test_model_super_validate_is_called(): 813 | class SubModel(MyModel): 814 | pass 815 | 816 | model = SubModel(foo='f', bar='b') 817 | 818 | with pytest.raises(ValidationError): 819 | model.validate() 820 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from simple_model.utils import ( 4 | camel_case, 5 | capitalize_first, 6 | coerce_to_alpha, 7 | getkey, 8 | is_private_attribute, 9 | snake_case, 10 | ) 11 | 12 | 13 | def test_utils_capitalize_first(): 14 | assert capitalize_first('') == '' 15 | assert capitalize_first('foobar') == 'Foobar' 16 | assert capitalize_first('foobar_*/') == 'Foobar_*/' 17 | 18 | 19 | def test_utils_camel_case(): 20 | assert camel_case('foo') == 'Foo' 21 | assert camel_case('foo_bar_baz') == 'FooBarBaz' 22 | assert camel_case('foo-bar_baz') == 'FooBarBaz' 23 | assert camel_case('foobarbaz') == 'Foobarbaz' 24 | assert camel_case('fooBarBaz') == 'FooBarBaz' 25 | assert camel_case('foo bar baz') == 'FooBarBaz' 26 | assert camel_case('foo Bar-baz') == 'FooBarBaz' 27 | assert camel_case('') == '' 28 | 29 | 30 | def test_coerce_to_alpha(): 31 | assert coerce_to_alpha('') == '' 32 | assert coerce_to_alpha('foo-bar.baz') == 'foo_bar_baz' 33 | 34 | 35 | def test_snake_case(): 36 | assert snake_case('') == '' 37 | assert snake_case('foo') == 'foo' 38 | assert snake_case('FooBarBazQux') == 'foo_bar_baz_qux' 39 | assert snake_case('FooBarBaz___') == 'foo_bar_baz___' 40 | 41 | 42 | def test_getkey(): 43 | d = {'foo': 'bar'} 44 | assert getkey(d, 'foo') == d['foo'] 45 | 46 | with pytest.raises(KeyError): 47 | getkey(d, 'toba') 48 | 49 | 50 | @pytest.mark.parametrize('attr_name, result', ( 51 | ('_Foo__bar', True), 52 | ('_MyModel__attr', True), 53 | ('bar', False), 54 | ('__bar', False), 55 | )) 56 | def test_is_private_attribute(attr_name, result): 57 | assert is_private_attribute(attr_name) is result 58 | -------------------------------------------------------------------------------- /tests/test_version.py: -------------------------------------------------------------------------------- 1 | from simple_model.__version__ import __author__, __title__, __version__ 2 | 3 | 4 | def test_version(): 5 | assert __author__ 6 | assert __title__ 7 | assert __version__ 8 | --------------------------------------------------------------------------------