diff --git a/.github/workflows/license-audit.yml b/.github/workflows/license-audit.yml index 5f0998ea..951828ca 100644 --- a/.github/workflows/license-audit.yml +++ b/.github/workflows/license-audit.yml @@ -4,9 +4,7 @@ on: [push, pull_request] jobs: license-audit: - # TODO: a GH action update broke the 'ubuntu-latest' image - # when it's fixed, we should switch back - runs-on: ubuntu-20.04 + runs-on: 'ubuntu-latest' steps: - uses: actions/checkout@v2 @@ -14,8 +12,8 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - # License Finder's Docker image uses Python 3.5 - python-version: 3.5 + # License Finder's Docker image uses Python 3.10 + python-version: '3.10' - name: Fetch decisions.yml run: curl https://raw.githubusercontent.com/bugsnag/license-audit/master/config/decision_files/global.yml -o decisions.yml @@ -32,6 +30,10 @@ jobs: run: > docker run -v $PWD:/scan licensefinder/license_finder /bin/bash -lc " cd /scan && - pip3 install -r requirements.txt --quiet && + apt-get update && + apt-get install -y python3-venv && + python3 -m venv .venv && + source .venv/bin/activate && + pip3 install -r requirements.txt && license_finder --decisions-file decisions.yml --python-version 3 --enabled-package-managers=pip " diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 6525b40a..06de675a 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -14,16 +14,19 @@ jobs: include: - python-version: '3.5' os: 'ubuntu-20.04' + pip-trusted-host: 'pypi.python.org pypi.org files.pythonhosted.org' - python-version: '3.6' os: 'ubuntu-20.04' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} + env: + PIP_TRUSTED_HOST: ${{ matrix.pip-trusted-host }} - name: Install dependencies run: | diff --git a/CHANGELOG.md b/CHANGELOG.md index 382946bb..a856b6d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ Changelog ========= +## v4.7.1 (2024-05-22) + +### Bug fixes + +* Avoid reading `__code__` when setting a custom delivery unless it exists + [#387](https://github.com/bugsnag/bugsnag-python/pull/387) + ## v4.7.0 (2024-04-24) ### Enhancements diff --git a/bugsnag/configuration.py b/bugsnag/configuration.py index f96752b8..114de35b 100644 --- a/bugsnag/configuration.py +++ b/bugsnag/configuration.py @@ -279,7 +279,11 @@ def delivery(self, value): # this should be made mandatory in the next major release if ( hasattr(value, 'deliver_sessions') and - callable(value.deliver_sessions) + callable(value.deliver_sessions) and + # Mock objects don't allow accessing or mocking '__code__' so + # ensure it exists before attempting to read it + # __code__ should always be present in a real delivery object + hasattr(value.deliver_sessions, '__code__') ): parameter_names = value.deliver_sessions.__code__.co_varnames diff --git a/bugsnag/delivery.py b/bugsnag/delivery.py index acd2da2b..6dc8cd05 100644 --- a/bugsnag/delivery.py +++ b/bugsnag/delivery.py @@ -93,7 +93,6 @@ def deliver_sessions(self, config, payload: Any, options=None): options = {} options['endpoint'] = config.session_endpoint - options['success'] = 202 self.deliver(config, payload, options) @@ -151,10 +150,14 @@ def request(): status = resp.getcode() if 'success' in options: - success = options['success'] + # if an expected status code has been given then it must match + # exactly with the actual status code + success = status == options['success'] else: - success = 200 - if status != success: + # warn if we don't get a 2xx status code by default + success = status >= 200 and status < 300 + + if not success: config.logger.warning( 'Delivery to %s failed, status %d' % (uri, status) ) @@ -184,12 +187,16 @@ def request(): response = requests.post(uri, **req_options) status = response.status_code + if 'success' in options: - success = options['success'] + # if an expected status code has been given then it must match + # exactly with the actual status code + success = status == options['success'] else: - success = requests.codes.ok + # warn if we don't get a 2xx status code by default + success = status >= 200 and status < 300 - if status != success: + if not success: config.logger.warning( 'Delivery to %s failed, status %d' % (uri, status) ) diff --git a/bugsnag/notifier.py b/bugsnag/notifier.py index 26284415..6f011f0e 100644 --- a/bugsnag/notifier.py +++ b/bugsnag/notifier.py @@ -1,5 +1,5 @@ _NOTIFIER_INFORMATION = { 'name': 'Python Bugsnag Notifier', 'url': 'https://github.com/bugsnag/bugsnag-python', - 'version': '4.7.0' + 'version': '4.7.1' } diff --git a/bugsnag/sessiontracker.py b/bugsnag/sessiontracker.py index edb8bf26..adc31537 100644 --- a/bugsnag/sessiontracker.py +++ b/bugsnag/sessiontracker.py @@ -139,7 +139,10 @@ def __deliver(self, sessions: List[Dict], asynchronous=True): deliver = self.config.delivery.deliver_sessions - if 'options' in deliver.__code__.co_varnames: + if ( + hasattr(deliver, '__code__') and + 'options' in deliver.__code__.co_varnames + ): try: post_delivery_callback = self._request_tracker.new_request() diff --git a/features/celery.feature b/features/celery.feature new file mode 100644 index 00000000..c78ce0c9 --- /dev/null +++ b/features/celery.feature @@ -0,0 +1,142 @@ +Feature: Celery + +Scenario Outline: Handled exceptions are delivered in Celery + Given I start the service "celery-" + When I execute the command "python bugsnag_celery_test_app/queue_task.py handled" in the service "celery-" + And I wait to receive an error + Then the error is valid for the error reporting API version "4.0" for the "Python Bugsnag Notifier" notifier + And the exception "errorClass" equals "Exception" + And the exception "message" equals "oooh nooo" + And the event "unhandled" is false + And the event "severity" equals "warning" + And the event "severityReason.type" equals "handledException" + And the event "device.runtimeVersions.celery" matches "\.\d+\.\d+" + + @not-python-3.11 @not-python-3.12 + Examples: + | celery-version | + | 4 | + + @not-python-3.5 + Examples: + | celery-version | + | 5 | + +Scenario Outline: Unhandled exceptions are delivered in Celery + Given I start the service "celery-" + When I execute the command "python bugsnag_celery_test_app/queue_task.py unhandled" in the service "celery-" + And I wait to receive an error + Then the error is valid for the error reporting API version "4.0" for the "Python Bugsnag Notifier" notifier + And the exception "errorClass" equals "KeyError" + And the exception "message" equals "'b'" + And the event "unhandled" is true + And the event "severity" equals "error" + And the event "severityReason.type" equals "unhandledExceptionMiddleware" + And the event "severityReason.attributes.framework" equals "Celery" + And the event "device.runtimeVersions.celery" matches "\.\d+\.\d+" + And the event "context" equals "bugsnag_celery_test_app.tasks.unhandled" + And the event "metaData.extra_data.task_id" is not null + # these aren't strings but the maze runner step works on arrays and hashes + And the event "metaData.extra_data.args" string is empty + And the event "metaData.extra_data.kwargs" string is empty + + @not-python-3.11 @not-python-3.12 + Examples: + | celery-version | + | 4 | + + @not-python-3.5 + Examples: + | celery-version | + | 5 | + +Scenario Outline: Task arguments are added to metadata in Celery + Given I start the service "celery-" + When I execute the command "python bugsnag_celery_test_app/queue_task.py add 1 2 3 '4' a=100 b=200" in the service "celery-" + And I wait to receive an error + Then the error is valid for the error reporting API version "4.0" for the "Python Bugsnag Notifier" notifier + And the exception "errorClass" equals "AssertionError" + And the exception "message" equals "" + And the event "unhandled" is true + And the event "severity" equals "error" + And the event "severityReason.type" equals "unhandledExceptionMiddleware" + And the event "severityReason.attributes.framework" equals "Celery" + And the event "device.runtimeVersions.celery" matches "\.\d+\.\d+" + And the event "context" equals "bugsnag_celery_test_app.tasks.add" + And the event "metaData.extra_data.task_id" is not null + And the error payload field "events.0.metaData.extra_data.args" is an array with 4 elements + And the event "metaData.extra_data.args.0" equals "1" + And the event "metaData.extra_data.args.1" equals "2" + And the event "metaData.extra_data.args.2" equals "3" + And the event "metaData.extra_data.args.3" equals "4" + And the event "metaData.extra_data.kwargs.a" equals "100" + And the event "metaData.extra_data.kwargs.b" equals "200" + + @not-python-3.11 @not-python-3.12 + Examples: + | celery-version | + | 4 | + + @not-python-3.5 + Examples: + | celery-version | + | 5 | + +Scenario Outline: Errors in shared tasks are reported in Celery + Given I start the service "celery-" + When I execute the command "python bugsnag_celery_test_app/queue_task.py divide 10 0" in the service "celery-" + And I wait to receive an error + Then the error is valid for the error reporting API version "4.0" for the "Python Bugsnag Notifier" notifier + And the exception "errorClass" equals "ZeroDivisionError" + And the exception "message" equals "division by zero" + And the event "unhandled" is true + And the event "severity" equals "error" + And the event "severityReason.type" equals "unhandledExceptionMiddleware" + And the event "severityReason.attributes.framework" equals "Celery" + And the event "device.runtimeVersions.celery" matches "\.\d+\.\d+" + And the event "context" equals "bugsnag_celery_test_app.tasks.divide" + And the event "metaData.extra_data.task_id" is not null + And the error payload field "events.0.metaData.extra_data.args" is an array with 2 elements + And the event "metaData.extra_data.args.0" equals "10" + And the event "metaData.extra_data.args.1" equals "0" + And the event "metaData.extra_data.kwargs" string is empty + + @not-python-3.11 @not-python-3.12 + Examples: + | celery-version | + | 4 | + + @not-python-3.5 + Examples: + | celery-version | + | 5 | + +Scenario Outline: Successful tasks do not report errors in Celery + Given I start the service "celery-" + When I execute the command "python bugsnag_celery_test_app/queue_task.py add 1 2 3 4 5 6 7 a=8 b=9" in the service "celery-" + Then I should receive no errors + + @not-python-3.11 @not-python-3.12 + Examples: + | celery-version | + | 4 | + + @not-python-3.5 + Examples: + | celery-version | + | 5 | + +Scenario Outline: Successful shared tasks do not report errors in Celery + Given I start the service "celery-" + When I execute the command "python bugsnag_celery_test_app/queue_task.py divide 10 2" in the service "celery-" + Then I should receive no errors + + @not-python-3.11 @not-python-3.12 + Examples: + | celery-version | + | 4 | + + @not-python-3.5 + Examples: + | celery-version | + | 5 | diff --git a/features/fixtures/celery/Dockerfile b/features/fixtures/celery/Dockerfile new file mode 100644 index 00000000..fddff847 --- /dev/null +++ b/features/fixtures/celery/Dockerfile @@ -0,0 +1,12 @@ +ARG PYTHON_TEST_VERSION +FROM python:$PYTHON_TEST_VERSION + +COPY app/ /usr/src/app +COPY temp-bugsnag-python/ /usr/src/bugsnag + +WORKDIR /usr/src/app + +ARG CELERY_TEST_VERSION +RUN CELERY_TEST_VERSION=$CELERY_TEST_VERSION pip install --no-cache-dir -r requirements.txt + +CMD celery --app bugsnag_celery_test_app.main worker -l INFO diff --git a/features/fixtures/celery/app/bugsnag_celery_test_app/__init__.py b/features/fixtures/celery/app/bugsnag_celery_test_app/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/features/fixtures/celery/app/bugsnag_celery_test_app/main.py b/features/fixtures/celery/app/bugsnag_celery_test_app/main.py new file mode 100644 index 00000000..a56efc9b --- /dev/null +++ b/features/fixtures/celery/app/bugsnag_celery_test_app/main.py @@ -0,0 +1,24 @@ +import os +import bugsnag +from celery import Celery +from bugsnag.celery import connect_failure_handler + + +bugsnag.configure( + api_key=os.environ["BUGSNAG_API_KEY"], + endpoint=os.environ["BUGSNAG_ERROR_ENDPOINT"], + session_endpoint=os.environ["BUGSNAG_SESSION_ENDPOINT"], +) + +app = Celery( + 'bugsnag_celery_test_app', + broker='redis://redis:6379', + backend='rpc://', + include=['bugsnag_celery_test_app.tasks'], +) + +connect_failure_handler() + + +if __name__ == '__main__': + app.start() diff --git a/features/fixtures/celery/app/bugsnag_celery_test_app/queue_task.py b/features/fixtures/celery/app/bugsnag_celery_test_app/queue_task.py new file mode 100644 index 00000000..f038fbf2 --- /dev/null +++ b/features/fixtures/celery/app/bugsnag_celery_test_app/queue_task.py @@ -0,0 +1,23 @@ +import sys +import json +import bugsnag_celery_test_app.tasks as tasks + + +if __name__ == '__main__': + task = sys.argv[1] + arguments = [] + keyword_arguments = {} + + if len(sys.argv) > 2: + raw_arguments = sys.argv[2:] + + for argument in raw_arguments: + if '=' in argument: + key, value = argument.split('=') + keyword_arguments[key] = value + else: + arguments.append(argument) + + print("~*~ Queueing task '%s' with args: [%s] and kwargs: %s" % (task, ", ".join(arguments), json.dumps(keyword_arguments))) + + getattr(tasks, task).delay(*arguments, **keyword_arguments) diff --git a/features/fixtures/celery/app/bugsnag_celery_test_app/tasks.py b/features/fixtures/celery/app/bugsnag_celery_test_app/tasks.py new file mode 100644 index 00000000..b6f94c28 --- /dev/null +++ b/features/fixtures/celery/app/bugsnag_celery_test_app/tasks.py @@ -0,0 +1,34 @@ +import bugsnag +from celery import shared_task +from bugsnag_celery_test_app.main import app + + +@app.task +def handled(): + bugsnag.notify(Exception('oooh nooo')) + + return 'hello world' + + +@app.task +def unhandled(): + a = {} + + return a['b'] + + +@app.task +def add(*args, a, b): + total = int(a) + int(b) + + for arg in args: + total += int(arg) + + assert total < 100 + + return total + + +@shared_task +def divide(a, b): + return int(a) / int(b) diff --git a/features/fixtures/celery/app/requirements.txt b/features/fixtures/celery/app/requirements.txt new file mode 100644 index 00000000..ce07aeb3 --- /dev/null +++ b/features/fixtures/celery/app/requirements.txt @@ -0,0 +1,4 @@ +. +../bugsnag +celery[redis]${CELERY_TEST_VERSION} +importlib-metadata<5.0 diff --git a/features/fixtures/celery/app/setup.py b/features/fixtures/celery/app/setup.py new file mode 100644 index 00000000..a0bdc947 --- /dev/null +++ b/features/fixtures/celery/app/setup.py @@ -0,0 +1,3 @@ +from setuptools import setup + +setup(name="bugsnag_celery_test_app", packages=["bugsnag_celery_test_app"]) diff --git a/features/fixtures/docker-compose.yml b/features/fixtures/docker-compose.yml index c8d4ae6b..afd7becb 100644 --- a/features/fixtures/docker-compose.yml +++ b/features/fixtures/docker-compose.yml @@ -1,6 +1,9 @@ version: "3.8" services: + redis: + image: redis + plain: build: context: plain @@ -27,3 +30,26 @@ services: volumes: - "/var/run/docker.sock:/var/run/docker.sock" - "./aws-lambda/app:/usr/src/app" + + celery-4: &celery + build: + context: celery + args: + - PYTHON_TEST_VERSION + - CELERY_TEST_VERSION=>=4,<5 + environment: + - BUGSNAG_API_KEY + - BUGSNAG_ERROR_ENDPOINT + - BUGSNAG_SESSION_ENDPOINT + extra_hosts: + - "host.docker.internal:host-gateway" + depends_on: + - redis + + celery-5: + <<: *celery + build: + context: celery + args: + - PYTHON_TEST_VERSION + - CELERY_TEST_VERSION=>=5,<6 diff --git a/setup.py b/setup.py index 1f821ceb..ddd999a0 100755 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ setup( name='bugsnag', - version='4.7.0', + version='4.7.1', description='Automatic error monitoring for django, flask, etc.', long_description=__doc__, author='Simon Maynard', diff --git a/tests/integrations/test_celery.py b/tests/integrations/test_celery.py deleted file mode 100644 index 495891d6..00000000 --- a/tests/integrations/test_celery.py +++ /dev/null @@ -1,224 +0,0 @@ -from contextlib import contextmanager -import pytest -import celery -from celery import shared_task -from celery.signals import task_failure -from bugsnag.celery import connect_failure_handler, failure_handler -from tests.utils import MissingRequestError - - -@pytest.fixture(scope='function') -def celery_config(): - return { - 'broker_url': 'memory://', - 'result_backend': 'rpc', - } - - -@contextmanager -def celery_failure_handler(): - """ - The bugsnag celery integration works by listening to the celery - task_failure signal and sending an event when the signal is received. - - This context manages the signal connection and ensures that error handling - does not occur across separate tests. - """ - connect_failure_handler() - try: - yield - finally: - task_failure.disconnect(failure_handler) - - -def test_app_task_operation(celery_app, celery_worker, bugsnag_server): - """ - Configuring bugsnag should not interfere with tasks succeeding normally - """ - - @celery_app.task - def square(x): - return x * x - - celery_worker.reload() - - with celery_failure_handler(): - assert square.delay(3).get(timeout=1) == 9 - - with pytest.raises(MissingRequestError): - bugsnag_server.wait_for_event() - - assert len(bugsnag_server.events_received) == 0 - - -def test_app_task_failure(celery_app, celery_worker, bugsnag_server): - """ - Bugsnag should capture failures in app tasks - """ - - def validate(x, y): - raise FloatingPointError('expect the unexpected!') - - @celery_app.task - def cube(x): - return x * x * x - - @celery_app.task - def divide(x, y): - if validate(x, y): - return x / y - - celery_worker.reload() - - with celery_failure_handler(): - # bugsnag should not depend on the result being resolved using get() - divide.delay(7, 0) - # other (non-failing) tasks should behave normally - result = cube.delay(3) - bugsnag_server.wait_for_event() - - assert len(bugsnag_server.events_received) == 1 - assert result.get(timeout=1) == 27 - - payload = bugsnag_server.events_received[0]['json_body'] - event = payload['events'][0] - exception = event['exceptions'][0] - task = event['metaData']['extra_data'] - - assert 'task_id' in task - assert task['args'] == [7, 0] - assert event['context'] == 'test_celery.divide' - assert event['severityReason']['type'] == 'unhandledExceptionMiddleware' - assert event['severityReason']['attributes'] == {'framework': 'Celery'} - assert event['device']['runtimeVersions']['celery'] == celery.__version__ - assert exception['errorClass'] == 'FloatingPointError' - assert exception['message'] == 'expect the unexpected!' - assert exception['stacktrace'][0]['method'] == 'validate' - assert exception['stacktrace'][1]['method'] == 'divide' - - -def test_app_task_failure_result_status(celery_app, celery_worker, - bugsnag_server): - """ - Bugsnag integration should not suppress normal failure behavior when - checking the result of a failed task - """ - def validate(x, y): - raise FloatingPointError('expect the unexpected!') - - @celery_app.task - def cube(x): - return x * x * x - - @celery_app.task - def divide(x, y): - if validate(x, y): - return x / y - - celery_worker.reload() - - with celery_failure_handler(): - failed_result = divide.delay(7, 0) - result = cube.delay(3) - - with pytest.raises(FloatingPointError): - # bugsnag should not suppress the exception - failed_result.get(timeout=1) - - bugsnag_server.wait_for_event() - assert len(bugsnag_server.events_received) == 1 - assert result.get(timeout=1) == 27 - - -def test_shared_task_operation(celery_worker, bugsnag_server): - """ - Configuring bugsnag should not interfere with shared tasks succeeding - normally - """ - - @shared_task - def add(x, y): - return x + y - - celery_worker.reload() - - with celery_failure_handler(): - result = add.delay(2, 2) - - with pytest.raises(MissingRequestError): - bugsnag_server.wait_for_event() - - assert len(bugsnag_server.events_received) == 0 - assert result.get(timeout=1) == 4 - - -def test_shared_task_failure(celery_worker, bugsnag_server): - """ - Bugsnag should capture failures in standalone tasks - """ - - @shared_task - def divide(x, y, **kwargs): - return x / y - - celery_worker.reload() - - with celery_failure_handler(): - divide.delay(2, 0, parts='multi', cache=2) - - bugsnag_server.wait_for_event() - - assert len(bugsnag_server.events_received) == 1 - - payload = bugsnag_server.events_received[0]['json_body'] - event = payload['events'][0] - exception = event['exceptions'][0] - task = event['metaData']['extra_data'] - - assert 'task_id' in task - assert task['args'] == [2, 0] - assert task['kwargs'] == {'parts': 'multi', 'cache': 2} - assert event['context'] == 'test_celery.divide' - assert event['severityReason']['type'] == 'unhandledExceptionMiddleware' - assert event['severityReason']['attributes'] == {'framework': 'Celery'} - assert event['device']['runtimeVersions']['celery'] == celery.__version__ - assert exception['errorClass'] == 'ZeroDivisionError' - assert exception['stacktrace'][0]['method'] == 'divide' - - -def test_task_failure_with_chained_exceptions( - celery_app, - celery_worker, - bugsnag_server -): - @celery_app.task - def oh_no(): - try: - try: - raise Exception('A') - except Exception as exception: - raise RuntimeError('B') from exception - except RuntimeError: - raise ArithmeticError('C') - - celery_worker.reload() - - with celery_failure_handler(): - oh_no.delay() - bugsnag_server.wait_for_event() - - assert len(bugsnag_server.events_received) == 1 - - payload = bugsnag_server.events_received[0]['json_body'] - event = payload['events'][0] - - assert len(event['exceptions']) == 3 - - assert event['exceptions'][0]['errorClass'] == 'ArithmeticError' - assert event['exceptions'][0]['message'] == 'C' - - assert event['exceptions'][1]['errorClass'] == 'RuntimeError' - assert event['exceptions'][1]['message'] == 'B' - - assert event['exceptions'][2]['errorClass'] == 'Exception' - assert event['exceptions'][2]['message'] == 'A' diff --git a/tests/test_client.py b/tests/test_client.py index f01b8589..3759055e 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -18,6 +18,7 @@ FeatureFlag ) +from bugsnag.delivery import Delivery import bugsnag.legacy as legacy from tests.utils import ( BrokenDelivery, @@ -124,6 +125,22 @@ def deliver(foo, config, payload, options={}): self.assertTrue(self.called) del self.called + # test for a regression caused by reading '__code__', which does not exist + # on Mock objects, nor can it be mocked + # see: https://github.com/bugsnag/bugsnag-python/commit/77e11747c293ba715bc764d17b49fb32918c030a#r142130768 # noqa: E501 + def test_delivery_can_be_mocked(self): + delivery = Mock(spec=Delivery) + + client = Client(delivery=delivery, api_key='abc') + client.notify(Exception('Oh no')) + + assert delivery.deliver.call_count == 1 + + client.session_tracker.start_session() + client.session_tracker.send_sessions() + + assert delivery.deliver_sessions.call_count == 1 + # Capture def test_notify_capture(self): diff --git a/tox.ini b/tox.ini index 61ab3eac..325d6b3d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,7 @@ [tox] envlist= py{35,36,37,38,39,310,311,312}-{test,requests,flask,tornado,wsgi,bottle} - py{35,36,37,38,39,310}-celery4 - py{36,37,38,39,310,311,312}-{asgi,celery5} + py{36,37,38,39,310,311,312}-asgi py{35,36,37}-django{18,19,110,111} py{35,36,37,38,39}-django20 py{35,36,37,38,39,310}-django{21,22} @@ -46,13 +45,10 @@ deps= asgi: httpx bottle: webtest bottle: bottle - celery4: celery>=4,<5 - celery5: celery>=5,<6 - celery5: pytest-celery<1 - py37-celery{4,5}: importlib_metadata<5 flask: flask flask: blinker tornado: tornado + tornado: pytest<8.2 django18: Django>=1.8,<1.9 django19: Django>=1.9,<1.10 django110: Django>=1.10,<1.11 @@ -80,7 +76,6 @@ commands = threadtest: pytest -p no:threadexception tests/integrations/test_thread_excepthook.py requests: pytest --ignore=tests/integrations tests tests/integrations/test_requests_delivery.py bottle: pytest tests/integrations/test_bottle.py - celery{4,5}: pytest tests/integrations/test_celery.py wsgi: pytest tests/integrations/test_wsgi.py asgi: pytest tests/integrations/test_asgi.py flask: pytest tests/integrations/test_flask.py