├── .gitignore ├── .travis.yml ├── CHANGES.rst ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── examples ├── quickstart.py └── simple.py ├── preparer ├── __init__.py └── preparers.py ├── pytest.ini ├── requirements-dev.txt ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── conftest.py └── test_preparers.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | sudo: yes 3 | language: python 4 | python: 5 | - "3.5" 6 | - "3.6" 7 | - "3.7" 8 | install: 9 | - pip install -r requirements-dev.txt 10 | script: 11 | pytest 12 | after_success: 13 | codecov 14 | -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | --------- 3 | 4 | 0.1.0 5 | ~~~~~ 6 | 7 | * Initial release. 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Allisson Azevedo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.rst 3 | include CHANGES.rst 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc 2 | 3 | default: test 4 | 5 | clean-pyc: 6 | @find . -iname '*.py[co]' -delete 7 | @find . -iname '__pycache__' -delete 8 | @find . -iname '.coverage' -delete 9 | @rm -rf htmlcov/ 10 | 11 | clean-dist: 12 | @rm -rf dist/ 13 | @rm -rf build/ 14 | @rm -rf *.egg-info 15 | 16 | clean: clean-pyc clean-dist 17 | 18 | test: 19 | py.test 20 | 21 | dist: clean 22 | python setup.py sdist 23 | python setup.py bdist_wheel 24 | 25 | release: dist 26 | git tag `python setup.py -q version` 27 | git push origin `python setup.py -q version` 28 | twine upload dist/* 29 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Python Preparer 2 | =============== 3 | 4 | |TravisCI Build Status| |Coverage Status| |Requirements Status| |Version| 5 | 6 | ---- 7 | 8 | Simple way to build a new dict based on fields declaration. 9 | 10 | 11 | How to install 12 | -------------- 13 | 14 | .. code:: shell 15 | 16 | pip install preparer 17 | 18 | 19 | How to use 20 | ---------- 21 | 22 | .. code:: python 23 | 24 | from preparer import FieldsPreparer, SubPreparer, CollectionSubPreparer 25 | 26 | xfiles_game = { 27 | 'description': 'As an extension of one of the most long-running...', 28 | 'game_id': 1, 29 | 'genres': [ 30 | { 31 | 'genre_category': 'Basic Genres', 32 | 'genre_category_id': 1, 33 | 'genre_id': 2, 34 | 'genre_name': 'Adventure' 35 | }, 36 | { 37 | 'genre_category': 'Perspective', 38 | 'genre_category_id': 2, 39 | 'genre_id': 7, 40 | 'genre_name': '1st-person' 41 | }, 42 | { 43 | 'genre_category': 'Narrative Theme/Topic', 44 | 'genre_category_id': 8, 45 | 'genre_id': 55, 46 | 'genre_name': 'Detective / Mystery' 47 | }, 48 | { 49 | 'genre_category': 'Setting', 50 | 'genre_category_id': 10, 51 | 'genre_id': 8, 52 | 'genre_name': 'Sci-Fi / Futuristic' 53 | }, 54 | { 55 | 'genre_category': 'Other Attributes', 56 | 'genre_category_id': 6, 57 | 'genre_id': 82, 58 | 'genre_name': 'Licensed Title' 59 | } 60 | ], 61 | 'moby_score': 3.8, 62 | 'moby_url': 'http://www.mobygames.com/game/x-files-game', 63 | 'num_votes': 53, 64 | 'official_url': None, 65 | 'platforms': [ 66 | { 67 | 'first_release_date': '1998', 68 | 'platform_id': 3, 69 | 'platform_name': 'Windows' 70 | }, 71 | { 72 | 'first_release_date': '1998-06', 73 | 'platform_id': 74, 74 | 'platform_name': 'Macintosh' 75 | }, 76 | { 77 | 'first_release_date': '1999', 78 | 'platform_id': 6, 79 | 'platform_name': 'PlayStation' 80 | } 81 | ], 82 | 'sample_cover': { 83 | 'height': 927, 84 | 'image': 'http://www.mobygames.com/images/covers/l/3-the-x-files-game...', 85 | 'platforms': [ 86 | 'Windows' 87 | ], 88 | 'thumbnail_image': 'http://www.mobygames.com/images/covers/s/3-the-x-files...', 89 | 'width': 800 90 | }, 91 | 'sample_screenshots': [ 92 | { 93 | 'caption': 'Mulder and Special Agent Willmore', 94 | 'height': 480, 95 | 'image': 'http://www.mobygames.com/images/shots/l/86087-the-x-files...', 96 | 'thumbnail_image': 'http://www.mobygames.com/images/shots/s/86087-the...', 97 | 'width': 640 98 | }, 99 | { 100 | 'caption': 'Title screen (from intro)', 101 | 'height': 480, 102 | 'image': 'http://www.mobygames.com/images/shots/l/313897-the-x-files-game...', 103 | 'thumbnail_image': 'http://www.mobygames.com/images/shots/s/313897-the-x...', 104 | 'width': 640 105 | }, 106 | { 107 | 'caption': 'Gillian Anderson (from intro)', 108 | 'height': 480, 109 | 'image': 'http://www.mobygames.com/images/shots/l/313919-the-x-files-game...', 110 | 'thumbnail_image': 'http://www.mobygames.com/images/shots/s/313919-the-x...', 111 | 'width': 640 112 | }, 113 | { 114 | 'caption': 'David Duchovny (from intro)', 115 | 'height': 480, 116 | 'image': 'http://www.mobygames.com/images/shots/l/313908-the-x-files-game-windows...', 117 | 'thumbnail_image': 'http://www.mobygames.com/images/shots/s/313908-the-x-files...', 118 | 'width': 640 119 | } 120 | ], 121 | 'title': 'The X-Files Game' 122 | } 123 | 124 | preparer = FieldsPreparer(fields={ 125 | 'id': 'game_id', 126 | 'title': 'title', 127 | 'description': 'description' 128 | }) 129 | 130 | cover_preparer = FieldsPreparer(fields={ 131 | 'image': 'image', 132 | 'thumbnail': 'thumbnail_image' 133 | }) 134 | preparer_with_cover = FieldsPreparer(fields={ 135 | 'id': 'game_id', 136 | 'title': 'title', 137 | 'description': 'description', 138 | 'cover': SubPreparer('sample_cover', cover_preparer) 139 | }) 140 | 141 | screenshot_preparer = FieldsPreparer(fields={ 142 | 'caption': 'caption', 143 | 'image': 'image', 144 | 'thumbnail': 'thumbnail_image' 145 | }) 146 | preparer_with_cover_and_screenshots = FieldsPreparer(fields={ 147 | 'id': 'game_id', 148 | 'title': 'title', 149 | 'description': 'description', 150 | 'cover': SubPreparer('sample_cover', cover_preparer), 151 | 'screenshots': CollectionSubPreparer('sample_screenshots', screenshot_preparer) 152 | }) 153 | 154 | 155 | .. code:: python 156 | 157 | >>> import pprint 158 | >>> pp = pprint.PrettyPrinter(indent=4) 159 | >>> pp.pprint(preparer.prepare(xfiles_game)) 160 | { 'description': 'As an extension of one of the most long-running...', 161 | 'id': 1, 162 | 'title': 'The X-Files Game'} 163 | >>> pp.pprint(preparer_with_cover.prepare(xfiles_game)) 164 | { 'cover': { 'image': 'http://www.mobygames.com/images/covers/l/3-the-x-files-game...', 165 | 'thumbnail': 'http://www.mobygames.com/images/covers/s/3-the-x-files...'}, 166 | 'description': 'As an extension of one of the most long-running...', 167 | 'id': 1, 168 | 'title': 'The X-Files Game'} 169 | >>> pp.pprint(preparer_with_cover_and_screenshots.prepare(xfiles_game)) 170 | { 'cover': { 'image': 'http://www.mobygames.com/images/covers/l/3-the-x-files-game...', 171 | 'thumbnail': 'http://www.mobygames.com/images/covers/s/3-the-x-files...'}, 172 | 'description': 'As an extension of one of the most long-running...', 173 | 'id': 1, 174 | 'screenshots': [ { 'caption': 'Mulder and Special Agent Willmore', 175 | 'image': 'http://www.mobygames.com/images/shots/l/86087-the-x-files...', 176 | 'thumbnail': 'http://www.mobygames.com/images/shots/s/86087-the...'}, 177 | { 'caption': 'Title screen (from intro)', 178 | 'image': 'http://www.mobygames.com/images/shots/l/313897-the-x-files-game...', 179 | 'thumbnail': 'http://www.mobygames.com/images/shots/s/313897-the-x...'}, 180 | { 'caption': 'Gillian Anderson (from intro)', 181 | 'image': 'http://www.mobygames.com/images/shots/l/313919-the-x-files-game...', 182 | 'thumbnail': 'http://www.mobygames.com/images/shots/s/313919-the-x...'}, 183 | { 'caption': 'David Duchovny (from intro)', 184 | 'image': 'http://www.mobygames.com/images/shots/l/313908-the-x-files-game-windows...', 185 | 'thumbnail': 'http://www.mobygames.com/images/shots/s/313908-the-x-files...'}], 186 | 'title': 'The X-Files Game'} 187 | 188 | Check `https://github.com/allisson/python-preparer/tree/master/examples `_ for more code examples. 189 | 190 | 191 | Credits 192 | ------- 193 | 194 | This is a fork of excellent https://github.com/toastdriven/restless/blob/master/restless/preparers.py 195 | 196 | 197 | .. |TravisCI Build Status| image:: https://travis-ci.org/allisson/python-preparer.svg?branch=master 198 | :target: https://travis-ci.org/allisson/python-preparer 199 | .. |Coverage Status| image:: https://codecov.io/gh/allisson/python-preparer/branch/master/graph/badge.svg 200 | :target: https://codecov.io/gh/allisson/python-preparer 201 | .. |Requirements Status| image:: https://requires.io/github/allisson/python-preparer/requirements.svg?branch=master 202 | :target: https://requires.io/github/allisson/python-preparer/requirements/?branch=master 203 | .. |Version| image:: https://badge.fury.io/py/preparer.svg 204 | :target: https://badge.fury.io/py/preparer 205 | -------------------------------------------------------------------------------- /examples/quickstart.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | 3 | from preparer import FieldsPreparer, SubPreparer, CollectionSubPreparer 4 | 5 | 6 | pp = pprint.PrettyPrinter(indent=4) 7 | xfiles_game = { 8 | 'description': 'As an extension of one of the most long-running...', 9 | 'game_id': 1, 10 | 'genres': [ 11 | { 12 | 'genre_category': 'Basic Genres', 13 | 'genre_category_id': 1, 14 | 'genre_id': 2, 15 | 'genre_name': 'Adventure' 16 | }, 17 | { 18 | 'genre_category': 'Perspective', 19 | 'genre_category_id': 2, 20 | 'genre_id': 7, 21 | 'genre_name': '1st-person' 22 | }, 23 | { 24 | 'genre_category': 'Narrative Theme/Topic', 25 | 'genre_category_id': 8, 26 | 'genre_id': 55, 27 | 'genre_name': 'Detective / Mystery' 28 | }, 29 | { 30 | 'genre_category': 'Setting', 31 | 'genre_category_id': 10, 32 | 'genre_id': 8, 33 | 'genre_name': 'Sci-Fi / Futuristic' 34 | }, 35 | { 36 | 'genre_category': 'Other Attributes', 37 | 'genre_category_id': 6, 38 | 'genre_id': 82, 39 | 'genre_name': 'Licensed Title' 40 | } 41 | ], 42 | 'moby_score': 3.8, 43 | 'moby_url': 'http://www.mobygames.com/game/x-files-game', 44 | 'num_votes': 53, 45 | 'official_url': None, 46 | 'platforms': [ 47 | { 48 | 'first_release_date': '1998', 49 | 'platform_id': 3, 50 | 'platform_name': 'Windows' 51 | }, 52 | { 53 | 'first_release_date': '1998-06', 54 | 'platform_id': 74, 55 | 'platform_name': 'Macintosh' 56 | }, 57 | { 58 | 'first_release_date': '1999', 59 | 'platform_id': 6, 60 | 'platform_name': 'PlayStation' 61 | } 62 | ], 63 | 'sample_cover': { 64 | 'height': 927, 65 | 'image': 'http://www.mobygames.com/images/covers/l/3-the-x-files-game...', 66 | 'platforms': [ 67 | 'Windows' 68 | ], 69 | 'thumbnail_image': 'http://www.mobygames.com/images/covers/s/3-the-x-files...', 70 | 'width': 800 71 | }, 72 | 'sample_screenshots': [ 73 | { 74 | 'caption': 'Mulder and Special Agent Willmore', 75 | 'height': 480, 76 | 'image': 'http://www.mobygames.com/images/shots/l/86087-the-x-files...', 77 | 'thumbnail_image': 'http://www.mobygames.com/images/shots/s/86087-the...', 78 | 'width': 640 79 | }, 80 | { 81 | 'caption': 'Title screen (from intro)', 82 | 'height': 480, 83 | 'image': 'http://www.mobygames.com/images/shots/l/313897-the-x-files-game...', 84 | 'thumbnail_image': 'http://www.mobygames.com/images/shots/s/313897-the-x...', 85 | 'width': 640 86 | }, 87 | { 88 | 'caption': 'Gillian Anderson (from intro)', 89 | 'height': 480, 90 | 'image': 'http://www.mobygames.com/images/shots/l/313919-the-x-files-game...', 91 | 'thumbnail_image': 'http://www.mobygames.com/images/shots/s/313919-the-x...', 92 | 'width': 640 93 | }, 94 | { 95 | 'caption': 'David Duchovny (from intro)', 96 | 'height': 480, 97 | 'image': 'http://www.mobygames.com/images/shots/l/313908-the-x-files-game-windows...', 98 | 'thumbnail_image': 'http://www.mobygames.com/images/shots/s/313908-the-x-files...', 99 | 'width': 640 100 | } 101 | ], 102 | 'title': 'The X-Files Game' 103 | } 104 | 105 | preparer = FieldsPreparer(fields={ 106 | 'id': 'game_id', 107 | 'title': 'title', 108 | 'description': 'description' 109 | }) 110 | 111 | cover_preparer = FieldsPreparer(fields={ 112 | 'image': 'image', 113 | 'thumbnail': 'thumbnail_image' 114 | }) 115 | preparer_with_cover = FieldsPreparer(fields={ 116 | 'id': 'game_id', 117 | 'title': 'title', 118 | 'description': 'description', 119 | 'cover': SubPreparer('sample_cover', cover_preparer) 120 | }) 121 | 122 | screenshot_preparer = FieldsPreparer(fields={ 123 | 'caption': 'caption', 124 | 'image': 'image', 125 | 'thumbnail': 'thumbnail_image' 126 | }) 127 | preparer_with_cover_and_screenshot = FieldsPreparer(fields={ 128 | 'id': 'game_id', 129 | 'title': 'title', 130 | 'description': 'description', 131 | 'cover': SubPreparer('sample_cover', cover_preparer), 132 | 'screenshots': CollectionSubPreparer('sample_screenshots', screenshot_preparer) 133 | }) 134 | 135 | pp.pprint(preparer.prepare(xfiles_game)) 136 | pp.pprint(preparer_with_cover.prepare(xfiles_game)) 137 | pp.pprint(preparer_with_cover_and_screenshot.prepare(xfiles_game)) 138 | -------------------------------------------------------------------------------- /examples/simple.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | 3 | from preparer import FieldsPreparer 4 | 5 | pp = pprint.PrettyPrinter(indent=4) 6 | payload = { 7 | 'type': 'articles', 8 | 'id': '1', 9 | 'attributes': { 10 | 'title': 'JSON API paints my bikeshed!', 11 | 'body': 'The shortest article. Ever.', 12 | 'created': '2015-05-22T14:56:29.000Z', 13 | 'updated': '2015-05-22T14:56:28.000Z' 14 | }, 15 | 'relationships': { 16 | 'author': { 17 | 'data': {'id': '42', 'type': 'people'} 18 | } 19 | }, 20 | 'tags': [ 21 | {'name': 'tag-1', 'count': 1}, 22 | {'name': 'tag-2', 'count': 2}, 23 | {'name': 'tag-3', 'count': 3}, 24 | ], 25 | } 26 | 27 | preparer = FieldsPreparer(fields={ 28 | 'id': 'id', 29 | 'type': 'type', 30 | 'title': 'attributes.title', 31 | 'body': 'attributes.body' 32 | }) 33 | 34 | pp.pprint(preparer.prepare(payload)) 35 | -------------------------------------------------------------------------------- /preparer/__init__.py: -------------------------------------------------------------------------------- 1 | from .preparers import FieldsPreparer, SubPreparer, CollectionSubPreparer # noqa 2 | -------------------------------------------------------------------------------- /preparer/preparers.py: -------------------------------------------------------------------------------- 1 | import abc 2 | 3 | 4 | class Preparer: 5 | """ 6 | A plain preparation object which just passes through data. 7 | It also is relevant as the protocol subclasses should implement to work with 8 | Restless. 9 | """ 10 | 11 | @abc.abstractmethod 12 | def prepare(self, data): 13 | """ 14 | Handles actually transforming the data. 15 | """ 16 | 17 | 18 | class FieldsPreparer(Preparer): 19 | """ 20 | A more complex preparation object, this will return a given set of fields. 21 | This takes a ``fields`` parameter, which should be a dictionary of 22 | keys (fieldnames to expose to the user) & values (a dotted lookup path to 23 | the desired attribute/key on the object). 24 | Example:: 25 | preparer = FieldsPreparer(fields={ 26 | # ``user`` is the key the client will see. 27 | # ``author.pk`` is the dotted path lookup ``FieldsPreparer`` 28 | # will traverse on the data to return a value. 29 | 'user': 'author.pk', 30 | }) 31 | """ 32 | def __init__(self, fields=None): 33 | self.fields = fields or {} 34 | 35 | def prepare(self, data): 36 | """ 37 | Handles transforming the provided data into the fielded data that should 38 | be exposed to the end user. 39 | Uses the ``lookup_data`` method to traverse dotted paths. 40 | Returns a dictionary of data as the response. 41 | """ 42 | result = {} 43 | 44 | if not self.fields: 45 | # No fields specified. Serialize everything. 46 | return data 47 | 48 | for fieldname, lookup in self.fields.items(): 49 | if isinstance(lookup, SubPreparer): 50 | result[fieldname] = lookup.prepare(data) 51 | else: 52 | result[fieldname] = self.lookup_data(lookup, data) 53 | 54 | return result 55 | 56 | def lookup_data(self, lookup, data): 57 | """ 58 | Given a lookup string, attempts to descend through nested data looking for 59 | the value. 60 | Can work with either dictionary-alikes or objects (or any combination of 61 | those). 62 | Lookups should be a string. If it is a dotted path, it will be split on 63 | ``.`` & it will traverse through to find the final value. If not, it will 64 | simply attempt to find either a key or attribute of that name & return it. 65 | Example:: 66 | >>> data = { 67 | ... 'type': 'message', 68 | ... 'greeting': { 69 | ... 'en': 'hello', 70 | ... 'fr': 'bonjour', 71 | ... 'es': 'hola', 72 | ... }, 73 | ... 'person': Person( 74 | ... name='daniel' 75 | ... ) 76 | ... } 77 | >>> lookup_data('type', data) 78 | 'message' 79 | >>> lookup_data('greeting.en', data) 80 | 'hello' 81 | >>> lookup_data('person.name', data) 82 | 'daniel' 83 | """ 84 | value = data 85 | parts = lookup.split('.') 86 | 87 | if not parts or not parts[0]: 88 | return value 89 | 90 | part = parts[0] 91 | remaining_lookup = '.'.join(parts[1:]) 92 | 93 | if callable(getattr(data, 'keys', None)) and hasattr(data, '__getitem__'): 94 | # Dictionary enough for us. 95 | value = data[part] 96 | elif data is not None: 97 | # Assume it's an object. 98 | value = getattr(data, part) 99 | 100 | # Call if it's callable except if it's a Django DB manager instance 101 | # We check if is a manager by checking the db_manager (duck typing) 102 | if callable(value) and not hasattr(value, 'db_manager'): 103 | value = value() 104 | 105 | if not remaining_lookup: 106 | return value 107 | 108 | # There's more to lookup, so dive in recursively. 109 | return self.lookup_data(remaining_lookup, value) 110 | 111 | 112 | class SubPreparer(FieldsPreparer): 113 | """ 114 | A preparation class designed to be used within other preparers. 115 | This is primary to enable deeply-nested structures, allowing you 116 | to compose/share definitions as well. Typical usage consists of creating 117 | a configured instance of a FieldsPreparer, then use a `SubPreparer` to 118 | pull it in. 119 | Example:: 120 | # First, define the nested fields you'd like to expose. 121 | author_preparer = FieldsPreparer(fields={ 122 | 'id': 'pk', 123 | 'username': 'username', 124 | 'name': 'get_full_name', 125 | }) 126 | # Then, in the main preparer, pull them in using `SubPreparer`. 127 | preparer = FieldsPreparer(fields={ 128 | 'author': SubPreparer('user', author_preparer), 129 | # Other fields can come before/follow as normal. 130 | 'content': 'post', 131 | 'created': 'created_at', 132 | }) 133 | """ 134 | def __init__(self, lookup, preparer): 135 | self.lookup = lookup 136 | self.preparer = preparer 137 | 138 | def get_inner_data(self, data): 139 | """ 140 | Used internally so that the correct data is extracted out of the 141 | broader dataset, allowing the preparer being called to deal with just 142 | the expected subset. 143 | """ 144 | return self.lookup_data(self.lookup, data) 145 | 146 | def prepare(self, data): 147 | """ 148 | Handles passing the data to the configured preparer. 149 | Uses the ``get_inner_data`` method to provide the correct subset of 150 | the data. 151 | Returns a dictionary of data as the response. 152 | """ 153 | return self.preparer.prepare(self.get_inner_data(data)) 154 | 155 | 156 | class CollectionSubPreparer(SubPreparer): 157 | """ 158 | A preparation class designed to handle collections of data. 159 | This is useful in the case where you have a 1-to-many or many-to-many 160 | relationship of data to expose as part of the parent data. 161 | Example:: 162 | # First, set up a preparer that handles the data for each thing in 163 | # the broader collection. 164 | comment_preparer = FieldsPreparer(fields={ 165 | 'comment': 'comment_text', 166 | 'created': 'created', 167 | }) 168 | # Then use it with the ``CollectionSubPreparer`` to create a list 169 | # of prepared sub items. 170 | preparer = FieldsPreparer(fields={ 171 | # A normal blog post field. 172 | 'post': 'post_text', 173 | # All the comments on the post. 174 | 'comments': CollectionSubPreparer('comments.all', comment_preparer), 175 | }) 176 | """ 177 | def prepare(self, data): 178 | """ 179 | Handles passing each item in the collection data to the configured 180 | subpreparer. 181 | Uses a loop and the ``get_inner_data`` method to provide the correct 182 | item of the data. 183 | Returns a list of data as the response. 184 | """ 185 | result = [] 186 | 187 | for item in self.get_inner_data(data): 188 | result.append(self.preparer.prepare(item)) 189 | 190 | return result 191 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = -vvv --cov=preparer --cov-report=term-missing -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | Sphinx 3 | codecov 4 | flake8 5 | pysimplemodel==0.15.0 6 | pytest 7 | pytest-cov 8 | twine 9 | wheel 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/allisson/python-preparer/fa72a583203881554ba4265042d3e001fd8ce970/requirements.txt -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | import re 4 | 5 | from setuptools import setup, find_packages, Command 6 | 7 | here = os.path.abspath(os.path.dirname(__file__)) 8 | 9 | version = '0.0.0' 10 | changes = os.path.join(here, 'CHANGES.rst') 11 | match = r'^#*\s*(?P[0-9]+\.[0-9]+(\.[0-9]+)?)$' 12 | with codecs.open(changes, encoding='utf-8') as changes: 13 | for line in changes: 14 | res = re.match(match, line) 15 | if res: 16 | version = res.group('version') 17 | break 18 | 19 | # Get the long description 20 | with codecs.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f: 21 | long_description = f.read() 22 | 23 | # Get version 24 | with codecs.open(os.path.join(here, 'CHANGES.rst'), encoding='utf-8') as f: 25 | changelog = f.read() 26 | 27 | 28 | install_requirements = [] 29 | tests_requirements = [ 30 | 'pysimplemodel==0.15.0', 31 | 'pytest', 32 | 'pytest-cov', 33 | ] 34 | 35 | 36 | class VersionCommand(Command): 37 | description = 'print library version' 38 | user_options = [] 39 | 40 | def initialize_options(self): 41 | pass 42 | 43 | def finalize_options(self): 44 | pass 45 | 46 | def run(self): 47 | print(version) 48 | 49 | 50 | setup( 51 | name='preparer', 52 | version=version, 53 | description='Simple way to build a new dict based on fields declaration', 54 | long_description=long_description, 55 | url='https://github.com/allisson/python-preparer', 56 | author='Allisson Azevedo', 57 | author_email='allisson@gmail.com', 58 | classifiers=[ 59 | 'Development Status :: 3 - Alpha', 60 | 'Intended Audience :: Developers', 61 | 'Programming Language :: Python :: 3.5', 62 | 'Programming Language :: Python :: 3.6', 63 | 'Topic :: Software Development :: Libraries', 64 | ], 65 | packages=find_packages(exclude=['docs', 'tests*']), 66 | setup_requires=['pytest-runner'], 67 | install_requires=install_requirements, 68 | tests_require=tests_requirements, 69 | cmdclass={ 70 | 'version': VersionCommand, 71 | }, 72 | ) 73 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/allisson/python-preparer/fa72a583203881554ba4265042d3e001fd8ce970/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from preparer import CollectionSubPreparer, FieldsPreparer, SubPreparer 4 | 5 | 6 | @pytest.fixture 7 | def payload(): 8 | return { 9 | 'type': 'articles', 10 | 'id': '1', 11 | 'attributes': { 12 | 'title': 'JSON API paints my bikeshed!', 13 | 'body': 'The shortest article. Ever.', 14 | 'created': '2015-05-22T14:56:29.000Z', 15 | 'updated': '2015-05-22T14:56:28.000Z' 16 | }, 17 | 'relationships': { 18 | 'author': { 19 | 'data': {'id': '42', 'type': 'people'} 20 | } 21 | }, 22 | 'tags': [ 23 | {'name': 'tag-1', 'count': 1}, 24 | {'name': 'tag-2', 'count': 2}, 25 | {'name': 'tag-3', 'count': 3}, 26 | ], 27 | } 28 | 29 | 30 | @pytest.fixture 31 | def simple_preparer_without_fields(): 32 | return FieldsPreparer() 33 | 34 | 35 | @pytest.fixture 36 | def simple_preparer(): 37 | fields = { 38 | 'type': 'type', 39 | 'id': 'id', 40 | 'title': 'attributes.title', 41 | 'body': 'attributes.body', 42 | 'relationship_id': 'relationships.author.data.id' 43 | } 44 | return FieldsPreparer(fields=fields) 45 | 46 | 47 | @pytest.fixture 48 | def preparer_with_subpreparer(): 49 | attributes_preparer = FieldsPreparer( 50 | fields={'title': 'title', 'body': 'body'} 51 | ) 52 | fields = { 53 | 'type': 'type', 54 | 'id': 'id', 55 | 'attrs': SubPreparer('attributes', attributes_preparer) 56 | } 57 | return FieldsPreparer(fields=fields) 58 | 59 | 60 | @pytest.fixture 61 | def preparer_with_collectionsubpreparer(): 62 | tags_preparer = FieldsPreparer(fields={'tag_name': 'name'}) 63 | fields = { 64 | 'type': 'type', 65 | 'id': 'id', 66 | 'tag_list': CollectionSubPreparer('tags', tags_preparer) 67 | } 68 | return FieldsPreparer(fields=fields) 69 | -------------------------------------------------------------------------------- /tests/test_preparers.py: -------------------------------------------------------------------------------- 1 | from simple_model.builder import model_builder 2 | 3 | 4 | def test_simple_preparer_lookup_data_with_dict(simple_preparer, payload): 5 | assert simple_preparer.lookup_data('', payload) == payload 6 | assert simple_preparer.lookup_data('type', payload) == payload['type'] 7 | assert simple_preparer.lookup_data('attributes.title', payload) == payload['attributes']['title'] 8 | assert simple_preparer.lookup_data('relationships.author.data.id', payload) == payload['relationships']['author']['data']['id'] 9 | 10 | 11 | def test_simple_preparer_lookup_data_with_object(simple_preparer, payload): 12 | obj = model_builder(payload) 13 | assert simple_preparer.lookup_data('', obj) == obj 14 | assert simple_preparer.lookup_data('type', obj) == obj.type 15 | assert simple_preparer.lookup_data('attributes.title', obj) == obj.attributes.title 16 | assert simple_preparer.lookup_data('relationships.author.data.id', obj) == obj.relationships.author.data.id 17 | 18 | 19 | def test_simple_preparer_prepare(simple_preparer, payload): 20 | expected = { 21 | 'type': 'articles', 22 | 'id': '1', 23 | 'title': 'JSON API paints my bikeshed!', 24 | 'body': 'The shortest article. Ever.', 25 | 'relationship_id': '42' 26 | } 27 | assert simple_preparer.prepare(payload) == expected 28 | assert simple_preparer.prepare(model_builder(payload)) == expected 29 | 30 | 31 | def test_simple_preparer_without_fields_prepare(simple_preparer_without_fields, payload): 32 | assert simple_preparer_without_fields.prepare(payload) == payload 33 | assert simple_preparer_without_fields.prepare(model_builder(payload)) == payload 34 | 35 | 36 | def test_preparer_with_subpreparer_prepare(preparer_with_subpreparer, payload): 37 | expected = { 38 | 'type': 'articles', 39 | 'id': '1', 40 | 'attrs': { 41 | 'title': 'JSON API paints my bikeshed!', 42 | 'body': 'The shortest article. Ever.' 43 | } 44 | } 45 | assert preparer_with_subpreparer.prepare(payload) == expected 46 | assert preparer_with_subpreparer.prepare(model_builder(payload)) == expected 47 | 48 | 49 | def test_preparer_with_collectionsubpreparer_prepare(preparer_with_collectionsubpreparer, payload): 50 | expected = { 51 | 'type': 'articles', 52 | 'id': '1', 53 | 'tag_list': [ 54 | {'tag_name': 'tag-1'}, 55 | {'tag_name': 'tag-2'}, 56 | {'tag_name': 'tag-3'}, 57 | ] 58 | } 59 | assert preparer_with_collectionsubpreparer.prepare(payload) == expected 60 | assert preparer_with_collectionsubpreparer.prepare(model_builder(payload)) == expected 61 | --------------------------------------------------------------------------------