Compare commits

...

31 Commits

Author SHA1 Message Date
Adam Hopkins
44bf7ba79a Cleanup tests 2023-08-21 22:54:04 +03:00
Adam Hopkins
9e7ca10c52 Cleanup tests 2023-08-21 21:37:45 +03:00
Adam Hopkins
fe32f4eb74 Fix border character 2023-08-21 20:10:47 +03:00
Adam Hopkins
ebe29d3d26 Make pretty 2023-08-21 16:40:22 +03:00
Adam Hopkins
f651f7436f Fix MOTD for extra data 2023-08-21 16:37:55 +03:00
Adam Hopkins
16256522f6 Disable Test PyPI dist 2023-07-25 16:13:47 +03:00
Adam Hopkins
205795d1e8 Prepare for v23.6 release (#2797) 2023-07-25 15:57:29 +03:00
Adam Hopkins
9cbe1fb8ad Add convenience method for exception reporting (#2792) 2023-07-18 00:21:55 +03:00
L. Kärkkäinen
31d7ba8f8c Add request.client_ip (#2790)
Co-authored-by: L. Kärkkäinen <Tronic@users.noreply.github.com>
2023-07-13 23:01:02 +03:00
Adam Hopkins
dc3c4d1393 Add custom typing to config and ctx (#2785) 2023-07-12 23:47:58 +03:00
Adam Hopkins
929d270569 Update bug-report.yml (#2788) 2023-07-12 19:00:28 +03:00
Adam Hopkins
93714df051 Update bug-report.yml (#2787) 2023-07-12 18:53:14 +03:00
L. Kärkkäinen
6e61eab872 Increase KEEP_ALIVE_TIMEOUT default to 120 seconds (#2670)
Co-authored-by: L. Kärkkäinen <Tronic@users.noreply.github.com>
2023-07-12 08:45:30 +03:00
Adam Hopkins
6848ff24d8 Run keep alive tests in loop to get available port (#2779) 2023-07-09 22:58:17 +03:00
Adam Hopkins
666371bb92 Set multiprocessing start method early (#2776) 2023-07-09 22:34:15 +03:00
Adam Hopkins
4a2b82e42e Remove Python3.7 support (#2777) 2023-07-09 22:00:14 +03:00
Moshe Nahmias
5dd1623192 Alow Blueprint routes to explicitly define error_format (#2773)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2023-07-09 14:47:59 +03:00
Adam Hopkins
976da69e79 Add a new exception signal for ALL exceptions raised anywhere in application (#2724) 2023-07-09 10:53:36 +03:00
Liam Coatman
11a0b15194 Handle case when headers argument of ResponseStream constructor is None (#2729)
* Handle case when headers is None

* Add test for response stream with default headers

* Move test

---------

Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2023-07-09 10:34:40 +03:00
Adam Hopkins
c21999a248 Resolve headers on different renderers - Issue 2749 (#2774)
* Resolve headers on different renderers - Issue 2749

* Make pretty
2023-07-09 09:57:22 +03:00
guacs
c17230ef94 Update request type on middleware types (#2754)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2023-07-09 09:35:24 +03:00
Adam Hopkins
049983cb70 Fix traversals for intended results (#2728) 2023-07-09 09:21:39 +03:00
Zhiwei
e374409567 Adding allow route overwrite option in blueprint (#2716)
* Adding allow route overwrite option

* Add test case for route overwriting after bp copy

* Fix test

* Fix

* Add test case `test_bp_allow_override`

* Remove conflicted future routes when overwriting is allowed

* Improved test test_bp_copy_with_route_overwriting

* Fix type

* Fix type 2

* Add `test_bp_copy_without_route_overwriting` case

* make `allow_route_overwrite` flag to be internal

* Remove unwanted test case

---------

Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2023-07-07 14:56:42 +03:00
Adam Hopkins
4068a0d83d Add name prefixing to BP groups (#2727) 2023-07-05 19:31:25 +03:00
Benjamin
70da5e9879 Fix Inner bug: TypeError: __init__() got an unexpected keyword argument 'escape_forward_slashes' #2740 (#2772) 2023-07-05 15:30:38 +03:00
Moshe Nahmias
f48506d620 fix #2757 - Improved error messaging on startup time application induced import error (#2770)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2023-07-05 14:38:15 +03:00
Mohammad Almoghrabi
f2cc83c1ba fix examples for freeze_support() issue on windows (#2741)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2023-07-05 13:45:08 +03:00
Mohammad Almoghrabi
273825dab6 Sanic on pypy (#2682)
Co-authored-by: L. Kärkkäinen <98187+Tronic@users.noreply.github.com>
Co-authored-by: Adam Hopkins <admhpkns@gmail.com>
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2023-07-05 12:14:47 +03:00
Zhiwei
9a7dafd531 Unpin setuptools version (#2766) 2023-07-05 11:06:43 +03:00
Thirumalaisamy K
50117d174c Fix issue in getting current request through classmethod when served through a different ASGI server (#2760) 2023-06-14 22:03:43 +03:00
Néstor Pérez
af67801062 Fix JSONResponse default content type (#2737) 2023-04-09 22:23:21 +03:00
86 changed files with 1337 additions and 307 deletions

View File

@@ -21,7 +21,14 @@ body:
id: code id: code
attributes: attributes:
label: Code snippet 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: validations:
required: false required: false
- type: textarea - type: textarea
@@ -42,11 +49,16 @@ body:
- ASGI - ASGI
validations: validations:
required: true required: true
- type: input - type: dropdown
id: os id: os
attributes: attributes:
label: Operating System label: Operating System
description: What OS? description: What OS?
options:
- Linux
- MacOS
- Windows
- Other (tell us in the description)
validations: validations:
required: true required: true
- type: input - type: input

View File

@@ -4,10 +4,12 @@ on:
push: push:
branches: branches:
- main - main
- current-release
- "*LTS" - "*LTS"
pull_request: pull_request:
branches: branches:
- main - main
- current-release
- "*LTS" - "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
schedule: schedule:

View File

@@ -3,12 +3,14 @@ on:
push: push:
branches: branches:
- main - main
- current-release
- "*LTS" - "*LTS"
tags: tags:
- "!*" # Do not execute on tags - "!*" # Do not execute on tags
pull_request: pull_request:
branches: branches:
- main - main
- current-release
- "*LTS" - "*LTS"
jobs: jobs:
test: test:

View File

@@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- current-release
- "*LTS" - "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
@@ -16,7 +17,6 @@ jobs:
matrix: matrix:
os: [ubuntu-latest] os: [ubuntu-latest]
config: config:
- { python-version: 3.7, tox-env: security}
- { python-version: 3.8, tox-env: security} - { python-version: 3.8, tox-env: security}
- { python-version: 3.9, tox-env: security} - { python-version: 3.9, tox-env: security}
- { python-version: "3.10", tox-env: security} - { python-version: "3.10", tox-env: security}

View File

@@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- current-release
- "*LTS" - "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- current-release
- "*LTS" - "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -5,11 +5,11 @@ on:
tox-env: tox-env:
description: "Tox Env to run on the PyPy Infra" description: "Tox Env to run on the PyPy Infra"
required: false required: false
default: "pypy37" default: "pypy310"
pypy-version: pypy-version:
description: "Version of PyPy to use" description: "Version of PyPy to use"
required: false required: false
default: "pypy-3.7" default: "pypy-3.10"
jobs: jobs:
testPyPy: testPyPy:
name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }} name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }}

View File

@@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- current-release
- "*LTS" - "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- current-release
- "*LTS" - "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -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"

View File

@@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- current-release
- "*LTS" - "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- current-release
- "*LTS" - "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- current-release
- "*LTS" - "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
@@ -16,7 +17,6 @@ jobs:
matrix: matrix:
os: [ubuntu-latest] os: [ubuntu-latest]
config: config:
# - { python-version: 3.7, tox-env: type-checking}
- { python-version: 3.8, tox-env: type-checking} - { python-version: 3.8, tox-env: type-checking}
- { python-version: 3.9, tox-env: type-checking} - { python-version: 3.9, tox-env: type-checking}
- { python-version: "3.10", tox-env: type-checking} - { python-version: "3.10", tox-env: type-checking}

View File

@@ -3,6 +3,7 @@ on:
pull_request: pull_request:
branches: branches:
- main - main
- current-release
- "*LTS" - "*LTS"
types: [opened, synchronize, reopened, ready_for_review] types: [opened, synchronize, reopened, ready_for_review]
@@ -15,12 +16,10 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
config: config:
- { python-version: 3.7, tox-env: py37-no-ext }
- { python-version: 3.8, tox-env: py38-no-ext } - { python-version: 3.8, tox-env: py38-no-ext }
- { python-version: 3.9, tox-env: py39-no-ext } - { python-version: 3.9, tox-env: py39-no-ext }
- { python-version: "3.10", tox-env: py310-no-ext } - { python-version: "3.10", tox-env: py310-no-ext }
- { python-version: "3.11", 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: steps:
- name: Checkout Repository - name: Checkout Repository

View File

@@ -14,7 +14,7 @@ jobs:
strategy: strategy:
fail-fast: true fail-fast: true
matrix: matrix:
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] python-version: ["3.8", "3.9", "3.10", "3.11"]
steps: steps:
- name: Checkout repository - name: Checkout repository

View File

@@ -1,28 +1,39 @@
name: Publish Artifacts name: Upload Python Package
on: on:
release: release:
types: [created] types: [created]
workflow_dispatch:
jobs: jobs:
publishPythonPackage: build-n-publish:
name: Publishing Sanic Release Artifacts name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
python-version: ["3.10"]
steps: steps:
- name: Checkout Repository - uses: actions/checkout@v3
uses: actions/checkout@v2 - name: Set up Python
uses: actions/setup-python@v4
- name: Publish Python Package with:
uses: harshanarayana/custom-actions@main python-version: "3.x"
with: - name: Install pypa/build
python-version: ${{ matrix.python-version }} run: >-
package-infra-name: "twine" python3 -m
pypi-user: __token__ pip install
pypi-access-token: ${{ secrets.PYPI_ACCESS_TOKEN }} build
action: "package-publish" --user
pypi-verify-metadata: "true" - 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
View File

@@ -21,4 +21,5 @@ dist/*
pip-wheel-metadata/ pip-wheel-metadata/
.pytest_cache/* .pytest_cache/*
.venv/* .venv/*
venv/*
.vscode/* .vscode/*

View File

@@ -5,6 +5,7 @@
| 🔷 In support release | 🔷 In support release
| |
.. mdinclude:: ./releases/23/23.6.md
.. mdinclude:: ./releases/23/23.3.md .. mdinclude:: ./releases/23/23.3.md
.. mdinclude:: ./releases/22/22.12.md .. mdinclude:: ./releases/22/22.12.md
.. mdinclude:: ./releases/22/22.9.md .. mdinclude:: ./releases/22/22.9.md

View File

@@ -1,4 +1,4 @@
## Version 23.3.0 🔶 ## Version 23.3.0
### Features ### Features
- [#2545](https://github.com/sanic-org/sanic/pull/2545) Standardize init of exceptions for more consistent control of HTTP responses using exceptions - [#2545](https://github.com/sanic-org/sanic/pull/2545) Standardize init of exceptions for more consistent control of HTTP responses using exceptions

View 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:

View File

@@ -25,5 +25,5 @@ def key_exist_handler(request):
return text("num does not exist in request") return text("num does not exist in request")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000, debug=True) app.run(host="0.0.0.0", port=8000, debug=True)

View File

@@ -50,4 +50,5 @@ def pop_handler(request):
app.blueprint(bp, url_prefix="/bp") 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)

View File

@@ -37,4 +37,5 @@ app.blueprint(blueprint)
app.blueprint(blueprint2) app.blueprint(blueprint2)
app.blueprint(blueprint3) 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)

View File

@@ -69,5 +69,5 @@ async def runner(app: Sanic, app_server: AsyncioServer):
app.is_running = False app.is_running = False
app.is_stopping = True app.is_stopping = True
if __name__ == "__main__":
https.run(port=HTTPS_PORT, debug=True) https.run(port=HTTPS_PORT, debug=True)

View File

@@ -39,4 +39,5 @@ async def test(request):
return json(response) 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)

View File

@@ -20,4 +20,5 @@ def test(request):
return text("hey") return text("hey")
app.run(host="0.0.0.0", port=8000) if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)

View File

@@ -6,5 +6,5 @@ data = ""
for i in range(1, 250000): for i in range(1, 250000):
data += str(i) 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) print(r.text)

View File

@@ -20,4 +20,5 @@ def timeout(request, exception):
return response.text("RequestTimeout from error_handler.", 408) 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)

View File

@@ -35,34 +35,34 @@ async def after_server_stop(app, loop):
async def test(request): async def test(request):
return response.json({"answer": "42"}) 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()) # When using app.run(), this actually triggers before the serv_coro.
serv_coro = app.create_server( # But, in this example, we are using the convenience method, even if it is
host="0.0.0.0", port=8000, return_asyncio_server=True # out of order.
) loop.run_until_complete(server.before_start())
loop = asyncio.get_event_loop() loop.run_until_complete(server.after_start())
serv_task = asyncio.ensure_future(serv_coro, loop=loop) try:
signal(SIGINT, lambda s, f: loop.stop()) loop.run_forever()
server: AsyncioServer = loop.run_until_complete(serv_task) except KeyboardInterrupt:
loop.run_until_complete(server.startup()) loop.stop()
finally:
loop.run_until_complete(server.before_stop())
# When using app.run(), this actually triggers before the serv_coro. # Wait for server to close
# But, in this example, we are using the convenience method, even if it is close_task = server.close()
# out of order. loop.run_until_complete(close_task)
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 # Complete all tasks on the loop
close_task = server.close() for connection in server.connections:
loop.run_until_complete(close_task) 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())

View File

@@ -1,5 +1,5 @@
[build-system] [build-system]
requires = ["setuptools<60.0", "wheel"] requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.black] [tool.black]

View File

@@ -1,6 +1,11 @@
from types import SimpleNamespace
from typing_extensions import TypeAlias
from sanic.__version__ import __version__ from sanic.__version__ import __version__
from sanic.app import Sanic from sanic.app import Sanic
from sanic.blueprints import Blueprint from sanic.blueprints import Blueprint
from sanic.config import Config
from sanic.constants import HTTPMethod from sanic.constants import HTTPMethod
from sanic.exceptions import ( from sanic.exceptions import (
BadRequest, BadRequest,
@@ -32,15 +37,29 @@ from sanic.response import (
from sanic.server.websockets.impl import WebsocketImplProtocol as Websocket 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__ = ( __all__ = (
"__version__", "__version__",
# Common objects # Common objects
"Sanic", "Sanic",
"Config",
"Blueprint", "Blueprint",
"HTTPMethod", "HTTPMethod",
"HTTPResponse", "HTTPResponse",
"Request", "Request",
"Websocket", "Websocket",
# Common types
"DefaultSanic",
"DefaultRequest",
# Common exceptions # Common exceptions
"BadRequest", "BadRequest",
"ExpectationFailed", "ExpectationFailed",

View File

@@ -1 +1 @@
__version__ = "23.3.0" __version__ = "23.6.0"

View File

@@ -17,7 +17,7 @@ from asyncio import (
from asyncio.futures import Future from asyncio.futures import Future
from collections import defaultdict, deque from collections import defaultdict, deque
from contextlib import contextmanager, suppress from contextlib import contextmanager, suppress
from functools import partial from functools import partial, wraps
from inspect import isawaitable from inspect import isawaitable
from os import environ from os import environ
from socket import socket from socket import socket
@@ -29,9 +29,11 @@ from typing import (
AnyStr, AnyStr,
Awaitable, Awaitable,
Callable, Callable,
ClassVar,
Coroutine, Coroutine,
Deque, Deque,
Dict, Dict,
Generic,
Iterable, Iterable,
Iterator, Iterator,
List, List,
@@ -41,6 +43,8 @@ from typing import (
Type, Type,
TypeVar, TypeVar,
Union, Union,
cast,
overload,
) )
from urllib.parse import urlencode, urlunparse from urllib.parse import urlencode, urlunparse
@@ -83,7 +87,7 @@ from sanic.request import Request
from sanic.response import BaseHTTPResponse, HTTPResponse, ResponseStream from sanic.response import BaseHTTPResponse, HTTPResponse, ResponseStream
from sanic.router import Router from sanic.router import Router
from sanic.server.websockets.impl import ConnectionClosed 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.touchup import TouchUp, TouchUpMeta
from sanic.types.shared_ctx import SharedContext from sanic.types.shared_ctx import SharedContext
from sanic.worker.inspector import Inspector from sanic.worker.inspector import Inspector
@@ -102,8 +106,17 @@ if TYPE_CHECKING:
if OS_IS_WINDOWS: # no cov if OS_IS_WINDOWS: # no cov
enable_windows_color_support() 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 The main application instance
""" """
@@ -158,14 +171,102 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
"websocket_tasks", "websocket_tasks",
) )
_app_registry: Dict[str, "Sanic"] = {} _app_registry: ClassVar[Dict[str, "Sanic"]] = {}
test_mode = False 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__( def __init__(
self, self,
name: Optional[str] = None, name: str,
config: Optional[Config] = None, config: Optional[config_type] = None,
ctx: Optional[Any] = None, ctx: Optional[ctx_type] = None,
router: Optional[Router] = None, router: Optional[Router] = None,
signal_router: Optional[SignalRouter] = None, signal_router: Optional[SignalRouter] = None,
error_handler: Optional[ErrorHandler] = None, error_handler: Optional[ErrorHandler] = None,
@@ -193,7 +294,9 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
) )
# First setup config # 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: if inspector:
self.config.INSPECTOR = inspector self.config.INSPECTOR = inspector
@@ -217,7 +320,7 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
certloader_class or CertLoader certloader_class or CertLoader
) )
self.configure_logging: bool = configure_logging 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.error_handler: ErrorHandler = error_handler or ErrorHandler()
self.inspector_class: Type[Inspector] = inspector_class or Inspector self.inspector_class: Type[Inspector] = inspector_class or Inspector
self.listeners: Dict[str, List[ListenerType[Any]]] = defaultdict(list) 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): def _apply_listener(self, listener: FutureListener):
return self.register_listener(listener.listener, listener.event) 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 = route._asdict()
params["overwrite"] = overwrite
websocket = params.pop("websocket", False) websocket = params.pop("websocket", False)
subprotocols = params.pop("subprotocols", None) subprotocols = params.pop("subprotocols", None)
@@ -499,6 +605,19 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
raise NotFound("Could not find signal %s" % event) raise NotFound("Could not find signal %s" % event)
return await wait_for(signal.ctx.event.wait(), timeout=timeout) 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): def enable_websocket(self, enable=True):
"""Enable or disable the support for websocket. """Enable or disable the support for websocket.
@@ -550,6 +669,9 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
) )
else: else:
params["version_prefix"] = blueprint.version_prefix 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) self.blueprint(item, **params)
return return
if blueprint.name in self.blueprints: if blueprint.name in self.blueprints:
@@ -767,6 +889,12 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
:raises ServerError: response 500 :raises ServerError: response 500
""" """
response = None 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( await self.dispatch(
"http.lifecycle.exception", "http.lifecycle.exception",
inline=True, inline=True,
@@ -1197,13 +1325,28 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
app, app,
loop, loop,
): ):
if callable(task): async def do(task):
try: try:
task = task(app) if callable(task):
except TypeError: try:
task = task() 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 @classmethod
def _loop_add_task( def _loop_add_task(
@@ -1217,18 +1360,9 @@ class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
) -> Task: ) -> Task:
if not isinstance(task, Future): if not isinstance(task, Future):
prepped = cls._prep_task(task, app, loop) prepped = cls._prep_task(task, app, loop)
if sys.version_info < (3, 8): # no cov task = loop.create_task(prepped, name=name)
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)
if name and register and sys.version_info > (3, 7): if name and register:
app._task_registry[name] = task app._task_registry[name] = task
return task return task

View File

@@ -3,7 +3,7 @@ import sys
from os import environ from os import environ
from sanic.compat import is_atty from sanic.helpers import is_atty
BASE_LOGO = """ BASE_LOGO = """

View File

@@ -4,7 +4,7 @@ from textwrap import indent, wrap
from typing import Dict, Optional from typing import Dict, Optional
from sanic import __version__ from sanic import __version__
from sanic.compat import is_atty from sanic.helpers import is_atty
from sanic.log import logger from sanic.log import logger
@@ -73,6 +73,14 @@ class MOTDTTY(MOTD):
self.value_width = min( self.value_width = min(
max(map(len, self.data.values())), self.max_value_width 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_lines = self.logo.split("\n") if self.logo else []
self.logo_line_length = 24 self.logo_line_length = 24
self.centering_length = ( self.centering_length = (
@@ -104,7 +112,7 @@ class MOTDTTY(MOTD):
self._render_data(lines, self.data, 0) self._render_data(lines, self.data, 0)
if self.extra: if self.extra:
logo_part = self._get_logo_part(len(lines) - 4) 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_data(lines, self.extra, len(lines) - 4)
self._render_fill(lines) self._render_fill(lines)

View File

@@ -175,6 +175,7 @@ class ASGIApp:
instance.transport, instance.transport,
sanic_app, sanic_app,
) )
request_class._current.set(instance.request)
instance.request.stream = instance # type: ignore instance.request.stream = instance # type: ignore
instance.request_body = True instance.request_body = True
instance.request.conn_info = ConnInfo(instance.transport) instance.request.conn_info = ConnInfo(instance.transport)

View File

@@ -65,6 +65,7 @@ class BlueprintGroup(MutableSequence):
"_version", "_version",
"_strict_slashes", "_strict_slashes",
"_version_prefix", "_version_prefix",
"_name_prefix",
) )
def __init__( def __init__(
@@ -73,6 +74,7 @@ class BlueprintGroup(MutableSequence):
version: Optional[Union[int, str, float]] = None, version: Optional[Union[int, str, float]] = None,
strict_slashes: Optional[bool] = None, strict_slashes: Optional[bool] = None,
version_prefix: str = "/v", version_prefix: str = "/v",
name_prefix: Optional[str] = "",
): ):
""" """
Create a new Blueprint Group Create a new Blueprint Group
@@ -87,6 +89,7 @@ class BlueprintGroup(MutableSequence):
self._version = version self._version = version
self._version_prefix = version_prefix self._version_prefix = version_prefix
self._strict_slashes = strict_slashes self._strict_slashes = strict_slashes
self._name_prefix = name_prefix
@property @property
def url_prefix(self) -> Optional[Union[int, str, float]]: def url_prefix(self) -> Optional[Union[int, str, float]]:
@@ -134,6 +137,15 @@ class BlueprintGroup(MutableSequence):
""" """
return self._version_prefix 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): def __iter__(self):
""" """
Tun the class Blueprint Group into an Iterable item Tun the class Blueprint Group into an Iterable item

View File

@@ -93,6 +93,7 @@ class Blueprint(BaseSanic):
"_future_listeners", "_future_listeners",
"_future_exceptions", "_future_exceptions",
"_future_signals", "_future_signals",
"_allow_route_overwrite",
"copied_from", "copied_from",
"ctx", "ctx",
"exceptions", "exceptions",
@@ -110,7 +111,7 @@ class Blueprint(BaseSanic):
def __init__( def __init__(
self, self,
name: str = None, name: str,
url_prefix: Optional[str] = None, url_prefix: Optional[str] = None,
host: Optional[Union[List[str], str]] = None, host: Optional[Union[List[str], str]] = None,
version: Optional[Union[int, str, float]] = None, version: Optional[Union[int, str, float]] = None,
@@ -119,6 +120,7 @@ class Blueprint(BaseSanic):
): ):
super().__init__(name=name) super().__init__(name=name)
self.reset() self.reset()
self._allow_route_overwrite = False
self.copied_from = "" self.copied_from = ""
self.ctx = SimpleNamespace() self.ctx = SimpleNamespace()
self.host = host self.host = host
@@ -169,6 +171,7 @@ class Blueprint(BaseSanic):
def reset(self): def reset(self):
self._apps: Set[Sanic] = set() self._apps: Set[Sanic] = set()
self._allow_route_overwrite = False
self.exceptions: List[RouteHandler] = [] self.exceptions: List[RouteHandler] = []
self.listeners: Dict[str, List[ListenerType[Any]]] = {} self.listeners: Dict[str, List[ListenerType[Any]]] = {}
self.middlewares: List[MiddlewareType] = [] self.middlewares: List[MiddlewareType] = []
@@ -182,6 +185,7 @@ class Blueprint(BaseSanic):
url_prefix: Optional[Union[str, Default]] = _default, url_prefix: Optional[Union[str, Default]] = _default,
version: Optional[Union[int, str, float, Default]] = _default, version: Optional[Union[int, str, float, Default]] = _default,
version_prefix: Union[str, Default] = _default, version_prefix: Union[str, Default] = _default,
allow_route_overwrite: Union[bool, Default] = _default,
strict_slashes: Optional[Union[bool, Default]] = _default, strict_slashes: Optional[Union[bool, Default]] = _default,
with_registration: bool = True, with_registration: bool = True,
with_ctx: bool = False, with_ctx: bool = False,
@@ -225,6 +229,8 @@ class Blueprint(BaseSanic):
new_bp.strict_slashes = strict_slashes new_bp.strict_slashes = strict_slashes
if not isinstance(version_prefix, Default): if not isinstance(version_prefix, Default):
new_bp.version_prefix = version_prefix 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(): for key, value in attrs_backup.items():
setattr(self, key, value) setattr(self, key, value)
@@ -250,6 +256,7 @@ class Blueprint(BaseSanic):
version: Optional[Union[int, str, float]] = None, version: Optional[Union[int, str, float]] = None,
strict_slashes: Optional[bool] = None, strict_slashes: Optional[bool] = None,
version_prefix: str = "/v", version_prefix: str = "/v",
name_prefix: Optional[str] = "",
) -> BlueprintGroup: ) -> BlueprintGroup:
""" """
Create a list of blueprints, optionally grouping them under a Create a list of blueprints, optionally grouping them under a
@@ -275,6 +282,7 @@ class Blueprint(BaseSanic):
version=version, version=version,
strict_slashes=strict_slashes, strict_slashes=strict_slashes,
version_prefix=version_prefix, version_prefix=version_prefix,
name_prefix=name_prefix,
) )
for bp in chain(blueprints): for bp in chain(blueprints):
bps.append(bp) bps.append(bp)
@@ -295,6 +303,7 @@ class Blueprint(BaseSanic):
opt_version = options.get("version", None) opt_version = options.get("version", None)
opt_strict_slashes = options.get("strict_slashes", None) opt_strict_slashes = options.get("strict_slashes", None)
opt_version_prefix = options.get("version_prefix", self.version_prefix) opt_version_prefix = options.get("version_prefix", self.version_prefix)
opt_name_prefix = options.get("name_prefix", None)
error_format = options.get( error_format = options.get(
"error_format", app.config.FALLBACK_ERROR_FORMAT "error_format", app.config.FALLBACK_ERROR_FORMAT
) )
@@ -310,6 +319,10 @@ class Blueprint(BaseSanic):
# Prepend the blueprint URI prefix if available # Prepend the blueprint URI prefix if available
uri = self._setup_uri(future.uri, url_prefix) 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 version_prefix = self.version_prefix
for prefix in ( for prefix in (
future.version_prefix, future.version_prefix,
@@ -326,7 +339,10 @@ class Blueprint(BaseSanic):
future.strict_slashes, opt_strict_slashes, self.strict_slashes 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 host = future.host or self.host
if isinstance(host, list): if isinstance(host, list):
host = tuple(host) host = tuple(host)
@@ -346,7 +362,7 @@ class Blueprint(BaseSanic):
future.unquote, future.unquote,
future.static, future.static,
version_prefix, version_prefix,
error_format, route_error_format,
future.route_context, future.route_context,
) )
@@ -354,7 +370,9 @@ class Blueprint(BaseSanic):
continue continue
registered.add(apply_route) 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 # If it is a copied BP, then make sure all of the names of routes
# matchup with the new BP name # matchup with the new BP name

View File

@@ -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 File: project/sanic_server.py -> app\n"
" Example Module: project.sanic_server.app" " 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) sys.exit(1)
else: else:
raise e raise e

View File

@@ -1,5 +1,6 @@
import asyncio import asyncio
import os import os
import platform
import signal import signal
import sys import sys
@@ -10,6 +11,7 @@ from typing import Awaitable, Union
from multidict import CIMultiDict # type: ignore from multidict import CIMultiDict # type: ignore
from sanic.helpers import Default from sanic.helpers import Default
from sanic.log import error_logger
if sys.version_info < (3, 8): # no cov if sys.version_info < (3, 8): # no cov
@@ -22,6 +24,7 @@ else: # no cov
] ]
OS_IS_WINDOWS = os.name == "nt" OS_IS_WINDOWS = os.name == "nt"
PYPY_IMPLEMENTATION = platform.python_implementation() == "PyPy"
UVLOOP_INSTALLED = False UVLOOP_INSTALLED = False
try: try:
@@ -73,6 +76,38 @@ def enable_windows_color_support():
kernel.SetConsoleMode(kernel.GetStdHandle(-11), 7) 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): class Header(CIMultiDict):
""" """
Container used for both request and response headers. It is a subclass of 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>`_ <https://multidict.readthedocs.io/en/stable/multidict.html#multidict>`_
for more details about how to use the object. In general, it should work for more details about how to use the object. In general, it should work
very similar to a regular dictionary. very similar to a regular dictionary.
""" """ # noqa: E501
def __getattr__(self, key: str) -> str: def __getattr__(self, key: str) -> str:
if key.startswith("_"): if key.startswith("_"):
@@ -112,6 +147,12 @@ if use_trio: # pragma: no cover
open_async = trio.open_file open_async = trio.open_file
CancelledErrors = tuple([asyncio.CancelledError, trio.Cancelled]) CancelledErrors = tuple([asyncio.CancelledError, trio.Cancelled])
else: 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 import open as aio_open # type: ignore
from aiofiles.os import stat as stat_async # type: ignore # noqa: F401 from aiofiles.os import stat as stat_async # type: ignore # noqa: F401
@@ -143,7 +184,3 @@ def ctrlc_workaround_for_windows(app):
die = False die = False
signal.signal(signal.SIGINT, ctrlc_handler) signal.signal(signal.SIGINT, ctrlc_handler)
app.add_task(stay_active) app.add_task(stay_active)
def is_atty() -> bool:
return bool(sys.stdout and sys.stdout.isatty())

View File

@@ -43,14 +43,14 @@ DEFAULT_CONFIG = {
"DEPRECATION_FILTER": "once", "DEPRECATION_FILTER": "once",
"FORWARDED_FOR_HEADER": "X-Forwarded-For", "FORWARDED_FOR_HEADER": "X-Forwarded-For",
"FORWARDED_SECRET": None, "FORWARDED_SECRET": None,
"GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec "GRACEFUL_SHUTDOWN_TIMEOUT": 15.0,
"INSPECTOR": False, "INSPECTOR": False,
"INSPECTOR_HOST": "localhost", "INSPECTOR_HOST": "localhost",
"INSPECTOR_PORT": 6457, "INSPECTOR_PORT": 6457,
"INSPECTOR_TLS_KEY": _default, "INSPECTOR_TLS_KEY": _default,
"INSPECTOR_TLS_CERT": _default, "INSPECTOR_TLS_CERT": _default,
"INSPECTOR_API_KEY": "", "INSPECTOR_API_KEY": "",
"KEEP_ALIVE_TIMEOUT": 5, # 5 seconds "KEEP_ALIVE_TIMEOUT": 120,
"KEEP_ALIVE": True, "KEEP_ALIVE": True,
"LOCAL_CERT_CREATOR": LocalCertCreator.AUTO, "LOCAL_CERT_CREATOR": LocalCertCreator.AUTO,
"LOCAL_TLS_KEY": _default, "LOCAL_TLS_KEY": _default,
@@ -61,16 +61,16 @@ DEFAULT_CONFIG = {
"NOISY_EXCEPTIONS": False, "NOISY_EXCEPTIONS": False,
"PROXIES_COUNT": None, "PROXIES_COUNT": None,
"REAL_IP_HEADER": None, "REAL_IP_HEADER": None,
"REQUEST_BUFFER_SIZE": 65536, # 64 KiB "REQUEST_BUFFER_SIZE": 65536,
"REQUEST_MAX_HEADER_SIZE": 8192, # 8 KiB, but cannot exceed 16384 "REQUEST_MAX_HEADER_SIZE": 8192, # Cannot exceed 16384
"REQUEST_ID_HEADER": "X-Request-ID", "REQUEST_ID_HEADER": "X-Request-ID",
"REQUEST_MAX_SIZE": 100000000, # 100 megabytes "REQUEST_MAX_SIZE": 100_000_000,
"REQUEST_TIMEOUT": 60, # 60 seconds "REQUEST_TIMEOUT": 60,
"RESPONSE_TIMEOUT": 60, # 60 seconds "RESPONSE_TIMEOUT": 60,
"TLS_CERT_PASSWORD": "", "TLS_CERT_PASSWORD": "",
"TOUCHUP": _default, "TOUCHUP": _default,
"USE_UVLOOP": _default, "USE_UVLOOP": _default,
"WEBSOCKET_MAX_SIZE": 2**20, # 1 megabyte "WEBSOCKET_MAX_SIZE": 2**20, # 1 MiB
"WEBSOCKET_PING_INTERVAL": 20, "WEBSOCKET_PING_INTERVAL": 20,
"WEBSOCKET_PING_TIMEOUT": 20, "WEBSOCKET_PING_TIMEOUT": 20,
} }

View File

@@ -92,8 +92,10 @@ class BaseRenderer:
self.full self.full
if self.debug and not getattr(self.exception, "quiet", False) if self.debug and not getattr(self.exception, "quiet", False)
else self.minimal else self.minimal
) )()
return output() output.status = self.status
output.headers.update(self.headers)
return output
def minimal(self) -> HTTPResponse: # noqa def minimal(self) -> HTTPResponse: # noqa
""" """
@@ -125,7 +127,7 @@ class HTMLRenderer(BaseRenderer):
request=self.request, request=self.request,
exc=self.exception, exc=self.exception,
) )
return html(page.render(), status=self.status, headers=self.headers) return html(page.render())
def minimal(self) -> HTTPResponse: def minimal(self) -> HTTPResponse:
return self.full() return self.full()
@@ -146,8 +148,7 @@ class TextRenderer(BaseRenderer):
text=self.text, text=self.text,
bar=("=" * len(self.title)), bar=("=" * len(self.title)),
body=self._generate_body(full=True), body=self._generate_body(full=True),
), )
status=self.status,
) )
def minimal(self) -> HTTPResponse: def minimal(self) -> HTTPResponse:
@@ -157,9 +158,7 @@ class TextRenderer(BaseRenderer):
text=self.text, text=self.text,
bar=("=" * len(self.title)), bar=("=" * len(self.title)),
body=self._generate_body(full=False), body=self._generate_body(full=False),
), )
status=self.status,
headers=self.headers,
) )
@property @property
@@ -218,11 +217,11 @@ class JSONRenderer(BaseRenderer):
def full(self) -> HTTPResponse: def full(self) -> HTTPResponse:
output = self._generate_output(full=True) 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: def minimal(self) -> HTTPResponse:
output = self._generate_output(full=False) 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): def _generate_output(self, *, full):
output = { output = {
@@ -313,7 +312,7 @@ def exception_response(
debug: bool, debug: bool,
fallback: str, fallback: str,
base: t.Type[BaseRenderer], base: t.Type[BaseRenderer],
renderer: t.Type[t.Optional[BaseRenderer]] = None, renderer: t.Optional[t.Type[BaseRenderer]] = None,
) -> HTTPResponse: ) -> HTTPResponse:
""" """
Render a response for the default FALLBACK exception handler. Render a response for the default FALLBACK exception handler.

View File

@@ -90,7 +90,7 @@ class SanicException(Exception):
super().__init__(message) super().__init__(message)
self.status_code = status_code self.status_code = status_code or self.status_code
self.quiet = quiet self.quiet = quiet
self.headers = headers self.headers = headers

View File

@@ -6,7 +6,9 @@ from sanic.errorpages import BaseRenderer, TextRenderer, exception_response
from sanic.exceptions import ServerError from sanic.exceptions import ServerError
from sanic.log import error_logger from sanic.log import error_logger
from sanic.models.handler_types import RouteHandler from sanic.models.handler_types import RouteHandler
from sanic.request.types import Request
from sanic.response import text from sanic.response import text
from sanic.response.types import HTTPResponse
class ErrorHandler: class ErrorHandler:
@@ -148,7 +150,7 @@ class ErrorHandler:
return text("An error occurred while handling an error", 500) return text("An error occurred while handling an error", 500)
return response 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`. Provide a default behavior for the objects of :class:`ErrorHandler`.
If a developer chooses to extent the :class:`ErrorHandler` they can If a developer chooses to extent the :class:`ErrorHandler` they can

View File

@@ -436,7 +436,7 @@ def format_http1_response(status: int, headers: HeaderBytesIterable) -> bytes:
def parse_credentials( def parse_credentials(
header: Optional[str], header: Optional[str],
prefixes: Union[List, Tuple, Set] = None, prefixes: Optional[Union[List, Tuple, Set]] = None,
) -> Tuple[Optional[str], Optional[str]]: ) -> Tuple[Optional[str], Optional[str]]:
"""Parses any header with the aim to retrieve any credentials from it.""" """Parses any header with the aim to retrieve any credentials from it."""
if not prefixes or not isinstance(prefixes, (list, tuple, set)): if not prefixes or not isinstance(prefixes, (list, tuple, set)):

View File

@@ -1,5 +1,7 @@
"""Defines basics of HTTP standard.""" """Defines basics of HTTP standard."""
import sys
from importlib import import_module from importlib import import_module
from inspect import ismodule from inspect import ismodule
from typing import Dict from typing import Dict
@@ -157,6 +159,10 @@ def import_string(module_name, package=None):
return obj() return obj()
def is_atty() -> bool:
return bool(sys.stdout and sys.stdout.isatty())
class Default: class Default:
""" """
It is used to replace `None` or `object()` as a sentinel It is used to replace `None` or `object()` as a sentinel

View File

@@ -5,7 +5,7 @@ from enum import Enum
from typing import TYPE_CHECKING, Any, Dict from typing import TYPE_CHECKING, Any, Dict
from warnings import warn 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. # Python 3.11 changed the way Enum formatting works for mixed-in types.

View File

@@ -38,3 +38,15 @@ class ExceptionMixin(metaclass=SanicMeta):
return handler return handler
return decorator 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)

View File

@@ -159,7 +159,11 @@ class RouteMixin(BaseMixin, metaclass=SanicMeta):
error_format, error_format,
route_context, 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) self._future_routes.add(route)
args = list(signature(handler).parameters.keys()) args = list(signature(handler).parameters.keys())
@@ -182,7 +186,7 @@ class RouteMixin(BaseMixin, metaclass=SanicMeta):
handler.is_stream = stream handler.is_stream = stream
if apply: if apply:
self._apply_route(route) self._apply_route(route, overwrite=overwrite)
if static: if static:
return route, handler return route, handler

View File

@@ -4,7 +4,7 @@ from typing import Any, Callable, Dict, Optional, Set, Union
from sanic.base.meta import SanicMeta from sanic.base.meta import SanicMeta
from sanic.models.futures import FutureSignal from sanic.models.futures import FutureSignal
from sanic.models.handler_types import SignalHandler from sanic.models.handler_types import SignalHandler
from sanic.signals import Signal from sanic.signals import Event, Signal
from sanic.types import HashableDict from sanic.types import HashableDict
@@ -80,3 +80,9 @@ class SignalMixin(metaclass=SanicMeta):
def event(self, event: str): def event(self, event: str):
raise NotImplementedError 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)

View File

@@ -16,7 +16,13 @@ from asyncio import (
from contextlib import suppress from contextlib import suppress
from functools import partial from functools import partial
from importlib import import_module 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 multiprocessing.context import BaseContext
from pathlib import Path from pathlib import Path
from socket import SHUT_RDWR, socket from socket import SHUT_RDWR, socket
@@ -25,6 +31,7 @@ from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Any, Any,
Callable, Callable,
ClassVar,
Dict, Dict,
List, List,
Mapping, Mapping,
@@ -41,9 +48,9 @@ from sanic.application.logo import get_logo
from sanic.application.motd import MOTD from sanic.application.motd import MOTD
from sanic.application.state import ApplicationServerInfo, Mode, ServerStage from sanic.application.state import ApplicationServerInfo, Mode, ServerStage
from sanic.base.meta import SanicMeta 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.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.constants import HTTP
from sanic.http.tls import get_ssl_context, process_to_context from sanic.http.tls import get_ssl_context, process_to_context
from sanic.http.tls.context import SanicSSLContext from sanic.http.tls.context import SanicSSLContext
@@ -81,13 +88,18 @@ else: # no cov
class StartupMixin(metaclass=SanicMeta): class StartupMixin(metaclass=SanicMeta):
_app_registry: Dict[str, Sanic] _app_registry: ClassVar[Dict[str, Sanic]]
name: str
config: Config config: Config
listeners: Dict[str, List[ListenerType[Any]]] listeners: Dict[str, List[ListenerType[Any]]]
state: ApplicationState state: ApplicationState
websocket_enabled: bool websocket_enabled: bool
multiplexer: WorkerMultiplexer 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): def setup_loop(self):
if not self.asgi: if not self.asgi:
@@ -594,6 +606,7 @@ class StartupMixin(metaclass=SanicMeta):
server = "ASGI" if self.asgi else "unknown" # type: ignore server = "ASGI" if self.asgi else "unknown" # type: ignore
display = { display = {
"app": self.name,
"mode": " ".join(mode), "mode": " ".join(mode),
"server": server, "server": server,
"python": platform.python_version(), "python": platform.python_version(),
@@ -691,11 +704,26 @@ class StartupMixin(metaclass=SanicMeta):
else "spawn" 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 @classmethod
def _get_context(cls) -> BaseContext: def _get_context(cls) -> BaseContext:
method = cls._get_startup_method() method = cls._get_startup_method()
logger.debug("Creating multiprocessing context using '%s'", 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 @classmethod
def serve( def serve(
@@ -705,6 +733,7 @@ class StartupMixin(metaclass=SanicMeta):
app_loader: Optional[AppLoader] = None, app_loader: Optional[AppLoader] = None,
factory: Optional[Callable[[], Sanic]] = None, factory: Optional[Callable[[], Sanic]] = None,
) -> None: ) -> None:
cls._set_startup_method()
os.environ["SANIC_MOTD_OUTPUT"] = "true" os.environ["SANIC_MOTD_OUTPUT"] = "true"
apps = list(cls._app_registry.values()) apps = list(cls._app_registry.values())
if factory: if factory:

View File

@@ -95,7 +95,7 @@ class StaticMixin(BaseMixin, metaclass=SanicMeta):
) )
try: try:
file_or_directory = Path(file_or_directory) file_or_directory = Path(file_or_directory).resolve()
except TypeError: except TypeError:
raise TypeError( raise TypeError(
"Static file or directory must be a path-like object or string" "Static file or directory must be a path-like object or string"

View File

@@ -1,5 +1,4 @@
import asyncio import asyncio
import sys
from typing import Any, Awaitable, Callable, MutableMapping, Optional, Union from typing import Any, Awaitable, Callable, MutableMapping, Optional, Union
@@ -16,20 +15,10 @@ ASGIReceive = Callable[[], Awaitable[ASGIMessage]]
class MockProtocol: # no cov class MockProtocol: # no cov
def __init__(self, transport: "MockTransport", loop): def __init__(self, transport: "MockTransport", loop):
# This should be refactored when < 3.8 support is dropped
self.transport = transport self.transport = transport
# Fixup for 3.8+; Sanic still supports 3.7 where loop is required self._not_paused = asyncio.Event()
loop = loop if sys.version_info[:2] < (3, 8) else None self._not_paused.set()
# Optional in 3.9, necessary in 3.10 because the parameter "loop" self._complete = asyncio.Event()
# 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)
def pause_writing(self) -> None: def pause_writing(self) -> None:
self._not_paused.clear() self._not_paused.clear()

View File

@@ -3,11 +3,12 @@ from typing import Any, Callable, Coroutine, Optional, TypeVar, Union
import sanic import sanic
from sanic.request import Request from sanic import request
from sanic.response import BaseHTTPResponse, HTTPResponse from sanic.response import BaseHTTPResponse, HTTPResponse
Sanic = TypeVar("Sanic", bound="sanic.Sanic") Sanic = TypeVar("Sanic", bound="sanic.Sanic")
Request = TypeVar("Request", bound="request.Request")
MiddlewareResponse = Union[ MiddlewareResponse = Union[
Optional[HTTPResponse], Coroutine[Any, Any, Optional[HTTPResponse]] Optional[HTTPResponse], Coroutine[Any, Any, Optional[HTTPResponse]]

View File

@@ -2,11 +2,13 @@ from __future__ import annotations
from contextvars import ContextVar from contextvars import ContextVar
from inspect import isawaitable from inspect import isawaitable
from types import SimpleNamespace
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Any, Any,
DefaultDict, DefaultDict,
Dict, Dict,
Generic,
List, List,
Optional, Optional,
Tuple, Tuple,
@@ -15,6 +17,7 @@ from typing import (
) )
from sanic_routing.route import Route from sanic_routing.route import Route
from typing_extensions import TypeVar
from sanic.http.constants import HTTP # type: ignore from sanic.http.constants import HTTP # type: ignore
from sanic.http.stream import Stream from sanic.http.stream import Stream
@@ -23,13 +26,13 @@ from sanic.models.http_types import Credentials
if TYPE_CHECKING: if TYPE_CHECKING:
from sanic.server import ConnInfo
from sanic.app import Sanic from sanic.app import Sanic
from sanic.config import Config
from sanic.server import ConnInfo
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from types import SimpleNamespace
from urllib.parse import parse_qs, parse_qsl, urlunparse from urllib.parse import parse_qs, parse_qsl, urlunparse
from httptools import parse_url from httptools import parse_url
@@ -68,8 +71,21 @@ try:
except ImportError: except ImportError:
from json import loads as json_loads # type: ignore 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. Properties of an HTTP request such as URL, headers, etc.
""" """
@@ -80,6 +96,7 @@ class Request:
__slots__ = ( __slots__ = (
"__weakref__", "__weakref__",
"_cookies", "_cookies",
"_ctx",
"_id", "_id",
"_ip", "_ip",
"_parsed_url", "_parsed_url",
@@ -96,7 +113,6 @@ class Request:
"app", "app",
"body", "body",
"conn_info", "conn_info",
"ctx",
"head", "head",
"headers", "headers",
"method", "method",
@@ -125,7 +141,7 @@ class Request:
version: str, version: str,
method: str, method: str,
transport: TransportProtocol, transport: TransportProtocol,
app: Sanic, app: sanic_type,
head: bytes = b"", head: bytes = b"",
stream_id: int = 0, stream_id: int = 0,
): ):
@@ -149,7 +165,7 @@ class Request:
# Init but do not inhale # Init but do not inhale
self.body = b"" self.body = b""
self.conn_info: Optional[ConnInfo] = None self.conn_info: Optional[ConnInfo] = None
self.ctx = SimpleNamespace() self._ctx: Optional[ctx_type] = None
self.parsed_accept: Optional[AcceptList] = None self.parsed_accept: Optional[AcceptList] = None
self.parsed_args: DefaultDict[ self.parsed_args: DefaultDict[
Tuple[bool, bool, str, str], RequestParameters Tuple[bool, bool, str, str], RequestParameters
@@ -176,6 +192,10 @@ class Request:
class_name = self.__class__.__name__ class_name = self.__class__.__name__
return f"<{class_name}: {self.method} {self.path}>" return f"<{class_name}: {self.method} {self.path}>"
@staticmethod
def make_context() -> ctx_type:
return cast(ctx_type, SimpleNamespace())
@classmethod @classmethod
def get_current(cls) -> Request: def get_current(cls) -> Request:
""" """
@@ -205,6 +225,15 @@ class Request:
def generate_id(*_): def generate_id(*_):
return uuid.uuid4() 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 @property
def stream_id(self): def stream_id(self):
""" """
@@ -809,19 +838,31 @@ class Request:
@property @property
def remote_addr(self) -> str: def remote_addr(self) -> str:
""" """
Client IP address, if available. Client IP address, if available from proxy.
1. proxied remote address `self.forwarded['for']`
2. local remote address `self.ip`
:return: IPv4, bracketed IPv6, UNIX socket name or arbitrary string :return: IPv4, bracketed IPv6, UNIX socket name or arbitrary string
:rtype: str :rtype: str
""" """
if not hasattr(self, "_remote_addr"): if not hasattr(self, "_remote_addr"):
self._remote_addr = str( self._remote_addr = str(self.forwarded.get("for", ""))
self.forwarded.get("for", "")
) # or self.ip
return self._remote_addr 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 @property
def scheme(self) -> str: def scheme(self) -> str:
""" """

View File

@@ -38,7 +38,9 @@ else:
try: 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: except ImportError:
# This is done in order to ensure that the JSON response is # This is done in order to ensure that the JSON response is
# kept consistent across both ujson and inbuilt json usage. # kept consistent across both ujson and inbuilt json usage.
@@ -345,7 +347,7 @@ class JSONResponse(HTTPResponse):
body: Optional[Any] = None, body: Optional[Any] = None,
status: int = 200, status: int = 200,
headers: Optional[Union[Header, Dict[str, str]]] = None, headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None, content_type: str = "application/json",
dumps: Optional[Callable[..., str]] = None, dumps: Optional[Callable[..., str]] = None,
**kwargs: Any, **kwargs: Any,
): ):
@@ -520,7 +522,9 @@ class ResponseStream:
headers: Optional[Union[Header, Dict[str, str]]] = None, headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[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) headers = Header(headers)
self.streaming_fn = streaming_fn self.streaming_fn = streaming_fn
self.status = status self.status = status

View File

@@ -75,11 +75,12 @@ class Router(BaseRouter):
strict_slashes: bool = False, strict_slashes: bool = False,
stream: bool = False, stream: bool = False,
ignore_body: bool = False, ignore_body: bool = False,
version: Union[str, float, int] = None, version: Optional[Union[str, float, int]] = None,
name: Optional[str] = None, name: Optional[str] = None,
unquote: bool = False, unquote: bool = False,
static: bool = False, static: bool = False,
version_prefix: str = "/v", version_prefix: str = "/v",
overwrite: bool = False,
error_format: Optional[str] = None, error_format: Optional[str] = None,
) -> Union[Route, List[Route]]: ) -> Union[Route, List[Route]]:
""" """
@@ -122,6 +123,7 @@ class Router(BaseRouter):
name=name, name=name,
strict=strict_slashes, strict=strict_slashes,
unquote=unquote, unquote=unquote,
overwrite=overwrite,
) )
if isinstance(host, str): if isinstance(host, str):

View File

@@ -1,7 +1,5 @@
from __future__ import annotations from __future__ import annotations
import sys
from ssl import SSLContext from ssl import SSLContext
from typing import TYPE_CHECKING, Dict, Optional, Type, Union 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)) loop.run_until_complete(asyncio.sleep(0.1))
start_shutdown = start_shutdown + 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 # Force close non-idle connection after waiting for
# graceful_shutdown_timeout # graceful_shutdown_timeout

View File

@@ -96,6 +96,7 @@ class WebsocketFrameAssembler:
If ``timeout`` is set and elapses before a complete message is If ``timeout`` is set and elapses before a complete message is
received, :meth:`get` returns ``None``. received, :meth:`get` returns ``None``.
""" """
completed: bool
async with self.read_mutex: async with self.read_mutex:
if timeout is not None and timeout <= 0: if timeout is not None and timeout <= 0:
if not self.message_complete.is_set(): if not self.message_complete.is_set():

View File

@@ -21,7 +21,7 @@ from websockets.frames import Frame, Opcode
try: # websockets < 11.0 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 from websockets.server import ServerConnection as ServerProtocol
except ImportError: # websockets >= 11.0 except ImportError: # websockets >= 11.0
from websockets.protocol import Event, State # type: ignore from websockets.protocol import Event, State # type: ignore

View File

@@ -16,6 +16,7 @@ from sanic.models.handler_types import SignalHandler
class Event(Enum): class Event(Enum):
SERVER_EXCEPTION_REPORT = "server.exception.report"
SERVER_INIT_AFTER = "server.init.after" SERVER_INIT_AFTER = "server.init.after"
SERVER_INIT_BEFORE = "server.init.before" SERVER_INIT_BEFORE = "server.init.before"
SERVER_SHUTDOWN_AFTER = "server.shutdown.after" SERVER_SHUTDOWN_AFTER = "server.shutdown.after"
@@ -39,6 +40,7 @@ class Event(Enum):
RESERVED_NAMESPACES = { RESERVED_NAMESPACES = {
"server": ( "server": (
Event.SERVER_EXCEPTION_REPORT.value,
Event.SERVER_INIT_AFTER.value, Event.SERVER_INIT_AFTER.value,
Event.SERVER_INIT_BEFORE.value, Event.SERVER_INIT_BEFORE.value,
Event.SERVER_SHUTDOWN_AFTER.value, Event.SERVER_SHUTDOWN_AFTER.value,
@@ -168,6 +170,17 @@ class SignalRouter(BaseRouter):
elif maybe_coroutine: elif maybe_coroutine:
return maybe_coroutine return maybe_coroutine
return None 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: finally:
for signal_event in events: for signal_event in events:
signal_event.clear() signal_event.clear()
@@ -217,14 +230,6 @@ class SignalRouter(BaseRouter):
if not trigger: if not trigger:
event = ".".join([*parts[:2], "<__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( signal = super().add(
event, event,
handler, handler,

View File

@@ -83,10 +83,7 @@ class Inspector:
async def _respond(self, request: Request, output: Any): async def _respond(self, request: Request, output: Any):
name = request.match_info.get("action", "info") name = request.match_info.get("action", "info")
return json( return json({"meta": {"action": name}, "result": output})
{"meta": {"action": name}, "result": output},
escape_forward_slashes=False,
)
def _state_to_json(self) -> Dict[str, Any]: def _state_to_json(self) -> Dict[str, Any]:
output = {"info": self.app_info} output = {"info": self.app_info}

View File

@@ -83,12 +83,11 @@ setup_kwargs = {
"packages": find_packages(exclude=("tests", "tests.*")), "packages": find_packages(exclude=("tests", "tests.*")),
"package_data": {"sanic": ["py.typed", "pages/styles/*"]}, "package_data": {"sanic": ["py.typed", "pages/styles/*"]},
"platforms": "any", "platforms": "any",
"python_requires": ">=3.7", "python_requires": ">=3.8",
"classifiers": [ "classifiers": [
"Development Status :: 4 - Beta", "Development Status :: 4 - Beta",
"Environment :: Web Environment", "Environment :: Web Environment",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
@@ -104,7 +103,7 @@ ujson = "ujson>=1.35" + env_dependency
uvloop = "uvloop>=0.15.0" + env_dependency uvloop = "uvloop>=0.15.0" + env_dependency
types_ujson = "types-ujson" + env_dependency types_ujson = "types-ujson" + env_dependency
requirements = [ requirements = [
"sanic-routing>=22.8.0", "sanic-routing>=23.6.0",
"httptools>=0.0.10", "httptools>=0.0.10",
uvloop, uvloop,
ujson, ujson,
@@ -113,10 +112,11 @@ requirements = [
"multidict>=5.0,<7.0", "multidict>=5.0,<7.0",
"html5tagger>=1.2.1", "html5tagger>=1.2.1",
"tracerite>=1.0.0", "tracerite>=1.0.0",
"typing-extensions>=4.4.0",
] ]
tests_require = [ tests_require = [
"sanic-testing>=23.3.0", "sanic-testing>=23.6.0",
"pytest==7.1.*", "pytest==7.1.*",
"coverage", "coverage",
"beautifulsoup4", "beautifulsoup4",
@@ -127,7 +127,7 @@ tests_require = [
"black", "black",
"isort>=5.0.0", "isort>=5.0.0",
"bandit", "bandit",
"mypy>=0.901,<0.910", "mypy",
"docutils", "docutils",
"pygments", "pygments",
"uvicorn<0.15.0", "uvicorn<0.15.0",

View File

@@ -293,7 +293,7 @@ def test_handle_request_with_nested_sanic_exception(
def test_app_name_required(): def test_app_name_required():
with pytest.raises(SanicException): with pytest.raises(TypeError):
Sanic() Sanic()

View File

@@ -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 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_bp4.handle_request" in route_names
assert "test_bp_copy.test_bp5.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 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"

View File

@@ -1,3 +1,5 @@
import pytest
from pytest import raises from pytest import raises
from sanic.app import Sanic from sanic.app import Sanic
@@ -340,3 +342,40 @@ def test_nested_bp_group_properties():
routes = [route.path for route in app.router.routes] routes = [route.path for route in app.router.routes]
assert routes == ["three/one/four"] 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",
]

View File

@@ -2,6 +2,8 @@ import logging
import pytest import pytest
import sanic
from sanic import Sanic from sanic import Sanic
from sanic.config import Config from sanic.config import Config
from sanic.errorpages import TextRenderer, exception_response, guess_mime 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" 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): def test_unknown_fallback_format(app):
with pytest.raises(SanicException, match="Unknown format: bad"): with pytest.raises(SanicException, match="Unknown format: bad"):
app.config.FALLBACK_ERROR_FORMAT = "bad" app.config.FALLBACK_ERROR_FORMAT = "bad"
@@ -527,3 +550,26 @@ def test_guess_mime_logging(
] ]
assert logmsg == expected 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

View File

@@ -17,6 +17,7 @@ from sanic.response import text
CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True} CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True}
PORT = 42001 # test_keep_alive_timeout_reuse doesn't work with random port PORT = 42001 # test_keep_alive_timeout_reuse doesn't work with random port
MAX_LOOPS = 15
port_counter = count() 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 """If the server keep-alive timeout and client keep-alive timeout are
both longer than the delay, the client _and_ server will successfully both longer than the delay, the client _and_ server will successfully
reuse the existing connection.""" reuse the existing connection."""
port = get_port() loops = 0
loop = asyncio.new_event_loop() while True:
asyncio.set_event_loop(loop) port = get_port()
client = ReusableClient(keep_alive_timeout_app_reuse, loop=loop, port=port) loop = asyncio.new_event_loop()
with client: asyncio.set_event_loop(loop)
headers = {"Connection": "keep-alive"} client = ReusableClient(
request, response = client.get("/1", headers=headers) keep_alive_timeout_app_reuse, loop=loop, port=port
assert response.status == 200 )
assert response.text == "OK" try:
assert request.protocol.state["requests_count"] == 1 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") request, response = client.get("/1")
assert response.status == 200 assert response.status == 200
assert response.text == "OK" assert response.text == "OK"
assert request.protocol.state["requests_count"] == 2 assert request.protocol.state["requests_count"] == 2
except OSError:
loops += 1
if loops > MAX_LOOPS:
raise
continue
else:
break
@pytest.mark.skipif( @pytest.mark.skipif(
@@ -97,23 +110,35 @@ def test_keep_alive_timeout_reuse():
def test_keep_alive_client_timeout(): def test_keep_alive_client_timeout():
"""If the server keep-alive timeout is longer than the client """If the server keep-alive timeout is longer than the client
keep-alive timeout, client will try to create a new connection here.""" keep-alive timeout, client will try to create a new connection here."""
port = get_port() loops = 0
loop = asyncio.new_event_loop() while True:
asyncio.set_event_loop(loop) try:
client = ReusableClient( port = get_port()
keep_alive_app_client_timeout, loop=loop, port=port loop = asyncio.new_event_loop()
) asyncio.set_event_loop(loop)
with client: client = ReusableClient(
headers = {"Connection": "keep-alive"} keep_alive_app_client_timeout, loop=loop, port=port
request, response = client.get("/1", headers=headers, timeout=1) )
with client:
headers = {"Connection": "keep-alive"}
request, response = client.get(
"/1", headers=headers, timeout=1
)
assert response.status == 200 assert response.status == 200
assert response.text == "OK" assert response.text == "OK"
assert request.protocol.state["requests_count"] == 1 assert request.protocol.state["requests_count"] == 1
loop.run_until_complete(aio_sleep(2)) loop.run_until_complete(aio_sleep(2))
request, response = client.get("/1", timeout=1) request, response = client.get("/1", timeout=1)
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( @pytest.mark.skipif(
@@ -125,24 +150,36 @@ def test_keep_alive_server_timeout():
keep-alive timeout, the client will either a 'Connection reset' error keep-alive timeout, the client will either a 'Connection reset' error
_or_ a new connection. Depending on how the event-loop handles the _or_ a new connection. Depending on how the event-loop handles the
broken server connection.""" broken server connection."""
port = get_port() loops = 0
loop = asyncio.new_event_loop() while True:
asyncio.set_event_loop(loop) try:
client = ReusableClient( port = get_port()
keep_alive_app_server_timeout, loop=loop, port=port loop = asyncio.new_event_loop()
) asyncio.set_event_loop(loop)
with client: client = ReusableClient(
headers = {"Connection": "keep-alive"} keep_alive_app_server_timeout, loop=loop, port=port
request, response = client.get("/1", headers=headers, timeout=60) )
with client:
headers = {"Connection": "keep-alive"}
request, response = client.get(
"/1", headers=headers, timeout=60
)
assert response.status == 200 assert response.status == 200
assert response.text == "OK" assert response.text == "OK"
assert request.protocol.state["requests_count"] == 1 assert request.protocol.state["requests_count"] == 1
loop.run_until_complete(aio_sleep(3)) loop.run_until_complete(aio_sleep(3))
request, response = client.get("/1", timeout=60) 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( @pytest.mark.skipif(
@@ -150,20 +187,34 @@ def test_keep_alive_server_timeout():
reason="Not testable with current client", reason="Not testable with current client",
) )
def test_keep_alive_connection_context(): def test_keep_alive_connection_context():
port = get_port() loops = 0
loop = asyncio.new_event_loop() while True:
asyncio.set_event_loop(loop) try:
client = ReusableClient(keep_alive_app_context, loop=loop, port=port) port = get_port()
with client: loop = asyncio.new_event_loop()
headers = {"Connection": "keep-alive"} asyncio.set_event_loop(loop)
request1, _ = client.post("/ctx", headers=headers) 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)) loop.run_until_complete(aio_sleep(1))
request2, response = client.get("/ctx") request2, response = client.get("/ctx")
assert response.text == "hello" assert response.text == "hello"
assert id(request1.conn_info.ctx) == id(request2.conn_info.ctx) assert id(request1.conn_info.ctx) == id(request2.conn_info.ctx)
assert ( assert (
request1.conn_info.ctx.foo == request2.conn_info.ctx.foo == "hello" request1.conn_info.ctx.foo
) == request2.conn_info.ctx.foo
assert request2.protocol.state["requests_count"] == 2 == "hello"
)
assert request2.protocol.state["requests_count"] == 2
except OSError:
loops += 1
if loops > MAX_LOOPS:
raise
continue
else:
break

View File

@@ -31,10 +31,11 @@ def test_motd_with_expected_info(app, run_startup):
logs = run_startup(app) logs = run_startup(app)
assert logs[1][2] == f"Sanic v{__version__}" assert logs[1][2] == f"Sanic v{__version__}"
assert logs[3][2] == "mode: debug, single worker" assert logs[3][2] == "app: test_motd_with_expected_info"
assert logs[4][2] == "server: sanic, HTTP/1.1" assert logs[4][2] == "mode: debug, single worker"
assert logs[5][2] == f"python: {platform.python_version()}" assert logs[5][2] == "server: sanic, HTTP/1.1"
assert logs[6][2] == f"platform: {platform.platform()}" assert logs[6][2] == f"python: {platform.python_version()}"
assert logs[7][2] == f"platform: {platform.platform()}"
def test_motd_init(): def test_motd_init():
@@ -61,7 +62,7 @@ def test_motd_display(caplog):
│ │ │ │
├───────────────────────┬────────┤ ├───────────────────────┬────────┤
│ foobar │ one: 1 │ │ foobar │ one: 1 │
| ├────────┤ ├────────┤
│ │ two: 2 │ │ │ two: 2 │
└───────────────────────┴────────┘ └───────────────────────┴────────┘
""" """

View File

@@ -126,7 +126,7 @@ def test_redirect_with_params(app, test_str):
@app.route("/api/v2/test/<test>/", unquote=True) @app.route("/api/v2/test/<test>/", unquote=True)
async def target_handler(request, test): async def target_handler(request, test):
assert test == test_str assert test == quote(test_str)
return text("OK") return text("OK")
_, response = app.test_client.get(f"/api/v1/test/{use_in_uri}/") _, response = app.test_client.get(f"/api/v1/test/{use_in_uri}/")

View File

@@ -310,3 +310,29 @@ def test_request_idempotent(method, idempotent):
def test_request_cacheable(method, cacheable): def test_request_cacheable(method, cacheable):
request = Request(b"/", {}, None, method, None, None) request = Request(b"/", {}, None, method, None, None)
assert request.is_cacheable is cacheable 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"]

View File

@@ -513,6 +513,7 @@ def test_standard_forwarded(app):
request, response = app.test_client.get("/", headers=headers) request, response = app.test_client.get("/", headers=headers)
assert response.json == {"for": "127.0.0.2", "proto": "ws"} assert response.json == {"for": "127.0.0.2", "proto": "ws"}
assert request.remote_addr == "127.0.0.2" assert request.remote_addr == "127.0.0.2"
assert request.client_ip == "127.0.0.2"
assert request.scheme == "ws" assert request.scheme == "ws"
assert request.server_name == "local.site" assert request.server_name == "local.site"
assert request.server_port == 80 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"} headers = {"X-Forwarded-For": "127.0.1.1"}
request, response = app.test_client.get("/", headers=headers) request, response = app.test_client.get("/", headers=headers)
assert request.remote_addr == "" assert request.remote_addr == ""
assert request.client_ip == "127.0.0.1"
assert response.body == b"" assert response.body == b""
headers = {"X-Forwarded-For": "127.0.0.1, 127.0.1.2"} headers = {"X-Forwarded-For": "127.0.0.1, 127.0.1.2"}

View File

@@ -23,6 +23,7 @@ from sanic.compat import Header
from sanic.cookies import CookieJar from sanic.cookies import CookieJar
from sanic.response import ( from sanic.response import (
HTTPResponse, HTTPResponse,
ResponseStream,
empty, empty,
file, file,
file_stream, file_stream,
@@ -943,3 +944,17 @@ def test_file_validating_304_response(
) )
assert response.status == 304 assert response.status == 304
assert response.body == b"" 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"

View File

@@ -213,3 +213,12 @@ def test_pop_list(json_app: Sanic):
_, resp = json_app.test_client.get("/json-pop") _, resp = json_app.test_client.get("/json-pop")
assert resp.body == json_dumps(["b"]).encode() 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"

View File

@@ -4,15 +4,18 @@ import signal
from queue import Queue from queue import Queue
from types import SimpleNamespace from types import SimpleNamespace
from typing import Optional
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
from sanic_testing.testing import HOST, PORT from sanic_testing.testing import HOST, PORT
from sanic import Sanic
from sanic.compat import ctrlc_workaround_for_windows 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.response import HTTPResponse
from sanic.signals import Event
async def stop(app, loop): async def stop(app, loop):
@@ -148,3 +151,26 @@ def test_signals_with_invalid_invocation(app):
BadRequest, match="Invalid event registration: Missing event name" BadRequest, match="Invalid event registration: Missing event name"
): ):
app.listener(stop) 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"

View File

@@ -2,6 +2,7 @@ import asyncio
from enum import Enum from enum import Enum
from inspect import isawaitable from inspect import isawaitable
from itertools import count
import pytest import pytest
@@ -9,6 +10,7 @@ from sanic_routing.exceptions import NotFound
from sanic import Blueprint, Sanic, empty from sanic import Blueprint, Sanic, empty
from sanic.exceptions import InvalidSignal, SanicException from sanic.exceptions import InvalidSignal, SanicException
from sanic.signals import Event
def test_add_signal(app): def test_add_signal(app):
@@ -427,3 +429,114 @@ def test_signal_reservation(app, event, expected):
app.signal(event)(lambda: ...) app.signal(event)(lambda: ...)
else: else:
app.signal(event)(lambda: ...) 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

View File

@@ -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) 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( @pytest.mark.parametrize(
"file_name", "file_name",
[b"test.file", b"decode me.txt", b"python.png"], [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]) counter = Counter([(r[0], r[1]) for r in caplog.record_tuples])
assert response.status == 404 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.root", logging.ERROR)] == 0
assert counter[("sanic.error", logging.ERROR)] == 0 assert counter[("sanic.error", logging.ERROR)] == 0
assert counter[("sanic.server", logging.INFO)] == 2 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]) counter = Counter([(r[0], r[1]) for r in caplog.record_tuples])
assert response.status == 404 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.root", logging.ERROR)] == 0
assert counter[("sanic.error", logging.ERROR)] == 0 assert counter[("sanic.error", logging.ERROR)] == 0
assert counter[("sanic.server", logging.INFO)] == 2 assert counter[("sanic.server", logging.INFO)] == 2

View 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)

View File

@@ -0,0 +1,9 @@
from sanic import Sanic
class Foo:
pass
app = Sanic("test", ctx=Foo())
reveal_type(app)

View File

@@ -0,0 +1,5 @@
from sanic import Sanic
app = Sanic("test")
reveal_type(app)

View 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)

View 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)

View 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)

View 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
View 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}"'

View File

@@ -1,14 +1,14 @@
[tox] [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] [testenv]
usedevelop = true usedevelop = true
setenv = setenv =
{py37,py38,py39,py310,py311,pyNightly}-no-ext: SANIC_NO_UJSON=1 {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_UVLOOP=1
extras = test, http3 extras = test, http3
deps = deps =
httpx==0.23 httpx>=0.23
allowlist_externals = allowlist_externals =
pytest pytest
coverage coverage