Compare commits
31 Commits
v23.3.0
...
motd-fixes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44bf7ba79a | ||
|
|
9e7ca10c52 | ||
|
|
fe32f4eb74 | ||
|
|
ebe29d3d26 | ||
|
|
f651f7436f | ||
|
|
16256522f6 | ||
|
|
205795d1e8 | ||
|
|
9cbe1fb8ad | ||
|
|
31d7ba8f8c | ||
|
|
dc3c4d1393 | ||
|
|
929d270569 | ||
|
|
93714df051 | ||
|
|
6e61eab872 | ||
|
|
6848ff24d8 | ||
|
|
666371bb92 | ||
|
|
4a2b82e42e | ||
|
|
5dd1623192 | ||
|
|
976da69e79 | ||
|
|
11a0b15194 | ||
|
|
c21999a248 | ||
|
|
c17230ef94 | ||
|
|
049983cb70 | ||
|
|
e374409567 | ||
|
|
4068a0d83d | ||
|
|
70da5e9879 | ||
|
|
f48506d620 | ||
|
|
f2cc83c1ba | ||
|
|
273825dab6 | ||
|
|
9a7dafd531 | ||
|
|
50117d174c | ||
|
|
af67801062 |
16
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
16
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -21,7 +21,14 @@ body:
|
||||
id: code
|
||||
attributes:
|
||||
label: Code snippet
|
||||
description: Relevant source code, make sure to remove what is not necessary.
|
||||
description: |
|
||||
Relevant source code, make sure to remove what is not necessary. Please try and format your code so that it is easier to read. For example:
|
||||
|
||||
```python
|
||||
from sanic import Sanic
|
||||
|
||||
app = Sanic("Example")
|
||||
```
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
@@ -42,11 +49,16 @@ body:
|
||||
- ASGI
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: What OS?
|
||||
options:
|
||||
- Linux
|
||||
- MacOS
|
||||
- Windows
|
||||
- Other (tell us in the description)
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
|
||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -4,10 +4,12 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
schedule:
|
||||
|
||||
2
.github/workflows/coverage.yml
vendored
2
.github/workflows/coverage.yml
vendored
@@ -3,12 +3,14 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
tags:
|
||||
- "!*" # Do not execute on tags
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
jobs:
|
||||
test:
|
||||
|
||||
2
.github/workflows/pr-bandit.yml
vendored
2
.github/workflows/pr-bandit.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
@@ -16,7 +17,6 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
config:
|
||||
- { python-version: 3.7, tox-env: security}
|
||||
- { python-version: 3.8, tox-env: security}
|
||||
- { python-version: 3.9, tox-env: security}
|
||||
- { python-version: "3.10", tox-env: security}
|
||||
|
||||
1
.github/workflows/pr-docs.yml
vendored
1
.github/workflows/pr-docs.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
1
.github/workflows/pr-linter.yml
vendored
1
.github/workflows/pr-linter.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
4
.github/workflows/pr-python-pypy.yml
vendored
4
.github/workflows/pr-python-pypy.yml
vendored
@@ -5,11 +5,11 @@ on:
|
||||
tox-env:
|
||||
description: "Tox Env to run on the PyPy Infra"
|
||||
required: false
|
||||
default: "pypy37"
|
||||
default: "pypy310"
|
||||
pypy-version:
|
||||
description: "Version of PyPy to use"
|
||||
required: false
|
||||
default: "pypy-3.7"
|
||||
default: "pypy-3.10"
|
||||
jobs:
|
||||
testPyPy:
|
||||
name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }}
|
||||
|
||||
1
.github/workflows/pr-python310.yml
vendored
1
.github/workflows/pr-python310.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
1
.github/workflows/pr-python311.yml
vendored
1
.github/workflows/pr-python311.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
35
.github/workflows/pr-python37.yml
vendored
35
.github/workflows/pr-python37.yml
vendored
@@ -1,35 +0,0 @@
|
||||
name: Python 3.7 Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
testPy37:
|
||||
if: github.event.pull_request.draft == false
|
||||
name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
# os: [ubuntu-latest, macos-latest]
|
||||
os: [ubuntu-latest]
|
||||
config:
|
||||
- { python-version: 3.7, tox-env: py37 }
|
||||
- { python-version: 3.7, tox-env: py37-no-ext }
|
||||
steps:
|
||||
- name: Checkout the Repository
|
||||
uses: actions/checkout@v2
|
||||
id: checkout-branch
|
||||
|
||||
- name: Run Unit Tests
|
||||
uses: harshanarayana/custom-actions@main
|
||||
with:
|
||||
python-version: ${{ matrix.config.python-version }}
|
||||
test-infra-tool: tox
|
||||
test-infra-version: latest
|
||||
action: tests
|
||||
test-additional-args: "-e=${{ matrix.config.tox-env }}"
|
||||
test-failure-retry: "3"
|
||||
1
.github/workflows/pr-python38.yml
vendored
1
.github/workflows/pr-python38.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
1
.github/workflows/pr-python39.yml
vendored
1
.github/workflows/pr-python39.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
|
||||
2
.github/workflows/pr-type-check.yml
vendored
2
.github/workflows/pr-type-check.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
@@ -16,7 +17,6 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
config:
|
||||
# - { python-version: 3.7, tox-env: type-checking}
|
||||
- { python-version: 3.8, tox-env: type-checking}
|
||||
- { python-version: 3.9, tox-env: type-checking}
|
||||
- { python-version: "3.10", tox-env: type-checking}
|
||||
|
||||
3
.github/workflows/pr-windows.yml
vendored
3
.github/workflows/pr-windows.yml
vendored
@@ -3,6 +3,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- current-release
|
||||
- "*LTS"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
@@ -15,12 +16,10 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
config:
|
||||
- { python-version: 3.7, tox-env: py37-no-ext }
|
||||
- { python-version: 3.8, tox-env: py38-no-ext }
|
||||
- { python-version: 3.9, tox-env: py39-no-ext }
|
||||
- { python-version: "3.10", tox-env: py310-no-ext }
|
||||
- { python-version: "3.11", tox-env: py310-no-ext }
|
||||
- { python-version: pypy-3.7, tox-env: pypy37-no-ext }
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
|
||||
2
.github/workflows/publish-images.yml
vendored
2
.github/workflows/publish-images.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
55
.github/workflows/publish-package.yml
vendored
55
.github/workflows/publish-package.yml
vendored
@@ -1,28 +1,39 @@
|
||||
name: Publish Artifacts
|
||||
name: Upload Python Package
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [created]
|
||||
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
publishPythonPackage:
|
||||
name: Publishing Sanic Release Artifacts
|
||||
build-n-publish:
|
||||
name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
python-version: ["3.10"]
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Publish Python Package
|
||||
uses: harshanarayana/custom-actions@main
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
package-infra-name: "twine"
|
||||
pypi-user: __token__
|
||||
pypi-access-token: ${{ secrets.PYPI_ACCESS_TOKEN }}
|
||||
action: "package-publish"
|
||||
pypi-verify-metadata: "true"
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Install pypa/build
|
||||
run: >-
|
||||
python3 -m
|
||||
pip install
|
||||
build
|
||||
--user
|
||||
- name: Build a binary wheel and a source tarball
|
||||
run: >-
|
||||
python3 -m
|
||||
build
|
||||
--sdist
|
||||
--wheel
|
||||
--outdir dist/
|
||||
.
|
||||
# - name: Publish distribution 📦 to Test PyPI
|
||||
# uses: pypa/gh-action-pypi-publish@release/v1
|
||||
# with:
|
||||
# password: ${{ secrets.SANIC_TEST_PYPI_API_TOKEN }}
|
||||
# repository-url: https://test.pypi.org/legacy/
|
||||
- name: Publish distribution 📦 to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.SANIC_PYPI_API_TOKEN }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,4 +21,5 @@ dist/*
|
||||
pip-wheel-metadata/
|
||||
.pytest_cache/*
|
||||
.venv/*
|
||||
venv/*
|
||||
.vscode/*
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
| 🔷 In support release
|
||||
|
|
||||
|
||||
.. mdinclude:: ./releases/23/23.6.md
|
||||
.. mdinclude:: ./releases/23/23.3.md
|
||||
.. mdinclude:: ./releases/22/22.12.md
|
||||
.. mdinclude:: ./releases/22/22.9.md
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
## Version 23.3.0 🔶
|
||||
## Version 23.3.0
|
||||
|
||||
### Features
|
||||
- [#2545](https://github.com/sanic-org/sanic/pull/2545) Standardize init of exceptions for more consistent control of HTTP responses using exceptions
|
||||
|
||||
33
docs/sanic/releases/23/23.6.md
Normal file
33
docs/sanic/releases/23/23.6.md
Normal file
@@ -0,0 +1,33 @@
|
||||
## Version 23.6.0 🔶
|
||||
|
||||
### Features
|
||||
- [#2670](https://github.com/sanic-org/sanic/pull/2670) Increase `KEEP_ALIVE_TIMEOUT` default to 120 seconds
|
||||
- [#2716](https://github.com/sanic-org/sanic/pull/2716) Adding allow route overwrite option in blueprint
|
||||
- [#2724](https://github.com/sanic-org/sanic/pull/2724) and [#2792](https://github.com/sanic-org/sanic/pull/2792) Add a new exception signal for ALL exceptions raised anywhere in application
|
||||
- [#2727](https://github.com/sanic-org/sanic/pull/2727) Add name prefixing to BP groups
|
||||
- [#2754](https://github.com/sanic-org/sanic/pull/2754) Update request type on middleware types
|
||||
- [#2770](https://github.com/sanic-org/sanic/pull/2770) Better exception message on startup time application induced import error
|
||||
- [#2776](https://github.com/sanic-org/sanic/pull/2776) Set multiprocessing start method early
|
||||
- [#2785](https://github.com/sanic-org/sanic/pull/2785) Add custom typing to config and ctx objects
|
||||
- [#2790](https://github.com/sanic-org/sanic/pull/2790) Add `request.client_ip`
|
||||
|
||||
### Bugfixes
|
||||
- [#2728](https://github.com/sanic-org/sanic/pull/2728) Fix traversals for intended results
|
||||
- [#2729](https://github.com/sanic-org/sanic/pull/2729) Handle case when headers argument of ResponseStream constructor is None
|
||||
- [#2737](https://github.com/sanic-org/sanic/pull/2737) Fix type annotation for `JSONREsponse` default content type
|
||||
- [#2740](https://github.com/sanic-org/sanic/pull/2740) Use Sanic's serializer for JSON responses in the Inspector
|
||||
- [#2760](https://github.com/sanic-org/sanic/pull/2760) Support for `Request.get_current` in ASGI mode
|
||||
- [#2773](https://github.com/sanic-org/sanic/pull/2773) Alow Blueprint routes to explicitly define error_format
|
||||
- [#2774](https://github.com/sanic-org/sanic/pull/2774) Resolve headers on different renderers
|
||||
- [#2782](https://github.com/sanic-org/sanic/pull/2782) Resolve pypy compatibility issues
|
||||
|
||||
### Deprecations and Removals
|
||||
- [#2777](https://github.com/sanic-org/sanic/pull/2777) Remove Python 3.7 support
|
||||
|
||||
### Developer infrastructure
|
||||
- [#2766](https://github.com/sanic-org/sanic/pull/2766) Unpin setuptools version
|
||||
- [#2779](https://github.com/sanic-org/sanic/pull/2779) Run keep alive tests in loop to get available port
|
||||
|
||||
### Improved Documentation
|
||||
- [#2741](https://github.com/sanic-org/sanic/pull/2741) Better documentation examples about running Sanic
|
||||
From that list, the items to highlight in the release notes:
|
||||
@@ -25,5 +25,5 @@ def key_exist_handler(request):
|
||||
|
||||
return text("num does not exist in request")
|
||||
|
||||
|
||||
app.run(host="0.0.0.0", port=8000, debug=True)
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000, debug=True)
|
||||
|
||||
@@ -50,4 +50,5 @@ def pop_handler(request):
|
||||
|
||||
app.blueprint(bp, url_prefix="/bp")
|
||||
|
||||
app.run(host="0.0.0.0", port=8000, debug=True, auto_reload=False)
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000, debug=True, auto_reload=False)
|
||||
|
||||
@@ -37,4 +37,5 @@ app.blueprint(blueprint)
|
||||
app.blueprint(blueprint2)
|
||||
app.blueprint(blueprint3)
|
||||
|
||||
app.run(host="0.0.0.0", port=9999, debug=True)
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=9999, debug=True)
|
||||
|
||||
@@ -69,5 +69,5 @@ async def runner(app: Sanic, app_server: AsyncioServer):
|
||||
app.is_running = False
|
||||
app.is_stopping = True
|
||||
|
||||
|
||||
https.run(port=HTTPS_PORT, debug=True)
|
||||
if __name__ == "__main__":
|
||||
https.run(port=HTTPS_PORT, debug=True)
|
||||
|
||||
@@ -39,4 +39,5 @@ async def test(request):
|
||||
return json(response)
|
||||
|
||||
|
||||
app.run(host="0.0.0.0", port=8000, workers=2)
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000, workers=2)
|
||||
|
||||
@@ -20,4 +20,5 @@ def test(request):
|
||||
return text("hey")
|
||||
|
||||
|
||||
app.run(host="0.0.0.0", port=8000)
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000)
|
||||
|
||||
@@ -6,5 +6,5 @@ data = ""
|
||||
for i in range(1, 250000):
|
||||
data += str(i)
|
||||
|
||||
r = requests.post('http://0.0.0.0:8000/stream', data=data)
|
||||
r = requests.post("http://0.0.0.0:8000/stream", data=data)
|
||||
print(r.text)
|
||||
|
||||
@@ -20,4 +20,5 @@ def timeout(request, exception):
|
||||
return response.text("RequestTimeout from error_handler.", 408)
|
||||
|
||||
|
||||
app.run(host="0.0.0.0", port=8000)
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000)
|
||||
|
||||
@@ -35,34 +35,34 @@ async def after_server_stop(app, loop):
|
||||
async def test(request):
|
||||
return response.json({"answer": "42"})
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.set_event_loop(uvloop.new_event_loop())
|
||||
serv_coro = app.create_server(
|
||||
host="0.0.0.0", port=8000, return_asyncio_server=True
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
serv_task = asyncio.ensure_future(serv_coro, loop=loop)
|
||||
signal(SIGINT, lambda s, f: loop.stop())
|
||||
server: AsyncioServer = loop.run_until_complete(serv_task)
|
||||
loop.run_until_complete(server.startup())
|
||||
|
||||
asyncio.set_event_loop(uvloop.new_event_loop())
|
||||
serv_coro = app.create_server(
|
||||
host="0.0.0.0", port=8000, return_asyncio_server=True
|
||||
)
|
||||
loop = asyncio.get_event_loop()
|
||||
serv_task = asyncio.ensure_future(serv_coro, loop=loop)
|
||||
signal(SIGINT, lambda s, f: loop.stop())
|
||||
server: AsyncioServer = loop.run_until_complete(serv_task)
|
||||
loop.run_until_complete(server.startup())
|
||||
# When using app.run(), this actually triggers before the serv_coro.
|
||||
# But, in this example, we are using the convenience method, even if it is
|
||||
# out of order.
|
||||
loop.run_until_complete(server.before_start())
|
||||
loop.run_until_complete(server.after_start())
|
||||
try:
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
loop.stop()
|
||||
finally:
|
||||
loop.run_until_complete(server.before_stop())
|
||||
|
||||
# When using app.run(), this actually triggers before the serv_coro.
|
||||
# But, in this example, we are using the convenience method, even if it is
|
||||
# out of order.
|
||||
loop.run_until_complete(server.before_start())
|
||||
loop.run_until_complete(server.after_start())
|
||||
try:
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt:
|
||||
loop.stop()
|
||||
finally:
|
||||
loop.run_until_complete(server.before_stop())
|
||||
# Wait for server to close
|
||||
close_task = server.close()
|
||||
loop.run_until_complete(close_task)
|
||||
|
||||
# Wait for server to close
|
||||
close_task = server.close()
|
||||
loop.run_until_complete(close_task)
|
||||
|
||||
# Complete all tasks on the loop
|
||||
for connection in server.connections:
|
||||
connection.close_if_idle()
|
||||
loop.run_until_complete(server.after_stop())
|
||||
# Complete all tasks on the loop
|
||||
for connection in server.connections:
|
||||
connection.close_if_idle()
|
||||
loop.run_until_complete(server.after_stop())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[build-system]
|
||||
requires = ["setuptools<60.0", "wheel"]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.black]
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from typing_extensions import TypeAlias
|
||||
|
||||
from sanic.__version__ import __version__
|
||||
from sanic.app import Sanic
|
||||
from sanic.blueprints import Blueprint
|
||||
from sanic.config import Config
|
||||
from sanic.constants import HTTPMethod
|
||||
from sanic.exceptions import (
|
||||
BadRequest,
|
||||
@@ -32,15 +37,29 @@ from sanic.response import (
|
||||
from sanic.server.websockets.impl import WebsocketImplProtocol as Websocket
|
||||
|
||||
|
||||
DefaultSanic: TypeAlias = "Sanic[Config, SimpleNamespace]"
|
||||
"""
|
||||
A type alias for a Sanic app with a default config and namespace.
|
||||
"""
|
||||
|
||||
DefaultRequest: TypeAlias = Request[DefaultSanic, SimpleNamespace]
|
||||
"""
|
||||
A type alias for a request with a default Sanic app and namespace.
|
||||
"""
|
||||
|
||||
__all__ = (
|
||||
"__version__",
|
||||
# Common objects
|
||||
"Sanic",
|
||||
"Config",
|
||||
"Blueprint",
|
||||
"HTTPMethod",
|
||||
"HTTPResponse",
|
||||
"Request",
|
||||
"Websocket",
|
||||
# Common types
|
||||
"DefaultSanic",
|
||||
"DefaultRequest",
|
||||
# Common exceptions
|
||||
"BadRequest",
|
||||
"ExpectationFailed",
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "23.3.0"
|
||||
__version__ = "23.6.0"
|
||||
|
||||
188
sanic/app.py
188
sanic/app.py
@@ -17,7 +17,7 @@ from asyncio import (
|
||||
from asyncio.futures import Future
|
||||
from collections import defaultdict, deque
|
||||
from contextlib import contextmanager, suppress
|
||||
from functools import partial
|
||||
from functools import partial, wraps
|
||||
from inspect import isawaitable
|
||||
from os import environ
|
||||
from socket import socket
|
||||
@@ -29,9 +29,11 @@ from typing import (
|
||||
AnyStr,
|
||||
Awaitable,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Coroutine,
|
||||
Deque,
|
||||
Dict,
|
||||
Generic,
|
||||
Iterable,
|
||||
Iterator,
|
||||
List,
|
||||
@@ -41,6 +43,8 @@ from typing import (
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
cast,
|
||||
overload,
|
||||
)
|
||||
from urllib.parse import urlencode, urlunparse
|
||||
|
||||
@@ -83,7 +87,7 @@ from sanic.request import Request
|
||||
from sanic.response import BaseHTTPResponse, HTTPResponse, ResponseStream
|
||||
from sanic.router import Router
|
||||
from sanic.server.websockets.impl import ConnectionClosed
|
||||
from sanic.signals import Signal, SignalRouter
|
||||
from sanic.signals import Event, Signal, SignalRouter
|
||||
from sanic.touchup import TouchUp, TouchUpMeta
|
||||
from sanic.types.shared_ctx import SharedContext
|
||||
from sanic.worker.inspector import Inspector
|
||||
@@ -102,8 +106,17 @@ if TYPE_CHECKING:
|
||||
if OS_IS_WINDOWS: # no cov
|
||||
enable_windows_color_support()
|
||||
|
||||
ctx_type = TypeVar("ctx_type")
|
||||
config_type = TypeVar("config_type", bound=Config)
|
||||
|
||||
class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||
|
||||
class Sanic(
|
||||
Generic[config_type, ctx_type],
|
||||
StaticHandleMixin,
|
||||
BaseSanic,
|
||||
StartupMixin,
|
||||
metaclass=TouchUpMeta,
|
||||
):
|
||||
"""
|
||||
The main application instance
|
||||
"""
|
||||
@@ -158,14 +171,102 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||
"websocket_tasks",
|
||||
)
|
||||
|
||||
_app_registry: Dict[str, "Sanic"] = {}
|
||||
test_mode = False
|
||||
_app_registry: ClassVar[Dict[str, "Sanic"]] = {}
|
||||
test_mode: ClassVar[bool] = False
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self: Sanic[Config, SimpleNamespace],
|
||||
name: str,
|
||||
config: None = None,
|
||||
ctx: None = None,
|
||||
router: Optional[Router] = None,
|
||||
signal_router: Optional[SignalRouter] = None,
|
||||
error_handler: Optional[ErrorHandler] = None,
|
||||
env_prefix: Optional[str] = SANIC_PREFIX,
|
||||
request_class: Optional[Type[Request]] = None,
|
||||
strict_slashes: bool = False,
|
||||
log_config: Optional[Dict[str, Any]] = None,
|
||||
configure_logging: bool = True,
|
||||
dumps: Optional[Callable[..., AnyStr]] = None,
|
||||
loads: Optional[Callable[..., Any]] = None,
|
||||
inspector: bool = False,
|
||||
inspector_class: Optional[Type[Inspector]] = None,
|
||||
certloader_class: Optional[Type[CertLoader]] = None,
|
||||
) -> None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self: Sanic[config_type, SimpleNamespace],
|
||||
name: str,
|
||||
config: Optional[config_type] = None,
|
||||
ctx: None = None,
|
||||
router: Optional[Router] = None,
|
||||
signal_router: Optional[SignalRouter] = None,
|
||||
error_handler: Optional[ErrorHandler] = None,
|
||||
env_prefix: Optional[str] = SANIC_PREFIX,
|
||||
request_class: Optional[Type[Request]] = None,
|
||||
strict_slashes: bool = False,
|
||||
log_config: Optional[Dict[str, Any]] = None,
|
||||
configure_logging: bool = True,
|
||||
dumps: Optional[Callable[..., AnyStr]] = None,
|
||||
loads: Optional[Callable[..., Any]] = None,
|
||||
inspector: bool = False,
|
||||
inspector_class: Optional[Type[Inspector]] = None,
|
||||
certloader_class: Optional[Type[CertLoader]] = None,
|
||||
) -> None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self: Sanic[Config, ctx_type],
|
||||
name: str,
|
||||
config: None = None,
|
||||
ctx: Optional[ctx_type] = None,
|
||||
router: Optional[Router] = None,
|
||||
signal_router: Optional[SignalRouter] = None,
|
||||
error_handler: Optional[ErrorHandler] = None,
|
||||
env_prefix: Optional[str] = SANIC_PREFIX,
|
||||
request_class: Optional[Type[Request]] = None,
|
||||
strict_slashes: bool = False,
|
||||
log_config: Optional[Dict[str, Any]] = None,
|
||||
configure_logging: bool = True,
|
||||
dumps: Optional[Callable[..., AnyStr]] = None,
|
||||
loads: Optional[Callable[..., Any]] = None,
|
||||
inspector: bool = False,
|
||||
inspector_class: Optional[Type[Inspector]] = None,
|
||||
certloader_class: Optional[Type[CertLoader]] = None,
|
||||
) -> None:
|
||||
...
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self: Sanic[config_type, ctx_type],
|
||||
name: str,
|
||||
config: Optional[config_type] = None,
|
||||
ctx: Optional[ctx_type] = None,
|
||||
router: Optional[Router] = None,
|
||||
signal_router: Optional[SignalRouter] = None,
|
||||
error_handler: Optional[ErrorHandler] = None,
|
||||
env_prefix: Optional[str] = SANIC_PREFIX,
|
||||
request_class: Optional[Type[Request]] = None,
|
||||
strict_slashes: bool = False,
|
||||
log_config: Optional[Dict[str, Any]] = None,
|
||||
configure_logging: bool = True,
|
||||
dumps: Optional[Callable[..., AnyStr]] = None,
|
||||
loads: Optional[Callable[..., Any]] = None,
|
||||
inspector: bool = False,
|
||||
inspector_class: Optional[Type[Inspector]] = None,
|
||||
certloader_class: Optional[Type[CertLoader]] = None,
|
||||
) -> None:
|
||||
...
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: Optional[str] = None,
|
||||
config: Optional[Config] = None,
|
||||
ctx: Optional[Any] = None,
|
||||
name: str,
|
||||
config: Optional[config_type] = None,
|
||||
ctx: Optional[ctx_type] = None,
|
||||
router: Optional[Router] = None,
|
||||
signal_router: Optional[SignalRouter] = None,
|
||||
error_handler: Optional[ErrorHandler] = None,
|
||||
@@ -193,7 +294,9 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||
)
|
||||
|
||||
# First setup config
|
||||
self.config: Config = config or Config(env_prefix=env_prefix)
|
||||
self.config: config_type = cast(
|
||||
config_type, config or Config(env_prefix=env_prefix)
|
||||
)
|
||||
if inspector:
|
||||
self.config.INSPECTOR = inspector
|
||||
|
||||
@@ -217,7 +320,7 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||
certloader_class or CertLoader
|
||||
)
|
||||
self.configure_logging: bool = configure_logging
|
||||
self.ctx: Any = ctx or SimpleNamespace()
|
||||
self.ctx: ctx_type = cast(ctx_type, ctx or SimpleNamespace())
|
||||
self.error_handler: ErrorHandler = error_handler or ErrorHandler()
|
||||
self.inspector_class: Type[Inspector] = inspector_class or Inspector
|
||||
self.listeners: Dict[str, List[ListenerType[Any]]] = defaultdict(list)
|
||||
@@ -417,8 +520,11 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||
def _apply_listener(self, listener: FutureListener):
|
||||
return self.register_listener(listener.listener, listener.event)
|
||||
|
||||
def _apply_route(self, route: FutureRoute) -> List[Route]:
|
||||
def _apply_route(
|
||||
self, route: FutureRoute, overwrite: bool = False
|
||||
) -> List[Route]:
|
||||
params = route._asdict()
|
||||
params["overwrite"] = overwrite
|
||||
websocket = params.pop("websocket", False)
|
||||
subprotocols = params.pop("subprotocols", None)
|
||||
|
||||
@@ -499,6 +605,19 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||
raise NotFound("Could not find signal %s" % event)
|
||||
return await wait_for(signal.ctx.event.wait(), timeout=timeout)
|
||||
|
||||
def report_exception(
|
||||
self, handler: Callable[[Sanic, Exception], Coroutine[Any, Any, None]]
|
||||
):
|
||||
@wraps(handler)
|
||||
async def report(exception: Exception) -> None:
|
||||
await handler(self, exception)
|
||||
|
||||
self.add_signal(
|
||||
handler=report, event=Event.SERVER_EXCEPTION_REPORT.value
|
||||
)
|
||||
|
||||
return report
|
||||
|
||||
def enable_websocket(self, enable=True):
|
||||
"""Enable or disable the support for websocket.
|
||||
|
||||
@@ -550,6 +669,9 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||
)
|
||||
else:
|
||||
params["version_prefix"] = blueprint.version_prefix
|
||||
name_prefix = getattr(blueprint, "name_prefix", None)
|
||||
if name_prefix and "name_prefix" not in params:
|
||||
params["name_prefix"] = name_prefix
|
||||
self.blueprint(item, **params)
|
||||
return
|
||||
if blueprint.name in self.blueprints:
|
||||
@@ -767,6 +889,12 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||
:raises ServerError: response 500
|
||||
"""
|
||||
response = None
|
||||
if not getattr(exception, "__dispatched__", False):
|
||||
... # DO NOT REMOVE THIS LINE. IT IS NEEDED FOR TOUCHUP.
|
||||
await self.dispatch(
|
||||
"server.exception.report",
|
||||
context={"exception": exception},
|
||||
)
|
||||
await self.dispatch(
|
||||
"http.lifecycle.exception",
|
||||
inline=True,
|
||||
@@ -1197,13 +1325,28 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||
app,
|
||||
loop,
|
||||
):
|
||||
if callable(task):
|
||||
async def do(task):
|
||||
try:
|
||||
task = task(app)
|
||||
except TypeError:
|
||||
task = task()
|
||||
if callable(task):
|
||||
try:
|
||||
task = task(app)
|
||||
except TypeError:
|
||||
task = task()
|
||||
if isawaitable(task):
|
||||
await task
|
||||
except CancelledError:
|
||||
error_logger.warning(
|
||||
f"Task {task} was cancelled before it completed."
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
await app.dispatch(
|
||||
"server.exception.report",
|
||||
context={"exception": e},
|
||||
)
|
||||
raise
|
||||
|
||||
return task
|
||||
return do(task)
|
||||
|
||||
@classmethod
|
||||
def _loop_add_task(
|
||||
@@ -1217,18 +1360,9 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
|
||||
) -> Task:
|
||||
if not isinstance(task, Future):
|
||||
prepped = cls._prep_task(task, app, loop)
|
||||
if sys.version_info < (3, 8): # no cov
|
||||
task = loop.create_task(prepped)
|
||||
if name:
|
||||
error_logger.warning(
|
||||
"Cannot set a name for a task when using Python 3.7. "
|
||||
"Your task will be created without a name."
|
||||
)
|
||||
task.get_name = lambda: name
|
||||
else:
|
||||
task = loop.create_task(prepped, name=name)
|
||||
task = loop.create_task(prepped, name=name)
|
||||
|
||||
if name and register and sys.version_info > (3, 7):
|
||||
if name and register:
|
||||
app._task_registry[name] = task
|
||||
|
||||
return task
|
||||
|
||||
@@ -3,7 +3,7 @@ import sys
|
||||
|
||||
from os import environ
|
||||
|
||||
from sanic.compat import is_atty
|
||||
from sanic.helpers import is_atty
|
||||
|
||||
|
||||
BASE_LOGO = """
|
||||
|
||||
@@ -4,7 +4,7 @@ from textwrap import indent, wrap
|
||||
from typing import Dict, Optional
|
||||
|
||||
from sanic import __version__
|
||||
from sanic.compat import is_atty
|
||||
from sanic.helpers import is_atty
|
||||
from sanic.log import logger
|
||||
|
||||
|
||||
@@ -73,6 +73,14 @@ class MOTDTTY(MOTD):
|
||||
self.value_width = min(
|
||||
max(map(len, self.data.values())), self.max_value_width
|
||||
)
|
||||
if self.extra:
|
||||
self.key_width = max(
|
||||
self.key_width, max(map(len, self.extra.keys()))
|
||||
)
|
||||
self.value_width = min(
|
||||
max((*map(len, self.extra.values()), self.value_width)),
|
||||
self.max_value_width,
|
||||
)
|
||||
self.logo_lines = self.logo.split("\n") if self.logo else []
|
||||
self.logo_line_length = 24
|
||||
self.centering_length = (
|
||||
@@ -104,7 +112,7 @@ class MOTDTTY(MOTD):
|
||||
self._render_data(lines, self.data, 0)
|
||||
if self.extra:
|
||||
logo_part = self._get_logo_part(len(lines) - 4)
|
||||
lines.append(f"| {logo_part} ├{display_filler}┤")
|
||||
lines.append(f"│ {logo_part} ├{display_filler}┤")
|
||||
self._render_data(lines, self.extra, len(lines) - 4)
|
||||
|
||||
self._render_fill(lines)
|
||||
|
||||
@@ -175,6 +175,7 @@ class ASGIApp:
|
||||
instance.transport,
|
||||
sanic_app,
|
||||
)
|
||||
request_class._current.set(instance.request)
|
||||
instance.request.stream = instance # type: ignore
|
||||
instance.request_body = True
|
||||
instance.request.conn_info = ConnInfo(instance.transport)
|
||||
|
||||
@@ -65,6 +65,7 @@ class BlueprintGroup(MutableSequence):
|
||||
"_version",
|
||||
"_strict_slashes",
|
||||
"_version_prefix",
|
||||
"_name_prefix",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
@@ -73,6 +74,7 @@ class BlueprintGroup(MutableSequence):
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
version_prefix: str = "/v",
|
||||
name_prefix: Optional[str] = "",
|
||||
):
|
||||
"""
|
||||
Create a new Blueprint Group
|
||||
@@ -87,6 +89,7 @@ class BlueprintGroup(MutableSequence):
|
||||
self._version = version
|
||||
self._version_prefix = version_prefix
|
||||
self._strict_slashes = strict_slashes
|
||||
self._name_prefix = name_prefix
|
||||
|
||||
@property
|
||||
def url_prefix(self) -> Optional[Union[int, str, float]]:
|
||||
@@ -134,6 +137,15 @@ class BlueprintGroup(MutableSequence):
|
||||
"""
|
||||
return self._version_prefix
|
||||
|
||||
@property
|
||||
def name_prefix(self) -> Optional[str]:
|
||||
"""
|
||||
Name prefix for the blueprint group
|
||||
|
||||
:return: str
|
||||
"""
|
||||
return self._name_prefix
|
||||
|
||||
def __iter__(self):
|
||||
"""
|
||||
Tun the class Blueprint Group into an Iterable item
|
||||
|
||||
@@ -93,6 +93,7 @@ class Blueprint(BaseSanic):
|
||||
"_future_listeners",
|
||||
"_future_exceptions",
|
||||
"_future_signals",
|
||||
"_allow_route_overwrite",
|
||||
"copied_from",
|
||||
"ctx",
|
||||
"exceptions",
|
||||
@@ -110,7 +111,7 @@ class Blueprint(BaseSanic):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = None,
|
||||
name: str,
|
||||
url_prefix: Optional[str] = None,
|
||||
host: Optional[Union[List[str], str]] = None,
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
@@ -119,6 +120,7 @@ class Blueprint(BaseSanic):
|
||||
):
|
||||
super().__init__(name=name)
|
||||
self.reset()
|
||||
self._allow_route_overwrite = False
|
||||
self.copied_from = ""
|
||||
self.ctx = SimpleNamespace()
|
||||
self.host = host
|
||||
@@ -169,6 +171,7 @@ class Blueprint(BaseSanic):
|
||||
|
||||
def reset(self):
|
||||
self._apps: Set[Sanic] = set()
|
||||
self._allow_route_overwrite = False
|
||||
self.exceptions: List[RouteHandler] = []
|
||||
self.listeners: Dict[str, List[ListenerType[Any]]] = {}
|
||||
self.middlewares: List[MiddlewareType] = []
|
||||
@@ -182,6 +185,7 @@ class Blueprint(BaseSanic):
|
||||
url_prefix: Optional[Union[str, Default]] = _default,
|
||||
version: Optional[Union[int, str, float, Default]] = _default,
|
||||
version_prefix: Union[str, Default] = _default,
|
||||
allow_route_overwrite: Union[bool, Default] = _default,
|
||||
strict_slashes: Optional[Union[bool, Default]] = _default,
|
||||
with_registration: bool = True,
|
||||
with_ctx: bool = False,
|
||||
@@ -225,6 +229,8 @@ class Blueprint(BaseSanic):
|
||||
new_bp.strict_slashes = strict_slashes
|
||||
if not isinstance(version_prefix, Default):
|
||||
new_bp.version_prefix = version_prefix
|
||||
if not isinstance(allow_route_overwrite, Default):
|
||||
new_bp._allow_route_overwrite = allow_route_overwrite
|
||||
|
||||
for key, value in attrs_backup.items():
|
||||
setattr(self, key, value)
|
||||
@@ -250,6 +256,7 @@ class Blueprint(BaseSanic):
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
version_prefix: str = "/v",
|
||||
name_prefix: Optional[str] = "",
|
||||
) -> BlueprintGroup:
|
||||
"""
|
||||
Create a list of blueprints, optionally grouping them under a
|
||||
@@ -275,6 +282,7 @@ class Blueprint(BaseSanic):
|
||||
version=version,
|
||||
strict_slashes=strict_slashes,
|
||||
version_prefix=version_prefix,
|
||||
name_prefix=name_prefix,
|
||||
)
|
||||
for bp in chain(blueprints):
|
||||
bps.append(bp)
|
||||
@@ -295,6 +303,7 @@ class Blueprint(BaseSanic):
|
||||
opt_version = options.get("version", None)
|
||||
opt_strict_slashes = options.get("strict_slashes", None)
|
||||
opt_version_prefix = options.get("version_prefix", self.version_prefix)
|
||||
opt_name_prefix = options.get("name_prefix", None)
|
||||
error_format = options.get(
|
||||
"error_format", app.config.FALLBACK_ERROR_FORMAT
|
||||
)
|
||||
@@ -310,6 +319,10 @@ class Blueprint(BaseSanic):
|
||||
# Prepend the blueprint URI prefix if available
|
||||
uri = self._setup_uri(future.uri, url_prefix)
|
||||
|
||||
route_error_format = (
|
||||
future.error_format if future.error_format else error_format
|
||||
)
|
||||
|
||||
version_prefix = self.version_prefix
|
||||
for prefix in (
|
||||
future.version_prefix,
|
||||
@@ -326,7 +339,10 @@ class Blueprint(BaseSanic):
|
||||
future.strict_slashes, opt_strict_slashes, self.strict_slashes
|
||||
)
|
||||
|
||||
name = app._generate_name(future.name)
|
||||
name = future.name
|
||||
if opt_name_prefix:
|
||||
name = f"{opt_name_prefix}_{future.name}"
|
||||
name = app._generate_name(name)
|
||||
host = future.host or self.host
|
||||
if isinstance(host, list):
|
||||
host = tuple(host)
|
||||
@@ -346,7 +362,7 @@ class Blueprint(BaseSanic):
|
||||
future.unquote,
|
||||
future.static,
|
||||
version_prefix,
|
||||
error_format,
|
||||
route_error_format,
|
||||
future.route_context,
|
||||
)
|
||||
|
||||
@@ -354,7 +370,9 @@ class Blueprint(BaseSanic):
|
||||
continue
|
||||
|
||||
registered.add(apply_route)
|
||||
route = app._apply_route(apply_route)
|
||||
route = app._apply_route(
|
||||
apply_route, overwrite=self._allow_route_overwrite
|
||||
)
|
||||
|
||||
# If it is a copied BP, then make sure all of the names of routes
|
||||
# matchup with the new BP name
|
||||
|
||||
@@ -180,6 +180,10 @@ Or, a path to a directory to run as a simple HTTP server:
|
||||
" Example File: project/sanic_server.py -> app\n"
|
||||
" Example Module: project.sanic_server.app"
|
||||
)
|
||||
error_logger.error(
|
||||
"\nThe error below might have caused the above one:\n"
|
||||
f"{e.msg}"
|
||||
)
|
||||
sys.exit(1)
|
||||
else:
|
||||
raise e
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import signal
|
||||
import sys
|
||||
|
||||
@@ -10,6 +11,7 @@ from typing import Awaitable, Union
|
||||
from multidict import CIMultiDict # type: ignore
|
||||
|
||||
from sanic.helpers import Default
|
||||
from sanic.log import error_logger
|
||||
|
||||
|
||||
if sys.version_info < (3, 8): # no cov
|
||||
@@ -22,6 +24,7 @@ else: # no cov
|
||||
]
|
||||
|
||||
OS_IS_WINDOWS = os.name == "nt"
|
||||
PYPY_IMPLEMENTATION = platform.python_implementation() == "PyPy"
|
||||
UVLOOP_INSTALLED = False
|
||||
|
||||
try:
|
||||
@@ -73,6 +76,38 @@ def enable_windows_color_support():
|
||||
kernel.SetConsoleMode(kernel.GetStdHandle(-11), 7)
|
||||
|
||||
|
||||
def pypy_os_module_patch() -> None:
|
||||
"""
|
||||
The PyPy os module is missing the 'readlink' function, which causes issues
|
||||
withaiofiles. This workaround replaces the missing 'readlink' function
|
||||
with 'os.path.realpath', which serves the same purpose.
|
||||
"""
|
||||
if hasattr(os, "readlink"):
|
||||
error_logger.warning(
|
||||
"PyPy: Skipping patching of the os module as it appears the "
|
||||
"'readlink' function has been added."
|
||||
)
|
||||
return
|
||||
|
||||
module = sys.modules["os"]
|
||||
module.readlink = os.path.realpath # type: ignore
|
||||
|
||||
|
||||
def pypy_windows_set_console_cp_patch() -> None:
|
||||
"""
|
||||
A patch function for PyPy on Windows that sets the console code page to
|
||||
UTF-8 encodingto allow for proper handling of non-ASCII characters. This
|
||||
function uses ctypes to call the Windows API functions SetConsoleCP and
|
||||
SetConsoleOutputCP to set the code page.
|
||||
"""
|
||||
from ctypes import windll # type: ignore
|
||||
|
||||
code: int = windll.kernel32.GetConsoleOutputCP()
|
||||
if code != 65001:
|
||||
windll.kernel32.SetConsoleCP(65001)
|
||||
windll.kernel32.SetConsoleOutputCP(65001)
|
||||
|
||||
|
||||
class Header(CIMultiDict):
|
||||
"""
|
||||
Container used for both request and response headers. It is a subclass of
|
||||
@@ -86,7 +121,7 @@ class Header(CIMultiDict):
|
||||
<https://multidict.readthedocs.io/en/stable/multidict.html#multidict>`_
|
||||
for more details about how to use the object. In general, it should work
|
||||
very similar to a regular dictionary.
|
||||
"""
|
||||
""" # noqa: E501
|
||||
|
||||
def __getattr__(self, key: str) -> str:
|
||||
if key.startswith("_"):
|
||||
@@ -112,6 +147,12 @@ if use_trio: # pragma: no cover
|
||||
open_async = trio.open_file
|
||||
CancelledErrors = tuple([asyncio.CancelledError, trio.Cancelled])
|
||||
else:
|
||||
if PYPY_IMPLEMENTATION:
|
||||
pypy_os_module_patch()
|
||||
|
||||
if OS_IS_WINDOWS:
|
||||
pypy_windows_set_console_cp_patch()
|
||||
|
||||
from aiofiles import open as aio_open # type: ignore
|
||||
from aiofiles.os import stat as stat_async # type: ignore # noqa: F401
|
||||
|
||||
@@ -143,7 +184,3 @@ def ctrlc_workaround_for_windows(app):
|
||||
die = False
|
||||
signal.signal(signal.SIGINT, ctrlc_handler)
|
||||
app.add_task(stay_active)
|
||||
|
||||
|
||||
def is_atty() -> bool:
|
||||
return bool(sys.stdout and sys.stdout.isatty())
|
||||
|
||||
@@ -43,14 +43,14 @@ DEFAULT_CONFIG = {
|
||||
"DEPRECATION_FILTER": "once",
|
||||
"FORWARDED_FOR_HEADER": "X-Forwarded-For",
|
||||
"FORWARDED_SECRET": None,
|
||||
"GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec
|
||||
"GRACEFUL_SHUTDOWN_TIMEOUT": 15.0,
|
||||
"INSPECTOR": False,
|
||||
"INSPECTOR_HOST": "localhost",
|
||||
"INSPECTOR_PORT": 6457,
|
||||
"INSPECTOR_TLS_KEY": _default,
|
||||
"INSPECTOR_TLS_CERT": _default,
|
||||
"INSPECTOR_API_KEY": "",
|
||||
"KEEP_ALIVE_TIMEOUT": 5, # 5 seconds
|
||||
"KEEP_ALIVE_TIMEOUT": 120,
|
||||
"KEEP_ALIVE": True,
|
||||
"LOCAL_CERT_CREATOR": LocalCertCreator.AUTO,
|
||||
"LOCAL_TLS_KEY": _default,
|
||||
@@ -61,16 +61,16 @@ DEFAULT_CONFIG = {
|
||||
"NOISY_EXCEPTIONS": False,
|
||||
"PROXIES_COUNT": None,
|
||||
"REAL_IP_HEADER": None,
|
||||
"REQUEST_BUFFER_SIZE": 65536, # 64 KiB
|
||||
"REQUEST_MAX_HEADER_SIZE": 8192, # 8 KiB, but cannot exceed 16384
|
||||
"REQUEST_BUFFER_SIZE": 65536,
|
||||
"REQUEST_MAX_HEADER_SIZE": 8192, # Cannot exceed 16384
|
||||
"REQUEST_ID_HEADER": "X-Request-ID",
|
||||
"REQUEST_MAX_SIZE": 100000000, # 100 megabytes
|
||||
"REQUEST_TIMEOUT": 60, # 60 seconds
|
||||
"RESPONSE_TIMEOUT": 60, # 60 seconds
|
||||
"REQUEST_MAX_SIZE": 100_000_000,
|
||||
"REQUEST_TIMEOUT": 60,
|
||||
"RESPONSE_TIMEOUT": 60,
|
||||
"TLS_CERT_PASSWORD": "",
|
||||
"TOUCHUP": _default,
|
||||
"USE_UVLOOP": _default,
|
||||
"WEBSOCKET_MAX_SIZE": 2**20, # 1 megabyte
|
||||
"WEBSOCKET_MAX_SIZE": 2**20, # 1 MiB
|
||||
"WEBSOCKET_PING_INTERVAL": 20,
|
||||
"WEBSOCKET_PING_TIMEOUT": 20,
|
||||
}
|
||||
|
||||
@@ -92,8 +92,10 @@ class BaseRenderer:
|
||||
self.full
|
||||
if self.debug and not getattr(self.exception, "quiet", False)
|
||||
else self.minimal
|
||||
)
|
||||
return output()
|
||||
)()
|
||||
output.status = self.status
|
||||
output.headers.update(self.headers)
|
||||
return output
|
||||
|
||||
def minimal(self) -> HTTPResponse: # noqa
|
||||
"""
|
||||
@@ -125,7 +127,7 @@ class HTMLRenderer(BaseRenderer):
|
||||
request=self.request,
|
||||
exc=self.exception,
|
||||
)
|
||||
return html(page.render(), status=self.status, headers=self.headers)
|
||||
return html(page.render())
|
||||
|
||||
def minimal(self) -> HTTPResponse:
|
||||
return self.full()
|
||||
@@ -146,8 +148,7 @@ class TextRenderer(BaseRenderer):
|
||||
text=self.text,
|
||||
bar=("=" * len(self.title)),
|
||||
body=self._generate_body(full=True),
|
||||
),
|
||||
status=self.status,
|
||||
)
|
||||
)
|
||||
|
||||
def minimal(self) -> HTTPResponse:
|
||||
@@ -157,9 +158,7 @@ class TextRenderer(BaseRenderer):
|
||||
text=self.text,
|
||||
bar=("=" * len(self.title)),
|
||||
body=self._generate_body(full=False),
|
||||
),
|
||||
status=self.status,
|
||||
headers=self.headers,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -218,11 +217,11 @@ class JSONRenderer(BaseRenderer):
|
||||
|
||||
def full(self) -> HTTPResponse:
|
||||
output = self._generate_output(full=True)
|
||||
return json(output, status=self.status, dumps=self.dumps)
|
||||
return json(output, dumps=self.dumps)
|
||||
|
||||
def minimal(self) -> HTTPResponse:
|
||||
output = self._generate_output(full=False)
|
||||
return json(output, status=self.status, dumps=self.dumps)
|
||||
return json(output, dumps=self.dumps)
|
||||
|
||||
def _generate_output(self, *, full):
|
||||
output = {
|
||||
@@ -313,7 +312,7 @@ def exception_response(
|
||||
debug: bool,
|
||||
fallback: str,
|
||||
base: t.Type[BaseRenderer],
|
||||
renderer: t.Type[t.Optional[BaseRenderer]] = None,
|
||||
renderer: t.Optional[t.Type[BaseRenderer]] = None,
|
||||
) -> HTTPResponse:
|
||||
"""
|
||||
Render a response for the default FALLBACK exception handler.
|
||||
|
||||
@@ -90,7 +90,7 @@ class SanicException(Exception):
|
||||
|
||||
super().__init__(message)
|
||||
|
||||
self.status_code = status_code
|
||||
self.status_code = status_code or self.status_code
|
||||
self.quiet = quiet
|
||||
self.headers = headers
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ from sanic.errorpages import BaseRenderer, TextRenderer, exception_response
|
||||
from sanic.exceptions import ServerError
|
||||
from sanic.log import error_logger
|
||||
from sanic.models.handler_types import RouteHandler
|
||||
from sanic.request.types import Request
|
||||
from sanic.response import text
|
||||
from sanic.response.types import HTTPResponse
|
||||
|
||||
|
||||
class ErrorHandler:
|
||||
@@ -148,7 +150,7 @@ class ErrorHandler:
|
||||
return text("An error occurred while handling an error", 500)
|
||||
return response
|
||||
|
||||
def default(self, request, exception):
|
||||
def default(self, request: Request, exception: Exception) -> HTTPResponse:
|
||||
"""
|
||||
Provide a default behavior for the objects of :class:`ErrorHandler`.
|
||||
If a developer chooses to extent the :class:`ErrorHandler` they can
|
||||
|
||||
@@ -436,7 +436,7 @@ def format_http1_response(status: int, headers: HeaderBytesIterable) -> bytes:
|
||||
|
||||
def parse_credentials(
|
||||
header: Optional[str],
|
||||
prefixes: Union[List, Tuple, Set] = None,
|
||||
prefixes: Optional[Union[List, Tuple, Set]] = None,
|
||||
) -> Tuple[Optional[str], Optional[str]]:
|
||||
"""Parses any header with the aim to retrieve any credentials from it."""
|
||||
if not prefixes or not isinstance(prefixes, (list, tuple, set)):
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Defines basics of HTTP standard."""
|
||||
|
||||
import sys
|
||||
|
||||
from importlib import import_module
|
||||
from inspect import ismodule
|
||||
from typing import Dict
|
||||
@@ -157,6 +159,10 @@ def import_string(module_name, package=None):
|
||||
return obj()
|
||||
|
||||
|
||||
def is_atty() -> bool:
|
||||
return bool(sys.stdout and sys.stdout.isatty())
|
||||
|
||||
|
||||
class Default:
|
||||
"""
|
||||
It is used to replace `None` or `object()` as a sentinel
|
||||
|
||||
@@ -5,7 +5,7 @@ from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Dict
|
||||
from warnings import warn
|
||||
|
||||
from sanic.compat import is_atty
|
||||
from sanic.helpers import is_atty
|
||||
|
||||
|
||||
# Python 3.11 changed the way Enum formatting works for mixed-in types.
|
||||
|
||||
@@ -38,3 +38,15 @@ class ExceptionMixin(metaclass=SanicMeta):
|
||||
return handler
|
||||
|
||||
return decorator
|
||||
|
||||
def all_exceptions(self, handler):
|
||||
"""
|
||||
This method enables the process of creating a global exception
|
||||
handler for the current blueprint under question.
|
||||
|
||||
:param handler: A coroutine function to handle exceptions
|
||||
|
||||
:return a decorated method to handle global exceptions for any
|
||||
route registered under this blueprint.
|
||||
"""
|
||||
return self.exception(Exception)(handler)
|
||||
|
||||
@@ -159,7 +159,11 @@ class RouteMixin(BaseMixin, metaclass=SanicMeta):
|
||||
error_format,
|
||||
route_context,
|
||||
)
|
||||
|
||||
overwrite = getattr(self, "_allow_route_overwrite", False)
|
||||
if overwrite:
|
||||
self._future_routes = set(
|
||||
filter(lambda x: x.uri != uri, self._future_routes)
|
||||
)
|
||||
self._future_routes.add(route)
|
||||
|
||||
args = list(signature(handler).parameters.keys())
|
||||
@@ -182,7 +186,7 @@ class RouteMixin(BaseMixin, metaclass=SanicMeta):
|
||||
handler.is_stream = stream
|
||||
|
||||
if apply:
|
||||
self._apply_route(route)
|
||||
self._apply_route(route, overwrite=overwrite)
|
||||
|
||||
if static:
|
||||
return route, handler
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any, Callable, Dict, Optional, Set, Union
|
||||
from sanic.base.meta import SanicMeta
|
||||
from sanic.models.futures import FutureSignal
|
||||
from sanic.models.handler_types import SignalHandler
|
||||
from sanic.signals import Signal
|
||||
from sanic.signals import Event, Signal
|
||||
from sanic.types import HashableDict
|
||||
|
||||
|
||||
@@ -80,3 +80,9 @@ class SignalMixin(metaclass=SanicMeta):
|
||||
|
||||
def event(self, event: str):
|
||||
raise NotImplementedError
|
||||
|
||||
def catch_exception(self, handler):
|
||||
async def signal_handler(exception: Exception):
|
||||
await handler(self, exception)
|
||||
|
||||
self.signal(Event.SERVER_LIFECYCLE_EXCEPTION)(signal_handler)
|
||||
|
||||
@@ -16,7 +16,13 @@ from asyncio import (
|
||||
from contextlib import suppress
|
||||
from functools import partial
|
||||
from importlib import import_module
|
||||
from multiprocessing import Manager, Pipe, get_context
|
||||
from multiprocessing import (
|
||||
Manager,
|
||||
Pipe,
|
||||
get_context,
|
||||
get_start_method,
|
||||
set_start_method,
|
||||
)
|
||||
from multiprocessing.context import BaseContext
|
||||
from pathlib import Path
|
||||
from socket import SHUT_RDWR, socket
|
||||
@@ -25,6 +31,7 @@ from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Dict,
|
||||
List,
|
||||
Mapping,
|
||||
@@ -41,9 +48,9 @@ from sanic.application.logo import get_logo
|
||||
from sanic.application.motd import MOTD
|
||||
from sanic.application.state import ApplicationServerInfo, Mode, ServerStage
|
||||
from sanic.base.meta import SanicMeta
|
||||
from sanic.compat import OS_IS_WINDOWS, StartMethod, is_atty
|
||||
from sanic.compat import OS_IS_WINDOWS, StartMethod
|
||||
from sanic.exceptions import ServerKilled
|
||||
from sanic.helpers import Default, _default
|
||||
from sanic.helpers import Default, _default, is_atty
|
||||
from sanic.http.constants import HTTP
|
||||
from sanic.http.tls import get_ssl_context, process_to_context
|
||||
from sanic.http.tls.context import SanicSSLContext
|
||||
@@ -81,13 +88,18 @@ else: # no cov
|
||||
|
||||
|
||||
class StartupMixin(metaclass=SanicMeta):
|
||||
_app_registry: Dict[str, Sanic]
|
||||
_app_registry: ClassVar[Dict[str, Sanic]]
|
||||
|
||||
name: str
|
||||
config: Config
|
||||
listeners: Dict[str, List[ListenerType[Any]]]
|
||||
state: ApplicationState
|
||||
websocket_enabled: bool
|
||||
multiplexer: WorkerMultiplexer
|
||||
start_method: StartMethod = _default
|
||||
|
||||
test_mode: ClassVar[bool]
|
||||
start_method: ClassVar[StartMethod] = _default
|
||||
START_METHOD_SET: ClassVar[bool] = False
|
||||
|
||||
def setup_loop(self):
|
||||
if not self.asgi:
|
||||
@@ -594,6 +606,7 @@ class StartupMixin(metaclass=SanicMeta):
|
||||
server = "ASGI" if self.asgi else "unknown" # type: ignore
|
||||
|
||||
display = {
|
||||
"app": self.name,
|
||||
"mode": " ".join(mode),
|
||||
"server": server,
|
||||
"python": platform.python_version(),
|
||||
@@ -691,11 +704,26 @@ class StartupMixin(metaclass=SanicMeta):
|
||||
else "spawn"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _set_startup_method(cls) -> None:
|
||||
if cls.START_METHOD_SET and not cls.test_mode:
|
||||
return
|
||||
|
||||
method = cls._get_startup_method()
|
||||
set_start_method(method, force=cls.test_mode)
|
||||
cls.START_METHOD_SET = True
|
||||
|
||||
@classmethod
|
||||
def _get_context(cls) -> BaseContext:
|
||||
method = cls._get_startup_method()
|
||||
logger.debug("Creating multiprocessing context using '%s'", method)
|
||||
return get_context(method)
|
||||
actual = get_start_method()
|
||||
if method != actual:
|
||||
raise RuntimeError(
|
||||
f"Start method '{method}' was requested, but '{actual}' "
|
||||
"was actually set."
|
||||
)
|
||||
return get_context()
|
||||
|
||||
@classmethod
|
||||
def serve(
|
||||
@@ -705,6 +733,7 @@ class StartupMixin(metaclass=SanicMeta):
|
||||
app_loader: Optional[AppLoader] = None,
|
||||
factory: Optional[Callable[[], Sanic]] = None,
|
||||
) -> None:
|
||||
cls._set_startup_method()
|
||||
os.environ["SANIC_MOTD_OUTPUT"] = "true"
|
||||
apps = list(cls._app_registry.values())
|
||||
if factory:
|
||||
|
||||
@@ -95,7 +95,7 @@ class StaticMixin(BaseMixin, metaclass=SanicMeta):
|
||||
)
|
||||
|
||||
try:
|
||||
file_or_directory = Path(file_or_directory)
|
||||
file_or_directory = Path(file_or_directory).resolve()
|
||||
except TypeError:
|
||||
raise TypeError(
|
||||
"Static file or directory must be a path-like object or string"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from typing import Any, Awaitable, Callable, MutableMapping, Optional, Union
|
||||
|
||||
@@ -16,20 +15,10 @@ ASGIReceive = Callable[[], Awaitable[ASGIMessage]]
|
||||
|
||||
class MockProtocol: # no cov
|
||||
def __init__(self, transport: "MockTransport", loop):
|
||||
# This should be refactored when < 3.8 support is dropped
|
||||
self.transport = transport
|
||||
# Fixup for 3.8+; Sanic still supports 3.7 where loop is required
|
||||
loop = loop if sys.version_info[:2] < (3, 8) else None
|
||||
# Optional in 3.9, necessary in 3.10 because the parameter "loop"
|
||||
# was completely removed
|
||||
if not loop:
|
||||
self._not_paused = asyncio.Event()
|
||||
self._not_paused.set()
|
||||
self._complete = asyncio.Event()
|
||||
else:
|
||||
self._not_paused = asyncio.Event(loop=loop)
|
||||
self._not_paused.set()
|
||||
self._complete = asyncio.Event(loop=loop)
|
||||
self._not_paused = asyncio.Event()
|
||||
self._not_paused.set()
|
||||
self._complete = asyncio.Event()
|
||||
|
||||
def pause_writing(self) -> None:
|
||||
self._not_paused.clear()
|
||||
|
||||
@@ -3,11 +3,12 @@ from typing import Any, Callable, Coroutine, Optional, TypeVar, Union
|
||||
|
||||
import sanic
|
||||
|
||||
from sanic.request import Request
|
||||
from sanic import request
|
||||
from sanic.response import BaseHTTPResponse, HTTPResponse
|
||||
|
||||
|
||||
Sanic = TypeVar("Sanic", bound="sanic.Sanic")
|
||||
Request = TypeVar("Request", bound="request.Request")
|
||||
|
||||
MiddlewareResponse = Union[
|
||||
Optional[HTTPResponse], Coroutine[Any, Any, Optional[HTTPResponse]]
|
||||
|
||||
@@ -2,11 +2,13 @@ from __future__ import annotations
|
||||
|
||||
from contextvars import ContextVar
|
||||
from inspect import isawaitable
|
||||
from types import SimpleNamespace
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
DefaultDict,
|
||||
Dict,
|
||||
Generic,
|
||||
List,
|
||||
Optional,
|
||||
Tuple,
|
||||
@@ -15,6 +17,7 @@ from typing import (
|
||||
)
|
||||
|
||||
from sanic_routing.route import Route
|
||||
from typing_extensions import TypeVar
|
||||
|
||||
from sanic.http.constants import HTTP # type: ignore
|
||||
from sanic.http.stream import Stream
|
||||
@@ -23,13 +26,13 @@ from sanic.models.http_types import Credentials
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic.server import ConnInfo
|
||||
from sanic.app import Sanic
|
||||
from sanic.config import Config
|
||||
from sanic.server import ConnInfo
|
||||
|
||||
import uuid
|
||||
|
||||
from collections import defaultdict
|
||||
from types import SimpleNamespace
|
||||
from urllib.parse import parse_qs, parse_qsl, urlunparse
|
||||
|
||||
from httptools import parse_url
|
||||
@@ -68,8 +71,21 @@ try:
|
||||
except ImportError:
|
||||
from json import loads as json_loads # type: ignore
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# The default argument of TypeVar is proposed to be added in Python 3.13
|
||||
# by PEP 696 (https://www.python.org/dev/peps/pep-0696/).
|
||||
# Therefore, we use typing_extensions.TypeVar for compatibility.
|
||||
# For more information, see:
|
||||
# https://discuss.python.org/t/pep-696-type-defaults-for-typevarlikes
|
||||
sanic_type = TypeVar(
|
||||
"sanic_type", bound=Sanic, default=Sanic[Config, SimpleNamespace]
|
||||
)
|
||||
else:
|
||||
sanic_type = TypeVar("sanic_type")
|
||||
ctx_type = TypeVar("ctx_type")
|
||||
|
||||
class Request:
|
||||
|
||||
class Request(Generic[sanic_type, ctx_type]):
|
||||
"""
|
||||
Properties of an HTTP request such as URL, headers, etc.
|
||||
"""
|
||||
@@ -80,6 +96,7 @@ class Request:
|
||||
__slots__ = (
|
||||
"__weakref__",
|
||||
"_cookies",
|
||||
"_ctx",
|
||||
"_id",
|
||||
"_ip",
|
||||
"_parsed_url",
|
||||
@@ -96,7 +113,6 @@ class Request:
|
||||
"app",
|
||||
"body",
|
||||
"conn_info",
|
||||
"ctx",
|
||||
"head",
|
||||
"headers",
|
||||
"method",
|
||||
@@ -125,7 +141,7 @@ class Request:
|
||||
version: str,
|
||||
method: str,
|
||||
transport: TransportProtocol,
|
||||
app: Sanic,
|
||||
app: sanic_type,
|
||||
head: bytes = b"",
|
||||
stream_id: int = 0,
|
||||
):
|
||||
@@ -149,7 +165,7 @@ class Request:
|
||||
# Init but do not inhale
|
||||
self.body = b""
|
||||
self.conn_info: Optional[ConnInfo] = None
|
||||
self.ctx = SimpleNamespace()
|
||||
self._ctx: Optional[ctx_type] = None
|
||||
self.parsed_accept: Optional[AcceptList] = None
|
||||
self.parsed_args: DefaultDict[
|
||||
Tuple[bool, bool, str, str], RequestParameters
|
||||
@@ -176,6 +192,10 @@ class Request:
|
||||
class_name = self.__class__.__name__
|
||||
return f"<{class_name}: {self.method} {self.path}>"
|
||||
|
||||
@staticmethod
|
||||
def make_context() -> ctx_type:
|
||||
return cast(ctx_type, SimpleNamespace())
|
||||
|
||||
@classmethod
|
||||
def get_current(cls) -> Request:
|
||||
"""
|
||||
@@ -205,6 +225,15 @@ class Request:
|
||||
def generate_id(*_):
|
||||
return uuid.uuid4()
|
||||
|
||||
@property
|
||||
def ctx(self) -> ctx_type:
|
||||
"""
|
||||
:return: The current request context
|
||||
"""
|
||||
if not self._ctx:
|
||||
self._ctx = self.make_context()
|
||||
return self._ctx
|
||||
|
||||
@property
|
||||
def stream_id(self):
|
||||
"""
|
||||
@@ -809,19 +838,31 @@ class Request:
|
||||
@property
|
||||
def remote_addr(self) -> str:
|
||||
"""
|
||||
Client IP address, if available.
|
||||
1. proxied remote address `self.forwarded['for']`
|
||||
2. local remote address `self.ip`
|
||||
Client IP address, if available from proxy.
|
||||
|
||||
:return: IPv4, bracketed IPv6, UNIX socket name or arbitrary string
|
||||
:rtype: str
|
||||
"""
|
||||
if not hasattr(self, "_remote_addr"):
|
||||
self._remote_addr = str(
|
||||
self.forwarded.get("for", "")
|
||||
) # or self.ip
|
||||
self._remote_addr = str(self.forwarded.get("for", ""))
|
||||
return self._remote_addr
|
||||
|
||||
@property
|
||||
def client_ip(self) -> str:
|
||||
"""
|
||||
Client IP address.
|
||||
1. proxied remote address `self.forwarded['for']`
|
||||
2. local peer address `self.ip`
|
||||
|
||||
New in Sanic 23.6. Prefer this over `remote_addr` for determining the
|
||||
client address regardless of whether the service runs behind a proxy
|
||||
or not (proxy deployment needs separate configuration).
|
||||
|
||||
:return: IPv4, bracketed IPv6, UNIX socket name or arbitrary string
|
||||
:rtype: str
|
||||
"""
|
||||
return self.remote_addr or self.ip
|
||||
|
||||
@property
|
||||
def scheme(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -38,7 +38,9 @@ else:
|
||||
|
||||
|
||||
try:
|
||||
from ujson import dumps as json_dumps
|
||||
from ujson import dumps as ujson_dumps
|
||||
|
||||
json_dumps = partial(ujson_dumps, escape_forward_slashes=False)
|
||||
except ImportError:
|
||||
# This is done in order to ensure that the JSON response is
|
||||
# kept consistent across both ujson and inbuilt json usage.
|
||||
@@ -345,7 +347,7 @@ class JSONResponse(HTTPResponse):
|
||||
body: Optional[Any] = None,
|
||||
status: int = 200,
|
||||
headers: Optional[Union[Header, Dict[str, str]]] = None,
|
||||
content_type: Optional[str] = None,
|
||||
content_type: str = "application/json",
|
||||
dumps: Optional[Callable[..., str]] = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
@@ -520,7 +522,9 @@ class ResponseStream:
|
||||
headers: Optional[Union[Header, Dict[str, str]]] = None,
|
||||
content_type: Optional[str] = None,
|
||||
):
|
||||
if not isinstance(headers, Header):
|
||||
if headers is None:
|
||||
headers = Header()
|
||||
elif not isinstance(headers, Header):
|
||||
headers = Header(headers)
|
||||
self.streaming_fn = streaming_fn
|
||||
self.status = status
|
||||
|
||||
@@ -75,11 +75,12 @@ class Router(BaseRouter):
|
||||
strict_slashes: bool = False,
|
||||
stream: bool = False,
|
||||
ignore_body: bool = False,
|
||||
version: Union[str, float, int] = None,
|
||||
version: Optional[Union[str, float, int]] = None,
|
||||
name: Optional[str] = None,
|
||||
unquote: bool = False,
|
||||
static: bool = False,
|
||||
version_prefix: str = "/v",
|
||||
overwrite: bool = False,
|
||||
error_format: Optional[str] = None,
|
||||
) -> Union[Route, List[Route]]:
|
||||
"""
|
||||
@@ -122,6 +123,7 @@ class Router(BaseRouter):
|
||||
name=name,
|
||||
strict=strict_slashes,
|
||||
unquote=unquote,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
|
||||
if isinstance(host, str):
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from ssl import SSLContext
|
||||
from typing import TYPE_CHECKING, Dict, Optional, Type, Union
|
||||
|
||||
@@ -251,8 +249,7 @@ def _serve_http_1(
|
||||
loop.run_until_complete(asyncio.sleep(0.1))
|
||||
start_shutdown = start_shutdown + 0.1
|
||||
|
||||
if sys.version_info > (3, 7):
|
||||
app.shutdown_tasks(graceful - start_shutdown)
|
||||
app.shutdown_tasks(graceful - start_shutdown)
|
||||
|
||||
# Force close non-idle connection after waiting for
|
||||
# graceful_shutdown_timeout
|
||||
|
||||
@@ -96,6 +96,7 @@ class WebsocketFrameAssembler:
|
||||
If ``timeout`` is set and elapses before a complete message is
|
||||
received, :meth:`get` returns ``None``.
|
||||
"""
|
||||
completed: bool
|
||||
async with self.read_mutex:
|
||||
if timeout is not None and timeout <= 0:
|
||||
if not self.message_complete.is_set():
|
||||
|
||||
@@ -21,7 +21,7 @@ from websockets.frames import Frame, Opcode
|
||||
|
||||
|
||||
try: # websockets < 11.0
|
||||
from websockets.connection import Event, State
|
||||
from websockets.connection import Event, State # type: ignore
|
||||
from websockets.server import ServerConnection as ServerProtocol
|
||||
except ImportError: # websockets >= 11.0
|
||||
from websockets.protocol import Event, State # type: ignore
|
||||
|
||||
@@ -16,6 +16,7 @@ from sanic.models.handler_types import SignalHandler
|
||||
|
||||
|
||||
class Event(Enum):
|
||||
SERVER_EXCEPTION_REPORT = "server.exception.report"
|
||||
SERVER_INIT_AFTER = "server.init.after"
|
||||
SERVER_INIT_BEFORE = "server.init.before"
|
||||
SERVER_SHUTDOWN_AFTER = "server.shutdown.after"
|
||||
@@ -39,6 +40,7 @@ class Event(Enum):
|
||||
|
||||
RESERVED_NAMESPACES = {
|
||||
"server": (
|
||||
Event.SERVER_EXCEPTION_REPORT.value,
|
||||
Event.SERVER_INIT_AFTER.value,
|
||||
Event.SERVER_INIT_BEFORE.value,
|
||||
Event.SERVER_SHUTDOWN_AFTER.value,
|
||||
@@ -168,6 +170,17 @@ class SignalRouter(BaseRouter):
|
||||
elif maybe_coroutine:
|
||||
return maybe_coroutine
|
||||
return None
|
||||
except Exception as e:
|
||||
if self.ctx.app.debug and self.ctx.app.state.verbosity >= 1:
|
||||
error_logger.exception(e)
|
||||
|
||||
if event != Event.SERVER_EXCEPTION_REPORT.value:
|
||||
await self.dispatch(
|
||||
Event.SERVER_EXCEPTION_REPORT.value,
|
||||
context={"exception": e},
|
||||
)
|
||||
setattr(e, "__dispatched__", True)
|
||||
raise e
|
||||
finally:
|
||||
for signal_event in events:
|
||||
signal_event.clear()
|
||||
@@ -217,14 +230,6 @@ class SignalRouter(BaseRouter):
|
||||
if not trigger:
|
||||
event = ".".join([*parts[:2], "<__trigger__>"])
|
||||
|
||||
try:
|
||||
# Attaching __requirements__ and __trigger__ to the handler
|
||||
# is deprecated and will be removed in v23.6.
|
||||
handler.__requirements__ = condition # type: ignore
|
||||
handler.__trigger__ = trigger # type: ignore
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
signal = super().add(
|
||||
event,
|
||||
handler,
|
||||
|
||||
@@ -83,10 +83,7 @@ class Inspector:
|
||||
|
||||
async def _respond(self, request: Request, output: Any):
|
||||
name = request.match_info.get("action", "info")
|
||||
return json(
|
||||
{"meta": {"action": name}, "result": output},
|
||||
escape_forward_slashes=False,
|
||||
)
|
||||
return json({"meta": {"action": name}, "result": output})
|
||||
|
||||
def _state_to_json(self) -> Dict[str, Any]:
|
||||
output = {"info": self.app_info}
|
||||
|
||||
10
setup.py
10
setup.py
@@ -83,12 +83,11 @@ setup_kwargs = {
|
||||
"packages": find_packages(exclude=("tests", "tests.*")),
|
||||
"package_data": {"sanic": ["py.typed", "pages/styles/*"]},
|
||||
"platforms": "any",
|
||||
"python_requires": ">=3.7",
|
||||
"python_requires": ">=3.8",
|
||||
"classifiers": [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: Web Environment",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
@@ -104,7 +103,7 @@ ujson = "ujson>=1.35" + env_dependency
|
||||
uvloop = "uvloop>=0.15.0" + env_dependency
|
||||
types_ujson = "types-ujson" + env_dependency
|
||||
requirements = [
|
||||
"sanic-routing>=22.8.0",
|
||||
"sanic-routing>=23.6.0",
|
||||
"httptools>=0.0.10",
|
||||
uvloop,
|
||||
ujson,
|
||||
@@ -113,10 +112,11 @@ requirements = [
|
||||
"multidict>=5.0,<7.0",
|
||||
"html5tagger>=1.2.1",
|
||||
"tracerite>=1.0.0",
|
||||
"typing-extensions>=4.4.0",
|
||||
]
|
||||
|
||||
tests_require = [
|
||||
"sanic-testing>=23.3.0",
|
||||
"sanic-testing>=23.6.0",
|
||||
"pytest==7.1.*",
|
||||
"coverage",
|
||||
"beautifulsoup4",
|
||||
@@ -127,7 +127,7 @@ tests_require = [
|
||||
"black",
|
||||
"isort>=5.0.0",
|
||||
"bandit",
|
||||
"mypy>=0.901,<0.910",
|
||||
"mypy",
|
||||
"docutils",
|
||||
"pygments",
|
||||
"uvicorn<0.15.0",
|
||||
|
||||
@@ -293,7 +293,7 @@ def test_handle_request_with_nested_sanic_exception(
|
||||
|
||||
|
||||
def test_app_name_required():
|
||||
with pytest.raises(SanicException):
|
||||
with pytest.raises(TypeError):
|
||||
Sanic()
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
from sanic import Blueprint, Sanic
|
||||
import pytest
|
||||
|
||||
from sanic_routing.exceptions import RouteExists
|
||||
|
||||
from sanic import Blueprint, Request, Sanic
|
||||
from sanic.response import text
|
||||
|
||||
|
||||
@@ -74,3 +78,76 @@ def test_bp_copy(app: Sanic):
|
||||
assert "test_bp_copy.test_bp4.handle_request" in route_names
|
||||
assert "test_bp_copy.test_bp5.handle_request" in route_names
|
||||
assert "test_bp_copy.test_bp6.handle_request" in route_names
|
||||
|
||||
|
||||
def test_bp_copy_without_route_overwriting(app: Sanic):
|
||||
bpv1 = Blueprint("bp_v1", version=1, url_prefix="my_api")
|
||||
|
||||
@bpv1.route("/")
|
||||
async def handler(request: Request):
|
||||
return text("v1")
|
||||
|
||||
app.blueprint(bpv1)
|
||||
|
||||
bpv2 = bpv1.copy("bp_v2", version=2, allow_route_overwrite=False)
|
||||
bpv3 = bpv1.copy(
|
||||
"bp_v3",
|
||||
version=3,
|
||||
allow_route_overwrite=False,
|
||||
with_registration=False,
|
||||
)
|
||||
|
||||
with pytest.raises(RouteExists, match="Route already registered*"):
|
||||
|
||||
@bpv2.route("/")
|
||||
async def handler(request: Request):
|
||||
return text("v2")
|
||||
|
||||
app.blueprint(bpv2)
|
||||
|
||||
with pytest.raises(RouteExists, match="Route already registered*"):
|
||||
|
||||
@bpv3.route("/")
|
||||
async def handler(request: Request):
|
||||
return text("v3")
|
||||
|
||||
app.blueprint(bpv3)
|
||||
|
||||
|
||||
def test_bp_copy_with_route_overwriting(app: Sanic):
|
||||
bpv1 = Blueprint("bp_v1", version=1, url_prefix="my_api")
|
||||
|
||||
@bpv1.route("/")
|
||||
async def handler(request: Request):
|
||||
return text("v1")
|
||||
|
||||
app.blueprint(bpv1)
|
||||
|
||||
bpv2 = bpv1.copy("bp_v2", version=2, allow_route_overwrite=True)
|
||||
bpv3 = bpv1.copy(
|
||||
"bp_v3", version=3, allow_route_overwrite=True, with_registration=False
|
||||
)
|
||||
|
||||
@bpv2.route("/")
|
||||
async def handler(request: Request):
|
||||
return text("v2")
|
||||
|
||||
app.blueprint(bpv2)
|
||||
|
||||
@bpv3.route("/")
|
||||
async def handler(request: Request):
|
||||
return text("v3")
|
||||
|
||||
app.blueprint(bpv3)
|
||||
|
||||
_, response = app.test_client.get("/v1/my_api")
|
||||
assert response.status == 200
|
||||
assert response.text == "v1"
|
||||
|
||||
_, response = app.test_client.get("/v2/my_api")
|
||||
assert response.status == 200
|
||||
assert response.text == "v2"
|
||||
|
||||
_, response = app.test_client.get("/v3/my_api")
|
||||
assert response.status == 200
|
||||
assert response.text == "v3"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from pytest import raises
|
||||
|
||||
from sanic.app import Sanic
|
||||
@@ -340,3 +342,40 @@ def test_nested_bp_group_properties():
|
||||
|
||||
routes = [route.path for route in app.router.routes]
|
||||
assert routes == ["three/one/four"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_nested_bp_group():
|
||||
bp1 = Blueprint("bp1", url_prefix="/bp1")
|
||||
bp2 = Blueprint("bp2", url_prefix="/bp2")
|
||||
|
||||
bp1.add_route(lambda _: ..., "/", name="route1")
|
||||
bp2.add_route(lambda _: ..., "/", name="route2")
|
||||
|
||||
group_a = Blueprint.group(
|
||||
bp1, bp2, url_prefix="/group-a", name_prefix="group-a"
|
||||
)
|
||||
group_b = Blueprint.group(
|
||||
bp1, bp2, url_prefix="/group-b", name_prefix="group-b"
|
||||
)
|
||||
|
||||
app = Sanic("PropTest")
|
||||
app.blueprint(group_a)
|
||||
app.blueprint(group_b)
|
||||
|
||||
await app._startup()
|
||||
|
||||
routes = [route.path for route in app.router.routes]
|
||||
assert routes == [
|
||||
"group-a/bp1",
|
||||
"group-a/bp2",
|
||||
"group-b/bp1",
|
||||
"group-b/bp2",
|
||||
]
|
||||
names = [route.name for route in app.router.routes]
|
||||
assert names == [
|
||||
"PropTest.group-a_bp1.route1",
|
||||
"PropTest.group-a_bp2.route2",
|
||||
"PropTest.group-b_bp1.route1",
|
||||
"PropTest.group-b_bp2.route2",
|
||||
]
|
||||
|
||||
@@ -2,6 +2,8 @@ import logging
|
||||
|
||||
import pytest
|
||||
|
||||
import sanic
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.config import Config
|
||||
from sanic.errorpages import TextRenderer, exception_response, guess_mime
|
||||
@@ -205,6 +207,27 @@ def test_route_error_response_from_explicit_format(app):
|
||||
assert response.content_type == "text/plain; charset=utf-8"
|
||||
|
||||
|
||||
def test_blueprint_error_response_from_explicit_format(app):
|
||||
bp = sanic.Blueprint("MyBlueprint")
|
||||
|
||||
@bp.get("/text", error_format="json")
|
||||
def text_response(request):
|
||||
raise Exception("oops")
|
||||
return text("Never gonna see this")
|
||||
|
||||
@bp.get("/json", error_format="text")
|
||||
def json_response(request):
|
||||
raise Exception("oops")
|
||||
return json({"message": "Never gonna see this"})
|
||||
|
||||
app.blueprint(bp)
|
||||
_, response = app.test_client.get("/text")
|
||||
assert response.content_type == "application/json"
|
||||
|
||||
_, response = app.test_client.get("/json")
|
||||
assert response.content_type == "text/plain; charset=utf-8"
|
||||
|
||||
|
||||
def test_unknown_fallback_format(app):
|
||||
with pytest.raises(SanicException, match="Unknown format: bad"):
|
||||
app.config.FALLBACK_ERROR_FORMAT = "bad"
|
||||
@@ -527,3 +550,26 @@ def test_guess_mime_logging(
|
||||
]
|
||||
|
||||
assert logmsg == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"format,expected",
|
||||
(
|
||||
("html", "text/html; charset=utf-8"),
|
||||
("text", "text/plain; charset=utf-8"),
|
||||
("json", "application/json"),
|
||||
),
|
||||
)
|
||||
def test_exception_header_on_renderers(app: Sanic, format, expected):
|
||||
app.config.FALLBACK_ERROR_FORMAT = format
|
||||
|
||||
@app.get("/test")
|
||||
def test(request):
|
||||
raise SanicException(
|
||||
"test", status_code=400, headers={"exception": "test"}
|
||||
)
|
||||
|
||||
_, response = app.test_client.get("/test")
|
||||
assert response.status == 400
|
||||
assert response.headers.get("exception") == "test"
|
||||
assert response.content_type == expected
|
||||
|
||||
@@ -17,6 +17,7 @@ from sanic.response import text
|
||||
CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True}
|
||||
|
||||
PORT = 42001 # test_keep_alive_timeout_reuse doesn't work with random port
|
||||
MAX_LOOPS = 15
|
||||
port_counter = count()
|
||||
|
||||
|
||||
@@ -69,23 +70,35 @@ def test_keep_alive_timeout_reuse():
|
||||
"""If the server keep-alive timeout and client keep-alive timeout are
|
||||
both longer than the delay, the client _and_ server will successfully
|
||||
reuse the existing connection."""
|
||||
port = get_port()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
client = ReusableClient(keep_alive_timeout_app_reuse, loop=loop, port=port)
|
||||
with client:
|
||||
headers = {"Connection": "keep-alive"}
|
||||
request, response = client.get("/1", headers=headers)
|
||||
assert response.status == 200
|
||||
assert response.text == "OK"
|
||||
assert request.protocol.state["requests_count"] == 1
|
||||
loops = 0
|
||||
while True:
|
||||
port = get_port()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
client = ReusableClient(
|
||||
keep_alive_timeout_app_reuse, loop=loop, port=port
|
||||
)
|
||||
try:
|
||||
with client:
|
||||
headers = {"Connection": "keep-alive"}
|
||||
request, response = client.get("/1", headers=headers)
|
||||
assert response.status == 200
|
||||
assert response.text == "OK"
|
||||
assert request.protocol.state["requests_count"] == 1
|
||||
|
||||
loop.run_until_complete(aio_sleep(1))
|
||||
loop.run_until_complete(aio_sleep(1))
|
||||
|
||||
request, response = client.get("/1")
|
||||
assert response.status == 200
|
||||
assert response.text == "OK"
|
||||
assert request.protocol.state["requests_count"] == 2
|
||||
request, response = client.get("/1")
|
||||
assert response.status == 200
|
||||
assert response.text == "OK"
|
||||
assert request.protocol.state["requests_count"] == 2
|
||||
except OSError:
|
||||
loops += 1
|
||||
if loops > MAX_LOOPS:
|
||||
raise
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
@@ -97,23 +110,35 @@ def test_keep_alive_timeout_reuse():
|
||||
def test_keep_alive_client_timeout():
|
||||
"""If the server keep-alive timeout is longer than the client
|
||||
keep-alive timeout, client will try to create a new connection here."""
|
||||
port = get_port()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
client = ReusableClient(
|
||||
keep_alive_app_client_timeout, loop=loop, port=port
|
||||
)
|
||||
with client:
|
||||
headers = {"Connection": "keep-alive"}
|
||||
request, response = client.get("/1", headers=headers, timeout=1)
|
||||
loops = 0
|
||||
while True:
|
||||
try:
|
||||
port = get_port()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
client = ReusableClient(
|
||||
keep_alive_app_client_timeout, loop=loop, port=port
|
||||
)
|
||||
with client:
|
||||
headers = {"Connection": "keep-alive"}
|
||||
request, response = client.get(
|
||||
"/1", headers=headers, timeout=1
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
assert response.text == "OK"
|
||||
assert request.protocol.state["requests_count"] == 1
|
||||
assert response.status == 200
|
||||
assert response.text == "OK"
|
||||
assert request.protocol.state["requests_count"] == 1
|
||||
|
||||
loop.run_until_complete(aio_sleep(2))
|
||||
request, response = client.get("/1", timeout=1)
|
||||
assert request.protocol.state["requests_count"] == 1
|
||||
loop.run_until_complete(aio_sleep(2))
|
||||
request, response = client.get("/1", timeout=1)
|
||||
assert request.protocol.state["requests_count"] == 1
|
||||
except OSError:
|
||||
loops += 1
|
||||
if loops > MAX_LOOPS:
|
||||
raise
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
@@ -125,24 +150,36 @@ def test_keep_alive_server_timeout():
|
||||
keep-alive timeout, the client will either a 'Connection reset' error
|
||||
_or_ a new connection. Depending on how the event-loop handles the
|
||||
broken server connection."""
|
||||
port = get_port()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
client = ReusableClient(
|
||||
keep_alive_app_server_timeout, loop=loop, port=port
|
||||
)
|
||||
with client:
|
||||
headers = {"Connection": "keep-alive"}
|
||||
request, response = client.get("/1", headers=headers, timeout=60)
|
||||
loops = 0
|
||||
while True:
|
||||
try:
|
||||
port = get_port()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
client = ReusableClient(
|
||||
keep_alive_app_server_timeout, loop=loop, port=port
|
||||
)
|
||||
with client:
|
||||
headers = {"Connection": "keep-alive"}
|
||||
request, response = client.get(
|
||||
"/1", headers=headers, timeout=60
|
||||
)
|
||||
|
||||
assert response.status == 200
|
||||
assert response.text == "OK"
|
||||
assert request.protocol.state["requests_count"] == 1
|
||||
assert response.status == 200
|
||||
assert response.text == "OK"
|
||||
assert request.protocol.state["requests_count"] == 1
|
||||
|
||||
loop.run_until_complete(aio_sleep(3))
|
||||
request, response = client.get("/1", timeout=60)
|
||||
loop.run_until_complete(aio_sleep(3))
|
||||
request, response = client.get("/1", timeout=60)
|
||||
|
||||
assert request.protocol.state["requests_count"] == 1
|
||||
assert request.protocol.state["requests_count"] == 1
|
||||
except OSError:
|
||||
loops += 1
|
||||
if loops > MAX_LOOPS:
|
||||
raise
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
@@ -150,20 +187,34 @@ def test_keep_alive_server_timeout():
|
||||
reason="Not testable with current client",
|
||||
)
|
||||
def test_keep_alive_connection_context():
|
||||
port = get_port()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
client = ReusableClient(keep_alive_app_context, loop=loop, port=port)
|
||||
with client:
|
||||
headers = {"Connection": "keep-alive"}
|
||||
request1, _ = client.post("/ctx", headers=headers)
|
||||
loops = 0
|
||||
while True:
|
||||
try:
|
||||
port = get_port()
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
client = ReusableClient(
|
||||
keep_alive_app_context, loop=loop, port=port
|
||||
)
|
||||
with client:
|
||||
headers = {"Connection": "keep-alive"}
|
||||
request1, _ = client.post("/ctx", headers=headers)
|
||||
|
||||
loop.run_until_complete(aio_sleep(1))
|
||||
request2, response = client.get("/ctx")
|
||||
loop.run_until_complete(aio_sleep(1))
|
||||
request2, response = client.get("/ctx")
|
||||
|
||||
assert response.text == "hello"
|
||||
assert id(request1.conn_info.ctx) == id(request2.conn_info.ctx)
|
||||
assert (
|
||||
request1.conn_info.ctx.foo == request2.conn_info.ctx.foo == "hello"
|
||||
)
|
||||
assert request2.protocol.state["requests_count"] == 2
|
||||
assert response.text == "hello"
|
||||
assert id(request1.conn_info.ctx) == id(request2.conn_info.ctx)
|
||||
assert (
|
||||
request1.conn_info.ctx.foo
|
||||
== request2.conn_info.ctx.foo
|
||||
== "hello"
|
||||
)
|
||||
assert request2.protocol.state["requests_count"] == 2
|
||||
except OSError:
|
||||
loops += 1
|
||||
if loops > MAX_LOOPS:
|
||||
raise
|
||||
continue
|
||||
else:
|
||||
break
|
||||
|
||||
@@ -31,10 +31,11 @@ def test_motd_with_expected_info(app, run_startup):
|
||||
logs = run_startup(app)
|
||||
|
||||
assert logs[1][2] == f"Sanic v{__version__}"
|
||||
assert logs[3][2] == "mode: debug, single worker"
|
||||
assert logs[4][2] == "server: sanic, HTTP/1.1"
|
||||
assert logs[5][2] == f"python: {platform.python_version()}"
|
||||
assert logs[6][2] == f"platform: {platform.platform()}"
|
||||
assert logs[3][2] == "app: test_motd_with_expected_info"
|
||||
assert logs[4][2] == "mode: debug, single worker"
|
||||
assert logs[5][2] == "server: sanic, HTTP/1.1"
|
||||
assert logs[6][2] == f"python: {platform.python_version()}"
|
||||
assert logs[7][2] == f"platform: {platform.platform()}"
|
||||
|
||||
|
||||
def test_motd_init():
|
||||
@@ -61,7 +62,7 @@ def test_motd_display(caplog):
|
||||
│ │
|
||||
├───────────────────────┬────────┤
|
||||
│ foobar │ one: 1 │
|
||||
| ├────────┤
|
||||
│ ├────────┤
|
||||
│ │ two: 2 │
|
||||
└───────────────────────┴────────┘
|
||||
"""
|
||||
|
||||
@@ -126,7 +126,7 @@ def test_redirect_with_params(app, test_str):
|
||||
|
||||
@app.route("/api/v2/test/<test>/", unquote=True)
|
||||
async def target_handler(request, test):
|
||||
assert test == test_str
|
||||
assert test == quote(test_str)
|
||||
return text("OK")
|
||||
|
||||
_, response = app.test_client.get(f"/api/v1/test/{use_in_uri}/")
|
||||
|
||||
@@ -310,3 +310,29 @@ def test_request_idempotent(method, idempotent):
|
||||
def test_request_cacheable(method, cacheable):
|
||||
request = Request(b"/", {}, None, method, None, None)
|
||||
assert request.is_cacheable is cacheable
|
||||
|
||||
|
||||
def test_custom_ctx():
|
||||
class CustomContext:
|
||||
FOO = "foo"
|
||||
|
||||
class CustomRequest(Request[Sanic, CustomContext]):
|
||||
@staticmethod
|
||||
def make_context() -> CustomContext:
|
||||
return CustomContext()
|
||||
|
||||
app = Sanic("Test", request_class=CustomRequest)
|
||||
|
||||
@app.get("/")
|
||||
async def handler(request: CustomRequest):
|
||||
return response.json(
|
||||
[
|
||||
isinstance(request, CustomRequest),
|
||||
isinstance(request.ctx, CustomContext),
|
||||
request.ctx.FOO,
|
||||
]
|
||||
)
|
||||
|
||||
_, resp = app.test_client.get("/")
|
||||
|
||||
assert resp.json == [True, True, "foo"]
|
||||
|
||||
@@ -513,6 +513,7 @@ def test_standard_forwarded(app):
|
||||
request, response = app.test_client.get("/", headers=headers)
|
||||
assert response.json == {"for": "127.0.0.2", "proto": "ws"}
|
||||
assert request.remote_addr == "127.0.0.2"
|
||||
assert request.client_ip == "127.0.0.2"
|
||||
assert request.scheme == "ws"
|
||||
assert request.server_name == "local.site"
|
||||
assert request.server_port == 80
|
||||
@@ -737,6 +738,7 @@ def test_remote_addr_with_two_proxies(app):
|
||||
headers = {"X-Forwarded-For": "127.0.1.1"}
|
||||
request, response = app.test_client.get("/", headers=headers)
|
||||
assert request.remote_addr == ""
|
||||
assert request.client_ip == "127.0.0.1"
|
||||
assert response.body == b""
|
||||
|
||||
headers = {"X-Forwarded-For": "127.0.0.1, 127.0.1.2"}
|
||||
|
||||
@@ -23,6 +23,7 @@ from sanic.compat import Header
|
||||
from sanic.cookies import CookieJar
|
||||
from sanic.response import (
|
||||
HTTPResponse,
|
||||
ResponseStream,
|
||||
empty,
|
||||
file,
|
||||
file_stream,
|
||||
@@ -943,3 +944,17 @@ def test_file_validating_304_response(
|
||||
)
|
||||
assert response.status == 304
|
||||
assert response.body == b""
|
||||
|
||||
|
||||
def test_stream_response_with_default_headers(app: Sanic):
|
||||
async def sample_streaming_fn(response_):
|
||||
await response_.write("foo")
|
||||
|
||||
@app.route("/")
|
||||
async def test(request: Request):
|
||||
return ResponseStream(sample_streaming_fn, content_type="text/csv")
|
||||
|
||||
_, response = app.test_client.get("/")
|
||||
assert response.text == "foo"
|
||||
assert response.headers["Transfer-Encoding"] == "chunked"
|
||||
assert response.headers["Content-Type"] == "text/csv"
|
||||
|
||||
@@ -213,3 +213,12 @@ def test_pop_list(json_app: Sanic):
|
||||
|
||||
_, resp = json_app.test_client.get("/json-pop")
|
||||
assert resp.body == json_dumps(["b"]).encode()
|
||||
|
||||
|
||||
def test_json_response_class_sets_proper_content_type(json_app: Sanic):
|
||||
@json_app.get("/json-class")
|
||||
async def handler(request: Request):
|
||||
return JSONResponse(JSON_BODY)
|
||||
|
||||
_, resp = json_app.test_client.get("/json-class")
|
||||
assert resp.headers["content-type"] == "application/json"
|
||||
|
||||
@@ -4,15 +4,18 @@ import signal
|
||||
|
||||
from queue import Queue
|
||||
from types import SimpleNamespace
|
||||
from typing import Optional
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from sanic_testing.testing import HOST, PORT
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.compat import ctrlc_workaround_for_windows
|
||||
from sanic.exceptions import BadRequest
|
||||
from sanic.exceptions import BadRequest, ServerError
|
||||
from sanic.response import HTTPResponse
|
||||
from sanic.signals import Event
|
||||
|
||||
|
||||
async def stop(app, loop):
|
||||
@@ -148,3 +151,26 @@ def test_signals_with_invalid_invocation(app):
|
||||
BadRequest, match="Invalid event registration: Missing event name"
|
||||
):
|
||||
app.listener(stop)
|
||||
|
||||
|
||||
def test_signal_server_lifecycle_exception(app: Sanic):
|
||||
trigger: Optional[Exception] = None
|
||||
|
||||
@app.route("/hello")
|
||||
async def hello_route(request):
|
||||
return HTTPResponse()
|
||||
|
||||
@app.signal(Event.SERVER_EXCEPTION_REPORT)
|
||||
async def test_signal(exception: Exception):
|
||||
nonlocal trigger
|
||||
trigger = exception
|
||||
|
||||
@app.before_server_start
|
||||
async def test_before_server_start(app):
|
||||
raise ServerError("test_before_server_start")
|
||||
|
||||
with pytest.raises(ServerError, match="test_before_server_start"):
|
||||
app.run(single_process=True)
|
||||
|
||||
assert isinstance(trigger, ServerError)
|
||||
assert str(trigger) == "test_before_server_start"
|
||||
|
||||
@@ -2,6 +2,7 @@ import asyncio
|
||||
|
||||
from enum import Enum
|
||||
from inspect import isawaitable
|
||||
from itertools import count
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -9,6 +10,7 @@ from sanic_routing.exceptions import NotFound
|
||||
|
||||
from sanic import Blueprint, Sanic, empty
|
||||
from sanic.exceptions import InvalidSignal, SanicException
|
||||
from sanic.signals import Event
|
||||
|
||||
|
||||
def test_add_signal(app):
|
||||
@@ -427,3 +429,114 @@ def test_signal_reservation(app, event, expected):
|
||||
app.signal(event)(lambda: ...)
|
||||
else:
|
||||
app.signal(event)(lambda: ...)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_report_exception(app: Sanic):
|
||||
@app.report_exception
|
||||
async def catch_any_exception(app: Sanic, exception: Exception):
|
||||
...
|
||||
|
||||
@app.route("/")
|
||||
async def handler(request):
|
||||
1 / 0
|
||||
|
||||
app.signal_router.finalize()
|
||||
|
||||
registered_signal_handlers = [
|
||||
handler
|
||||
for handler, *_ in app.signal_router.get(
|
||||
Event.SERVER_EXCEPTION_REPORT.value
|
||||
)
|
||||
]
|
||||
|
||||
assert catch_any_exception in registered_signal_handlers
|
||||
|
||||
|
||||
def test_report_exception_runs(app: Sanic):
|
||||
event = asyncio.Event()
|
||||
|
||||
@app.report_exception
|
||||
async def catch_any_exception(app: Sanic, exception: Exception):
|
||||
event.set()
|
||||
|
||||
@app.route("/")
|
||||
async def handler(request):
|
||||
1 / 0
|
||||
|
||||
app.test_client.get("/")
|
||||
|
||||
assert event.is_set()
|
||||
|
||||
|
||||
def test_report_exception_runs_once_inline(app: Sanic):
|
||||
event = asyncio.Event()
|
||||
c = count()
|
||||
|
||||
@app.report_exception
|
||||
async def catch_any_exception(app: Sanic, exception: Exception):
|
||||
event.set()
|
||||
next(c)
|
||||
|
||||
@app.route("/")
|
||||
async def handler(request):
|
||||
...
|
||||
|
||||
@app.signal(Event.HTTP_ROUTING_AFTER.value)
|
||||
async def after_routing(**_):
|
||||
1 / 0
|
||||
|
||||
app.test_client.get("/")
|
||||
|
||||
assert event.is_set()
|
||||
assert next(c) == 1
|
||||
|
||||
|
||||
def test_report_exception_runs_once_custom(app: Sanic):
|
||||
event = asyncio.Event()
|
||||
c = count()
|
||||
|
||||
@app.report_exception
|
||||
async def catch_any_exception(app: Sanic, exception: Exception):
|
||||
event.set()
|
||||
next(c)
|
||||
|
||||
@app.route("/")
|
||||
async def handler(request):
|
||||
await app.dispatch("one.two.three")
|
||||
return empty()
|
||||
|
||||
@app.signal("one.two.three")
|
||||
async def one_two_three(**_):
|
||||
1 / 0
|
||||
|
||||
app.test_client.get("/")
|
||||
|
||||
assert event.is_set()
|
||||
assert next(c) == 1
|
||||
|
||||
|
||||
def test_report_exception_runs_task(app: Sanic):
|
||||
c = count()
|
||||
|
||||
async def task_1():
|
||||
next(c)
|
||||
|
||||
async def task_2(app):
|
||||
next(c)
|
||||
|
||||
@app.report_exception
|
||||
async def catch_any_exception(app: Sanic, exception: Exception):
|
||||
next(c)
|
||||
|
||||
@app.route("/")
|
||||
async def handler(request):
|
||||
app.add_task(task_1)
|
||||
app.add_task(task_1())
|
||||
app.add_task(task_2)
|
||||
app.add_task(task_2(app))
|
||||
return empty()
|
||||
|
||||
app.test_client.get("/")
|
||||
|
||||
assert next(c) == 4
|
||||
|
||||
@@ -101,6 +101,31 @@ def test_static_file_pathlib(app, static_file_directory, file_name):
|
||||
assert response.body == get_file_content(static_file_directory, file_name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"file_name",
|
||||
[
|
||||
"test.file",
|
||||
"decode me.txt",
|
||||
"python.png",
|
||||
"symlink",
|
||||
"hard_link",
|
||||
],
|
||||
)
|
||||
def test_static_file_pathlib_relative_path_traversal(
|
||||
app, static_file_directory, file_name
|
||||
):
|
||||
"""Get the current working directory and check if it ends with "sanic" """
|
||||
cwd = Path.cwd()
|
||||
if not str(cwd).endswith("sanic"):
|
||||
pytest.skip("Current working directory does not end with 'sanic'")
|
||||
|
||||
file_path = "./tests/static/../static/"
|
||||
app.static("/", file_path)
|
||||
_, response = app.test_client.get(f"/{file_name}")
|
||||
assert response.status == 200
|
||||
assert response.body == get_file_content(static_file_directory, file_name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"file_name",
|
||||
[b"test.file", b"decode me.txt", b"python.png"],
|
||||
@@ -492,7 +517,7 @@ def test_stack_trace_on_not_found(app, static_file_directory, caplog):
|
||||
counter = Counter([(r[0], r[1]) for r in caplog.record_tuples])
|
||||
|
||||
assert response.status == 404
|
||||
assert counter[("sanic.root", logging.INFO)] == 9
|
||||
assert counter[("sanic.root", logging.INFO)] == 10
|
||||
assert counter[("sanic.root", logging.ERROR)] == 0
|
||||
assert counter[("sanic.error", logging.ERROR)] == 0
|
||||
assert counter[("sanic.server", logging.INFO)] == 2
|
||||
@@ -511,7 +536,7 @@ def test_no_stack_trace_on_not_found(app, static_file_directory, caplog):
|
||||
counter = Counter([(r[0], r[1]) for r in caplog.record_tuples])
|
||||
|
||||
assert response.status == 404
|
||||
assert counter[("sanic.root", logging.INFO)] == 9
|
||||
assert counter[("sanic.root", logging.INFO)] == 10
|
||||
assert counter[("sanic.root", logging.ERROR)] == 0
|
||||
assert counter[("sanic.error", logging.ERROR)] == 0
|
||||
assert counter[("sanic.server", logging.INFO)] == 2
|
||||
|
||||
10
tests/typing/samples/app_custom_config.py
Normal file
10
tests/typing/samples/app_custom_config.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from sanic import Sanic
|
||||
from sanic.config import Config
|
||||
|
||||
|
||||
class CustomConfig(Config):
|
||||
pass
|
||||
|
||||
|
||||
app = Sanic("test", config=CustomConfig())
|
||||
reveal_type(app)
|
||||
9
tests/typing/samples/app_custom_ctx.py
Normal file
9
tests/typing/samples/app_custom_ctx.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from sanic import Sanic
|
||||
|
||||
|
||||
class Foo:
|
||||
pass
|
||||
|
||||
|
||||
app = Sanic("test", ctx=Foo())
|
||||
reveal_type(app)
|
||||
5
tests/typing/samples/app_default.py
Normal file
5
tests/typing/samples/app_default.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from sanic import Sanic
|
||||
|
||||
|
||||
app = Sanic("test")
|
||||
reveal_type(app)
|
||||
14
tests/typing/samples/app_fully_custom.py
Normal file
14
tests/typing/samples/app_fully_custom.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from sanic import Sanic
|
||||
from sanic.config import Config
|
||||
|
||||
|
||||
class CustomConfig(Config):
|
||||
pass
|
||||
|
||||
|
||||
class Foo:
|
||||
pass
|
||||
|
||||
|
||||
app = Sanic("test", config=CustomConfig(), ctx=Foo())
|
||||
reveal_type(app)
|
||||
17
tests/typing/samples/request_custom_ctx.py
Normal file
17
tests/typing/samples/request_custom_ctx.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sanic import Request, Sanic
|
||||
from sanic.config import Config
|
||||
|
||||
|
||||
class Foo:
|
||||
pass
|
||||
|
||||
|
||||
app = Sanic("test")
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def handler(request: Request[Sanic[Config, SimpleNamespace], Foo]):
|
||||
reveal_type(request.ctx)
|
||||
reveal_type(request.app)
|
||||
19
tests/typing/samples/request_custom_sanic.py
Normal file
19
tests/typing/samples/request_custom_sanic.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from sanic import Request, Sanic
|
||||
from sanic.config import Config
|
||||
|
||||
|
||||
class CustomConfig(Config):
|
||||
pass
|
||||
|
||||
|
||||
app = Sanic("test", config=CustomConfig())
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def handler(
|
||||
request: Request[Sanic[CustomConfig, SimpleNamespace], SimpleNamespace]
|
||||
):
|
||||
reveal_type(request.ctx)
|
||||
reveal_type(request.app)
|
||||
34
tests/typing/samples/request_fully_custom.py
Normal file
34
tests/typing/samples/request_fully_custom.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from sanic import Request, Sanic
|
||||
from sanic.config import Config
|
||||
|
||||
|
||||
class CustomConfig(Config):
|
||||
pass
|
||||
|
||||
|
||||
class Foo:
|
||||
pass
|
||||
|
||||
|
||||
class RequestContext:
|
||||
foo: Foo
|
||||
|
||||
|
||||
class CustomRequest(Request[Sanic[CustomConfig, Foo], RequestContext]):
|
||||
@staticmethod
|
||||
def make_context() -> RequestContext:
|
||||
ctx = RequestContext()
|
||||
ctx.foo = Foo()
|
||||
return ctx
|
||||
|
||||
|
||||
app = Sanic(
|
||||
"test", config=CustomConfig(), ctx=Foo(), request_class=CustomRequest
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def handler(request: CustomRequest):
|
||||
reveal_type(request)
|
||||
reveal_type(request.ctx)
|
||||
reveal_type(request.app)
|
||||
127
tests/typing/test_typing.py
Normal file
127
tests/typing/test_typing.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# flake8: noqa: E501
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Tuple
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
CURRENT_DIR = Path(__file__).parent
|
||||
|
||||
|
||||
def run_check(path_location: str) -> str:
|
||||
"""Use mypy to check the given path location and return the output."""
|
||||
|
||||
mypy_path = "mypy"
|
||||
path = CURRENT_DIR / path_location
|
||||
command = [mypy_path, path.resolve().as_posix()]
|
||||
|
||||
process = subprocess.run(
|
||||
command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
)
|
||||
output = process.stdout + process.stderr
|
||||
return output
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path_location,expected",
|
||||
(
|
||||
(
|
||||
"app_default.py",
|
||||
[
|
||||
(
|
||||
"sanic.app.Sanic[sanic.config.Config, types.SimpleNamespace]",
|
||||
5,
|
||||
)
|
||||
],
|
||||
),
|
||||
(
|
||||
"app_custom_config.py",
|
||||
[
|
||||
(
|
||||
"sanic.app.Sanic[app_custom_config.CustomConfig, types.SimpleNamespace]",
|
||||
10,
|
||||
)
|
||||
],
|
||||
),
|
||||
(
|
||||
"app_custom_ctx.py",
|
||||
[("sanic.app.Sanic[sanic.config.Config, app_custom_ctx.Foo]", 9)],
|
||||
),
|
||||
(
|
||||
"app_fully_custom.py",
|
||||
[
|
||||
(
|
||||
"sanic.app.Sanic[app_fully_custom.CustomConfig, app_fully_custom.Foo]",
|
||||
14,
|
||||
)
|
||||
],
|
||||
),
|
||||
(
|
||||
"request_custom_sanic.py",
|
||||
[
|
||||
("types.SimpleNamespace", 18),
|
||||
(
|
||||
"sanic.app.Sanic[request_custom_sanic.CustomConfig, types.SimpleNamespace]",
|
||||
19,
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"request_custom_ctx.py",
|
||||
[
|
||||
("request_custom_ctx.Foo", 16),
|
||||
(
|
||||
"sanic.app.Sanic[sanic.config.Config, types.SimpleNamespace]",
|
||||
17,
|
||||
),
|
||||
],
|
||||
),
|
||||
(
|
||||
"request_fully_custom.py",
|
||||
[
|
||||
("request_fully_custom.CustomRequest", 32),
|
||||
("request_fully_custom.RequestContext", 33),
|
||||
(
|
||||
"sanic.app.Sanic[request_fully_custom.CustomConfig, request_fully_custom.Foo]",
|
||||
34,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_check_app_default(
|
||||
path_location: str, expected: List[Tuple[str, int]]
|
||||
) -> None:
|
||||
output = run_check(f"samples/{path_location}")
|
||||
|
||||
for text, number in expected:
|
||||
current = CURRENT_DIR / f"samples/{path_location}"
|
||||
path = current.relative_to(CURRENT_DIR.parent)
|
||||
|
||||
target = Path.cwd()
|
||||
while True:
|
||||
note = _text_from_path(current, path, target, number, text)
|
||||
try:
|
||||
assert note in output, output
|
||||
except AssertionError:
|
||||
target = target.parent
|
||||
if not target.exists():
|
||||
raise
|
||||
else:
|
||||
break
|
||||
|
||||
|
||||
def _text_from_path(
|
||||
base: Path, path: Path, target: Path, number: int, text: str
|
||||
) -> str:
|
||||
relative_to_cwd = base.relative_to(target)
|
||||
prefix = ".".join(relative_to_cwd.parts[:-1])
|
||||
text = text.replace(path.stem, f"{prefix}.{path.stem}")
|
||||
return f'{path}:{number}: note: Revealed type is "{text}"'
|
||||
8
tox.ini
8
tox.ini
@@ -1,14 +1,14 @@
|
||||
[tox]
|
||||
envlist = py37, py38, py39, py310, py311, pyNightly, pypy37, {py37,py38,py39,py310,py311,pyNightly,pypy37}-no-ext, lint, check, security, docs, type-checking
|
||||
envlist = py38, py39, py310, py311, pyNightly, pypy310, {py38,py39,py310,py311,pyNightly,pypy310}-no-ext, lint, check, security, docs, type-checking
|
||||
|
||||
[testenv]
|
||||
usedevelop = true
|
||||
setenv =
|
||||
{py37,py38,py39,py310,py311,pyNightly}-no-ext: SANIC_NO_UJSON=1
|
||||
{py37,py38,py39,py310,py311,pyNightly}-no-ext: SANIC_NO_UVLOOP=1
|
||||
{py38,py39,py310,py311,pyNightly}-no-ext: SANIC_NO_UJSON=1
|
||||
{py38,py39,py310,py311,pyNightly}-no-ext: SANIC_NO_UVLOOP=1
|
||||
extras = test, http3
|
||||
deps =
|
||||
httpx==0.23
|
||||
httpx>=0.23
|
||||
allowlist_externals =
|
||||
pytest
|
||||
coverage
|
||||
|
||||
Reference in New Issue
Block a user