From c38c71b9285e71de94d0185ff3c5bf65ee163345 Mon Sep 17 00:00:00 2001 From: Taishi Ikai Date: Fri, 10 Mar 2023 02:48:17 +0900 Subject: [PATCH] Add FastAPI extension (#1124) --- README.rst | 1 + contrib/opencensus-ext-fastapi/CHANGELOG.md | 7 + contrib/opencensus-ext-fastapi/README.rst | 50 +++++ .../opencensus/__init__.py | 1 + .../opencensus/ext/__init__.py | 1 + .../opencensus/ext/fastapi/__init__.py | 1 + .../ext/fastapi/fastapi_middleware.py | 182 ++++++++++++++++ contrib/opencensus-ext-fastapi/setup.cfg | 2 + contrib/opencensus-ext-fastapi/setup.py | 49 +++++ .../tests/test_fastapi_middleware.py | 197 ++++++++++++++++++ contrib/opencensus-ext-fastapi/version.py | 15 ++ noxfile.py | 1 + opencensus/trace/integrations.py | 1 + tox.ini | 11 +- 14 files changed, 514 insertions(+), 5 deletions(-) create mode 100644 contrib/opencensus-ext-fastapi/CHANGELOG.md create mode 100644 contrib/opencensus-ext-fastapi/README.rst create mode 100644 contrib/opencensus-ext-fastapi/opencensus/__init__.py create mode 100644 contrib/opencensus-ext-fastapi/opencensus/ext/__init__.py create mode 100644 contrib/opencensus-ext-fastapi/opencensus/ext/fastapi/__init__.py create mode 100644 contrib/opencensus-ext-fastapi/opencensus/ext/fastapi/fastapi_middleware.py create mode 100644 contrib/opencensus-ext-fastapi/setup.cfg create mode 100644 contrib/opencensus-ext-fastapi/setup.py create mode 100644 contrib/opencensus-ext-fastapi/tests/test_fastapi_middleware.py create mode 100644 contrib/opencensus-ext-fastapi/version.py diff --git a/README.rst b/README.rst index 5edf1ed77..8c8653a20 100644 --- a/README.rst +++ b/README.rst @@ -241,6 +241,7 @@ Trace Exporter .. _Datadog: https://github.com/census-instrumentation/opencensus-python/tree/master/contrib/opencensus-ext-datadog .. _Django: https://github.com/census-instrumentation/opencensus-python/tree/master/contrib/opencensus-ext-django .. _Flask: https://github.com/census-instrumentation/opencensus-python/tree/master/contrib/opencensus-ext-flask +.. _FastAPI: https://github.com/census-instrumentation/opencensus-python/tree/master/contrib/opencensus-ext-fastapi .. _gevent: https://github.com/census-instrumentation/opencensus-python/tree/master/contrib/opencensus-ext-gevent .. _Google Cloud Client Libraries: https://github.com/census-instrumentation/opencensus-python/tree/master/contrib/opencensus-ext-google-cloud-clientlibs .. _gRPC: https://github.com/census-instrumentation/opencensus-python/tree/master/contrib/opencensus-ext-grpc diff --git a/contrib/opencensus-ext-fastapi/CHANGELOG.md b/contrib/opencensus-ext-fastapi/CHANGELOG.md new file mode 100644 index 000000000..5558d8b57 --- /dev/null +++ b/contrib/opencensus-ext-fastapi/CHANGELOG.md @@ -0,0 +1,7 @@ +# Changelog + +## Unreleased + +## 0.1.0 + +- Initial version diff --git a/contrib/opencensus-ext-fastapi/README.rst b/contrib/opencensus-ext-fastapi/README.rst new file mode 100644 index 000000000..7946d56ed --- /dev/null +++ b/contrib/opencensus-ext-fastapi/README.rst @@ -0,0 +1,50 @@ +OpenCensus FastAPI Integration +============================================================================ + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opencensus-ext-fastapi.svg + :target: https://pypi.org/project/opencensus-ext-fastapi/ + +Installation +------------ + +:: + + pip install opencensus-ext-fastapi + +Usage +----- + +.. code:: python + + from fastapi import FastAPI + from opencensus.ext.fastapi.fastapi_middleware import FastAPIMiddleware + + app = FastAPI(__name__) + app.add_middleware(FastAPIMiddleware) + + @app.get('/') + def hello(): + return 'Hello World!' + +Additional configuration can be provided, please read +`Customization `_ +for a complete reference. + +.. code:: python + + app.add_middleware( + FastAPIMiddleware, + excludelist_paths=["paths"], + excludelist_hostnames=["hostnames"], + sampler=sampler, + exporter=exporter, + propagator=propagator, + ) + + +References +---------- + +* `OpenCensus Project `_ diff --git a/contrib/opencensus-ext-fastapi/opencensus/__init__.py b/contrib/opencensus-ext-fastapi/opencensus/__init__.py new file mode 100644 index 000000000..69e3be50d --- /dev/null +++ b/contrib/opencensus-ext-fastapi/opencensus/__init__.py @@ -0,0 +1 @@ +__path__ = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/contrib/opencensus-ext-fastapi/opencensus/ext/__init__.py b/contrib/opencensus-ext-fastapi/opencensus/ext/__init__.py new file mode 100644 index 000000000..69e3be50d --- /dev/null +++ b/contrib/opencensus-ext-fastapi/opencensus/ext/__init__.py @@ -0,0 +1 @@ +__path__ = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/contrib/opencensus-ext-fastapi/opencensus/ext/fastapi/__init__.py b/contrib/opencensus-ext-fastapi/opencensus/ext/fastapi/__init__.py new file mode 100644 index 000000000..69e3be50d --- /dev/null +++ b/contrib/opencensus-ext-fastapi/opencensus/ext/fastapi/__init__.py @@ -0,0 +1 @@ +__path__ = __import__('pkgutil').extend_path(__path__, __name__) diff --git a/contrib/opencensus-ext-fastapi/opencensus/ext/fastapi/fastapi_middleware.py b/contrib/opencensus-ext-fastapi/opencensus/ext/fastapi/fastapi_middleware.py new file mode 100644 index 000000000..6dfd1a812 --- /dev/null +++ b/contrib/opencensus-ext-fastapi/opencensus/ext/fastapi/fastapi_middleware.py @@ -0,0 +1,182 @@ +# Copyright 2022, OpenCensus Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import traceback +from typing import Union + +from starlette.middleware.base import ( + BaseHTTPMiddleware, + RequestResponseEndpoint, +) +from starlette.requests import Request +from starlette.responses import Response +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR +from starlette.types import ASGIApp + +from opencensus.trace import ( + attributes_helper, + execution_context, + integrations, + print_exporter, + samplers, +) +from opencensus.trace import span as span_module +from opencensus.trace import tracer as tracer_module +from opencensus.trace import utils +from opencensus.trace.blank_span import BlankSpan +from opencensus.trace.propagation import trace_context_http_header_format +from opencensus.trace.span import Span + +HTTP_HOST = attributes_helper.COMMON_ATTRIBUTES["HTTP_HOST"] +HTTP_METHOD = attributes_helper.COMMON_ATTRIBUTES["HTTP_METHOD"] +HTTP_PATH = attributes_helper.COMMON_ATTRIBUTES["HTTP_PATH"] +HTTP_ROUTE = attributes_helper.COMMON_ATTRIBUTES["HTTP_ROUTE"] +HTTP_URL = attributes_helper.COMMON_ATTRIBUTES["HTTP_URL"] +HTTP_STATUS_CODE = attributes_helper.COMMON_ATTRIBUTES["HTTP_STATUS_CODE"] +ERROR_MESSAGE = attributes_helper.COMMON_ATTRIBUTES['ERROR_MESSAGE'] +ERROR_NAME = attributes_helper.COMMON_ATTRIBUTES['ERROR_NAME'] +STACKTRACE = attributes_helper.COMMON_ATTRIBUTES["STACKTRACE"] + +module_logger = logging.getLogger(__name__) + + +class FastAPIMiddleware(BaseHTTPMiddleware): + """FastAPI middleware to automatically trace requests. + + :type app: :class: `~fastapi.FastAPI` + :param app: A fastapi application. + + :type excludelist_paths: list + :param excludelist_paths: Paths that do not trace. + + :type excludelist_hostnames: list + :param excludelist_hostnames: Hostnames that do not trace. + + :type sampler: :class:`~opencensus.trace.samplers.base.Sampler` + :param sampler: A sampler. It should extend from the base + :class:`.Sampler` type and implement + :meth:`.Sampler.should_sample`. Defaults to + :class:`.ProbabilitySampler`. Other options include + :class:`.AlwaysOnSampler` and :class:`.AlwaysOffSampler`. + + :type exporter: :class:`~opencensus.trace.base_exporter.exporter` + :param exporter: An exporter. Default to + :class:`.PrintExporter`. The rest options are + :class:`.FileExporter`, :class:`.LoggingExporter` and + trace exporter extensions. + + :type propagator: :class: 'object' + :param propagator: A propagator. Default to + :class:`.TraceContextPropagator`. The rest options + are :class:`.BinaryFormatPropagator`, + :class:`.GoogleCloudFormatPropagator` and + :class:`.TextFormatPropagator`. + """ + + def __init__( + self, + app: ASGIApp, + excludelist_paths=None, + excludelist_hostnames=None, + sampler=None, + exporter=None, + propagator=None, + ) -> None: + super().__init__(app) + self.excludelist_paths = excludelist_paths + self.excludelist_hostnames = excludelist_hostnames + self.sampler = sampler or samplers.AlwaysOnSampler() + self.exporter = exporter or print_exporter.PrintExporter() + self.propagator = ( + propagator or + trace_context_http_header_format.TraceContextPropagator() + ) + + # pylint: disable=protected-access + integrations.add_integration(integrations._Integrations.FASTAPI) + + def _prepare_tracer(self, request: Request) -> tracer_module.Tracer: + span_context = self.propagator.from_headers(request.headers) + tracer = tracer_module.Tracer( + span_context=span_context, + sampler=self.sampler, + exporter=self.exporter, + propagator=self.propagator, + ) + return tracer + + def _before_request(self, span: Union[Span, BlankSpan], request: Request): + span.span_kind = span_module.SpanKind.SERVER + span.name = "[{}]{}".format(request.method, request.url) + span.add_attribute(HTTP_HOST, request.url.hostname) + span.add_attribute(HTTP_METHOD, request.method) + span.add_attribute(HTTP_PATH, request.url.path) + span.add_attribute(HTTP_URL, str(request.url)) + span.add_attribute(HTTP_ROUTE, request.url.path) + execution_context.set_opencensus_attr( + "excludelist_hostnames", self.excludelist_hostnames + ) + + def _after_request(self, span: Union[Span, BlankSpan], response: Response): + span.add_attribute(HTTP_STATUS_CODE, response.status_code) + + def _handle_exception(self, + span: Union[Span, BlankSpan], exception: Exception): + span.add_attribute(ERROR_NAME, exception.__class__.__name__) + span.add_attribute(ERROR_MESSAGE, str(exception)) + span.add_attribute( + STACKTRACE, + "\n".join(traceback.format_tb(exception.__traceback__))) + span.add_attribute(HTTP_STATUS_CODE, HTTP_500_INTERNAL_SERVER_ERROR) + + async def dispatch( + self, request: Request, call_next: RequestResponseEndpoint + ) -> Response: + + # Do not trace if the url is in the exclude list + if utils.disable_tracing_url(str(request.url), self.excludelist_paths): + return await call_next(request) + + try: + tracer = self._prepare_tracer(request) + span = tracer.start_span() + except Exception: # pragma: NO COVER + module_logger.error("Failed to trace request", exc_info=True) + return await call_next(request) + + try: + self._before_request(span, request) + except Exception: # pragma: NO COVER + module_logger.error("Failed to trace request", exc_info=True) + + try: + response = await call_next(request) + except Exception as err: # pragma: NO COVER + try: + self._handle_exception(span, err) + tracer.end_span() + tracer.finish() + except Exception: # pragma: NO COVER + module_logger.error("Failed to trace response", exc_info=True) + raise err + + try: + self._after_request(span, response) + tracer.end_span() + tracer.finish() + except Exception: # pragma: NO COVER + module_logger.error("Failed to trace response", exc_info=True) + + return response diff --git a/contrib/opencensus-ext-fastapi/setup.cfg b/contrib/opencensus-ext-fastapi/setup.cfg new file mode 100644 index 000000000..2a9acf13d --- /dev/null +++ b/contrib/opencensus-ext-fastapi/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 diff --git a/contrib/opencensus-ext-fastapi/setup.py b/contrib/opencensus-ext-fastapi/setup.py new file mode 100644 index 000000000..f4ade731e --- /dev/null +++ b/contrib/opencensus-ext-fastapi/setup.py @@ -0,0 +1,49 @@ +# Copyright 2022, OpenCensus Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from setuptools import find_packages, setup + +from version import __version__ + +setup( + name='opencensus-ext-fastapi', + version=__version__, # noqa + author='OpenCensus Authors', + author_email='census-developers@googlegroups.com', + classifiers=[ + 'Intended Audience :: Developers', + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + ], + description='OpenCensus FastAPI Integration', + include_package_data=True, + long_description=open('README.rst').read(), + install_requires=[ + 'fastapi >= 0.75.2', + 'opencensus >= 0.9.dev0, < 1.0.0', + ], + extras_require={}, + license='Apache-2.0', + packages=find_packages(exclude=('tests',)), + namespace_packages=[], + url='https://github.com/census-instrumentation/opencensus-python/tree/master/contrib/opencensus-ext-fastapi', # noqa: E501 + zip_safe=False, +) diff --git a/contrib/opencensus-ext-fastapi/tests/test_fastapi_middleware.py b/contrib/opencensus-ext-fastapi/tests/test_fastapi_middleware.py new file mode 100644 index 000000000..e2d8f113f --- /dev/null +++ b/contrib/opencensus-ext-fastapi/tests/test_fastapi_middleware.py @@ -0,0 +1,197 @@ +# Copyright 2022, OpenCensus Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import sys +import traceback +import unittest +from unittest.mock import ANY + +import mock +from fastapi import FastAPI +from starlette.testclient import TestClient + +from opencensus.ext.fastapi.fastapi_middleware import FastAPIMiddleware +from opencensus.trace import print_exporter, samplers +from opencensus.trace import span as span_module +from opencensus.trace import tracer as tracer_module +from opencensus.trace.propagation import trace_context_http_header_format + + +class FastAPITestException(Exception): + pass + + +class TestFastAPIMiddleware(unittest.TestCase): + + def tearDown(self) -> None: + from opencensus.trace import execution_context + execution_context.clear() + + return super().tearDown() + + def create_app(self): + app = FastAPI() + + @app.get('/') + def index(): + return 'test fastapi trace' # pragma: NO COVER + + @app.get('/wiki/{entry}') + def wiki(entry): + return 'test fastapi trace' # pragma: NO COVER + + @app.get('/health') + def health_check(): + return 'test health check' # pragma: NO COVER + + @app.get('/error') + def error(): + raise FastAPITestException('test error') + + return app + + def test_constructor_default(self): + app = self.create_app() + middleware = FastAPIMiddleware(app) + + self.assertIs(middleware.app, app) + self.assertIsNone(middleware.excludelist_paths) + self.assertIsNone(middleware.excludelist_hostnames) + self.assertIsInstance(middleware.sampler, samplers.AlwaysOnSampler) + self.assertIsInstance(middleware.exporter, + print_exporter.PrintExporter) + self.assertIsInstance( + middleware.propagator, + trace_context_http_header_format.TraceContextPropagator) + + def test_constructor_explicit(self): + excludelist_paths = mock.Mock() + excludelist_hostnames = mock.Mock() + sampler = mock.Mock() + exporter = mock.Mock() + propagator = mock.Mock() + + app = self.create_app() + middleware = FastAPIMiddleware( + app=app, + excludelist_paths=excludelist_paths, + excludelist_hostnames=excludelist_hostnames, + sampler=sampler, + exporter=exporter, + propagator=propagator) + + self.assertEqual(middleware.app, app) + self.assertEqual(middleware.excludelist_paths, excludelist_paths) + self.assertEqual( + middleware.excludelist_hostnames, excludelist_hostnames) + self.assertEqual(middleware.sampler, sampler) + self.assertEqual(middleware.exporter, exporter) + self.assertEqual(middleware.propagator, propagator) + + @mock.patch.object(tracer_module.Tracer, "finish") + @mock.patch.object(tracer_module.Tracer, "end_span") + @mock.patch.object(tracer_module.Tracer, "start_span") + def test_request(self, mock_m1, mock_m2, mock_m3): + app = self.create_app() + app.add_middleware( + FastAPIMiddleware, sampler=samplers.AlwaysOnSampler()) + + test_client = TestClient(app) + test_client.get("/wiki/Rabbit") + + mock_span = mock_m1.return_value + self.assertEqual(mock_span.add_attribute.call_count, 6) + mock_span.add_attribute.assert_has_calls([ + mock.call("http.host", "testserver"), + mock.call("http.method", "GET"), + mock.call("http.path", "/wiki/Rabbit"), + mock.call("http.url", "http://testserver/wiki/Rabbit"), + mock.call("http.route", "/wiki/Rabbit"), + mock.call("http.status_code", 200) + ]) + mock_m2.assert_called_once() + mock_m3.assert_called_once() + + self.assertEqual( + mock_span.span_kind, + span_module.SpanKind.SERVER) + self.assertEqual( + mock_span.name, + "[{}]{}".format("GET", "http://testserver/wiki/Rabbit")) + + @mock.patch.object(FastAPIMiddleware, "_prepare_tracer") + def test_request_excludelist(self, mock_m): + app = self.create_app() + app.add_middleware( + FastAPIMiddleware, + excludelist_paths=["health"], + sampler=samplers.AlwaysOnSampler()) + + test_client = TestClient(app) + test_client.get("/health") + + mock_m.assert_not_called() + + @mock.patch.object(tracer_module.Tracer, "finish") + @mock.patch.object(tracer_module.Tracer, "end_span") + @mock.patch.object(tracer_module.Tracer, "start_span") + def test_request_exception(self, mock_m1, mock_m2, mock_m3): + app = self.create_app() + app.add_middleware(FastAPIMiddleware) + + test_client = TestClient(app) + + with self.assertRaises(FastAPITestException): + test_client.get("/error") + + mock_span = mock_m1.return_value + self.assertEqual(mock_span.add_attribute.call_count, 9) + mock_span.add_attribute.assert_has_calls([ + mock.call("http.host", "testserver"), + mock.call("http.method", "GET"), + mock.call("http.path", "/error"), + mock.call("http.url", "http://testserver/error"), + mock.call("http.route", "/error"), + mock.call("error.name", "FastAPITestException"), + mock.call("error.message", "test error"), + mock.call("stacktrace", ANY), + mock.call("http.status_code", 500) + ]) + mock_m2.assert_called_once() + mock_m3.assert_called_once() + + def test_request_exception_stacktrace(self): + tb = None + try: + raise RuntimeError("bork bork bork") + except Exception as exc: + test_exception = exc + if hasattr(exc, "__traceback__"): + tb = exc.__traceback__ + else: + _, _, tb = sys.exc_info() + + app = self.create_app() + middleware = FastAPIMiddleware(app) + + mock_span = mock.Mock() + mock_span.add_attribute = mock.Mock() + middleware._handle_exception(mock_span, test_exception) + + mock_span.add_attribute.assert_has_calls([ + mock.call("error.name", "RuntimeError"), + mock.call("error.message", "bork bork bork"), + mock.call("stacktrace", "\n".join(traceback.format_tb(tb))), + mock.call("http.status_code", 500) + ]) diff --git a/contrib/opencensus-ext-fastapi/version.py b/contrib/opencensus-ext-fastapi/version.py new file mode 100644 index 000000000..50e4d191e --- /dev/null +++ b/contrib/opencensus-ext-fastapi/version.py @@ -0,0 +1,15 @@ +# Copyright 2022, OpenCensus Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__version__ = '0.2.dev0' diff --git a/noxfile.py b/noxfile.py index 466079fc8..aa9d3dba6 100644 --- a/noxfile.py +++ b/noxfile.py @@ -28,6 +28,7 @@ def _install_dev_packages(session): session.install('-e', 'contrib/opencensus-ext-datadog') session.install('-e', 'contrib/opencensus-ext-dbapi') session.install('-e', 'contrib/opencensus-ext-django') + session.install('-e', 'contrib/opencensus-ext-fastapi') session.install('-e', 'contrib/opencensus-ext-flask') session.install('-e', 'contrib/opencensus-ext-gevent') session.install('-e', 'contrib/opencensus-ext-grpc') diff --git a/opencensus/trace/integrations.py b/opencensus/trace/integrations.py index c5b09a850..7a58f4911 100644 --- a/opencensus/trace/integrations.py +++ b/opencensus/trace/integrations.py @@ -33,6 +33,7 @@ class _Integrations: REQUESTS = 1024 SQLALCHEMY = 2056 HTTPX = 4096 + FASTAPI = 8192 def get_integrations(): diff --git a/tox.ini b/tox.ini index 3f7f28d1a..9802276e5 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = +envlist = py{27,35,36,37,38,39}-unit py39-bandit py39-lint @@ -12,7 +12,7 @@ unit-base-command = py.test --quiet --cov={envdir}/opencensus --cov=context --co [testenv] install_command = python -m pip install {opts} {packages} -deps = +deps = unit,lint: mock==3.0.5 unit,lint: pytest==4.6.4 unit,lint: pytest-cov @@ -30,6 +30,7 @@ deps = ; unit,lint: -e contrib/opencensus-ext-datadog unit,lint,bandit: -e contrib/opencensus-ext-dbapi unit,lint,bandit: -e contrib/opencensus-ext-django + py3{6,7,8,9}-unit,lint,bandit: -e contrib/opencensus-ext-fastapi unit,lint,bandit: -e contrib/opencensus-ext-flask unit,lint,bandit: -e contrib/opencensus-ext-gevent unit,lint,bandit: -e contrib/opencensus-ext-grpc @@ -45,7 +46,7 @@ deps = unit,lint,bandit: -e contrib/opencensus-ext-pymysql unit,lint,bandit: -e contrib/opencensus-ext-pyramid unit,lint,bandit: -e contrib/opencensus-ext-requests - py3{7,8,9}-unit,lint,bandit: -e contrib/opencensus-ext-httpx + py3{7,8,9}-unit,lint,bandit: -e contrib/opencensus-ext-httpx unit,lint,bandit: -e contrib/opencensus-ext-sqlalchemy py3{6,7,8,9}-unit,lint,bandit: -e contrib/opencensus-ext-stackdriver unit,lint,bandit: -e contrib/opencensus-ext-threading @@ -58,8 +59,8 @@ deps = docs: setuptools >= 36.4.0 docs: sphinx >= 1.6.3 -commands = - py{27,34,35}-unit: {[constants]unit-base-command} --ignore=contrib/opencensus-ext-stackdriver --ignore=contrib/opencensus-ext-flask --ignore=contrib/opencensus-ext-httpx +commands = + py{27,34,35}-unit: {[constants]unit-base-command} --ignore=contrib/opencensus-ext-stackdriver --ignore=contrib/opencensus-ext-flask --ignore=contrib/opencensus-ext-httpx --ignore=contrib/opencensus-ext-fastapi py36-unit: {[constants]unit-base-command} --ignore=contrib/opencensus-ext-httpx py3{7,8,9}-unit: {[constants]unit-base-command}