diff --git a/.circleci/config.yml b/.circleci/config.yml index 6f2f11a..47e2901 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,6 +1,6 @@ version: 2 jobs: - build: + build_test: docker: - image: circleci/python:3.6.9 steps: @@ -12,7 +12,7 @@ jobs: - run: name: install dependencies - command: poetry install -v -E flask + command: poetry install -v -E pytest - save_cache: key: deps-{{ checksum "poetry.lock" }} @@ -26,11 +26,58 @@ jobs: - run: name: run tests command: | - poetry run coverage run -m pytest . + poetry run coverage run -m pytest -vv poetry run coveralls + tox: + docker: + - image: circleci/python:3.6.9 + steps: + - checkout + + - restore_cache: + keys: + - pyenv-{{ checksum ".python-version" }} + + - run: + name: install pyenv + command: | + git clone https://github.com/pyenv/pyenv.git ~/.pyenv || (cd ~/.pyenv && git pull) + # ~/.local/bin is already in the path and the bash_profile doesn't get sourced between steps + mkdir -p ~/.local/bin + ln -s ~/.pyenv/bin/pyenv ~/.local/bin/ + + - restore_cache: + keys: + - deps-{{ checksum "poetry.lock" }} + + - run: + name: make sure tox is installed + command: poetry install -v + + - save_cache: + key: deps-{{ checksum "poetry.lock" }} + paths: + - /home/circleci/.cache/pypoetry/virtualenvs + + - run: + name: install all Python versions + command: | + pyenv versions + cat .python-version | xargs -x -l1 pyenv install --skip-existing + + - save_cache: + key: pyenv-{{ checksum ".python-version" }} + paths: + - /home/circleci/.pyenv + + - run: + name: test across multiple Python / libraries versions + command: poetry run tox -vv + workflows: version: 2 build: jobs: - - build + - build_test + - tox diff --git a/.python-version b/.python-version index adda3ad..4231944 100644 --- a/.python-version +++ b/.python-version @@ -1,3 +1,3 @@ -3.6.9 -3.7.4 -3.8-dev +3.8.3 +3.6.10 +3.7.7 diff --git a/README.md b/README.md index 9055c22..46a7ebb 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,14 @@ poetry publish # Lint the code poetry run flake8 howfast_apm -# Running the tests +# Run the tests poetry run pytest -# Running the tests across a matrix of Python versions and Flask versions -pip install tox tox-pyenv -tox +# Run the tests across a matrix of Python versions and Flask versions +# make sure all the relevant Python versions are available locally +cat .python-version | xargs -x -l1 pyenv install --skip-existing +# run the tests on the selected versions +poetry run tox ``` ## Publish diff --git a/howfast_apm/__init__.py b/howfast_apm/__init__.py index e880a6a..2fcd7f1 100644 --- a/howfast_apm/__init__.py +++ b/howfast_apm/__init__.py @@ -1 +1,19 @@ -from .flask import HowFastFlaskMiddleware +# TODO: refactor module autodetection + +try: + import flask +except ImportError: + # Flask not available + def HowFastFlaskMiddleware(*args, **kwargs): + raise Exception("Flask is not installed, cannot use HowFastFlaskMiddleware.") +else: + from .flask import HowFastFlaskMiddleware + +try: + import quart +except ImportError: + # Quart not available + def HowFastQuartMiddleware(*args, **kwargs): + raise Exception("Quart is not installed, cannot use HowFastQuartMiddleware.") +else: + from .quart import HowFastQuartMiddleware diff --git a/howfast_apm/flask.py b/howfast_apm/flask.py index 6f9caac..3f25ef3 100644 --- a/howfast_apm/flask.py +++ b/howfast_apm/flask.py @@ -15,9 +15,6 @@ class HowFastFlaskMiddleware(CoreAPM): """ Flask middleware to measure how much time is spent per endpoint. - - This implementation is purposedly naive and potentially slow, but its goal is to validate the - PoC. It should be replaced/improved in the future, based on the results of the PoC. """ def __init__( diff --git a/howfast_apm/quart.py b/howfast_apm/quart.py new file mode 100644 index 0000000..d470148 --- /dev/null +++ b/howfast_apm/quart.py @@ -0,0 +1,137 @@ +import logging +from typing import List +from datetime import datetime, timezone +from timeit import default_timer as timer +from quart import Quart, request, exceptions +from quart.signals import request_started +from werkzeug import local + +from .core import CoreAPM +from .utils import is_in_blacklist, compile_endpoints + +logger = logging.getLogger('howfast_apm') + + +class HowFastQuartMiddleware(CoreAPM): + """ + Quart middleware to measure how much time is spent per endpoint. + """ + + def __init__( + self, + # The Flask application to analyze + app: Quart, + # The HowFast app ID to use + app_id: str = None, + # Endpoints not to monitor + endpoints_blacklist: List[str] = None, + # Other configuration parameters passed to the CoreAPM constructor + **kwargs, + ): + + super().__init__(**kwargs) + + self.app = app + self.asgi_app = app.asgi_app + + # We need to store thread local information, let's use Werkzeug's context locals + # (see https://werkzeug.palletsprojects.com/en/1.0.x/local/) + self.local = local.Local() + self.local_manager = local.LocalManager([self.local]) + + # Overwrite the passed ASGI application + app.asgi_app = self + + if endpoints_blacklist: + self.endpoints_blacklist = compile_endpoints(*endpoints_blacklist) + else: + self.endpoints_blacklist = [] + + # Setup the queue and the background thread + self.setup(app_id) + + request_started.connect( + self._request_started, + # I'm not completely sure why, but it looks like the receiver function is somehow + # recognized as out of scope, which resets the reference. If the receiver is defined at + # the module level (outside of the class) then it works. To avoid the reference being + # reset, we have to explicitly ask it not be reset. + weak=False, + ) + + async def __call__(self, scope, receive, send): + if not self.app_id: + # HF APM not configured, return early to save some time + # TODO: wouldn't it be better to just not replace the ASGI app? + return await self.asgi_app(scope, receive, send) + + if scope.get('type') != "http": + # Other protocols + # - "lifespan" is not relevant (startup/shutdown of ASGI, see + # https://asgi.readthedocs.io/en/latest/specs/lifespan.html) + # - "websocket" is not supported (see + # https://asgi.readthedocs.io/en/latest/specs/www.html#websocket) + return await self.asgi_app(scope, receive, send) + + # https://asgi.readthedocs.io/en/latest/specs/www.html#connection-scope + + uri = scope.get('path') + + if is_in_blacklist(uri, self.endpoints_blacklist): + # Endpoint blacklist, return now + return await self.asgi_app(scope, receive, send) + + instance = {'response_status': None} + + def _send_wrapped(response): + if response['type'] == 'http.response.start': + instance['response_status'] = response['status'] + return send(response) + + method = scope.get('method') + + time_request_started = datetime.now(timezone.utc) + try: + # Time the function execution + start = timer() + response = await self.asgi_app(scope, receive, _send_wrapped) + # Stop the timer as soon as possible to get the best measure of the function's execution time + end = timer() + except BaseException: + instance['response_status'] = 500 + raise + finally: + elapsed = end - start + self.save_point( + time_request_started=time_request_started, + time_elapsed=elapsed, + method=method, + uri=uri, + response_status=str(instance['response_status']), + # Request metadata + endpoint_name=getattr(self.local, 'endpoint_name', None), + url_rule=getattr(self.local, 'url_rule', None), + is_not_found=getattr(self.local, 'is_not_found', None), + ) + # Werkzeug locals are designed for use in WSGI, so for ASGI we cannot use the helpful + # local_manager.make_middleware() to clean the locals after each request - we do it + # manually here instead + self.local_manager.cleanup() + + return response + + async def _request_started(self, sender, **kwargs): + async with sender.app_context(): + self._save_request_metadata() + + def _save_request_metadata(self): + """ Extract and save request metadata in the context local """ + # This will yield strings like: + # * "monitor" (when the endpoint is defined using a resource) + # * "apm-collection.store_points" (when the endpoint is defined with a blueprint) + # The endpoint name will always be lowercase + self.local.endpoint_name = request.endpoint + # This will yield strings like "/v1.1/apm//endpoint" + self.local.url_rule = request.url_rule.rule if request.url_rule is not None else None + # We want to tell the difference between a "real" 404 and a 404 returned by an existing view + self.local.is_not_found = isinstance(request.routing_exception, exceptions.NotFound) diff --git a/poetry.lock b/poetry.lock index 00eeb5f..eb0b489 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,3 +1,20 @@ +[[package]] +category = "main" +description = "File support for asyncio." +marker = "python_version >= \"3.7\" and python_version < \"3.9\"" +name = "aiofiles" +optional = true +python-versions = "*" +version = "0.5.0" + +[[package]] +category = "dev" +description = "A small Python module for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +name = "appdirs" +optional = false +python-versions = "*" +version = "1.4.4" + [[package]] category = "dev" description = "Disable App Nap on OS X 10.9" @@ -14,7 +31,7 @@ marker = "sys_platform == \"win32\"" name = "atomicwrites" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "1.3.0" +version = "1.4.0" [[package]] category = "dev" @@ -69,7 +86,7 @@ description = "Composable command line interface toolkit" name = "click" optional = true python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" -version = "7.1.1" +version = "7.1.2" [[package]] category = "dev" @@ -112,6 +129,14 @@ optional = false python-versions = ">=2.6, !=3.0.*, !=3.1.*" version = "4.4.2" +[[package]] +category = "dev" +description = "Distribution utilities" +name = "distlib" +optional = false +python-versions = "*" +version = "0.3.0" + [[package]] category = "dev" description = "Pythonic argument parser, that will make you smile" @@ -122,25 +147,28 @@ version = "0.6.2" [[package]] category = "dev" -description = "Discover and load entry points from installed packages." -name = "entrypoints" +description = "A platform independent file lock." +name = "filelock" optional = false -python-versions = ">=2.7" -version = "0.3" +python-versions = "*" +version = "3.0.12" [[package]] category = "dev" -description = "the modular source code checker: pep8, pyflakes and co" +description = "the modular source code checker: pep8 pyflakes and co" name = "flake8" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "3.7.9" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +version = "3.8.2" [package.dependencies] -entrypoints = ">=0.3.0,<0.4.0" mccabe = ">=0.6.0,<0.7.0" -pycodestyle = ">=2.5.0,<2.6.0" -pyflakes = ">=2.1.0,<2.2.0" +pycodestyle = ">=2.6.0a1,<2.7.0" +pyflakes = ">=2.2.0,<2.3.0" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = "*" [[package]] category = "main" @@ -161,6 +189,69 @@ dev = ["pytest", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinxco docs = ["sphinx", "pallets-sphinx-themes", "sphinxcontrib-log-cabinet", "sphinx-issues"] dotenv = ["python-dotenv"] +[[package]] +category = "main" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +marker = "python_version >= \"3.7\" and python_version < \"3.9\"" +name = "h11" +optional = true +python-versions = "*" +version = "0.9.0" + +[[package]] +category = "main" +description = "HTTP/2 State-Machine based protocol implementation" +marker = "python_version >= \"3.7\" and python_version < \"3.9\"" +name = "h2" +optional = true +python-versions = "*" +version = "3.2.0" + +[package.dependencies] +hpack = ">=3.0,<4" +hyperframe = ">=5.2.0,<6" + +[[package]] +category = "main" +description = "Pure-Python HPACK header compression" +marker = "python_version >= \"3.7\" and python_version < \"3.9\"" +name = "hpack" +optional = true +python-versions = "*" +version = "3.0.0" + +[[package]] +category = "main" +description = "A ASGI Server based on Hyper libraries and inspired by Gunicorn." +marker = "python_version >= \"3.7\" and python_version < \"3.9\"" +name = "hypercorn" +optional = true +python-versions = ">=3.7" +version = "0.9.5" + +[package.dependencies] +h11 = "*" +h2 = ">=3.1.0" +priority = "*" +toml = "*" +typing-extensions = "*" +wsproto = ">=0.14.0" + +[package.extras] +h3 = ["aioquic (>=0.8.1,<1.0)"] +tests = ["asynctest", "hypothesis", "pytest", "pytest-asyncio", "pytest-cov", "pytest-trio", "trio"] +trio = ["trio (>=0.11.0)"] +uvloop = ["uvloop"] + +[[package]] +category = "main" +description = "HTTP/2 framing layer for Python" +marker = "python_version >= \"3.7\" and python_version < \"3.9\"" +name = "hyperframe" +optional = true +python-versions = "*" +version = "5.2.0" + [[package]] category = "main" description = "Internationalized Domain Names in Applications (IDNA)" @@ -185,6 +276,27 @@ zipp = ">=0.5" docs = ["sphinx", "rst.linker"] testing = ["packaging", "importlib-resources"] +[[package]] +category = "dev" +description = "Read resources from Python packages" +marker = "python_version < \"3.7\"" +name = "importlib-resources" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "1.5.0" + +[package.dependencies] +[package.dependencies.importlib-metadata] +python = "<3.8" +version = "*" + +[package.dependencies.zipp] +python = "<3.8" +version = ">=0.4" + +[package.extras] +docs = ["sphinx", "rst.linker", "jaraco.packaging"] + [[package]] category = "dev" description = "IPython-enabled pdb" @@ -207,7 +319,7 @@ marker = "python_version >= \"3.4\"" name = "ipython" optional = false python-versions = ">=3.6" -version = "7.13.0" +version = "7.14.0" [package.dependencies] appnope = "*" @@ -223,7 +335,7 @@ setuptools = ">=18.5" traitlets = ">=4.2" [package.extras] -all = ["numpy (>=1.14)", "testpath", "notebook", "nose (>=0.10.1)", "nbconvert", "requests", "ipywidgets", "qtconsole", "ipyparallel", "Sphinx (>=1.3)", "pygments", "nbformat", "ipykernel"] +all = ["nose (>=0.10.1)", "Sphinx (>=1.3)", "testpath", "nbformat", "ipywidgets", "qtconsole", "numpy (>=1.14)", "notebook", "ipyparallel", "ipykernel", "pygments", "requests", "nbconvert"] doc = ["Sphinx (>=1.3)"] kernel = ["ipykernel"] nbconvert = ["nbconvert"] @@ -302,7 +414,7 @@ description = "More routines for operating on iterables, beyond itertools" name = "more-itertools" optional = false python-versions = ">=3.5" -version = "8.2.0" +version = "8.3.0" [[package]] category = "dev" @@ -334,7 +446,7 @@ description = "Core utilities for Python packages" name = "packaging" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "20.3" +version = "20.4" [package.dependencies] pyparsing = ">=2.0.2" @@ -397,6 +509,15 @@ version = ">=0.12" [package.extras] dev = ["pre-commit", "tox"] +[[package]] +category = "main" +description = "A pure-Python implementation of the HTTP/2 priority tree" +marker = "python_version >= \"3.7\" and python_version < \"3.9\"" +name = "priority" +optional = true +python-versions = "*" +version = "1.3.0" + [[package]] category = "dev" description = "Library for building powerful interactive command lines in Python" @@ -432,7 +553,7 @@ description = "Python style guide checker" name = "pycodestyle" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.5.0" +version = "2.6.0" [[package]] category = "dev" @@ -440,7 +561,7 @@ description = "passive checker of Python programs" name = "pyflakes" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -version = "2.1.1" +version = "2.2.0" [[package]] category = "dev" @@ -465,7 +586,7 @@ description = "pytest: simple powerful testing with Python" name = "pytest" optional = false python-versions = ">=3.5" -version = "5.4.1" +version = "5.4.2" [package.dependencies] atomicwrites = ">=1.0" @@ -485,6 +606,20 @@ version = ">=0.12" checkqa-mypy = ["mypy (v0.761)"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "requests", "xmlschema"] +[[package]] +category = "dev" +description = "Pytest support for asyncio." +name = "pytest-asyncio" +optional = false +python-versions = ">= 3.5" +version = "0.12.0" + +[package.dependencies] +pytest = ">=5.4.0" + +[package.extras] +testing = ["async_generator (>=1.3)", "coverage", "hypothesis (>=5.7.1)"] + [[package]] category = "dev" description = "Local continuous test runner with pytest and watchdog." @@ -499,6 +634,28 @@ docopt = ">=0.4.0" pytest = ">=2.6.4" watchdog = ">=0.6.0" +[[package]] +category = "main" +description = "A Python ASGI web microframework with the same API as Flask" +marker = "python_version >= \"3.7\" and python_version < \"3.9\"" +name = "quart" +optional = true +python-versions = ">=3.7.0" +version = "0.12.0" + +[package.dependencies] +aiofiles = "*" +blinker = "*" +click = "*" +hypercorn = ">=0.7.0" +itsdangerous = "*" +jinja2 = "*" +toml = "*" +werkzeug = ">=1.0.0" + +[package.extras] +dotenv = ["python-dotenv"] + [[package]] category = "main" description = "Python HTTP for Humans." @@ -523,7 +680,52 @@ description = "Python 2 and 3 compatibility utilities" name = "six" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" -version = "1.14.0" +version = "1.15.0" + +[[package]] +category = "main" +description = "Python Library for Tom's Obvious, Minimal Language" +name = "toml" +optional = false +python-versions = "*" +version = "0.10.1" + +[[package]] +category = "dev" +description = "tox is a generic virtualenv management and test command line tool" +name = "tox" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" +version = "3.15.1" + +[package.dependencies] +colorama = ">=0.4.1" +filelock = ">=3.0.0" +packaging = ">=14" +pluggy = ">=0.12.0" +py = ">=1.4.17" +six = ">=1.14.0" +toml = ">=0.9.4" +virtualenv = ">=16.0.0,<20.0.0 || >20.0.0,<20.0.1 || >20.0.1,<20.0.2 || >20.0.2,<20.0.3 || >20.0.3,<20.0.4 || >20.0.4,<20.0.5 || >20.0.5,<20.0.6 || >20.0.6,<20.0.7 || >20.0.7" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12,<2" + +[package.extras] +docs = ["sphinx (>=2.0.0)", "towncrier (>=18.5.0)", "pygments-github-lexers (>=0.0.5)", "sphinxcontrib-autoprogram (>=0.1.5)"] +testing = ["freezegun (>=0.3.11)", "pathlib2 (>=2.3.3)", "pytest (>=4.0.0)", "pytest-cov (>=2.5.1)", "pytest-mock (>=1.10.0)", "pytest-xdist (>=1.22.2)", "pytest-randomly (>=1.0.0)", "flaky (>=3.4.0)", "psutil (>=5.6.1)"] + +[[package]] +category = "dev" +description = "tox plugin that makes tox use `pyenv which` to find python executables" +name = "tox-pyenv" +optional = false +python-versions = "*" +version = "1.1.0" + +[package.dependencies] +tox = ">=2.0" [[package]] category = "dev" @@ -551,7 +753,7 @@ python-versions = "*" version = "1.4.1" [[package]] -category = "dev" +category = "main" description = "Backported and Experimental Type Hints for Python 3.5+" name = "typing-extensions" optional = false @@ -571,6 +773,32 @@ brotli = ["brotlipy (>=0.6.0)"] secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "pyOpenSSL (>=0.14)", "ipaddress"] socks = ["PySocks (>=1.5.6,<1.5.7 || >1.5.7,<2.0)"] +[[package]] +category = "dev" +description = "Virtual Python Environment builder" +name = "virtualenv" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" +version = "20.0.21" + +[package.dependencies] +appdirs = ">=1.4.3,<2" +distlib = ">=0.3.0,<1" +filelock = ">=3.0.0,<4" +six = ">=1.9.0,<2" + +[package.dependencies.importlib-metadata] +python = "<3.8" +version = ">=0.12,<2" + +[package.dependencies.importlib-resources] +python = "<3.7" +version = ">=1.0,<2" + +[package.extras] +docs = ["sphinx (>=3)", "sphinx-argparse (>=0.2.5)", "sphinx-rtd-theme (>=0.4.3)", "towncrier (>=19.9.0rc1)", "proselint (>=0.10.2)"] +testing = ["pytest (>=4)", "coverage (>=5)", "coverage-enable-subprocess (>=1)", "pytest-xdist (>=1.31.0)", "pytest-mock (>=2)", "pytest-env (>=0.6.2)", "pytest-randomly (>=1)", "pytest-timeout", "packaging (>=20.0)", "xonsh (>=0.9.16)"] + [[package]] category = "dev" description = "Filesystem events monitoring" @@ -605,6 +833,18 @@ version = "1.0.1" dev = ["pytest", "pytest-timeout", "coverage", "tox", "sphinx", "pallets-sphinx-themes", "sphinx-issues"] watchdog = ["watchdog"] +[[package]] +category = "main" +description = "WebSockets state-machine based protocol implementation" +marker = "python_version >= \"3.7\" and python_version < \"3.9\"" +name = "wsproto" +optional = true +python-versions = ">=3.6.1" +version = "0.15.0" + +[package.dependencies] +h11 = ">=0.8.1" + [[package]] category = "dev" description = "A formatter for Python code." @@ -628,19 +868,29 @@ testing = ["jaraco.itertools", "func-timeout"] [extras] flask = ["flask", "blinker", "werkzeug"] +pytest = ["flask", "quart"] +quart = ["quart"] [metadata] -content-hash = "7ffdee2c10f72401b85b2586efa52355af506f57b025ec1b5ca79a72a60de0b0" +content-hash = "f0cba87045cab3d2e6c10c896c8b7823924ce13bb63a1ad0a803f748211abffc" python-versions = ">=3.6,<3.9" [metadata.files] +aiofiles = [ + {file = "aiofiles-0.5.0-py3-none-any.whl", hash = "sha256:377fdf7815cc611870c59cbd07b68b180841d2a2b79812d8c218be02448c2acb"}, + {file = "aiofiles-0.5.0.tar.gz", hash = "sha256:98e6bcfd1b50f97db4980e182ddd509b7cc35909e903a8fe50d8849e02d815af"}, +] +appdirs = [ + {file = "appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128"}, + {file = "appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41"}, +] appnope = [ {file = "appnope-0.1.0-py2.py3-none-any.whl", hash = "sha256:5b26757dc6f79a3b7dc9fab95359328d5747fcb2409d331ea66d0272b90ab2a0"}, {file = "appnope-0.1.0.tar.gz", hash = "sha256:8b995ffe925347a2138d7ac0fe77155e4311a0ea6d6da4f5128fe4b3cbe5ed71"}, ] atomicwrites = [ - {file = "atomicwrites-1.3.0-py2.py3-none-any.whl", hash = "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4"}, - {file = "atomicwrites-1.3.0.tar.gz", hash = "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6"}, + {file = "atomicwrites-1.4.0-py2.py3-none-any.whl", hash = "sha256:6d1784dea7c0c8d4a5172b6c620f40b6e4cbfdf96d783691f2e1302a7b88e197"}, + {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ {file = "attrs-19.3.0-py2.py3-none-any.whl", hash = "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c"}, @@ -662,8 +912,8 @@ chardet = [ {file = "chardet-3.0.4.tar.gz", hash = "sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae"}, ] click = [ - {file = "click-7.1.1-py2.py3-none-any.whl", hash = "sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a"}, - {file = "click-7.1.1.tar.gz", hash = "sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc"}, + {file = "click-7.1.2-py2.py3-none-any.whl", hash = "sha256:dacca89f4bfadd5de3d7489b7c8a566eee0d3676333fbb50030263894c38c0dc"}, + {file = "click-7.1.2.tar.gz", hash = "sha256:d2b5255c7c6349bc1bd1e59e08cd12acbbd63ce649f2588755783aa94dfb6b1a"}, ] colorama = [ {file = "colorama-0.4.3-py2.py3-none-any.whl", hash = "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff"}, @@ -711,21 +961,44 @@ decorator = [ {file = "decorator-4.4.2-py2.py3-none-any.whl", hash = "sha256:41fa54c2a0cc4ba648be4fd43cff00aedf5b9465c9bf18d64325bc225f08f760"}, {file = "decorator-4.4.2.tar.gz", hash = "sha256:e3a62f0520172440ca0dcc823749319382e377f37f140a0b99ef45fecb84bfe7"}, ] +distlib = [ + {file = "distlib-0.3.0.zip", hash = "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21"}, +] docopt = [ {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"}, ] -entrypoints = [ - {file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"}, - {file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"}, +filelock = [ + {file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"}, + {file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"}, ] flake8 = [ - {file = "flake8-3.7.9-py2.py3-none-any.whl", hash = "sha256:49356e766643ad15072a789a20915d3c91dc89fd313ccd71802303fd67e4deca"}, - {file = "flake8-3.7.9.tar.gz", hash = "sha256:45681a117ecc81e870cbf1262835ae4af5e7a8b08e40b944a8a6e6b895914cfb"}, + {file = "flake8-3.8.2-py2.py3-none-any.whl", hash = "sha256:ccaa799ef9893cebe69fdfefed76865aeaefbb94cb8545617b2298786a4de9a5"}, + {file = "flake8-3.8.2.tar.gz", hash = "sha256:c69ac1668e434d37a2d2880b3ca9aafd54b3a10a3ac1ab101d22f29e29cf8634"}, ] flask = [ {file = "Flask-1.1.2-py2.py3-none-any.whl", hash = "sha256:8a4fdd8936eba2512e9c85df320a37e694c93945b33ef33c89946a340a238557"}, {file = "Flask-1.1.2.tar.gz", hash = "sha256:4efa1ae2d7c9865af48986de8aeb8504bf32c7f3d6fdc9353d34b21f4b127060"}, ] +h11 = [ + {file = "h11-0.9.0-py2.py3-none-any.whl", hash = "sha256:4bc6d6a1238b7615b266ada57e0618568066f57dd6fa967d1290ec9309b2f2f1"}, + {file = "h11-0.9.0.tar.gz", hash = "sha256:33d4bca7be0fa039f4e84d50ab00531047e53d6ee8ffbc83501ea602c169cae1"}, +] +h2 = [ + {file = "h2-3.2.0-py2.py3-none-any.whl", hash = "sha256:61e0f6601fa709f35cdb730863b4e5ec7ad449792add80d1410d4174ed139af5"}, + {file = "h2-3.2.0.tar.gz", hash = "sha256:875f41ebd6f2c44781259005b157faed1a5031df3ae5aa7bcb4628a6c0782f14"}, +] +hpack = [ + {file = "hpack-3.0.0-py2.py3-none-any.whl", hash = "sha256:0edd79eda27a53ba5be2dfabf3b15780928a0dff6eb0c60a3d6767720e970c89"}, + {file = "hpack-3.0.0.tar.gz", hash = "sha256:8eec9c1f4bfae3408a3f30500261f7e6a65912dc138526ea054f9ad98892e9d2"}, +] +hypercorn = [ + {file = "Hypercorn-0.9.5-py3-none-any.whl", hash = "sha256:c53eb444d05e40ac1aacecaa6d3a8fabada90bbea8aacf617e75d41ac065c310"}, + {file = "Hypercorn-0.9.5.tar.gz", hash = "sha256:d94fa535e238ce1cd9c9b5f4cb77cb785d53069a5dc57a017e7c2fc51104ad5e"}, +] +hyperframe = [ + {file = "hyperframe-5.2.0-py2.py3-none-any.whl", hash = "sha256:5187962cb16dcc078f23cb5a4b110098d546c3f41ff2d4038a9896893bbd0b40"}, + {file = "hyperframe-5.2.0.tar.gz", hash = "sha256:a9f5c17f2cc3c719b917c4f33ed1c61bd1f8dfac4b1bd23b7c80b3400971b41f"}, +] idna = [ {file = "idna-2.9-py2.py3-none-any.whl", hash = "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"}, {file = "idna-2.9.tar.gz", hash = "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb"}, @@ -734,12 +1007,16 @@ importlib-metadata = [ {file = "importlib_metadata-1.6.0-py2.py3-none-any.whl", hash = "sha256:2a688cbaa90e0cc587f1df48bdc97a6eadccdcd9c35fb3f976a09e3b5016d90f"}, {file = "importlib_metadata-1.6.0.tar.gz", hash = "sha256:34513a8a0c4962bc66d35b359558fd8a5e10cd472d37aec5f66858addef32c1e"}, ] +importlib-resources = [ + {file = "importlib_resources-1.5.0-py2.py3-none-any.whl", hash = "sha256:85dc0b9b325ff78c8bef2e4ff42616094e16b98ebd5e3b50fe7e2f0bbcdcde49"}, + {file = "importlib_resources-1.5.0.tar.gz", hash = "sha256:6f87df66833e1942667108628ec48900e02a4ab4ad850e25fbf07cb17cf734ca"}, +] ipdb = [ {file = "ipdb-0.12.3.tar.gz", hash = "sha256:5d9a4a0e3b7027a158fc6f2929934341045b9c3b0b86ed5d7e84e409653f72fd"}, ] ipython = [ - {file = "ipython-7.13.0-py3-none-any.whl", hash = "sha256:eb8d075de37f678424527b5ef6ea23f7b80240ca031c2dd6de5879d687a65333"}, - {file = "ipython-7.13.0.tar.gz", hash = "sha256:ca478e52ae1f88da0102360e57e528b92f3ae4316aabac80a2cd7f7ab2efb48a"}, + {file = "ipython-7.14.0-py3-none-any.whl", hash = "sha256:5b241b84bbf0eb085d43ae9d46adf38a13b45929ca7774a740990c2c242534bb"}, + {file = "ipython-7.14.0.tar.gz", hash = "sha256:f0126781d0f959da852fb3089e170ed807388e986a8dd4e6ac44855845b0fb1c"}, ] ipython-genutils = [ {file = "ipython_genutils-0.2.0-py2.py3-none-any.whl", hash = "sha256:72dd37233799e619666c9f639a9da83c34013a73e8bbc79a7a6348d93c61fab8"}, @@ -792,8 +1069,8 @@ mccabe = [ {file = "mccabe-0.6.1.tar.gz", hash = "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"}, ] more-itertools = [ - {file = "more-itertools-8.2.0.tar.gz", hash = "sha256:b1ddb932186d8a6ac451e1d95844b382f55e12686d51ca0c68b6f61f2ab7a507"}, - {file = "more_itertools-8.2.0-py3-none-any.whl", hash = "sha256:5dd8bcf33e5f9513ffa06d5ad33d78f31e1931ac9a18f33d37e77a180d393a7c"}, + {file = "more-itertools-8.3.0.tar.gz", hash = "sha256:558bb897a2232f5e4f8e2399089e35aecb746e1f9191b6584a151647e89267be"}, + {file = "more_itertools-8.3.0-py3-none-any.whl", hash = "sha256:7818f596b1e87be009031c7653d01acc46ed422e6656b394b0f765ce66ed4982"}, ] mypy = [ {file = "mypy-0.720-cp35-cp35m-macosx_10_6_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:437020a39417e85e22ea8edcb709612903a9924209e10b3ec6d8c9f05b79f498"}, @@ -813,8 +1090,8 @@ mypy-extensions = [ {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, ] packaging = [ - {file = "packaging-20.3-py2.py3-none-any.whl", hash = "sha256:82f77b9bee21c1bafbf35a84905d604d5d1223801d639cf3ed140bd651c08752"}, - {file = "packaging-20.3.tar.gz", hash = "sha256:3c292b474fda1671ec57d46d739d072bfd495a4f51ad01a055121d81e952b7a3"}, + {file = "packaging-20.4-py2.py3-none-any.whl", hash = "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181"}, + {file = "packaging-20.4.tar.gz", hash = "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8"}, ] parso = [ {file = "parso-0.7.0-py2.py3-none-any.whl", hash = "sha256:158c140fc04112dc45bca311633ae5033c2c2a7b732fa33d0955bad8152a8dd0"}, @@ -835,6 +1112,10 @@ pluggy = [ {file = "pluggy-0.13.1-py2.py3-none-any.whl", hash = "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d"}, {file = "pluggy-0.13.1.tar.gz", hash = "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0"}, ] +priority = [ + {file = "priority-1.3.0-py2.py3-none-any.whl", hash = "sha256:be4fcb94b5e37cdeb40af5533afe6dd603bd665fe9c8b3052610fc1001d5d1eb"}, + {file = "priority-1.3.0.tar.gz", hash = "sha256:6bc1961a6d7fcacbfc337769f1a382c8e746566aaa365e78047abe9f66b2ffbe"}, +] prompt-toolkit = [ {file = "prompt_toolkit-3.0.3-py3-none-any.whl", hash = "sha256:c93e53af97f630f12f5f62a3274e79527936ed466f038953dfa379d4941f651a"}, {file = "prompt_toolkit-3.0.3.tar.gz", hash = "sha256:a402e9bf468b63314e37460b68ba68243d55b2f8c4d0192f85a019af3945050e"}, @@ -848,12 +1129,12 @@ py = [ {file = "py-1.8.1.tar.gz", hash = "sha256:5e27081401262157467ad6e7f851b7aa402c5852dbcb3dae06768434de5752aa"}, ] pycodestyle = [ - {file = "pycodestyle-2.5.0-py2.py3-none-any.whl", hash = "sha256:95a2219d12372f05704562a14ec30bc76b05a5b297b21a5dfe3f6fac3491ae56"}, - {file = "pycodestyle-2.5.0.tar.gz", hash = "sha256:e40a936c9a450ad81df37f549d676d127b1b66000a6c500caa2b085bc0ca976c"}, + {file = "pycodestyle-2.6.0-py2.py3-none-any.whl", hash = "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367"}, + {file = "pycodestyle-2.6.0.tar.gz", hash = "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e"}, ] pyflakes = [ - {file = "pyflakes-2.1.1-py2.py3-none-any.whl", hash = "sha256:17dbeb2e3f4d772725c777fabc446d5634d1038f234e77343108ce445ea69ce0"}, - {file = "pyflakes-2.1.1.tar.gz", hash = "sha256:d976835886f8c5b31d47970ed689944a0262b5f3afa00a5a7b4dc81e5449f8a2"}, + {file = "pyflakes-2.2.0-py2.py3-none-any.whl", hash = "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92"}, + {file = "pyflakes-2.2.0.tar.gz", hash = "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8"}, ] pygments = [ {file = "Pygments-2.6.1-py3-none-any.whl", hash = "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324"}, @@ -864,19 +1145,38 @@ pyparsing = [ {file = "pyparsing-2.4.7.tar.gz", hash = "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1"}, ] pytest = [ - {file = "pytest-5.4.1-py3-none-any.whl", hash = "sha256:0e5b30f5cb04e887b91b1ee519fa3d89049595f428c1db76e73bd7f17b09b172"}, - {file = "pytest-5.4.1.tar.gz", hash = "sha256:84dde37075b8805f3d1f392cc47e38a0e59518fb46a431cfdaf7cf1ce805f970"}, + {file = "pytest-5.4.2-py3-none-any.whl", hash = "sha256:95c710d0a72d91c13fae35dce195633c929c3792f54125919847fdcdf7caa0d3"}, + {file = "pytest-5.4.2.tar.gz", hash = "sha256:eb2b5e935f6a019317e455b6da83dd8650ac9ffd2ee73a7b657a30873d67a698"}, +] +pytest-asyncio = [ + {file = "pytest-asyncio-0.12.0.tar.gz", hash = "sha256:475bd2f3dc0bc11d2463656b3cbaafdbec5a47b47508ea0b329ee693040eebd2"}, ] pytest-watch = [ {file = "pytest-watch-4.2.0.tar.gz", hash = "sha256:06136f03d5b361718b8d0d234042f7b2f203910d8568f63df2f866b547b3d4b9"}, ] +quart = [ + {file = "Quart-0.12.0-py3-none-any.whl", hash = "sha256:f706e6eff0ef756b88d9965359f7445c9c2773551dca822deb8c018c58c00902"}, + {file = "Quart-0.12.0.tar.gz", hash = "sha256:99a65bc90d0e1260c9cc9a7dd0b8523eb6cd9f4ec146e771e98a55ee180ae6c2"}, +] requests = [ {file = "requests-2.23.0-py2.py3-none-any.whl", hash = "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee"}, {file = "requests-2.23.0.tar.gz", hash = "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"}, ] six = [ - {file = "six-1.14.0-py2.py3-none-any.whl", hash = "sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"}, - {file = "six-1.14.0.tar.gz", hash = "sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a"}, + {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, + {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, +] +toml = [ + {file = "toml-0.10.1-py2.py3-none-any.whl", hash = "sha256:bda89d5935c2eac546d648028b9901107a595863cb36bae0c73ac804a9b4ce88"}, + {file = "toml-0.10.1.tar.gz", hash = "sha256:926b612be1e5ce0634a2ca03470f95169cf16f939018233a670519cb4ac58b0f"}, +] +tox = [ + {file = "tox-3.15.1-py2.py3-none-any.whl", hash = "sha256:322dfdf007d7d53323f767badcb068a5cfa7c44d8aabb698d131b28cf44e62c4"}, + {file = "tox-3.15.1.tar.gz", hash = "sha256:8c9ad9b48659d291c5bc78bcabaa4d680d627687154b812fa52baedaa94f9f83"}, +] +tox-pyenv = [ + {file = "tox-pyenv-1.1.0.tar.gz", hash = "sha256:916c2213577aec0b3b5452c5bfb32fd077f3a3196f50a81ad57d7ef3fc2599e4"}, + {file = "tox_pyenv-1.1.0-py2.py3-none-any.whl", hash = "sha256:e470c18af115fe52eeff95e7e3cdd0793613eca19709966fc2724b79d55246cb"}, ] traitlets = [ {file = "traitlets-4.3.3-py2.py3-none-any.whl", hash = "sha256:70b4c6a1d9019d7b4f6846832288f86998aa3b9207c6821f3578a6a6a467fe44"}, @@ -914,6 +1214,10 @@ urllib3 = [ {file = "urllib3-1.25.9-py2.py3-none-any.whl", hash = "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"}, {file = "urllib3-1.25.9.tar.gz", hash = "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527"}, ] +virtualenv = [ + {file = "virtualenv-20.0.21-py2.py3-none-any.whl", hash = "sha256:a730548b27366c5e6cbdf6f97406d861cccece2e22275e8e1a757aeff5e00c70"}, + {file = "virtualenv-20.0.21.tar.gz", hash = "sha256:a116629d4e7f4d03433b8afa27f43deba09d48bc48f5ecefa4f015a178efb6cf"}, +] watchdog = [ {file = "watchdog-0.10.2.tar.gz", hash = "sha256:c560efb643faed5ef28784b2245cf8874f939569717a4a12826a173ac644456b"}, ] @@ -925,6 +1229,10 @@ werkzeug = [ {file = "Werkzeug-1.0.1-py2.py3-none-any.whl", hash = "sha256:2de2a5db0baeae7b2d2664949077c2ac63fbd16d98da0ff71837f7d1dea3fd43"}, {file = "Werkzeug-1.0.1.tar.gz", hash = "sha256:6c80b1e5ad3665290ea39320b91e1be1e0d5f60652b964a3070216de83d2e47c"}, ] +wsproto = [ + {file = "wsproto-0.15.0-py2.py3-none-any.whl", hash = "sha256:e3d190a11d9307112ba23bbe60055604949b172143969c8f641318476a9b6f1d"}, + {file = "wsproto-0.15.0.tar.gz", hash = "sha256:614798c30e5dc2b3f65acc03d2d50842b97621487350ce79a80a711229edfa9d"}, +] yapf = [ {file = "yapf-0.28.0-py2.py3-none-any.whl", hash = "sha256:02ace10a00fa2e36c7ebd1df2ead91dbfbd7989686dc4ccbdc549e95d19f5780"}, {file = "yapf-0.28.0.tar.gz", hash = "sha256:6f94b6a176a7c114cfa6bad86d40f259bbe0f10cf2fa7f2f4b3596fc5802a41b"}, diff --git a/pyproject.toml b/pyproject.toml index fd35c92..892a5cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,24 +26,30 @@ classifiers=[ ] [tool.poetry.dependencies] -python = ">=3.6,<3.9" +python = "^3.6" requests = "^2.22" flask = {version = ">=0.8", optional = true} werkzeug = {version = ">=0.7", optional = true} blinker = {version = ">=1.1", optional = true} +quart = {version = "^0.12.0", optional = true, extras = ["quart"], python = ">=3.7,<3.9"} [tool.poetry.dev-dependencies] pytest = "^5" +pytest-asyncio = "^0.12.0" +pytest-watch = "^4.2" coverage = "^4.5" flake8 = "^3.7" yapf = "^0.28.0" mypy = "^0.720.0" coveralls = "^1.8" ipdb = "^0.12.2" -pytest-watch = "^4.2" +tox-pyenv = "^1.1.0" [tool.poetry.extras] flask = ["flask", "blinker", "werkzeug"] +quart = ["quart"] +# To run the tests, we need to install the tested frameworks +pytest = ["pytest", "pytest-asyncio", "flask", "quart"] [build-system] requires = ["poetry>=0.12"] diff --git a/setup.cfg b/setup.cfg index 4db51dd..2618193 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,7 +5,7 @@ universal=1 [tool:pytest] -testpaths = . +testpaths = tests addopts = --doctest-modules flake8-ignore = migrations/*.py E402 diff --git a/tests/test_middleware_flask.py b/tests/test_middleware_flask.py index 61bf6e6..447b78c 100644 --- a/tests/test_middleware_flask.py +++ b/tests/test_middleware_flask.py @@ -1,7 +1,11 @@ import pytest import requests -from flask import Flask +try: + from flask import Flask +except ModuleNotFoundError: + pytest.skip("Flask is not installed, skipping flask-only tests", allow_module_level=True) + from unittest.mock import MagicMock, patch from datetime import datetime, timezone diff --git a/tests/test_middleware_quart.py b/tests/test_middleware_quart.py new file mode 100644 index 0000000..bae31c5 --- /dev/null +++ b/tests/test_middleware_quart.py @@ -0,0 +1,223 @@ +import pytest +import requests + +try: + from quart import Quart +except ModuleNotFoundError: + pytest.skip("Quart is not installed, skipping quart-only tests", allow_module_level=True) + +from unittest.mock import MagicMock, patch +from datetime import datetime, timezone + + +@pytest.fixture() +def app(): + app = Quart("test") + + @app.route('/') + async def index(): + return 'ok' + + @app.route('/name/') + async def names(name): + return f'ok, {name}' + + @app.route('/external-call') + async def external_call(): + requests.put('https://does-not-exist/') + return 'ok' + + @app.route('/exception') + async def exception(): + raise Exception("Unhandled exception, kaboom!") + + @app.route('/error') + async def error(): + raise SystemExit() + + @app.route('/record/') + async def records(id): + if id <= 42: + return 'ok' + # Return a 404 status code + return 'not found', 404 + + return app + + +@pytest.fixture() +def HowFastQuartMiddleware(): + """ Patch the save_point() method """ + from howfast_apm import HowFastQuartMiddleware + HowFastQuartMiddleware._save_point = MagicMock() + # Prevent the background thread to actually start + HowFastQuartMiddleware.start_background_thread = MagicMock() + return HowFastQuartMiddleware + + +@pytest.mark.asyncio +async def test_ok_without_dsn(app, HowFastQuartMiddleware): + """ The middleware should install on a Flask application even with no DSN """ + # No DSN passed + middleware = HowFastQuartMiddleware(app) + + tester = app.test_client() + response = await tester.get('/') + assert response.status_code == 200 + assert await response.get_data() == b"ok" + assert middleware._save_point.called is False + + +@pytest.mark.asyncio +async def test_ok_with_dsn(app, HowFastQuartMiddleware): + """ The middleware should install on a Flask application """ + middleware = HowFastQuartMiddleware(app, app_id='some-dsn') + + tester = app.test_client() + response = await tester.get('/') + assert response.status_code == 200 + assert await response.get_data() == b"ok" + assert middleware._save_point.called is True + assert middleware._save_point.call_count == 1 + point = middleware._save_point.call_args[1] + assert point.get('time_elapsed') > 0 + assert point.get('time_request_started') < datetime.now(timezone.utc) + assert point.get('method') == "GET" + assert point.get('response_status') == "200 OK" + assert point.get('uri') == "/" + + response = await tester.post('/does-not-exist') + assert response.status_code == 404 + assert middleware._save_point.call_count == 2 + point = middleware._save_point.call_args[1] + assert point.get('method') == "POST" + assert point.get('response_status') == "404 NOT FOUND" + assert point.get('uri') == "/does-not-exist" + + +@pytest.mark.asyncio +async def test_with_exception(app, HowFastQuartMiddleware): + """ The middleware should gracefully handle routes that raise an Exception """ + middleware = HowFastQuartMiddleware(app, app_id='some-dsn') + + tester = app.test_client() + response = await tester.get('/exception') + assert response.status_code == 500 + assert middleware._save_point.called is True + assert middleware._save_point.call_count == 1 + point = middleware._save_point.call_args[1] + assert point.get('time_elapsed') > 0 + assert point.get('time_request_started') < datetime.now(timezone.utc) + assert point.get('method') == "GET" + assert point.get('response_status') == "500 INTERNAL SERVER ERROR" + assert point.get('uri') == "/exception" + + +@pytest.mark.asyncio +async def test_with_error(app, HowFastQuartMiddleware): + """ The middleware should gracefully handle routes that raise an Error """ + middleware = HowFastQuartMiddleware(app, app_id='some-dsn') + + tester = app.test_client() + with pytest.raises(SystemExit): + # Flask will propagate the SystemExit instead of catching it + await tester.get('/error') + # However, the failure should still be logged by the middleware + assert middleware._save_point.called is True + assert middleware._save_point.call_count == 1 + point = middleware._save_point.call_args[1] + assert point.get('time_elapsed') > 0 + assert point.get('time_request_started') < datetime.now(timezone.utc) + assert point.get('method') == "GET" + assert point.get('response_status') == "500 INTERNAL SERVER ERROR" + assert point.get('uri') == "/error" + + +@pytest.mark.asyncio +async def test_with_path_parameter(app, HowFastQuartMiddleware): + """ Endpoints with a path parameter should be deduplicated """ + middleware = HowFastQuartMiddleware(app, app_id='some-dsn') + + tester = app.test_client() + response = await tester.get('/name/donald') + assert response.status_code == 200 + assert middleware._save_point.call_count == 1 + point = middleware._save_point.call_args[1] + assert point.get('endpoint_name') == "names" + assert point.get('url_rule') == "/name/" + + +@pytest.mark.asyncio +async def test_not_found(app, HowFastQuartMiddleware): + """ Requests with no matching route should have their is_not_found flag set to true """ + middleware = HowFastQuartMiddleware(app, app_id='some-dsn') + + tester = app.test_client() + response = await tester.get('/record/12') + assert response.status_code == 200 + assert middleware._save_point.call_count == 1 + point = middleware._save_point.call_args[1] + assert point.get('is_not_found') is False + middleware._save_point.reset_mock() + + response = await tester.get('/record/100') + assert response.status_code == 404 + assert middleware._save_point.call_count == 1 + point = middleware._save_point.call_args[1] + assert point.get('is_not_found') is False + middleware._save_point.reset_mock() + + response = await tester.get('/does-not-exist') + assert response.status_code == 404 + assert middleware._save_point.call_count == 1 + point = middleware._save_point.call_args[1] + assert point.get('is_not_found') is True + + +@pytest.mark.asyncio +async def test_blacklist_option(app, HowFastQuartMiddleware): + """ URLs in the blacklist should not be tracked """ + middleware = HowFastQuartMiddleware( + app, + app_id='some-dsn', + endpoints_blacklist=['/name/toto', '/name/test-*'], + ) + + tester = app.test_client() + response = await tester.get('/name/toto') + assert response.status_code == 200 + assert middleware._save_point.called is False + + # Matching with patterns + response = await tester.get('/name/test-34abc') + assert response.status_code == 200 + assert middleware._save_point.called is False + + +@patch('requests.put') +@pytest.mark.asyncio +async def test_interactions_option(app, put_mocked, HowFastQuartMiddleware): + """ The record_interactions parameter should be accepted """ + from howfast_apm import HowFastQuartMiddleware + middleware = HowFastQuartMiddleware( + app, + app_id='some-dsn', + record_interactions=True, + ) + + tester = app.test_client() + response = await tester.get('/external-call') + assert response.status_code == 200 + assert put_mocked.called is True + assert middleware._save_point.called is True + + # This assumes that _save_point is static and is not responsible for emptying the list of + # interactions... + assert len(middleware.interactions) == 0, \ + "after the point is saved, the interaction list should be empty for the next point" + + point = middleware._save_point.call_args[1] + assert len(point.get('interactions')) == 1 + [interaction] = point['interactions'] + assert interaction.interaction_type == 'request' + assert interaction.name == 'https://does-not-exist/' diff --git a/tox.ini b/tox.ini index 3055fef..f0e0caf 100644 --- a/tox.ini +++ b/tox.ini @@ -1,20 +1,26 @@ [tox] envlist = + {py37,py38}-quart {py36,py37,py38}-flask-{1.1,1.0,0.11,0.12,dev} isolated_build = true [testenv] -whitelist_externals = poetry - setenv = PYTHONWARNINGS = all + # When using Poetry with Tox, Pytest starts acting up. This variable was introduced largely + # because of the issues caused when using tox, see https://github.com/pytest-dev/pytest/issues/2042 + PY_IGNORE_IMPORTMISMATCH = 1 extras = flask: flask + quart: quart + # There is a pytest extra, that provides required plugins like pytest-asyncio + pytest: pytest deps = pytest + quart: quart>=0.12.0 flask-0.11: Flask>=0.11,<0.12 flask-0.12: Flask>=0.12,<0.13 flask-1.0: Flask>=1.0,<1.1 @@ -22,4 +28,4 @@ deps = flask-dev: git+https://github.com/pallets/flask.git#egg=flask commands = - poetry run pytest {posargs} + pytest {posargs}