Compare commits

...

84 Commits
2.5.0 ... main

Author SHA1 Message Date
pre-commit-ci[bot]
7c9334952d
[pre-commit.ci] pre-commit autoupdate (#551)
updates:
- [github.com/psf/black: 24.10.0 → 25.1.0](https://github.com/psf/black/compare/24.10.0...25.1.0)
- [github.com/pycqa/isort: 5.13.2 → 6.0.1](https://github.com/pycqa/isort/compare/5.13.2...6.0.1)
- [github.com/PyCQA/flake8: 7.1.1 → 7.2.0](https://github.com/PyCQA/flake8/compare/7.1.1...7.2.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2025-04-18 06:58:38 -07:00
Andrew Sears
630caed915
Upgrade project metadata (#542) 2025-01-22 17:34:39 +01:00
pre-commit-ci[bot]
786e4c120a
[pre-commit.ci] pre-commit autoupdate (#546) 2025-01-07 07:33:08 +01:00
David Fuentes Baldomir
f0a3ec60e9
Add --nostatic and --insecure args to runserver command. (#450)
Fixes #449
2025-01-04 09:37:17 +01:00
David Smith
32ac73e1a0
Added support for Python 3.13. (#539) 2024-11-18 20:57:11 +01:00
David Smith
e25b4bcc31
Target py39-plus with pyupgrade. (#540) 2024-11-18 20:56:40 +01:00
Carlton Gibson
06afd9b94a
Drop support for EOL Python 3.8. (#536) 2024-10-28 19:03:41 -07:00
pre-commit-ci[bot]
ffad9c3cfd
[pre-commit.ci] pre-commit autoupdate (#532)
updates:
- [github.com/asottile/pyupgrade: v3.16.0 → v3.17.0](https://github.com/asottile/pyupgrade/compare/v3.16.0...v3.17.0)
- [github.com/psf/black: 24.4.2 → 24.10.0](https://github.com/psf/black/compare/24.4.2...24.10.0)
- [github.com/PyCQA/flake8: 7.1.0 → 7.1.1](https://github.com/PyCQA/flake8/compare/7.1.0...7.1.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-10-07 17:10:56 -07:00
Jon Janzen
862ebcd08d
Include test support files in sdist (#530)
Closes: https://github.com/django/daphne/issues/522
2024-08-26 07:02:23 -07:00
Jon Janzen
b1902e8ccd
Remove pytest-runner (#528)
Closes: https://github.com/django/daphne/issues/523
2024-08-24 11:51:43 -07:00
Robert Schütz
9ec5798c0d
fix tests with Twisted 24.7.0 (#526)
In the fixed test cases the responses now contain `HTTP/1.1` rather than
`HTTP/1.0`.
2024-08-24 20:47:58 +02:00
Jon Janzen
3607351212
Revert "Remove pytest-runner"
This reverts commit 420f065d9e.
2024-08-24 11:36:44 -07:00
Jon Janzen
420f065d9e
Remove pytest-runner
Closes: https://github.com/django/daphne/issues/523
2024-08-24 11:34:33 -07:00
pre-commit-ci[bot]
4a55faca45
[pre-commit.ci] pre-commit autoupdate (#515)
updates:
- [github.com/asottile/pyupgrade: v3.15.2 → v3.16.0](https://github.com/asottile/pyupgrade/compare/v3.15.2...v3.16.0)
- [github.com/psf/black: 24.3.0 → 24.4.2](https://github.com/psf/black/compare/24.3.0...24.4.2)
- [github.com/PyCQA/flake8: 7.0.0 → 7.1.0](https://github.com/PyCQA/flake8/compare/7.0.0...7.1.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-07-02 09:38:28 +02:00
Carlton Gibson
0f15e4595b
Fixed packaging configuration. (#510)
Use auto-discovery, but make sure daphne package is correctly listed.
2024-04-11 15:23:49 +02:00
Carlton Gibson
cf9145985b Updated version and changelog for v4.1.1 release. 2024-04-10 17:42:01 +02:00
sdc50
c0b630834c
Fix twisted.plugin install (#507) 2024-04-10 17:33:21 +02:00
pre-commit-ci[bot]
63790936d1
[pre-commit.ci] pre-commit autoupdate (#503) 2024-04-02 07:14:14 +02:00
Carlton Gibson
df0680c9ad Updated change notes and version for 4.1 release. 2024-02-10 15:39:05 +01:00
Carlton Gibson
ef24796243
Validate HTTP header names as per RFC 9110. (#500)
Fixes #497.
2024-02-10 15:31:37 +01:00
Alejandro R. Sedeño
9a282dd627
Handle Daphne-Root-Path for websockets, adding root_path to scope. (#453)
Signed-off-by: Alejandro R. Sedeño <asedeno@mit.edu>
2024-02-06 09:15:03 +01:00
Carlton Gibson
5fdc9176e5
Ignored flake8-bugbear B036. (#499)
"except BaseException: without re-raising" used in testing.py
2024-02-06 09:04:10 +01:00
pre-commit-ci[bot]
993efe62ce
[pre-commit.ci] pre-commit autoupdate (#492) 2024-01-01 19:58:51 +01:00
dependabot[bot]
c07925d53f
Bump actions/setup-python from 4 to 5 (#491) 2023-12-12 07:19:48 +01:00
Paolo Melchiorre
4d24e22c72
Fixed #489 -- Add support for Python 3.12 (#490) 2023-11-26 16:12:23 +01:00
pre-commit-ci[bot]
2d4dcbf149
[pre-commit.ci] pre-commit autoupdate (#485) 2023-10-03 12:59:12 +02:00
InvalidInterrupt
3758c514fd
Raise minimum supported Python version to 3.8 (#481) 2023-09-07 09:09:10 +02:00
dependabot[bot]
f108bbc7c1
Bump actions/checkout from 3 to 4 (#480)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-05 07:51:07 +02:00
pre-commit-ci[bot]
e49c39a4e5
[pre-commit.ci] pre-commit autoupdate (#477)
updates:
- [github.com/asottile/pyupgrade: v3.3.1 → v3.8.0](https://github.com/asottile/pyupgrade/compare/v3.3.1...v3.8.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-07-04 05:48:51 +01:00
Carlton Gibson
1eaf2206cc
Set pre-commit to quarterly updates. (#472) 2023-04-16 10:33:27 +02:00
Abenezer Belachew
09da15dc4e
Double quotes during pip (#467)
Single quotes return => ERROR: Invalid requirement: "'Twisted[tls,http2]'"
2023-04-14 15:10:16 +02:00
pre-commit-ci[bot]
21513b84da
[pre-commit.ci] pre-commit autoupdate (#470)
updates:
- [github.com/psf/black: 23.1.0 → 23.3.0](https://github.com/psf/black/compare/23.1.0...23.3.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Carlton Gibson <carlton.gibson@noumenal.es>
2023-04-14 15:03:51 +02:00
Carlton Gibson
077dd81809
Added missing stacklevel warning parameter. (#471) 2023-04-14 14:53:12 +02:00
pre-commit-ci[bot]
b0204165b1
[pre-commit.ci] pre-commit autoupdate (#466)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-02-07 15:20:08 +00:00
pre-commit-ci[bot]
79fd65dec3
[pre-commit.ci] pre-commit autoupdate (#464)
updates:
- [github.com/pycqa/isort: 5.11.4 → 5.12.0](https://github.com/pycqa/isort/compare/5.11.4...5.12.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-01-31 06:52:32 +00:00
pre-commit-ci[bot]
5681d71c17
[pre-commit.ci] pre-commit autoupdate (#462)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-12-29 12:19:00 +00:00
pre-commit-ci[bot]
fdc944a280
[pre-commit.ci] pre-commit autoupdate (#461)
updates:
- [github.com/pycqa/isort: 5.11.0 → v5.11.3](https://github.com/pycqa/isort/compare/5.11.0...v5.11.3)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-12-20 10:37:51 +01:00
pre-commit-ci[bot]
c9343aa9d6
[pre-commit.ci] pre-commit autoupdate (#460)
updates:
- [github.com/asottile/pyupgrade: v3.3.0 → v3.3.1](https://github.com/asottile/pyupgrade/compare/v3.3.0...v3.3.1)
- [github.com/psf/black: 22.10.0 → 22.12.0](https://github.com/psf/black/compare/22.10.0...22.12.0)
- [github.com/pycqa/isort: 5.10.1 → 5.11.0](https://github.com/pycqa/isort/compare/5.10.1...5.11.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-12-12 23:15:35 +00:00
Adam Johnson
d59c2bd424
Upgrade to tox 4 (#458) 2022-12-08 01:06:41 +00:00
pre-commit-ci[bot]
2015ecdd8f
[pre-commit.ci] pre-commit autoupdate (#457)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-12-06 09:01:28 +00:00
pre-commit-ci[bot]
18e936eed1
[pre-commit.ci] pre-commit autoupdate (#456)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-29 20:34:37 +00:00
Michael K
ef946d5637
Upgrade GitHub Actions actions (#454)
And set up dependabot to take care of them in the future.
2022-11-18 11:33:03 +01:00
pre-commit-ci[bot]
a0b2ac0e8f
[pre-commit.ci] pre-commit autoupdate (#452)
updates:
- [github.com/asottile/pyupgrade: v3.2.0 → v3.2.2](https://github.com/asottile/pyupgrade/compare/v3.2.0...v3.2.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-14 20:47:53 +00:00
pre-commit-ci[bot]
afd0d51b83
[pre-commit.ci] pre-commit autoupdate (#448)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-01 11:09:39 +00:00
Michael K
d5fbdfc4cb
Run tests against Python 3.11 and add trove classifier (#446) 2022-10-25 13:46:20 +01:00
pre-commit-ci[bot]
71be46265d
[pre-commit.ci] pre-commit autoupdate (#444)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-17 23:09:03 +01:00
pre-commit-ci[bot]
91c61f4ff4
[pre-commit.ci] pre-commit autoupdate (#443)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-10 23:39:20 +01:00
Carlton Gibson
060202f491 Updated change log and version for v4.0 release. 2022-10-07 14:53:16 +02:00
Adam Johnson
12e543750b
Make DaphneProcess pickleable (#440) 2022-10-07 13:22:40 +02:00
Jakub Stawowy
fef1490eff
Removed deprecated —ws-protocols CLI option. (#387) 2022-10-07 12:21:22 +02:00
pre-commit-ci[bot]
898529c489
[pre-commit.ci] pre-commit autoupdate (#441)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-09-26 21:47:43 +01:00
pre-commit-ci[bot]
e9637419df
[pre-commit.ci] pre-commit autoupdate (#439)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-09-20 10:07:55 +01:00
pre-commit-ci[bot]
b3bfbd6135
[pre-commit.ci] pre-commit autoupdate (#438)
updates:
- [github.com/psf/black: 22.6.0 → 22.8.0](https://github.com/psf/black/compare/22.6.0...22.8.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-09-06 09:35:26 +02:00
Michael K
c9a7fdc669
Run tests on main branch, not master (#435) 2022-08-30 07:35:56 +02:00
Carlton Gibson
6a466b7bee
Bumped version and changelog for 4.0b1 release. (#434) 2022-08-25 12:04:14 +02:00
pre-commit-ci[bot]
7453ad9cc5
[pre-commit.ci] pre-commit autoupdate (#430)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-08-08 21:40:53 +01:00
Carlton Gibson
2b13b74ce2
Added runserver to Daphne. (#429)
* Made daphne installable as a Django app.
* Added system check to ensure daphne is installed before
  django.contrib.staticfiles.
* Moved runserver command from Channels.
* Added changelog entry for runserver command.
2022-08-08 14:10:03 +02:00
pre-commit-ci[bot]
438b7ad06d
[pre-commit.ci] pre-commit autoupdate (#427)
updates:
- [github.com/asottile/pyupgrade: v2.37.2 → v2.37.3](https://github.com/asottile/pyupgrade/compare/v2.37.2...v2.37.3)
- [github.com/PyCQA/flake8: 4.0.1 → 5.0.2](https://github.com/PyCQA/flake8/compare/4.0.1...5.0.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-08-02 08:56:41 +02:00
pre-commit-ci[bot]
e04b4077f4
[pre-commit.ci] pre-commit autoupdate (#426)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-07-25 23:59:09 +01:00
pre-commit-ci[bot]
5502d1d37b
[pre-commit.ci] pre-commit autoupdate (#424) 2022-07-13 06:24:58 +01:00
Carlton Gibson
71ba440761
Added support for ASGI_THREADS max worker limit. (#422)
Closes #319
2022-07-06 12:37:26 +02:00
David Smith
6199d509c2
Merge pull request #421 from django/pre-commit-ci-update-config
[pre-commit.ci] pre-commit autoupdate
2022-07-04 21:08:13 +01:00
pre-commit-ci[bot]
1df4f08fac
[pre-commit.ci] pre-commit autoupdate
updates:
- [github.com/psf/black: 22.3.0 → 22.6.0](https://github.com/psf/black/compare/22.3.0...22.6.0)
2022-07-04 19:40:09 +00:00
pre-commit-ci[bot]
7d4316fd4a
[pre-commit.ci] pre-commit autoupdate (#418)
updates:
- [github.com/asottile/pyupgrade: v2.32.1 → v2.34.0](https://github.com/asottile/pyupgrade/compare/v2.32.1...v2.34.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-06-13 23:58:38 +01:00
baseplate-admin
54745d0f83
Set a default Server header for HTTP responses (#396)
Co-authored-by: Carlton Gibson <carlton.gibson@noumenal.es>
2022-05-24 12:40:02 +02:00
Abhimanyu Saharan
87bc5a7975 Added argument to change log format. (#414) 2022-05-23 16:41:45 +02:00
Carlton Gibson
5e709795b4
Updated supported Python and dependency versions. (#417)
* Updated Python support and dependencies.
* Updated Python support in README.
* Removed PY36 from GHA workflow.
* Remove pre-commit workflow. Fixes #397
* Updated Black in pre-commit to 22.3.0.
* Update all pre-commit hooks.
* [pre-commit.ci] auto fixes from pre-commit.com hooks
2022-05-23 15:34:29 +02:00
Marcin Muszynski
eae1ff0df4
Set default attributes on WebRequest (#406) 2022-02-14 16:12:56 +01:00
Carlton Gibson
6a5093982c
Run CI tests on Windows. (#393)
* Updated minimum twisted to 19.7

Co-authored-by: Michael Käufl <django@c.michael-kaeufl.de>
2021-11-09 20:12:29 +01:00
Carlton Gibson
b62e58a023
Added Python 3.10 to CI. (#392) 2021-11-09 15:58:09 +01:00
Carlton Gibson
15a754d903
Unpinned test dependencies. (#391) 2021-11-09 15:55:18 +01:00
Adam Johnson
36ce9fd1ed
Use tox-py in CI (#369) 2021-04-16 18:21:51 +02:00
Carlton Gibson
e480917c1a Bumped version and change notes for 3.0.2 release. 2021-04-07 20:26:57 +02:00
Carlton Gibson
d5c41bf641 Updated various README URLs. 2021-04-07 20:26:57 +02:00
Adam Johnson
2b6f153616
Used partial() to wrap Server.handle_reply() (#364)
Fixes #332.
2021-04-07 20:14:02 +02:00
Adam Johnson
ca61162129
Lint with pre-commit (#365)
* Lint with pre-commit

* Move existing tox qa hooks into pre-commit.
* Set up GitHub Action based on https://github.com/pre-commit/action/ (we could also use https://pre-commit.ci ).
* Add `pyupgrade` to drop old Python syntax.
* Add `flake8-bugbear` plugin to prevent flake8 errors.

* Drop custom GHA
2021-04-07 20:11:21 +02:00
Carlton Gibson
aac4708a61 Bumped version and change notes for 3.0.1 release. 2020-11-12 20:34:13 +01:00
Patrick Gingras
aae0870971
Handle asyncio.CancelledError in Server.application_checker (#341)
As of [bpo-32528](https://bugs.python.org/issue32528), asyncio.CancelledError is
not a subclass of concurrent.futures.CancelledError. This means that if an
asyncio future raises an exception, it won't be caught. Therefore, the
exception will bubble past the try-except within the loop in application_checker,
resulting in done applications not being cleaned up, and the application_checker
task not being queued again.
2020-11-11 16:12:33 +01:00
Carlton Gibson
a69723ca3f Version 3.0 release.
* Bump version number.
* Changelog.
* README.
* Update asgiref dependency specifier to match Django 3.1.
2020-10-28 20:52:00 +01:00
Ryan Fredericks
525b6d2dbb
Update README for shell compatibility. (#327) 2020-10-28 20:45:41 +01:00
Avinash Raj
15ba5c6776
Updated to use ASGI v3 applications internally. (#275)
Used guarantee_single_callable().
Removed unneeded --asgi-protocol CLI option.
Updated tests.

Co-authored-by: Carlton Gibson <carlton.gibson@noumenal.es>
2020-10-27 19:50:50 +01:00
Samori Gorse
e1b77e930b
Added request body chunking (#335)
The entire body was previously read in memory which would lead
the server to be killed by the scheduler.
This change allows 8Kb chunks to be read until the entire body is
consummed.

Co-authored-by: Samori Gorse <samori@codeinstyle.io>
2020-10-21 16:38:03 +02:00
Michael K
b96720390f
Switch from Travis CI to GitHub Actions (#336)
* Add GitHub Actions
* Remove Travis CI
* Remove known first party from isort's config
2020-10-20 16:44:54 +02:00
Sergey Klyuykov
d0e841b41d Added support for executing from interpreter.
For run from python interpreter as module use command:
`python -m daphne [all daphne arguments]`
2020-10-16 08:16:34 +02:00
36 changed files with 944 additions and 267 deletions

11
.flake8 Normal file
View File

@ -0,0 +1,11 @@
[flake8]
exclude =
.venv,
.tox,
docs,
testproject,
js_client,
.eggs
extend-ignore = E123, E128, E266, E402, W503, E731, W601, B036
max-line-length = 120

6
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,6 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly

43
.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: Tests
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
tests:
runs-on: ${{ matrix.os }}-latest
strategy:
fail-fast: false
matrix:
os:
- ubuntu
- windows
python-version:
- "3.9"
- "3.10"
- "3.11"
- "3.12"
- "3.13"
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip setuptools wheel
python -m pip install --upgrade tox
- name: Run tox targets for ${{ matrix.python-version }}
run: tox run -f py$(echo ${{ matrix.python-version }} | tr -d .)

1
.gitignore vendored
View File

@ -13,3 +13,4 @@ test_consumer*
.python-version
.pytest_cache/
.vscode
.coverage

23
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,23 @@
repos:
- repo: https://github.com/asottile/pyupgrade
rev: v3.19.1
hooks:
- id: pyupgrade
args: [--py39-plus]
- repo: https://github.com/psf/black
rev: 25.1.0
hooks:
- id: black
language_version: python3
- repo: https://github.com/pycqa/isort
rev: 6.0.1
hooks:
- id: isort
- repo: https://github.com/PyCQA/flake8
rev: 7.2.0
hooks:
- id: flake8
additional_dependencies:
- flake8-bugbear
ci:
autoupdate_schedule: quarterly

View File

@ -1,48 +0,0 @@
sudo: false
language: python
python:
- '3.8'
- '3.7'
- '3.6'
- '3.5'
env:
- TWISTED="twisted"
- TWISTED="twisted==18.7.0"
install:
- pip install $TWISTED -e .[tests]
- pip freeze
script:
- pytest
stages:
- lint
- test
- name: release
if: tag IS present
jobs:
include:
- stage: lint
python: 3.6
install: pip install -U -e .[tests] black pyflakes isort
script:
- pyflakes daphne tests
- black --check daphne tests
- isort --check-only --diff --recursive daphne tests
- stage: release
python: 3.6
script: skip
deploy:
provider: pypi
user: andrewgodwin_bot
on:
tags: true
distributions: sdist bdist_wheel
password:
secure: IA+dvSmMKN+fT47rgRb6zdmrExhK5QCVEDH8kheC6kAacw80ORBZKo6sMX9GQBJ3BlfhTqrzAhItHkDUxonb579rJDvmlJ7FPg7axZpsY9Fmls6q1rJC/La8iGWx20+ctberejKSH3wSwa0LH0imJXGDoKKzf1DLmk5pEEWjG2QqhKdEtyAcnzOPnDWcRCs+DKfQcMzETH7lMFN8oe3aBhHLLtcg4yA78cN5CeyyH92lmbaVp7k/b1FqXXFgf16bi5tlgLrb6DhmcnNjwLMSHRafNoPCXkWQOwh6gEHeHRR3OsHsBueyJHIikuHNrpmgpAqjYlVQ5WqmfgMlhCfRm9xL+G4G+KK9n8AJNGAszUfxVlPvMTw+nkOSd/bmxKrdCqqYnDIvDLucXJ86TstNzklfAwr3FL+wBlucRtOMLhQlHIaPTXYcNpOuh6B4ELjC+WjDGh8EdRKvcsZz7+5AS5ZaDDccuviMzQFsXVcE2d4HiosbARVrkxJ7j3MWp0OGgWVxXgRO2EQIksbgGSIjI8PqFjBqht2WT6MhVZPCc9XHUlP2CiAR5+QY8JgTIztbEDuhpgr0cRAtiHwJEAxDR9tJR/j/v4X/Pau2ZdR0C0yW77lVgD75spLL0khAnU7q+qgiF0hyQ7gRRVy0tElT0HBenVbzjzHowdJX8lSPjRg=

View File

@ -1,3 +1,118 @@
4.2.0 (to be released)
------------------
* Added support for Python 3.13.
* Drop support for EOL Python 3.8.
* Removed unused pytest-runner
* Fixed sdist file to ensure it includes all tests
4.1.2 (2024-04-11)
------------------
* Fixed a setuptools configuration error in 4.1.1.
4.1.1 (2024-04-10)
------------------
* Fixed a twisted.plugin packaging error in 4.1.0.
Thanks to sdc50.
4.1.0 (2024-02-10)
------------------
* Added support for Python 3.12.
* Dropped support for EOL Python 3.7.
* Handled root path for websocket scopes.
* Validate HTTP header names as per RFC 9110.
4.0.0 (2022-10-07)
------------------
Major versioning targeting use with Channels 4.0 and beyond. Except where
noted should remain usable with Channels v3 projects, but updating Channels to the latest version is recommended.
* Added a ``runserver`` command to run an ASGI Django development server.
Added ``"daphne"`` to the ``INSTALLED_APPS`` setting, before
``"django.contrib.staticfiles"`` to enable:
INSTALLED_APPS = [
"daphne",
...
]
This replaces the Channels implementation of ``runserver``, which is removed
in Channels 4.0.
* Made the ``DaphneProcess`` tests helper class compatible with the ``spawn``
process start method, which is used on macOS and Windows.
Note that requires Channels v4 if using with ``ChannelsLiveServerTestCase``.
* Dropped support for Python 3.6.
* Updated dependencies to the latest versions.
Previously a range of Twisted versions have been supported. Recent Twisted
releases (22.2, 22.4) have issued security fixes, so those are now the
minimum supported version. Given the stability of Twisted, supporting a
range of versions does not represent a good use of maintainer time. Going
forward the latest Twisted version will be required.
* Set ``daphne`` as default ``Server`` header.
This can be configured with the ``--server-name`` CLI argument.
Added the new ``--no-server-name`` CLI argument to disable the ``Server``
header, which is equivalent to ``--server-name=` (an empty name).
* Added ``--log-fmt`` CLI argument.
* Added support for ``ASGI_THREADS`` environment variable, setting the maximum
number of workers used by a ``SyncToAsync`` thread-pool executor.
Set e.g. ``ASGI_THREADS=4 daphne ...`` when running to limit the number of
workers.
* Removed deprecated ``--ws_protocols`` CLI option.
3.0.2 (2021-04-07)
------------------
* Fixed a bug where ``send`` passed to applications wasn't a true async
function but a lambda wrapper, preventing it from being used with
``asgiref.sync.async_to_sync()``.
3.0.1 (2020-11-12)
------------------
* Fixed a bug where ``asyncio.CancelledError`` was not correctly handled on
Python 3.8+, resulting in incorrect protocol application cleanup.
3.0.0 (2020-10-28)
------------------
* Updates internals to use ASGI v3 throughout. ``asgiref.compatibility`` is
used for older applications.
* Consequently, the `--asgi-protocol` command-line option is removed.
* HTTP request bodies are now read, and passed to the application, in chunks.
* Added support for Python 3.9.
* Dropped support for Python 3.5.
2.5.0 (2020-04-15)
------------------
@ -9,6 +124,7 @@
``lock`` argument to ``__init__()``. This is expected to be an instance of
``multiprocessing.Lock``.
2.4.1 (2019-12-18)
------------------
@ -16,6 +132,7 @@
3.0's ``async_unsafe()`` decorator in threaded contexts, such as using the
auto-reloader.
2.4.0 (2019-11-20)
------------------
@ -33,11 +150,13 @@
* Adds missing LICENSE to distribution.
2.3.0 (2019-04-09)
------------------
* Added support for ASGI v3.
2.2.5 (2019-01-31)
------------------

View File

@ -1 +1,2 @@
include LICENSE
recursive-include tests *.py

View File

@ -1,24 +1,17 @@
daphne
======
.. image:: https://api.travis-ci.org/django/daphne.svg
:target: https://travis-ci.org/django/daphne
.. image:: https://img.shields.io/pypi/v/daphne.svg
:target: https://pypi.python.org/pypi/daphne
Daphne is a HTTP, HTTP2 and WebSocket protocol server for
`ASGI <https://github.com/django/asgiref/blob/master/specs/asgi.rst>`_ and
`ASGI-HTTP <https://github.com/django/asgiref/blob/master/specs/www.rst>`_,
`ASGI <https://github.com/django/asgiref/blob/main/specs/asgi.rst>`_ and
`ASGI-HTTP <https://github.com/django/asgiref/blob/main/specs/www.rst>`_,
developed to power Django Channels.
It supports automatic negotiation of protocols; there's no need for URL
prefixing to determine WebSocket endpoints versus HTTP endpoints.
*Note:* Daphne 2 is not compatible with Channels 1.x applications, only with
Channels 2.x and other ASGI applications. Install a 1.x version of Daphne
for Channels 1.x support.
Running
-------
@ -61,7 +54,7 @@ Daphne supports terminating HTTP/2 connections natively. You'll
need to do a couple of things to get it working, though. First, you need to
make sure you install the Twisted ``http2`` and ``tls`` extras::
pip install -U Twisted[tls,http2]
pip install -U "Twisted[tls,http2]"
Next, because all current browsers only support HTTP/2 when using TLS, you will
need to start Daphne with TLS turned on, which can be done using the Twisted endpoint syntax::
@ -115,19 +108,19 @@ should start with a slash, but not end with one; for example::
Python Support
--------------
Daphne requires Python 3.5 or later.
Daphne requires Python 3.9 or later.
Contributing
------------
Please refer to the
`main Channels contributing docs <https://github.com/django/channels/blob/master/CONTRIBUTING.rst>`_.
`main Channels contributing docs <https://github.com/django/channels/blob/main/CONTRIBUTING.rst>`_.
To run tests, make sure you have installed the ``tests`` extra with the package::
cd daphne/
pip install -e .[tests]
pip install -e '.[tests]'
pytest
@ -141,4 +134,4 @@ https://docs.djangoproject.com/en/dev/internals/security/.
To report bugs or request new features, please open a new GitHub issue.
This repository is part of the Channels project. For the shepherd and maintenance team, please see the
`main Channels readme <https://github.com/django/channels/blob/master/README.rst>`_.
`main Channels readme <https://github.com/django/channels/blob/main/README.rst>`_.

View File

@ -1,6 +1,6 @@
import sys
__version__ = "2.5.0"
__version__ = "4.1.3"
# Windows on Python 3.8+ uses ProactorEventLoop, which is not compatible with

3
daphne/__main__.py Normal file
View File

@ -0,0 +1,3 @@
from daphne.cli import CommandLineInterface
CommandLineInterface.entrypoint()

View File

@ -1,7 +1,7 @@
import datetime
class AccessLogGenerator(object):
class AccessLogGenerator:
"""
Object that implements the Daphne "action logger" internal interface in
order to provide an access log in something resembling NCSA format.

16
daphne/apps.py Normal file
View File

@ -0,0 +1,16 @@
# Import the server here to ensure the reactor is installed very early on in case other
# packages import twisted.internet.reactor (e.g. raven does this).
from django.apps import AppConfig
from django.core import checks
import daphne.server # noqa: F401
from .checks import check_daphne_installed
class DaphneConfig(AppConfig):
name = "daphne"
verbose_name = "Daphne"
def ready(self):
checks.register(check_daphne_installed, checks.Tags.staticfiles)

21
daphne/checks.py Normal file
View File

@ -0,0 +1,21 @@
# Django system check to ensure daphne app is listed in INSTALLED_APPS before django.contrib.staticfiles.
from django.core.checks import Error, register
@register()
def check_daphne_installed(app_configs, **kwargs):
from django.apps import apps
from django.contrib.staticfiles.apps import StaticFilesConfig
from daphne.apps import DaphneConfig
for app in apps.get_app_configs():
if isinstance(app, DaphneConfig):
return []
if isinstance(app, StaticFilesConfig):
return [
Error(
"Daphne must be listed before django.contrib.staticfiles in INSTALLED_APPS.",
id="daphne.E001",
)
]

View File

@ -1,10 +1,9 @@
import argparse
import functools
import logging
import sys
from argparse import ArgumentError, Namespace
from asgiref.compatibility import is_double_callable
from asgiref.compatibility import guarantee_single_callable
from .access import AccessLogGenerator
from .endpoints import build_endpoint_description_strings
@ -17,20 +16,7 @@ DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8000
class ASGI3Middleware:
def __init__(self, app):
self.app = app
def __call__(self, scope):
scope.setdefault("asgi", {})
scope["asgi"]["version"] = "3.0"
return functools.partial(self.asgi, scope=scope)
async def asgi(self, receive, send, scope):
await self.app(scope, receive, send)
class CommandLineInterface(object):
class CommandLineInterface:
"""
Acts as the main CLI entry point for running the server.
"""
@ -104,6 +90,11 @@ class CommandLineInterface(object):
help="Where to write the access log (- for stdout, the default for verbosity=1)",
default=None,
)
self.parser.add_argument(
"--log-fmt",
help="Log format to use",
default="%(asctime)-15s %(levelname)-8s %(message)s",
)
self.parser.add_argument(
"--ping-interval",
type=int,
@ -122,20 +113,6 @@ class CommandLineInterface(object):
help="The number of seconds an ASGI application has to exit after client disconnect before it is killed",
default=10,
)
self.parser.add_argument(
"--ws-protocol",
nargs="*",
dest="ws_protocols",
help="The WebSocket protocols you wish to support",
default=None,
)
self.parser.add_argument(
"--asgi-protocol",
dest="asgi_protocol",
help="The version of the ASGI protocol to use",
default="auto",
choices=["asgi2", "asgi3", "auto"],
)
self.parser.add_argument(
"--root-path",
dest="root_path",
@ -178,7 +155,10 @@ class CommandLineInterface(object):
"--server-name",
dest="server_name",
help="specify which value should be passed to response header Server attribute",
default="Daphne",
default="daphne",
)
self.parser.add_argument(
"--no-server-name", dest="server_name", action="store_const", const=""
)
self.server = None
@ -236,7 +216,7 @@ class CommandLineInterface(object):
2: logging.DEBUG,
3: logging.DEBUG, # Also turns on asyncio debug
}[args.verbosity],
format="%(asctime)-15s %(levelname)-8s %(message)s",
format=args.log_fmt,
)
# If verbosity is 1 or greater, or they told us explicitly, set up access log
access_log_stream = None
@ -247,16 +227,11 @@ class CommandLineInterface(object):
access_log_stream = open(args.access_log, "a", 1)
elif args.verbosity >= 1:
access_log_stream = sys.stdout
# Import application
sys.path.insert(0, ".")
application = import_by_path(args.application)
asgi_protocol = args.asgi_protocol
if asgi_protocol == "auto":
asgi_protocol = "asgi2" if is_double_callable(application) else "asgi3"
if asgi_protocol == "asgi3":
application = ASGI3Middleware(application)
application = guarantee_single_callable(application)
# Set up port/host bindings
if not any(
@ -284,7 +259,7 @@ class CommandLineInterface(object):
)
endpoints = sorted(args.socket_strings + endpoints)
# Start the server
logger.info("Starting server at %s" % (", ".join(endpoints),))
logger.info("Starting server at {}".format(", ".join(endpoints)))
self.server = self.server_class(
application=application,
endpoints=endpoints,
@ -295,17 +270,16 @@ class CommandLineInterface(object):
websocket_connect_timeout=args.websocket_connect_timeout,
websocket_handshake_timeout=args.websocket_connect_timeout,
application_close_timeout=args.application_close_timeout,
action_logger=AccessLogGenerator(access_log_stream)
if access_log_stream
else None,
ws_protocols=args.ws_protocols,
action_logger=(
AccessLogGenerator(access_log_stream) if access_log_stream else None
),
root_path=args.root_path,
verbosity=args.verbosity,
proxy_forwarded_address_header=self._get_forwarded_host(args=args),
proxy_forwarded_port_header=self._get_forwarded_port(args=args),
proxy_forwarded_proto_header="X-Forwarded-Proto"
if args.proxy_headers
else None,
proxy_forwarded_proto_header=(
"X-Forwarded-Proto" if args.proxy_headers else None
),
server_name=args.server_name,
)
self.server.run()

View File

@ -9,7 +9,7 @@ from twisted.protocols.policies import ProtocolWrapper
from twisted.web import http
from zope.interface import implementer
from .utils import parse_x_forwarded_for
from .utils import HEADER_NAME_RE, parse_x_forwarded_for
logger = logging.getLogger(__name__)
@ -50,6 +50,8 @@ class WebRequest(http.Request):
) # Shorten it a bit, bytes wise
def __init__(self, *args, **kwargs):
self.client_addr = None
self.server_addr = None
try:
http.Request.__init__(self, *args, **kwargs)
# Easy server link
@ -67,6 +69,13 @@ class WebRequest(http.Request):
def process(self):
try:
self.request_start = time.time()
# Validate header names.
for name, _ in self.requestHeaders.getAllRawHeaders():
if not HEADER_NAME_RE.fullmatch(name):
self.basic_error(400, b"Bad Request", "Invalid header name")
return
# Get upgrade header
upgrade_header = None
if self.requestHeaders.hasHeader(b"Upgrade"):
@ -77,9 +86,6 @@ class WebRequest(http.Request):
# requires unicode string.
self.client_addr = [str(self.client.host), self.client.port]
self.server_addr = [str(self.host.host), self.host.port]
else:
self.client_addr = None
self.server_addr = None
self.client_scheme = "https" if self.isSecure() else "http"
@ -185,9 +191,19 @@ class WebRequest(http.Request):
# Not much we can do, the request is prematurely abandoned.
return
# Run application against request
self.application_queue.put_nowait(
{"type": "http.request", "body": self.content.read()}
)
buffer_size = self.server.request_buffer_size
while True:
chunk = self.content.read(buffer_size)
more_body = not (len(chunk) < buffer_size)
payload = {
"type": "http.request",
"body": chunk,
"more_body": more_body,
}
self.application_queue.put_nowait(payload)
if not more_body:
break
except Exception:
logger.error(traceback.format_exc())
self.basic_error(
@ -240,8 +256,8 @@ class WebRequest(http.Request):
# Write headers
for header, value in message.get("headers", {}):
self.responseHeaders.addRawHeader(header, value)
if self.server.server_name and self.server.server_name.lower() != "daphne":
self.setHeader(b"server", self.server.server_name.encode("utf-8"))
if self.server.server_name and not self.responseHeaders.hasHeader("server"):
self.setHeader(b"server", self.server.server_name.encode())
logger.debug(
"HTTP %s response started for %s", message["status"], self.client_addr
)
@ -270,9 +286,11 @@ class WebRequest(http.Request):
"path": uri,
"status": self.code,
"method": self.method.decode("ascii", "replace"),
"client": "%s:%s" % tuple(self.client_addr)
"client": (
"%s:%s" % tuple(self.client_addr)
if self.client_addr
else None,
else None
),
"time_taken": self.duration(),
"size": self.sentLength,
},

View File

View File

View File

@ -0,0 +1,203 @@
import datetime
import importlib
import logging
import sys
from django.apps import apps
from django.conf import settings
from django.contrib.staticfiles.handlers import ASGIStaticFilesHandler
from django.core.exceptions import ImproperlyConfigured
from django.core.management import CommandError
from django.core.management.commands.runserver import Command as RunserverCommand
from daphne import __version__
from daphne.endpoints import build_endpoint_description_strings
from daphne.server import Server
logger = logging.getLogger("django.channels.server")
def get_default_application():
"""
Gets the default application, set in the ASGI_APPLICATION setting.
"""
try:
path, name = settings.ASGI_APPLICATION.rsplit(".", 1)
except (ValueError, AttributeError):
raise ImproperlyConfigured("Cannot find ASGI_APPLICATION setting.")
try:
module = importlib.import_module(path)
except ImportError:
raise ImproperlyConfigured("Cannot import ASGI_APPLICATION module %r" % path)
try:
value = getattr(module, name)
except AttributeError:
raise ImproperlyConfigured(
f"Cannot find {name!r} in ASGI_APPLICATION module {path}"
)
return value
class Command(RunserverCommand):
protocol = "http"
server_cls = Server
def add_arguments(self, parser):
super().add_arguments(parser)
parser.add_argument(
"--noasgi",
action="store_false",
dest="use_asgi",
default=True,
help="Run the old WSGI-based runserver rather than the ASGI-based one",
)
parser.add_argument(
"--http_timeout",
action="store",
dest="http_timeout",
type=int,
default=None,
help=(
"Specify the daphne http_timeout interval in seconds "
"(default: no timeout)"
),
)
parser.add_argument(
"--websocket_handshake_timeout",
action="store",
dest="websocket_handshake_timeout",
type=int,
default=5,
help=(
"Specify the daphne websocket_handshake_timeout interval in "
"seconds (default: 5)"
),
)
parser.add_argument(
"--nostatic",
action="store_false",
dest="use_static_handler",
help="Tells Django to NOT automatically serve static files at STATIC_URL.",
)
parser.add_argument(
"--insecure",
action="store_true",
dest="insecure_serving",
help="Allows serving static files even if DEBUG is False.",
)
def handle(self, *args, **options):
self.http_timeout = options.get("http_timeout", None)
self.websocket_handshake_timeout = options.get("websocket_handshake_timeout", 5)
# Check Channels is installed right
if options["use_asgi"] and not hasattr(settings, "ASGI_APPLICATION"):
raise CommandError(
"You have not set ASGI_APPLICATION, which is needed to run the server."
)
# Dispatch upward
super().handle(*args, **options)
def inner_run(self, *args, **options):
# Maybe they want the wsgi one?
if not options.get("use_asgi", True):
if hasattr(RunserverCommand, "server_cls"):
self.server_cls = RunserverCommand.server_cls
return RunserverCommand.inner_run(self, *args, **options)
# Run checks
self.stdout.write("Performing system checks...\n\n")
self.check(display_num_errors=True)
self.check_migrations()
# Print helpful text
quit_command = "CTRL-BREAK" if sys.platform == "win32" else "CONTROL-C"
now = datetime.datetime.now().strftime("%B %d, %Y - %X")
self.stdout.write(now)
self.stdout.write(
(
"Django version %(version)s, using settings %(settings)r\n"
"Starting ASGI/Daphne version %(daphne_version)s development server"
" at %(protocol)s://%(addr)s:%(port)s/\n"
"Quit the server with %(quit_command)s.\n"
)
% {
"version": self.get_version(),
"daphne_version": __version__,
"settings": settings.SETTINGS_MODULE,
"protocol": self.protocol,
"addr": "[%s]" % self.addr if self._raw_ipv6 else self.addr,
"port": self.port,
"quit_command": quit_command,
}
)
# Launch server in 'main' thread. Signals are disabled as it's still
# actually a subthread under the autoreloader.
logger.debug("Daphne running, listening on %s:%s", self.addr, self.port)
# build the endpoint description string from host/port options
endpoints = build_endpoint_description_strings(host=self.addr, port=self.port)
try:
self.server_cls(
application=self.get_application(options),
endpoints=endpoints,
signal_handlers=not options["use_reloader"],
action_logger=self.log_action,
http_timeout=self.http_timeout,
root_path=getattr(settings, "FORCE_SCRIPT_NAME", "") or "",
websocket_handshake_timeout=self.websocket_handshake_timeout,
).run()
logger.debug("Daphne exited")
except KeyboardInterrupt:
shutdown_message = options.get("shutdown_message", "")
if shutdown_message:
self.stdout.write(shutdown_message)
return
def get_application(self, options):
"""
Returns the static files serving application wrapping the default application,
if static files should be served. Otherwise just returns the default
handler.
"""
staticfiles_installed = apps.is_installed("django.contrib.staticfiles")
use_static_handler = options.get("use_static_handler", staticfiles_installed)
insecure_serving = options.get("insecure_serving", False)
if use_static_handler and (settings.DEBUG or insecure_serving):
return ASGIStaticFilesHandler(get_default_application())
else:
return get_default_application()
def log_action(self, protocol, action, details):
"""
Logs various different kinds of requests to the console.
"""
# HTTP requests
if protocol == "http" and action == "complete":
msg = "HTTP %(method)s %(path)s %(status)s [%(time_taken).2f, %(client)s]"
# Utilize terminal colors, if available
if 200 <= details["status"] < 300:
# Put 2XX first, since it should be the common case
logger.info(self.style.HTTP_SUCCESS(msg), details)
elif 100 <= details["status"] < 200:
logger.info(self.style.HTTP_INFO(msg), details)
elif details["status"] == 304:
logger.info(self.style.HTTP_NOT_MODIFIED(msg), details)
elif 300 <= details["status"] < 400:
logger.info(self.style.HTTP_REDIRECT(msg), details)
elif details["status"] == 404:
logger.warning(self.style.HTTP_NOT_FOUND(msg), details)
elif 400 <= details["status"] < 500:
logger.warning(self.style.HTTP_BAD_REQUEST(msg), details)
else:
# Any 5XX, or any other response
logger.error(self.style.HTTP_SERVER_ERROR(msg), details)
# Websocket requests
elif protocol == "websocket" and action == "connected":
logger.info("WebSocket CONNECT %(path)s [%(client)s]", details)
elif protocol == "websocket" and action == "disconnected":
logger.info("WebSocket DISCONNECT %(path)s [%(client)s]", details)
elif protocol == "websocket" and action == "connecting":
logger.info("WebSocket HANDSHAKING %(path)s [%(client)s]", details)
elif protocol == "websocket" and action == "rejected":
logger.info("WebSocket REJECT %(path)s [%(client)s]", details)

View File

@ -1,10 +1,18 @@
# This has to be done first as Twisted is import-order-sensitive with reactors
import asyncio # isort:skip
import os # isort:skip
import sys # isort:skip
import warnings # isort:skip
from concurrent.futures import ThreadPoolExecutor # isort:skip
from twisted.internet import asyncioreactor # isort:skip
twisted_loop = asyncio.new_event_loop()
if "ASGI_THREADS" in os.environ:
twisted_loop.set_default_executor(
ThreadPoolExecutor(max_workers=int(os.environ["ASGI_THREADS"]))
)
current_reactor = sys.modules.get("twisted.internet.reactor", None)
if current_reactor is not None:
if not isinstance(current_reactor, asyncioreactor.AsyncioSelectorReactor):
@ -13,6 +21,7 @@ if current_reactor is not None:
+ "you can fix this warning by importing daphne.server early in your codebase or "
+ "finding the package that imports Twisted and importing it later on.",
UserWarning,
stacklevel=2,
)
del sys.modules["twisted.internet.reactor"]
asyncioreactor.install(twisted_loop)
@ -22,6 +31,7 @@ else:
import logging
import time
from concurrent.futures import CancelledError
from functools import partial
from twisted.internet import defer, reactor
from twisted.internet.endpoints import serverFromString
@ -34,7 +44,7 @@ from .ws_protocol import WebSocketFactory
logger = logging.getLogger(__name__)
class Server(object):
class Server:
def __init__(
self,
application,
@ -42,6 +52,7 @@ class Server(object):
signal_handlers=True,
action_logger=None,
http_timeout=None,
request_buffer_size=8192,
websocket_timeout=86400,
websocket_connect_timeout=20,
ping_interval=20,
@ -54,9 +65,7 @@ class Server(object):
websocket_handshake_timeout=5,
application_close_timeout=10,
ready_callable=None,
server_name="Daphne",
# Deprecated and does not work, remove in version 2.2
ws_protocols=None,
server_name="daphne",
):
self.application = application
self.endpoints = endpoints or []
@ -67,6 +76,7 @@ class Server(object):
self.http_timeout = http_timeout
self.ping_interval = ping_interval
self.ping_timeout = ping_timeout
self.request_buffer_size = request_buffer_size
self.proxy_forwarded_address_header = proxy_forwarded_address_header
self.proxy_forwarded_port_header = proxy_forwarded_port_header
self.proxy_forwarded_proto_header = proxy_forwarded_proto_header
@ -197,15 +207,17 @@ class Server(object):
assert "application_instance" not in self.connections[protocol]
# Make an instance of the application
input_queue = asyncio.Queue()
application_instance = self.application(scope=scope)
scope.setdefault("asgi", {"version": "3.0"})
application_instance = self.application(
scope=scope,
receive=input_queue.get,
send=partial(self.handle_reply, protocol),
)
# Run it, and stash the future for later checking
if protocol not in self.connections:
return None
self.connections[protocol]["application_instance"] = asyncio.ensure_future(
application_instance(
receive=input_queue.get,
send=lambda message: self.handle_reply(protocol, message),
),
application_instance,
loop=asyncio.get_event_loop(),
)
return input_queue
@ -273,7 +285,7 @@ class Server(object):
if application_instance and application_instance.done():
try:
exception = application_instance.exception()
except CancelledError:
except (CancelledError, asyncio.CancelledError):
# Future cancellation. We can ignore this.
pass
else:

View File

@ -5,10 +5,9 @@ import pickle
import tempfile
import traceback
from concurrent.futures import CancelledError
from functools import partial
class DaphneTestingInstance:
class BaseDaphneTestingInstance:
"""
Launches an instance of Daphne in a subprocess, with a host and port
attribute allowing you to call it.
@ -18,18 +17,23 @@ class DaphneTestingInstance:
startup_timeout = 2
def __init__(self, xff=False, http_timeout=None):
def __init__(
self, xff=False, http_timeout=None, request_buffer_size=None, *, application
):
self.xff = xff
self.http_timeout = http_timeout
self.host = "127.0.0.1"
self.lock = multiprocessing.Lock()
self.request_buffer_size = request_buffer_size
self.application = application
def get_application(self):
return self.application
def __enter__(self):
# Clear result storage
TestApplication.delete_setup()
TestApplication.delete_result()
# Option Daphne features
kwargs = {}
if self.request_buffer_size:
kwargs["request_buffer_size"] = self.request_buffer_size
# Optionally enable X-Forwarded-For support.
if self.xff:
kwargs["proxy_forwarded_address_header"] = "X-Forwarded-For"
@ -40,7 +44,7 @@ class DaphneTestingInstance:
# Start up process
self.process = DaphneProcess(
host=self.host,
application=partial(TestApplication, lock=self.lock),
get_application=self.get_application,
kwargs=kwargs,
setup=self.process_setup,
teardown=self.process_teardown,
@ -74,6 +78,21 @@ class DaphneTestingInstance:
"""
pass
def get_received(self):
pass
class DaphneTestingInstance(BaseDaphneTestingInstance):
def __init__(self, *args, **kwargs):
self.lock = multiprocessing.Lock()
super().__init__(*args, **kwargs, application=TestApplication(lock=self.lock))
def __enter__(self):
# Clear result storage
TestApplication.delete_setup()
TestApplication.delete_result()
return super().__enter__()
def get_received(self):
"""
Returns the scope and messages the test application has received
@ -107,13 +126,13 @@ class DaphneProcess(multiprocessing.Process):
port it ends up listening on back to the parent process.
"""
def __init__(self, host, application, kwargs=None, setup=None, teardown=None):
def __init__(self, host, get_application, kwargs=None, setup=None, teardown=None):
super().__init__()
self.host = host
self.application = application
self.get_application = get_application
self.kwargs = kwargs or {}
self.setup = setup or (lambda: None)
self.teardown = teardown or (lambda: None)
self.setup = setup
self.teardown = teardown
self.port = multiprocessing.Value("i")
self.ready = multiprocessing.Event()
self.errors = multiprocessing.Queue()
@ -127,27 +146,31 @@ class DaphneProcess(multiprocessing.Process):
from twisted.internet import reactor
from .server import Server
from .endpoints import build_endpoint_description_strings
from .server import Server
application = self.get_application()
try:
# Create the server class
endpoints = build_endpoint_description_strings(host=self.host, port=0)
self.server = Server(
application=self.application,
application=application,
endpoints=endpoints,
signal_handlers=False,
**self.kwargs
**self.kwargs,
)
# Set up a poller to look for the port
reactor.callLater(0.1, self.resolve_port)
# Run with setup/teardown
if self.setup is not None:
self.setup()
try:
self.server.run()
finally:
if self.teardown is not None:
self.teardown()
except Exception as e:
except BaseException as e:
# Put the error on our queue so the parent gets it
self.errors.put((e, traceback.format_exc()))
@ -170,12 +193,12 @@ class TestApplication:
setup_storage = os.path.join(tempfile.gettempdir(), "setup.testio")
result_storage = os.path.join(tempfile.gettempdir(), "result.testio")
def __init__(self, scope, lock):
self.scope = scope
def __init__(self, lock):
self.lock = lock
self.messages = []
async def __call__(self, send, receive):
async def __call__(self, scope, receive, send):
self.scope = scope
# Receive input and send output
logging.debug("test app coroutine alive")
try:
@ -266,8 +289,8 @@ class TestApplication:
def _reinstall_reactor():
import sys
import asyncio
import sys
from twisted.internet import asyncioreactor

View File

@ -7,7 +7,7 @@ from zope.interface import implementer
@implementer(IPlugin, IStreamServerEndpointStringParser)
class _FDParser(object):
class _FDParser:
prefix = "fd"
def _parseServer(self, reactor, fileno, domain=socket.AF_INET):

View File

@ -1,7 +1,12 @@
import importlib
import re
from twisted.web.http_headers import Headers
# Header name regex as per h11.
# https://github.com/python-hyper/h11/blob/a2c68948accadc3876dffcf979d98002e4a4ed27/h11/_abnf.py#L10-L21
HEADER_NAME_RE = re.compile(rb"[-!#$%&'*+.^_`|~0-9a-zA-Z]+")
def import_by_path(path):
"""

View File

@ -31,16 +31,20 @@ class WebSocketProtocol(WebSocketServerProtocol):
self.server.protocol_connected(self)
self.request = request
self.protocol_to_accept = None
self.root_path = self.server.root_path
self.socket_opened = time.time()
self.last_ping = time.time()
try:
# Sanitize and decode headers
# Sanitize and decode headers, potentially extracting root path
self.clean_headers = []
for name, value in request.headers.items():
name = name.encode("ascii")
# Prevent CVE-2015-0219
if b"_" in name:
continue
if name.lower() == b"daphne-root-path":
self.root_path = unquote(value)
else:
self.clean_headers.append((name.lower(), value.encode("latin1")))
# Get client address if possible
peer = self.transport.getPeer()
@ -76,6 +80,7 @@ class WebSocketProtocol(WebSocketServerProtocol):
"type": "websocket",
"path": unquote(self.path.decode("ascii")),
"raw_path": self.path,
"root_path": self.root_path,
"headers": self.clean_headers,
"query_string": self._raw_query_string, # Passed by HTTP protocol
"client": self.client_addr,
@ -110,9 +115,9 @@ class WebSocketProtocol(WebSocketServerProtocol):
"connecting",
{
"path": self.request.path,
"client": "%s:%s" % tuple(self.client_addr)
if self.client_addr
else None,
"client": (
"%s:%s" % tuple(self.client_addr) if self.client_addr else None
),
},
)
@ -133,9 +138,9 @@ class WebSocketProtocol(WebSocketServerProtocol):
"connected",
{
"path": self.request.path,
"client": "%s:%s" % tuple(self.client_addr)
if self.client_addr
else None,
"client": (
"%s:%s" % tuple(self.client_addr) if self.client_addr else None
),
},
)
@ -170,9 +175,9 @@ class WebSocketProtocol(WebSocketServerProtocol):
"disconnected",
{
"path": self.request.path,
"client": "%s:%s" % tuple(self.client_addr)
if self.client_addr
else None,
"client": (
"%s:%s" % tuple(self.client_addr) if self.client_addr else None
),
},
)
@ -237,9 +242,9 @@ class WebSocketProtocol(WebSocketServerProtocol):
"rejected",
{
"path": self.request.path,
"client": "%s:%s" % tuple(self.client_addr)
if self.client_addr
else None,
"client": (
"%s:%s" % tuple(self.client_addr) if self.client_addr else None
),
},
)
@ -297,7 +302,7 @@ class WebSocketProtocol(WebSocketServerProtocol):
return id(self) == id(other)
def __repr__(self):
return "<WebSocketProtocol client=%r path=%r>" % (self.client_addr, self.path)
return f"<WebSocketProtocol client={self.client_addr!r} path={self.path!r}>"
class WebSocketFactory(WebSocketServerFactory):
@ -318,7 +323,7 @@ class WebSocketFactory(WebSocketServerFactory):
Builds protocol instances. We use this to inject the factory object into the protocol.
"""
try:
protocol = super(WebSocketFactory, self).buildProtocol(addr)
protocol = super().buildProtocol(addr)
protocol.factory = self
return protocol
except Exception:

81
pyproject.toml Normal file
View File

@ -0,0 +1,81 @@
[project]
name = "daphne"
dynamic = ["version"]
description = "Django ASGI (HTTP/WebSocket) server"
requires-python = ">=3.9"
authors = [
{ name = "Django Software Foundation", email = "foundation@djangoproject.com" },
]
license = { text = "BSD" }
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Web Environment",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Internet :: WWW/HTTP",
]
dependencies = ["asgiref>=3.5.2,<4", "autobahn>=22.4.2", "twisted[tls]>=22.4"]
[project.optional-dependencies]
tests = [
"django",
"hypothesis",
"pytest",
"pytest-asyncio",
"pytest-cov",
"black",
"tox",
"flake8",
"flake8-bugbear",
"mypy",
]
[project.urls]
homepage = "https://github.com/django/daphne"
documentation = "https://channels.readthedocs.io"
repository = "https://github.com/django/daphne.git"
changelog = "https://github.com/django/daphne/blob/main/CHANGELOG.txt"
issues = "https://github.com/django/daphne/issues"
[project.scripts]
daphne = "daphne.cli:CommandLineInterface.entrypoint"
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["daphne"]
[tool.setuptools.dynamic]
version = { attr = "daphne.__version__" }
readme = { file = "README.rst", content-type = "text/x-rst" }
[tool.isort]
profile = "black"
[tool.pytest]
testpaths = ["tests"]
asyncio_mode = "strict"
filterwarnings = ["ignore::pytest.PytestDeprecationWarning"]
[tool.coverage.run]
omit = ["tests/*"]
concurrency = ["multiprocessing"]
[tool.coverage.report]
show_missing = "true"
skip_covered = "true"
[tool.coverage.html]
directory = "reports/coverage_html_report"

View File

@ -1,16 +0,0 @@
[bdist_wheel]
universal=1
[tool:pytest]
addopts = tests/
[isort]
include_trailing_comma = True
multi_line_output = 3
known_first_party = channels,daphne,asgiref,channels_redis
line_length = 88
[flake8]
exclude = venv/*,tox/*,docs/*,testproject/*,js_client/*,.eggs/*
ignore = E123,E128,E266,E402,W503,E731,W601
max-line-length = 120

View File

@ -1,47 +0,0 @@
import os
from setuptools import find_packages, setup
from daphne import __version__
# We use the README as the long_description
readme_path = os.path.join(os.path.dirname(__file__), "README.rst")
with open(readme_path) as fp:
long_description = fp.read()
setup(
name="daphne",
version=__version__,
url="https://github.com/django/daphne",
author="Django Software Foundation",
author_email="foundation@djangoproject.com",
description="Django ASGI (HTTP/WebSocket) server",
long_description=long_description,
license="BSD",
zip_safe=False,
package_dir={"twisted": "daphne/twisted"},
packages=find_packages() + ["twisted.plugins"],
include_package_data=True,
install_requires=["twisted[tls]>=18.7", "autobahn>=0.18", "asgiref~=3.2"],
setup_requires=["pytest-runner"],
extras_require={
"tests": ["hypothesis==4.23", "pytest~=3.10", "pytest-asyncio~=0.8"]
},
entry_points={
"console_scripts": ["daphne = daphne.cli:CommandLineInterface.entrypoint"]
},
classifiers=[
"Development Status :: 4 - Beta",
"Environment :: Web Environment",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Topic :: Internet :: WWW/HTTP",
],
)

View File

@ -20,13 +20,24 @@ class DaphneTestCase(unittest.TestCase):
### Plain HTTP helpers
def run_daphne_http(
self, method, path, params, body, responses, headers=None, timeout=1, xff=False
self,
method,
path,
params,
body,
responses,
headers=None,
timeout=1,
xff=False,
request_buffer_size=None,
):
"""
Runs Daphne with the given request callback (given the base URL)
and response messages.
"""
with DaphneTestingInstance(xff=xff) as test_app:
with DaphneTestingInstance(
xff=xff, request_buffer_size=request_buffer_size
) as test_app:
# Add the response messages
test_app.add_send_messages(responses)
# Send it the request. We have to do this the long way to allow
@ -79,7 +90,14 @@ class DaphneTestCase(unittest.TestCase):
)
def run_daphne_request(
self, method, path, params=None, body=None, headers=None, xff=False
self,
method,
path,
params=None,
body=None,
headers=None,
xff=False,
request_buffer_size=None,
):
"""
Convenience method for just testing request handling.
@ -92,6 +110,7 @@ class DaphneTestCase(unittest.TestCase):
body=body,
headers=headers,
xff=xff,
request_buffer_size=request_buffer_size,
responses=[
{"type": "http.response.start", "status": 200},
{"type": "http.response.body", "body": b"OK"},
@ -163,7 +182,7 @@ class DaphneTestCase(unittest.TestCase):
if response.status != 101:
raise RuntimeError("WebSocket upgrade did not result in status code 101")
# Prepare headers for subprotocol searching
response_headers = dict((n.lower(), v) for n, v in response.getheaders())
response_headers = {n.lower(): v for n, v in response.getheaders()}
response.read()
assert not response.closed
# Return the raw socket and any subprotocol
@ -233,7 +252,7 @@ class DaphneTestCase(unittest.TestCase):
"""
try:
socket.inet_aton(address)
except socket.error:
except OSError:
self.fail("'%s' is not a valid IP address." % address)
def assert_key_sets(self, required_keys, optional_keys, actual_keys):

21
tests/test_checks.py Normal file
View File

@ -0,0 +1,21 @@
import django
from django.conf import settings
from django.test.utils import override_settings
from daphne.checks import check_daphne_installed
def test_check_daphne_installed():
"""
Test check error is raised if daphne is not listed before staticfiles, and vice versa.
"""
settings.configure(
INSTALLED_APPS=["daphne.apps.DaphneConfig", "django.contrib.staticfiles"]
)
django.setup()
errors = check_daphne_installed(None)
assert len(errors) == 0
with override_settings(INSTALLED_APPS=["django.contrib.staticfiles", "daphne"]):
errors = check_daphne_installed(None)
assert len(errors) == 1
assert errors[0].id == "daphne.E001"

View File

@ -1,8 +1,7 @@
# coding: utf8
import logging
import os
from argparse import ArgumentError
from unittest import TestCase
from unittest import TestCase, skipUnless
from daphne.cli import CommandLineInterface
from daphne.endpoints import build_endpoint_description_strings as build
@ -242,3 +241,27 @@ class TestCLIInterface(TestCase):
exc.exception.message,
"--proxy-headers has to be passed for this parameter.",
)
def test_custom_servername(self):
"""
Passing `--server-name` will set the default server header
from 'daphne' to the passed one.
"""
self.assertCLI([], {"server_name": "daphne"})
self.assertCLI(["--server-name", ""], {"server_name": ""})
self.assertCLI(["--server-name", "python"], {"server_name": "python"})
def test_no_servername(self):
"""
Passing `--no-server-name` will set server name to '' (empty string)
"""
self.assertCLI(["--no-server-name"], {"server_name": ""})
@skipUnless(os.getenv("ASGI_THREADS"), "ASGI_THREADS environment variable not set.")
class TestASGIThreads(TestCase):
def test_default_executor(self):
from daphne.server import twisted_loop
executor = twisted_loop._default_executor
self.assertEqual(executor._max_workers, int(os.getenv("ASGI_THREADS")))

View File

@ -0,0 +1,49 @@
import unittest
from daphne.http_protocol import WebRequest
class MockServer:
"""
Mock server object for testing.
"""
def protocol_connected(self, *args, **kwargs):
pass
class MockFactory:
"""
Mock factory object for testing.
"""
def __init__(self):
self.server = MockServer()
class MockChannel:
"""
Mock channel object for testing.
"""
def __init__(self):
self.factory = MockFactory()
self.transport = None
def getPeer(self, *args, **kwargs):
return "peer"
def getHost(self, *args, **kwargs):
return "host"
class TestHTTPProtocol(unittest.TestCase):
"""
Tests the HTTP protocol classes.
"""
def test_web_request_initialisation(self):
channel = MockChannel()
request = WebRequest(channel)
self.assertIsNone(request.client_addr)
self.assertIsNone(request.server_addr)

View File

@ -1,12 +1,10 @@
# coding: utf8
import collections
from urllib import parse
from hypothesis import assume, given, settings
import http_strategies
from http_base import DaphneTestCase
from hypothesis import assume, given, settings
from hypothesis.strategies import integers
class TestHTTPRequest(DaphneTestCase):
@ -24,6 +22,7 @@ class TestHTTPRequest(DaphneTestCase):
# Check overall keys
self.assert_key_sets(
required_keys={
"asgi",
"type",
"http_version",
"method",
@ -35,6 +34,7 @@ class TestHTTPRequest(DaphneTestCase):
optional_keys={"scheme", "root_path", "client", "server"},
actual_keys=scope.keys(),
)
self.assertEqual(scope["asgi"]["version"], "3.0")
# Check that it is the right type
self.assertEqual(scope["type"], "http")
# Method (uppercased unicode string)
@ -120,6 +120,26 @@ class TestHTTPRequest(DaphneTestCase):
self.assert_valid_http_scope(scope, "GET", request_path, params=request_params)
self.assert_valid_http_request_message(messages[0], body=b"")
@given(request_path=http_strategies.http_path(), chunk_size=integers(min_value=1))
@settings(max_examples=5, deadline=5000)
def test_request_body_chunking(self, request_path, chunk_size):
"""
Tests request body chunking logic.
"""
body = b"The quick brown fox jumps over the lazy dog"
_, messages = self.run_daphne_request(
"POST",
request_path,
body=body,
request_buffer_size=chunk_size,
)
# Avoid running those asserts when there's a single "http.disconnect"
if len(messages) > 1:
assert messages[0]["body"].decode() == body.decode()[:chunk_size]
assert not messages[-2]["more_body"]
assert messages[-1] == {"type": "http.disconnect"}
@given(
request_path=http_strategies.http_path(),
request_body=http_strategies.http_body(),
@ -284,9 +304,21 @@ class TestHTTPRequest(DaphneTestCase):
response = self.run_daphne_raw(
b"GET /\xc3\xa4\xc3\xb6\xc3\xbc HTTP/1.0\r\n\r\n"
)
self.assertTrue(response.startswith(b"HTTP/1.0 400 Bad Request"))
self.assertTrue(b"400 Bad Request" in response)
# Bad querystring
response = self.run_daphne_raw(
b"GET /?\xc3\xa4\xc3\xb6\xc3\xbc HTTP/1.0\r\n\r\n"
)
self.assertTrue(response.startswith(b"HTTP/1.0 400 Bad Request"))
self.assertTrue(b"400 Bad Request" in response)
def test_invalid_header_name(self):
"""
Tests that requests with invalid header names fail.
"""
# Test cases follow those used by h11
# https://github.com/python-hyper/h11/blob/a2c68948accadc3876dffcf979d98002e4a4ed27/h11/tests/test_headers.py#L24-L35
for header_name in [b"foo bar", b"foo\x00bar", b"foo\xffbar", b"foo\x01bar"]:
response = self.run_daphne_raw(
f"GET / HTTP/1.0\r\n{header_name}: baz\r\n\r\n".encode("ascii")
)
self.assertTrue(b"400 Bad Request" in response)

View File

@ -1,9 +1,6 @@
# coding: utf8
from hypothesis import given, settings
import http_strategies
from http_base import DaphneTestCase
from hypothesis import given, settings
class TestHTTPResponse(DaphneTestCase):
@ -16,10 +13,11 @@ class TestHTTPResponse(DaphneTestCase):
Lowercases and sorts headers, and strips transfer-encoding ones.
"""
return sorted(
[
[(b"server", b"daphne")]
+ [
(name.lower(), value.strip())
for name, value in headers
if name.lower() != b"transfer-encoding"
if name.lower() not in (b"server", b"transfer-encoding")
]
)

View File

@ -1,5 +1,3 @@
# coding: utf8
from unittest import TestCase
from twisted.web.http_headers import Headers

View File

@ -1,13 +1,12 @@
# coding: utf8
import collections
import time
from urllib import parse
from hypothesis import given, settings
import http_strategies
from http_base import DaphneTestCase, DaphneTestingInstance
from hypothesis import given, settings
from daphne.testing import BaseDaphneTestingInstance
class TestWebsocket(DaphneTestCase):
@ -24,10 +23,18 @@ class TestWebsocket(DaphneTestCase):
"""
# Check overall keys
self.assert_key_sets(
required_keys={"type", "path", "raw_path", "query_string", "headers"},
required_keys={
"asgi",
"type",
"path",
"raw_path",
"query_string",
"headers",
},
optional_keys={"scheme", "root_path", "client", "server", "subprotocols"},
actual_keys=scope.keys(),
)
self.assertEqual(scope["asgi"]["version"], "3.0")
# Check that it is the right type
self.assertEqual(scope["type"], "websocket")
# Path
@ -185,6 +192,30 @@ class TestWebsocket(DaphneTestCase):
self.assertEqual(scope["path"], "/foo/bar")
self.assertEqual(scope["raw_path"], b"/foo%2Fbar")
@given(daphne_path=http_strategies.http_path())
@settings(max_examples=5, deadline=2000)
def test_root_path(self, *, daphne_path):
"""
Tests root_path handling.
"""
headers = [("Daphne-Root-Path", parse.quote(daphne_path))]
with DaphneTestingInstance() as test_app:
test_app.add_send_messages([{"type": "websocket.accept"}])
self.websocket_handshake(
test_app,
path="/",
headers=headers,
)
# Validate the scope and messages we got
scope, _ = test_app.get_received()
# Daphne-Root-Path is not included in the returned 'headers' section.
self.assertNotIn(
"daphne-root-path", (header[0].lower() for header in scope["headers"])
)
# And what we're looking for, root_path being set.
self.assertEqual(scope["root_path"], daphne_path)
def test_text_frames(self):
"""
Tests we can send and receive text frames.
@ -254,3 +285,54 @@ class TestWebsocket(DaphneTestCase):
self.websocket_send_frame(sock, "still alive?")
# Receive a frame and make sure it's correct
assert self.websocket_receive_frame(sock) == "cake"
def test_application_checker_handles_asyncio_cancellederror(self):
with CancellingTestingInstance() as app:
# Connect to the websocket app, it will immediately raise
# asyncio.CancelledError
sock, _ = self.websocket_handshake(app)
# Disconnect from the socket
sock.close()
# Wait for application_checker to clean up the applications for
# disconnected clients, and for the server to be stopped.
time.sleep(3)
# Make sure we received either no error, or a ConnectionsNotEmpty
while not app.process.errors.empty():
err, _tb = app.process.errors.get()
if not isinstance(err, ConnectionsNotEmpty):
raise err
self.fail(
"Server connections were not cleaned up after an asyncio.CancelledError was raised"
)
async def cancelling_application(scope, receive, send):
import asyncio
from twisted.internet import reactor
# Stop the server after a short delay so that the teardown is run.
reactor.callLater(2, reactor.stop)
await send({"type": "websocket.accept"})
raise asyncio.CancelledError()
class ConnectionsNotEmpty(Exception):
pass
class CancellingTestingInstance(BaseDaphneTestingInstance):
def __init__(self):
super().__init__(application=cancelling_application)
def process_teardown(self):
import multiprocessing
# Get a hold of the enclosing DaphneProcess (we're currently running in
# the same process as the application).
proc = multiprocessing.current_process()
# By now the (only) socket should have disconnected, and the
# application_checker should have run. If there are any connections
# still, it means that the application_checker did not clean them up.
if proc.server.connections:
raise ConnectionsNotEmpty()

8
tox.ini Normal file
View File

@ -0,0 +1,8 @@
[tox]
envlist =
py{39,310,311,312,313}
[testenv]
extras = tests
commands =
pytest -v {posargs}