From 1245dea9da9081c90f8c04430470075da5248ba8 Mon Sep 17 00:00:00 2001 From: Dan Date: Wed, 29 May 2019 02:42:02 -0400 Subject: [PATCH] Adding documentation (#2) * Set up CI with Azure Pipelines * add documentation * update documentation and introduce basic tests --- .gitignore | 3 +- README.md | 78 ++++++++++++++++++++++++- azure-pipelines.yml | 22 +++++++ jstruct/__init__.py | 34 +++++++++-- jstruct/types.py | 135 +++++++++++++++++++++++++++++++++++++------ requirements.txt | 2 + scripts.sh | 18 ++++++ setup.py | 13 ++++- test/__init__.py | 0 test/test_types.py | 138 ++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 411 insertions(+), 32 deletions(-) create mode 100644 azure-pipelines.yml create mode 100644 requirements.txt create mode 100755 scripts.sh create mode 100644 test/__init__.py create mode 100644 test/test_types.py diff --git a/.gitignore b/.gitignore index 1761287..b54f941 100644 --- a/.gitignore +++ b/.gitignore @@ -102,4 +102,5 @@ venv.bak/ # mypy .mypy_cache/ -.vscode/ \ No newline at end of file +.vscode/ +.idea/ \ No newline at end of file diff --git a/README.md b/README.md index 86780df..f284225 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,81 @@ # jstruct -JSON to struct to JSON +An eloquent and opinionated python library for nested object models definition offering simple serialization and deserialization into python dictionaries. + +[![Build Status](https://dev.azure.com/danielkobina0854/danielkobina/_apis/build/status/DanH91.jstruct?branchName=master)](https://dev.azure.com/danielkobina0854/danielkobina/_build/latest?definitionId=1&branchName=master) +[![Codacy Badge](https://api.codacy.com/project/badge/Grade/cbe02771e00e42cd882ab48543782b40)](https://www.codacy.com/app/DanH91/jstruct?utm_source=github.com&utm_medium=referral&utm_content=DanH91/jstruct&utm_campaign=Badge_Grade) +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/python/black) +[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) + +## Why + +The deserialization of JSON or yaml into python data types is a common practice useful in many ways. + - Configuration file reading and writing + - REST API message response generation and request processing + - Object-Document Mapping for a document store + - Data import parsing or export generation + +## How + +`JStruct` leverage [attrs](https://www.attrs.org/en/stable/) the great `Classes without boilerplate` library to define structs without boilerplate. + +## What + +The result is a simple and intuitive syntax familiar to a pythonista that brings `Validation`, `Deserialization` and `Serialization`. + +## Requirements + + - Python 3.6 and + ## Installation -```bash -pip install -f https://git.io/purplship jstruct +Install using pip + +```shell +pip install jstruct ``` + +## Usage + +```python +import attr +from typing import List +from jstruct import struct, JList + +@struct +class Person: + first_name: str + last_name: str + +@struct +class RoleModels: + scientists: List[Person] = JList[Person] + + +payload = { + "scientists": [{"first_name": "John", "last_name": "Doe"}] +} + +role_models = RoleModels(**payload) + +print(role_models) + +# RoleModels(scientists=[Person(first_name='John', last_name='Doe')]) + +print(attr.asdict(role_models)) + +# {'scientists': [{'first_name': 'John', 'last_name': 'Doe'}]} + +``` + +## Authors + +- **Daniel K.** - [@DanHK91](https://twitter.com/DanHK91) | [https://danielk.xyz](https://danielk.xyz/) + +## Contribute + +Contributors are welcomed. + +## License + +This project is licensed under the MIT License - see the [LICENSE.md](https://github.com/DanH91/jstruct/blob/document-jstruct/LICENSE) file for details diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 0000000..90e0d7d --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,22 @@ +# Starter pipeline +# Start with a minimal pipeline that you can customize to build and deploy your code. +# Add steps that build, run tests, deploy, and more: +# https://aka.ms/yaml + +trigger: +- master + +pool: + vmImage: 'ubuntu-latest' + +steps: +- task: UsePythonVersion@0 + inputs: + versionSpec: '3.6' + architecture: 'x64' + +- script: | + source ./scripts.sh + init + test + displayName: 'Run tests' diff --git a/jstruct/__init__.py b/jstruct/__init__.py index 7681455..0839438 100644 --- a/jstruct/__init__.py +++ b/jstruct/__init__.py @@ -1,6 +1,28 @@ -from jstruct.types import ( - JStruct, - JList, - JDict, - REQUIRED -) +# MIT License +# +# Copyright (c) 2019 Dan +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""Import core names of jstruct. + +from jstruct import struct, JStruct, JList, JDict +""" + +from jstruct.types import struct, JStruct, JList, JDict, REQUIRED diff --git a/jstruct/types.py b/jstruct/types.py index 49d03fc..db3446c 100644 --- a/jstruct/types.py +++ b/jstruct/types.py @@ -1,62 +1,159 @@ import attr from functools import reduce -from typing import List, Dict +from typing import List, Dict, Union, Tuple, Optional REQUIRED = True -@attr.s(auto_attribs=True) +def struct(*args, **kwargs): + """a struct definition decorator for Python 3 datatypes like syntax. + + :return the attrs.s funtion types + """ + return attr.s(auto_attribs=True, *args, **kwargs) + + class _JStruct: - def __getitem__(self, arguments): - class_, required_, *kwargs = arguments if isinstance(arguments, tuple) else (arguments, False) + """A typing definition wrapper used to defined nested struct. + + @struct + class Child: + child_prop1: int + + @struct + class Parent: + parent_prop: str + child: Child = JStruct[Child] + """ + + def __getitem__( + self, arguments: Union[type, Tuple[type, Optional[bool], Optional[dict]]] + ): + """Override the `[]` operator to offer a typing wrapper syntactic sugar. + + :arguments is either a `type` or a `tuple` + - type: the nested struct type (or class) + - tuple: ( type, REQUIRED, {dictionary of extra attr.ib arguments} ) + + :return a property initializer from attrs (attr.ib) + """ + + class_, required_, *kwargs = ( + arguments if isinstance(arguments, tuple) else (arguments, False) + ) - def build(args) -> class_: + def converter(args) -> class_: return class_(**args) if isinstance(args, dict) else args + default_ = dict(default=attr.NOTHING if required_ else None) return attr.ib( **default_, - converter=build, + converter=converter, **dict(reduce(lambda r, d: r + list(d.items()), kwargs, [])) ) -@attr.s(auto_attribs=True) class _JList: - def __getitem__(self, arguments): - class_, required_, *kwargs = arguments if isinstance(arguments, tuple) else (arguments, False) + """A typing definition wrapper used to defined nested collection (list) of struct. + + @struct + class Child: + child_prop1: int + + @struct + class Parent: + parent_prop: str + children: List[Child] = JList[Child] + """ + + def __getitem__( + self, arguments: Union[type, Tuple[type, Optional[bool], Optional[dict]]] + ): + """Override the `[]` operator to offer a typing wrapper syntactic sugar. + + :arguments is either a `type` or a `tuple` + - type: the nested struct type (or class) + - tuple: ( type, REQUIRED, {dictionary of extra attr.ib arguments} ) - def build(args) -> List[class_]: + :return a property initializer from attrs (attr.ib) + """ + + class_, required_, *kwargs = ( + arguments if isinstance(arguments, tuple) else (arguments, False) + ) + + def converter(args) -> List[class_]: if isinstance(args, list): items = args else: items = [args] + return [ - (class_(**item) if isinstance(item, dict) else item) - for item in items + (class_(**item) if isinstance(item, dict) else item) for item in items ] + default_ = dict(default=attr.NOTHING if required_ else []) + return attr.ib( **default_, - converter=build, + converter=converter, **dict(reduce(lambda r, d: r + list(d.items()), kwargs, [])) ) -@attr.s(auto_attribs=True) class _JDict: - def __getitem__(self, arguments): - key_type, value_type, required_, *kwargs = arguments if isinstance(arguments, tuple) else (arguments, False) + """A typing definition wrapper used to defined nested dictionary struct typing. + + from jstruct import struct + from jstruct.type import _JDict + + JDict = _JDict() + + @struct + class Child: + child_prop1: int + + @struct + class Parent: + parent_prop: str + children: Dict[str, Child] = JDict[str, Child] + """ + + def __getitem__(self, arguments: Tuple[type, type, Optional[bool], Optional[dict]]): + """Override the `[]` operator to offer a typing wrapper syntactic sugar. + + :arguments is a `tuple` + ( key_type, value_type, REQUIRED, {dictionary of extra attr.ib arguments} ) + + :return a property initializer from attrs (attr.ib) + """ - def build(args) -> Dict[key_type, value_type]: + key_type, value_type, required_, *kwargs = ( + arguments + (False,) if len(arguments) > 3 else arguments + ) + + def converter(args) -> Dict[key_type, value_type]: return { - key_type(key): (value_type(**value) if isinstance(value, dict) else value) + key_type(key): ( + value_type(**value) if isinstance(value, dict) else value + ) for (key, value) in args.items() } + default_ = dict(default=attr.NOTHING if required_ else {}) - return attr.ib(**default_, converter=build, **kwargs) + return attr.ib( + **default_, + converter=converter, + **dict(reduce(lambda r, d: r + list(d.items()), kwargs, [])) + ) +# Instance of _JStruct JStruct = _JStruct() + +# Instance of _JList JList = _JList() + +# Instance of _JDict JDict = _JDict() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c5d0706 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +pytest==4.5.0 +black==19.3b0 \ No newline at end of file diff --git a/scripts.sh b/scripts.sh new file mode 100755 index 0000000..fdc1e36 --- /dev/null +++ b/scripts.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +test() { + pytest test/ +} + +build() { + python setup.py sdist bdist_wheel +} + +init() { + deactivate || true + rm -r ./venv || true + python3 -m venv ./venv && + . ./venv/bin/activate && + pip install -r requirements.txt && + pip install -e . +} diff --git a/setup.py b/setup.py index bca7c77..c2057aa 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,9 @@ from setuptools import setup -setup(name='jstruct', +setup( + name='jstruct', version='1.0.0', - description='Elegant JSON to python Data class', + description='Readable serializable and deserializable Python nested models', url='https://github.com/DanH91/jstruct', author='DanH91', author_email='danielk.developer@gmail.com', @@ -11,4 +12,10 @@ install_requires=[ 'attrs==18.2.0' ], - zip_safe=False) \ No newline at end of file + zip_safe=False, + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ] +) \ No newline at end of file diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/test_types.py b/test/test_types.py new file mode 100644 index 0000000..57a1fa6 --- /dev/null +++ b/test/test_types.py @@ -0,0 +1,138 @@ +import pytest +import attr +from typing import List, Dict +from jstruct import struct, JList, JDict, JStruct, REQUIRED + + +class TestClass(object): + def test_nested_models_serialization(self): + payload = { + "scientists": [ + { + "first_name": "John", + "last_name": "Doe", + "profession": { + "title": "Bio Engineer", + "roles": { + "researcher": { + "description": "applies engineering principles of design and analysis to biological systems and biomedical technologies." + } + }, + }, + } + ] + } + + role_models = RoleModels( + scientists=[ + Person( + first_name="John", + last_name="Doe", + profession=Profession( + title="Bio Engineer", + roles={ + "researcher": Role( + description="applies engineering principles of design and analysis to biological systems and biomedical technologies." + ) + }, + ), + ) + ] + ) + + assert role_models == RoleModels(**payload) + + def test_nested_models_deserialization(self): + role_models = RoleModels( + scientists=[ + Person( + first_name="Jane", + last_name="Doe", + profession=Profession( + title="Astronaut", + roles={ + "researcher": Role( + description="An astronaut or cosmonaut is a person trained by a human spaceflight program to command, pilot, or serve as a crew member of a spacecraft." + ) + }, + ), + ) + ] + ) + + data = { + "scientists": [ + { + "first_name": "Jane", + "last_name": "Doe", + "profession": { + "title": "Astronaut", + "roles": { + "researcher": { + "description": "An astronaut or cosmonaut is a person trained by a human spaceflight program to command, pilot, or serve as a crew member of a spacecraft." + } + }, + }, + } + ] + } + + assert data == attr.asdict(role_models) + + def test_nested_models_required_validation(self): + payload = { + "scientists": [ + { + "first_name": "Jane", + "last_name": "Doe", + "profession": { + "title": "Astronaut" + }, + } + ] + } + + with pytest.raises(TypeError): + RoleModels(**payload) + + def test_nested_models_argument_validation(self): + payload = { + "scientists": [ + { + "first_name": "Jane", + "last_name": "Doe", + "fake_attribute": "This is a FAKE attribute" + } + ] + } + + with pytest.raises(TypeError): + RoleModels(**payload) + + +""" + Test Models +""" + + +@struct +class Role: + description: str + + +@struct +class Profession: + title: str + roles: Dict[str, Role] = JDict[str, Role, REQUIRED] + + +@struct +class Person: + first_name: str + last_name: str + profession: Profession = JStruct[Profession] + + +@struct +class RoleModels: + scientists: List[Person] = JList[Person]