From 31f64ccb520664c2328faef456f9cb4570a35ef4 Mon Sep 17 00:00:00 2001 From: Fliiiix Date: Thu, 15 Jul 2021 20:02:59 +0200 Subject: [PATCH 1/3] fix(backend): refactore token check and calculation --- docat/app.py | 52 ++++++++++++++++++++++---------------- docat/docat/utils.py | 16 +++++++++++- docat/tests/test_delete.py | 6 +++-- 3 files changed, 49 insertions(+), 25 deletions(-) diff --git a/docat/app.py b/docat/app.py index a602f8ed6..6edd63e1b 100644 --- a/docat/app.py +++ b/docat/app.py @@ -7,8 +7,6 @@ :copyright: (c) 2019 by docat, https://github.com/randombenj/docat :license: MIT, see LICENSE for more details. """ - -import hashlib import os import secrets from http import HTTPStatus @@ -17,7 +15,7 @@ from tinydb import Query, TinyDB from werkzeug.utils import secure_filename -from docat.docat.utils import UPLOAD_FOLDER, create_nginx_config, create_symlink, extract_archive, remove_docs +from docat.docat.utils import UPLOAD_FOLDER, calculate_token, create_nginx_config, create_symlink, extract_archive, remove_docs app = Flask(__name__) app.config["UPLOAD_FOLDER"] = os.getenv("DOCAT_DOC_PATH", UPLOAD_FOLDER) @@ -41,7 +39,7 @@ def upload(project, version): # ensure directory for the uploaded doc exists base_path.mkdir(parents=True, exist_ok=True) - # save the upploaded documentation + # save the uploaded documentation uploaded_file.save(str(target_file)) extract_archive(target_file, base_path) @@ -79,33 +77,43 @@ def claim(project): token = secrets.token_hex(16) salt = os.urandom(32) - token_hash = hashlib.pbkdf2_hmac("sha256", token.encode("utf-8"), salt, 100000) + token_hash = calculate_token(token, salt) table.insert({"name": project, "token": token_hash, "salt": salt}) - return {"message": f"Project {project} successfully claimed", "token": token}, HTTPStatus.CREATED + return {"message": f"Project {project} successfully claimed", "token": token}, HTTPStatus.CREATED -@app.route("/api//", methods=["DELETE"]) -def delete(project, version): - headers = request.headers - auth = headers.get("Docat-Api-Key") +def check_token_for_project(token, project): Project = Query() table = app.db.table('claims') result = table.search(Project.name == project) - if result and auth: - token_hash = hashlib.pbkdf2_hmac("sha256", auth.encode("utf-8"), result[0]["salt"], 100000) - print(f"stored hash: {result[0]['token']} calculated hash: {token_hash}") + if result and token: + token_hash = calculate_token(token, result[0]["salt"]) if result[0]["token"] == token_hash: - message = remove_docs(project, version) - if message: - return ({"message": message}, HTTPStatus.NOT_FOUND) - else: - return ( - {"message": f"Successfully deleted version '{version}'"}, - HTTPStatus.OK, - ) - return ({"message": f"Please provide a header with a valid Docat-Api-Key token for {project}"}, HTTPStatus.UNAUTHORIZED) + return True + else: + return ({"message": f"Docat-Api-Key token is not valid for {project}"}, HTTPStatus.UNAUTHORIZED) + else: + return ({"message": f"Please provide a header with a valid Docat-Api-Key token for {project}"}, HTTPStatus.UNAUTHORIZED) + + +@app.route("/api//", methods=["DELETE"]) +def delete(project, version): + token = request.headers.get("Docat-Api-Key") + + result = check_token_for_project(token, project) + if result is True: + message = remove_docs(project, version) + if message: + return ({"message": message}, HTTPStatus.NOT_FOUND) + else: + return ( + {"message": f"Successfully deleted version '{version}'"}, + HTTPStatus.OK, + ) + else: + return result # serve_local_docs for local testing without a nginx diff --git a/docat/docat/utils.py b/docat/docat/utils.py index 0114dd1ca..e92083492 100644 --- a/docat/docat/utils.py +++ b/docat/docat/utils.py @@ -1,6 +1,7 @@ """ docat utilities """ +import hashlib import os import shutil import subprocess @@ -33,7 +34,7 @@ def create_symlink(source, destination): def create_nginx_config(project, project_base_path): """ - Creates an nginx configuration for an upploaded project + Creates an Nginx configuration for an uploaded project version. Args: @@ -70,6 +71,7 @@ def extract_archive(target_file, destination): def remove_docs(project, version): """ Delete documentation + Args: project (str): name of the project version (str): project version @@ -90,3 +92,15 @@ def remove_docs(project, version): nginx_config.unlink() else: return f"Could not find version '{docs}'" + + +def calculate_token(password, salt): + """ + Wrapper function for pbkdf2_hmac to ensure consistent use of + hash digest algorithm and iteration count. + + Args: + password (str): the password to hash + salt (str): the salt used for the password + """ + return hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, 100000) diff --git a/docat/tests/test_delete.py b/docat/tests/test_delete.py index 0a8d13bab..c29ae5828 100644 --- a/docat/tests/test_delete.py +++ b/docat/tests/test_delete.py @@ -10,12 +10,14 @@ def test_successfully_delete(client_with_claimed_project): def test_no_valid_token_delete(client_with_claimed_project): with patch("app.remove_docs", return_value="remove mock"): rv = client_with_claimed_project.delete("/api/some-project/1.0.0", headers={"Docat-Api-Key": "abcd"}) - assert b"Please provide a header with a valid Docat-Api-Key token" in rv.data + assert 401 == rv.status_code + assert b"Docat-Api-Key token is not valid for" in rv.data def test_no_token_delete(client_with_claimed_project): with patch("app.remove_docs", return_value="remove mock"): rv = client_with_claimed_project.delete("/api/some-project/1.0.0") - assert b"Please provide a header with a valid Docat-Api-Key token" in rv.data + assert 401 == rv.status_code + assert b"Please provide a header with a valid Docat-Api-Key token" in rv.data From dd619c36e1bfdf28c53b882647eb9b374c781ebc Mon Sep 17 00:00:00 2001 From: Fliiiix Date: Thu, 15 Jul 2021 20:38:39 +0200 Subject: [PATCH 2/3] feat(backend): protect version override by token --- docat/app.py | 41 ++++++++++++++++---------- docat/tests/conftest.py | 6 ++++ docat/tests/test_upload.py | 60 ++++++++++++++++++++++++++++++++++++++ docat/tests/test_utils.py | 1 - 4 files changed, 91 insertions(+), 17 deletions(-) create mode 100644 docat/tests/test_upload.py diff --git a/docat/app.py b/docat/app.py index 6edd63e1b..d4b174ea0 100644 --- a/docat/app.py +++ b/docat/app.py @@ -10,6 +10,7 @@ import os import secrets from http import HTTPStatus +from pathlib import Path from flask import Flask, request, send_from_directory from tinydb import Query, TinyDB @@ -18,7 +19,7 @@ from docat.docat.utils import UPLOAD_FOLDER, calculate_token, create_nginx_config, create_symlink, extract_archive, remove_docs app = Flask(__name__) -app.config["UPLOAD_FOLDER"] = os.getenv("DOCAT_DOC_PATH", UPLOAD_FOLDER) +app.config["UPLOAD_FOLDER"] = Path(os.getenv("DOCAT_DOC_PATH", UPLOAD_FOLDER)) app.config["MAX_CONTENT_LENGTH"] = 100 * 1024 * 1024 # 100M app.db = TinyDB('db.json') @@ -36,6 +37,14 @@ def upload(project, version): base_path = project_base_path / version target_file = base_path / secure_filename(uploaded_file.filename) + if base_path.exists(): + token = request.headers.get("Docat-Api-Key") + result = check_token_for_project(token, project) + if result is True: + remove_docs(project, version) + else: + return result + # ensure directory for the uploaded doc exists base_path.mkdir(parents=True, exist_ok=True) @@ -83,21 +92,6 @@ def claim(project): return {"message": f"Project {project} successfully claimed", "token": token}, HTTPStatus.CREATED -def check_token_for_project(token, project): - Project = Query() - table = app.db.table('claims') - result = table.search(Project.name == project) - - if result and token: - token_hash = calculate_token(token, result[0]["salt"]) - if result[0]["token"] == token_hash: - return True - else: - return ({"message": f"Docat-Api-Key token is not valid for {project}"}, HTTPStatus.UNAUTHORIZED) - else: - return ({"message": f"Please provide a header with a valid Docat-Api-Key token for {project}"}, HTTPStatus.UNAUTHORIZED) - - @app.route("/api//", methods=["DELETE"]) def delete(project, version): token = request.headers.get("Docat-Api-Key") @@ -116,6 +110,21 @@ def delete(project, version): return result +def check_token_for_project(token, project): + Project = Query() + table = app.db.table('claims') + result = table.search(Project.name == project) + + if result and token: + token_hash = calculate_token(token, result[0]["salt"]) + if result[0]["token"] == token_hash: + return True + else: + return ({"message": f"Docat-Api-Key token is not valid for {project}"}, HTTPStatus.UNAUTHORIZED) + else: + return ({"message": f"Please provide a header with a valid Docat-Api-Key token for {project}"}, HTTPStatus.UNAUTHORIZED) + + # serve_local_docs for local testing without a nginx if os.environ.get("DOCAT_SERVE_FILES"): diff --git a/docat/tests/conftest.py b/docat/tests/conftest.py index 09bc79019..72e336769 100644 --- a/docat/tests/conftest.py +++ b/docat/tests/conftest.py @@ -1,3 +1,6 @@ +import tempfile +from pathlib import Path + import pytest from tinydb import TinyDB from tinydb.storages import MemoryStorage @@ -8,10 +11,13 @@ @pytest.fixture def client(): + temp_dir = tempfile.TemporaryDirectory() + app.config["UPLOAD_FOLDER"] = Path(temp_dir.name) app.config["TESTING"] = True app.db = TinyDB(storage=MemoryStorage) yield app.test_client() app.db = None + temp_dir.cleanup() @pytest.fixture diff --git a/docat/tests/test_upload.py b/docat/tests/test_upload.py new file mode 100644 index 000000000..29a50540b --- /dev/null +++ b/docat/tests/test_upload.py @@ -0,0 +1,60 @@ +import io +from unittest.mock import call, patch + + +def test_successfully_upload(client): + with patch("app.remove_docs"), patch("app.create_nginx_config"): + data = {"file": (io.BytesIO(b"

Hello World

"), "index.html")} + rv = client.post("/api/some-project/1.0.0", data=data, content_type="multipart/form-data") + + assert 201 == rv.status_code + assert b"File successfully uploaded" in rv.data + + +def test_successfully_override(client_with_claimed_project): + with patch("app.remove_docs") as remove_mock, patch("app.create_nginx_config"): + data = {"file": (io.BytesIO(b"

Hello World

"), "index.html")} + rv = client_with_claimed_project.post("/api/some-project/1.0.0", data=data, content_type="multipart/form-data") + assert 201 == rv.status_code + + data = {"file": (io.BytesIO(b"

Hello World

"), "index.html")} + rv = client_with_claimed_project.post( + "/api/some-project/1.0.0", data=data, content_type="multipart/form-data", headers={"Docat-Api-Key": "1234"} + ) + + assert 201 == rv.status_code + assert b"File successfully uploaded" in rv.data + assert remove_mock.mock_calls == [call("some-project", "1.0.0")] + + +def test_tags_are_not_overwritten_without_api_key(client_with_claimed_project): + with patch("app.remove_docs") as remove_mock, patch("app.create_nginx_config"): + data = {"file": (io.BytesIO(b"

Hello World

"), "index.html")} + rv = client_with_claimed_project.post("/api/some-project/1.0.0", data=data, content_type="multipart/form-data") + assert 201 == rv.status_code + + rv = client_with_claimed_project.put("/api/some-project/1.0.0/tags/latest") + assert 201 == rv.status_code + + data = {"file": (io.BytesIO(b"

Hello World

"), "index.html")} + rv = client_with_claimed_project.post("/api/some-project/latest", data=data, content_type="multipart/form-data") + + assert 401 == rv.status_code + assert b"provide a header with a valid Docat-Api-Key token" in rv.data + assert remove_mock.mock_calls == [] + + +def test_fails_with_invalid_token(client_with_claimed_project): + with patch("app.remove_docs") as remove_mock, patch("app.create_nginx_config"): + data = {"file": (io.BytesIO(b"

Hello World

"), "index.html")} + rv = client_with_claimed_project.post("/api/some-project/1.0.0", data=data, content_type="multipart/form-data") + assert 201 == rv.status_code + + data = {"file": (io.BytesIO(b"

Hello World

"), "index.html")} + rv = client_with_claimed_project.post( + "/api/some-project/1.0.0", data=data, content_type="multipart/form-data", headers={"Docat-Api-Key": "456"} + ) + + assert 401 == rv.status_code + assert b"Docat-Api-Key token is not valid" in rv.data + assert remove_mock.mock_calls == [] diff --git a/docat/tests/test_utils.py b/docat/tests/test_utils.py index e56748408..40e4a99e6 100644 --- a/docat/tests/test_utils.py +++ b/docat/tests/test_utils.py @@ -102,7 +102,6 @@ def test_archive_artifact(): def test_remove_version(temp_project_version): - docs, config = temp_project_version("project", "1.0") with patch("docat.docat.utils.UPLOAD_FOLDER", docs), patch("docat.docat.utils.NGINX_CONFIG_PATH", config): remove_docs("project", "1.0") From 9ca38083449b1c7c073846b0221e287d397ef109 Mon Sep 17 00:00:00 2001 From: Fliiiix Date: Thu, 15 Jul 2021 20:46:55 +0200 Subject: [PATCH 3/3] chore(backend): update deps --- docat/poetry.lock | 115 +++++++++++++++++++++++----------------------- 1 file changed, 58 insertions(+), 57 deletions(-) diff --git a/docat/poetry.lock b/docat/poetry.lock index 73cdd7066..077942bb8 100644 --- a/docat/poetry.lock +++ b/docat/poetry.lock @@ -149,7 +149,7 @@ dotenv = ["python-dotenv"] [[package]] name = "importlib-metadata" -version = "4.5.0" +version = "4.6.1" description = "Read metadata from Python packages" category = "main" optional = false @@ -161,7 +161,8 @@ zipp = ">=0.5" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] +perf = ["ipython"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "packaging", "pep517", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources (>=1.3)"] [[package]] name = "iniconfig" @@ -246,11 +247,11 @@ python-versions = "*" [[package]] name = "packaging" -version = "20.9" +version = "21.0" description = "Core utilities for Python packages" category = "dev" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.6" [package.dependencies] pyparsing = ">=2.0.2" @@ -348,7 +349,7 @@ testing = ["fields", "hunter", "process-tests (==2.0.2)", "six", "pytest-xdist", [[package]] name = "regex" -version = "2021.4.4" +version = "2021.7.6" description = "Alternative regular expression module, to replace re." category = "dev" optional = false @@ -356,7 +357,7 @@ python-versions = "*" [[package]] name = "testfixtures" -version = "6.17.1" +version = "6.18.0" description = "A collection of helpers and mock objects for unit tests and doc tests." category = "dev" optional = false @@ -415,7 +416,7 @@ watchdog = ["watchdog"] [[package]] name = "zipp" -version = "3.4.1" +version = "3.5.0" description = "Backport of pathlib-compatible object wrapper for zip files" category = "main" optional = false @@ -423,7 +424,7 @@ python-versions = ">=3.6" [package.extras] docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"] -testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pytest-cov", "pytest-enabler", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] +testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "jaraco.itertools", "func-timeout", "pytest-black (>=0.3.7)", "pytest-mypy"] [metadata] lock-version = "1.1" @@ -516,8 +517,8 @@ flask = [ {file = "Flask-2.0.1.tar.gz", hash = "sha256:1c4c257b1892aec1398784c63791cbaa43062f1f7aeb555c4da961b20ee68f55"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.5.0-py3-none-any.whl", hash = "sha256:833b26fb89d5de469b24a390e9df088d4e52e4ba33b01dc5e0e4f41b81a16c00"}, - {file = "importlib_metadata-4.5.0.tar.gz", hash = "sha256:b142cc1dd1342f31ff04bb7d022492b09920cb64fed867cd3ea6f80fe3ebd139"}, + {file = "importlib_metadata-4.6.1-py3-none-any.whl", hash = "sha256:9f55f560e116f8643ecf2922d9cd3e1c7e8d52e683178fecd9d08f6aa357e11e"}, + {file = "importlib_metadata-4.6.1.tar.gz", hash = "sha256:079ada16b7fc30dfbb5d13399a5113110dab1aa7c2bc62f66af75f0b717c8cac"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -584,8 +585,8 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] packaging = [ - {file = "packaging-20.9-py2.py3-none-any.whl", hash = "sha256:67714da7f7bc052e064859c05c595155bd1ee9f69f76557e21f051443c20947a"}, - {file = "packaging-20.9.tar.gz", hash = "sha256:5b327ac1320dc863dca72f4514ecc086f31186744b84a230374cc1fd776feae5"}, + {file = "packaging-21.0-py3-none-any.whl", hash = "sha256:c86254f9220d55e31cc94d69bade760f0847da8000def4dfe1c6b872fd14ff14"}, + {file = "packaging-21.0.tar.gz", hash = "sha256:7dc96269f53a4ccec5c0670940a4281106dd0bb343f47b7471f779df49c2fbe7"}, ] pathspec = [ {file = "pathspec-0.8.1-py2.py3-none-any.whl", hash = "sha256:aa0cb481c4041bf52ffa7b0d8fa6cd3e88a2ca4879c533c9153882ee2556790d"}, @@ -620,51 +621,51 @@ pytest-cov = [ {file = "pytest_cov-2.10.1-py2.py3-none-any.whl", hash = "sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191"}, ] regex = [ - {file = "regex-2021.4.4-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:619d71c59a78b84d7f18891fe914446d07edd48dc8328c8e149cbe0929b4e000"}, - {file = "regex-2021.4.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:47bf5bf60cf04d72bf6055ae5927a0bd9016096bf3d742fa50d9bf9f45aa0711"}, - {file = "regex-2021.4.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:281d2fd05555079448537fe108d79eb031b403dac622621c78944c235f3fcf11"}, - {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:bd28bc2e3a772acbb07787c6308e00d9626ff89e3bfcdebe87fa5afbfdedf968"}, - {file = "regex-2021.4.4-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:7c2a1af393fcc09e898beba5dd59196edaa3116191cc7257f9224beaed3e1aa0"}, - {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:c38c71df845e2aabb7fb0b920d11a1b5ac8526005e533a8920aea97efb8ec6a4"}, - {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:96fcd1888ab4d03adfc9303a7b3c0bd78c5412b2bfbe76db5b56d9eae004907a"}, - {file = "regex-2021.4.4-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:ade17eb5d643b7fead300a1641e9f45401c98eee23763e9ed66a43f92f20b4a7"}, - {file = "regex-2021.4.4-cp36-cp36m-win32.whl", hash = "sha256:e8e5b509d5c2ff12f8418006d5a90e9436766133b564db0abaec92fd27fcee29"}, - {file = "regex-2021.4.4-cp36-cp36m-win_amd64.whl", hash = "sha256:11d773d75fa650cd36f68d7ca936e3c7afaae41b863b8c387a22aaa78d3c5c79"}, - {file = "regex-2021.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d3029c340cfbb3ac0a71798100ccc13b97dddf373a4ae56b6a72cf70dfd53bc8"}, - {file = "regex-2021.4.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:18c071c3eb09c30a264879f0d310d37fe5d3a3111662438889ae2eb6fc570c31"}, - {file = "regex-2021.4.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:4c557a7b470908b1712fe27fb1ef20772b78079808c87d20a90d051660b1d69a"}, - {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:01afaf2ec48e196ba91b37451aa353cb7eda77efe518e481707e0515025f0cd5"}, - {file = "regex-2021.4.4-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:3a9cd17e6e5c7eb328517969e0cb0c3d31fd329298dd0c04af99ebf42e904f82"}, - {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:90f11ff637fe8798933fb29f5ae1148c978cccb0452005bf4c69e13db951e765"}, - {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:919859aa909429fb5aa9cf8807f6045592c85ef56fdd30a9a3747e513db2536e"}, - {file = "regex-2021.4.4-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:339456e7d8c06dd36a22e451d58ef72cef293112b559010db3d054d5560ef439"}, - {file = "regex-2021.4.4-cp37-cp37m-win32.whl", hash = "sha256:67bdb9702427ceddc6ef3dc382455e90f785af4c13d495f9626861763ee13f9d"}, - {file = "regex-2021.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:32e65442138b7b76dd8173ffa2cf67356b7bc1768851dded39a7a13bf9223da3"}, - {file = "regex-2021.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1e1c20e29358165242928c2de1482fb2cf4ea54a6a6dea2bd7a0e0d8ee321500"}, - {file = "regex-2021.4.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:314d66636c494ed9c148a42731b3834496cc9a2c4251b1661e40936814542b14"}, - {file = "regex-2021.4.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:6d1b01031dedf2503631d0903cb563743f397ccaf6607a5e3b19a3d76fc10480"}, - {file = "regex-2021.4.4-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:741a9647fcf2e45f3a1cf0e24f5e17febf3efe8d4ba1281dcc3aa0459ef424dc"}, - {file = "regex-2021.4.4-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:4c46e22a0933dd783467cf32b3516299fb98cfebd895817d685130cc50cd1093"}, - {file = "regex-2021.4.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e512d8ef5ad7b898cdb2d8ee1cb09a8339e4f8be706d27eaa180c2f177248a10"}, - {file = "regex-2021.4.4-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:980d7be47c84979d9136328d882f67ec5e50008681d94ecc8afa8a65ed1f4a6f"}, - {file = "regex-2021.4.4-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:ce15b6d103daff8e9fee13cf7f0add05245a05d866e73926c358e871221eae87"}, - {file = "regex-2021.4.4-cp38-cp38-win32.whl", hash = "sha256:a91aa8619b23b79bcbeb37abe286f2f408d2f2d6f29a17237afda55bb54e7aac"}, - {file = "regex-2021.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:c0502c0fadef0d23b128605d69b58edb2c681c25d44574fc673b0e52dce71ee2"}, - {file = "regex-2021.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:598585c9f0af8374c28edd609eb291b5726d7cbce16be6a8b95aa074d252ee17"}, - {file = "regex-2021.4.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:ee54ff27bf0afaf4c3b3a62bcd016c12c3fdb4ec4f413391a90bd38bc3624605"}, - {file = "regex-2021.4.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:7d9884d86dd4dd489e981d94a65cd30d6f07203d90e98f6f657f05170f6324c9"}, - {file = "regex-2021.4.4-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:bf5824bfac591ddb2c1f0a5f4ab72da28994548c708d2191e3b87dd207eb3ad7"}, - {file = "regex-2021.4.4-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:563085e55b0d4fb8f746f6a335893bda5c2cef43b2f0258fe1020ab1dd874df8"}, - {file = "regex-2021.4.4-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b9c3db21af35e3b3c05764461b262d6f05bbca08a71a7849fd79d47ba7bc33ed"}, - {file = "regex-2021.4.4-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:3916d08be28a1149fb97f7728fca1f7c15d309a9f9682d89d79db75d5e52091c"}, - {file = "regex-2021.4.4-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:fd45ff9293d9274c5008a2054ecef86a9bfe819a67c7be1afb65e69b405b3042"}, - {file = "regex-2021.4.4-cp39-cp39-win32.whl", hash = "sha256:fa4537fb4a98fe8fde99626e4681cc644bdcf2a795038533f9f711513a862ae6"}, - {file = "regex-2021.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:97f29f57d5b84e73fbaf99ab3e26134e6687348e95ef6b48cfd2c06807005a07"}, - {file = "regex-2021.4.4.tar.gz", hash = "sha256:52ba3d3f9b942c49d7e4bc105bb28551c44065f139a65062ab7912bef10c9afb"}, + {file = "regex-2021.7.6-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:e6a1e5ca97d411a461041d057348e578dc344ecd2add3555aedba3b408c9f874"}, + {file = "regex-2021.7.6-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:6afe6a627888c9a6cfbb603d1d017ce204cebd589d66e0703309b8048c3b0854"}, + {file = "regex-2021.7.6-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:ccb3d2190476d00414aab36cca453e4596e8f70a206e2aa8db3d495a109153d2"}, + {file = "regex-2021.7.6-cp36-cp36m-manylinux2010_i686.whl", hash = "sha256:ed693137a9187052fc46eedfafdcb74e09917166362af4cc4fddc3b31560e93d"}, + {file = "regex-2021.7.6-cp36-cp36m-manylinux2010_x86_64.whl", hash = "sha256:99d8ab206a5270c1002bfcf25c51bf329ca951e5a169f3b43214fdda1f0b5f0d"}, + {file = "regex-2021.7.6-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:b85ac458354165405c8a84725de7bbd07b00d9f72c31a60ffbf96bb38d3e25fa"}, + {file = "regex-2021.7.6-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:3f5716923d3d0bfb27048242a6e0f14eecdb2e2a7fac47eda1d055288595f222"}, + {file = "regex-2021.7.6-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5983c19d0beb6af88cb4d47afb92d96751fb3fa1784d8785b1cdf14c6519407"}, + {file = "regex-2021.7.6-cp36-cp36m-win32.whl", hash = "sha256:c92831dac113a6e0ab28bc98f33781383fe294df1a2c3dfd1e850114da35fd5b"}, + {file = "regex-2021.7.6-cp36-cp36m-win_amd64.whl", hash = "sha256:791aa1b300e5b6e5d597c37c346fb4d66422178566bbb426dd87eaae475053fb"}, + {file = "regex-2021.7.6-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:59506c6e8bd9306cd8a41511e32d16d5d1194110b8cfe5a11d102d8b63cf945d"}, + {file = "regex-2021.7.6-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:564a4c8a29435d1f2256ba247a0315325ea63335508ad8ed938a4f14c4116a5d"}, + {file = "regex-2021.7.6-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:59c00bb8dd8775473cbfb967925ad2c3ecc8886b3b2d0c90a8e2707e06c743f0"}, + {file = "regex-2021.7.6-cp37-cp37m-manylinux2010_i686.whl", hash = "sha256:9a854b916806c7e3b40e6616ac9e85d3cdb7649d9e6590653deb5b341a736cec"}, + {file = "regex-2021.7.6-cp37-cp37m-manylinux2010_x86_64.whl", hash = "sha256:db2b7df831c3187a37f3bb80ec095f249fa276dbe09abd3d35297fc250385694"}, + {file = "regex-2021.7.6-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:173bc44ff95bc1e96398c38f3629d86fa72e539c79900283afa895694229fe6a"}, + {file = "regex-2021.7.6-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:15dddb19823f5147e7517bb12635b3c82e6f2a3a6b696cc3e321522e8b9308ad"}, + {file = "regex-2021.7.6-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2ddeabc7652024803666ea09f32dd1ed40a0579b6fbb2a213eba590683025895"}, + {file = "regex-2021.7.6-cp37-cp37m-win32.whl", hash = "sha256:f080248b3e029d052bf74a897b9d74cfb7643537fbde97fe8225a6467fb559b5"}, + {file = "regex-2021.7.6-cp37-cp37m-win_amd64.whl", hash = "sha256:d8bbce0c96462dbceaa7ac4a7dfbbee92745b801b24bce10a98d2f2b1ea9432f"}, + {file = "regex-2021.7.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:edd1a68f79b89b0c57339bce297ad5d5ffcc6ae7e1afdb10f1947706ed066c9c"}, + {file = "regex-2021.7.6-cp38-cp38-manylinux1_i686.whl", hash = "sha256:422dec1e7cbb2efbbe50e3f1de36b82906def93ed48da12d1714cabcd993d7f0"}, + {file = "regex-2021.7.6-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:cbe23b323988a04c3e5b0c387fe3f8f363bf06c0680daf775875d979e376bd26"}, + {file = "regex-2021.7.6-cp38-cp38-manylinux2010_i686.whl", hash = "sha256:0eb2c6e0fcec5e0f1d3bcc1133556563222a2ffd2211945d7b1480c1b1a42a6f"}, + {file = "regex-2021.7.6-cp38-cp38-manylinux2010_x86_64.whl", hash = "sha256:1c78780bf46d620ff4fff40728f98b8afd8b8e35c3efd638c7df67be2d5cddbf"}, + {file = "regex-2021.7.6-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:bc84fb254a875a9f66616ed4538542fb7965db6356f3df571d783f7c8d256edd"}, + {file = "regex-2021.7.6-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:598c0a79b4b851b922f504f9f39a863d83ebdfff787261a5ed061c21e67dd761"}, + {file = "regex-2021.7.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:875c355360d0f8d3d827e462b29ea7682bf52327d500a4f837e934e9e4656068"}, + {file = "regex-2021.7.6-cp38-cp38-win32.whl", hash = "sha256:e586f448df2bbc37dfadccdb7ccd125c62b4348cb90c10840d695592aa1b29e0"}, + {file = "regex-2021.7.6-cp38-cp38-win_amd64.whl", hash = "sha256:2fe5e71e11a54e3355fa272137d521a40aace5d937d08b494bed4529964c19c4"}, + {file = "regex-2021.7.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6110bab7eab6566492618540c70edd4d2a18f40ca1d51d704f1d81c52d245026"}, + {file = "regex-2021.7.6-cp39-cp39-manylinux1_i686.whl", hash = "sha256:4f64fc59fd5b10557f6cd0937e1597af022ad9b27d454e182485f1db3008f417"}, + {file = "regex-2021.7.6-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:89e5528803566af4df368df2d6f503c84fbfb8249e6631c7b025fe23e6bd0cde"}, + {file = "regex-2021.7.6-cp39-cp39-manylinux2010_i686.whl", hash = "sha256:2366fe0479ca0e9afa534174faa2beae87847d208d457d200183f28c74eaea59"}, + {file = "regex-2021.7.6-cp39-cp39-manylinux2010_x86_64.whl", hash = "sha256:f9392a4555f3e4cb45310a65b403d86b589adc773898c25a39184b1ba4db8985"}, + {file = "regex-2021.7.6-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:2bceeb491b38225b1fee4517107b8491ba54fba77cf22a12e996d96a3c55613d"}, + {file = "regex-2021.7.6-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:f98dc35ab9a749276f1a4a38ab3e0e2ba1662ce710f6530f5b0a6656f1c32b58"}, + {file = "regex-2021.7.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:319eb2a8d0888fa6f1d9177705f341bc9455a2c8aca130016e52c7fe8d6c37a3"}, + {file = "regex-2021.7.6-cp39-cp39-win32.whl", hash = "sha256:eaf58b9e30e0e546cdc3ac06cf9165a1ca5b3de8221e9df679416ca667972035"}, + {file = "regex-2021.7.6-cp39-cp39-win_amd64.whl", hash = "sha256:4c9c3155fe74269f61e27617529b7f09552fbb12e44b1189cebbdb24294e6e1c"}, + {file = "regex-2021.7.6.tar.gz", hash = "sha256:8394e266005f2d8c6f0bc6780001f7afa3ef81a7a2111fa35058ded6fce79e4d"}, ] testfixtures = [ - {file = "testfixtures-6.17.1-py2.py3-none-any.whl", hash = "sha256:9ed31e83f59619e2fa17df053b241e16e0608f4580f7b5a9333a0c9bdcc99137"}, - {file = "testfixtures-6.17.1.tar.gz", hash = "sha256:5ec3a0dd6f71cc4c304fbc024a10cc293d3e0b852c868014b9f233203e149bda"}, + {file = "testfixtures-6.18.0-py2.py3-none-any.whl", hash = "sha256:9bddf79b2dddb36420a20c25a65c827a8e7398c6ed4e2c75c2697857cb006be9"}, + {file = "testfixtures-6.18.0.tar.gz", hash = "sha256:d4bd1c4f90eac90a73e1bdc59c31d03943f218d687f3c5a09e48478841a8af5f"}, ] tinydb = [ {file = "tinydb-3.15.2-py2.py3-none-any.whl", hash = "sha256:1087ade5300c47dbf9539d9f6dafd53115bd5e85a67d480d8188bdbfa2d9eaf4"}, @@ -716,6 +717,6 @@ werkzeug = [ {file = "Werkzeug-2.0.1.tar.gz", hash = "sha256:1de1db30d010ff1af14a009224ec49ab2329ad2cde454c8a708130642d579c42"}, ] zipp = [ - {file = "zipp-3.4.1-py3-none-any.whl", hash = "sha256:51cb66cc54621609dd593d1787f286ee42a5c0adbb4b29abea5a63edc3e03098"}, - {file = "zipp-3.4.1.tar.gz", hash = "sha256:3607921face881ba3e026887d8150cca609d517579abe052ac81fc5aeffdbd76"}, + {file = "zipp-3.5.0-py3-none-any.whl", hash = "sha256:957cfda87797e389580cb8b9e3870841ca991e2125350677b2ca83a0e99390a3"}, + {file = "zipp-3.5.0.tar.gz", hash = "sha256:f5812b1e007e48cff63449a5e9f4e7ebea716b4111f9c4f9a645f91d579bf0c4"}, ]