├── .devcontainer ├── Dockerfile ├── devcontainer.json └── docker-compose.yml ├── .editorconfig ├── .github └── workflows │ ├── build.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── MANIFEST ├── Makefile ├── README.md ├── docs ├── Makefile ├── source │ ├── about.rst │ ├── adapter_class.rst │ ├── buildingawrapper.rst │ ├── changelog.rst │ ├── conf.py │ ├── contributors.rst │ ├── exceptions.rst │ ├── features.rst │ ├── flavours.rst │ ├── index.rst │ ├── quickstart.rst │ └── serializers.rst └── static │ ├── logo.png │ └── tapioca.jpg ├── mkdocs.yml ├── setup.py ├── tapioca ├── __init__.py ├── adapters.py ├── exceptions.py ├── serializers.py └── tapioca.py ├── tests ├── __init__.py ├── client.py ├── test_exceptions.py ├── test_serializers.py └── test_tapioca.py └── tox.ini /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/python:0-3.8 2 | 3 | RUN apt-get -qq update && apt-get install -qq -y python3-sphinx 4 | 5 | RUN pip install --upgrade pip 6 | RUN pip install "tox<4" tox-pyenv build twine 7 | 8 | RUN git clone https://github.com/pyenv/pyenv.git /.pyenvsrc 9 | ENV PYENV_ROOT="/.pyenv" 10 | ENV PATH="${PYENV_ROOT}/shims:${PYENV_ROOT}/bin:${PATH}" 11 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/python 3 | { 4 | "name": "Tapioca Env", 5 | "dockerComposeFile": "docker-compose.yml", 6 | "service": "devcontainer", 7 | "workspaceFolder": "/tapioca-wrapper", 8 | // Features to add to the dev container. More info: https://containers.dev/features. 9 | "features": { 10 | "ghcr.io/jungaretti/features/make:1": {} 11 | }, 12 | // Configure tool-specific properties. 13 | "customizations": { 14 | "vscode": { 15 | "extensions": [ 16 | "ms-python.python", 17 | "ms-python.flake8", 18 | "the-compiler.python-tox", 19 | "ms-azuretools.vscode-docker", 20 | "editorconfig.editorconfig", 21 | "redhat.vscode-yaml", 22 | "github.vscode-github-actions" 23 | ], 24 | // if it grows create a .vscode/settings.json 25 | "settings": { 26 | "python.linting.flake8Enabled": true 27 | } 28 | } 29 | } 30 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 31 | // "remoteUser": "root" 32 | } 33 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | devcontainer: 4 | build: 5 | context: . 6 | dockerfile: Dockerfile 7 | volumes: 8 | - ../.:/tapioca-wrapper:cached 9 | - ../.pyenv-cache:/.pyenv:cached 10 | command: sleep infinity 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | insert_final_newline = false 15 | 16 | [*.rst] 17 | indent_style = tab 18 | indent_size = 4 19 | trim_trailing_whitespace = false 20 | insert_final_newline = false 21 | 22 | [*.rst] 23 | indent_style = tab 24 | trim_trailing_whitespace = false 25 | insert_final_newline = false 26 | 27 | [Makefile] 28 | indent_style = tab 29 | insert_final_newline = false 30 | 31 | [*.yaml] 32 | indent_size = 2 33 | 34 | [*.yml] 35 | indent_size = 2 36 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up Python 3.8 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: 3.8 18 | - name: Install dependencies 19 | run: | 20 | sudo apt-get -qq update && sudo apt-get -qq install python3-sphinx -y 21 | pip install --upgrade pip && pip install build twine 22 | - name: Build docs 23 | run: make docs 24 | - name: Build 25 | run: make dist 26 | 27 | tests: 28 | runs-on: ubuntu-22.04 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | python-version: ["3.8", "3.9", "3.10", "3.11"] 33 | 34 | steps: 35 | - uses: actions/checkout@v3 36 | - name: Set up Python ${{ matrix.python-version }} 37 | uses: actions/setup-python@v4 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | - name: Install dependencies 41 | run: | 42 | pip install --upgrade pip 43 | pip install "tox<4" tox-gh-actions 44 | - name: Test with tox 45 | env: 46 | COVERALLS_REPO_TOKEN: ${{ secrets.COVERALLS_REPO_TOKEN }} 47 | run: tox 48 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | release: 7 | runs-on: ubuntu-22.04 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Set up Python 3.8 11 | uses: actions/setup-python@v4 12 | with: 13 | python-version: "3.8" 14 | - name: Install dependencies 15 | run: pip install --upgrade pip && pip install build twine 16 | - name: set up env 17 | run: echo "TAG=$(eval 'python setup.py version')" >> $GITHUB_ENV 18 | - name: Build 19 | run: make dist 20 | - name: Release 21 | env: 22 | TWINE_USERNAME: __token__ 23 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 24 | run: twine upload dist/* 25 | - uses: rickstaa/action-create-tag@v1 26 | id: "tag_create" 27 | with: 28 | tag: ${{ env.TAG }} 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | /.eggs 21 | 22 | # Installer logs 23 | pip-log.txt 24 | 25 | # Unit test / coverage reports 26 | .coverage 27 | .tox 28 | nosetests.xml 29 | htmlcov 30 | 31 | # mypy 32 | .mypy_cache 33 | 34 | # Translations 35 | *.mo 36 | 37 | # Mr Developer 38 | .mr.developer.cfg 39 | .project 40 | .pydevproject 41 | 42 | # Complexity 43 | output/*.html 44 | output/*/index.html 45 | 46 | # Sphinx 47 | docs/_build 48 | 49 | # Virtual Env 50 | /venv/ 51 | 52 | # pyenv 53 | .python-version 54 | .pyenv-cache 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2015 Vinta Software 4 | 5 | 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: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | 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. 10 | -------------------------------------------------------------------------------- /MANIFEST: -------------------------------------------------------------------------------- 1 | # file GENERATED by distutils, do NOT edit 2 | setup.py 3 | tapioca/__init__.py 4 | tapioca/adapters.py 5 | tapioca/exceptions.py 6 | tapioca/serializers.py 7 | tapioca/tapioca.py 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean-pyc clean-build docs clean 2 | 3 | help: 4 | @echo "setup-devcontainer - Make devcontainer ready for development" 5 | @echo "clean - remove all build, test, coverage and Python artifacts" 6 | @echo "clean-build - remove build artifacts" 7 | @echo "clean-pyc - remove Python file artifacts" 8 | @echo "clean-test - remove test and coverage artifacts" 9 | @echo "lint - check style with flake8" 10 | @echo "test - run tests on every Python version with tox" 11 | @echo "coverage - check code coverage quickly with the default Python" 12 | @echo "docs - generate Sphinx HTML documentation, including API docs" 13 | @echo "dist - package" 14 | 15 | setup-devcontainer: 16 | sudo mv -n /.pyenvsrc/** /.pyenv 17 | sudo chmod 777 /.pyenv 18 | 19 | pyenv install 3.9 20 | pyenv install 3.10 21 | pyenv install 3.11 22 | 23 | pyenv local 3.9 3.10 3.11 24 | 25 | clean: clean-build clean-pyc clean-test 26 | 27 | clean-build: 28 | rm -fr build/ 29 | rm -fr dist/ 30 | rm -fr *.egg-info 31 | 32 | clean-pyc: 33 | find . -name '*.pyc' -exec rm -f {} + 34 | find . -name '*.pyo' -exec rm -f {} + 35 | find . -name '*~' -exec rm -f {} + 36 | find . -name '__pycache__' -exec rm -fr {} + 37 | 38 | clean-test: 39 | rm -fr .tox/ 40 | rm -f .coverage 41 | rm -fr htmlcov/ 42 | 43 | lint: 44 | flake8 45 | 46 | test: 47 | tox -e py38,py39,py310,py311 48 | 49 | coverage: 50 | coverage run --source tapioca setup.py test 51 | coverage report -m 52 | coverage html 53 | open htmlcov/index.html 54 | 55 | docs: 56 | rm -f docs/tapioca.rst 57 | rm -f docs/modules.rst 58 | sphinx-apidoc -o docs/ tapioca 59 | $(MAKE) -C docs clean 60 | $(MAKE) -C docs html 61 | 62 | dist: clean 63 | python -m build 64 | twine check dist/* 65 | ls -l dist 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tapioca-Wrapper 2 | 3 | [![Join the chat at https://gitter.im/vintasoftware/tapioca-wrapper](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/vintasoftware/tapioca-wrapper?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) 4 | [![Build Status](https://github.com/vintasoftware/tapioca-wrapper/actions/workflows/build.yml/badge.svg)](https://github.com/vintasoftware/tapioca-wrapper/actions/workflows/build.yml) 5 | [![Coverage Status](https://coveralls.io/repos/vintasoftware/tapioca-wrapper/badge.svg?branch=master&service=github)](https://coveralls.io/github/vintasoftware/tapioca-wrapper?branch=master) 6 | [![Current version at PyPI](https://img.shields.io/pypi/v/tapioca-wrapper.svg)](https://pypi.python.org/pypi/tapioca-wrapper) 7 | ![Supported Python Versions](https://img.shields.io/pypi/pyversions/tapioca-wrapper.svg) 8 | [![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/vintasoftware/tapioca-wrapper/master/LICENSE) 9 | 10 | ![](docs/static/logo.png) 11 | 12 | Tapioca helps you generating Python clients for APIs. 13 | APIs wrapped by Tapioca are explorable and follow a simple interaction pattern that works uniformly so developers don't need to learn how to use a new coding interface/style for each service API. 14 | 15 | 16 | ## Documentation 17 | 18 | Full documentation hosted by [readthedocs](http://tapioca-wrapper.readthedocs.org/). 19 | 20 | ## Flavours 21 | 22 | You can find the full list of available tapioca clients [here](http://tapioca-wrapper.readthedocs.org/en/stable/flavours.html). 23 | 24 | To create new flavours, refer to [Building a wrapper](http://tapioca-wrapper.readthedocs.org/en/stable/buildingawrapper.html) in the documentation. There is also a [cookiecutter template](https://github.com/vintasoftware/cookiecutter-tapioca) to help bootstraping new API clients. 25 | 26 | ## Contributing 27 | 28 | ### Setup dev environment 29 | 30 | Tapioca-Wrapper is ready for development with [Dev Container](https://code.visualstudio.com/docs/devcontainers/tutorial). After downloading the code you just need to use "Reopen in Container option and after building run `make setup-devcontainer` just once. 31 | 32 | ### How to Release: 33 | 34 | #### Pre release: 35 | - Include the changes in `docs/source/changelog.rst` 36 | - Update the version in `tapioca/__init__.py` 37 | 38 | #### Release: 39 | - Run the github action [release](https://github.com/vintasoftware/tapioca-wrapper/actions/workflows/release.yml) 40 | 41 | #### Post release: 42 | - Check if docs were updated at [readthedocs](http://tapioca-wrapper.readthedocs.org/). 43 | 44 | ## Other resources 45 | 46 | - [Contributors](https://github.com/vintasoftware/tapioca-wrapper/graphs/contributors) 47 | - [Changelog](http://tapioca-wrapper.readthedocs.org/en/stable/changelog.html) 48 | - [Blog post explaining the basics about Tapioca](http://www.vinta.com.br/blog/2016/python-api-clients-with-tapioca/) 49 | 50 | ## Help 51 | 52 | If you have any questions or need help, please send an email to: contact@vinta.com.br 53 | 54 | ## Commercial Support 55 | 56 | This project, as other Vinta open-source projects, is used in products of Vinta clients. We are always looking for exciting work, so if you need any commercial support, feel free to get in touch: contact@vinta.com.br 57 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext 23 | 24 | help: 25 | @echo "Please use \'make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " livehtml to make rebuild on save" 28 | @echo " dirhtml to make HTML files named index.html in directories" 29 | @echo " singlehtml to make a single large HTML file" 30 | @echo " pickle to make pickle files" 31 | @echo " json to make JSON files" 32 | @echo " htmlhelp to make HTML files and a HTML help project" 33 | @echo " qthelp to make HTML files and a qthelp project" 34 | @echo " applehelp to make an Apple Help Book" 35 | @echo " devhelp to make HTML files and a Devhelp project" 36 | @echo " epub to make an epub" 37 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 38 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 39 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 40 | @echo " text to make text files" 41 | @echo " man to make manual pages" 42 | @echo " texinfo to make Texinfo files" 43 | @echo " info to make Texinfo files and run them through makeinfo" 44 | @echo " gettext to make PO message catalogs" 45 | @echo " changes to make an overview of all changed/added/deprecated items" 46 | @echo " xml to make Docutils-native XML files" 47 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 48 | @echo " linkcheck to check all external links for integrity" 49 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 50 | @echo " coverage to run coverage check of the documentation (if enabled)" 51 | 52 | clean: 53 | rm -rf $(BUILDDIR)/* 54 | 55 | html: 56 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 57 | @echo 58 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 59 | 60 | livehtml: 61 | sphinx-autobuild -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 62 | 63 | dirhtml: 64 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 65 | @echo 66 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 67 | 68 | singlehtml: 69 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 70 | @echo 71 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 72 | 73 | pickle: 74 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 75 | @echo 76 | @echo "Build finished; now you can process the pickle files." 77 | 78 | json: 79 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 80 | @echo 81 | @echo "Build finished; now you can process the JSON files." 82 | 83 | htmlhelp: 84 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 85 | @echo 86 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 87 | ".hhp project file in $(BUILDDIR)/htmlhelp." 88 | 89 | qthelp: 90 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 91 | @echo 92 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 93 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 94 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/tapioca-wrapper.qhcp" 95 | @echo "To view the help file:" 96 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/tapioca-wrapper.qhc" 97 | 98 | applehelp: 99 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 100 | @echo 101 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 102 | @echo "N.B. You won't be able to view it unless you put it in" \ 103 | "~/Library/Documentation/Help or install it in your application" \ 104 | "bundle." 105 | 106 | devhelp: 107 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 108 | @echo 109 | @echo "Build finished." 110 | @echo "To view the help file:" 111 | @echo "# mkdir -p $$HOME/.local/share/devhelp/tapioca-wrapper" 112 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/tapioca-wrapper" 113 | @echo "# devhelp" 114 | 115 | epub: 116 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 117 | @echo 118 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 119 | 120 | latex: 121 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 122 | @echo 123 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 124 | @echo "Run \'make' in that directory to run these through (pdf)latex" \ 125 | "(use \'make latexpdf' here to do that automatically)." 126 | 127 | latexpdf: 128 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 129 | @echo "Running LaTeX files through pdflatex..." 130 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 131 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 132 | 133 | latexpdfja: 134 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 135 | @echo "Running LaTeX files through platex and dvipdfmx..." 136 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 137 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 138 | 139 | text: 140 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 141 | @echo 142 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 143 | 144 | man: 145 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 146 | @echo 147 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 148 | 149 | texinfo: 150 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 151 | @echo 152 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 153 | @echo "Run \'make' in that directory to run these through makeinfo" \ 154 | "(use \'make info' here to do that automatically)." 155 | 156 | info: 157 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 158 | @echo "Running Texinfo files through makeinfo..." 159 | make -C $(BUILDDIR)/texinfo info 160 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 161 | 162 | gettext: 163 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 164 | @echo 165 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 166 | 167 | changes: 168 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 169 | @echo 170 | @echo "The overview file is in $(BUILDDIR)/changes." 171 | 172 | linkcheck: 173 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 174 | @echo 175 | @echo "Link check complete; look for any errors in the above output " \ 176 | "or in $(BUILDDIR)/linkcheck/output.txt." 177 | 178 | doctest: 179 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 180 | @echo "Testing of doctests in the sources finished, look at the " \ 181 | "results in $(BUILDDIR)/doctest/output.txt." 182 | 183 | coverage: 184 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 185 | @echo "Testing of coverage in the sources finished, look at the " \ 186 | "results in $(BUILDDIR)/coverage/python.txt." 187 | 188 | xml: 189 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 190 | @echo 191 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 192 | 193 | pseudoxml: 194 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 195 | @echo 196 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 197 | -------------------------------------------------------------------------------- /docs/source/about.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | About 3 | ===== 4 | 5 | 6 | **tapioca-wrapper** provides an easy way to make explorable python API wrappers. 7 | APIs wrapped by Tapioca follow a simple interaction pattern that works uniformly so developers don't need to learn how to use a new coding interface/style for each service API. 8 | 9 | .. image:: ../static/tapioca.jpg 10 | :alt: Tapioca -------------------------------------------------------------------------------- /docs/source/adapter_class.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | TapiocaAdapter class 3 | ==================== 4 | 5 | .. class:: TapiocaAdapter 6 | 7 | Attributes 8 | ---------- 9 | 10 | .. attribute:: api_root 11 | 12 | This should contain the base URL that will be concatenated with the resource mapping itens and generate the final request URL. You can either set this attribute or use the ``get_api_root`` method. 13 | 14 | .. attribute:: serializer_class 15 | 16 | For more information about the ``serializer_class`` attribute, read the :doc:`serializers documentation `. 17 | 18 | 19 | Methods 20 | ------- 21 | 22 | .. method:: get_api_root(self, api_params, \*\*kwargs) 23 | 24 | This method can be used instead of the ``api_root`` attribute. You might also use it to decide which base URL to use according to a user input. 25 | 26 | .. code-block:: python 27 | 28 | def get_api_root(self, api_params, **kwargs): 29 | if api_params.get('development'): 30 | return 'http://api.the-dev-url.com/' 31 | return 'http://api.the-production-url.com/' 32 | 33 | You may also need to set different api_root to a specific resource. To do that you can access the ``resource_name`` inside ``kwargs``. 34 | 35 | .. code-block:: python 36 | 37 | def get_api_root(self, api_params, **kwargs): 38 | if kwargs.get('resource_name') == 'some_resource_name': 39 | return 'http://api.another.com/' 40 | else: 41 | return self.api_root 42 | 43 | .. method:: get_resource_mapping(self, api_params) 44 | 45 | You can use it to customize the resource map dynamically. 46 | 47 | .. code-block:: python 48 | 49 | def get_resource_mapping(self, api_params): 50 | if api_params.get('version') == 'v2': 51 | return RESOURCE_MAPPING_V2 52 | 53 | return self.resource_mapping 54 | 55 | .. method:: get_request_kwargs(self, api_params, \*args, \*\*kwargs) 56 | 57 | This method is called just before any request is made. You should use it to set whatever credentials the request might need. The **api_params** argument is a dictionary and has the parameters passed during the initialization of the tapioca client: 58 | 59 | .. code-block:: python 60 | 61 | cli = Facebook(access_token='blablabla', client_id='thisistheis') 62 | 63 | For this example, api_params will be a dictionary with the keys ``access_token`` and ``client_id``. 64 | 65 | Here is an example of how to implement Basic Auth: 66 | 67 | .. code-block:: python 68 | 69 | from requests.auth import HTTPBasicAuth 70 | 71 | class MyServiceClientAdapter(TapiocaAdapter): 72 | ... 73 | def get_request_kwargs(self, api_params, *args, **kwargs): 74 | params = super(MyServiceClientAdapter, self).get_request_kwargs( 75 | api_params, *args, **kwargs) 76 | 77 | params['auth'] = HTTPBasicAuth( 78 | api_params.get('user'), api_params.get('password')) 79 | 80 | return params 81 | 82 | .. method:: process_response(self, response) 83 | 84 | This method is responsible for converting data returned in a response to a dictionary (which should be returned). It should also be used to raise exceptions when an error message or error response status is returned. 85 | 86 | .. method:: format_data_to_request(self, data) 87 | 88 | This converts data passed to the body of the request into text. For example, if you need to send JSON, you should use ``json.dumps(data)`` and return the response. **See the mixins section above.** 89 | 90 | .. method:: response_to_native(self, response) 91 | 92 | This method receives the response of a request and should return a dictionay with the data contained in the response. **see the mixins section above.** 93 | 94 | .. method:: get_iterator_next_request_kwargs(self, iterator_request_kwargs, response_data, response) 95 | 96 | Override this method if the service you are using supports pagination. It should return a dictionary that will be used to fetch the next batch of data, e.g.: 97 | 98 | .. code-block:: python 99 | 100 | def get_iterator_next_request_kwargs(self, 101 | iterator_request_kwargs, response_data, response): 102 | paging = response_data.get('paging') 103 | if not paging: 104 | return 105 | url = paging.get('next') 106 | 107 | if url: 108 | iterator_request_kwargs['url'] = url 109 | return iterator_request_kwargs 110 | 111 | In this example, we are updating the URL from the last call made. ``iterator_request_kwargs`` contains the paramenters from the last call made, ``response_data`` contains the response data after it was parsed by ``process_response`` method, and ``response`` is the full response object with all its attributes like headers and status code. 112 | 113 | .. method:: get_iterator_list(self, response_data) 114 | 115 | Many APIs enclose the returned list of objects in one of the returned attributes. Use this method to extract and return only the list from the response. 116 | 117 | .. code-block:: python 118 | 119 | def get_iterator_list(self, response_data): 120 | return response_data['data'] 121 | 122 | In this example, the object list is enclosed in the ``data`` attribute. 123 | 124 | .. method:: is_authentication_expired(self, exception, \*args, \*\*kwargs) 125 | 126 | Given an exception, checks if the authentication has expired or not. If so and ```refresh_token_by_default=True``` or 127 | the HTTP method was called with ```refresh_token=True```, then it will automatically call ```refresh_authentication``` 128 | method and retry the original request. 129 | 130 | If not implemented, ```is_authentication_expired``` will assume ```False```, ```refresh_token_by_default``` also 131 | defaults to ```False``` in the client initialization. 132 | 133 | .. method:: refresh_authentication(self, api_params, \*args, \*\*kwargs): 134 | 135 | Should do refresh authentication logic. Make sure you update `api_params` dictionary with the new token. If it successfully refreshs token it should return a truthy value that will be stored for later access in the executor class in the ``refresh_data`` attribute. If the refresh logic fails, return a falsy value. The original request will be retried only if a truthy is returned. 136 | -------------------------------------------------------------------------------- /docs/source/buildingawrapper.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | Building a wrapper 3 | ================== 4 | 5 | 6 | Wrapping an API with Tapioca 7 | ============================ 8 | 9 | The easiest way to wrap an API using tapioca is starting from the `cookiecutter template `_. 10 | 11 | To use it, install cookiecutter in your machine: 12 | 13 | .. code-block:: bash 14 | 15 | pip install cookiecutter 16 | 17 | and then use it to download the template and run the config steps: 18 | 19 | .. code-block:: bash 20 | 21 | cookiecutter gh:vintasoftware/cookiecutter-tapioca 22 | 23 | After this process, it's possible that you have a ready to go wrapper. But in most cases, you will need to customize stuff. Read through this document to understand what methods are available and how your wrapper can make the most of tapioca. Also, you might want to take a look in the source code of :doc:`other wrappers ` to get more ideas. 24 | 25 | In case you are having any difficulties, seek help on `Gitter `_ or send an email to contact@vinta.com.br . 26 | 27 | Adapter 28 | ======= 29 | 30 | Tapioca features are mainly implemented in the ``TapiocaClient`` and ``TapiocaClientExecutor`` classes. Those are generic classes common to all wrappers and cannot be customized to specific services. All the code specific to the API wrapper you are creating goes in your adapter class, which should inherit from ``TapiocaAdapter`` and implement specific behaviours to the service you are working with. 31 | 32 | Take a look in the generated code from the cookiecutter or in the `tapioca-facebook adapter `_ to get a little familiar with it before you continue. Note that at the end of the module you will need to perform the transformation of your adapter into a client: 33 | 34 | .. code-block:: python 35 | 36 | Facebook = generate_wrapper_from_adapter(FacebookClientAdapter) 37 | 38 | Plase refer to the :doc:`TapiocaAdapter class ` document for more information on the available methods. 39 | 40 | Features 41 | ======== 42 | 43 | Here is some information you should know when building your wrapper. You may choose to or not to support features marked with `(optional)`. 44 | 45 | Resource Mapping 46 | ---------------- 47 | 48 | The resource mapping is a very simple dictionary which will tell your tapioca client about the available endpoints and how to call them. There's an example in your cookiecutter generated project. You can also take a look at `tapioca-facebook's resource mapping `_. 49 | 50 | Tapioca uses `requests `_ to perform HTTP requests. This is important to know because you will be using the method ``get_request_kwargs`` to set authentication details and return a dictionary that will be passed directly to the request method. 51 | 52 | 53 | Formatting data 54 | -------------- 55 | 56 | Use the methods ``format_data_to_request`` and ``response_to_native`` to correctly treat data leaving and being received in your wrapper. 57 | 58 | **TODO: add examples** 59 | 60 | You might want to use one of the following mixins to help you with data format handling in your wrapper: 61 | 62 | - ``FormAdapterMixin`` 63 | - ``JSONAdapterMixin`` 64 | - ``XMLAdapterMixin`` 65 | 66 | 67 | Exceptions 68 | ---------- 69 | 70 | Overwrite the ``process_response`` method to identify API server and client errors raising the correct exception accordingly. Please refer to the :doc:`exceptions ` for more information about exceptions. 71 | 72 | **TODO: add examples** 73 | 74 | Pagination (optional) 75 | --------------------- 76 | 77 | ``get_iterator_list`` and ``get_iterator_next_request_kwargs`` are the two methods you will need to implement for the executor ``pages()`` method to work. 78 | 79 | **TODO: add examples** 80 | 81 | Serializers (optional) 82 | ---------------------- 83 | 84 | Set a ``serializer_class`` attribute or overwrite the ``get_serializer()`` method in your wrapper for it to have a default serializer. 85 | 86 | .. code-block:: python 87 | 88 | from tapioca import TapiocaAdapter 89 | from tapioca.serializers import SimpleSerializer 90 | 91 | class MyAPISerializer(SimpleSerializer): 92 | 93 | def serialize_datetime(self, data): 94 | return data.isoformat() 95 | 96 | 97 | class MyAPIAdapter(TapiocaAdapter): 98 | serializer_class = MyAPISerializer 99 | ... 100 | 101 | In the example, every time a ``datetime`` is passed to the parameters of an HTTP method, it will be converted to an ISO formatted ``string``. 102 | 103 | It's important that you let people know you are providing a serializer, so make sure you have it documented in your `README`. 104 | 105 | .. code-block:: text 106 | 107 | ## Serialization 108 | - datetime 109 | - Decimal 110 | 111 | ## Deserialization 112 | - datetime 113 | - Decimal 114 | 115 | Please refer to the :doc:`serializers ` for more information about serializers. 116 | 117 | Refreshing Authentication (optional) 118 | ------------------------------------ 119 | 120 | You can implement the ```refresh_authentication``` and ```is_authentication_expired``` methods in your Tapioca Client to refresh your authentication token every time it expires. 121 | 122 | ```is_authentication_expired``` receives an error object from the request method (it contains the server response and HTTP Status code). You can use it to decide if a request failed because of the token. This method should return ```True``` if the authentication is expired or ```False``` otherwise (default behavior). 123 | 124 | ``refresh_authentication`` receives ``api_params`` and should perform the token refresh protocol. If it is successfull it should return a truthy value (the original request will then be automatically tried). If the token refresh fails, it should return a falsy value (and the the original request wont be retried). 125 | 126 | Once these methods are implemented, the client can be instantiated with ```refresh_token_by_default=True``` (or pass 127 | ```refresh_token=True``` in HTTP calls) and ```refresh_authentication``` will be called automatically. 128 | 129 | .. code-block:: python 130 | 131 | def is_authentication_expired(self, exception, *args, **kwargs): 132 | .... 133 | 134 | def refresh_authentication(self, api_params, *args, **kwargs): 135 | ... 136 | 137 | 138 | XMLAdapterMixin Configuration (only if required) 139 | ------------------------------------------------ 140 | 141 | Additionally, the XMLAdapterMixin accepts configuration keyword arguments to be passed to the xmltodict library during parsing and unparsing by prefixing the xmltodict keyword with ``xmltodict_parse__`` or ``xmltodict_unparse`` respectively. These parameters should be configured so that the end-user has a consistent experience across multiple Tapioca wrappers irrespective of various API requirements from wrapper to wrapper. 142 | 143 | Note that the end-user should **not** need to modify these keyword arguments themselves. See xmltodict `docs `_ and `source `_ for valid parameters. 144 | 145 | Users should be able to construct dictionaries as defined by the xmltodict library, and responses should be returned in the canonical format. 146 | 147 | Example XMLAdapterMixin configuration keywords: 148 | 149 | .. code-block:: python 150 | 151 | class MyXMLClientAdapter(XMLAdapterMixin, TapiocaAdapter): 152 | ... 153 | def get_request_kwargs(self, api_params, *args, **kwargs): 154 | ... 155 | # omits XML declaration when constructing requests from dictionary 156 | kwargs['xmltodict_unparse__full_document'] = False 157 | ... 158 | -------------------------------------------------------------------------------- /docs/source/changelog.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | 2.3.0 6 | ===== 7 | - Adds get_resource_mapping on TapiocaAdapter to customize the resource map dynamically. 8 | 9 | 2.2.0 10 | ===== 11 | - Remove unnecessary version pinning of arrow library 12 | - Drops support for python 3.6 and 3.7 13 | - Adds support for python 3.11 14 | 15 | 2.1.0 16 | ===== 17 | - Make ``TapiocaClient`` and ``TapiocaClientExecutor`` pickle-able. 18 | 19 | 2.0.2 20 | ===== 21 | - Updated deprecated collections import 22 | - Adds support for python 3.10 23 | 24 | 2.0.1 25 | ===== 26 | - Updates the list of supported versions in setup.py 27 | 28 | 2.0 29 | === 30 | - Drops support for python 2.7 and 3.4 31 | - Adds support for python 3.7 and 3.8 32 | 33 | 1.5.1 34 | ===== 35 | - Adds a ``resource_name`` kwarg to the ``get_api_root`` method 36 | 37 | 1.5 38 | === 39 | - Removes support for Python 3.3 40 | 41 | 42 | 1.4 43 | === 44 | - Adds support to Session requests 45 | 46 | 1.3 47 | === 48 | - ``refresh_authentication`` should return data about the refresh token process 49 | - If a falsy value is returned by ``refresh_authentication`` the request wont be retried automatically 50 | - Data returned by ``refresh_authentication`` is stored in the tapioca class and can be accessed in the executor via the attribute ``refresh_data`` 51 | 52 | 1.2.3 53 | ====== 54 | - ``refresh_token_by_default`` introduced to prevent passing ``refresh_token`` on every request. 55 | 56 | 1.1.10 57 | ====== 58 | - Fixed bugs regarding ``request_kwargs`` passing over calls 59 | - Fixed bugs regarding external ``serializer`` passing over calls 60 | - Wrapper instatiation now accepts ``default_url_params`` 61 | 62 | 1.1 63 | === 64 | - Automatic refresh token support 65 | - Added Python 3.5 support 66 | - Added support for ``OrderedDict`` 67 | - Documentation cleanup 68 | 69 | 1.0 70 | === 71 | - Data serialization and deserialization 72 | - Access CamelCase attributes using snake_case 73 | - Dependencies are now tied to specific versions of libraries 74 | - ``data`` and ``response`` are now attributes instead of methods in the executor 75 | - Added ``status_code`` attribute to tapioca executor 76 | - Renamed ``status`` exception attribute to ``status_code`` 77 | - Fixed return for ``dir`` call on executor, so it's lot easier to explore it 78 | - Multiple improvments to documentation 79 | 80 | 0.6.0 81 | ===== 82 | - Giving access to request_method in ``get_request_kwargs`` 83 | - Verifying response content before trying to convert it to json on ``JSONAdapterMixin`` 84 | - Support for ``in`` operator 85 | - pep8 improvments 86 | 87 | 0.5.3 88 | ===== 89 | - Adding ``max_pages`` and ``max_items`` to ``pages`` method 90 | 91 | 0.5.1 92 | ===== 93 | - Verifying if there's data before json dumping it on ``JSONAdapterMixin`` 94 | 95 | 0.5.0 96 | ===== 97 | - Automatic pagination now requires an explicit ``pages()`` call 98 | - Support for ``len()`` 99 | - Attributes of wrapped data can now be accessed via executor 100 | - It's now possible to iterate over wrapped lists 101 | 102 | 0.4.1 103 | ===== 104 | - changed parameters for Adapter's ``get_request_kwargs``. Also, subclasses are expected to call ``super``. 105 | - added mixins to allow adapters to easily choose witch data format they will be dealing with. 106 | - ``ServerError`` and ``ClientError`` are now raised on 4xx and 5xx response status. This behaviour can be customized for each service by overwriting adapter's ``process_response`` method. 107 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # tapioca-wrapper documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Jun 29 10:24:45 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | import shlex 18 | 19 | # If extensions (or modules to document with autodoc) are in another directory, 20 | # add these directories to sys.path here. If the directory is relative to the 21 | # documentation root, use os.path.abspath to make it absolute, like shown here. 22 | #sys.path.insert(0, os.path.abspath('.')) 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | #needs_sphinx = '1.0' 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = [] 33 | 34 | # Add any paths that contain templates here, relative to this directory. 35 | templates_path = ['_templates'] 36 | 37 | # The suffix(es) of source filenames. 38 | # You can specify multiple suffix as a list of string: 39 | # source_suffix = ['.rst', '.md'] 40 | # source_suffix = '.rst' 41 | 42 | source_suffix = '.rst' 43 | 44 | # The encoding of source files. 45 | #source_encoding = 'utf-8-sig' 46 | 47 | # The master toctree document. 48 | master_doc = 'index' 49 | 50 | # General information about the project. 51 | project = u'tapioca-wrapper' 52 | copyright = u'2015, Filipe Ximenes' 53 | author = u'Filipe Ximenes' 54 | 55 | # The version info for the project you're documenting, acts as replacement for 56 | # |version| and |release|, also used in various other places throughout the 57 | # built documents. 58 | # 59 | # The short X.Y version. 60 | version = '2.3' 61 | # The full version, including alpha/beta/rc tags. 62 | release = '2.3' 63 | 64 | # The language for content autogenerated by Sphinx. Refer to documentation 65 | # for a list of supported languages. 66 | # 67 | # This is also used if you do content translation via gettext catalogs. 68 | # Usually you set "language" from the command line for these cases. 69 | language = None 70 | 71 | # There are two options for replacing |today|: either, you set today to some 72 | # non-false value, then it is used: 73 | #today = '' 74 | # Else, today_fmt is used as the format for a strftime call. 75 | #today_fmt = '%B %d, %Y' 76 | 77 | # List of patterns, relative to source directory, that match files and 78 | # directories to ignore when looking for source files. 79 | exclude_patterns = [] 80 | 81 | # The reST default role (used for this markup: `text`) to use for all 82 | # documents. 83 | #default_role = None 84 | 85 | # If true, '()' will be appended to :func: etc. cross-reference text. 86 | #add_function_parentheses = True 87 | 88 | # If true, the current module name will be prepended to all description 89 | # unit titles (such as .. function::). 90 | #add_module_names = True 91 | 92 | # If true, sectionauthor and moduleauthor directives will be shown in the 93 | # output. They are ignored by default. 94 | #show_authors = False 95 | 96 | # The name of the Pygments (syntax highlighting) style to use. 97 | pygments_style = 'sphinx' 98 | 99 | # A list of ignored prefixes for module index sorting. 100 | #modindex_common_prefix = [] 101 | 102 | # If true, keep warnings as "system message" paragraphs in the built documents. 103 | #keep_warnings = False 104 | 105 | # If true, `todo` and `todoList` produce output, else they produce nothing. 106 | todo_include_todos = False 107 | 108 | 109 | # -- Options for HTML output ---------------------------------------------- 110 | 111 | # The theme to use for HTML and HTML Help pages. See the documentation for 112 | # a list of builtin themes. 113 | html_theme = 'alabaster' 114 | 115 | # Theme options are theme-specific and customize the look and feel of a theme 116 | # further. For a list of options available for each theme, see the 117 | # documentation. 118 | #html_theme_options = {} 119 | 120 | # Add any paths that contain custom themes here, relative to this directory. 121 | #html_theme_path = [] 122 | 123 | # The name for this set of Sphinx documents. If None, it defaults to 124 | # " v documentation". 125 | #html_title = None 126 | 127 | # A shorter title for the navigation bar. Default is the same as html_title. 128 | #html_short_title = None 129 | 130 | # The name of an image file (relative to this directory) to place at the top 131 | # of the sidebar. 132 | #html_logo = None 133 | 134 | # The name of an image file (within the static path) to use as favicon of the 135 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 136 | # pixels large. 137 | #html_favicon = None 138 | 139 | # Add any paths that contain custom static files (such as style sheets) here, 140 | # relative to this directory. They are copied after the builtin static files, 141 | # so a file named "default.css" will overwrite the builtin "default.css". 142 | html_static_path = ['_static'] 143 | 144 | # Add any extra paths that contain custom files (such as robots.txt or 145 | # .htaccess) here, relative to this directory. These files are copied 146 | # directly to the root of the documentation. 147 | #html_extra_path = [] 148 | 149 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 150 | # using the given strftime format. 151 | #html_last_updated_fmt = '%b %d, %Y' 152 | 153 | # If true, SmartyPants will be used to convert quotes and dashes to 154 | # typographically correct entities. 155 | #html_use_smartypants = True 156 | 157 | # Custom sidebar templates, maps document names to template names. 158 | #html_sidebars = {} 159 | 160 | # Additional templates that should be rendered to pages, maps page names to 161 | # template names. 162 | #html_additional_pages = {} 163 | 164 | # If false, no module index is generated. 165 | #html_domain_indices = True 166 | 167 | # If false, no index is generated. 168 | #html_use_index = True 169 | 170 | # If true, the index is split into individual pages for each letter. 171 | #html_split_index = False 172 | 173 | # If true, links to the reST sources are added to the pages. 174 | #html_show_sourcelink = True 175 | 176 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 177 | #html_show_sphinx = True 178 | 179 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 180 | #html_show_copyright = True 181 | 182 | # If true, an OpenSearch description file will be output, and all pages will 183 | # contain a tag referring to it. The value of this option must be the 184 | # base URL from which the finished HTML is served. 185 | #html_use_opensearch = '' 186 | 187 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 188 | #html_file_suffix = None 189 | 190 | # Language to be used for generating the HTML full-text search index. 191 | # Sphinx supports the following languages: 192 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 193 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 194 | #html_search_language = 'en' 195 | 196 | # A dictionary with options for the search language support, empty by default. 197 | # Now only 'ja' uses this config value 198 | #html_search_options = {'type': 'default'} 199 | 200 | # The name of a javascript file (relative to the configuration directory) that 201 | # implements a search results scorer. If empty, the default will be used. 202 | #html_search_scorer = 'scorer.js' 203 | 204 | # Output file base name for HTML help builder. 205 | htmlhelp_basename = 'tapioca-wrapperdoc' 206 | 207 | # -- Options for LaTeX output --------------------------------------------- 208 | 209 | latex_elements = { 210 | # The paper size ('letterpaper' or 'a4paper'). 211 | #'papersize': 'letterpaper', 212 | 213 | # The font size ('10pt', '11pt' or '12pt'). 214 | #'pointsize': '10pt', 215 | 216 | # Additional stuff for the LaTeX preamble. 217 | #'preamble': '', 218 | 219 | # Latex figure (float) alignment 220 | #'figure_align': 'htbp', 221 | } 222 | 223 | # Grouping the document tree into LaTeX files. List of tuples 224 | # (source start file, target name, title, 225 | # author, documentclass [howto, manual, or own class]). 226 | latex_documents = [ 227 | (master_doc, 'tapioca-wrapper.tex', u'tapioca-wrapper Documentation', 228 | u'Filipe Ximenes', 'manual'), 229 | ] 230 | 231 | # The name of an image file (relative to this directory) to place at the top of 232 | # the title page. 233 | #latex_logo = None 234 | 235 | # For "manual" documents, if this is true, then toplevel headings are parts, 236 | # not chapters. 237 | #latex_use_parts = False 238 | 239 | # If true, show page references after internal links. 240 | #latex_show_pagerefs = False 241 | 242 | # If true, show URL addresses after external links. 243 | #latex_show_urls = False 244 | 245 | # Documents to append as an appendix to all manuals. 246 | #latex_appendices = [] 247 | 248 | # If false, no module index is generated. 249 | #latex_domain_indices = True 250 | 251 | 252 | # -- Options for manual page output --------------------------------------- 253 | 254 | # One entry per manual page. List of tuples 255 | # (source start file, name, description, authors, manual section). 256 | man_pages = [ 257 | (master_doc, 'tapioca-wrapper', u'tapioca-wrapper Documentation', 258 | [author], 1) 259 | ] 260 | 261 | # If true, show URL addresses after external links. 262 | #man_show_urls = False 263 | 264 | 265 | # -- Options for Texinfo output ------------------------------------------- 266 | 267 | # Grouping the document tree into Texinfo files. List of tuples 268 | # (source start file, target name, title, author, 269 | # dir menu entry, description, category) 270 | texinfo_documents = [ 271 | (master_doc, 'tapioca-wrapper', u'tapioca-wrapper Documentation', 272 | author, 'tapioca-wrapper', 'One line description of project.', 273 | 'Miscellaneous'), 274 | ] 275 | 276 | # Documents to append as an appendix to all manuals. 277 | #texinfo_appendices = [] 278 | 279 | # If false, no module index is generated. 280 | #texinfo_domain_indices = True 281 | 282 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 283 | #texinfo_show_urls = 'footnote' 284 | 285 | # If true, do not generate a @detailmenu in the "Top" node's menu. 286 | #texinfo_no_detailmenu = False 287 | -------------------------------------------------------------------------------- /docs/source/contributors.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributors 3 | ============ 4 | 5 | Thanks! 6 | ------- 7 | 8 | - Filipe Ximenes (filipeximenes@gmail.com) 9 | - André Ericson (de.ericson@gmail.com) 10 | - Luiz Sotero (luizsotero@gmail.com) 11 | - Elias Granja Jr (contato@eliasgranja.com) 12 | - Rômulo Collopy (romulocollopy@gmail.com) -------------------------------------------------------------------------------- /docs/source/exceptions.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Exceptions 3 | ========== 4 | 5 | Catching API errors 6 | =================== 7 | 8 | Tapioca supports 2 main types of exceptions: ``ClientError`` and ``ServerError``. The default implementation raises ``ClientError`` for HTTP response 4xx status codes and ``ServerError`` for 5xx status codes. Since each API has its own ways of reporting errors and not all of them follow HTTP recommendations for status codes, this can be overriden by each implemented client to reflect its behaviour. Both of these exceptions extend ``TapiocaException`` which can be used to catch errors in a more generic way. 9 | 10 | 11 | .. class:: TapiocaException 12 | 13 | Base class for tapioca exceptions. Example usage: 14 | 15 | .. code-block:: python 16 | 17 | from tapioca.exceptions import TapiocaException 18 | 19 | try: 20 | cli.fetch_something().get() 21 | except TapiocaException, e: 22 | print("API call failed with error %s", e.status_code) 23 | 24 | You can also access a tapioca client that contains response data from the exception: 25 | 26 | .. code-block:: python 27 | 28 | from tapioca.exceptions import TapiocaException 29 | 30 | try: 31 | cli.fetch_something().get() 32 | except TapiocaException, e: 33 | print(e.client().data) 34 | 35 | .. class:: ClientError 36 | 37 | Default exception for client errors. Extends from ``TapiocaException``. 38 | 39 | .. class:: ServerError 40 | 41 | Default exception for server errors. Extends from ``TapiocaException``. 42 | -------------------------------------------------------------------------------- /docs/source/features.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Features 3 | ======== 4 | 5 | Here are some features tapioca supports. The wrapper you are using may support them or not, it will depend on the `tapioca-wrapper` version it is tied to and if the developer implemented the methods needed to support the feature. Either way, if you find yourself in a situation where you need one of these features, clone the wrapper, update the `tapioca-wrapper` version to the latest one, implement the features you need and submit a pull request to the developer. You will be helping a lot of people! 6 | 7 | 8 | TapiocaClient 9 | ============= 10 | 11 | The first object you get after you instanciate a tapioca wrapper is an instance of the ``TapiocaClient`` class. This class is capable of accessing the API endpoints of the wrapper and traversing response objects. No other action besides those can be achieved from a ``TapiocaClient``. To retrieve the raw data returned from the API call you will need to transform it in a ``TapiocaClientExecutor``. 12 | 13 | **TODO: add examples** 14 | 15 | Default URL params 16 | ------------------ 17 | 18 | Sometimes URLs templates need parameters that will be repeated across all API calls. For example, an user id: 19 | 20 | .. code-block:: bash 21 | 22 | http://www.someapi.com/{user_id}/resources/ 23 | http://www.someapi.com/{user_id}/resources/{resource_id}/ 24 | http://www.someapi.com/{user_id}/other-resources/{other_id}/ 25 | 26 | 27 | In this cases you can instantiate the wrapper passing a ``default_url_params`` parameter, and they will be used automatically to fill URL templates. 28 | 29 | .. code-block:: python 30 | 31 | cli = MyWrapper(access_token='some_token', default_url_params={'user_id': 123456}) 32 | cli.resources() # http://www.someapi.com/123456/resources/ 33 | 34 | Using an existing requests.Session 35 | ---------------------------------- 36 | 37 | Requests provides access to a number of advanced features by letting users maintain a `Session object`_. 38 | 39 | To use these features you can create a ``TapiocaClient`` with an existing session by passing it to the new client as the ``session`` parameter: 40 | 41 | .. code-block:: python 42 | 43 | session = requests.Session() 44 | cli = MyWrapper(access_token='some_token', session=session) 45 | cli.resources() # http://www.someapi.com/123456/resources/ 46 | 47 | This allows us to perform some interesting operations without having to support them directly in ``TapiocaClient``. 48 | For example caching for github requests using `cachecontrol`_: 49 | 50 | .. code-block:: python 51 | 52 | from cachecontrol import CacheControl 53 | from cachecontrol.caches import FileCache 54 | import requests 55 | import tapioca_github 56 | 57 | session = CacheControl(requests.Session(), cache=FileCache('webcache')) 58 | gh = tapioca_github.Github(client_id='some_id', access_token='some_token', session=session) 59 | response = gh.repo_single(owner="vintasoftware", repo="tapioca-wrapper").get() 60 | repo_data = response().data 61 | 62 | This will cache the E-tags provided by github to the folder `webcache`. 63 | 64 | .. _Session object: http://docs.python-requests.org/en/master/user/advanced/#session-objects 65 | .. _cachecontrol: https://cachecontrol.readthedocs.io/en/latest/ 66 | 67 | TapiocaClientExecutor 68 | ===================== 69 | 70 | Every time you ``call`` in ``TapiocaClient`` you will get a ``TapiocaClientExecutor``. Here are the features available in a ``TapiocaClientExecutor``: 71 | 72 | Accessing raw response data 73 | --------------------------- 74 | 75 | To access the raw data contained in the executor, use the ``data`` **attribute**. To access the raw response, use the ``response`` **attribute**. To access the status code of the response, use the ``status_code`` **attribute**. If during the request the ``Auth refreshing`` process was executed, the returned value from it will be accessible in the ``refresh_data`` **attribute**. 76 | 77 | **TODO: add examples** 78 | 79 | HTTP calls 80 | ---------- 81 | 82 | Executors have access to make HTTP calls using the current data it possesses as the URL. The `requests `_ library is used as the engine to perform API calls. Every key word parameter you pass to: ``get()``, ``post()``, ``put()``, ``patch()``, ``delete()`` methods will be directly passed to the request library call. This means you will be using ``params={'myparam': 'paramvalue'}`` to send querystring arguments in the url and ``data={'datakey': 'keyvalue'}`` to send data in the body of the request. 83 | 84 | **TODO: add examples** 85 | 86 | Auth refreshing (\*) 87 | -------------------- 88 | 89 | Some clients need to update its token once they have expired. If the client supports this feature, you might instantiate it 90 | passing ```refresh_token_by_default=True``` or make any HTTP call passing ```refresh_auth=True``` (both defaults to 91 | ```False```). Note that if your client instance have ```refresh_token_by_default=True```, then you don't need to 92 | explicity set it on HTTP calls. 93 | 94 | **TODO: add examples** 95 | 96 | *the wrapper you are current using may not support this feature 97 | 98 | Pagination (\*) 99 | --------------- 100 | 101 | Use ``pages()`` method to call an endpoint that returns a collection of objects in batches. This will make your client automatically fetch more data untill there is none more left. You may use ``max_pages`` and/or ``max_items`` to limit the number of items you want to iterate over. 102 | 103 | **TODO: add examples** 104 | 105 | *the wrapper you are current using may not support this feature 106 | 107 | 108 | Open docs (\*) 109 | -------------- 110 | 111 | When accessing an endpoint, you may want to read it's documentation in the internet. By calling ``open_docs()`` in a python interactive session, the doc page will be openned in a browser. 112 | 113 | **TODO: add examples** 114 | 115 | *the wrapper you are current using may not support this feature 116 | 117 | Open in the browser (\*) 118 | ------------------------ 119 | 120 | Whenever the data contained in the executor is a URL, you can directly open it in the browser from an interactive session by calling ``open_in_browser()`` 121 | 122 | **TODO: add examples** 123 | 124 | *the wrapper you are current using may not support this feature 125 | 126 | Exceptions 127 | ========== 128 | 129 | Tapioca built in exceptions will help you to beautifuly catch and handle whenever there is a client or server error. Make sure the wrapper you are using correctly raises exceptions, the developer might not have treated this. Please refer to the :doc:`exceptions ` for more information about exceptions. 130 | 131 | Serializers 132 | =========== 133 | 134 | Serializers will help you processing data before it is sent to the endpoint and transforming data from responses into python objects. Please refer to the :doc:`serializers ` for more information about serializers. 135 | -------------------------------------------------------------------------------- /docs/source/flavours.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | Flavours 3 | ======== 4 | 5 | Available Flavours 6 | ================== 7 | 8 | 9 | .. _flavour-facebook: 10 | 11 | Facebook 12 | -------- 13 | ``_ 14 | 15 | .. _flavour-twitter: 16 | 17 | Twitter 18 | ------- 19 | ``_ 20 | 21 | Mandrill 22 | -------- 23 | ``_ 24 | 25 | Parse 26 | ----- 27 | ``_ 28 | 29 | Bitbucket 30 | --------- 31 | ``_ 32 | 33 | Disqus 34 | ------ 35 | ``_ 36 | 37 | Harvest 38 | ------- 39 | ``_ 40 | 41 | CrunchBase 42 | ---------- 43 | ``_ 44 | 45 | Otter 46 | ----- 47 | ``_ 48 | 49 | GitHub 50 | ------ 51 | ``_ 52 | 53 | Meetup 54 | ------ 55 | ``_ 56 | 57 | Toggl 58 | ----- 59 | ``_ 60 | 61 | Braspag 62 | ------- 63 | ``_ 64 | 65 | Iugu 66 | ---- 67 | ``_ 68 | 69 | Instagram 70 | --------- 71 | ``_ 72 | 73 | Youtube 74 | --------- 75 | ``_ 76 | 77 | Asana 78 | ----- 79 | ``_ 80 | 81 | Desk 82 | ----- 83 | ``_ 84 | 85 | Mailgun 86 | ----- 87 | ``_ 88 | 89 | Discourse 90 | ----- 91 | ``_ 92 | 93 | StatusPage 94 | ----- 95 | ``_ 96 | 97 | Trello 98 | ------ 99 | ``_ 100 | 101 | Your flavour 102 | ============ 103 | To create a new wrapper, please refer to :doc:`Building a wrapper `. Upload it to pypi and send a pull request here for it to be added to the list. 104 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | 2 | Welcome to tapioca-wrapper documentation! 3 | ========================================= 4 | 5 | Contents: 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | about 11 | quickstart 12 | features 13 | serializers 14 | exceptions 15 | flavours 16 | buildingawrapper 17 | adapter_class 18 | contributors 19 | changelog 20 | -------------------------------------------------------------------------------- /docs/source/quickstart.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Quickstart 3 | ========== 4 | 5 | Using a tapioca package 6 | ======================= 7 | 8 | **Yes, you are in the right place** 9 | 10 | There is a good chance you found this page because you clicked a link from some python package called **tapioca-SOMETHING**. Well, welcome! You are in the right place. This page will teach you the basics of how to use the package that sent you here. If you didn't arrive here from another package, then please keep reading. The concepts learned here apply to any tapioca-**package** available. 11 | 12 | What's tapioca? 13 | =============== 14 | 15 | **tapioca** is an *API wrapper maker*. It helps Python developers creating packages for APIs (like the :ref:`Facebook Graph API ` or the :ref:`Twitter REST API `. You can find a full list of available API packages made with tapioca :doc:`here `. 16 | 17 | All wrappers made with tapioca follow a simple interaction pattern that works uniformly, so once you learn how tapioca works, you will be able to work with any tapioca package available. 18 | 19 | Getting started 20 | =============== 21 | 22 | We will use ``tapioca-facebook`` as example to guide us through tapioca concepts. Let's install ``tapioca-facebook``: 23 | 24 | .. code-block:: bash 25 | 26 | $ pip install tapioca-facebook 27 | 28 | To better experience tapioca, we will also use IPython: 29 | 30 | .. code-block:: bash 31 | 32 | $ pip install ipython 33 | 34 | Let's explore! 35 | 36 | Go to `https://developers.facebook.com/tools/explorer/ `_, click "Get Access Token", select all "User Data Permissions" and "Extended Permissions", and click "Get Access Token". This will give you a temporary access token to play with Facebook API. In case it expires, just generate a new one. 37 | 38 | TapiocaClient object 39 | ==================== 40 | 41 | This is how you initialize your tapioca client: 42 | 43 | .. code-block:: python 44 | 45 | from tapioca_facebook import Facebook 46 | 47 | api = Facebook(access_token='{your_genereated_access_token}') 48 | 49 | 50 | If you are using IPython, you can now list available endpoints by typing ``api.`` and pressing ``tab``. 51 | 52 | .. code-block:: python 53 | 54 | >>> api. 55 | api.user_likes api.page_blocked api.page_locations 56 | api.page_statuses api.user_applications_developer api.user_friends 57 | api.user_invitable_friends api.user_photos api.user_videos 58 | api.object api.page_conversations api.page_milestones 59 | ... 60 | 61 | 62 | Resources 63 | --------- 64 | 65 | Those are the available endpoints for the Facebook API. As we can see, there is one called ``user_likes``. Let's take a closer look. 66 | 67 | Type ``api.user_likes?`` and press ``enter``. 68 | 69 | .. code-block:: python 70 | 71 | In [3]: api.user_likes? 72 | ... 73 | Docstring: 74 | Automatic generated __doc__ from resource_mapping. 75 | Resource: {id}/likes 76 | Docs: https://developers.facebook.com/docs/graph-api/reference/v2.2/user/likes 77 | 78 | 79 | As we can see, the ``user_likes`` resource requires an ``id`` to be passed to the URL. Let's do it: 80 | 81 | .. code-block:: python 82 | 83 | api.user_likes(id='me') 84 | 85 | 86 | Fetching data 87 | ------------- 88 | 89 | To request the current user likes, its easy: 90 | 91 | .. code-block:: python 92 | 93 | likes = api.user_likes(id='me').get() 94 | 95 | 96 | To print the returned data: 97 | 98 | .. code-block:: python 99 | 100 | In [9]: likes().data 101 | OUT [9]: { 102 | 'data': [...], 103 | 'paging': {...} 104 | } 105 | 106 | 107 | Exploring data 108 | -------------- 109 | 110 | We can also explore the returned data using the IPython ``tab`` auto-complete: 111 | 112 | .. code-block:: python 113 | 114 | In [9]: likes. 115 | likes.data likes.paging 116 | 117 | 118 | Iterating over data 119 | ------------------- 120 | 121 | You can iterate over returned data: 122 | 123 | .. code-block:: python 124 | 125 | likes = api.user_likes(id='me').get() 126 | 127 | for like in likes.data: 128 | print(like.id().data) 129 | 130 | Items passed to the ``for`` loop will be wrapped in tapioca so you still have access to all features. 131 | 132 | TapiocaClientExecutor object 133 | ============================ 134 | 135 | Whenever you make a "call" on a ``TapiocaClient``, it will return an ``TapiocaClientExecutor`` object. You will use the executor every time you want to perform an action over data you possess. 136 | 137 | We did this already when we filled the URL parameters for the ``user_like`` resource (calling it and passing the argument ``id='me'``). In this new object, you will find many methods to help you play with the data available. 138 | 139 | Here is the list of the methods available in a ``TapiocaClientExecutor``: 140 | 141 | Making requests 142 | --------------- 143 | 144 | Tapioca uses the `requests `_ library to make requests so HTTP methods will work just the same (get()/post()/put()/delete()/head()/options()). The only difference is that we don't need to pass a URL since tapioca will take care of this. 145 | 146 | .. code-block:: python 147 | 148 | likes = api.user_likes(id='me').get() 149 | 150 | 151 | **URL params** 152 | 153 | To pass query string parameters in the URL, you can use the ```params``` parameter: 154 | 155 | .. code-block:: python 156 | 157 | likes = api.user_likes(id='me').get( 158 | params={'limit': 5}) 159 | 160 | This will return only 5 results. 161 | 162 | **Body data** 163 | 164 | If you need to pass data in the body of your request, you can use the ```data``` parameter. For example, let's post a message to a Facebook wall: 165 | 166 | .. code-block:: python 167 | 168 | # this will only work if you have a post to wall permission 169 | api.user_feed(id='me').post( 170 | data={'message': 'I love tapiocas!! S2'}) 171 | 172 | Please read `requests `_ for more detailed information about how to use HTTP methods. 173 | 174 | Accessing raw data 175 | ------------------ 176 | 177 | Use ``data`` to return data contained in the Tapioca object. 178 | 179 | .. code-block:: python 180 | 181 | >>> likes = api.user_likes(id='me').get() 182 | >>> likes().data 183 | { 184 | 'data': [...], 185 | 'paging': {...} 186 | } 187 | >>> # this will print only the array contained 188 | >>> # in the 'data' field of the response 189 | >>> likes.data().data 190 | >>> [...] 191 | 192 | Dynamically fetching pages 193 | ------------------------- 194 | 195 | Many APIs use a paging concept to provide large amounts of data. This way, data is returned in multiple requests to avoid a single long request. Tapioca is built to provide an easy way to access paged data using the ``pages()`` method: 196 | 197 | .. code-block:: python 198 | 199 | likes = api.user_likes(id='me').get() 200 | 201 | for like in likes().pages(): 202 | print(like.name().data) 203 | 204 | This will keep fetching user likes until there are none left. Items passed to the ``for`` loop will be wrapped in tapioca so you still have access to all features. 205 | 206 | This method also accepts ``max_pages`` and ``max_items`` parameters. If both parameters are used, the ``for`` loop will stop after ``max_pages`` are fetched or ``max_items`` are yielded, whichever comes first: 207 | 208 | .. code-block:: python 209 | 210 | for item in resp().pages(max_pages=2, max_items=40): 211 | print(item) 212 | # in this example, the for loop will stop after two pages are fetched or 40 items are yielded, 213 | # whichever comes first. 214 | 215 | Accessing wrapped data attributes 216 | --------------------------------- 217 | 218 | It's possible to access wrapped data attributes on executor. For example, it's possible to reverse a wrapped list: 219 | 220 | .. code-block:: python 221 | 222 | likes = api.user_likes(id='me').get() 223 | 224 | likes_list = likes.data 225 | likes_list().reverse() 226 | # items in the likes_list are now in reverse order 227 | # but still wrapped in a tapioca object 228 | 229 | Opening documentation in the browser 230 | ------------------------------------ 231 | 232 | If you are accessing a resource, you can call ``open_docs`` to open the resource documentation in a browser: 233 | 234 | .. code-block:: python 235 | 236 | api.user_likes().open_docs() 237 | 238 | Opening any link in the browser 239 | ------------------------------- 240 | 241 | Whenever the data contained in a tapioca object is a URL, you can open it in a browser by using the ``open_in_browser()`` method. 242 | 243 | 244 | For more information on what wrappers are capable of, please refer to the :doc:`features ` section. 245 | -------------------------------------------------------------------------------- /docs/source/serializers.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Serializers 3 | =========== 4 | 5 | 6 | Serializer classes are capable of performing serialization and deserialization of data. 7 | 8 | **Serialization** is the transformation of data in a native format (in our case Python data types) into a serialized format (e.g. text). For example, this could be transforming a native Python ``Datetime`` instance containing a date into a string. 9 | 10 | **Deserialization** is the transformation of data in a serialized format (e.g. text) into a native format. For example, this could be transforming a string containing a date into a native Python ``Datetime`` instance. 11 | 12 | 13 | Usage 14 | ===== 15 | 16 | Serialization 17 | ------------- 18 | 19 | Data serialization is done in the background when tapioca is executing the request. It will traverse any data structure passed to the ``data`` param of the request and convert Python data types into serialized types. 20 | 21 | .. code-block:: python 22 | 23 | >>> reponse = cli.the_resource().post(data={'date': datetime.today()}) 24 | 25 | In this example, ``datetime.today()`` will be converted into a string formatted date just before the request is executed. 26 | 27 | Deserialization 28 | --------------- 29 | 30 | To deserialize data, you need to transform your client into an executor and then call a deserialization method from it: 31 | 32 | .. code-block:: python 33 | 34 | >>> reponse = cli.the_resource().get() 35 | >>> print(response.created_at()) 36 | 38 | >>> print(respose.created_at().to_datetime()) 39 | 2015-10-25 22:34:51+00:00 40 | >>> print(type(respose.created_at().to_datetime())) 41 | datetime.datetime 42 | 43 | 44 | Swapping the default serializer 45 | ------------------------------- 46 | 47 | Clients might have the default ``SimpleSerializer``, some custom serializer designed by the wrapper creator, or even no serializer. Either way, you can swap it for one of your own even if you were not the developer of the library. For this, you only need to pass the desired serializer class during the client initialization: 48 | 49 | .. code-block:: python 50 | 51 | from my_serializers import MyCustomSerializer 52 | 53 | cli = MyServiceClient( 54 | access_token='blablabla', 55 | serializer_class=MyCustomSerializer) 56 | 57 | 58 | Built-ins 59 | ========= 60 | 61 | .. class:: SimpleSerializer 62 | 63 | ``SimpleSerializer`` is a very basic and generic serializer. It is included by default in adapters unless explicitly removed. It supports serialization from `Decimal` and `datetime` and deserialization methods to those two types as well. Here is it's full code: 64 | 65 | .. code-block:: python 66 | 67 | class SimpleSerializer(BaseSerializer): 68 | 69 | def to_datetime(self, value): 70 | return arrow.get(value).datetime 71 | 72 | def to_decimal(self, value): 73 | return Decimal(value) 74 | 75 | def serialize_decimal(self, data): 76 | return str(data) 77 | 78 | def serialize_datetime(self, data): 79 | return arrow.get(data).isoformat() 80 | 81 | 82 | As you can see, ``datetime`` values will be formatted to iso format. 83 | 84 | Writing a custom serializer 85 | =========================== 86 | 87 | To write a custom serializer, you just need to extend the ``BaseSerializer`` class and add the methods you want. But you can also extend from ``SimpleSerializer`` to inherit its functionalities. 88 | 89 | Serializing 90 | ----------- 91 | To allow serialization of any desired data type, add a method to your serializer named using the following pattern: ``serialize_ + name_of_your_data_type_in_lower_case``. For example: 92 | 93 | .. code-block:: python 94 | 95 | class MyCustomDataType(object): 96 | message = '' 97 | 98 | class MyCustomSerializer(SimpleSerializer): 99 | 100 | def serialize_mycustomdatatype(self, data): 101 | return data.message 102 | 103 | 104 | Deserializing 105 | ------------- 106 | Any method starting with ``to_`` in your custom serializer class will be available for data deserialization. It also accepts key word arguments. 107 | 108 | .. code-block:: python 109 | 110 | from tapioca.serializers import BaseSerializer 111 | 112 | class MyCustomSerializer(BaseSerializer): 113 | 114 | def to_striped(self, value, **kwargs): 115 | return value.strip() 116 | 117 | Here's a usage example for it: 118 | 119 | .. code-block:: python 120 | 121 | from my_serializers import MyCustomSerializer 122 | 123 | cli = MyServiceClient( 124 | access_token='blablabla', 125 | serializer_class=MyCustomSerializer) 126 | 127 | response = cli.the_resource().get() 128 | 129 | striped_data = response.the_data().to_striped() 130 | 131 | 132 | -------------------------------------------------------------------------------- /docs/static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/tapioca-wrapper/6e1b213f8cbbd8bca481b641aea9f4f849ad7958/docs/static/logo.png -------------------------------------------------------------------------------- /docs/static/tapioca.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/tapioca-wrapper/6e1b213f8cbbd8bca481b641aea9f4f849ad7958/docs/static/tapioca.jpg -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Tapioca-Wrapper 2 | pages: 3 | - Home: index.md 4 | - About: about.md 5 | - Quickstart: quickstart.md 6 | - Available Flavours: flavours.md 7 | - New Flavour: newflavour.md 8 | - Contributors: contributors.md 9 | - Changelog: changelog.md 10 | theme: readthedocs 11 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | try: 6 | from setuptools import setup 7 | except ImportError: 8 | from distutils.core import setup 9 | 10 | import re 11 | import os 12 | import sys 13 | 14 | description = """ 15 | Tapioca provides an easy way to make explorable Python API wrappers. 16 | APIs wrapped by Tapioca follow a simple interaction pattern that works uniformly so 17 | developers don't need to learn how to use a new coding interface/style for each service API. 18 | 19 | Source code hosted on Github: https://github.com/vintasoftware/tapioca-wrapper 20 | 21 | Documentation hosted by Readthedocs: http://tapioca-wrapper.readthedocs.io/en/stable/ 22 | """ 23 | 24 | package = 'tapioca' 25 | requirements = [ 26 | 'requests[security]>=2.6', 27 | 'arrow>=0.6.0', 28 | 'six>=1', 29 | 'xmltodict>=0.9.2' 30 | ] 31 | test_requirements = [ 32 | 'responses>=0.5', 33 | 'mock>=1.3,<1.4' 34 | ] 35 | 36 | 37 | def get_version(package): 38 | """ 39 | Return package version as listed in `__version__` in `init.py`. 40 | """ 41 | init_py = open(os.path.join(package, '__init__.py')).read() 42 | return re.search("^__version__ = ['\"]([^'\"]+)['\"]", init_py, re.MULTILINE).group(1) 43 | 44 | 45 | if sys.argv[-1] == 'version': 46 | print(get_version(package)) 47 | sys.exit() 48 | 49 | 50 | setup( 51 | name='tapioca-wrapper', 52 | version=get_version(package), 53 | description='Python API client generator', 54 | long_description=description, 55 | author='Filipe Ximenes', 56 | author_email='filipeximenes@gmail.com', 57 | url='https://github.com/vintasoftware/tapioca-wrapper', 58 | packages=[ 59 | 'tapioca', 60 | ], 61 | package_dir={'tapioca': 'tapioca'}, 62 | include_package_data=True, 63 | install_requires=requirements, 64 | license="MIT", 65 | zip_safe=False, 66 | keywords='tapioca,wrapper,api', 67 | classifiers=[ 68 | 'Development Status :: 5 - Production/Stable', 69 | 'Intended Audience :: Developers', 70 | 'License :: OSI Approved :: MIT License', 71 | 'Natural Language :: English', 72 | 'Programming Language :: Python :: 3', 73 | ], 74 | test_suite='tests', 75 | tests_require=test_requirements 76 | ) 77 | -------------------------------------------------------------------------------- /tapioca/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from __future__ import unicode_literals 4 | 5 | __author__ = 'Filipe Ximenes' 6 | __email__ = 'filipeximenes@gmail.com' 7 | __version__ = '2.3.0' 8 | 9 | 10 | from .adapters import ( 11 | generate_wrapper_from_adapter, 12 | TapiocaAdapter, 13 | FormAdapterMixin, JSONAdapterMixin) 14 | -------------------------------------------------------------------------------- /tapioca/adapters.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | import json 4 | import xmltodict 5 | from collections.abc import Mapping 6 | 7 | from .tapioca import TapiocaInstantiator 8 | from .exceptions import ( 9 | ResponseProcessException, ClientError, ServerError) 10 | from .serializers import SimpleSerializer 11 | 12 | 13 | def generate_wrapper_from_adapter(adapter_class): 14 | return TapiocaInstantiator(adapter_class) 15 | 16 | 17 | class TapiocaAdapter(object): 18 | serializer_class = SimpleSerializer 19 | 20 | def __init__(self, serializer_class=None, *args, **kwargs): 21 | if serializer_class: 22 | self.serializer = serializer_class() 23 | else: 24 | self.serializer = self.get_serializer() 25 | 26 | def _get_to_native_method(self, method_name, value): 27 | if not self.serializer: 28 | raise NotImplementedError("This client does not have a serializer") 29 | 30 | def to_native_wrapper(**kwargs): 31 | return self._value_to_native(method_name, value, **kwargs) 32 | 33 | return to_native_wrapper 34 | 35 | def _value_to_native(self, method_name, value, **kwargs): 36 | return self.serializer.deserialize(method_name, value, **kwargs) 37 | 38 | def get_serializer(self): 39 | if self.serializer_class: 40 | return self.serializer_class() 41 | 42 | def get_api_root(self, api_params, **kwargs): 43 | return self.api_root 44 | 45 | def get_resource_mapping(self, api_params): 46 | return self.resource_mapping 47 | 48 | def fill_resource_template_url(self, template, params): 49 | return template.format(**params) 50 | 51 | def get_request_kwargs(self, api_params, *args, **kwargs): 52 | serialized = self.serialize_data(kwargs.get('data')) 53 | 54 | kwargs.update({ 55 | 'data': self.format_data_to_request(serialized), 56 | }) 57 | return kwargs 58 | 59 | def get_error_message(self, data, response=None): 60 | return str(data) 61 | 62 | def process_response(self, response): 63 | if 500 <= response.status_code < 600: 64 | raise ResponseProcessException(ServerError, None) 65 | 66 | data = self.response_to_native(response) 67 | 68 | if 400 <= response.status_code < 500: 69 | raise ResponseProcessException(ClientError, data) 70 | 71 | return data 72 | 73 | def serialize_data(self, data): 74 | if self.serializer: 75 | return self.serializer.serialize(data) 76 | 77 | return data 78 | 79 | def format_data_to_request(self, data): 80 | raise NotImplementedError() 81 | 82 | def response_to_native(self, response): 83 | raise NotImplementedError() 84 | 85 | def get_iterator_list(self, response_data): 86 | raise NotImplementedError() 87 | 88 | def get_iterator_next_request_kwargs(self, iterator_request_kwargs, 89 | response_data, response): 90 | raise NotImplementedError() 91 | 92 | def is_authentication_expired(self, exception, *args, **kwargs): 93 | return False 94 | 95 | def refresh_authentication(self, api_params, *args, **kwargs): 96 | raise NotImplementedError() 97 | 98 | 99 | class FormAdapterMixin(object): 100 | 101 | def format_data_to_request(self, data): 102 | return data 103 | 104 | def response_to_native(self, response): 105 | return {'text': response.text} 106 | 107 | 108 | class JSONAdapterMixin(object): 109 | 110 | def get_request_kwargs(self, api_params, *args, **kwargs): 111 | arguments = super(JSONAdapterMixin, self).get_request_kwargs( 112 | api_params, *args, **kwargs) 113 | 114 | if 'headers' not in arguments: 115 | arguments['headers'] = {} 116 | arguments['headers']['Content-Type'] = 'application/json' 117 | return arguments 118 | 119 | def format_data_to_request(self, data): 120 | if data: 121 | return json.dumps(data) 122 | 123 | def response_to_native(self, response): 124 | if response.content.strip(): 125 | return response.json() 126 | 127 | def get_error_message(self, data, response=None): 128 | if not data and response.content.strip(): 129 | data = json.loads(response.content.decode('utf-8')) 130 | 131 | if data: 132 | return data.get('error', None) 133 | 134 | 135 | class XMLAdapterMixin(object): 136 | 137 | def _input_branches_to_xml_bytestring(self, data): 138 | if isinstance(data, Mapping): 139 | return xmltodict.unparse( 140 | data, **self._xmltodict_unparse_kwargs).encode('utf-8') 141 | try: 142 | return data.encode('utf-8') 143 | except Exception as e: 144 | raise type(e)('Format not recognized, please enter an XML as string or a dictionary' 145 | 'in xmltodict spec: \n%s' % e.message) 146 | 147 | def get_request_kwargs(self, api_params, *args, **kwargs): 148 | # stores kwargs prefixed with 'xmltodict_unparse__' for use by xmltodict.unparse 149 | self._xmltodict_unparse_kwargs = {k[len('xmltodict_unparse__'):]: kwargs.pop(k) 150 | for k in kwargs.copy().keys() 151 | if k.startswith('xmltodict_unparse__')} 152 | # stores kwargs prefixed with 'xmltodict_parse__' for use by xmltodict.parse 153 | self._xmltodict_parse_kwargs = {k[len('xmltodict_parse__'):]: kwargs.pop(k) 154 | for k in kwargs.copy().keys() 155 | if k.startswith('xmltodict_parse__')} 156 | 157 | arguments = super(XMLAdapterMixin, self).get_request_kwargs( 158 | api_params, *args, **kwargs) 159 | 160 | if 'headers' not in arguments: 161 | arguments['headers'] = {} 162 | arguments['headers']['Content-Type'] = 'application/xml' 163 | return arguments 164 | 165 | def format_data_to_request(self, data): 166 | if data: 167 | return self._input_branches_to_xml_bytestring(data) 168 | 169 | def response_to_native(self, response): 170 | if response.content.strip(): 171 | if 'xml' in response.headers['content-type']: 172 | return xmltodict.parse(response.content, **self._xmltodict_parse_kwargs) 173 | return {'text': response.text} 174 | -------------------------------------------------------------------------------- /tapioca/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class ResponseProcessException(Exception): 3 | 4 | def __init__(self, tapioca_exception, data, *args, **kwargs): 5 | self.tapioca_exception = tapioca_exception 6 | self.data = data 7 | super(ResponseProcessException, self).__init__(*args, **kwargs) 8 | 9 | 10 | class TapiocaException(Exception): 11 | 12 | def __init__(self, message, client): 13 | self.status_code = None 14 | self.client = client 15 | if client is not None: 16 | self.status_code = client().status_code 17 | 18 | if not message: 19 | message = "response status code: {}".format(self.status_code) 20 | super(TapiocaException, self).__init__(message) 21 | 22 | 23 | class ClientError(TapiocaException): 24 | 25 | def __init__(self, message='', client=None): 26 | super(ClientError, self).__init__(message, client=client) 27 | 28 | 29 | class ServerError(TapiocaException): 30 | 31 | def __init__(self, message='', client=None): 32 | super(ServerError, self).__init__(message, client=client) 33 | -------------------------------------------------------------------------------- /tapioca/serializers.py: -------------------------------------------------------------------------------- 1 | 2 | import arrow 3 | from decimal import Decimal 4 | 5 | 6 | class BaseSerializer(object): 7 | 8 | def deserialize(self, method_name, value, **kwargs): 9 | if hasattr(self, method_name): 10 | return getattr(self, method_name)(value, **kwargs) 11 | raise NotImplementedError("Desserialization method not found") 12 | 13 | def serialize_dict(self, data): 14 | serialized = {} 15 | 16 | for key, value in data.items(): 17 | serialized[key] = self.serialize(value) 18 | 19 | return serialized 20 | 21 | def serialize_list(self, data): 22 | serialized = [] 23 | for item in data: 24 | serialized.append(self.serialize(item)) 25 | 26 | return serialized 27 | 28 | def serialize(self, data): 29 | data_type = type(data).__name__ 30 | 31 | serialize_method = ('serialize_' + data_type).lower() 32 | if hasattr(self, serialize_method): 33 | return getattr(self, serialize_method)(data) 34 | 35 | return data 36 | 37 | 38 | class SimpleSerializer(BaseSerializer): 39 | 40 | def to_datetime(self, value): 41 | return arrow.get(value).datetime 42 | 43 | def to_decimal(self, value): 44 | return Decimal(value) 45 | 46 | def serialize_decimal(self, data): 47 | return str(data) 48 | 49 | def serialize_datetime(self, data): 50 | return arrow.get(data).isoformat() 51 | -------------------------------------------------------------------------------- /tapioca/tapioca.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from __future__ import unicode_literals 4 | 5 | import copy 6 | 7 | import requests 8 | import webbrowser 9 | 10 | import json 11 | from collections import OrderedDict 12 | 13 | from .exceptions import ResponseProcessException 14 | 15 | 16 | class TapiocaInstantiator(object): 17 | 18 | def __init__(self, adapter_class): 19 | self.adapter_class = adapter_class 20 | 21 | def __call__(self, serializer_class=None, session=None, **kwargs): 22 | refresh_token_default = kwargs.pop('refresh_token_by_default', False) 23 | return TapiocaClient( 24 | self.adapter_class(serializer_class=serializer_class), 25 | api_params=kwargs, refresh_token_by_default=refresh_token_default, 26 | session=session) 27 | 28 | 29 | class TapiocaClient(object): 30 | 31 | def __init__(self, api, data=None, response=None, request_kwargs=None, 32 | api_params=None, resource=None, refresh_token_by_default=False, 33 | refresh_data=None, session=None, *args, **kwargs): 34 | self._api = api 35 | self._data = data 36 | self._response = response 37 | self._api_params = api_params or {} 38 | self._request_kwargs = request_kwargs 39 | self._resource = resource 40 | self._refresh_token_default = refresh_token_by_default 41 | self._refresh_data = refresh_data 42 | self._session = session or requests.Session() 43 | 44 | def _instatiate_api(self): 45 | serializer_class = None 46 | if self._api.serializer: 47 | serializer_class = self._api.serializer.__class__ 48 | return self._api.__class__( 49 | serializer_class=serializer_class) 50 | 51 | def _wrap_in_tapioca(self, data, *args, **kwargs): 52 | request_kwargs = kwargs.pop('request_kwargs', self._request_kwargs) 53 | return TapiocaClient(self._instatiate_api(), data=data, 54 | api_params=self._api_params, 55 | request_kwargs=request_kwargs, 56 | refresh_token_by_default=self._refresh_token_default, 57 | refresh_data=self._refresh_data, 58 | session=self._session, 59 | *args, **kwargs) 60 | 61 | def _wrap_in_tapioca_executor(self, data, *args, **kwargs): 62 | request_kwargs = kwargs.pop('request_kwargs', self._request_kwargs) 63 | return TapiocaClientExecutor(self._instatiate_api(), data=data, 64 | api_params=self._api_params, 65 | request_kwargs=request_kwargs, 66 | refresh_token_by_default=self._refresh_token_default, 67 | refresh_data=self._refresh_data, 68 | session=self._session, 69 | *args, **kwargs) 70 | 71 | def _get_doc(self): 72 | resources = copy.copy(self._resource) 73 | docs = ("Automatic generated __doc__ from resource_mapping.\n" 74 | "Resource: %s\n" 75 | "Docs: %s\n" % (resources.pop('resource', ''), 76 | resources.pop('docs', ''))) 77 | for key, value in sorted(resources.items()): 78 | docs += "%s: %s\n" % (key.title(), value) 79 | docs = docs.strip() 80 | return docs 81 | 82 | __doc__ = property(_get_doc) 83 | 84 | def __call__(self, *args, **kwargs): 85 | data = self._data 86 | 87 | url_params = self._api_params.get('default_url_params', {}) 88 | url_params.update(kwargs) 89 | if self._resource and url_params: 90 | data = self._api.fill_resource_template_url(self._data, url_params) 91 | 92 | return self._wrap_in_tapioca_executor(data, resource=self._resource, 93 | response=self._response) 94 | """ 95 | Convert a snake_case string in CamelCase. 96 | http://stackoverflow.com/questions/19053707/convert-snake-case-snake-case-to-lower-camel-case-lowercamelcase-in-python 97 | """ 98 | def _to_camel_case(self, name): 99 | if isinstance(name, int): 100 | return name 101 | components = name.split('_') 102 | return components[0] + "".join(x.title() for x in components[1:]) 103 | 104 | def _get_client_from_name(self, name): 105 | if (isinstance(self._data, list) and isinstance(name, int) or 106 | hasattr(self._data, '__iter__') and name in self._data): 107 | return self._wrap_in_tapioca(data=self._data[name]) 108 | 109 | # if could not access, falback to resource mapping 110 | resource_mapping = self._api.get_resource_mapping(self._api_params) 111 | if name in resource_mapping: 112 | resource = resource_mapping[name] 113 | api_root = self._api.get_api_root( 114 | self._api_params, resource_name=name 115 | ) 116 | 117 | url = api_root.rstrip('/') + '/' + resource['resource'].lstrip('/') 118 | return self._wrap_in_tapioca(url, resource=resource) 119 | 120 | return None 121 | 122 | def _get_client_from_name_or_fallback(self, name): 123 | client = self._get_client_from_name(name) 124 | if client is not None: 125 | return client 126 | 127 | camel_case_name = self._to_camel_case(name) 128 | client = self._get_client_from_name(camel_case_name) 129 | if client is not None: 130 | return client 131 | 132 | normal_camel_case_name = camel_case_name[0].upper() 133 | normal_camel_case_name += camel_case_name[1:] 134 | 135 | client = self._get_client_from_name(normal_camel_case_name) 136 | if client is not None: 137 | return client 138 | 139 | return None 140 | 141 | def __getattr__(self, name): 142 | # Fix to be pickle-able: 143 | # return None for all unimplemented dunder methods 144 | if name.startswith('__') and name.endswith('__'): 145 | raise AttributeError(name) 146 | ret = self._get_client_from_name_or_fallback(name) 147 | if ret is None: 148 | raise AttributeError(name) 149 | return ret 150 | 151 | def __getitem__(self, key): 152 | ret = self._get_client_from_name_or_fallback(key) 153 | if ret is None: 154 | raise KeyError(key) 155 | return ret 156 | 157 | def __dir__(self): 158 | if self._api and self._data is None: 159 | return [key for key in 160 | self._api.get_resource_mapping(self._api_params).keys()] 161 | 162 | if isinstance(self._data, dict): 163 | return self._data.keys() 164 | 165 | return [] 166 | 167 | def __str__(self): 168 | if type(self._data) == OrderedDict: 169 | return ("<{} object, printing as dict:\n" 170 | "{}>").format( 171 | self.__class__.__name__, json.dumps(self._data, indent=4)) 172 | else: 173 | import pprint 174 | pp = pprint.PrettyPrinter(indent=4) 175 | return ("<{} object\n" 176 | "{}>").format( 177 | self.__class__.__name__, pp.pformat(self._data)) 178 | 179 | def _repr_pretty_(self, p, cycle): 180 | p.text(self.__str__()) 181 | 182 | def __len__(self): 183 | return len(self._data) 184 | 185 | def __contains__(self, key): 186 | return key in self._data 187 | 188 | 189 | class TapiocaClientExecutor(TapiocaClient): 190 | 191 | def __init__(self, api, *args, **kwargs): 192 | super(TapiocaClientExecutor, self).__init__(api, *args, **kwargs) 193 | 194 | def __getitem__(self, key): 195 | raise Exception("This operation cannot be done on a" + 196 | " TapiocaClientExecutor object") 197 | 198 | def __iter__(self): 199 | raise Exception("Cannot iterate over a TapiocaClientExecutor object") 200 | 201 | def __getattr__(self, name): 202 | # Fix to be pickle-able: 203 | # return None for all unimplemented dunder methods 204 | if name.startswith('__') and name.endswith('__'): 205 | raise AttributeError(name) 206 | if name.startswith('to_'): # deserializing 207 | return self._api._get_to_native_method(name, self._data) 208 | return self._wrap_in_tapioca_executor(getattr(self._data, name)) 209 | 210 | def __call__(self, *args, **kwargs): 211 | return self._wrap_in_tapioca(self._data.__call__(*args, **kwargs)) 212 | 213 | @property 214 | def data(self): 215 | return self._data 216 | 217 | @property 218 | def response(self): 219 | if self._response is None: 220 | raise Exception("This instance has no response object") 221 | return self._response 222 | 223 | @property 224 | def status_code(self): 225 | return self.response.status_code 226 | 227 | @property 228 | def refresh_data(self): 229 | return self._refresh_data 230 | 231 | def _make_request(self, request_method, refresh_token=None, *args, **kwargs): 232 | if 'url' not in kwargs: 233 | kwargs['url'] = self._data 234 | 235 | request_kwargs = self._api.get_request_kwargs( 236 | self._api_params, request_method, *args, **kwargs) 237 | 238 | response = self._session.request(request_method, **request_kwargs) 239 | 240 | try: 241 | data = self._api.process_response(response) 242 | except ResponseProcessException as e: 243 | client = self._wrap_in_tapioca(e.data, response=response, 244 | request_kwargs=request_kwargs) 245 | 246 | error_message = self._api.get_error_message(data=e.data, 247 | response=response) 248 | tapioca_exception = e.tapioca_exception(message=error_message, 249 | client=client) 250 | 251 | should_refresh_token = (refresh_token is not False and 252 | self._refresh_token_default) 253 | auth_expired = self._api.is_authentication_expired(tapioca_exception) 254 | 255 | propagate_exception = True 256 | 257 | if should_refresh_token and auth_expired: 258 | self._refresh_data = self._api.refresh_authentication(self._api_params) 259 | if self._refresh_data: 260 | propagate_exception = False 261 | return self._make_request(request_method, 262 | refresh_token=False, *args, **kwargs) 263 | 264 | if propagate_exception: 265 | raise tapioca_exception 266 | 267 | return self._wrap_in_tapioca(data, response=response, 268 | request_kwargs=request_kwargs) 269 | 270 | def get(self, *args, **kwargs): 271 | return self._make_request('GET', *args, **kwargs) 272 | 273 | def post(self, *args, **kwargs): 274 | return self._make_request('POST', *args, **kwargs) 275 | 276 | def options(self, *args, **kwargs): 277 | return self._make_request('OPTIONS', *args, **kwargs) 278 | 279 | def put(self, *args, **kwargs): 280 | return self._make_request('PUT', *args, **kwargs) 281 | 282 | def patch(self, *args, **kwargs): 283 | return self._make_request('PATCH', *args, **kwargs) 284 | 285 | def delete(self, *args, **kwargs): 286 | return self._make_request('DELETE', *args, **kwargs) 287 | 288 | def _get_iterator_list(self): 289 | return self._api.get_iterator_list(self._data) 290 | 291 | def _get_iterator_next_request_kwargs(self): 292 | return self._api.get_iterator_next_request_kwargs( 293 | self._request_kwargs, self._data, self._response) 294 | 295 | def _reached_max_limits(self, page_count, item_count, max_pages, 296 | max_items): 297 | reached_page_limit = max_pages is not None and max_pages <= page_count 298 | reached_item_limit = max_items is not None and max_items <= item_count 299 | return reached_page_limit or reached_item_limit 300 | 301 | def pages(self, max_pages=None, max_items=None, **kwargs): 302 | executor = self 303 | iterator_list = executor._get_iterator_list() 304 | page_count = 0 305 | item_count = 0 306 | 307 | while iterator_list: 308 | if self._reached_max_limits(page_count, item_count, max_pages, 309 | max_items): 310 | break 311 | for item in iterator_list: 312 | if self._reached_max_limits(page_count, item_count, max_pages, 313 | max_items): 314 | break 315 | yield self._wrap_in_tapioca(item) 316 | item_count += 1 317 | 318 | page_count += 1 319 | 320 | next_request_kwargs = executor._get_iterator_next_request_kwargs() 321 | 322 | if not next_request_kwargs: 323 | break 324 | 325 | response = self.get(**next_request_kwargs) 326 | executor = response() 327 | iterator_list = executor._get_iterator_list() 328 | 329 | def open_docs(self): 330 | if not self._resource: 331 | raise KeyError() 332 | 333 | new = 2 # open in new tab 334 | webbrowser.open(self._resource['docs'], new=new) 335 | 336 | def open_in_browser(self): 337 | new = 2 # open in new tab 338 | webbrowser.open(self._data, new=new) 339 | 340 | def __dir__(self): 341 | methods = [m for m in TapiocaClientExecutor.__dict__.keys() if not m.startswith('_')] 342 | methods += [m for m in dir(self._api.serializer) if m.startswith('to_')] 343 | 344 | return methods 345 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vintasoftware/tapioca-wrapper/6e1b213f8cbbd8bca481b641aea9f4f849ad7958/tests/__init__.py -------------------------------------------------------------------------------- /tests/client.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from __future__ import unicode_literals 4 | 5 | from tapioca.adapters import ( 6 | TapiocaAdapter, JSONAdapterMixin, XMLAdapterMixin, 7 | generate_wrapper_from_adapter) 8 | from tapioca.serializers import SimpleSerializer 9 | 10 | 11 | RESOURCE_MAPPING = { 12 | 'test': { 13 | 'resource': 'test/', 14 | 'docs': 'http://www.example.org' 15 | }, 16 | 'user': { 17 | 'resource': 'user/{id}/', 18 | 'docs': 'http://www.example.org/user' 19 | }, 20 | 'resource': { 21 | 'resource': 'resource/{number}/', 22 | 'docs': 'http://www.example.org/resource', 23 | 'spam': 'eggs', 24 | 'foo': 'bar' 25 | }, 26 | 'another_root': { 27 | 'resource': 'another-root/', 28 | 'docs': 'http://www.example.org/another-root' 29 | }, 30 | } 31 | 32 | 33 | class TesterClientAdapter(JSONAdapterMixin, TapiocaAdapter): 34 | serializer_class = None 35 | api_root = 'https://api.example.org' 36 | resource_mapping = RESOURCE_MAPPING 37 | 38 | def get_api_root(self, api_params, **kwargs): 39 | if kwargs.get('resource_name') == 'another_root': 40 | return 'https://api.another.com/' 41 | else: 42 | return self.api_root 43 | 44 | def get_iterator_list(self, response_data): 45 | return response_data['data'] 46 | 47 | def get_iterator_next_request_kwargs(self, iterator_request_kwargs, 48 | response_data, response): 49 | paging = response_data.get('paging') 50 | if not paging: 51 | return 52 | url = paging.get('next') 53 | 54 | if url: 55 | return {'url': url} 56 | 57 | 58 | TesterClient = generate_wrapper_from_adapter(TesterClientAdapter) 59 | 60 | 61 | class CustomSerializer(SimpleSerializer): 62 | 63 | def to_kwargs(self, data, **kwargs): 64 | return kwargs 65 | 66 | 67 | class SerializerClientAdapter(TesterClientAdapter): 68 | serializer_class = CustomSerializer 69 | 70 | 71 | SerializerClient = generate_wrapper_from_adapter(SerializerClientAdapter) 72 | 73 | 74 | class TokenRefreshClientAdapter(TesterClientAdapter): 75 | 76 | def is_authentication_expired(self, exception, *args, **kwargs): 77 | return exception.status_code == 401 78 | 79 | def refresh_authentication(self, api_params, *args, **kwargs): 80 | new_token = 'new_token' 81 | api_params['token'] = new_token 82 | return new_token 83 | 84 | 85 | TokenRefreshClient = generate_wrapper_from_adapter(TokenRefreshClientAdapter) 86 | 87 | 88 | class FailTokenRefreshClientAdapter(TokenRefreshClientAdapter): 89 | 90 | def refresh_authentication(self, api_params, *args, **kwargs): 91 | return None 92 | 93 | 94 | FailTokenRefreshClient = generate_wrapper_from_adapter(FailTokenRefreshClientAdapter) 95 | 96 | 97 | class XMLClientAdapter(XMLAdapterMixin, TapiocaAdapter): 98 | api_root = 'https://api.example.org' 99 | resource_mapping = RESOURCE_MAPPING 100 | 101 | 102 | XMLClient = generate_wrapper_from_adapter(XMLClientAdapter) 103 | -------------------------------------------------------------------------------- /tests/test_exceptions.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from __future__ import unicode_literals 4 | 5 | import unittest 6 | 7 | import responses 8 | import requests 9 | 10 | from tapioca.exceptions import ( 11 | ClientError, ServerError, ResponseProcessException, 12 | TapiocaException) 13 | from tapioca.tapioca import TapiocaClient 14 | 15 | from tests.client import TesterClient, TesterClientAdapter 16 | 17 | 18 | class TestTapiocaException(unittest.TestCase): 19 | 20 | def setUp(self): 21 | self.wrapper = TesterClient() 22 | 23 | @responses.activate 24 | def test_exception_contain_tapioca_client(self): 25 | responses.add(responses.GET, self.wrapper.test().data, 26 | body='{"data": {"key": "value"}}', 27 | status=400, 28 | content_type='application/json') 29 | 30 | try: 31 | self.wrapper.test().get() 32 | except TapiocaException as e: 33 | exception = e 34 | 35 | self.assertIs(exception.client.__class__, TapiocaClient) 36 | 37 | @responses.activate 38 | def test_exception_contain_status_code(self): 39 | responses.add(responses.GET, self.wrapper.test().data, 40 | body='{"data": {"key": "value"}}', 41 | status=400, 42 | content_type='application/json') 43 | 44 | try: 45 | self.wrapper.test().get() 46 | except TapiocaException as e: 47 | exception = e 48 | 49 | self.assertIs(exception.status_code, 400) 50 | 51 | @responses.activate 52 | def test_exception_message(self): 53 | responses.add(responses.GET, self.wrapper.test().data, 54 | body='{"data": {"key": "value"}}', 55 | status=400, 56 | content_type='application/json') 57 | 58 | try: 59 | self.wrapper.test().get() 60 | except TapiocaException as e: 61 | exception = e 62 | 63 | self.assertEqual(str(exception), 'response status code: 400') 64 | 65 | 66 | class TestExceptions(unittest.TestCase): 67 | 68 | def setUp(self): 69 | self.wrapper = TesterClient() 70 | 71 | @responses.activate 72 | def test_adapter_raises_response_process_exception_on_400s(self): 73 | responses.add(responses.GET, self.wrapper.test().data, 74 | body='{"erros": "Server Error"}', 75 | status=400, 76 | content_type='application/json') 77 | 78 | response = requests.get(self.wrapper.test().data) 79 | 80 | with self.assertRaises(ResponseProcessException): 81 | TesterClientAdapter().process_response(response) 82 | 83 | @responses.activate 84 | def test_adapter_raises_response_process_exception_on_500s(self): 85 | responses.add(responses.GET, self.wrapper.test().data, 86 | body='{"erros": "Server Error"}', 87 | status=500, 88 | content_type='application/json') 89 | 90 | response = requests.get(self.wrapper.test().data) 91 | 92 | with self.assertRaises(ResponseProcessException): 93 | TesterClientAdapter().process_response(response) 94 | 95 | @responses.activate 96 | def test_raises_request_error(self): 97 | responses.add(responses.GET, self.wrapper.test().data, 98 | body='{"data": {"key": "value"}}', 99 | status=400, 100 | content_type='application/json') 101 | 102 | with self.assertRaises(ClientError): 103 | self.wrapper.test().get() 104 | 105 | @responses.activate 106 | def test_raises_server_error(self): 107 | responses.add(responses.GET, self.wrapper.test().data, 108 | status=500, 109 | content_type='application/json') 110 | 111 | with self.assertRaises(ServerError): 112 | self.wrapper.test().get() 113 | -------------------------------------------------------------------------------- /tests/test_serializers.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from __future__ import unicode_literals 4 | 5 | import arrow 6 | import unittest 7 | import responses 8 | import json 9 | from decimal import Decimal 10 | 11 | from tapioca.serializers import BaseSerializer, SimpleSerializer 12 | 13 | from tests.client import TesterClient, SerializerClient 14 | 15 | 16 | class TestSerlializer(unittest.TestCase): 17 | 18 | def setUp(self): 19 | self.wrapper = SerializerClient() 20 | 21 | def test_passing_serializer_on_instatiation(self): 22 | wrapper = TesterClient(serializer_class=SimpleSerializer) 23 | serializer = wrapper._api.serializer 24 | self.assertTrue(isinstance(serializer, BaseSerializer)) 25 | 26 | @responses.activate 27 | def test_external_serializer_is_passed_along_clients(self): 28 | serializer_wrapper = TesterClient(serializer_class=SimpleSerializer) 29 | 30 | responses.add(responses.GET, serializer_wrapper.test().data, 31 | body='{"date": "2014-11-13T14:53:18.694072+00:00"}', 32 | status=200, 33 | content_type='application/json') 34 | 35 | response = serializer_wrapper.test().get() 36 | 37 | self.assertTrue(response._api.serializer.__class__, SimpleSerializer) 38 | 39 | def test_serializer_client_adapter_has_serializer(self): 40 | serializer = self.wrapper._api.serializer 41 | self.assertTrue(isinstance(serializer, BaseSerializer)) 42 | 43 | @responses.activate 44 | def test_executor_dir_returns_serializer_methods(self): 45 | responses.add(responses.GET, self.wrapper.test().data, 46 | body='{"date": "2014-11-13T14:53:18.694072+00:00"}', 47 | status=200, 48 | content_type='application/json') 49 | 50 | response = self.wrapper.test().get() 51 | 52 | e_dir = dir(response()) 53 | 54 | self.assertIn('to_datetime', e_dir) 55 | self.assertIn('to_decimal', e_dir) 56 | 57 | @responses.activate 58 | def test_request_with_data_serialization(self): 59 | responses.add(responses.POST, self.wrapper.test().data, 60 | body='{}', status=200, content_type='application/json') 61 | 62 | string_date = '2014-11-13T14:53:18.694072+00:00' 63 | string_decimal = '1.45' 64 | 65 | data = { 66 | 'date': arrow.get(string_date).datetime, 67 | 'decimal': Decimal(string_decimal), 68 | } 69 | 70 | self.wrapper.test().post(data=data) 71 | 72 | request_body = responses.calls[0].request.body 73 | 74 | self.assertEqual( 75 | json.loads(request_body), 76 | {'date': string_date, 'decimal': string_decimal}) 77 | 78 | 79 | class TestDeserialization(unittest.TestCase): 80 | 81 | def setUp(self): 82 | self.wrapper = SerializerClient() 83 | 84 | @responses.activate 85 | def test_convert_to_decimal(self): 86 | responses.add(responses.GET, self.wrapper.test().data, 87 | body='{"decimal_value": "10.51"}', 88 | status=200, 89 | content_type='application/json') 90 | 91 | response = self.wrapper.test().get() 92 | self.assertEqual( 93 | response.decimal_value().to_decimal(), 94 | Decimal('10.51')) 95 | 96 | @responses.activate 97 | def test_convert_to_datetime(self): 98 | responses.add(responses.GET, self.wrapper.test().data, 99 | body='{"date": "2014-11-13T14:53:18.694072+00:00"}', 100 | status=200, 101 | content_type='application/json') 102 | 103 | response = self.wrapper.test().get() 104 | date = response.date().to_datetime() 105 | self.assertEqual(date.year, 2014) 106 | self.assertEqual(date.month, 11) 107 | self.assertEqual(date.day, 13) 108 | self.assertEqual(date.hour, 14) 109 | self.assertEqual(date.minute, 53) 110 | self.assertEqual(date.second, 18) 111 | 112 | @responses.activate 113 | def test_call_non_existent_conversion(self): 114 | responses.add(responses.GET, self.wrapper.test().data, 115 | body='{"any_data": "%#ˆ$&"}', 116 | status=200, 117 | content_type='application/json') 118 | 119 | response = self.wrapper.test().get() 120 | with self.assertRaises(NotImplementedError): 121 | response.any_data().to_blablabla() 122 | 123 | @responses.activate 124 | def test_call_conversion_with_no_serializer(self): 125 | wrapper = TesterClient() 126 | responses.add(responses.GET, wrapper.test().data, 127 | body='{"any_data": "%#ˆ$&"}', 128 | status=200, 129 | content_type='application/json') 130 | 131 | response = wrapper.test().get() 132 | with self.assertRaises(NotImplementedError): 133 | response.any_data().to_datetime() 134 | 135 | @responses.activate 136 | def test_pass_kwargs(self): 137 | responses.add(responses.GET, self.wrapper.test().data, 138 | body='{"decimal_value": "10.51"}', 139 | status=200, 140 | content_type='application/json') 141 | 142 | response = self.wrapper.test().get() 143 | 144 | self.assertEqual( 145 | response.decimal_value().to_kwargs(some_key='some value'), 146 | {'some_key': 'some value'}) 147 | 148 | 149 | class TestSerialization(unittest.TestCase): 150 | 151 | def setUp(self): 152 | self.serializer = SimpleSerializer() 153 | 154 | def test_serialize_int(self): 155 | data = 1 156 | 157 | serialized = self.serializer.serialize(data) 158 | 159 | self.assertEqual(serialized, data) 160 | 161 | def test_serialize_str(self): 162 | data = 'the str' 163 | 164 | serialized = self.serializer.serialize(data) 165 | 166 | self.assertEqual(serialized, data) 167 | 168 | def test_serialize_float(self): 169 | data = 1.23 170 | 171 | serialized = self.serializer.serialize(data) 172 | 173 | self.assertEqual(serialized, data) 174 | 175 | def test_serialize_none(self): 176 | data = None 177 | 178 | serialized = self.serializer.serialize(data) 179 | 180 | self.assertEqual(serialized, data) 181 | 182 | def test_serialization_of_simple_dict(self): 183 | data = { 184 | 'key1': 'value1', 185 | 'key2': 'value2', 186 | 'key3': 'value3', 187 | } 188 | 189 | serialized = self.serializer.serialize(data) 190 | 191 | self.assertEqual(serialized, data) 192 | 193 | def test_serialization_of_simple_list(self): 194 | data = [1, 2, 3, 4, 5] 195 | 196 | serialized = self.serializer.serialize(data) 197 | 198 | self.assertEqual(serialized, data) 199 | 200 | def test_serialization_of_nested_list_in_dict(self): 201 | data = { 202 | 'key1': [1, 2, 3, 4, 5], 203 | 'key2': [1], 204 | 'key3': [1, 2, 5], 205 | } 206 | 207 | serialized = self.serializer.serialize(data) 208 | 209 | self.assertEqual(serialized, data) 210 | 211 | def test_multi_level_serializations(self): 212 | data = [ 213 | {'key1': [1, 2, 3, 4, 5]}, 214 | {'key2': [1]}, 215 | {'key3': [1, 2, 5]}, 216 | ] 217 | 218 | serialized = self.serializer.serialize(data) 219 | 220 | self.assertEqual(serialized, data) 221 | 222 | def test_decimal_serialization(self): 223 | data = { 224 | 'key': [Decimal('1.0'), Decimal('1.1'), Decimal('1.2')] 225 | } 226 | 227 | serialized = self.serializer.serialize(data) 228 | 229 | self.assertEqual(serialized, {'key': ['1.0', '1.1', '1.2']}) 230 | 231 | def test_datetime_serialization(self): 232 | string_date = '2014-11-13T14:53:18.694072+00:00' 233 | 234 | data = [arrow.get(string_date).datetime] 235 | 236 | serialized = self.serializer.serialize(data) 237 | 238 | self.assertEqual(serialized, [string_date]) 239 | -------------------------------------------------------------------------------- /tests/test_tapioca.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | 3 | from __future__ import unicode_literals 4 | 5 | import unittest 6 | import responses 7 | import json 8 | import pickle 9 | 10 | import xmltodict 11 | from collections import OrderedDict 12 | 13 | from tapioca.tapioca import TapiocaClient 14 | from tapioca.exceptions import ClientError, ServerError 15 | 16 | from tests.client import TesterClient, TokenRefreshClient, XMLClient, FailTokenRefreshClient 17 | 18 | 19 | class TestTapiocaClient(unittest.TestCase): 20 | 21 | def setUp(self): 22 | self.wrapper = TesterClient() 23 | 24 | def test_fill_url_template(self): 25 | expected_url = 'https://api.example.org/user/123/' 26 | 27 | resource = self.wrapper.user(id='123') 28 | 29 | self.assertEqual(resource.data, expected_url) 30 | 31 | def test_fill_another_root_url_template(self): 32 | expected_url = 'https://api.another.com/another-root/' 33 | 34 | resource = self.wrapper.another_root() 35 | 36 | self.assertEqual(resource.data, expected_url) 37 | 38 | def test_calling_len_on_tapioca_list(self): 39 | client = self.wrapper._wrap_in_tapioca([0, 1, 2]) 40 | self.assertEqual(len(client), 3) 41 | 42 | def test_iterated_client_items_should_be_tapioca_instances(self): 43 | client = self.wrapper._wrap_in_tapioca([0, 1, 2]) 44 | 45 | for item in client: 46 | self.assertTrue(isinstance(item, TapiocaClient)) 47 | 48 | def test_iterated_client_items_should_contain_list_items(self): 49 | client = self.wrapper._wrap_in_tapioca([0, 1, 2]) 50 | 51 | for i, item in enumerate(client): 52 | self.assertEqual(item().data, i) 53 | 54 | @responses.activate 55 | def test_in_operator(self): 56 | responses.add(responses.GET, self.wrapper.test().data, 57 | body='{"data": 1, "other": 2}', 58 | status=200, 59 | content_type='application/json') 60 | 61 | response = self.wrapper.test().get() 62 | 63 | self.assertIn('data', response) 64 | self.assertIn('other', response) 65 | self.assertNotIn('wat', response) 66 | 67 | @responses.activate 68 | def test_transform_camelCase_in_snake_case(self): 69 | next_url = 'http://api.example.org/next_batch' 70 | 71 | responses.add(responses.GET, self.wrapper.test().data, 72 | body='{"data" :{"key_snake": "value", "camelCase": "data in camel case", "NormalCamelCase": "data in camel case"}, "paging": {"next": "%s"}}' % next_url, 73 | status=200, 74 | content_type='application/json') 75 | 76 | response = self.wrapper.test().get() 77 | 78 | self.assertEqual(response.data.key_snake().data, 'value') 79 | self.assertEqual(response.data.camel_case().data, 'data in camel case') 80 | self.assertEqual(response.data.normal_camel_case().data, 'data in camel case') 81 | 82 | @responses.activate 83 | def test_should_be_able_to_access_by_index(self): 84 | responses.add(responses.GET, self.wrapper.test().data, 85 | body='["a", "b", "c"]', 86 | status=200, 87 | content_type='application/json') 88 | 89 | response = self.wrapper.test().get() 90 | 91 | self.assertEqual(response[0]().data, 'a') 92 | self.assertEqual(response[1]().data, 'b') 93 | self.assertEqual(response[2]().data, 'c') 94 | 95 | @responses.activate 96 | def test_accessing_index_out_of_bounds_should_raise_index_error(self): 97 | responses.add(responses.GET, self.wrapper.test().data, 98 | body='["a", "b", "c"]', 99 | status=200, 100 | content_type='application/json') 101 | 102 | response = self.wrapper.test().get() 103 | 104 | with self.assertRaises(IndexError): 105 | response[3] 106 | 107 | @responses.activate 108 | def test_accessing_empty_list_should_raise_index_error(self): 109 | responses.add(responses.GET, self.wrapper.test().data, 110 | body='[]', 111 | status=200, 112 | content_type='application/json') 113 | 114 | response = self.wrapper.test().get() 115 | 116 | with self.assertRaises(IndexError): 117 | response[3] 118 | 119 | def test_fill_url_from_default_params(self): 120 | wrapper = TesterClient(default_url_params={'id': 123}) 121 | self.assertEqual(wrapper.user().data, 'https://api.example.org/user/123/') 122 | 123 | @responses.activate 124 | def test_is_pickleable(self): 125 | wrapper = TesterClient() 126 | wrapper = pickle.loads(pickle.dumps(wrapper)) 127 | 128 | # ensure requests keep working after pickle: 129 | next_url = 'http://api.example.org/next_batch' 130 | 131 | responses.add(responses.GET, wrapper.test().data, 132 | body='{"data": [{"key": "value"}], "paging": {"next": "%s"}}' % next_url, 133 | status=200, 134 | content_type='application/json') 135 | 136 | responses.add(responses.GET, next_url, 137 | body='{"data": [{"key": "value"}], "paging": {"next": ""}}', 138 | status=200, 139 | content_type='application/json') 140 | 141 | response = wrapper.test().get() 142 | 143 | iterations_count = 0 144 | for item in response().pages(): 145 | self.assertIn(item.key().data, 'value') 146 | iterations_count += 1 147 | 148 | self.assertEqual(iterations_count, 2) 149 | 150 | 151 | class TestTapiocaExecutor(unittest.TestCase): 152 | 153 | def setUp(self): 154 | self.wrapper = TesterClient() 155 | 156 | def test_resource_executor_data_should_be_composed_url(self): 157 | expected_url = 'https://api.example.org/test/' 158 | resource = self.wrapper.test() 159 | 160 | self.assertEqual(resource.data, expected_url) 161 | 162 | def test_docs(self): 163 | self.assertEqual( 164 | '\n'.join(self.wrapper.resource.__doc__.split('\n')[1:]), 165 | 'Resource: ' + self.wrapper.resource._resource['resource'] + '\n' 166 | 'Docs: ' + self.wrapper.resource._resource['docs'] + '\n' 167 | 'Foo: ' + self.wrapper.resource._resource['foo'] + '\n' 168 | 'Spam: ' + self.wrapper.resource._resource['spam']) 169 | 170 | def test_access_data_attributres_through_executor(self): 171 | client = self.wrapper._wrap_in_tapioca({'test': 'value'}) 172 | 173 | items = client().items() 174 | 175 | self.assertTrue(isinstance(items, TapiocaClient)) 176 | 177 | data = dict(items().data) 178 | 179 | self.assertEqual(data, {'test': 'value'}) 180 | 181 | def test_is_possible_to_reverse_a_list_through_executor(self): 182 | client = self.wrapper._wrap_in_tapioca([0, 1, 2]) 183 | client().reverse() 184 | self.assertEqual(client().data, [2, 1, 0]) 185 | 186 | def test_cannot__getittem__(self): 187 | client = self.wrapper._wrap_in_tapioca([0, 1, 2]) 188 | with self.assertRaises(Exception): 189 | client()[0] 190 | 191 | def test_cannot_iterate(self): 192 | client = self.wrapper._wrap_in_tapioca([0, 1, 2]) 193 | with self.assertRaises(Exception): 194 | for item in client(): 195 | pass 196 | 197 | def test_dir_call_returns_executor_methods(self): 198 | client = self.wrapper._wrap_in_tapioca([0, 1, 2]) 199 | 200 | e_dir = dir(client()) 201 | 202 | self.assertIn('data', e_dir) 203 | self.assertIn('response', e_dir) 204 | self.assertIn('get', e_dir) 205 | self.assertIn('post', e_dir) 206 | self.assertIn('pages', e_dir) 207 | self.assertIn('open_docs', e_dir) 208 | self.assertIn('open_in_browser', e_dir) 209 | 210 | @responses.activate 211 | def test_response_executor_object_has_a_response(self): 212 | next_url = 'http://api.example.org/next_batch' 213 | 214 | responses.add(responses.GET, self.wrapper.test().data, 215 | body='{"data": [{"key": "value"}], "paging": {"next": "%s"}}' % next_url, 216 | status=200, 217 | content_type='application/json') 218 | 219 | responses.add(responses.GET, next_url, 220 | body='{"data": [{"key": "value"}], "paging": {"next": ""}}', 221 | status=200, 222 | content_type='application/json') 223 | 224 | response = self.wrapper.test().get() 225 | executor = response() 226 | 227 | executor.response 228 | 229 | executor._response = None 230 | 231 | def test_raises_error_if_executor_does_not_have_a_response_object(self): 232 | client = self.wrapper 233 | 234 | with self.assertRaises(Exception): 235 | client().response 236 | 237 | @responses.activate 238 | def test_response_executor_has_a_status_code(self): 239 | responses.add(responses.GET, self.wrapper.test().data, 240 | body='{"data": {"key": "value"}}', 241 | status=200, 242 | content_type='application/json') 243 | 244 | response = self.wrapper.test().get() 245 | 246 | self.assertEqual(response().status_code, 200) 247 | 248 | 249 | class TestTapiocaExecutorRequests(unittest.TestCase): 250 | 251 | def setUp(self): 252 | self.wrapper = TesterClient() 253 | 254 | def test_when_executor_has_no_response(self): 255 | with self.assertRaises(Exception) as context: 256 | self.wrapper.test().response 257 | 258 | exception = context.exception 259 | 260 | self.assertIn("has no response", str(exception)) 261 | 262 | @responses.activate 263 | def test_get_request(self): 264 | responses.add(responses.GET, self.wrapper.test().data, 265 | body='{"data": {"key": "value"}}', 266 | status=200, 267 | content_type='application/json') 268 | 269 | response = self.wrapper.test().get() 270 | 271 | self.assertEqual(response().data, {'data': {'key': 'value'}}) 272 | 273 | @responses.activate 274 | def test_access_response_field(self): 275 | responses.add(responses.GET, self.wrapper.test().data, 276 | body='{"data": {"key": "value"}}', 277 | status=200, 278 | content_type='application/json') 279 | 280 | response = self.wrapper.test().get() 281 | 282 | response_data = response.data() 283 | 284 | self.assertEqual(response_data.data, {'key': 'value'}) 285 | 286 | @responses.activate 287 | def test_post_request(self): 288 | responses.add(responses.POST, self.wrapper.test().data, 289 | body='{"data": {"key": "value"}}', 290 | status=201, 291 | content_type='application/json') 292 | 293 | response = self.wrapper.test().post() 294 | 295 | self.assertEqual(response().data, {'data': {'key': 'value'}}) 296 | 297 | @responses.activate 298 | def test_put_request(self): 299 | responses.add(responses.PUT, self.wrapper.test().data, 300 | body='{"data": {"key": "value"}}', 301 | status=201, 302 | content_type='application/json') 303 | 304 | response = self.wrapper.test().put() 305 | 306 | self.assertEqual(response().data, {'data': {'key': 'value'}}) 307 | 308 | @responses.activate 309 | def test_patch_request(self): 310 | responses.add(responses.PATCH, self.wrapper.test().data, 311 | body='{"data": {"key": "value"}}', 312 | status=201, 313 | content_type='application/json') 314 | 315 | response = self.wrapper.test().patch() 316 | 317 | self.assertEqual(response().data, {'data': {'key': 'value'}}) 318 | 319 | @responses.activate 320 | def test_delete_request(self): 321 | responses.add(responses.DELETE, self.wrapper.test().data, 322 | body='{"data": {"key": "value"}}', 323 | status=201, 324 | content_type='application/json') 325 | 326 | response = self.wrapper.test().delete() 327 | 328 | self.assertEqual(response().data, {'data': {'key': 'value'}}) 329 | 330 | @responses.activate 331 | def test_carries_request_kwargs_over_calls(self): 332 | responses.add(responses.GET, self.wrapper.test().data, 333 | body='{"data": {"key": "value"}}', 334 | status=200, 335 | content_type='application/json') 336 | 337 | response = self.wrapper.test().get() 338 | 339 | request_kwargs = response.data.key()._request_kwargs 340 | 341 | self.assertIn('url', request_kwargs) 342 | self.assertIn('data', request_kwargs) 343 | self.assertIn('headers', request_kwargs) 344 | 345 | @responses.activate 346 | def test_thrown_tapioca_exception_with_clienterror_data(self): 347 | responses.add(responses.GET, self.wrapper.test().data, 348 | body='{"error": "bad request test"}', 349 | status=400, 350 | content_type='application/json') 351 | with self.assertRaises(ClientError) as client_exception: 352 | self.wrapper.test().get() 353 | self.assertIn("bad request test", client_exception.exception.args) 354 | 355 | @responses.activate 356 | def test_thrown_tapioca_exception_with_servererror_data(self): 357 | responses.add(responses.GET, self.wrapper.test().data, 358 | body='{"error": "server error test"}', 359 | status=500, 360 | content_type='application/json') 361 | with self.assertRaises(ServerError) as server_exception: 362 | self.wrapper.test().get() 363 | self.assertIn("server error test", server_exception.exception.args) 364 | 365 | 366 | class TestIteratorFeatures(unittest.TestCase): 367 | 368 | def setUp(self): 369 | self.wrapper = TesterClient() 370 | 371 | @responses.activate 372 | def test_simple_pages_iterator(self): 373 | next_url = 'http://api.example.org/next_batch' 374 | 375 | responses.add(responses.GET, self.wrapper.test().data, 376 | body='{"data": [{"key": "value"}], "paging": {"next": "%s"}}' % next_url, 377 | status=200, 378 | content_type='application/json') 379 | 380 | responses.add(responses.GET, next_url, 381 | body='{"data": [{"key": "value"}], "paging": {"next": ""}}', 382 | status=200, 383 | content_type='application/json') 384 | 385 | response = self.wrapper.test().get() 386 | 387 | iterations_count = 0 388 | for item in response().pages(): 389 | self.assertIn(item.key().data, 'value') 390 | iterations_count += 1 391 | 392 | self.assertEqual(iterations_count, 2) 393 | 394 | @responses.activate 395 | def test_simple_pages_with_max_items_iterator(self): 396 | next_url = 'http://api.example.org/next_batch' 397 | 398 | responses.add(responses.GET, self.wrapper.test().data, 399 | body='{"data": [{"key": "value"}], "paging": {"next": "%s"}}' % next_url, 400 | status=200, 401 | content_type='application/json') 402 | 403 | responses.add(responses.GET, next_url, 404 | body='{"data": [{"key": "value"}, {"key": "value"}, {"key": "value"}], "paging": {"next": ""}}', 405 | status=200, 406 | content_type='application/json') 407 | 408 | response = self.wrapper.test().get() 409 | 410 | iterations_count = 0 411 | for item in response().pages(max_items=3, max_pages=2): 412 | self.assertIn(item.key().data, 'value') 413 | iterations_count += 1 414 | 415 | self.assertEqual(iterations_count, 3) 416 | 417 | @responses.activate 418 | def test_simple_pages_with_max_pages_iterator(self): 419 | next_url = 'http://api.example.org/next_batch' 420 | 421 | responses.add(responses.GET, self.wrapper.test().data, 422 | body='{"data": [{"key": "value"}], "paging": {"next": "%s"}}' % next_url, 423 | status=200, 424 | content_type='application/json') 425 | responses.add(responses.GET, next_url, 426 | body='{"data": [{"key": "value"}, {"key": "value"}, {"key": "value"}], "paging": {"next": "%s"}}' % next_url, 427 | status=200, 428 | content_type='application/json') 429 | 430 | responses.add(responses.GET, next_url, 431 | body='{"data": [{"key": "value"}, {"key": "value"}, {"key": "value"}], "paging": {"next": "%s"}}' % next_url, 432 | status=200, 433 | content_type='application/json') 434 | 435 | responses.add(responses.GET, next_url, 436 | body='{"data": [{"key": "value"}, {"key": "value"}, {"key": "value"}], "paging": {"next": ""}}', 437 | status=200, 438 | content_type='application/json') 439 | 440 | response = self.wrapper.test().get() 441 | 442 | iterations_count = 0 443 | for item in response().pages(max_pages=3): 444 | self.assertIn(item.key().data, 'value') 445 | iterations_count += 1 446 | 447 | self.assertEqual(iterations_count, 7) 448 | 449 | @responses.activate 450 | def test_simple_pages_max_page_zero_iterator(self): 451 | next_url = 'http://api.example.org/next_batch' 452 | 453 | responses.add(responses.GET, self.wrapper.test().data, 454 | body='{"data": [{"key": "value"}], "paging": {"next": "%s"}}' % next_url, 455 | status=200, 456 | content_type='application/json') 457 | 458 | responses.add(responses.GET, next_url, 459 | body='{"data": [{"key": "value"}], "paging": {"next": ""}}', 460 | status=200, 461 | content_type='application/json') 462 | 463 | response = self.wrapper.test().get() 464 | 465 | iterations_count = 0 466 | for item in response().pages(max_pages=0): 467 | self.assertIn(item.key().data, 'value') 468 | iterations_count += 1 469 | 470 | self.assertEqual(iterations_count, 0) 471 | 472 | @responses.activate 473 | def test_simple_pages_max_item_zero_iterator(self): 474 | next_url = 'http://api.example.org/next_batch' 475 | 476 | responses.add(responses.GET, self.wrapper.test().data, 477 | body='{"data": [{"key": "value"}], "paging": {"next": "%s"}}' % next_url, 478 | status=200, 479 | content_type='application/json') 480 | 481 | responses.add(responses.GET, next_url, 482 | body='{"data": [{"key": "value"}], "paging": {"next": ""}}', 483 | status=200, 484 | content_type='application/json') 485 | 486 | response = self.wrapper.test().get() 487 | 488 | iterations_count = 0 489 | for item in response().pages(max_items=0): 490 | self.assertIn(item.key().data, 'value') 491 | iterations_count += 1 492 | 493 | self.assertEqual(iterations_count, 0) 494 | 495 | 496 | class TestTokenRefreshing(unittest.TestCase): 497 | 498 | def setUp(self): 499 | self.wrapper = TokenRefreshClient(token='token', refresh_token_by_default=True) 500 | 501 | @responses.activate 502 | def test_not_token_refresh_client_propagates_client_error(self): 503 | no_refresh_client = TesterClient() 504 | 505 | responses.add_callback( 506 | responses.POST, no_refresh_client.test().data, 507 | callback=lambda *a, **k: (401, {}, ''), 508 | content_type='application/json', 509 | ) 510 | 511 | with self.assertRaises(ClientError): 512 | no_refresh_client.test().post() 513 | 514 | @responses.activate 515 | def test_disable_token_refreshing(self): 516 | responses.add_callback( 517 | responses.POST, self.wrapper.test().data, 518 | callback=lambda *a, **k: (401, {}, ''), 519 | content_type='application/json', 520 | ) 521 | 522 | with self.assertRaises(ClientError): 523 | self.wrapper.test().post(refresh_token=False) 524 | 525 | @responses.activate 526 | def test_token_expired_automatically_refresh_authentication(self): 527 | self.first_call = True 528 | 529 | def request_callback(request): 530 | if self.first_call: 531 | self.first_call = False 532 | return (401, {'content_type': 'application/json'}, json.dumps({"error": "Token expired"})) 533 | else: 534 | self.first_call = None 535 | return (201, {'content_type': 'application/json'}, '') 536 | 537 | responses.add_callback( 538 | responses.POST, self.wrapper.test().data, 539 | callback=request_callback, 540 | content_type='application/json', 541 | ) 542 | 543 | response = self.wrapper.test().post() 544 | 545 | # refresh_authentication method should be able to update api_params 546 | self.assertEqual(response._api_params['token'], 'new_token') 547 | 548 | @responses.activate 549 | def test_raises_error_if_refresh_authentication_method_returns_falsy_value(self): 550 | client = FailTokenRefreshClient(token='token', refresh_token_by_default=True) 551 | 552 | self.first_call = True 553 | 554 | def request_callback(request): 555 | if self.first_call: 556 | self.first_call = False 557 | return (401, {}, '') 558 | else: 559 | self.first_call = None 560 | return (201, {}, '') 561 | 562 | responses.add_callback( 563 | responses.POST, client.test().data, 564 | callback=request_callback, 565 | content_type='application/json', 566 | ) 567 | 568 | with self.assertRaises(ClientError): 569 | client.test().post() 570 | 571 | @responses.activate 572 | def test_stores_refresh_authentication_method_response_for_further_access(self): 573 | self.first_call = True 574 | 575 | def request_callback(request): 576 | if self.first_call: 577 | self.first_call = False 578 | return (401, {}, '') 579 | else: 580 | self.first_call = None 581 | return (201, {}, '') 582 | 583 | responses.add_callback( 584 | responses.POST, self.wrapper.test().data, 585 | callback=request_callback, 586 | content_type='application/json', 587 | ) 588 | 589 | response = self.wrapper.test().post() 590 | 591 | self.assertEqual(response().refresh_data, 'new_token') 592 | 593 | 594 | class TestXMLRequests(unittest.TestCase): 595 | 596 | def setUp(self): 597 | self.wrapper = XMLClient() 598 | 599 | @responses.activate 600 | def test_xml_post_string(self): 601 | responses.add(responses.POST, self.wrapper.test().data, 602 | body='Any response', status=200, content_type='application/json') 603 | 604 | data = ('' 605 | 'text1' 606 | 'text2' 607 | '') 608 | 609 | self.wrapper.test().post(data=data) 610 | 611 | request_body = responses.calls[0].request.body 612 | 613 | self.assertEqual(request_body, data.encode('utf-8')) 614 | 615 | @responses.activate 616 | def test_xml_post_dict(self): 617 | responses.add(responses.POST, self.wrapper.test().data, 618 | body='Any response', status=200, content_type='application/json') 619 | 620 | data = OrderedDict([ 621 | ('tag1', OrderedDict([ 622 | ('@attr1', 'val1'), ('tag2', 'text1'), ('tag3', 'text2') 623 | ])) 624 | ]) 625 | 626 | self.wrapper.test().post(data=data) 627 | 628 | request_body = responses.calls[0].request.body 629 | 630 | self.assertEqual(request_body, xmltodict.unparse(data).encode('utf-8')) 631 | 632 | @responses.activate 633 | def test_xml_post_dict_passes_unparse_param(self): 634 | responses.add(responses.POST, self.wrapper.test().data, 635 | body='Any response', status=200, content_type='application/json') 636 | 637 | data = OrderedDict([ 638 | ('tag1', OrderedDict([ 639 | ('@attr1', 'val1'), ('tag2', 'text1'), ('tag3', 'text2') 640 | ])) 641 | ]) 642 | 643 | self.wrapper.test().post(data=data, xmltodict_unparse__full_document=False) 644 | 645 | request_body = responses.calls[0].request.body 646 | 647 | self.assertEqual(request_body, xmltodict.unparse( 648 | data, full_document=False).encode('utf-8')) 649 | 650 | @responses.activate 651 | def test_xml_returns_text_if_response_not_xml(self): 652 | responses.add(responses.POST, self.wrapper.test().data, 653 | body='Any response', status=200, content_type='any content') 654 | 655 | data = OrderedDict([ 656 | ('tag1', OrderedDict([ 657 | ('@attr1', 'val1'), ('tag2', 'text1'), ('tag3', 'text2') 658 | ])) 659 | ]) 660 | 661 | response = self.wrapper.test().post(data=data) 662 | 663 | self.assertEqual('Any response', response().data['text']) 664 | 665 | @responses.activate 666 | def test_xml_post_dict_returns_dict_if_response_xml(self): 667 | xml_body = 'text1' 668 | responses.add(responses.POST, self.wrapper.test().data, 669 | body=xml_body, status=200, 670 | content_type='application/xml') 671 | 672 | data = OrderedDict([ 673 | ('tag1', OrderedDict([ 674 | ('@attr1', 'val1'), ('tag2', 'text1'), ('tag3', 'text2') 675 | ])) 676 | ]) 677 | 678 | response = self.wrapper.test().post(data=data) 679 | 680 | self.assertEqual(response().data, xmltodict.parse(xml_body)) 681 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{38,39,310,311},coverage,flake8 3 | 4 | [testenv] 5 | deps = 6 | mock 7 | requests 8 | responses 9 | setuptools 10 | commands = python setup.py test 11 | 12 | [testenv:coverage] 13 | passenv = COVERALLS_REPO_TOKEN 14 | allowlist_externals = coverage 15 | basepython = python3.8 16 | deps = 17 | {[testenv]deps} 18 | coverage 19 | coveralls 20 | commands = 21 | coverage run --source=tapioca setup.py test 22 | coveralls 23 | 24 | [testenv:flake8] 25 | basepython = python3.8 26 | deps = 27 | {[testenv]deps} 28 | flake8 29 | commands = flake8 30 | 31 | [gh-actions] 32 | python = 33 | 3.8: py38, coverage, flake8 34 | 3.9: py39 35 | 3.10: py310 36 | 3.11: py311 37 | 38 | [flake8] 39 | max-line-length = 100 40 | exclude = docs,.git,__pycache__,.eggs,.tox,htmlcov,tapioca_wrapper.egg-info,.coverage,.pyenv-cache 41 | per-file-ignores = 42 | # imported but unused 43 | __init__.py: F401 44 | # legacy line too long 45 | tests/test_tapioca.py: E501 46 | --------------------------------------------------------------------------------