diff --git a/.gitignore b/.gitignore index fc77599..edcf3b4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,50 @@ -.env/ -env/ -dist/ -htmlcov/ -site/ -.tox/ -Flask_API.egg-info/ +# Temporary Python files *.pyc +*.egg-info __pycache__ -.coverage +.ipynb_checkpoints + +# Temporary OS files +Icon* + +# Temporary virtual environment files +/.cache/ +/.venv/ + +# Temporary server files +.env +*.pid + +# Generated documentation +/docs/gen/ +/docs/apidocs/ +/site/ +/*.html +/*.rst +/docs/*.png + +# Google Drive +*.gdoc +*.gsheet +*.gslides +*.gdraw + +# Testing and coverage results +/.coverage +/.coverage.* +/htmlcov/ +/xmlreport/ +/pyunit.xml +/tmp/ +*.tmp + +# Build and release directories +/build/ +/dist/ +*.spec + +# Sublime Text +*.sublime-workspace + +# Eclipse +.settings diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..77822ca --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,15 @@ +build: + nodes: + analysis: + tests: + override: + - pylint-run --rcfile=.pylint.ini + - py-scrutinizer-run +checks: + python: + code_rating: true + duplicate_code: true +filter: + excluded_paths: + - "*/tests/*" + - "*/static/js/*min.js" diff --git a/.travis.yml b/.travis.yml index 337f200..6fbac08 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,29 +1,41 @@ -language: python -sudo: false +dist: xenial +language: python python: -- 2.7 -- 3.3 -- 3.4 -- 3.5 + - 3.6 + - 3.7 + - 3.8 + +cache: + pip: true + directories: + - .venv env: + global: + - RANDOM_SEED=0 matrix: - - FLASK_VERSION=0.10.1 - # TODO: enable when future release is available: - # http://flask.pocoo.org/docs/0.11/changelog/#version-0-10-2 - # - FLASK_VERSION=0.10.2 - - FLASK_VERSION=0.11.0 + - FLASK_VERSION=2.3.2 + +before_install: + - pip install pipenv install: -- pip install -r requirements.txt -- pip install flask==${FLASK_VERSION} # override version for the build matrix -- pip install coverage==3.6 -- pip install python-coveralls==2.4.0 -- export PYTHONPATH=. + - make install + +before_script: + - pipenv run pip install flask==${FLASK_VERSION} -before_script: coverage erase +script: + - make check + - make test -script: ./runtests +after_success: + - pip install coveralls scrutinizer-ocular + - coveralls + - ocular -after_success: coverage report; coveralls +notifications: + email: + on_success: never + on_failure: never diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d66699e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,69 @@ +# For Contributors + +## Setup + +### Requirements + +* Make: + * Windows: http://mingw.org/download/installer + * Mac: http://developer.apple.com/xcode + * Linux: http://www.gnu.org/software/make +* pipenv: http://docs.pipenv.org + +### Installation + +Install project dependencies into a virtual environment: + +```sh +$ make install +``` + +## Development Tasks + +### Testing + +Manually run the tests: + +```sh +$ make test +``` + +or keep them running on change: + +```sh +$ make watch +``` + +> In order to have OS X notifications, `brew install terminal-notifier`. + +### Documentation + +Build the documentation: + +```sh +$ make docs +``` + +### Static Analysis + +Run linters and static analyzers: + +```sh +$ make check +``` + +## Continuous Integration + +The CI server will report overall build status: + +```sh +$ make ci +``` + +## Release Tasks + +Release to PyPI: + +```sh +$ make upload +``` diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d89b3e6 --- /dev/null +++ b/Makefile @@ -0,0 +1,153 @@ +# Project settings +PROJECT := Flask-API +PACKAGE := flask_api +REPOSITORY := flask-api/flask-api + +# Project paths +PACKAGES := $(PACKAGE) tests +CONFIG := $(wildcard *.py) +MODULES := $(wildcard $(PACKAGE)/*.py) + +# Virtual environment paths +export PIPENV_VENV_IN_PROJECT=true +export PIPENV_IGNORE_VIRTUALENVS=true +VENV := .venv + +# MAIN TASKS ################################################################## + +SNIFFER := pipenv run sniffer + +.PHONY: all +all: install + +.PHONY: ci +ci: check test ## Run all tasks that determine CI status + +.PHONY: watch +watch: install .clean-test ## Continuously run all CI tasks when files chanage + $(SNIFFER) + +.PHONY: run ## Start the program +run: install + pipenv run python $(PACKAGE)/__main__.py + +# PROJECT DEPENDENCIES ######################################################## + +DEPENDENCIES := $(VENV)/.installed + +.PHONY: install +install: $(DEPENDENCIES) + +$(DEPENDENCIES): Pipfile* setup.py + pipenv run python setup.py develop + pipenv install --dev + @ touch $@ + +# CHECKS ###################################################################### + +FLAKE8 := pipenv run flake8 + +.PHONY: check +check: flake8 ## Run linters and static analysis + +.PHONY: flake8 +flake8: install + $(FLAKE8) flask_api --ignore=E128,E501 --exclude=__init__.py + +# TESTS ####################################################################### + +NOSE := pipenv run nosetests +COVERAGE := pipenv run coverage +COVERAGESPACE := pipenv run coveragespace + +RANDOM_SEED ?= $(shell date +%s) + +NOSE_OPTIONS := --with-doctest +ifndef DISABLE_COVERAGE +NOSE_OPTIONS += --with-coverage --cover-package=$(PACKAGE) --cover-erase --cover-html --cover-html-dir=htmlcov --cover-branches +endif + +.PHONY: test +test: install ## Run unit and integration tests + $(NOSE) $(PACKAGE) $(NOSE_OPTIONS) + $(COVERAGESPACE) update overall + +.PHONY: read-coverage +read-coverage: + open htmlcov/index.html + +# DOCUMENTATION ############################################################### + +MKDOCS := pipenv run mkdocs + +MKDOCS_INDEX := site/index.html + +.PHONY: docs +docs: mkdocs ## Generate documentation + +.PHONY: mkdocs +mkdocs: install $(MKDOCS_INDEX) +$(MKDOCS_INDEX): mkdocs.yml docs/*.md + $(MKDOCS) build --clean --strict + +.PHONY: mkdocs-live +mkdocs-live: mkdocs + eval "sleep 3; open http://127.0.0.1:8000" & + $(MKDOCS) serve + +# BUILD ####################################################################### + +DIST_FILES := dist/*.tar.gz dist/*.whl + +.PHONY: dist +dist: install $(DIST_FILES) +$(DIST_FILES): $(MODULES) + rm -f $(DIST_FILES) + pipenv run python setup.py check --restructuredtext --strict --metadata + pipenv run python setup.py sdist + pipenv run python setup.py bdist_wheel + +# RELEASE ###################################################################### + +TWINE := pipenv run twine + +.PHONY: upload +upload: dist ## Upload the current version to PyPI + git diff --name-only --exit-code + $(TWINE) upload dist/*.* + open https://pypi.org/project/$(PROJECT) + +# CLEANUP ##################################################################### + +.PHONY: clean +clean: .clean-build .clean-docs .clean-test .clean-install ## Delete all generated and temporary files + +.PHONY: clean-all +clean-all: clean + rm -rf $(VENV) + +.PHONY: .clean-install +.clean-install: + find $(PACKAGES) -name '*.pyc' -delete + find $(PACKAGES) -name '__pycache__' -delete + rm -rf *.egg-info + +.PHONY: .clean-test +.clean-test: + rm -rf .cache .pytest .coverage htmlcov xmlreport + +.PHONY: .clean-docs +.clean-docs: + rm -rf *.rst docs/apidocs *.html docs/*.png site + +.PHONY: .clean-build +.clean-build: + rm -rf *.spec dist build + +# HELP ######################################################################## + +.PHONY: help +help: all + @ grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +.DEFAULT_GOAL := help diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..69c54e4 --- /dev/null +++ b/Pipfile @@ -0,0 +1,33 @@ +[[source]] + +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +Flask = "*" +Markdown = "<3" + +[dev-packages] + +# Linters +flake8 = "~=3.7.9" + +# Testing +nose = "*" + +# Reports +coveragespace = "~=4.1" + +# Documentation +mkdocs = "~=1.2.3" +docutils = "*" + +# Release +twine = "*" + +# Tooling +sniffer = "*" +pync = { version = "<2.0", sys_platform = "== 'darwin'" } +MacFSEvents = { version = "*", sys_platform = "== 'darwin'" } diff --git a/Pipfile.lock b/Pipfile.lock new file mode 100644 index 0000000..3db0df7 --- /dev/null +++ b/Pipfile.lock @@ -0,0 +1,876 @@ +{ + "_meta": { + "hash": { + "sha256": "662759c6b1e37a84f88b13fc4053a0d627d21897ff7f89cae656649393e94549" + }, + "pipfile-spec": 6, + "requires": {}, + "sources": [ + { + "name": "pypi", + "url": "https://pypi.org/simple", + "verify_ssl": true + } + ] + }, + "default": { + "blinker": { + "hashes": [ + "sha256:4afd3de66ef3a9f8067559fb7a1cbe555c17dcbe15971b05d1b625c3e7abe213", + "sha256:c3d739772abb7bc2860abf5f2ec284223d9ad5c76da018234f6f50d6f31ab1f0" + ], + "markers": "python_version >= '3.7'", + "version": "==1.6.2" + }, + "click": { + "hashes": [ + "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", + "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.3" + }, + "flask": { + "hashes": [ + "sha256:77fd4e1249d8c9923de34907236b747ced06e5467ecac1a7bb7115ae0e9670b0", + "sha256:8c2f9abd47a9e8df7f0c3f091ce9497d011dc3b31effcf4c85a6e2b50f4114ef" + ], + "index": "pypi", + "version": "==2.3.2" + }, + "itsdangerous": { + "hashes": [ + "sha256:2c2349112351b88699d8d4b6b075022c0808887cb7ad10069318a8b0bc88db44", + "sha256:5dbbc68b317e5e42f327f9021763545dc3fc3bfe22e6deb96aaf1fc38874156a" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.2" + }, + "jinja2": { + "hashes": [ + "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", + "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.2" + }, + "markdown": { + "hashes": [ + "sha256:9ba587db9daee7ec761cfc656272be6aabe2ed300fece21208e4aab2e457bc8f", + "sha256:a856869c7ff079ad84a3e19cd87a64998350c2b94e9e08e44270faef33400f81" + ], + "index": "pypi", + "version": "==2.6.11" + }, + "markupsafe": { + "hashes": [ + "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed", + "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc", + "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2", + "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460", + "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7", + "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0", + "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1", + "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa", + "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03", + "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323", + "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65", + "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013", + "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036", + "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f", + "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4", + "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419", + "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2", + "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619", + "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a", + "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a", + "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd", + "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7", + "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666", + "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65", + "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859", + "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625", + "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff", + "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156", + "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd", + "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba", + "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f", + "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1", + "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094", + "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a", + "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513", + "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed", + "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d", + "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3", + "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147", + "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c", + "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603", + "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601", + "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a", + "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1", + "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d", + "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3", + "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54", + "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2", + "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6", + "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.2" + }, + "werkzeug": { + "hashes": [ + "sha256:1d5a58e0377d1fe39d061a5de4469e414e78ccb1e1e59c0f5ad6fa1c36c52b76", + "sha256:48e5e61472fee0ddee27ebad085614ebedb7af41e88f687aaf881afb723a162f" + ], + "markers": "python_version >= '3.8'", + "version": "==2.3.4" + } + }, + "develop": { + "bleach": { + "hashes": [ + "sha256:1a1a85c1595e07d8db14c5f09f09e6433502c51c595970edc090551f0db99414", + "sha256:33c16e3353dbd13028ab4799a0f89a83f113405c766e9c122df8a06f5b85b3f4" + ], + "markers": "python_version >= '3.7'", + "version": "==6.0.0" + }, + "certifi": { + "hashes": [ + "sha256:0f0d56dc5a6ad56fd4ba36484d6cc34451e1c6548c61daad8c320169f91eddc7", + "sha256:c6c2e98f5c7869efca1f8916fed228dd91539f9f1b444c314c06eef02980c716" + ], + "markers": "python_version >= '3.6'", + "version": "==2023.5.7" + }, + "cffi": { + "hashes": [ + "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", + "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", + "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", + "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", + "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", + "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", + "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", + "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", + "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", + "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", + "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", + "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", + "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", + "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", + "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", + "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", + "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", + "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", + "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", + "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", + "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", + "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", + "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", + "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", + "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", + "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", + "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", + "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", + "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", + "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", + "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", + "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", + "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", + "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", + "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", + "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", + "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", + "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", + "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", + "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", + "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", + "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", + "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", + "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", + "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", + "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", + "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", + "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", + "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", + "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", + "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", + "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", + "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", + "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", + "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", + "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", + "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", + "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", + "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", + "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", + "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", + "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", + "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", + "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" + ], + "version": "==1.15.1" + }, + "charset-normalizer": { + "hashes": [ + "sha256:04afa6387e2b282cf78ff3dbce20f0cc071c12dc8f685bd40960cc68644cfea6", + "sha256:04eefcee095f58eaabe6dc3cc2262f3bcd776d2c67005880894f447b3f2cb9c1", + "sha256:0be65ccf618c1e7ac9b849c315cc2e8a8751d9cfdaa43027d4f6624bd587ab7e", + "sha256:0c95f12b74681e9ae127728f7e5409cbbef9cd914d5896ef238cc779b8152373", + "sha256:0ca564606d2caafb0abe6d1b5311c2649e8071eb241b2d64e75a0d0065107e62", + "sha256:10c93628d7497c81686e8e5e557aafa78f230cd9e77dd0c40032ef90c18f2230", + "sha256:11d117e6c63e8f495412d37e7dc2e2fff09c34b2d09dbe2bee3c6229577818be", + "sha256:11d3bcb7be35e7b1bba2c23beedac81ee893ac9871d0ba79effc7fc01167db6c", + "sha256:12a2b561af122e3d94cdb97fe6fb2bb2b82cef0cdca131646fdb940a1eda04f0", + "sha256:12d1a39aa6b8c6f6248bb54550efcc1c38ce0d8096a146638fd4738e42284448", + "sha256:1435ae15108b1cb6fffbcea2af3d468683b7afed0169ad718451f8db5d1aff6f", + "sha256:1c60b9c202d00052183c9be85e5eaf18a4ada0a47d188a83c8f5c5b23252f649", + "sha256:1e8fcdd8f672a1c4fc8d0bd3a2b576b152d2a349782d1eb0f6b8e52e9954731d", + "sha256:20064ead0717cf9a73a6d1e779b23d149b53daf971169289ed2ed43a71e8d3b0", + "sha256:21fa558996782fc226b529fdd2ed7866c2c6ec91cee82735c98a197fae39f706", + "sha256:22908891a380d50738e1f978667536f6c6b526a2064156203d418f4856d6e86a", + "sha256:3160a0fd9754aab7d47f95a6b63ab355388d890163eb03b2d2b87ab0a30cfa59", + "sha256:322102cdf1ab682ecc7d9b1c5eed4ec59657a65e1c146a0da342b78f4112db23", + "sha256:34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5", + "sha256:3573d376454d956553c356df45bb824262c397c6e26ce43e8203c4c540ee0acb", + "sha256:3747443b6a904001473370d7810aa19c3a180ccd52a7157aacc264a5ac79265e", + "sha256:38e812a197bf8e71a59fe55b757a84c1f946d0ac114acafaafaf21667a7e169e", + "sha256:3a06f32c9634a8705f4ca9946d667609f52cf130d5548881401f1eb2c39b1e2c", + "sha256:3a5fc78f9e3f501a1614a98f7c54d3969f3ad9bba8ba3d9b438c3bc5d047dd28", + "sha256:3d9098b479e78c85080c98e1e35ff40b4a31d8953102bb0fd7d1b6f8a2111a3d", + "sha256:3dc5b6a8ecfdc5748a7e429782598e4f17ef378e3e272eeb1340ea57c9109f41", + "sha256:4155b51ae05ed47199dc5b2a4e62abccb274cee6b01da5b895099b61b1982974", + "sha256:49919f8400b5e49e961f320c735388ee686a62327e773fa5b3ce6721f7e785ce", + "sha256:53d0a3fa5f8af98a1e261de6a3943ca631c526635eb5817a87a59d9a57ebf48f", + "sha256:5f008525e02908b20e04707a4f704cd286d94718f48bb33edddc7d7b584dddc1", + "sha256:628c985afb2c7d27a4800bfb609e03985aaecb42f955049957814e0491d4006d", + "sha256:65ed923f84a6844de5fd29726b888e58c62820e0769b76565480e1fdc3d062f8", + "sha256:6734e606355834f13445b6adc38b53c0fd45f1a56a9ba06c2058f86893ae8017", + "sha256:6baf0baf0d5d265fa7944feb9f7451cc316bfe30e8df1a61b1bb08577c554f31", + "sha256:6f4f4668e1831850ebcc2fd0b1cd11721947b6dc7c00bf1c6bd3c929ae14f2c7", + "sha256:6f5c2e7bc8a4bf7c426599765b1bd33217ec84023033672c1e9a8b35eaeaaaf8", + "sha256:6f6c7a8a57e9405cad7485f4c9d3172ae486cfef1344b5ddd8e5239582d7355e", + "sha256:7381c66e0561c5757ffe616af869b916c8b4e42b367ab29fedc98481d1e74e14", + "sha256:73dc03a6a7e30b7edc5b01b601e53e7fc924b04e1835e8e407c12c037e81adbd", + "sha256:74db0052d985cf37fa111828d0dd230776ac99c740e1a758ad99094be4f1803d", + "sha256:75f2568b4189dda1c567339b48cba4ac7384accb9c2a7ed655cd86b04055c795", + "sha256:78cacd03e79d009d95635e7d6ff12c21eb89b894c354bd2b2ed0b4763373693b", + "sha256:80d1543d58bd3d6c271b66abf454d437a438dff01c3e62fdbcd68f2a11310d4b", + "sha256:830d2948a5ec37c386d3170c483063798d7879037492540f10a475e3fd6f244b", + "sha256:891cf9b48776b5c61c700b55a598621fdb7b1e301a550365571e9624f270c203", + "sha256:8f25e17ab3039b05f762b0a55ae0b3632b2e073d9c8fc88e89aca31a6198e88f", + "sha256:9a3267620866c9d17b959a84dd0bd2d45719b817245e49371ead79ed4f710d19", + "sha256:a04f86f41a8916fe45ac5024ec477f41f886b3c435da2d4e3d2709b22ab02af1", + "sha256:aaf53a6cebad0eae578f062c7d462155eada9c172bd8c4d250b8c1d8eb7f916a", + "sha256:abc1185d79f47c0a7aaf7e2412a0eb2c03b724581139193d2d82b3ad8cbb00ac", + "sha256:ac0aa6cd53ab9a31d397f8303f92c42f534693528fafbdb997c82bae6e477ad9", + "sha256:ac3775e3311661d4adace3697a52ac0bab17edd166087d493b52d4f4f553f9f0", + "sha256:b06f0d3bf045158d2fb8837c5785fe9ff9b8c93358be64461a1089f5da983137", + "sha256:b116502087ce8a6b7a5f1814568ccbd0e9f6cfd99948aa59b0e241dc57cf739f", + "sha256:b82fab78e0b1329e183a65260581de4375f619167478dddab510c6c6fb04d9b6", + "sha256:bd7163182133c0c7701b25e604cf1611c0d87712e56e88e7ee5d72deab3e76b5", + "sha256:c36bcbc0d5174a80d6cccf43a0ecaca44e81d25be4b7f90f0ed7bcfbb5a00909", + "sha256:c3af8e0f07399d3176b179f2e2634c3ce9c1301379a6b8c9c9aeecd481da494f", + "sha256:c84132a54c750fda57729d1e2599bb598f5fa0344085dbde5003ba429a4798c0", + "sha256:cb7b2ab0188829593b9de646545175547a70d9a6e2b63bf2cd87a0a391599324", + "sha256:cca4def576f47a09a943666b8f829606bcb17e2bc2d5911a46c8f8da45f56755", + "sha256:cf6511efa4801b9b38dc5546d7547d5b5c6ef4b081c60b23e4d941d0eba9cbeb", + "sha256:d16fd5252f883eb074ca55cb622bc0bee49b979ae4e8639fff6ca3ff44f9f854", + "sha256:d2686f91611f9e17f4548dbf050e75b079bbc2a82be565832bc8ea9047b61c8c", + "sha256:d7fc3fca01da18fbabe4625d64bb612b533533ed10045a2ac3dd194bfa656b60", + "sha256:dd5653e67b149503c68c4018bf07e42eeed6b4e956b24c00ccdf93ac79cdff84", + "sha256:de5695a6f1d8340b12a5d6d4484290ee74d61e467c39ff03b39e30df62cf83a0", + "sha256:e0ac8959c929593fee38da1c2b64ee9778733cdf03c482c9ff1d508b6b593b2b", + "sha256:e1b25e3ad6c909f398df8921780d6a3d120d8c09466720226fc621605b6f92b1", + "sha256:e633940f28c1e913615fd624fcdd72fdba807bf53ea6925d6a588e84e1151531", + "sha256:e89df2958e5159b811af9ff0f92614dabf4ff617c03a4c1c6ff53bf1c399e0e1", + "sha256:ea9f9c6034ea2d93d9147818f17c2a0860d41b71c38b9ce4d55f21b6f9165a11", + "sha256:f645caaf0008bacf349875a974220f1f1da349c5dbe7c4ec93048cdc785a3326", + "sha256:f8303414c7b03f794347ad062c0516cee0e15f7a612abd0ce1e25caf6ceb47df", + "sha256:fca62a8301b605b954ad2e9c3666f9d97f63872aa4efcae5492baca2056b74ab" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.0" + }, + "click": { + "hashes": [ + "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e", + "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48" + ], + "markers": "python_version >= '3.7'", + "version": "==8.1.3" + }, + "colorama": { + "hashes": [ + "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", + "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", + "version": "==0.4.6" + }, + "coverage": { + "hashes": [ + "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f", + "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2", + "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a", + "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a", + "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01", + "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6", + "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7", + "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f", + "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02", + "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c", + "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063", + "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a", + "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5", + "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959", + "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97", + "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6", + "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f", + "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9", + "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5", + "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f", + "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562", + "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe", + "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9", + "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f", + "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb", + "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb", + "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1", + "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb", + "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250", + "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e", + "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511", + "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5", + "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59", + "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2", + "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d", + "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3", + "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4", + "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de", + "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9", + "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833", + "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0", + "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9", + "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d", + "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050", + "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d", + "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6", + "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353", + "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb", + "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e", + "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8", + "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495", + "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2", + "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd", + "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27", + "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1", + "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818", + "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4", + "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e", + "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850", + "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3" + ], + "markers": "python_version >= '3.7'", + "version": "==7.2.7" + }, + "coveragespace": { + "hashes": [ + "sha256:6dcdee802be5cdaa9820538203ad0e182a1ea56c679cdf65b36d4b85937d2e38", + "sha256:a59fa4227166406f74c0fd89ad871b6d699d35de5468eeca96bf556838af0f0c" + ], + "index": "pypi", + "version": "==4.1" + }, + "cryptography": { + "hashes": [ + "sha256:0ddaee209d1cf1f180f1efa338a68c4621154de0afaef92b89486f5f96047c55", + "sha256:14754bcdae909d66ff24b7b5f166d69340ccc6cb15731670435efd5719294895", + "sha256:344c6de9f8bda3c425b3a41b319522ba3208551b70c2ae00099c205f0d9fd3be", + "sha256:34d405ea69a8b34566ba3dfb0521379b210ea5d560fafedf9f800a9a94a41928", + "sha256:3680248309d340fda9611498a5319b0193a8dbdb73586a1acf8109d06f25b92d", + "sha256:3c5ef25d060c80d6d9f7f9892e1d41bb1c79b78ce74805b8cb4aa373cb7d5ec8", + "sha256:4ab14d567f7bbe7f1cdff1c53d5324ed4d3fc8bd17c481b395db224fb405c237", + "sha256:5c1f7293c31ebc72163a9a0df246f890d65f66b4a40d9ec80081969ba8c78cc9", + "sha256:6b71f64beeea341c9b4f963b48ee3b62d62d57ba93eb120e1196b31dc1025e78", + "sha256:7d92f0248d38faa411d17f4107fc0bce0c42cae0b0ba5415505df72d751bf62d", + "sha256:8362565b3835ceacf4dc8f3b56471a2289cf51ac80946f9087e66dc283a810e0", + "sha256:84a165379cb9d411d58ed739e4af3396e544eac190805a54ba2e0322feb55c46", + "sha256:88ff107f211ea696455ea8d911389f6d2b276aabf3231bf72c8853d22db755c5", + "sha256:9f65e842cb02550fac96536edb1d17f24c0a338fd84eaf582be25926e993dde4", + "sha256:a4fc68d1c5b951cfb72dfd54702afdbbf0fb7acdc9b7dc4301bbf2225a27714d", + "sha256:b7f2f5c525a642cecad24ee8670443ba27ac1fab81bba4cc24c7b6b41f2d0c75", + "sha256:b846d59a8d5a9ba87e2c3d757ca019fa576793e8758174d3868aecb88d6fc8eb", + "sha256:bf8fc66012ca857d62f6a347007e166ed59c0bc150cefa49f28376ebe7d992a2", + "sha256:f5d0bf9b252f30a31664b6f64432b4730bb7038339bd18b1fafe129cfc2be9be" + ], + "index": "pypi", + "version": "==41.0.0" + }, + "docopt": { + "hashes": [ + "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491" + ], + "version": "==0.6.2" + }, + "docutils": { + "hashes": [ + "sha256:686577d2e4c32380bb50cbb22f575ed742d58168cee37e99117a854bcd88f125", + "sha256:cf316c8370a737a022b72b56874f6602acf974a37a9fba42ec2876387549fc61" + ], + "index": "pypi", + "version": "==0.17.1" + }, + "entrypoints": { + "hashes": [ + "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19", + "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451" + ], + "markers": "python_version >= '2.7'", + "version": "==0.3" + }, + "flake8": { + "hashes": [ + "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb", + "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca" + ], + "index": "pypi", + "version": "==3.7.9" + }, + "ghp-import": { + "hashes": [ + "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", + "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343" + ], + "version": "==2.1.0" + }, + "idna": { + "hashes": [ + "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4", + "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" + ], + "markers": "python_version >= '3.5'", + "version": "==3.4" + }, + "importlib-metadata": { + "hashes": [ + "sha256:43dd286a2cd8995d5eaef7fee2066340423b818ed3fd70adf0bad5f1fac53fed", + "sha256:92501cdf9cc66ebd3e612f1b4f0c0765dfa42f0fa38ffb319b6bd84dd675d705" + ], + "markers": "python_version >= '3.7'", + "version": "==6.6.0" + }, + "jaraco.classes": { + "hashes": [ + "sha256:2353de3288bc6b82120752201c6b1c1a14b058267fa424ed5ce5984e3b922158", + "sha256:89559fa5c1d3c34eff6f631ad80bb21f378dbcbb35dd161fd2c6b93f5be2f98a" + ], + "markers": "python_version >= '3.7'", + "version": "==3.2.3" + }, + "jeepney": { + "hashes": [ + "sha256:5efe48d255973902f6badc3ce55e2aa6c5c3b3bc642059ef3a91247bcfcc5806", + "sha256:c0a454ad016ca575060802ee4d590dd912e35c122fa04e70306de3d076cce755" + ], + "markers": "sys_platform == 'linux'", + "version": "==0.8.0" + }, + "jinja2": { + "hashes": [ + "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852", + "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61" + ], + "markers": "python_version >= '3.7'", + "version": "==3.1.2" + }, + "keyring": { + "hashes": [ + "sha256:771ed2a91909389ed6148631de678f82ddc73737d85a927f382a8a1b157898cd", + "sha256:ba2e15a9b35e21908d0aaf4e0a47acc52d6ae33444df0da2b49d41a46ef6d678" + ], + "markers": "python_version >= '3.7'", + "version": "==23.13.1" + }, + "macfsevents": { + "hashes": [ + "sha256:1324b66b356051de662ba87d84f73ada062acd42b047ed1246e60a449f833e10" + ], + "markers": "sys_platform == 'darwin'", + "version": "==0.8.1" + }, + "markdown": { + "hashes": [ + "sha256:9ba587db9daee7ec761cfc656272be6aabe2ed300fece21208e4aab2e457bc8f", + "sha256:a856869c7ff079ad84a3e19cd87a64998350c2b94e9e08e44270faef33400f81" + ], + "index": "pypi", + "version": "==2.6.11" + }, + "markupsafe": { + "hashes": [ + "sha256:0576fe974b40a400449768941d5d0858cc624e3249dfd1e0c33674e5c7ca7aed", + "sha256:085fd3201e7b12809f9e6e9bc1e5c96a368c8523fad5afb02afe3c051ae4afcc", + "sha256:090376d812fb6ac5f171e5938e82e7f2d7adc2b629101cec0db8b267815c85e2", + "sha256:0b462104ba25f1ac006fdab8b6a01ebbfbce9ed37fd37fd4acd70c67c973e460", + "sha256:137678c63c977754abe9086a3ec011e8fd985ab90631145dfb9294ad09c102a7", + "sha256:1bea30e9bf331f3fef67e0a3877b2288593c98a21ccb2cf29b74c581a4eb3af0", + "sha256:22152d00bf4a9c7c83960521fc558f55a1adbc0631fbb00a9471e097b19d72e1", + "sha256:22731d79ed2eb25059ae3df1dfc9cb1546691cc41f4e3130fe6bfbc3ecbbecfa", + "sha256:2298c859cfc5463f1b64bd55cb3e602528db6fa0f3cfd568d3605c50678f8f03", + "sha256:28057e985dace2f478e042eaa15606c7efccb700797660629da387eb289b9323", + "sha256:2e7821bffe00aa6bd07a23913b7f4e01328c3d5cc0b40b36c0bd81d362faeb65", + "sha256:2ec4f2d48ae59bbb9d1f9d7efb9236ab81429a764dedca114f5fdabbc3788013", + "sha256:340bea174e9761308703ae988e982005aedf427de816d1afe98147668cc03036", + "sha256:40627dcf047dadb22cd25ea7ecfe9cbf3bbbad0482ee5920b582f3809c97654f", + "sha256:40dfd3fefbef579ee058f139733ac336312663c6706d1163b82b3003fb1925c4", + "sha256:4cf06cdc1dda95223e9d2d3c58d3b178aa5dacb35ee7e3bbac10e4e1faacb419", + "sha256:50c42830a633fa0cf9e7d27664637532791bfc31c731a87b202d2d8ac40c3ea2", + "sha256:55f44b440d491028addb3b88f72207d71eeebfb7b5dbf0643f7c023ae1fba619", + "sha256:608e7073dfa9e38a85d38474c082d4281f4ce276ac0010224eaba11e929dd53a", + "sha256:63ba06c9941e46fa389d389644e2d8225e0e3e5ebcc4ff1ea8506dce646f8c8a", + "sha256:65608c35bfb8a76763f37036547f7adfd09270fbdbf96608be2bead319728fcd", + "sha256:665a36ae6f8f20a4676b53224e33d456a6f5a72657d9c83c2aa00765072f31f7", + "sha256:6d6607f98fcf17e534162f0709aaad3ab7a96032723d8ac8750ffe17ae5a0666", + "sha256:7313ce6a199651c4ed9d7e4cfb4aa56fe923b1adf9af3b420ee14e6d9a73df65", + "sha256:7668b52e102d0ed87cb082380a7e2e1e78737ddecdde129acadb0eccc5423859", + "sha256:7df70907e00c970c60b9ef2938d894a9381f38e6b9db73c5be35e59d92e06625", + "sha256:7e007132af78ea9df29495dbf7b5824cb71648d7133cf7848a2a5dd00d36f9ff", + "sha256:835fb5e38fd89328e9c81067fd642b3593c33e1e17e2fdbf77f5676abb14a156", + "sha256:8bca7e26c1dd751236cfb0c6c72d4ad61d986e9a41bbf76cb445f69488b2a2bd", + "sha256:8db032bf0ce9022a8e41a22598eefc802314e81b879ae093f36ce9ddf39ab1ba", + "sha256:99625a92da8229df6d44335e6fcc558a5037dd0a760e11d84be2260e6f37002f", + "sha256:9cad97ab29dfc3f0249b483412c85c8ef4766d96cdf9dcf5a1e3caa3f3661cf1", + "sha256:a4abaec6ca3ad8660690236d11bfe28dfd707778e2442b45addd2f086d6ef094", + "sha256:a6e40afa7f45939ca356f348c8e23048e02cb109ced1eb8420961b2f40fb373a", + "sha256:a6f2fcca746e8d5910e18782f976489939d54a91f9411c32051b4aab2bd7c513", + "sha256:a806db027852538d2ad7555b203300173dd1b77ba116de92da9afbc3a3be3eed", + "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d", + "sha256:b8526c6d437855442cdd3d87eede9c425c4445ea011ca38d937db299382e6fa3", + "sha256:bb06feb762bade6bf3c8b844462274db0c76acc95c52abe8dbed28ae3d44a147", + "sha256:c0a33bc9f02c2b17c3ea382f91b4db0e6cde90b63b296422a939886a7a80de1c", + "sha256:c4a549890a45f57f1ebf99c067a4ad0cb423a05544accaf2b065246827ed9603", + "sha256:ca244fa73f50a800cf8c3ebf7fd93149ec37f5cb9596aa8873ae2c1d23498601", + "sha256:cf877ab4ed6e302ec1d04952ca358b381a882fbd9d1b07cccbfd61783561f98a", + "sha256:d9d971ec1e79906046aa3ca266de79eac42f1dbf3612a05dc9368125952bd1a1", + "sha256:da25303d91526aac3672ee6d49a2f3db2d9502a4a60b55519feb1a4c7714e07d", + "sha256:e55e40ff0cc8cc5c07996915ad367fa47da6b3fc091fdadca7f5403239c5fec3", + "sha256:f03a532d7dee1bed20bc4884194a16160a2de9ffc6354b3878ec9682bb623c54", + "sha256:f1cd098434e83e656abf198f103a8207a8187c0fc110306691a2e94a78d0abb2", + "sha256:f2bfb563d0211ce16b63c7cb9395d2c682a23187f54c3d79bfec33e6705473c6", + "sha256:f8ffb705ffcf5ddd0e80b65ddf7bed7ee4f5a441ea7d3419e861a12eaf41af58" + ], + "markers": "python_version >= '3.7'", + "version": "==2.1.2" + }, + "mccabe": { + "hashes": [ + "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", + "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" + ], + "version": "==0.6.1" + }, + "mergedeep": { + "hashes": [ + "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", + "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307" + ], + "markers": "python_version >= '3.6'", + "version": "==1.3.4" + }, + "minilog": { + "hashes": [ + "sha256:0c48879cc9e72f0127aa2c36b522dc6fa10fa8532956197436b491d31617d5d5", + "sha256:2048a8d381b36ef5f146fb9a657e627729411f8e2ed0047e2c1286cf8e3e58d7" + ], + "markers": "python_version >= '3.7' and python_version < '4.0'", + "version": "==2.1" + }, + "mkdocs": { + "hashes": [ + "sha256:89f5a094764381cda656af4298727c9f53dc3e602983087e1fe96ea1df24f4c1", + "sha256:a1fa8c2d0c1305d7fc2b9d9f607c71778572a8b110fb26642aa00296c9e6d072" + ], + "index": "pypi", + "version": "==1.2.3" + }, + "more-itertools": { + "hashes": [ + "sha256:cabaa341ad0389ea83c17a94566a53ae4c9d07349861ecb14dc6d0345cf9ac5d", + "sha256:d2bc7f02446e86a68911e58ded76d6561eea00cddfb2a91e7019bbb586c799f3" + ], + "markers": "python_version >= '3.7'", + "version": "==9.1.0" + }, + "nose": { + "hashes": [ + "sha256:9ff7c6cc443f8c51994b34a667bbcf45afd6d945be7477b52e97516fd17c53ac", + "sha256:dadcddc0aefbf99eea214e0f1232b94f2fa9bd98fa8353711dacb112bfcbbb2a", + "sha256:f1bffef9cbc82628f6e7d7b40d7e255aefaa1adb6a1b1d26c69a8b79e6208a98" + ], + "index": "pypi", + "version": "==1.3.7" + }, + "packaging": { + "hashes": [ + "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", + "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" + ], + "markers": "python_version >= '3.7'", + "version": "==23.1" + }, + "pkginfo": { + "hashes": [ + "sha256:4b7a555a6d5a22169fcc9cf7bfd78d296b0361adad412a346c1226849af5e546", + "sha256:8fd5896e8718a4372f0ea9cc9d96f6417c9b986e23a4d116dda26b62cc29d046" + ], + "markers": "python_version >= '3.6'", + "version": "==1.9.6" + }, + "pycodestyle": { + "hashes": [ + "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56", + "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.5.0" + }, + "pycparser": { + "hashes": [ + "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", + "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" + ], + "version": "==2.21" + }, + "pyflakes": { + "hashes": [ + "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0", + "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.1.1" + }, + "pygments": { + "hashes": [ + "sha256:8ace4d3c1dd481894b2005f560ead0f9f19ee64fe983366be1a21e171d12775c", + "sha256:db2db3deb4b4179f399a09054b023b6a586b76499d36965813c71aa8ed7b5fd1" + ], + "markers": "python_version >= '3.7'", + "version": "==2.15.1" + }, + "pync": { + "hashes": [ + "sha256:85737aab9fc69cf59dc9fe831adbe94ac224944c05e297c98de3c2413f253530" + ], + "markers": "sys_platform == 'darwin'", + "version": "==1.6.1" + }, + "python-dateutil": { + "hashes": [ + "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86", + "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.8.2" + }, + "python-termstyle": { + "hashes": [ + "sha256:6faf42ba42f2826c38cf70dacb3ac51f248a418e48afc0e36593df11cf3ab1d2", + "sha256:f42a6bb16fbfc5e2c66d553e7ad46524ea833872f75ee5d827c15115fafc94e2" + ], + "version": "==0.1.10" + }, + "pyyaml": { + "hashes": [ + "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf", + "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293", + "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b", + "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57", + "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b", + "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4", + "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07", + "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba", + "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9", + "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287", + "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513", + "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0", + "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782", + "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0", + "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92", + "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f", + "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2", + "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc", + "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1", + "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c", + "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86", + "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4", + "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c", + "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34", + "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b", + "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d", + "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c", + "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb", + "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7", + "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737", + "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3", + "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d", + "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358", + "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53", + "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78", + "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803", + "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a", + "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f", + "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174", + "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5" + ], + "markers": "python_version >= '3.6'", + "version": "==6.0" + }, + "pyyaml-env-tag": { + "hashes": [ + "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb", + "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069" + ], + "markers": "python_version >= '3.6'", + "version": "==0.1" + }, + "readme-renderer": { + "hashes": [ + "sha256:cd653186dfc73055656f090f227f5cb22a046d7f71a841dfa305f55c9a513273", + "sha256:f67a16caedfa71eef48a31b39708637a6f4664c4394801a7b0d6432d13907343" + ], + "markers": "python_version >= '3.7'", + "version": "==37.3" + }, + "requests": { + "hashes": [ + "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f", + "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" + ], + "markers": "python_version >= '3.7'", + "version": "==2.31.0" + }, + "requests-toolbelt": { + "hashes": [ + "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", + "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.0.0" + }, + "rfc3986": { + "hashes": [ + "sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd", + "sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.0" + }, + "secretstorage": { + "hashes": [ + "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", + "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99" + ], + "markers": "sys_platform == 'linux'", + "version": "==3.3.3" + }, + "setuptools": { + "hashes": [ + "sha256:5df61bf30bb10c6f756eb19e7c9f3b473051f48db77fddbe06ff2ca307df9a6f", + "sha256:62642358adc77ffa87233bc4d2354c4b2682d214048f500964dbe760ccedf102" + ], + "markers": "python_version >= '3.7'", + "version": "==67.8.0" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + }, + "sniffer": { + "hashes": [ + "sha256:b37665053fb83d7790bf9e51d616c11970863d14b5ea5a51155a4e95759d1529", + "sha256:f120843fe152d0e380402fc11313b151e2044c47fdd36895de2efedc8624dbb8" + ], + "index": "pypi", + "version": "==0.4.1" + }, + "tqdm": { + "hashes": [ + "sha256:1871fb68a86b8fb3b59ca4cdd3dcccbc7e6d613eeed31f4c332531977b89beb5", + "sha256:c4f53a17fe37e132815abceec022631be8ffe1b9381c2e6e30aa70edc99e9671" + ], + "markers": "python_version >= '3.7'", + "version": "==4.65.0" + }, + "twine": { + "hashes": [ + "sha256:16f706f2f1687d7ce30e7effceee40ed0a09b7c33b9abb5ef6434e5551565d83", + "sha256:a56c985264b991dc8a8f4234eb80c5af87fa8080d0c224ad8f2cd05a2c22e83b" + ], + "index": "pypi", + "version": "==3.4.1" + }, + "urllib3": { + "hashes": [ + "sha256:61717a1095d7e155cdb737ac7bb2f4324a858a1e2e6466f6d03ff630ca68d3cc", + "sha256:d055c2f9d38dc53c808f6fdc8eab7360b6fdbbde02340ed25cfbcd817c62469e" + ], + "markers": "python_version >= '3.7'", + "version": "==2.0.2" + }, + "watchdog": { + "hashes": [ + "sha256:0e06ab8858a76e1219e68c7573dfeba9dd1c0219476c5a44d5333b01d7e1743a", + "sha256:13bbbb462ee42ec3c5723e1205be8ced776f05b100e4737518c67c8325cf6100", + "sha256:233b5817932685d39a7896b1090353fc8efc1ef99c9c054e46c8002561252fb8", + "sha256:25f70b4aa53bd743729c7475d7ec41093a580528b100e9a8c5b5efe8899592fc", + "sha256:2b57a1e730af3156d13b7fdddfc23dea6487fceca29fc75c5a868beed29177ae", + "sha256:336adfc6f5cc4e037d52db31194f7581ff744b67382eb6021c868322e32eef41", + "sha256:3aa7f6a12e831ddfe78cdd4f8996af9cf334fd6346531b16cec61c3b3c0d8da0", + "sha256:3ed7c71a9dccfe838c2f0b6314ed0d9b22e77d268c67e015450a29036a81f60f", + "sha256:4c9956d27be0bb08fc5f30d9d0179a855436e655f046d288e2bcc11adfae893c", + "sha256:4d98a320595da7a7c5a18fc48cb633c2e73cda78f93cac2ef42d42bf609a33f9", + "sha256:4f94069eb16657d2c6faada4624c39464f65c05606af50bb7902e036e3219be3", + "sha256:5113334cf8cf0ac8cd45e1f8309a603291b614191c9add34d33075727a967709", + "sha256:51f90f73b4697bac9c9a78394c3acbbd331ccd3655c11be1a15ae6fe289a8c83", + "sha256:5d9f3a10e02d7371cd929b5d8f11e87d4bad890212ed3901f9b4d68767bee759", + "sha256:7ade88d0d778b1b222adebcc0927428f883db07017618a5e684fd03b83342bd9", + "sha256:7c5f84b5194c24dd573fa6472685b2a27cc5a17fe5f7b6fd40345378ca6812e3", + "sha256:7e447d172af52ad204d19982739aa2346245cc5ba6f579d16dac4bfec226d2e7", + "sha256:8ae9cda41fa114e28faf86cb137d751a17ffd0316d1c34ccf2235e8a84365c7f", + "sha256:8f3ceecd20d71067c7fd4c9e832d4e22584318983cabc013dbf3f70ea95de346", + "sha256:9fac43a7466eb73e64a9940ac9ed6369baa39b3bf221ae23493a9ec4d0022674", + "sha256:a70a8dcde91be523c35b2bf96196edc5730edb347e374c7de7cd20c43ed95397", + "sha256:adfdeab2da79ea2f76f87eb42a3ab1966a5313e5a69a0213a3cc06ef692b0e96", + "sha256:ba07e92756c97e3aca0912b5cbc4e5ad802f4557212788e72a72a47ff376950d", + "sha256:c07253088265c363d1ddf4b3cdb808d59a0468ecd017770ed716991620b8f77a", + "sha256:c9d8c8ec7efb887333cf71e328e39cffbf771d8f8f95d308ea4125bf5f90ba64", + "sha256:d00e6be486affb5781468457b21a6cbe848c33ef43f9ea4a73b4882e5f188a44", + "sha256:d429c2430c93b7903914e4db9a966c7f2b068dd2ebdd2fa9b9ce094c7d459f33" + ], + "markers": "python_version >= '3.7'", + "version": "==3.0.0" + }, + "webencodings": { + "hashes": [ + "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", + "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923" + ], + "version": "==0.5.1" + }, + "zipp": { + "hashes": [ + "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b", + "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556" + ], + "markers": "python_version >= '3.7'", + "version": "==3.15.0" + } + } +} diff --git a/README.md b/README.md index f73ec7e..29166a3 100644 --- a/README.md +++ b/README.md @@ -1,167 +1,147 @@ -**Status**: Flask API is not currently under active development. This will be reassessed once the [Django REST framework](https://github.com/tomchristie/django-rest-framework) Kickstarter project reaches completion. +# Flask API -# [Flask API](http://www.flaskapi.org) +Browsable web APIs for Flask. -Browsable Web APIs for Flask - +[![Unix Build Status](https://img.shields.io/travis/com/flask-api/flask-api.svg)](https://travis-ci.com/flask-api/flask-api) +[![Coverage Status](https://img.shields.io/coveralls/flask-api/flask-api.svg)](https://coveralls.io/r/flask-api/flask-api) +[![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/flask-api/flask-api.svg)](https://scrutinizer-ci.com/g/flask-api/flask-api/) +[![PyPI Version](https://img.shields.io/pypi/v/Flask-API.svg)](https://pypi.org/project/Flask-API/) -[![Build Status][travis-image]][travis-link] -[![Coverage Status][coveralls-image]][coveralls-link] -[![PyPI Version][pypi-image]][pypi-link] - ---- +**Status**: This project is in maintenance mode. The original author ([Tom Christie](https://twitter.com/_tomchristie)) has shifted his focus to [API Star](https://github.com/encode/apistar). Passing PRs will still be considered for releases by the maintainer ([Jace Browning](https://twitter.com/jacebrowning)). ## Overview -Flask API is an implementation of the same web browsable APIs that [Django REST framework][django-rest-framework] provides. - -It gives you properly content negotiated responses and smart request parsing. - -It is currently a work in progress, but the fundamentals are in place and you can already start building kick-ass browsable Web APIs with it. If you want to start using Flask API right now go ahead and do so, but be sure to follow the release notes of new versions carefully. +Flask API is a drop-in replacement for Flask that provides an implementation of browsable APIs similar to what [Django REST framework](http://www.django-rest-framework.org) offers. It gives you properly content-negotiated responses and smart request parsing: ![Screenshot](docs/screenshot.png) -## Roadmap - -Future work on getting Flask API to a 1.0 release will include: - -* Authentication, including session, basic and token authentication. -* Permissions, including a simple user-is-authenticated permission. -* Throttling, including a base rate throttling implementation. -* Support for using class based views, including the base view class. -* Browsable API improvements, such as breadcrumb generation. -* Customizable exception handling. -* CSRF protection for session authenticated requests. -* Login and logout views for the browsable API. -* Documentation on how to deal with request validation. -* Documentation on how to deal with hyperlinking. - -It is also possible that the core of Flask API could be refactored into an external dependency, in order to make browsable APIs easily available to any Python web framework. - ## Installation Requirements: -* Python 2.7+ or 3.3+ -* Flask 0.10+ +* Python 3.6+ +* Flask 1.1.+ -Install using `pip`. +Install using `pip`: - pip install Flask-API +```shell +$ pip install Flask-API +``` -Import and initialize your application. +Import and initialize your application: - from flask_api import FlaskAPI +```python +from flask_api import FlaskAPI - app = FlaskAPI(__name__) +app = FlaskAPI(__name__) +``` ## Responses Return any valid response object as normal, or return a `list` or `dict`. - @app.route('/example/') - def example(): - return {'hello': 'world'} +```python +@app.route('/example/') +def example(): + return {'hello': 'world'} +``` -A renderer for the response data will be selected using content negotiation based on the client 'Accept' header. If you're making the API request from a regular client, this will default to a JSON response. If you're viewing the API in a browser it'll default to the browsable API HTML. +A renderer for the response data will be selected using content negotiation based on the client 'Accept' header. If you're making the API request from a regular client, this will default to a JSON response. If you're viewing the API in a browser, it'll default to the browsable API HTML. ## Requests Access the parsed request data using `request.data`. This will handle JSON or form data by default. - @app.route('/example/') - def example(): - return {'request data': request.data} +```python +@app.route('/example/') +def example(): + return {'request data': request.data} +``` ## Example The following example demonstrates a simple API for creating, listing, updating and deleting notes. - from flask import request, url_for - from flask_api import FlaskAPI, status, exceptions - - app = FlaskAPI(__name__) - - - notes = { - 0: 'do the shopping', - 1: 'build the codez', - 2: 'paint the door', - } - - def note_repr(key): - return { - 'url': request.host_url.rstrip('/') + url_for('notes_detail', key=key), - 'text': notes[key] - } - - - @app.route("/", methods=['GET', 'POST']) - def notes_list(): - """ - List or create notes. - """ - if request.method == 'POST': - note = str(request.data.get('text', '')) - idx = max(notes.keys()) + 1 - notes[idx] = note - return note_repr(idx), status.HTTP_201_CREATED - - # request.method == 'GET' - return [note_repr(idx) for idx in sorted(notes.keys())] - - - @app.route("//", methods=['GET', 'PUT', 'DELETE']) - def notes_detail(key): - """ - Retrieve, update or delete note instances. - """ - if request.method == 'PUT': - note = str(request.data.get('text', '')) - notes[key] = note - return note_repr(key) - - elif request.method == 'DELETE': - notes.pop(key, None) - return '', status.HTTP_204_NO_CONTENT - - # request.method == 'GET' - if key not in notes: - raise exceptions.NotFound() - return note_repr(key) - - - if __name__ == "__main__": - app.run(debug=True) +```python +from flask import request, url_for +from flask_api import FlaskAPI, status, exceptions -Now run the webapp: +app = FlaskAPI(__name__) - $ python ./example.py - * Running on http://127.0.0.1:5000/ - * Restarting with reloader -You can now open a new tab and interact with the API from the command line: +notes = { + 0: 'do the shopping', + 1: 'build the codez', + 2: 'paint the door', +} - $ curl -X GET http://127.0.0.1:5000/ - [{"url": "http://127.0.0.1:5000/0/", "text": "do the shopping"}, {"url": "http://127.0.0.1:5000/1/", "text": "build the codez"}, {"url": "http://127.0.0.1:5000/2/", "text": "paint the door"}] - $ curl -X GET http://127.0.0.1:5000/1/ - {"url": "http://127.0.0.1:5000/1/", "text": "build the codez"} - $ curl -X PUT http://127.0.0.1:5000/1/ -d text="flask api is teh awesomez" - {"url": "http://127.0.0.1:5000/1/", "text": "flask api is teh awesomez"} +def note_repr(key): + return { + 'url': request.host_url.rstrip('/') + url_for('notes_detail', key=key), + 'text': notes[key] + } -You can also work on the API directly in your browser, by opening . You can then navigate between notes, and make `GET`, `PUT`, `POST` and `DELETE` API requests. -## Credits +@app.route("/", methods=['GET', 'POST']) +def notes_list(): + """ + List or create notes. + """ + if request.method == 'POST': + note = str(request.data.get('text', '')) + idx = max(notes.keys()) + 1 + notes[idx] = note + return note_repr(idx), status.HTTP_201_CREATED + + # request.method == 'GET' + return [note_repr(idx) for idx in sorted(notes.keys())] + -To stay up to date with progress on Flask API, follow [Tom Christie on twitter][tomchristie]. +@app.route("//", methods=['GET', 'PUT', 'DELETE']) +def notes_detail(key): + """ + Retrieve, update or delete note instances. + """ + if request.method == 'PUT': + note = str(request.data.get('text', '')) + notes[key] = note + return note_repr(key) -Many thanks to [Nicolas Clairon][nicolas-clarion] for making the `flask_api` PyPI package available. + elif request.method == 'DELETE': + notes.pop(key, None) + return '', status.HTTP_204_NO_CONTENT -[travis-image]: http://img.shields.io/travis/tomchristie/flask-api/master.svg -[travis-link]: https://travis-ci.org/tomchristie/flask-api -[coveralls-image]: http://img.shields.io/coveralls/tomchristie/flask-api/master.svg -[coveralls-link]: https://coveralls.io/r/tomchristie/flask-api?branch=master -[pypi-image]: http://img.shields.io/pypi/v/flask-api.svg -[pypi-link]: https://pypi.python.org/pypi/flask-api -[django-rest-framework]: http://www.django-rest-framework.org -[tomchristie]: https://twitter.com/_tomchristie -[nicolas-clarion]: https://github.com/namlook/ + # request.method == 'GET' + if key not in notes: + raise exceptions.NotFound() + return note_repr(key) + + +if __name__ == "__main__": + app.run(debug=True) +``` + +Now run the webapp: + +```shell +$ python ./example.py + * Running on http://127.0.0.1:5000/ + * Restarting with reloader +``` + +You can now open a new tab and interact with the API from the command line: + +```shell +$ curl -X GET http://127.0.0.1:5000/ +[{"url": "http://127.0.0.1:5000/0/", "text": "do the shopping"}, + {"url": "http://127.0.0.1:5000/1/", "text": "build the codez"}, + {"url": "http://127.0.0.1:5000/2/", "text": "paint the door"}] + +$ curl -X GET http://127.0.0.1:5000/1/ +{"url": "http://127.0.0.1:5000/1/", "text": "build the codez"} + +$ curl -X PUT http://127.0.0.1:5000/1/ -d text="flask api is teh awesomez" +{"url": "http://127.0.0.1:5000/1/", "text": "flask api is teh awesomez"} +``` + +You can also work on the API directly in your browser, by opening . You can then navigate between notes, and make `GET`, `PUT`, `POST` and `DELETE` API requests. diff --git a/docs/CNAME b/docs/CNAME deleted file mode 100644 index 08b39d2..0000000 --- a/docs/CNAME +++ /dev/null @@ -1 +0,0 @@ -www.flaskapi.org diff --git a/docs/about/release-notes.md b/docs/about/release-notes.md index d47df7d..084d595 100644 --- a/docs/about/release-notes.md +++ b/docs/about/release-notes.md @@ -1,6 +1,28 @@ # Release Notes -This project is currently in beta. It is functional and well tested but you are advised to pay close attention to the release notes when upgrading to future versions. +## Version 3.1 + +- Fixed support for Flask `2.3`. + +## Version 3.0 + +* Dropped support for Flask `<2.0`. + +## Version 2.0 + +* Dropped support for Python `<3.6`. +* Dropped support for Flask `<1.1`. + +## Version 1.1 + +* Added support for custom JSON encoders. +* Added `None` as a valid return value for empty responses (204). +* Dropped support for Flask `<0.12.3` due to [CVE-2018-1000656](https://nvd.nist.gov/vuln/detail/CVE-2018-1000656). +* Added support for `python-markdown` 3+. + +## Version 1.0 + +* Stable release to enter maintenance mode. ## Version 0.7.1 diff --git a/docs/api-guide/status-codes.md b/docs/api-guide/status-codes.md index f72c800..837a774 100644 --- a/docs/api-guide/status-codes.md +++ b/docs/api-guide/status-codes.md @@ -2,7 +2,7 @@ Flask API includes a set of named constants that you can use to make more code more obvious and readable. - from flask.ext.api import status + from flask_api import status ... @@ -15,7 +15,7 @@ The full set of HTTP status codes included in the `status` module is listed belo The module also includes a set of helper functions for testing if a status code is in a given range. - from flask.ext.api import status + from flask_api import status import unittest ... diff --git a/docs/api-guide/templates.md b/docs/api-guide/templates.md new file mode 100644 index 0000000..a55f621 --- /dev/null +++ b/docs/api-guide/templates.md @@ -0,0 +1,23 @@ +# Templates + +Flask API utilizes [blueprints](http://flask.pocoo.org/docs/latest/blueprints/) for managing browsable pages' template path. + +Off the box, it includes a default template. But you would want to customise this for your needs. + +To do that, simply copy `static` and `templates` to your project. + +Then override the previous blueprint with following: + + from flask import Blueprint + from flask_api import FlaskAPI + + theme = Blueprint( + 'flask-api', __name__, + url_prefix='/flask-api', + template_folder='templates', static_folder='static' + ) + + app = FlaskAPI(__name__) + app.blueprints['flask-api'] = theme + +Use `templates/base.html` as your base custom template. Note that this cannot be renamed. \ No newline at end of file diff --git a/docs/img/android-chrome-144x144.png b/docs/img/android-chrome-144x144.png new file mode 100644 index 0000000..bb754b4 Binary files /dev/null and b/docs/img/android-chrome-144x144.png differ diff --git a/docs/img/android-chrome-192x192.png b/docs/img/android-chrome-192x192.png new file mode 100644 index 0000000..8290d95 Binary files /dev/null and b/docs/img/android-chrome-192x192.png differ diff --git a/docs/img/android-chrome-256x256.png b/docs/img/android-chrome-256x256.png new file mode 100644 index 0000000..0fb7aa1 Binary files /dev/null and b/docs/img/android-chrome-256x256.png differ diff --git a/docs/img/android-chrome-36x36.png b/docs/img/android-chrome-36x36.png new file mode 100644 index 0000000..1c8b8c3 Binary files /dev/null and b/docs/img/android-chrome-36x36.png differ diff --git a/docs/img/android-chrome-384x384.png b/docs/img/android-chrome-384x384.png new file mode 100644 index 0000000..14e8162 Binary files /dev/null and b/docs/img/android-chrome-384x384.png differ diff --git a/docs/img/android-chrome-48x48.png b/docs/img/android-chrome-48x48.png new file mode 100644 index 0000000..33ce393 Binary files /dev/null and b/docs/img/android-chrome-48x48.png differ diff --git a/docs/img/android-chrome-512x512.png b/docs/img/android-chrome-512x512.png new file mode 100644 index 0000000..efcfa60 Binary files /dev/null and b/docs/img/android-chrome-512x512.png differ diff --git a/docs/img/android-chrome-72x72.png b/docs/img/android-chrome-72x72.png new file mode 100644 index 0000000..203309f Binary files /dev/null and b/docs/img/android-chrome-72x72.png differ diff --git a/docs/img/android-chrome-96x96.png b/docs/img/android-chrome-96x96.png new file mode 100644 index 0000000..6d0977a Binary files /dev/null and b/docs/img/android-chrome-96x96.png differ diff --git a/docs/img/apple-touch-icon-114x114.png b/docs/img/apple-touch-icon-114x114.png new file mode 100644 index 0000000..3092a66 Binary files /dev/null and b/docs/img/apple-touch-icon-114x114.png differ diff --git a/docs/img/apple-touch-icon-120x120.png b/docs/img/apple-touch-icon-120x120.png new file mode 100644 index 0000000..cabcf5e Binary files /dev/null and b/docs/img/apple-touch-icon-120x120.png differ diff --git a/docs/img/apple-touch-icon-144x144.png b/docs/img/apple-touch-icon-144x144.png new file mode 100644 index 0000000..6000d3e Binary files /dev/null and b/docs/img/apple-touch-icon-144x144.png differ diff --git a/docs/img/apple-touch-icon-152x152.png b/docs/img/apple-touch-icon-152x152.png new file mode 100644 index 0000000..26397cf Binary files /dev/null and b/docs/img/apple-touch-icon-152x152.png differ diff --git a/docs/img/apple-touch-icon-180x180.png b/docs/img/apple-touch-icon-180x180.png new file mode 100644 index 0000000..bacb214 Binary files /dev/null and b/docs/img/apple-touch-icon-180x180.png differ diff --git a/docs/img/apple-touch-icon-57x57.png b/docs/img/apple-touch-icon-57x57.png new file mode 100644 index 0000000..8005a77 Binary files /dev/null and b/docs/img/apple-touch-icon-57x57.png differ diff --git a/docs/img/apple-touch-icon-60x60.png b/docs/img/apple-touch-icon-60x60.png new file mode 100644 index 0000000..5814044 Binary files /dev/null and b/docs/img/apple-touch-icon-60x60.png differ diff --git a/docs/img/apple-touch-icon-72x72.png b/docs/img/apple-touch-icon-72x72.png new file mode 100644 index 0000000..59c9522 Binary files /dev/null and b/docs/img/apple-touch-icon-72x72.png differ diff --git a/docs/img/apple-touch-icon-76x76.png b/docs/img/apple-touch-icon-76x76.png new file mode 100644 index 0000000..9f81006 Binary files /dev/null and b/docs/img/apple-touch-icon-76x76.png differ diff --git a/docs/img/apple-touch-icon.png b/docs/img/apple-touch-icon.png new file mode 100644 index 0000000..7521822 Binary files /dev/null and b/docs/img/apple-touch-icon.png differ diff --git a/docs/img/browserconfig.xml b/docs/img/browserconfig.xml new file mode 100644 index 0000000..7ebe71a --- /dev/null +++ b/docs/img/browserconfig.xml @@ -0,0 +1,12 @@ + + + + + + + + + #e1e1e8 + + + diff --git a/docs/img/favicon-16x16.png b/docs/img/favicon-16x16.png new file mode 100644 index 0000000..36a756b Binary files /dev/null and b/docs/img/favicon-16x16.png differ diff --git a/docs/img/favicon-194x194.png b/docs/img/favicon-194x194.png new file mode 100644 index 0000000..ff2186e Binary files /dev/null and b/docs/img/favicon-194x194.png differ diff --git a/docs/img/favicon-32x32.png b/docs/img/favicon-32x32.png new file mode 100644 index 0000000..1479c3d Binary files /dev/null and b/docs/img/favicon-32x32.png differ diff --git a/docs/img/favicon.ico b/docs/img/favicon.ico new file mode 100644 index 0000000..b8435c6 Binary files /dev/null and b/docs/img/favicon.ico differ diff --git a/docs/img/manifest.json b/docs/img/manifest.json new file mode 100644 index 0000000..e85909a --- /dev/null +++ b/docs/img/manifest.json @@ -0,0 +1,53 @@ +{ + "name": "Flask API", + "icons": [ + { + "src": "./img/android-chrome-36x36.png", + "sizes": "36x36", + "type": "image/png" + }, + { + "src": "./img/android-chrome-48x48.png", + "sizes": "48x48", + "type": "image/png" + }, + { + "src": "./img/android-chrome-72x72.png", + "sizes": "72x72", + "type": "image/png" + }, + { + "src": "./img/android-chrome-96x96.png", + "sizes": "96x96", + "type": "image/png" + }, + { + "src": "./img/android-chrome-144x144.png", + "sizes": "144x144", + "type": "image/png" + }, + { + "src": "./img/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "./img/android-chrome-256x256.png", + "sizes": "256x256", + "type": "image/png" + }, + { + "src": "./img/android-chrome-384x384.png", + "sizes": "384x384", + "type": "image/png" + }, + { + "src": "./img/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "theme_color": "#e1e1e8", + "background_color": "#e1e1e8", + "display": "standalone" +} \ No newline at end of file diff --git a/docs/img/mstile-144x144.png b/docs/img/mstile-144x144.png new file mode 100644 index 0000000..d7cae80 Binary files /dev/null and b/docs/img/mstile-144x144.png differ diff --git a/docs/img/mstile-150x150.png b/docs/img/mstile-150x150.png new file mode 100644 index 0000000..12115a4 Binary files /dev/null and b/docs/img/mstile-150x150.png differ diff --git a/docs/img/mstile-310x150.png b/docs/img/mstile-310x150.png new file mode 100644 index 0000000..d2d64cf Binary files /dev/null and b/docs/img/mstile-310x150.png differ diff --git a/docs/img/mstile-310x310.png b/docs/img/mstile-310x310.png new file mode 100644 index 0000000..6fe3189 Binary files /dev/null and b/docs/img/mstile-310x310.png differ diff --git a/docs/img/mstile-70x70.png b/docs/img/mstile-70x70.png new file mode 100644 index 0000000..7833b6f Binary files /dev/null and b/docs/img/mstile-70x70.png differ diff --git a/docs/img/safari-pinned-tab.svg b/docs/img/safari-pinned-tab.svg new file mode 100644 index 0000000..5ce08d0 --- /dev/null +++ b/docs/img/safari-pinned-tab.svg @@ -0,0 +1,44 @@ + + + + +Created by potrace 1.11, written by Peter Selinger 2001-2013 + + + + + + + + + + + + diff --git a/docs/index.md b/docs/index.md index ef9f084..43831b7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,50 +6,28 @@ Browsable Web APIs for Flask ## Overview -Flask API is an implementation of the same web browsable APIs that [Django REST framework][django-rest-framework] provides. - -It gives you properly content negotiated responses and smart request parsing. - -It is currently a work in progress, but the fundamentals are in place and you can already start building kick-ass browsable Web APIs with it. If you want to start using Flask API right now go ahead and do so, but be sure to follow the release notes of new versions carefully. +Flask API is a drop-in replacement for Flask that provides an implementation of browsable APIs similar to what [Django REST framework](http://www.django-rest-framework.org) provides. It gives you properly content negotiated-responses and smart request parsing: ![Screenshot](screenshot.png) -## Roadmap - -Future work on getting Flask API to a 1.0 release will include: - -* Authentication, including session, basic and token authentication. -* Permissions, including a simple user-is-authenticated permission. -* Throttling, including a base rate throttling implementation. -* Support for using class based views, including the base view class. -* Browsable API improvements, such as breadcrumb generation. -* Customizable exception handling. -* CSRF protection for session authenticated requests. -* Login and logout views for the browsable API. -* Documentation on how to deal with request validation. -* Documentation on how to deal with hyperlinking. - -It is also possible that the core of Flask API could be refactored into an external dependency, in order to make browsable APIs easily available to any Python web framework. - ## Installation Requirements: -* Python 2.7+ or 3.3+ -* Flask 0.10+ +* Python 3.6+ +* Flask 1.1+ The following packages are optional: -* Markdown (2.1.0+) - Markdown support for the browsable API. +* Markdown (`2.6+`): Markdown support for the browsable API -Install using `pip`, including any optional packages you want... +Install using `pip`: pip install Flask-API - pip install markdown # Markdown support for the browsable API. -Import and initialize your application. +Import and initialize your application: - from flask.ext.api import FlaskAPI + from flask_api import FlaskAPI app = FlaskAPI(__name__) @@ -61,7 +39,7 @@ Return any valid response object as normal, or return a `list` or `dict`. def example(): return {'hello': 'world'} -A renderer for the response data will be selected using content negotiation based on the client 'Accept' header. If you're making the API request from a regular client, this will default to a JSON response. If you're viewing the API in a browser it'll default to the browsable API HTML. +A renderer for the response data will be selected using content negotiation based on the client 'Accept' header. If you're making the API request from a regular client, this will default to a JSON response. If you're viewing the API in a browser it'll default to the browsable API HTML. ## Requests @@ -75,62 +53,62 @@ Access the parsed request data using `request.data`. This will handle JSON or f The following example demonstrates a simple API for creating, listing, updating and deleting notes. - from flask import request, url_for - from flask.ext.api import FlaskAPI, status, exceptions - - app = FlaskAPI(__name__) - - - notes = { - 0: 'do the shopping', - 1: 'build the codez', - 2: 'paint the door', - } - - def note_repr(key): - return { - 'url': request.host_url.rstrip('/') + url_for('notes_detail', key=key), - 'text': notes[key] - } - - - @app.route("/", methods=['GET', 'POST']) - def notes_list(): - """ - List or create notes. - """ - if request.method == 'POST': - note = str(request.data.get('text', '')) - idx = max(notes.keys()) + 1 - notes[idx] = note - return note_repr(idx), status.HTTP_201_CREATED - - # request.method == 'GET' - return [note_repr(idx) for idx in sorted(notes.keys())] - - - @app.route("//", methods=['GET', 'PUT', 'DELETE']) - def notes_detail(key): - """ - Retrieve, update or delete note instances. - """ - if request.method == 'PUT': - note = str(request.data.get('text', '')) - notes[key] = note - return note_repr(key) - - elif request.method == 'DELETE': - notes.pop(key, None) - return '', status.HTTP_204_NO_CONTENT - - # request.method == 'GET' - if key not in notes: - raise exceptions.NotFound() - return note_repr(key) - - - if __name__ == "__main__": - app.run(debug=True) + from flask import request, url_for + from flask_api import FlaskAPI, status, exceptions + + app = FlaskAPI(__name__) + + + notes = { + 0: 'do the shopping', + 1: 'build the codez', + 2: 'paint the door', + } + + def note_repr(key): + return { + 'url': request.host_url.rstrip('/') + url_for('notes_detail', key=key), + 'text': notes[key] + } + + + @app.route("/", methods=['GET', 'POST']) + def notes_list(): + """ + List or create notes. + """ + if request.method == 'POST': + note = str(request.data.get('text', '')) + idx = max(notes.keys()) + 1 + notes[idx] = note + return note_repr(idx), status.HTTP_201_CREATED + + # request.method == 'GET' + return [note_repr(idx) for idx in sorted(notes.keys())] + + + @app.route("//", methods=['GET', 'PUT', 'DELETE']) + def notes_detail(key): + """ + Retrieve, update or delete note instances. + """ + if request.method == 'PUT': + note = str(request.data.get('text', '')) + notes[key] = note + return note_repr(key) + + elif request.method == 'DELETE': + notes.pop(key, None) + return '', status.HTTP_204_NO_CONTENT + + # request.method == 'GET' + if key not in notes: + raise exceptions.NotFound() + return note_repr(key) + + + if __name__ == "__main__": + app.run(debug=True) Now run the webapp: @@ -148,17 +126,3 @@ You can now open a new tab and interact with the API from the command line: {"url": "http://127.0.0.1:5000/1/", "text": "flask api is teh awesomez"} You can also work on the API directly in your browser, by opening . You can then navigate between notes, and make `GET`, `PUT`, `POST` and `DELETE` API requests. - -## Credits - -To stay up to date with progress on Flask API, follow Tom Christie on twitter, [here][tomchristie]. - -Many thanks to [Nicolas Clairon][nicolas-clarion] for making the `flask_api` PyPI package available. - -[travis-image]: https://travis-ci.org/tomchristie/flask-api.png?branch=master -[travis-link]: https://travis-ci.org/tomchristie/flask-api -[coveralls-image]: https://coveralls.io/repos/tomchristie/flask-api/badge.png?branch=master -[coveralls-link]: https://coveralls.io/r/tomchristie/flask-api?branch=master -[django-rest-framework]: http://www.django-rest-framework.org -[tomchristie]: https://twitter.com/_tomchristie -[nicolas-clarion]: https://github.com/namlook/ diff --git a/docs/logo.draw.io.xml b/docs/logo.draw.io.xml new file mode 100644 index 0000000..2bb0b6b --- /dev/null +++ b/docs/logo.draw.io.xml @@ -0,0 +1 @@ +7ZdNk6MgEIZ/jXcQNeY4cTO7lznlsGeiHaUGJUXImOyvH1T8iqY2M6VOTe3CQXy7W+FpELRIkF5+SnpMXkQE3LJRdLHID8u2se96+lIoV6Mgb1UpsWSR0Vphx/5A7WjUM4vg1HNUQnDFjn0xFFkGoeppVEqR990OgvffeqQxDIRdSPlQ/c0ilVSq56BW/wUsTtSNYU/D11iKc2ZeZ9nkUJbKnNL6Ucb/lNBI5B2JbC0SSCFU1UovAfCCbU2tinu+Y226LSFTDwWYiDfKz1B3ueyYutYsyuFAEYAssskTpmB3pGFhzXX6tZaolOs7rJsnJcUrBIILWUaTLdbVbyw1TT3SzYFx3vF8LovWY0kjpgdQ2zKRQeEuWkm7o7JonXIWZ1qTVT42ZkQgFVzuYsENbD2JQaSg5FW7mPlapzMfyX3SzTsyIjXzLW4e1ULXDcN9PAf23CloSPVTgIcpsMOiLsT6b3CdCdiSj7DFn2Ab7kfZjkxvWHvIXS3E1gSQ+jtj5rU/nNfYngm9Mzd6hFyyf+jLgpBPgqdl0Tv+16F350YPWNeH0B9Wuq4XRt8nj70hetebCb33H30H/dhGOorenwD96hujv3PkmT4j5NHFMMlq8Oc+2nzidLngasA326+DhvAbberlsP63999b9q49ZO9Pswno2/aPrbR1fovJ9h0= \ No newline at end of file diff --git a/docs/screenshot.png b/docs/screenshot.png index 9dba138..5d01222 100644 Binary files a/docs/screenshot.png and b/docs/screenshot.png differ diff --git a/example.py b/example.py index 0680147..4b5511c 100644 --- a/example.py +++ b/example.py @@ -1,29 +1,30 @@ from flask import request, url_for -from flask.ext.api import FlaskAPI, status, exceptions +from flask.ext.api import FlaskAPI, exceptions, status app = FlaskAPI(__name__) notes = { - 0: 'do the shopping', - 1: 'build the codez', - 2: 'paint the door', + 0: "do the shopping", + 1: "build the codez", + 2: "paint the door", } + def note_repr(key): return { - 'url': request.host_url.rstrip('/') + url_for('notes_detail', key=key), - 'text': notes[key] + "url": request.host_url.rstrip("/") + url_for("notes_detail", key=key), + "text": notes[key], } -@app.route("/", methods=['GET', 'POST']) +@app.route("/", methods=["GET", "POST"]) def notes_list(): """ List or create notes. """ - if request.method == 'POST': - note = str(request.data.get('text', '')) + if request.method == "POST": + note = str(request.data.get("text", "")) idx = max(notes.keys()) + 1 notes[idx] = note return note_repr(idx), status.HTTP_201_CREATED @@ -32,19 +33,19 @@ def notes_list(): return [note_repr(idx) for idx in sorted(notes.keys())] -@app.route("//", methods=['GET', 'PUT', 'DELETE']) +@app.route("//", methods=["GET", "PUT", "DELETE"]) def notes_detail(key): """ Retrieve, update or delete note instances. """ - if request.method == 'PUT': - note = str(request.data.get('text', '')) + if request.method == "PUT": + note = str(request.data.get("text", "")) notes[key] = note return note_repr(key) - elif request.method == 'DELETE': + elif request.method == "DELETE": notes.pop(key, None) - return '', status.HTTP_204_NO_CONTENT + return "", status.HTTP_204_NO_CONTENT # request.method == 'GET' if key not in notes: diff --git a/flask_api/__init__.py b/flask_api/__init__.py index 9202151..7d4d782 100644 --- a/flask_api/__init__.py +++ b/flask_api/__init__.py @@ -1,3 +1,3 @@ from flask_api.app import FlaskAPI -__version__ = '0.7.1' +__version__ = "3.1" diff --git a/flask_api/app.py b/flask_api/app.py index fbffd20..5556445 100644 --- a/flask_api/app.py +++ b/flask_api/app.py @@ -1,22 +1,23 @@ -# coding: utf8 -from __future__ import unicode_literals -from flask import request, Flask, Blueprint -from flask._compat import reraise, string_types, text_type +import re +import sys +from itertools import chain + +from flask import Blueprint, Flask, request +from werkzeug.exceptions import HTTPException + +from flask_api.compat import is_flask_legacy from flask_api.exceptions import APIException from flask_api.request import APIRequest from flask_api.response import APIResponse from flask_api.settings import APISettings -from itertools import chain -from werkzeug.exceptions import HTTPException -import re -import sys -from flask_api.compat import is_flask_legacy - +from flask_api.status import HTTP_204_NO_CONTENT api_resources = Blueprint( - 'flask-api', __name__, - url_prefix='/flask-api', - template_folder='templates', static_folder='static' + "flask-api", + __name__, + url_prefix="/flask-api", + template_folder="templates", + static_folder="static", ) @@ -29,15 +30,15 @@ class FlaskAPI(Flask): response_class = APIResponse def __init__(self, *args, **kwargs): - super(FlaskAPI, self).__init__(*args, **kwargs) + super().__init__(*args, **kwargs) self.api_settings = APISettings(self.config) self.register_blueprint(api_resources) - self.jinja_env.filters['urlize_quoted_links'] = urlize_quoted_links + self.jinja_env.filters["urlize_quoted_links"] = urlize_quoted_links def preprocess_request(self): request.parser_classes = self.api_settings.DEFAULT_PARSERS request.renderer_classes = self.api_settings.DEFAULT_RENDERERS - return super(FlaskAPI, self).preprocess_request() + return super().preprocess_request() def make_response(self, rv): """ @@ -48,14 +49,17 @@ def make_response(self, rv): if isinstance(rv, tuple): rv, status_or_headers, headers = rv + (None,) * (3 - len(rv)) + if rv is None and status_or_headers == HTTP_204_NO_CONTENT: + rv = "" + if rv is None and status_or_headers: - raise ValueError('View function did not return a response') + raise ValueError("View function did not return a response") if isinstance(status_or_headers, (dict, list)): headers, status_or_headers = status_or_headers, None if not isinstance(rv, self.response_class): - if isinstance(rv, (text_type, bytes, bytearray, list, dict)): + if isinstance(rv, (str, bytes, bytearray, list, dict)): status = status_or_headers rv = self.response_class(rv, headers=headers, status=status) headers = status_or_headers = None @@ -63,7 +67,7 @@ def make_response(self, rv): rv = self.response_class.force_type(rv, request.environ) if status_or_headers is not None: - if isinstance(status_or_headers, string_types): + if isinstance(status_or_headers, str): rv.status = status_or_headers else: rv.status_code = status_or_headers @@ -95,15 +99,18 @@ def handle_user_exception(self, e): if isinstance(e, typecheck): return handler(e) else: - for typecheck, handler in chain(dict(blueprint_handlers).items(), - dict(app_handlers).items()): + for typecheck, handler in chain( + dict(blueprint_handlers).items(), dict(app_handlers).items() + ): if isinstance(e, typecheck): return handler(e) - reraise(exc_type, exc_value, tb) + raise e def handle_api_exception(self, exc): - return APIResponse({'message': exc.detail}, status=exc.status_code) + content = {"message": exc.detail} + status = exc.status_code + return self.response_class(content, status=status) def create_url_adapter(self, request): """ @@ -114,13 +121,15 @@ def create_url_adapter(self, request): """ if request is not None: environ = request.environ.copy() - environ['REQUEST_METHOD'] = request.method - return self.url_map.bind_to_environ(environ, - server_name=self.config['SERVER_NAME']) + environ["REQUEST_METHOD"] = request.method + return self.url_map.bind_to_environ( + environ, server_name=self.config["SERVER_NAME"] + ) # We need at the very least the server name to be set for this # to work. - if self.config['SERVER_NAME'] is not None: + if self.config["SERVER_NAME"] is not None: return self.url_map.bind( - self.config['SERVER_NAME'], - script_name=self.config['APPLICATION_ROOT'] or '/', - url_scheme=self.config['PREFERRED_URL_SCHEME']) + self.config["SERVER_NAME"], + script_name=self.config["APPLICATION_ROOT"] or "/", + url_scheme=self.config["PREFERRED_URL_SCHEME"], + ) diff --git a/flask_api/compat.py b/flask_api/compat.py index 525884f..8760df5 100644 --- a/flask_api/compat.py +++ b/flask_api/compat.py @@ -1,10 +1,9 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals, absolute_import from flask import __version__ as flask_version # Markdown is optional try: import markdown + from markdown.extensions.toc import TocExtension def apply_markdown(text): """ @@ -12,12 +11,11 @@ def apply_markdown(text): of '#' style headers to

. """ - extensions = ['headerid(level=2)'] + extensions = [TocExtension(baselevel=2)] md = markdown.Markdown(extensions=extensions) return md.convert(text) - -except ImportError: +except ImportError: # pragma: no cover - markdown installed for tests apply_markdown = None diff --git a/flask_api/decorators.py b/flask_api/decorators.py index d3a09b2..6b9f5b8 100644 --- a/flask_api/decorators.py +++ b/flask_api/decorators.py @@ -1,4 +1,5 @@ from functools import wraps + from flask import request @@ -11,7 +12,9 @@ def decorated_function(*args, **kwargs): else: request.parser_classes = parsers return func(*args, **kwargs) + return decorated_function + return decorator @@ -24,5 +27,7 @@ def decorated_function(*args, **kwargs): else: request.renderer_classes = renderers return func(*args, **kwargs) + return decorated_function + return decorator diff --git a/flask_api/exceptions.py b/flask_api/exceptions.py index 27b261d..0ac0df1 100644 --- a/flask_api/exceptions.py +++ b/flask_api/exceptions.py @@ -1,10 +1,9 @@ -from __future__ import unicode_literals from flask_api import status class APIException(Exception): status_code = status.HTTP_500_INTERNAL_SERVER_ERROR - detail = '' + detail = "" def __init__(self, detail=None): if detail is not None: @@ -16,27 +15,28 @@ def __str__(self): class ParseError(APIException): status_code = status.HTTP_400_BAD_REQUEST - detail = 'Malformed request.' + detail = "Malformed request." class AuthenticationFailed(APIException): status_code = status.HTTP_401_UNAUTHORIZED - detail = 'Incorrect authentication credentials.' + detail = "Incorrect authentication credentials." class NotAuthenticated(APIException): status_code = status.HTTP_401_UNAUTHORIZED - detail = 'Authentication credentials were not provided.' + detail = "Authentication credentials were not provided." class PermissionDenied(APIException): status_code = status.HTTP_403_FORBIDDEN - detail = 'You do not have permission to perform this action.' + detail = "You do not have permission to perform this action." class NotFound(APIException): status_code = status.HTTP_404_NOT_FOUND - detail = 'This resource does not exist.' + detail = "This resource does not exist." + # class MethodNotAllowed(APIException): # status_code = status.HTTP_405_METHOD_NOT_ALLOWED @@ -48,17 +48,18 @@ class NotFound(APIException): class NotAcceptable(APIException): status_code = status.HTTP_406_NOT_ACCEPTABLE - detail = 'Could not satisfy the request Accept header.' + detail = "Could not satisfy the request Accept header." class UnsupportedMediaType(APIException): status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE - detail = 'Unsupported media type in the request Content-Type header.' + detail = "Unsupported media type in the request Content-Type header." class Throttled(APIException): status_code = status.HTTP_429_TOO_MANY_REQUESTS - detail = 'Request was throttled.' + detail = "Request was throttled." + # def __init__(self, wait=None, detail=None): # if wait is None: diff --git a/flask_api/mediatypes.py b/flask_api/mediatypes.py index eac96e8..e159b37 100644 --- a/flask_api/mediatypes.py +++ b/flask_api/mediatypes.py @@ -1,14 +1,10 @@ -# coding: utf8 -from __future__ import unicode_literals - - -class MediaType(object): +class MediaType: def __init__(self, media_type): self.main_type, self.sub_type, self.params = self._parse(media_type) @property def full_type(self): - return self.main_type + '/' + self.sub_type + return self.main_type + "/" + self.sub_type @property def precedence(self): @@ -20,11 +16,11 @@ def precedence(self): 1. 'type/*' 0. '*/*' """ - if self.main_type == '*': + if self.main_type == "*": return 0 - elif self.sub_type == '*': + elif self.sub_type == "*": return 1 - elif not self.params or list(self.params.keys()) == ['q']: + elif not self.params or list(self.params.keys()) == ["q"]: return 2 return 3 @@ -39,13 +35,21 @@ def satisfies(self, other): '*/*' >= 'text/plain' """ for key in self.params.keys(): - if key != 'q' and other.params.get(key, None) != self.params.get(key, None): + if key != "q" and other.params.get(key, None) != self.params.get(key, None): return False - if self.sub_type != '*' and other.sub_type != '*' and other.sub_type != self.sub_type: + if ( + self.sub_type != "*" + and other.sub_type != "*" + and other.sub_type != self.sub_type + ): return False - if self.main_type != '*' and other.main_type != '*' and other.main_type != self.main_type: + if ( + self.main_type != "*" + and other.main_type != "*" + and other.main_type != self.main_type + ): return False return True @@ -55,15 +59,15 @@ def _parse(self, media_type): Parse a media type string, like "application/json; indent=4" into a three-tuple, like: ('application', 'json', {'indent': 4}) """ - full_type, sep, param_string = media_type.partition(';') + full_type, sep, param_string = media_type.partition(";") params = {} - for token in param_string.strip().split(','): - key, sep, value = [s.strip() for s in token.partition('=')] + for token in param_string.strip().split(","): + key, sep, value = [s.strip() for s in token.partition("=")] if value.startswith('"') and value.endswith('"'): value = value[1:-1] if key: params[key] = value - main_type, sep, sub_type = [s.strip() for s in full_type.partition('/')] + main_type, sep, sub_type = [s.strip() for s in full_type.partition("/")] return (main_type, sub_type, params) def __repr__(self): @@ -75,11 +79,10 @@ def __str__(self): Note that this ensures the params are sorted. """ if self.params: - params_str = ', '.join([ - '%s="%s"' % (key, val) - for key, val in sorted(self.params.items()) - ]) - return self.full_type + '; ' + params_str + params_str = ", ".join( + ['%s="%s"' % (key, val) for key, val in sorted(self.params.items())] + ) + return self.full_type + "; " + params_str return self.full_type def __hash__(self): @@ -87,10 +90,7 @@ def __hash__(self): def __eq__(self, other): # Compare two MediaType instances, ignoring parameter ordering. - return ( - self.full_type == other.full_type and - self.params == other.params - ) + return self.full_type == other.full_type and self.params == other.params def parse_accept_header(accept): @@ -106,7 +106,7 @@ def parse_accept_header(accept): ] """ ret = [set(), set(), set(), set()] - for token in accept.split(','): + for token in accept.split(","): media_type = MediaType(token.strip()) ret[3 - media_type.precedence].add(media_type) return [media_types for media_types in ret if media_types] diff --git a/flask_api/negotiation.py b/flask_api/negotiation.py index 9ce0549..299d3a2 100644 --- a/flask_api/negotiation.py +++ b/flask_api/negotiation.py @@ -1,11 +1,10 @@ -# coding: utf8 -from __future__ import unicode_literals from flask import request + from flask_api import exceptions from flask_api.mediatypes import MediaType, parse_accept_header -class BaseNegotiation(object): +class BaseNegotiation: def select_parser(self, parsers): msg = '`select_parser()` method must be implemented for class "%s"' raise NotImplementedError(msg % self.__class__.__name__) @@ -36,7 +35,7 @@ def select_renderer(self, renderers): Determine which renderer to use for rendering the response body. Returns a two-tuple of (renderer, content type). """ - accept_header = request.headers.get('Accept', '*/*') + accept_header = request.headers.get("Accept", "*/*") for client_media_types in parse_accept_header(accept_header): for renderer in renderers: diff --git a/flask_api/parsers.py b/flask_api/parsers.py index de08c02..56f45f6 100644 --- a/flask_api/parsers.py +++ b/flask_api/parsers.py @@ -1,17 +1,16 @@ -# coding: utf8 -from __future__ import unicode_literals -from flask._compat import text_type -from flask_api import exceptions +import json + from werkzeug.formparser import MultiPartParser as WerkzeugMultiPartParser from werkzeug.formparser import default_stream_factory from werkzeug.urls import url_decode_stream -import json + +from flask_api import exceptions -class BaseParser(object): +class BaseParser: media_type = None handles_file_uploads = False # If set then 'request.files' will be populated. - handles_form_data = False # If set then 'request.form' will be populated. + handles_form_data = False # If set then 'request.form' will be populated. def parse(self, stream, media_type, **options): msg = '`parse()` method must be implemented for class "%s"' @@ -19,43 +18,50 @@ def parse(self, stream, media_type, **options): class JSONParser(BaseParser): - media_type = 'application/json' + media_type = "application/json" def parse(self, stream, media_type, **options): - data = stream.read().decode('utf-8') + data = stream.read().decode("utf-8") try: return json.loads(data) except ValueError as exc: - msg = 'JSON parse error - %s' % text_type(exc) + msg = "JSON parse error - %s" % str(exc) raise exceptions.ParseError(msg) class MultiPartParser(BaseParser): - media_type = 'multipart/form-data' + media_type = "multipart/form-data" handles_file_uploads = True handles_form_data = True def parse(self, stream, media_type, **options): - multipart_parser = WerkzeugMultiPartParser(default_stream_factory) - - boundary = media_type.params.get('boundary') + boundary = media_type.params.get("boundary") if boundary is None: - msg = 'Multipart message missing boundary in Content-Type header' + msg = "Multipart message missing boundary in Content-Type header" raise exceptions.ParseError(msg) - boundary = boundary.encode('ascii') + boundary = boundary.encode("ascii") + + content_length = options.get("content_length") + assert ( + content_length is not None + ), "MultiPartParser.parse() requires `content_length` argument" - content_length = options.get('content_length') - assert content_length is not None, 'MultiPartParser.parse() requires `content_length` argument' + buffer_size = content_length + while buffer_size % 4 or buffer_size < 1024: + buffer_size += 1 + multipart_parser = WerkzeugMultiPartParser( + default_stream_factory, buffer_size=buffer_size + ) try: return multipart_parser.parse(stream, boundary, content_length) except ValueError as exc: - msg = 'Multipart parse error - %s' % text_type(exc) + msg = "Multipart parse error - %s" % str(exc) raise exceptions.ParseError(msg) class URLEncodedParser(BaseParser): - media_type = 'application/x-www-form-urlencoded' + media_type = "application/x-www-form-urlencoded" handles_form_data = True def parse(self, stream, media_type, **options): diff --git a/flask_api/renderers.py b/flask_api/renderers.py index debda8a..a9aed23 100644 --- a/flask_api/renderers.py +++ b/flask_api/renderers.py @@ -1,14 +1,12 @@ -# coding: utf8 -from __future__ import unicode_literals -from flask import request, render_template, current_app -from flask.json import JSONEncoder -from flask.globals import _request_ctx_stack -from flask_api.mediatypes import MediaType -from flask_api.compat import apply_markdown -import json import pydoc import re +from flask import current_app, render_template, request +from flask.globals import _request_ctx_stack + +from flask_api.compat import apply_markdown +from flask_api.mediatypes import MediaType + def dedent(content): """ @@ -19,26 +17,29 @@ def dedent(content): as it fails to dedent multiline docstrings that include unindented text on the initial line. """ - whitespace_counts = [len(line) - len(line.lstrip(' ')) - for line in content.splitlines()[1:] if line.lstrip()] + whitespace_counts = [ + len(line) - len(line.lstrip(" ")) + for line in content.splitlines()[1:] + if line.lstrip() + ] # unindent the content if needed if whitespace_counts: - whitespace_pattern = '^' + (' ' * min(whitespace_counts)) - content = re.sub(re.compile(whitespace_pattern, re.MULTILINE), '', content) + whitespace_pattern = "^" + (" " * min(whitespace_counts)) + content = re.sub(re.compile(whitespace_pattern, re.MULTILINE), "", content) return content.strip() def convert_to_title(name): - for char in ['-', '_', '.']: - name = name.replace(char, ' ') + for char in ["-", "_", "."]: + name = name.replace(char, " ") return name.capitalize() -class BaseRenderer(object): +class BaseRenderer: media_type = None - charset = 'utf-8' + charset = "utf-8" handles_empty_responses = False def render(self, data, media_type, **options): @@ -47,44 +48,47 @@ def render(self, data, media_type, **options): class JSONRenderer(BaseRenderer): - media_type = 'application/json' + media_type = "application/json" charset = None def render(self, data, media_type, **options): # Requested indentation may be set in the Accept header. try: - indent = max(min(int(media_type.params['indent']), 8), 0) + indent = max(min(int(media_type.params["indent"]), 8), 0) except (KeyError, ValueError, TypeError): indent = None # Indent may be set explicitly, eg when rendered by the browsable API. - indent = options.get('indent', indent) - return json.dumps(data, cls=JSONEncoder, ensure_ascii=False, indent=indent) + indent = options.get("indent", indent) + return current_app.json.dumps( + data, ensure_ascii=False, indent=indent + ) -class HTMLRenderer(object): - media_type = 'text/html' - charset = 'utf-8' +class HTMLRenderer: + media_type = "text/html" + charset = "utf-8" def render(self, data, media_type, **options): return data.encode(self.charset) class BrowsableAPIRenderer(BaseRenderer): - media_type = 'text/html' + media_type = "text/html" handles_empty_responses = True - template = 'base.html' + template = "base.html" def render(self, data, media_type, **options): # Render the content as it would have been if the client # had requested 'Accept: */*'. available_renderers = [ - renderer for renderer in request.renderer_classes + renderer + for renderer in request.renderer_classes if not issubclass(renderer, BrowsableAPIRenderer) ] - assert available_renderers, 'BrowsableAPIRenderer cannot be the only renderer' + assert available_renderers, "BrowsableAPIRenderer cannot be the only renderer" mock_renderer = available_renderers[0]() mock_media_type = MediaType(mock_renderer.media_type) - if data == '' and not mock_renderer.handles_empty_responses: + if data == "" and not mock_renderer.handles_empty_responses: mock_content = None else: text = mock_renderer.render(data, mock_media_type, indent=4) @@ -97,37 +101,34 @@ def render(self, data, media_type, **options): endpoint = request.url_rule.endpoint view_name = str(endpoint) view_description = current_app.view_functions[endpoint].__doc__ - if apply_markdown is None and view_description: - view_description = dedent(view_description) - view_description = pydoc.html.preformat(view_description) - elif apply_markdown is not None and view_description: - view_description = dedent(view_description) - view_description = apply_markdown(view_description) - - status = options['status'] - headers = options['headers'] - headers['Content-Type'] = str(mock_media_type) + if view_description: + if apply_markdown: + view_description = dedent(view_description) + view_description = apply_markdown(view_description) + else: # pragma: no cover - markdown installed for tests + view_description = dedent(view_description) + view_description = pydoc.html.preformat(view_description) + + status = options["status"] + headers = options["headers"] + headers["Content-Type"] = str(mock_media_type) from flask_api import __version__ context = { - 'status': status, - 'headers': headers, - 'content': mock_content, - 'allowed_methods': allowed_methods, - 'view_name': convert_to_title(view_name), - 'view_description': view_description, - 'version': __version__ + "status": status, + "headers": headers, + "content": mock_content, + "allowed_methods": allowed_methods, + "view_name": convert_to_title(view_name), + "view_description": view_description, + "version": __version__, } return render_template(self.template, **context) @staticmethod def _html_escape(text): - escape_table = [ - ("&", "&"), - ("<", "<"), - (">", ">") - ] + escape_table = [("&", "&"), ("<", "<"), (">", ">")] for char, replacement in escape_table: text = text.replace(char, replacement) diff --git a/flask_api/request.py b/flask_api/request.py index aee96ce..33c3eaa 100644 --- a/flask_api/request.py +++ b/flask_api/request.py @@ -1,13 +1,12 @@ -# coding: utf8 -from __future__ import unicode_literals +import io + from flask import Request -from flask_api.negotiation import DefaultNegotiation -from flask_api.settings import default_settings from werkzeug.datastructures import MultiDict from werkzeug.urls import url_decode_stream from werkzeug.wsgi import get_content_length -from werkzeug._compat import to_unicode -import io + +from flask_api.negotiation import DefaultNegotiation +from flask_api.settings import default_settings class APIRequest(Request): @@ -20,25 +19,25 @@ class APIRequest(Request): @property def data(self): - if not hasattr(self, '_data'): + if not hasattr(self, "_data"): self._parse() return self._data @property def form(self): - if not hasattr(self, '_form'): + if not hasattr(self, "_form"): self._parse() return self._form @property def files(self): - if not hasattr(self, '_files'): + if not hasattr(self, "_files"): self._parse() return self._files def _parse(self): """ - Parse the body of the request, using whichever parser satifies the + Parse the body of the request, using whichever parser satisfies the client 'Content-Type' header. """ if not self.content_type or not self.content_length: @@ -51,14 +50,16 @@ def _parse(self): try: parser, media_type = negotiator.select_parser(parsers) ret = parser.parse(self.stream, media_type, **options) - except: + except Exception as e: # Ensure that accessing `request.data` again does not reraise # the exception, so that eg exceptions can handle properly. self._set_empty_data() - raise + raise e from None if parser.handles_file_uploads: - assert isinstance(ret, tuple) and len(ret) == 2, 'Expected a two-tuple of (data, files)' + assert ( + isinstance(ret, tuple) and len(ret) == 2 + ), "Expected a two-tuple of (data, files)" self._data, self._files = ret else: self._data = ret @@ -70,7 +71,7 @@ def _get_parser_options(self): """ Any additional information to pass to the parser. """ - return {'content_length': self.content_length} + return {"content_length": self.content_length} def _set_empty_data(self): """ @@ -84,13 +85,13 @@ def _set_empty_data(self): @property def accepted_renderer(self): - if not hasattr(self, '_accepted_renderer'): + if not hasattr(self, "_accepted_renderer"): self._perform_content_negotiation() return self._accepted_renderer @property def accepted_media_type(self): - if not hasattr(self, '_accepted_media_type'): + if not hasattr(self, "_accepted_media_type"): self._perform_content_negotiation() return self._accepted_media_type @@ -101,31 +102,37 @@ def _perform_content_negotiation(self): """ negotiator = self.negotiator_class() renderers = [renderer() for renderer in self.renderer_classes] - self._accepted_renderer, self._accepted_media_type = negotiator.select_renderer(renderers) + self._accepted_renderer, self._accepted_media_type = negotiator.select_renderer( + renderers + ) # Method and content type overloading. @property def method(self): - if not hasattr(self, '_method'): + if not hasattr(self, "_method"): self._perform_method_overloading() return self._method + @method.setter + def method(self, value): + self._method = value + @property def content_type(self): - if not hasattr(self, '_content_type'): + if not hasattr(self, "_content_type"): self._perform_method_overloading() return self._content_type @property def content_length(self): - if not hasattr(self, '_content_length'): + if not hasattr(self, "_content_length"): self._perform_method_overloading() return self._content_length @property def stream(self): - if not hasattr(self, '_stream'): + if not hasattr(self, "_stream"): self._perform_method_overloading() return self._stream @@ -134,29 +141,33 @@ def _perform_method_overloading(self): Perform method and content type overloading. Provides support for browser PUT, PATCH, DELETE & other requests, - by specifing a '_method' form field. + by specifying a '_method' form field. Also provides support for browser non-form requests (eg JSON), - by specifing '_content' and '_content_type' form fields. + by specifying '_content' and '_content_type' form fields. """ - self._method = super(APIRequest, self).method - self._stream = super(APIRequest, self).stream - self._content_type = self.headers.get('Content-Type') + if not hasattr(self, "_method"): + self.method = super().method + self._stream = super().stream + self._content_type = self.headers.get("Content-Type") self._content_length = get_content_length(self.environ) - if (self._method == 'POST' and self._content_type == 'application/x-www-form-urlencoded'): + if ( + self._method == "POST" + and self._content_type == "application/x-www-form-urlencoded" + ): # Read the request data, then push it back onto the stream again. body = self.get_data() data = url_decode_stream(io.BytesIO(body)) self._stream = io.BytesIO(body) - if '_method' in data: + if "_method" in data: # Support browser forms with PUT, PATCH, DELETE & other methods. - self._method = data['_method'] - if '_content' in data and '_content_type' in data: + self._method = data["_method"] + if "_content" in data and "_content_type" in data: # Support browser forms with non-form data, such as JSON. - body = data['_content'].encode('utf8') + body = data["_content"].encode("utf8") self._stream = io.BytesIO(body) - self._content_type = data['_content_type'] + self._content_type = data["_content_type"] self._content_length = len(body) # Misc... @@ -169,7 +180,7 @@ def full_path(self): """ if not self.query_string: return self.path - return self.path + u'?' + to_unicode(self.query_string, self.url_charset) + return self.path + "?" + self.query_string.decode() # @property # def auth(self): diff --git a/flask_api/response.py b/flask_api/response.py index 3551158..701c299 100644 --- a/flask_api/response.py +++ b/flask_api/response.py @@ -1,7 +1,4 @@ -# coding: utf8 -from __future__ import unicode_literals -from flask import request, Response -from flask._compat import text_type +from flask import Response, request class APIResponse(Response): @@ -9,12 +6,12 @@ class APIResponse(Response): api_return_types = (list, dict) def __init__(self, content=None, *args, **kwargs): - super(APIResponse, self).__init__(None, *args, **kwargs) + super().__init__(None, *args, **kwargs) media_type = None - if isinstance(content, self.api_return_types) or content == '': + if isinstance(content, self.api_return_types) or content == "": renderer = request.accepted_renderer - if content != '' or renderer.handles_empty_responses: + if content != "" or renderer.handles_empty_responses: media_type = request.accepted_media_type options = self.get_renderer_options() content = renderer.render(content, media_type, **options) @@ -24,17 +21,17 @@ def __init__(self, content=None, *args, **kwargs): # From `werkzeug.wrappers.BaseResponse` if content is None: content = [] - if isinstance(content, (text_type, bytes, bytearray)): + if isinstance(content, (str, bytes, bytearray)): self.set_data(content) else: self.response = content if media_type is not None: - self.headers['Content-Type'] = str(media_type) + self.headers["Content-Type"] = str(media_type) def get_renderer_options(self): return { - 'status': self.status, - 'status_code': self.status_code, - 'headers': self.headers + "status": self.status, + "status_code": self.status_code, + "headers": self.headers, } diff --git a/flask_api/settings.py b/flask_api/settings.py index b48044e..58f02d4 100644 --- a/flask_api/settings.py +++ b/flask_api/settings.py @@ -1,4 +1,3 @@ -from flask._compat import string_types import importlib @@ -7,7 +6,7 @@ def perform_imports(val, setting_name): If the given setting is a string import notation, then perform the necessary import or imports. """ - if isinstance(val, string_types): + if isinstance(val, str): return import_from_string(val, setting_name) elif isinstance(val, (list, tuple)): return [perform_imports(item, setting_name) for item in val] @@ -20,8 +19,8 @@ def import_from_string(val, setting_name): """ try: # Nod to tastypie's use of importlib. - parts = val.split('.') - module_path, class_name = '.'.join(parts[:-1]), parts[-1] + parts = val.split(".") + module_path, class_name = ".".join(parts[:-1]), parts[-1] module = importlib.import_module(module_path) return getattr(module, class_name) except ImportError as exc: @@ -30,28 +29,28 @@ def import_from_string(val, setting_name): raise ImportError(msg) -class APISettings(object): +class APISettings: def __init__(self, user_config=None): self.user_config = user_config or {} @property def DEFAULT_PARSERS(self): default = [ - 'flask_api.parsers.JSONParser', - 'flask_api.parsers.URLEncodedParser', - 'flask_api.parsers.MultiPartParser' + "flask_api.parsers.JSONParser", + "flask_api.parsers.URLEncodedParser", + "flask_api.parsers.MultiPartParser", ] - val = self.user_config.get('DEFAULT_PARSERS', default) - return perform_imports(val, 'DEFAULT_PARSERS') + val = self.user_config.get("DEFAULT_PARSERS", default) + return perform_imports(val, "DEFAULT_PARSERS") @property def DEFAULT_RENDERERS(self): default = [ - 'flask_api.renderers.JSONRenderer', - 'flask_api.renderers.BrowsableAPIRenderer' + "flask_api.renderers.JSONRenderer", + "flask_api.renderers.BrowsableAPIRenderer", ] - val = self.user_config.get('DEFAULT_RENDERERS', default) - return perform_imports(val, 'DEFAULT_RENDERERS') + val = self.user_config.get("DEFAULT_RENDERERS", default) + return perform_imports(val, "DEFAULT_RENDERERS") default_settings = APISettings() diff --git a/flask_api/status.py b/flask_api/status.py index 4163c7b..ffb78a0 100644 --- a/flask_api/status.py +++ b/flask_api/status.py @@ -1,4 +1,3 @@ -# coding: utf8 """ Descriptive HTTP status codes, for code readability. @@ -7,7 +6,6 @@ RFC 2616: http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html RFC 6585: http://tools.ietf.org/html/rfc6585 """ -from __future__ import unicode_literals def is_informational(code): @@ -67,6 +65,7 @@ def is_server_error(code): HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415 HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416 HTTP_417_EXPECTATION_FAILED = 417 +HTTP_418_IM_A_TEAPOT = 418 HTTP_428_PRECONDITION_REQUIRED = 428 HTTP_429_TOO_MANY_REQUESTS = 429 HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431 diff --git a/flask_api/templates/base.html b/flask_api/templates/base.html index 0a60422..0603943 100644 --- a/flask_api/templates/base.html +++ b/flask_api/templates/base.html @@ -31,7 +31,7 @@