Compare commits

..

16 Commits

Author SHA1 Message Date
L. Kärkkäinen
fd2e4819d1 Merge branch 'main' into accept-enhance 2023-02-05 18:53:34 +00:00
L. Karkkainen
0e024b46d9 black 2023-02-05 16:40:04 +00:00
L. Karkkainen
eae58e5d2a Minor cleanup. 2023-02-05 16:37:14 +00:00
L. Karkkainen
6472a69fbf More specific naming: mime is simple str, media_type may have q and raw is header component. 2023-02-05 16:28:32 +00:00
L. Karkkainen
2e2231919c Updated/removed tests due toe accept/mediatype complete API and semantics change. 2023-01-30 02:24:12 +00:00
L. Karkkainen
8da10a9c0c Compatibility with older version. 2023-01-30 02:23:26 +00:00
L. Karkkainen
ec25581262 Accept header choose() function removed and replaced by a more versatile match(). 2023-01-30 01:04:14 +00:00
L. Karkkainen
b8ae4285a4 Move all errorpages work to another branch error-format-redux. 2023-01-29 03:11:27 +00:00
L. Karkkainen
c0ca55530e Add back JSON detection by request body, but to be deprecated. 2023-01-29 03:03:26 +00:00
L. Karkkainen
52ecbb9dc7 Note that base renderer can be changed. 2023-01-29 03:01:26 +00:00
L. Karkkainen
3ef99568a5 Refactor acceptable check to a helper function. 2023-01-29 03:00:04 +00:00
L. Karkkainen
dfe2148333 Remove dubious or unnecessary handler types of response mapping. 2023-01-29 01:59:24 +00:00
L. Karkkainen
7909f673e5 Handle empty/missing accept header more directly 2023-01-29 01:52:53 +00:00
L. Karkkainen
e35286e332 Rethinking of renderer selection logic, cleanup. 2023-01-29 01:43:40 +00:00
L. Karkkainen
8eeb1c20dc Unfinished hacks, moving to another machine. 2023-01-29 00:04:39 +00:00
Adam Hopkins
43c9a0a49b Additional accept functionality 2023-01-25 00:13:44 +02:00
135 changed files with 1957 additions and 5107 deletions

View File

@@ -21,14 +21,7 @@ body:
id: code id: code
attributes: attributes:
label: Code snippet label: Code snippet
description: | description: Relevant source code, make sure to remove what is not necessary.
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
@@ -49,16 +42,11 @@ body:
- ASGI - ASGI
validations: validations:
required: true required: true
- type: dropdown - type: input
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

@@ -1,33 +0,0 @@
name: 💡 Request for Comments
description: Open an RFC for discussion
labels: ["RFC"]
body:
- type: input
id: compare
attributes:
label: Link to code
description: If available, share a [comparison](https://github.com/sanic-org/sanic/compare) from a POC branch to main
placeholder: https://github.com/sanic-org/sanic/compare/main...some-new-branch
validations:
required: false
- type: textarea
id: proposal
attributes:
label: Proposal
description: A thorough discussion of the proposal discussing the problem it solves, potential code, use cases, and impacts
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional context
description: Add any other context that is relevant
validations:
required: false
- type: checkboxes
id: breaking
attributes:
label: Is this a breaking change?
options:
- label: "Yes"
required: false

View File

@@ -4,12 +4,10 @@ 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,14 +3,12 @@ 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,7 +3,6 @@ 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]
@@ -17,6 +16,7 @@ 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,7 +3,6 @@ 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,7 +3,6 @@ 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: "pypy310" default: "pypy37"
pypy-version: pypy-version:
description: "Version of PyPy to use" description: "Version of PyPy to use"
required: false required: false
default: "pypy-3.10" default: "pypy-3.7"
jobs: jobs:
testPyPy: testPyPy:
name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }} name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }}

View File

@@ -3,7 +3,6 @@ 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,7 +3,6 @@ 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]

35
.github/workflows/pr-python37.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
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,7 +3,6 @@ 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,7 +3,6 @@ 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,7 +3,6 @@ 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]
@@ -17,6 +16,7 @@ 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,7 +3,6 @@ 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,10 +15,12 @@ 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.8", "3.9", "3.10", "3.11"] python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
steps: steps:
- name: Checkout repository - name: Checkout repository

View File

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

View File

@@ -316,6 +316,8 @@ Version 21.3.0
Version 20.12.3 Version 20.12.3
--------------- ---------------
`Current LTS version`
**Bugfixes** **Bugfixes**
* *

View File

@@ -1,12 +1,6 @@
📜 Changelog 📜 Changelog
============ ============
| 🔶 Current release
| 🔷 In support release
|
.. mdinclude:: ./releases/23/23.6.md
.. mdinclude:: ./releases/23/23.3.md
.. mdinclude:: ./releases/22/22.12.md .. mdinclude:: ./releases/22/22.12.md
.. mdinclude:: ./releases/22/22.9.md .. mdinclude:: ./releases/22/22.9.md
.. mdinclude:: ./releases/22/22.6.md .. mdinclude:: ./releases/22/22.6.md

View File

@@ -1,4 +1,4 @@
## Version 22.12.0 🔷 ## Version 22.12.0 🔶
_Current version_ _Current version_

View File

@@ -1,53 +0,0 @@
## Version 23.3.0
### Features
- [#2545](https://github.com/sanic-org/sanic/pull/2545) Standardize init of exceptions for more consistent control of HTTP responses using exceptions
- [#2606](https://github.com/sanic-org/sanic/pull/2606) Decode headers as UTF-8 also in ASGI
- [#2646](https://github.com/sanic-org/sanic/pull/2646) Separate ASGI request and lifespan callables
- [#2659](https://github.com/sanic-org/sanic/pull/2659) Use ``FALLBACK_ERROR_FORMAT`` for handlers that return ``empty()``
- [#2662](https://github.com/sanic-org/sanic/pull/2662) Add basic file browser (HTML page) and auto-index serving
- [#2667](https://github.com/sanic-org/sanic/pull/2667) Nicer traceback formatting (HTML page)
- [#2668](https://github.com/sanic-org/sanic/pull/2668) Smarter error page rendering format selection; more reliant upon header and "common sense" defaults
- [#2680](https://github.com/sanic-org/sanic/pull/2680) Check the status of socket before shutting down with ``SHUT_RDWR``
- [#2687](https://github.com/sanic-org/sanic/pull/2687) Refresh ``Request.accept`` functionality to be more performant and spec-compliant
- [#2696](https://github.com/sanic-org/sanic/pull/2696) Add header accessors as properties
```
Example-Field: Foo, Bar
Example-Field: Baz
```
```python
request.headers.example_field == "Foo, Bar,Baz"
```
- [#2700](https://github.com/sanic-org/sanic/pull/2700) Simpler CLI targets
```sh
$ sanic path.to.module:app # global app instance
$ sanic path.to.module:create_app # factory pattern
$ sanic ./path/to/directory/ # simple serve
```
- [#2701](https://github.com/sanic-org/sanic/pull/2701) API to define a number of workers in managed processes
- [#2704](https://github.com/sanic-org/sanic/pull/2704) Add convenience for dynamic changes to routing
- [#2706](https://github.com/sanic-org/sanic/pull/2706) Add convenience methods for cookie creation and deletion
```python
response = text("...")
response.add_cookie("test", "It worked!", domain=".yummy-yummy-cookie.com")
```
- [#2707](https://github.com/sanic-org/sanic/pull/2707) Simplified ``parse_content_header`` escaping to be RFC-compliant and remove outdated FF hack
- [#2710](https://github.com/sanic-org/sanic/pull/2710) Stricter charset handling and escaping of request URLs
- [#2711](https://github.com/sanic-org/sanic/pull/2711) Consume body on ``DELETE`` by default
- [#2719](https://github.com/sanic-org/sanic/pull/2719) Allow ``password`` to be passed to TLS context
- [#2720](https://github.com/sanic-org/sanic/pull/2720) Skip middleware on ``RequestCancelled``
- [#2721](https://github.com/sanic-org/sanic/pull/2721) Change access logging format to ``%s``
- [#2722](https://github.com/sanic-org/sanic/pull/2722) Add ``CertLoader`` as application option for directly controlling ``SSLContext`` objects
- [#2725](https://github.com/sanic-org/sanic/pull/2725) Worker sync state tolerance on race condition
### Bugfixes
- [#2651](https://github.com/sanic-org/sanic/pull/2651) ASGI websocket to pass thru bytes as is
- [#2697](https://github.com/sanic-org/sanic/pull/2697) Fix comparison between datetime aware and naive in ``file`` when using ``If-Modified-Since``
### Deprecations and Removals
- [#2666](https://github.com/sanic-org/sanic/pull/2666) Remove deprecated ``__blueprintname__`` property
### Improved Documentation
- [#2712](https://github.com/sanic-org/sanic/pull/2712) Improved example using ``'https'`` to create the redirect

View File

@@ -1,33 +0,0 @@
## 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,5 +50,4 @@ def pop_handler(request):
app.blueprint(bp, url_prefix="/bp") app.blueprint(bp, url_prefix="/bp")
if __name__ == "__main__": app.run(host="0.0.0.0", port=8000, debug=True, auto_reload=False)
app.run(host="0.0.0.0", port=8000, debug=True, auto_reload=False)

View File

@@ -37,5 +37,4 @@ app.blueprint(blueprint)
app.blueprint(blueprint2) app.blueprint(blueprint2)
app.blueprint(blueprint3) app.blueprint(blueprint3)
if __name__ == "__main__": app.run(host="0.0.0.0", port=9999, debug=True)
app.run(host="0.0.0.0", port=9999, debug=True)

View File

@@ -29,7 +29,7 @@ def proxy(request, path):
path=path, path=path,
_server=https.config.SERVER_NAME, _server=https.config.SERVER_NAME,
_external=True, _external=True,
_scheme="https", _scheme="http",
) )
return response.redirect(url) return response.redirect(url)
@@ -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,5 +39,4 @@ async def test(request):
return json(response) return json(response)
if __name__ == "__main__": app.run(host="0.0.0.0", port=8000, workers=2)
app.run(host="0.0.0.0", port=8000, workers=2)

View File

@@ -20,5 +20,4 @@ def test(request):
return text("hey") return text("hey")
if __name__ == "__main__": app.run(host="0.0.0.0", port=8000)
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,5 +20,4 @@ def timeout(request, exception):
return response.text("RequestTimeout from error_handler.", 408) return response.text("RequestTimeout from error_handler.", 408)
if __name__ == "__main__": app.run(host="0.0.0.0", port=8000)
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())
# When using app.run(), this actually triggers before the serv_coro. asyncio.set_event_loop(uvloop.new_event_loop())
# But, in this example, we are using the convenience method, even if it is serv_coro = app.create_server(
# out of order. host="0.0.0.0", port=8000, return_asyncio_server=True
loop.run_until_complete(server.before_start()) )
loop.run_until_complete(server.after_start()) loop = asyncio.get_event_loop()
try: serv_task = asyncio.ensure_future(serv_coro, loop=loop)
loop.run_forever() signal(SIGINT, lambda s, f: loop.stop())
except KeyboardInterrupt: server: AsyncioServer = loop.run_until_complete(serv_task)
loop.stop() loop.run_until_complete(server.startup())
finally:
loop.run_until_complete(server.before_stop())
# Wait for server to close # When using app.run(), this actually triggers before the serv_coro.
close_task = server.close() # But, in this example, we are using the convenience method, even if it is
loop.run_until_complete(close_task) # out of order.
loop.run_until_complete(server.before_start())
loop.run_until_complete(server.after_start())
try:
loop.run_forever()
except KeyboardInterrupt:
loop.stop()
finally:
loop.run_until_complete(server.before_stop())
# Complete all tasks on the loop # Wait for server to close
for connection in server.connections: close_task = server.close()
connection.close_if_idle() loop.run_until_complete(close_task)
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", "wheel"] requires = ["setuptools<60.0", "wheel"]
build-backend = "setuptools.build_meta" build-backend = "setuptools.build_meta"
[tool.black] [tool.black]
@@ -24,6 +24,5 @@ module = [
"sanic_routing.*", "sanic_routing.*",
"aioquic.*", "aioquic.*",
"html5tagger.*", "html5tagger.*",
"tracerite.*",
] ]
ignore_missing_imports = true ignore_missing_imports = true

View File

@@ -1,28 +1,7 @@
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 (
BadRequest,
ExpectationFailed,
FileNotFound,
Forbidden,
HeaderNotFound,
InternalServerError,
InvalidHeader,
MethodNotAllowed,
NotFound,
RangeNotSatisfiable,
SanicException,
ServerError,
ServiceUnavailable,
Unauthorized,
)
from sanic.request import Request from sanic.request import Request
from sanic.response import ( from sanic.response import (
HTTPResponse, HTTPResponse,
@@ -30,57 +9,24 @@ from sanic.response import (
file, file,
html, html,
json, json,
raw,
redirect, redirect,
text, text,
) )
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
"Sanic", "Sanic",
"Config",
"Blueprint", "Blueprint",
"HTTPMethod", "HTTPMethod",
"HTTPResponse", "HTTPResponse",
"Request", "Request",
"Websocket", "Websocket",
# Common types
"DefaultSanic",
"DefaultRequest",
# Common exceptions
"BadRequest",
"ExpectationFailed",
"FileNotFound",
"Forbidden",
"HeaderNotFound",
"InternalServerError",
"InvalidHeader",
"MethodNotAllowed",
"NotFound",
"RangeNotSatisfiable",
"SanicException",
"ServerError",
"ServiceUnavailable",
"Unauthorized",
# Common response methods
"empty", "empty",
"file", "file",
"html", "html",
"json", "json",
"raw",
"redirect", "redirect",
"text", "text",
) )

View File

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

View File

@@ -16,8 +16,8 @@ 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 suppress
from functools import partial, wraps from functools import partial
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,13 +29,10 @@ from typing import (
AnyStr, AnyStr,
Awaitable, Awaitable,
Callable, Callable,
ClassVar,
Coroutine, Coroutine,
Deque, Deque,
Dict, Dict,
Generic,
Iterable, Iterable,
Iterator,
List, List,
Optional, Optional,
Set, Set,
@@ -43,8 +40,6 @@ from typing import (
Type, Type,
TypeVar, TypeVar,
Union, Union,
cast,
overload,
) )
from urllib.parse import urlencode, urlunparse from urllib.parse import urlencode, urlunparse
@@ -53,7 +48,7 @@ from sanic_routing.route import Route
from sanic.application.ext import setup_ext from sanic.application.ext import setup_ext
from sanic.application.state import ApplicationState, ServerStage from sanic.application.state import ApplicationState, ServerStage
from sanic.asgi import ASGIApp, Lifespan from sanic.asgi import ASGIApp
from sanic.base.root import BaseSanic from sanic.base.root import BaseSanic
from sanic.blueprint_group import BlueprintGroup from sanic.blueprint_group import BlueprintGroup
from sanic.blueprints import Blueprint from sanic.blueprints import Blueprint
@@ -68,7 +63,12 @@ from sanic.exceptions import (
from sanic.handlers import ErrorHandler from sanic.handlers import ErrorHandler
from sanic.helpers import Default, _default from sanic.helpers import Default, _default
from sanic.http import Stage from sanic.http import Stage
from sanic.log import LOGGING_CONFIG_DEFAULTS, error_logger, logger from sanic.log import (
LOGGING_CONFIG_DEFAULTS,
deprecation,
error_logger,
logger,
)
from sanic.middleware import Middleware, MiddlewareLocation from sanic.middleware import Middleware, MiddlewareLocation
from sanic.mixins.listeners import ListenerEvent from sanic.mixins.listeners import ListenerEvent
from sanic.mixins.startup import StartupMixin from sanic.mixins.startup import StartupMixin
@@ -87,11 +87,10 @@ 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 Event, Signal, SignalRouter from sanic.signals import 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
from sanic.worker.loader import CertLoader
from sanic.worker.manager import WorkerManager from sanic.worker.manager import WorkerManager
@@ -106,17 +105,8 @@ 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
""" """
@@ -129,7 +119,6 @@ class Sanic(
) )
__slots__ = ( __slots__ = (
"_asgi_app", "_asgi_app",
"_asgi_lifespan",
"_asgi_client", "_asgi_client",
"_blueprint_order", "_blueprint_order",
"_delayed_tasks", "_delayed_tasks",
@@ -148,7 +137,6 @@ class Sanic(
"_test_client", "_test_client",
"_test_manager", "_test_manager",
"blueprints", "blueprints",
"certloader_class",
"config", "config",
"configure_logging", "configure_logging",
"ctx", "ctx",
@@ -171,102 +159,14 @@ class Sanic(
"websocket_tasks", "websocket_tasks",
) )
_app_registry: ClassVar[Dict[str, "Sanic"]] = {} _app_registry: Dict[str, "Sanic"] = {}
test_mode: ClassVar[bool] = False test_mode = 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: str, name: Optional[str] = None,
config: Optional[config_type] = None, config: Optional[Config] = None,
ctx: Optional[ctx_type] = None, ctx: Optional[Any] = 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,
@@ -279,7 +179,6 @@ class Sanic(
loads: Optional[Callable[..., Any]] = None, loads: Optional[Callable[..., Any]] = None,
inspector: bool = False, inspector: bool = False,
inspector_class: Optional[Type[Inspector]] = None, inspector_class: Optional[Type[Inspector]] = None,
certloader_class: Optional[Type[CertLoader]] = None,
) -> None: ) -> None:
super().__init__(name=name) super().__init__(name=name)
# logging # logging
@@ -294,15 +193,11 @@ class Sanic(
) )
# First setup config # First setup config
self.config: config_type = cast( self.config: Config = config or Config(env_prefix=env_prefix)
config_type, config or Config(env_prefix=env_prefix)
)
if inspector: if inspector:
self.config.INSPECTOR = inspector self.config.INSPECTOR = inspector
# Then we can do the rest # Then we can do the rest
self._asgi_app: Optional[ASGIApp] = None
self._asgi_lifespan: Optional[Lifespan] = None
self._asgi_client: Any = None self._asgi_client: Any = None
self._blueprint_order: List[Blueprint] = [] self._blueprint_order: List[Blueprint] = []
self._delayed_tasks: List[str] = [] self._delayed_tasks: List[str] = []
@@ -316,11 +211,8 @@ class Sanic(
self.asgi = False self.asgi = False
self.auto_reload = False self.auto_reload = False
self.blueprints: Dict[str, Blueprint] = {} self.blueprints: Dict[str, Blueprint] = {}
self.certloader_class: Type[CertLoader] = (
certloader_class or CertLoader
)
self.configure_logging: bool = configure_logging self.configure_logging: bool = configure_logging
self.ctx: ctx_type = cast(ctx_type, ctx or SimpleNamespace()) self.ctx: Any = 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)
@@ -520,11 +412,8 @@ class Sanic(
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( def _apply_route(self, route: FutureRoute) -> List[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)
@@ -541,15 +430,14 @@ class Sanic(
ctx = params.pop("route_context") ctx = params.pop("route_context")
with self.amend(): routes = self.router.add(**params)
routes = self.router.add(**params) if isinstance(routes, Route):
if isinstance(routes, Route): routes = [routes]
routes = [routes]
for r in routes: for r in routes:
r.extra.websocket = websocket r.extra.websocket = websocket
r.extra.static = params.get("static", False) r.extra.static = params.get("static", False)
r.ctx.__dict__.update(ctx) r.ctx.__dict__.update(ctx)
return routes return routes
@@ -558,19 +446,17 @@ class Sanic(
middleware: FutureMiddleware, middleware: FutureMiddleware,
route_names: Optional[List[str]] = None, route_names: Optional[List[str]] = None,
): ):
with self.amend(): if route_names:
if route_names: return self.register_named_middleware(
return self.register_named_middleware( middleware.middleware, route_names, middleware.attach_to
middleware.middleware, route_names, middleware.attach_to )
) else:
else: return self.register_middleware(
return self.register_middleware( middleware.middleware, middleware.attach_to
middleware.middleware, middleware.attach_to )
)
def _apply_signal(self, signal: FutureSignal) -> Signal: def _apply_signal(self, signal: FutureSignal) -> Signal:
with self.amend(): return self.signal_router.add(*signal)
return self.signal_router.add(*signal)
def dispatch( def dispatch(
self, self,
@@ -605,19 +491,6 @@ class Sanic(
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.
@@ -669,9 +542,6 @@ class Sanic(
) )
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:
@@ -889,12 +759,6 @@ class Sanic(
: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,
@@ -1011,8 +875,6 @@ class Sanic(
:param request: HTTP Request object :param request: HTTP Request object
:return: Nothing :return: Nothing
""" """
__tracebackhide__ = True
await self.dispatch( await self.dispatch(
"http.lifecycle.handle", "http.lifecycle.handle",
inline=True, inline=True,
@@ -1325,28 +1187,13 @@ class Sanic(
app, app,
loop, loop,
): ):
async def do(task): if callable(task):
try: try:
if callable(task): task = task(app)
try: except TypeError:
task = task(app) task = task()
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 do(task) return task
@classmethod @classmethod
def _loop_add_task( def _loop_add_task(
@@ -1360,9 +1207,18 @@ class Sanic(
) -> 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)
task = loop.create_task(prepped, name=name) if sys.version_info < (3, 8): # no cov
task = loop.create_task(prepped)
if name:
error_logger.warning(
"Cannot set a name for a task when using Python 3.7. "
"Your task will be created without a name."
)
task.get_name = lambda: name
else:
task = loop.create_task(prepped, name=name)
if name and register: if name and register and sys.version_info > (3, 7):
app._task_registry[name] = task app._task_registry[name] = task
return task return task
@@ -1491,14 +1347,12 @@ class Sanic(
three arguments: scope, receive, send. See the ASGI reference for more three arguments: scope, receive, send. See the ASGI reference for more
details: https://asgi.readthedocs.io/en/latest details: https://asgi.readthedocs.io/en/latest
""" """
self.asgi = True
if scope["type"] == "lifespan": if scope["type"] == "lifespan":
self.asgi = True
self.motd("") self.motd("")
self._asgi_lifespan = Lifespan(self, scope, receive, send) self._asgi_app = await ASGIApp.create(self, scope, receive, send)
await self._asgi_lifespan() asgi_app = self._asgi_app
else: await asgi_app()
self._asgi_app = await ASGIApp.create(self, scope, receive, send)
await self._asgi_app()
_asgi_single_callable = True # We conform to ASGI 3.0 single-callable _asgi_single_callable = True # We conform to ASGI 3.0 single-callable
@@ -1659,27 +1513,6 @@ class Sanic(
# Lifecycle # Lifecycle
# -------------------------------------------------------------------- # # -------------------------------------------------------------------- #
@contextmanager
def amend(self) -> Iterator[None]:
"""
If the application has started, this function allows changes
to be made to add routes, middleware, and signals.
"""
if not self.state.is_started:
yield
else:
do_router = self.router.finalized
do_signal_router = self.signal_router.finalized
if do_router:
self.router.reset()
if do_signal_router:
self.signal_router.reset()
yield
if do_signal_router:
self.signalize(self.config.TOUCHUP)
if do_router:
self.finalize()
def finalize(self): def finalize(self):
try: try:
self.router.finalize() self.router.finalize()
@@ -1713,20 +1546,17 @@ class Sanic(
self.signalize(self.config.TOUCHUP) self.signalize(self.config.TOUCHUP)
self.finalize() self.finalize()
route_names = [route.extra.ident for route in self.router.routes] route_names = [route.name for route in self.router.routes]
duplicates = { duplicates = {
name for name in route_names if route_names.count(name) > 1 name for name in route_names if route_names.count(name) > 1
} }
if duplicates: if duplicates:
names = ", ".join(duplicates) names = ", ".join(duplicates)
message = ( deprecation(
f"Duplicate route names detected: {names}. You should rename " f"Duplicate route names detected: {names}. In the future, "
"one or more of them explicitly by using the `name` param, " "Sanic will enforce uniqueness in route naming.",
"or changing the implicit name derived from the class and " 23.3,
"function name. For more details, please see "
"https://sanic.dev/en/guide/release-notes/v23.3.html#duplicated-route-names-are-no-longer-allowed" # noqa
) )
raise ServerError(message)
Sanic._check_uvloop_conflict() Sanic._check_uvloop_conflict()

View File

@@ -3,7 +3,7 @@ import sys
from os import environ from os import environ
from sanic.helpers import is_atty from sanic.compat import is_atty
BASE_LOGO = """ BASE_LOGO = """
@@ -40,7 +40,7 @@ FULL_COLOR_LOGO = """
""" # noqa """ # noqa
SVG_LOGO_SIMPLE = """<svg id=logo-simple viewBox="0 0 964 279"><desc>Sanic</desc><path d="M107 222c9-2 10-20 1-22s-20-2-30-2-17 7-16 14 6 10 15 10h30zm115-1c16-2 30-11 35-23s6-24 2-33-6-14-15-20-24-11-38-10c-7 3-10 13-5 19s17-1 24 4 15 14 13 24-5 15-14 18-50 0-74 0h-17c-6 4-10 15-4 20s16 2 23 3zM251 83q9-1 9-7 0-15-10-16h-13c-10 6-10 20 0 22zM147 60c-4 0-10 3-11 11s5 13 10 12 42 0 67 0c5-3 7-10 6-15s-4-8-9-8zm-33 1c-8 0-16 0-24 3s-20 10-25 20-6 24-4 36 15 22 26 27 78 8 94 3c4-4 4-12 0-18s-69 8-93-10c-8-7-9-23 0-30s12-10 20-10 12 2 16-3 1-15-5-18z" fill="#ff0d68"/><path d="M676 74c0-14-18-9-20 0s0 30 0 39 20 9 20 2zm-297-10c-12 2-15 12-23 23l-41 58H340l22-30c8-12 23-13 30-4s20 24 24 38-10 10-17 10l-68 2q-17 1-48 30c-7 6-10 20 0 24s15-8 20-13 20 -20 58-21h50 c20 2 33 9 52 30 8 10 24-4 16-13L384 65q-3-2-5-1zm131 0c-10 1-12 12-11 20v96c1 10-3 23 5 32s20-5 17-15c0-23-3-46 2-67 5-12 22-14 32-5l103 87c7 5 19 1 18-9v-64c-3-10-20-9-21 2s-20 22-30 13l-97-80c-5-4-10-10-18-10zM701 76v128c2 10 15 12 20 4s0-102 0-124s-20-18-20-7z M850 63c-35 0-69-2-86 15s-20 60-13 66 13 8 16 0 1-10 1-27 12-26 20-32 66-5 85-5 31 4 31-10-18-7-54-7M764 159c-6-2-15-2-16 12s19 37 33 43 23 8 25-4-4-11-11-14q-9-3-22-18c-4-7-3-16-10-19zM828 196c-4 0-8 1-10 5s-4 12 0 15 8 2 12 2h60c5 0 10-2 12-6 3-7-1-16-8-16z" fill="#1f1f1f"/></svg>""" # noqa SVG_LOGO = """<svg id=logo alt=Sanic viewBox="0 0 964 279"><path d="M107 222c9-2 10-20 1-22s-20-2-30-2-17 7-16 14 6 10 15 10h30zm115-1c16-2 30-11 35-23s6-24 2-33-6-14-15-20-24-11-38-10c-7 3-10 13-5 19s17-1 24 4 15 14 13 24-5 15-14 18-50 0-74 0h-17c-6 4-10 15-4 20s16 2 23 3zM251 83q9-1 9-7 0-15-10-16h-13c-10 6-10 20 0 22zM147 60c-4 0-10 3-11 11s5 13 10 12 42 0 67 0c5-3 7-10 6-15s-4-8-9-8zm-33 1c-8 0-16 0-24 3s-20 10-25 20-6 24-4 36 15 22 26 27 78 8 94 3c4-4 4-12 0-18s-69 8-93-10c-8-7-9-23 0-30s12-10 20-10 12 2 16-3 1-15-5-18z" fill="#ff0d68"/><path d="M676 74c0-14-18-9-20 0s0 30 0 39 20 9 20 2zm-297-10c-12 2-15 12-23 23l-41 58H340l22-30c8-12 23-13 30-4s20 24 24 38-10 10-17 10l-68 2q-17 1-48 30c-7 6-10 20 0 24s15-8 20-13 20 -20 58-21h50 c20 2 33 9 52 30 8 10 24-4 16-13L384 65q-3-2-5-1zm131 0c-10 1-12 12-11 20v96c1 10-3 23 5 32s20-5 17-15c0-23-3-46 2-67 5-12 22-14 32-5l103 87c7 5 19 1 18-9v-64c-3-10-20-9-21 2s-20 22-30 13l-97-80c-5-4-10-10-18-10zM701 76v128c2 10 15 12 20 4s0-102 0-124s-20-18-20-7z M850 63c-35 0-69-2-86 15s-20 60-13 66 13 8 16 0 1-10 1-27 12-26 20-32 66-5 85-5 31 4 31-10-18-7-54-7M764 159c-6-2-15-2-16 12s19 37 33 43 23 8 25-4-4-11-11-14q-9-3-22-18c-4-7-3-16-10-19zM828 196c-4 0-8 1-10 5s-4 12 0 15 8 2 12 2h60c5 0 10-2 12-6 3-7-1-16-8-16z" fill="#e1e1e1"/></svg>""" # noqa
ansi_pattern = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") ansi_pattern = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")

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.helpers import is_atty from sanic.compat import is_atty
from sanic.log import logger from sanic.log import logger

View File

@@ -3,9 +3,10 @@ from __future__ import annotations
import warnings import warnings
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from urllib.parse import quote
from sanic.compat import Header from sanic.compat import Header
from sanic.exceptions import BadRequest, ServerError from sanic.exceptions import ServerError
from sanic.helpers import Default from sanic.helpers import Default
from sanic.http import Stage from sanic.http import Stage
from sanic.log import error_logger, logger from sanic.log import error_logger, logger
@@ -21,15 +22,13 @@ if TYPE_CHECKING:
class Lifespan: class Lifespan:
def __init__( def __init__(self, asgi_app: ASGIApp) -> None:
self, sanic_app, scope: ASGIScope, receive: ASGIReceive, send: ASGISend self.asgi_app = asgi_app
) -> None:
self.sanic_app = sanic_app
self.scope = scope
self.receive = receive
self.send = send
if "server.init.before" in self.sanic_app.signal_router.name_index: if (
"server.init.before"
in self.asgi_app.sanic_app.signal_router.name_index
):
logger.debug( logger.debug(
'You have set a listener for "before_server_start" ' 'You have set a listener for "before_server_start" '
"in ASGI mode. " "in ASGI mode. "
@@ -37,7 +36,10 @@ class Lifespan:
"the ASGI server is started.", "the ASGI server is started.",
extra={"verbosity": 1}, extra={"verbosity": 1},
) )
if "server.shutdown.after" in self.sanic_app.signal_router.name_index: if (
"server.shutdown.after"
in self.asgi_app.sanic_app.signal_router.name_index
):
logger.debug( logger.debug(
'You have set a listener for "after_server_stop" ' 'You have set a listener for "after_server_stop" '
"in ASGI mode. " "in ASGI mode. "
@@ -55,11 +57,11 @@ class Lifespan:
in sequence since the ASGI lifespan protocol only supports a single in sequence since the ASGI lifespan protocol only supports a single
startup event. startup event.
""" """
await self.sanic_app._startup() await self.asgi_app.sanic_app._startup()
await self.sanic_app._server_event("init", "before") await self.asgi_app.sanic_app._server_event("init", "before")
await self.sanic_app._server_event("init", "after") await self.asgi_app.sanic_app._server_event("init", "after")
if not isinstance(self.sanic_app.config.USE_UVLOOP, Default): if not isinstance(self.asgi_app.sanic_app.config.USE_UVLOOP, Default):
warnings.warn( warnings.warn(
"You have set the USE_UVLOOP configuration option, but Sanic " "You have set the USE_UVLOOP configuration option, but Sanic "
"cannot control the event loop when running in ASGI mode." "cannot control the event loop when running in ASGI mode."
@@ -75,33 +77,35 @@ class Lifespan:
in sequence since the ASGI lifespan protocol only supports a single in sequence since the ASGI lifespan protocol only supports a single
shutdown event. shutdown event.
""" """
await self.sanic_app._server_event("shutdown", "before") await self.asgi_app.sanic_app._server_event("shutdown", "before")
await self.sanic_app._server_event("shutdown", "after") await self.asgi_app.sanic_app._server_event("shutdown", "after")
async def __call__(self) -> None: async def __call__(
while True: self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend
message = await self.receive() ) -> None:
if message["type"] == "lifespan.startup": message = await receive()
try: if message["type"] == "lifespan.startup":
await self.startup() try:
except Exception as e: await self.startup()
error_logger.exception(e) except Exception as e:
await self.send( error_logger.exception(e)
{"type": "lifespan.startup.failed", "message": str(e)} await send(
) {"type": "lifespan.startup.failed", "message": str(e)}
else: )
await self.send({"type": "lifespan.startup.complete"}) else:
elif message["type"] == "lifespan.shutdown": await send({"type": "lifespan.startup.complete"})
try:
await self.shutdown() message = await receive()
except Exception as e: if message["type"] == "lifespan.shutdown":
error_logger.exception(e) try:
await self.send( await self.shutdown()
{"type": "lifespan.shutdown.failed", "message": str(e)} except Exception as e:
) error_logger.exception(e)
else: await send(
await self.send({"type": "lifespan.shutdown.complete"}) {"type": "lifespan.shutdown.failed", "message": str(e)}
return )
else:
await send({"type": "lifespan.shutdown.complete"})
class ASGIApp: class ASGIApp:
@@ -113,79 +117,73 @@ class ASGIApp:
stage: Stage stage: Stage
response: Optional[BaseHTTPResponse] response: Optional[BaseHTTPResponse]
def __init__(self) -> None:
self.ws = None
@classmethod @classmethod
async def create( async def create(
cls, cls, sanic_app, scope: ASGIScope, receive: ASGIReceive, send: ASGISend
sanic_app: Sanic, ) -> "ASGIApp":
scope: ASGIScope,
receive: ASGIReceive,
send: ASGISend,
) -> ASGIApp:
instance = cls() instance = cls()
instance.ws = None
instance.sanic_app = sanic_app instance.sanic_app = sanic_app
instance.transport = MockTransport(scope, receive, send) instance.transport = MockTransport(scope, receive, send)
instance.transport.loop = sanic_app.loop instance.transport.loop = sanic_app.loop
instance.stage = Stage.IDLE instance.stage = Stage.IDLE
instance.response = None instance.response = None
instance.sanic_app.state.is_started = True
setattr(instance.transport, "add_task", sanic_app.loop.create_task) setattr(instance.transport, "add_task", sanic_app.loop.create_task)
try: headers = Header(
headers = Header( [
[ (key.decode("latin-1"), value.decode("latin-1"))
( for key, value in scope.get("headers", [])
key.decode("ASCII"), ]
value.decode(errors="surrogateescape"), )
) instance.lifespan = Lifespan(instance)
for key, value in scope.get("headers", [])
]
)
except UnicodeDecodeError:
raise BadRequest(
"Header names can only contain US-ASCII characters"
)
if scope["type"] == "http": if scope["type"] == "lifespan":
version = scope["http_version"] await instance.lifespan(scope, receive, send)
method = scope["method"]
elif scope["type"] == "websocket":
version = "1.1"
method = "GET"
instance.ws = instance.transport.create_websocket_connection(
send, receive
)
else: else:
raise ServerError("Received unknown ASGI scope") path = (
scope["path"][1:]
if scope["path"].startswith("/")
else scope["path"]
)
url = "/".join([scope.get("root_path", ""), quote(path)])
url_bytes = url.encode("latin-1")
url_bytes += b"?" + scope["query_string"]
url_bytes, query = scope["raw_path"], scope["query_string"] if scope["type"] == "http":
if query: version = scope["http_version"]
# httpx ASGI client sends query string as part of raw_path method = scope["method"]
url_bytes = url_bytes.split(b"?", 1)[0] elif scope["type"] == "websocket":
# All servers send them separately version = "1.1"
url_bytes = b"%b?%b" % (url_bytes, query) method = "GET"
request_class = sanic_app.request_class or Request instance.ws = instance.transport.create_websocket_connection(
instance.request = request_class( send, receive
url_bytes, )
headers, else:
version, raise ServerError("Received unknown ASGI scope")
method,
instance.transport,
sanic_app,
)
request_class._current.set(instance.request)
instance.request.stream = instance # type: ignore
instance.request_body = True
instance.request.conn_info = ConnInfo(instance.transport)
await instance.sanic_app.dispatch( request_class = sanic_app.request_class or Request
"http.lifecycle.request", instance.request = request_class(
inline=True, url_bytes,
context={"request": instance.request}, headers,
fail_not_found=False, version,
) method,
instance.transport,
sanic_app,
)
instance.request.stream = instance
instance.request_body = True
instance.request.conn_info = ConnInfo(instance.transport)
await sanic_app.dispatch(
"http.lifecycle.request",
inline=True,
context={"request": instance.request},
fail_not_found=False,
)
return instance return instance

View File

@@ -65,7 +65,6 @@ class BlueprintGroup(MutableSequence):
"_version", "_version",
"_strict_slashes", "_strict_slashes",
"_version_prefix", "_version_prefix",
"_name_prefix",
) )
def __init__( def __init__(
@@ -74,7 +73,6 @@ 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
@@ -89,7 +87,6 @@ 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]]:
@@ -137,15 +134,6 @@ 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,8 +93,6 @@ class Blueprint(BaseSanic):
"_future_listeners", "_future_listeners",
"_future_exceptions", "_future_exceptions",
"_future_signals", "_future_signals",
"_allow_route_overwrite",
"copied_from",
"ctx", "ctx",
"exceptions", "exceptions",
"host", "host",
@@ -111,7 +109,7 @@ class Blueprint(BaseSanic):
def __init__( def __init__(
self, self,
name: str, name: str = None,
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,
@@ -120,8 +118,6 @@ class Blueprint(BaseSanic):
): ):
super().__init__(name=name) super().__init__(name=name)
self.reset() self.reset()
self._allow_route_overwrite = False
self.copied_from = ""
self.ctx = SimpleNamespace() self.ctx = SimpleNamespace()
self.host = host self.host = host
self.strict_slashes = strict_slashes self.strict_slashes = strict_slashes
@@ -171,7 +167,6 @@ 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] = []
@@ -185,7 +180,6 @@ 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,
@@ -219,7 +213,6 @@ class Blueprint(BaseSanic):
self.reset() self.reset()
new_bp = deepcopy(self) new_bp = deepcopy(self)
new_bp.name = name new_bp.name = name
new_bp.copied_from = self.name
if not isinstance(url_prefix, Default): if not isinstance(url_prefix, Default):
new_bp.url_prefix = url_prefix new_bp.url_prefix = url_prefix
@@ -229,8 +222,6 @@ 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)
@@ -256,7 +247,6 @@ 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
@@ -282,7 +272,6 @@ 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)
@@ -303,7 +292,6 @@ 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
) )
@@ -319,10 +307,6 @@ 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,
@@ -339,10 +323,7 @@ class Blueprint(BaseSanic):
future.strict_slashes, opt_strict_slashes, self.strict_slashes future.strict_slashes, opt_strict_slashes, self.strict_slashes
) )
name = future.name name = app._generate_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)
@@ -362,7 +343,7 @@ class Blueprint(BaseSanic):
future.unquote, future.unquote,
future.static, future.static,
version_prefix, version_prefix,
route_error_format, error_format,
future.route_context, future.route_context,
) )
@@ -370,19 +351,7 @@ class Blueprint(BaseSanic):
continue continue
registered.add(apply_route) registered.add(apply_route)
route = app._apply_route( route = app._apply_route(apply_route)
apply_route, overwrite=self._allow_route_overwrite
)
# If it is a copied BP, then make sure all of the names of routes
# matchup with the new BP name
if self.copied_from:
for r in route:
r.name = r.name.replace(self.copied_from, self.name)
r.extra.ident = r.extra.ident.replace(
self.copied_from, self.name
)
operation = ( operation = (
routes.extend if isinstance(route, list) else routes.append routes.extend if isinstance(route, list) else routes.append
) )

View File

@@ -1,3 +1,4 @@
import logging
import os import os
import shutil import shutil
import sys import sys
@@ -5,7 +6,7 @@ import sys
from argparse import Namespace from argparse import Namespace
from functools import partial from functools import partial
from textwrap import indent from textwrap import indent
from typing import List, Union from typing import List, Union, cast
from sanic.app import Sanic from sanic.app import Sanic
from sanic.application.logo import get_logo from sanic.application.logo import get_logo
@@ -13,7 +14,7 @@ from sanic.cli.arguments import Group
from sanic.cli.base import SanicArgumentParser, SanicHelpFormatter from sanic.cli.base import SanicArgumentParser, SanicHelpFormatter
from sanic.cli.inspector import make_inspector_parser from sanic.cli.inspector import make_inspector_parser
from sanic.cli.inspector_client import InspectorClient from sanic.cli.inspector_client import InspectorClient
from sanic.log import error_logger from sanic.log import Colors, error_logger
from sanic.worker.loader import AppLoader from sanic.worker.loader import AppLoader
@@ -23,22 +24,17 @@ class SanicCLI:
{get_logo(True)} {get_logo(True)}
To start running a Sanic application, provide a path to the module, where To start running a Sanic application, provide a path to the module, where
app is a Sanic() instance in the global scope: app is a Sanic() instance:
$ sanic path.to.server:app $ sanic path.to.server:app
If the Sanic instance variable is called 'app', you can leave off the last
part, and only provide a path to the module where the instance is:
$ sanic path.to.server
Or, a path to a callable that returns a Sanic() instance: Or, a path to a callable that returns a Sanic() instance:
$ sanic path.to.factory:create_app $ sanic path.to.factory:create_app --factory
Or, a path to a directory to run as a simple HTTP server: Or, a path to a directory to run as a simple HTTP server:
$ sanic ./path/to/static $ sanic ./path/to/static --simple
""", """,
prefix=" ", prefix=" ",
) )
@@ -99,9 +95,13 @@ Or, a path to a directory to run as a simple HTTP server:
self.args = self.parser.parse_args(args=parse_args) self.args = self.parser.parse_args(args=parse_args)
self._precheck() self._precheck()
app_loader = AppLoader( app_loader = AppLoader(
self.args.target, self.args.factory, self.args.simple, self.args self.args.module, self.args.factory, self.args.simple, self.args
) )
if self.args.inspect or self.args.inspect_raw or self.args.trigger:
self._inspector_legacy(app_loader)
return
try: try:
app = self._get_app(app_loader) app = self._get_app(app_loader)
kwargs = self._build_run_kwargs() kwargs = self._build_run_kwargs()
@@ -112,10 +112,38 @@ Or, a path to a directory to run as a simple HTTP server:
app.prepare(**kwargs, version=http_version) app.prepare(**kwargs, version=http_version)
if self.args.single: if self.args.single:
serve = Sanic.serve_single serve = Sanic.serve_single
elif self.args.legacy:
serve = Sanic.serve_legacy
else: else:
serve = partial(Sanic.serve, app_loader=app_loader) serve = partial(Sanic.serve, app_loader=app_loader)
serve(app) serve(app)
def _inspector_legacy(self, app_loader: AppLoader):
host = port = None
module = cast(str, self.args.module)
if ":" in module:
maybe_host, maybe_port = module.rsplit(":", 1)
if maybe_port.isnumeric():
host, port = maybe_host, int(maybe_port)
if not host:
app = self._get_app(app_loader)
host, port = app.config.INSPECTOR_HOST, app.config.INSPECTOR_PORT
action = self.args.trigger or "info"
InspectorClient(
str(host), int(port or 6457), False, self.args.inspect_raw, ""
).do(action)
sys.stdout.write(
f"\n{Colors.BOLD}{Colors.YELLOW}WARNING:{Colors.END} "
"You are using the legacy CLI command that will be removed in "
f"{Colors.RED}v23.3{Colors.END}. See "
"https://sanic.dev/en/guide/release-notes/v22.12.html"
"#deprecations-and-removals or checkout the new "
"style commands:\n\n\t"
f"{Colors.YELLOW}sanic inspect --help{Colors.END}\n"
)
def _inspector(self): def _inspector(self):
args = sys.argv[2:] args = sys.argv[2:]
self.args, unknown = self.parser.parse_known_args(args=args) self.args, unknown = self.parser.parse_known_args(args=args)
@@ -169,6 +197,8 @@ Or, a path to a directory to run as a simple HTTP server:
) )
error_logger.error(message) error_logger.error(message)
sys.exit(1) sys.exit(1)
if self.args.inspect or self.args.inspect_raw:
logging.disable(logging.CRITICAL)
def _get_app(self, app_loader: AppLoader): def _get_app(self, app_loader: AppLoader):
try: try:
@@ -180,10 +210,6 @@ 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
@@ -220,6 +246,7 @@ Or, a path to a directory to run as a simple HTTP server:
"workers": self.args.workers, "workers": self.args.workers,
"auto_tls": self.args.auto_tls, "auto_tls": self.args.auto_tls,
"single_process": self.args.single, "single_process": self.args.single,
"legacy": self.args.legacy,
} }
for maybe_arg in ("auto_reload", "dev"): for maybe_arg in ("auto_reload", "dev"):

View File

@@ -57,15 +57,11 @@ class GeneralGroup(Group):
) )
self.container.add_argument( self.container.add_argument(
"target", "module",
help=( help=(
"Path to your Sanic app instance.\n" "Path to your Sanic app. Example: path.to.server:app\n"
"\tExample: path.to.server:app\n" "If running a Simple Server, path to directory to serve. "
"If running a Simple Server, path to directory to serve.\n" "Example: ./\n"
"\tExample: ./\n"
"Additionally, this can be a path to a factory function\n"
"that returns a Sanic app instance.\n"
"\tExample: path.to.server:create_app\n"
), ),
) )
@@ -93,6 +89,32 @@ class ApplicationGroup(Group):
"a directory\n(module arg should be a path)" "a directory\n(module arg should be a path)"
), ),
) )
group.add_argument(
"--inspect",
dest="inspect",
action="store_true",
help=("Inspect the state of a running instance, human readable"),
)
group.add_argument(
"--inspect-raw",
dest="inspect_raw",
action="store_true",
help=("Inspect the state of a running instance, JSON output"),
)
group.add_argument(
"--trigger-reload",
dest="trigger",
action="store_const",
const="reload",
help=("Trigger worker processes to reload"),
)
group.add_argument(
"--trigger-shutdown",
dest="trigger",
action="store_const",
const="shutdown",
help=("Trigger all processes to shutdown"),
)
class HTTPVersionGroup(Group): class HTTPVersionGroup(Group):
@@ -221,6 +243,11 @@ class WorkerGroup(Group):
action="store_true", action="store_true",
help="Do not use multiprocessing, run server in a single process", help="Do not use multiprocessing, run server in a single process",
) )
self.container.add_argument(
"--legacy",
action="store_true",
help="Use the legacy server manager",
)
self.add_bool_arguments( self.add_bool_arguments(
"--access-logs", "--access-logs",
dest="access_log", dest="access_log",

View File

@@ -1,6 +1,5 @@
import asyncio import asyncio
import os import os
import platform
import signal import signal
import sys import sys
@@ -11,7 +10,6 @@ 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
@@ -24,7 +22,6 @@ 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:
@@ -76,38 +73,6 @@ 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
@@ -121,13 +86,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:
if key.startswith("_"):
return self.__getattribute__(key)
key = key.rstrip("_").replace("_", "-")
return ",".join(self.getall(key, default=[]))
def get_all(self, key: str): def get_all(self, key: str):
""" """
@@ -147,12 +106,6 @@ 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
@@ -184,3 +137,7 @@ 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, "GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec
"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": 120, "KEEP_ALIVE_TIMEOUT": 5, # 5 seconds
"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, "REQUEST_BUFFER_SIZE": 65536, # 64 KiB
"REQUEST_MAX_HEADER_SIZE": 8192, # Cannot exceed 16384 "REQUEST_MAX_HEADER_SIZE": 8192, # 8 KiB, but cannot exceed 16384
"REQUEST_ID_HEADER": "X-Request-ID", "REQUEST_ID_HEADER": "X-Request-ID",
"REQUEST_MAX_SIZE": 100_000_000, "REQUEST_MAX_SIZE": 100000000, # 100 megabytes
"REQUEST_TIMEOUT": 60, "REQUEST_TIMEOUT": 60, # 60 seconds
"RESPONSE_TIMEOUT": 60, "RESPONSE_TIMEOUT": 60, # 60 seconds
"TLS_CERT_PASSWORD": "", "TLS_CERT_PASSWORD": "",
"TOUCHUP": _default, "TOUCHUP": _default,
"USE_UVLOOP": _default, "USE_UVLOOP": _default,
"WEBSOCKET_MAX_SIZE": 2**20, # 1 MiB "WEBSOCKET_MAX_SIZE": 2**20, # 1 megabyte
"WEBSOCKET_PING_INTERVAL": 20, "WEBSOCKET_PING_INTERVAL": 20,
"WEBSOCKET_PING_TIMEOUT": 20, "WEBSOCKET_PING_TIMEOUT": 20,
} }

156
sanic/cookies.py Normal file
View File

@@ -0,0 +1,156 @@
import re
import string
from datetime import datetime
from typing import Dict
DEFAULT_MAX_AGE = 0
# ------------------------------------------------------------ #
# SimpleCookie
# ------------------------------------------------------------ #
# Straight up copied this section of dark magic from SimpleCookie
_LegalChars = string.ascii_letters + string.digits + "!#$%&'*+-.^_`|~:"
_UnescapedChars = _LegalChars + " ()/<=>?@[]{}"
_Translator = {
n: "\\%03o" % n for n in set(range(256)) - set(map(ord, _UnescapedChars))
}
_Translator.update({ord('"'): '\\"', ord("\\"): "\\\\"})
def _quote(str):
r"""Quote a string for use in a cookie header.
If the string does not need to be double-quoted, then just return the
string. Otherwise, surround the string in doublequotes and quote
(with a \) special characters.
"""
if str is None or _is_legal_key(str):
return str
else:
return '"' + str.translate(_Translator) + '"'
_is_legal_key = re.compile("[%s]+" % re.escape(_LegalChars)).fullmatch
# ------------------------------------------------------------ #
# Custom SimpleCookie
# ------------------------------------------------------------ #
class CookieJar(dict):
"""
CookieJar dynamically writes headers as cookies are added and removed
It gets around the limitation of one header per name by using the
MultiHeader class to provide a unique key that encodes to Set-Cookie.
"""
def __init__(self, headers):
super().__init__()
self.headers: Dict[str, str] = headers
self.cookie_headers: Dict[str, str] = {}
self.header_key: str = "Set-Cookie"
def __setitem__(self, key, value):
# If this cookie doesn't exist, add it to the header keys
if not self.cookie_headers.get(key):
cookie = Cookie(key, value)
cookie["path"] = "/"
self.cookie_headers[key] = self.header_key
self.headers.add(self.header_key, cookie)
return super().__setitem__(key, cookie)
else:
self[key].value = value
def __delitem__(self, key):
if key not in self.cookie_headers:
self[key] = ""
self[key]["max-age"] = 0
else:
cookie_header = self.cookie_headers[key]
# remove it from header
cookies = self.headers.popall(cookie_header)
for cookie in cookies:
if cookie.key != key:
self.headers.add(cookie_header, cookie)
del self.cookie_headers[key]
return super().__delitem__(key)
class Cookie(dict):
"""A stripped down version of Morsel from SimpleCookie #gottagofast"""
_keys = {
"expires": "expires",
"path": "Path",
"comment": "Comment",
"domain": "Domain",
"max-age": "Max-Age",
"secure": "Secure",
"httponly": "HttpOnly",
"version": "Version",
"samesite": "SameSite",
}
_flags = {"secure", "httponly"}
def __init__(self, key, value):
if key in self._keys:
raise KeyError("Cookie name is a reserved word")
if not _is_legal_key(key):
raise KeyError("Cookie key contains illegal characters")
self.key = key
self.value = value
super().__init__()
def __setitem__(self, key, value):
if key not in self._keys:
raise KeyError("Unknown cookie property")
if value is not False:
if key.lower() == "max-age":
if not str(value).isdigit():
raise ValueError("Cookie max-age must be an integer")
elif key.lower() == "expires":
if not isinstance(value, datetime):
raise TypeError(
"Cookie 'expires' property must be a datetime"
)
return super().__setitem__(key, value)
def encode(self, encoding):
"""
Encode the cookie content in a specific type of encoding instructed
by the developer. Leverages the :func:`str.encode` method provided
by python.
This method can be used to encode and embed ``utf-8`` content into
the cookies.
:param encoding: Encoding to be used with the cookie
:return: Cookie encoded in a codec of choosing.
:except: UnicodeEncodeError
"""
return str(self).encode(encoding)
def __str__(self):
"""Format as a Set-Cookie header value."""
output = ["%s=%s" % (self.key, _quote(self.value))]
for key, value in self.items():
if key == "max-age":
try:
output.append("%s=%d" % (self._keys[key], value))
except TypeError:
output.append("%s=%s" % (self._keys[key], value))
elif key == "expires":
output.append(
"%s=%s"
% (self._keys[key], value.strftime("%a, %d-%b-%Y %T GMT"))
)
elif key in self._flags and self[key]:
output.append(self._keys[key])
else:
output.append("%s=%s" % (self._keys[key], value))
return "; ".join(output)

View File

@@ -1,4 +0,0 @@
from .response import Cookie, CookieJar
__all__ = ("Cookie", "CookieJar")

View File

@@ -1,119 +0,0 @@
import re
from typing import Any, Dict, List, Optional
from sanic.cookies.response import Cookie
from sanic.log import deprecation
from sanic.request.parameters import RequestParameters
COOKIE_NAME_RESERVED_CHARS = re.compile(
'[\x00-\x1F\x7F-\xFF()<>@,;:\\\\"/[\\]?={} \x09]'
)
OCTAL_PATTERN = re.compile(r"\\[0-3][0-7][0-7]")
QUOTE_PATTERN = re.compile(r"[\\].")
def _unquote(str): # no cov
if str is None or len(str) < 2:
return str
if str[0] != '"' or str[-1] != '"':
return str
str = str[1:-1]
i = 0
n = len(str)
res = []
while 0 <= i < n:
o_match = OCTAL_PATTERN.search(str, i)
q_match = QUOTE_PATTERN.search(str, i)
if not o_match and not q_match:
res.append(str[i:])
break
# else:
j = k = -1
if o_match:
j = o_match.start(0)
if q_match:
k = q_match.start(0)
if q_match and (not o_match or k < j):
res.append(str[i:k])
res.append(str[k + 1])
i = k + 2
else:
res.append(str[i:j])
res.append(chr(int(str[j + 1 : j + 4], 8))) # noqa: E203
i = j + 4
return "".join(res)
def parse_cookie(raw: str):
cookies: Dict[str, List] = {}
for token in raw.split(";"):
name, __, value = token.partition("=")
name = name.strip()
value = value.strip()
if not name:
continue
if COOKIE_NAME_RESERVED_CHARS.search(name): # no cov
continue
if len(value) > 2 and value[0] == '"' and value[-1] == '"': # no cov
value = _unquote(value)
if name in cookies:
cookies[name].append(value)
else:
cookies[name] = [value]
return cookies
class CookieRequestParameters(RequestParameters):
def __getitem__(self, key: str) -> Optional[str]:
deprecation(
f"You are accessing cookie key '{key}', which is currently in "
"compat mode returning a single cookie value. Starting in v24.3 "
"accessing a cookie value like this will return a list of values. "
"To avoid this behavior and continue accessing a single value, "
f"please upgrade from request.cookies['{key}'] to "
f"request.cookies.get('{key}'). See more details: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#request-cookies", # noqa
24.3,
)
try:
value = self._get_prefixed_cookie(key)
except KeyError:
value = super().__getitem__(key)
return value[0]
def __getattr__(self, key: str) -> str:
if key.startswith("_"):
return self.__getattribute__(key)
key = key.rstrip("_").replace("_", "-")
return str(self.get(key, ""))
def get(self, name: str, default: Optional[Any] = None) -> Optional[Any]:
try:
return self._get_prefixed_cookie(name)[0]
except KeyError:
return super().get(name, default)
def getlist(
self, name: str, default: Optional[Any] = None
) -> Optional[Any]:
try:
return self._get_prefixed_cookie(name)
except KeyError:
return super().getlist(name, default)
def _get_prefixed_cookie(self, name: str) -> Any:
getitem = super().__getitem__
try:
return getitem(f"{Cookie.HOST_PREFIX}{name}")
except KeyError:
return getitem(f"{Cookie.SECURE_PREFIX}{name}")

View File

@@ -1,617 +0,0 @@
from __future__ import annotations
import re
import string
import sys
from datetime import datetime
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
from sanic.exceptions import ServerError
from sanic.log import deprecation
if TYPE_CHECKING:
from sanic.compat import Header
if sys.version_info < (3, 8): # no cov
SameSite = str
else: # no cov
from typing import Literal
SameSite = Union[
Literal["Strict"],
Literal["Lax"],
Literal["None"],
Literal["strict"],
Literal["lax"],
Literal["none"],
]
DEFAULT_MAX_AGE = 0
SAMESITE_VALUES = ("strict", "lax", "none")
LEGAL_CHARS = string.ascii_letters + string.digits + "!#$%&'*+-.^_`|~:"
UNESCAPED_CHARS = LEGAL_CHARS + " ()/<=>?@[]{}"
TRANSLATOR = {ch: f"\\{ch:03o}" for ch in bytes(range(32)) + b'";\\\x7F'}
def _quote(str): # no cov
r"""Quote a string for use in a cookie header.
If the string does not need to be double-quoted, then just return the
string. Otherwise, surround the string in doublequotes and quote
(with a \) special characters.
"""
if str is None or _is_legal_key(str):
return str
else:
return f'"{str.translate(TRANSLATOR)}"'
_is_legal_key = re.compile("[%s]+" % re.escape(LEGAL_CHARS)).fullmatch
# In v24.3, we should remove this as being a subclass of dict
class CookieJar(dict):
"""
CookieJar dynamically writes headers as cookies are added and removed
It gets around the limitation of one header per name by using the
MultiHeader class to provide a unique key that encodes to Set-Cookie.
"""
HEADER_KEY = "Set-Cookie"
def __init__(self, headers: Header):
super().__init__()
self.headers = headers
def __setitem__(self, key, value):
# If this cookie doesn't exist, add it to the header keys
deprecation(
"Setting cookie values using the dict pattern has been "
"deprecated. You should instead use the cookies.add_cookie "
"method. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
0,
)
if key not in self:
self.add_cookie(key, value, secure=False, samesite=None)
else:
self[key].value = value
def __delitem__(self, key):
deprecation(
"Deleting cookie values using the dict pattern has been "
"deprecated. You should instead use the cookies.delete_cookie "
"method. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
0,
)
if key in self:
super().__delitem__(key)
self.delete_cookie(key)
def __len__(self): # no cov
return len(self.cookies)
def __getitem__(self, key: str) -> Cookie:
deprecation(
"Accessing cookies from the CookieJar by dict key is deprecated. "
"You should instead use the cookies.get_cookie method. "
"To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
0,
)
return super().__getitem__(key)
def __iter__(self): # no cov
deprecation(
"Iterating over the CookieJar has been deprecated and will be "
"removed in v24.3. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
24.3,
)
return super().__iter__()
def keys(self): # no cov
deprecation(
"Accessing CookieJar.keys() has been deprecated and will be "
"removed in v24.3. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
24.3,
)
return super().keys()
def values(self): # no cov
deprecation(
"Accessing CookieJar.values() has been deprecated and will be "
"removed in v24.3. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
24.3,
)
return super().values()
def items(self): # no cov
deprecation(
"Accessing CookieJar.items() has been deprecated and will be "
"removed in v24.3. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
24.3,
)
return super().items()
def get(self, *args, **kwargs): # no cov
deprecation(
"Accessing cookies from the CookieJar using get is deprecated "
"and will be removed in v24.3. You should instead use the "
"cookies.get_cookie method. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
24.3,
)
return super().get(*args, **kwargs)
def pop(self, key, *args, **kwargs): # no cov
deprecation(
"Using CookieJar.pop() has been deprecated and will be "
"removed in v24.3. To learn more, please see: "
"https://sanic.dev/en/guide/release-notes/v23.3.html#response-cookies", # noqa
24.3,
)
self.delete(key)
return super().pop(key, *args, **kwargs)
@property
def header_key(self): # no cov
deprecation(
"The CookieJar.header_key property has been deprecated and will "
"be removed in version 24.3. Use CookieJar.HEADER_KEY. ",
24.3,
)
return CookieJar.HEADER_KEY
@property
def cookie_headers(self) -> Dict[str, str]: # no cov
deprecation(
"The CookieJar.coookie_headers property has been deprecated "
"and will be removed in version 24.3. If you need to check if a "
"particular cookie key has been set, use CookieJar.has_cookie.",
24.3,
)
return {key: self.header_key for key in self}
@property
def cookies(self) -> List[Cookie]:
return self.headers.getall(self.HEADER_KEY)
def get_cookie(
self,
key: str,
path: str = "/",
domain: Optional[str] = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> Optional[Cookie]:
for cookie in self.cookies:
if (
cookie.key == Cookie.make_key(key, host_prefix, secure_prefix)
and cookie.path == path
and cookie.domain == domain
):
return cookie
return None
def has_cookie(
self,
key: str,
path: str = "/",
domain: Optional[str] = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> bool:
for cookie in self.cookies:
if (
cookie.key == Cookie.make_key(key, host_prefix, secure_prefix)
and cookie.path == path
and cookie.domain == domain
):
return True
return False
def add_cookie(
self,
key: str,
value: str,
*,
path: str = "/",
domain: Optional[str] = None,
secure: bool = True,
max_age: Optional[int] = None,
expires: Optional[datetime] = None,
httponly: bool = False,
samesite: Optional[SameSite] = "Lax",
partitioned: bool = False,
comment: Optional[str] = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> Cookie:
"""
Add a cookie to the CookieJar
:param key: Key of the cookie
:type key: str
:param value: Value of the cookie
:type value: str
:param path: Path of the cookie, defaults to None
:type path: Optional[str], optional
:param domain: Domain of the cookie, defaults to None
:type domain: Optional[str], optional
:param secure: Whether to set it as a secure cookie, defaults to True
:type secure: bool
:param max_age: Max age of the cookie in seconds; if set to 0 a
browser should delete it, defaults to None
:type max_age: Optional[int], optional
:param expires: When the cookie expires; if set to None browsers
should set it as a session cookie, defaults to None
:type expires: Optional[datetime], optional
:param httponly: Whether to set it as HTTP only, defaults to False
:type httponly: bool
:param samesite: How to set the samesite property, should be
strict, lax or none (case insensitive), defaults to Lax
:type samesite: Optional[SameSite], optional
:param partitioned: Whether to set it as partitioned, defaults to False
:type partitioned: bool
:param comment: A cookie comment, defaults to None
:type comment: Optional[str], optional
:param host_prefix: Whether to add __Host- as a prefix to the key.
This requires that path="/", domain=None, and secure=True,
defaults to False
:type host_prefix: bool
:param secure_prefix: Whether to add __Secure- as a prefix to the key.
This requires that secure=True, defaults to False
:type secure_prefix: bool
:return: The instance of the created cookie
:rtype: Cookie
"""
cookie = Cookie(
key,
value,
path=path,
expires=expires,
comment=comment,
domain=domain,
max_age=max_age,
secure=secure,
httponly=httponly,
samesite=samesite,
partitioned=partitioned,
host_prefix=host_prefix,
secure_prefix=secure_prefix,
)
self.headers.add(self.HEADER_KEY, cookie)
# This should be removed in v24.3
super().__setitem__(key, cookie)
return cookie
def delete_cookie(
self,
key: str,
*,
path: str = "/",
domain: Optional[str] = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> None:
"""
Delete a cookie
This will effectively set it as Max-Age: 0, which a browser should
interpret it to mean: "delete the cookie".
Since it is a browser/client implementation, your results may vary
depending upon which client is being used.
:param key: The key to be deleted
:type key: str
:param path: Path of the cookie, defaults to None
:type path: Optional[str], optional
:param domain: Domain of the cookie, defaults to None
:type domain: Optional[str], optional
:param host_prefix: Whether to add __Host- as a prefix to the key.
This requires that path="/", domain=None, and secure=True,
defaults to False
:type host_prefix: bool
:param secure_prefix: Whether to add __Secure- as a prefix to the key.
This requires that secure=True, defaults to False
:type secure_prefix: bool
"""
# remove it from header
cookies: List[Cookie] = self.headers.popall(self.HEADER_KEY, [])
for cookie in cookies:
if (
cookie.key != Cookie.make_key(key, host_prefix, secure_prefix)
or cookie.path != path
or cookie.domain != domain
):
self.headers.add(self.HEADER_KEY, cookie)
# This should be removed in v24.3
try:
super().__delitem__(key)
except KeyError:
...
self.add_cookie(
key=key,
value="",
path=path,
domain=domain,
max_age=0,
samesite=None,
host_prefix=host_prefix,
secure_prefix=secure_prefix,
)
# In v24.3, we should remove this as being a subclass of dict
# Instead, it should be an object with __slots__
# All of the current property accessors should be removed in favor
# of actual slotted properties.
class Cookie(dict):
"""A stripped down version of Morsel from SimpleCookie"""
HOST_PREFIX = "__Host-"
SECURE_PREFIX = "__Secure-"
_keys = {
"path": "Path",
"comment": "Comment",
"domain": "Domain",
"max-age": "Max-Age",
"expires": "expires",
"samesite": "SameSite",
"version": "Version",
"secure": "Secure",
"httponly": "HttpOnly",
"partitioned": "Partitioned",
}
_flags = {"secure", "httponly", "partitioned"}
def __init__(
self,
key: str,
value: str,
*,
path: str = "/",
domain: Optional[str] = None,
secure: bool = True,
max_age: Optional[int] = None,
expires: Optional[datetime] = None,
httponly: bool = False,
samesite: Optional[SameSite] = "Lax",
partitioned: bool = False,
comment: Optional[str] = None,
host_prefix: bool = False,
secure_prefix: bool = False,
):
if key in self._keys:
raise KeyError("Cookie name is a reserved word")
if not _is_legal_key(key):
raise KeyError("Cookie key contains illegal characters")
if host_prefix:
if not secure:
raise ServerError(
"Cannot set host_prefix on a cookie without secure=True"
)
if path != "/":
raise ServerError(
"Cannot set host_prefix on a cookie unless path='/'"
)
if domain:
raise ServerError(
"Cannot set host_prefix on a cookie with a defined domain"
)
elif secure_prefix and not secure:
raise ServerError(
"Cannot set secure_prefix on a cookie without secure=True"
)
if partitioned and not host_prefix:
# This is technically possible, but it is not advisable so we will
# take a stand and say "don't shoot yourself in the foot"
raise ServerError(
"Cannot create a partitioned cookie without "
"also setting host_prefix=True"
)
self.key = self.make_key(key, host_prefix, secure_prefix)
self.value = value
super().__init__()
# This is a temporary solution while this object is a dict. We update
# all of the values in bulk, except for the values that have
# key-specific validation in _set_value
self.update(
{
"path": path,
"comment": comment,
"domain": domain,
"secure": secure,
"httponly": httponly,
"partitioned": partitioned,
"expires": None,
"max-age": None,
"samesite": None,
}
)
if expires is not None:
self._set_value("expires", expires)
if max_age is not None:
self._set_value("max-age", max_age)
if samesite is not None:
self._set_value("samesite", samesite)
def __setitem__(self, key, value):
deprecation(
"Setting values on a Cookie object as a dict has been deprecated. "
"This feature will be removed in v24.3. You should instead set "
f"values on cookies as object properties: cookie.{key}=... ",
24.3,
)
self._set_value(key, value)
# This is a temporary method for backwards compat and should be removed
# in v24.3 when this is no longer a dict
def _set_value(self, key: str, value: Any) -> None:
if key not in self._keys:
raise KeyError("Unknown cookie property: %s=%s" % (key, value))
if value is not None:
if key.lower() == "max-age" and not str(value).isdigit():
raise ValueError("Cookie max-age must be an integer")
elif key.lower() == "expires" and not isinstance(value, datetime):
raise TypeError("Cookie 'expires' property must be a datetime")
elif key.lower() == "samesite":
if value.lower() not in SAMESITE_VALUES:
raise TypeError(
"Cookie 'samesite' property must "
f"be one of: {','.join(SAMESITE_VALUES)}"
)
value = value.title()
super().__setitem__(key, value)
def encode(self, encoding):
"""
Encode the cookie content in a specific type of encoding instructed
by the developer. Leverages the :func:`str.encode` method provided
by python.
This method can be used to encode and embed ``utf-8`` content into
the cookies.
:param encoding: Encoding to be used with the cookie
:return: Cookie encoded in a codec of choosing.
:except: UnicodeEncodeError
"""
deprecation(
"Direct encoding of a Cookie object has been deprecated and will "
"be removed in v24.3.",
24.3,
)
return str(self).encode(encoding)
def __str__(self):
"""Format as a Set-Cookie header value."""
output = ["%s=%s" % (self.key, _quote(self.value))]
key_index = list(self._keys)
for key, value in sorted(
self.items(), key=lambda x: key_index.index(x[0])
):
if value is not None and value is not False:
if key == "max-age":
try:
output.append("%s=%d" % (self._keys[key], value))
except TypeError:
output.append("%s=%s" % (self._keys[key], value))
elif key == "expires":
output.append(
"%s=%s"
% (
self._keys[key],
value.strftime("%a, %d-%b-%Y %T GMT"),
)
)
elif key in self._flags:
output.append(self._keys[key])
else:
output.append("%s=%s" % (self._keys[key], value))
return "; ".join(output)
@property
def path(self) -> str: # no cov
return self["path"]
@path.setter
def path(self, value: str) -> None: # no cov
self._set_value("path", value)
@property
def expires(self) -> Optional[datetime]: # no cov
return self.get("expires")
@expires.setter
def expires(self, value: datetime) -> None: # no cov
self._set_value("expires", value)
@property
def comment(self) -> Optional[str]: # no cov
return self.get("comment")
@comment.setter
def comment(self, value: str) -> None: # no cov
self._set_value("comment", value)
@property
def domain(self) -> Optional[str]: # no cov
return self.get("domain")
@domain.setter
def domain(self, value: str) -> None: # no cov
self._set_value("domain", value)
@property
def max_age(self) -> Optional[int]: # no cov
return self.get("max-age")
@max_age.setter
def max_age(self, value: int) -> None: # no cov
self._set_value("max-age", value)
@property
def secure(self) -> bool: # no cov
return self.get("secure", False)
@secure.setter
def secure(self, value: bool) -> None: # no cov
self._set_value("secure", value)
@property
def httponly(self) -> bool: # no cov
return self.get("httponly", False)
@httponly.setter
def httponly(self, value: bool) -> None: # no cov
self._set_value("httponly", value)
@property
def samesite(self) -> Optional[SameSite]: # no cov
return self.get("samesite")
@samesite.setter
def samesite(self, value: SameSite) -> None: # no cov
self._set_value("samesite", value)
@property
def partitioned(self) -> bool: # no cov
return self.get("partitioned", False)
@partitioned.setter
def partitioned(self, value: bool) -> None: # no cov
self._set_value("partitioned", value)
@classmethod
def make_key(
cls, key: str, host_prefix: bool = False, secure_prefix: bool = False
) -> str:
if host_prefix and secure_prefix:
raise ServerError(
"Both host_prefix and secure_prefix were requested. "
"A cookie should have only one prefix."
)
elif host_prefix:
key = cls.HOST_PREFIX + key
elif secure_prefix:
key = cls.SECURE_PREFIX + key
return key

View File

@@ -22,8 +22,6 @@ from traceback import extract_tb
from sanic.exceptions import BadRequest, SanicException from sanic.exceptions import BadRequest, SanicException
from sanic.helpers import STATUS_CODES from sanic.helpers import STATUS_CODES
from sanic.log import deprecation, logger
from sanic.pages.error import ErrorPage
from sanic.response import html, json, text from sanic.response import html, json, text
@@ -39,11 +37,11 @@ if t.TYPE_CHECKING:
from sanic import HTTPResponse, Request from sanic import HTTPResponse, Request
DEFAULT_FORMAT = "auto" DEFAULT_FORMAT = "auto"
FALLBACK_TEXT = """\ FALLBACK_TEXT = (
The application encountered an unexpected error and could not continue.\ "The server encountered an internal error and "
""" "cannot complete your request."
)
FALLBACK_STATUS = 500 FALLBACK_STATUS = 500
JSON = "application/json"
class BaseRenderer: class BaseRenderer:
@@ -92,10 +90,8 @@ 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
)() )
output.status = self.status return output()
output.headers.update(self.headers)
return output
def minimal(self) -> HTTPResponse: # noqa def minimal(self) -> HTTPResponse: # noqa
""" """
@@ -119,18 +115,134 @@ class HTMLRenderer(BaseRenderer):
The default fallback type. The default fallback type.
""" """
TRACEBACK_STYLE = """
html { font-family: sans-serif }
h2 { color: #888; }
.tb-wrapper p, dl, dd { margin: 0 }
.frame-border { margin: 1rem }
.frame-line > *, dt, dd { padding: 0.3rem 0.6rem }
.frame-line, dl { margin-bottom: 0.3rem }
.frame-code, dd { font-size: 16px; padding-left: 4ch }
.tb-wrapper, dl { border: 1px solid #eee }
.tb-header,.obj-header {
background: #eee; padding: 0.3rem; font-weight: bold
}
.frame-descriptor, dt { background: #e2eafb; font-size: 14px }
"""
TRACEBACK_WRAPPER_HTML = (
"<div class=tb-header>{exc_name}: {exc_value}</div>"
"<div class=tb-wrapper>{frame_html}</div>"
)
TRACEBACK_BORDER = (
"<div class=frame-border>"
"The above exception was the direct cause of the following exception:"
"</div>"
)
TRACEBACK_LINE_HTML = (
"<div class=frame-line>"
"<p class=frame-descriptor>"
"File {0.filename}, line <i>{0.lineno}</i>, "
"in <code><b>{0.name}</b></code>"
"<p class=frame-code><code>{0.line}</code>"
"</div>"
)
OBJECT_WRAPPER_HTML = (
"<div class=obj-header>{title}</div>"
"<dl class={obj_type}>{display_html}</dl>"
)
OBJECT_DISPLAY_HTML = "<dt>{key}</dt><dd><code>{value}</code></dd>"
OUTPUT_HTML = (
"<!DOCTYPE html><html lang=en>"
"<meta charset=UTF-8><title>{title}</title>\n"
"<style>{style}</style>\n"
"<h1>{title}</h1><p>{text}\n"
"{body}"
)
def full(self) -> HTTPResponse: def full(self) -> HTTPResponse:
page = ErrorPage( return html(
debug=self.debug, self.OUTPUT_HTML.format(
title=super().title, title=self.title,
text=super().text, text=self.text,
request=self.request, style=self.TRACEBACK_STYLE,
exc=self.exception, body=self._generate_body(full=True),
),
status=self.status,
) )
return html(page.render())
def minimal(self) -> HTTPResponse: def minimal(self) -> HTTPResponse:
return self.full() return html(
self.OUTPUT_HTML.format(
title=self.title,
text=self.text,
style=self.TRACEBACK_STYLE,
body=self._generate_body(full=False),
),
status=self.status,
headers=self.headers,
)
@property
def text(self):
return escape(super().text)
@property
def title(self):
return escape(f"⚠️ {super().title}")
def _generate_body(self, *, full):
lines = []
if full:
_, exc_value, __ = sys.exc_info()
exceptions = []
while exc_value:
exceptions.append(self._format_exc(exc_value))
exc_value = exc_value.__cause__
traceback_html = self.TRACEBACK_BORDER.join(reversed(exceptions))
appname = escape(self.request.app.name)
name = escape(self.exception.__class__.__name__)
value = escape(self.exception)
path = escape(self.request.path)
lines += [
f"<h2>Traceback of {appname} " "(most recent call last):</h2>",
f"{traceback_html}",
"<div class=summary><p>",
f"<b>{name}: {value}</b> "
f"while handling path <code>{path}</code>",
"</div>",
]
for attr, display in (("context", True), ("extra", bool(full))):
info = getattr(self.exception, attr, None)
if info and display:
lines.append(self._generate_object_display(info, attr))
return "\n".join(lines)
def _generate_object_display(
self, obj: t.Dict[str, t.Any], descriptor: str
) -> str:
display = "".join(
self.OBJECT_DISPLAY_HTML.format(key=key, value=value)
for key, value in obj.items()
)
return self.OBJECT_WRAPPER_HTML.format(
title=descriptor.title(),
display_html=display,
obj_type=descriptor.lower(),
)
def _format_exc(self, exc):
frames = extract_tb(exc.__traceback__)
frame_html = "".join(
self.TRACEBACK_LINE_HTML.format(frame) for frame in frames
)
return self.TRACEBACK_WRAPPER_HTML.format(
exc_name=escape(exc.__class__.__name__),
exc_value=escape(exc),
frame_html=frame_html,
)
class TextRenderer(BaseRenderer): class TextRenderer(BaseRenderer):
@@ -148,7 +260,8 @@ 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:
@@ -158,7 +271,9 @@ 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
@@ -217,11 +332,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, dumps=self.dumps) return json(output, status=self.status, 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, dumps=self.dumps) return json(output, status=self.status, dumps=self.dumps)
def _generate_output(self, *, full): def _generate_output(self, *, full):
output = { output = {
@@ -275,18 +390,21 @@ def escape(text):
return f"{text}".replace("&", "&amp;").replace("<", "&lt;") return f"{text}".replace("&", "&amp;").replace("<", "&lt;")
MIME_BY_CONFIG = { RENDERERS_BY_CONFIG = {
"text": "text/plain", "html": HTMLRenderer,
"json": "application/json", "json": JSONRenderer,
"html": "text/html", "text": TextRenderer,
} }
CONFIG_BY_MIME = {v: k for k, v in MIME_BY_CONFIG.items()}
RENDERERS_BY_CONTENT_TYPE = { RENDERERS_BY_CONTENT_TYPE = {
"text/plain": TextRenderer, "text/plain": TextRenderer,
"application/json": JSONRenderer, "application/json": JSONRenderer,
"multipart/form-data": HTMLRenderer, "multipart/form-data": HTMLRenderer,
"text/html": HTMLRenderer, "text/html": HTMLRenderer,
} }
CONTENT_TYPE_BY_RENDERERS = {
v: k for k, v in RENDERERS_BY_CONTENT_TYPE.items()
}
# Handler source code is checked for which response types it returns with the # Handler source code is checked for which response types it returns with the
# route error_format="auto" (default) to determine which format to use. # route error_format="auto" (default) to determine which format to use.
@@ -302,7 +420,7 @@ RESPONSE_MAPPING = {
def check_error_format(format): def check_error_format(format):
if format not in MIME_BY_CONFIG and format != "auto": if format not in RENDERERS_BY_CONFIG and format != "auto":
raise SanicException(f"Unknown format: {format}") raise SanicException(f"Unknown format: {format}")
@@ -312,73 +430,103 @@ def exception_response(
debug: bool, debug: bool,
fallback: str, fallback: str,
base: t.Type[BaseRenderer], base: t.Type[BaseRenderer],
renderer: t.Optional[t.Type[BaseRenderer]] = None, renderer: t.Type[t.Optional[BaseRenderer]] = None,
) -> HTTPResponse: ) -> HTTPResponse:
""" """
Render a response for the default FALLBACK exception handler. Render a response for the default FALLBACK exception handler.
""" """
content_type = None
if not renderer: if not renderer:
mt = guess_mime(request, fallback) # Make sure we have something set
renderer = RENDERERS_BY_CONTENT_TYPE.get(mt, base) renderer = base
render_format = fallback
if request:
# If there is a request, try and get the format
# from the route
if request.route:
try:
if request.route.extra.error_format:
render_format = request.route.extra.error_format
except AttributeError:
...
content_type = request.headers.getone("content-type", "").split(
";"
)[0]
acceptable = request.accept
# If the format is auto still, make a guess
if render_format == "auto":
# First, if there is an Accept header, check if text/html
# is the first option
# According to MDN Web Docs, all major browsers use text/html
# as the primary value in Accept (with the exception of IE 8,
# and, well, if you are supporting IE 8, then you have bigger
# problems to concern yourself with than what default exception
# renderer is used)
# Source:
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Content_negotiation/List_of_default_Accept_values
if acceptable and acceptable[0].match(
"text/html",
allow_type_wildcard=False,
allow_subtype_wildcard=False,
):
renderer = HTMLRenderer
# Second, if there is an Accept header, check if
# application/json is an option, or if the content-type
# is application/json
elif (
acceptable
and acceptable.match(
"application/json",
allow_type_wildcard=False,
allow_subtype_wildcard=False,
)
or content_type == "application/json"
):
renderer = JSONRenderer
# Third, if there is no Accept header, assume we want text.
# The likely use case here is a raw socket.
elif not acceptable:
renderer = TextRenderer
else:
# Fourth, look to see if there was a JSON body
# When in this situation, the request is probably coming
# from curl, an API client like Postman or Insomnia, or a
# package like requests or httpx
try:
# Give them the benefit of the doubt if they did:
# $ curl localhost:8000 -d '{"foo": "bar"}'
# And provide them with JSONRenderer
renderer = JSONRenderer if request.json else base
except BadRequest:
renderer = base
else:
renderer = RENDERERS_BY_CONFIG.get(render_format, renderer)
# Lastly, if there is an Accept header, make sure
# our choice is okay
if acceptable:
type_ = CONTENT_TYPE_BY_RENDERERS.get(renderer) # type: ignore
if type_ and type_ not in acceptable:
# If the renderer selected is not in the Accept header
# look through what is in the Accept header, and select
# the first option that matches. Otherwise, just drop back
# to the original default
for accept in acceptable:
mtype = f"{accept.type_}/{accept.subtype}"
maybe = RENDERERS_BY_CONTENT_TYPE.get(mtype)
if maybe:
renderer = maybe
break
else:
renderer = base
renderer = t.cast(t.Type[BaseRenderer], renderer) renderer = t.cast(t.Type[BaseRenderer], renderer)
return renderer(request, exception, debug).render() return renderer(request, exception, debug).render()
def guess_mime(req: Request, fallback: str) -> str:
# Attempt to find a suitable MIME format for the response.
# Insertion-ordered map of formats["html"] = "source of that suggestion"
formats = {}
name = ""
# Route error_format (by magic from handler code if auto, the default)
if req.route:
name = req.route.name
f = req.route.extra.error_format
if f in MIME_BY_CONFIG:
formats[f] = name
if not formats and fallback in MIME_BY_CONFIG:
formats[fallback] = "FALLBACK_ERROR_FORMAT"
# If still not known, check for the request for clues of JSON
if not formats and fallback == "auto" and req.accept.match(JSON):
if JSON in req.accept: # Literally, not wildcard
formats["json"] = "request.accept"
elif JSON in req.headers.getone("content-type", ""):
formats["json"] = "content-type"
# DEPRECATION: Remove this block in 24.3
else:
c = None
try:
c = req.json
except BadRequest:
pass
if c:
formats["json"] = "request.json"
deprecation(
"Response type was determined by the JSON content of "
"the request. This behavior is deprecated and will be "
"removed in v24.3. Please specify the format either by\n"
f' error_format="json" on route {name}, by\n'
' FALLBACK_ERROR_FORMAT = "json", or by adding header\n'
" accept: application/json to your requests.",
24.3,
)
# Any other supported formats
if fallback == "auto":
for k in MIME_BY_CONFIG:
if k not in formats:
formats[k] = "any"
mimes = [MIME_BY_CONFIG[k] for k in formats]
m = req.accept.match(*mimes)
if m:
format = CONFIG_BY_MIME[m.mime]
source = formats[format]
logger.debug(
f"The client accepts {m.header}, using '{format}' from {source}"
)
else:
logger.debug(f"No format found, the client accepts {req.accept!r}")
return m.mime

View File

@@ -1,6 +1,5 @@
from asyncio import CancelledError, Protocol from asyncio import CancelledError
from os import PathLike from typing import Any, Dict, Optional, Union
from typing import Any, Dict, Optional, Sequence, Union
from sanic.helpers import STATUS_CODES from sanic.helpers import STATUS_CODES
@@ -10,158 +9,51 @@ class RequestCancelled(CancelledError):
class ServerKilled(Exception): class ServerKilled(Exception):
""" ...
Exception Sanic server uses when killing a server process for something
unexpected happening.
"""
class SanicException(Exception): class SanicException(Exception):
"""
Generic exception that will generate an HTTP response when raised
in the context of a request lifecycle.
Usually it is best practice to use one of the more specific exceptions
than this generic. Even when trying to raise a 500, it is generally
preferrable to use :class:`.ServerError`
.. code-block:: python
raise SanicException(
"Something went wrong",
status_code=999,
context={
"info": "Some additional details",
},
headers={
"X-Foo": "bar"
}
)
:param message: The message to be sent to the client. If ``None``
then the appropriate HTTP response status message will be used
instead, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param status_code: The HTTP response code to send, if applicable. If
``None``, then it will be 500, defaults to None
:type status_code: Optional[int], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
"""
status_code: int = 500
quiet: Optional[bool] = False
headers: Dict[str, str] = {}
message: str = "" message: str = ""
def __init__( def __init__(
self, self,
message: Optional[Union[str, bytes]] = None, message: Optional[Union[str, bytes]] = None,
status_code: Optional[int] = None, status_code: Optional[int] = None,
*,
quiet: Optional[bool] = None, quiet: Optional[bool] = None,
context: Optional[Dict[str, Any]] = None, context: Optional[Dict[str, Any]] = None,
extra: Optional[Dict[str, Any]] = None, extra: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
) -> None: ) -> None:
self.context = context self.context = context
self.extra = extra self.extra = extra
status_code = status_code or getattr(
self.__class__, "status_code", None
)
quiet = quiet or getattr(self.__class__, "quiet", None)
headers = headers or getattr(self.__class__, "headers", {})
if message is None: if message is None:
if self.message: if self.message:
message = self.message message = self.message
elif status_code: elif status_code is not None:
msg: bytes = STATUS_CODES.get(status_code, b"") msg: bytes = STATUS_CODES.get(status_code, b"")
message = msg.decode("utf8") message = msg.decode("utf8")
super().__init__(message) super().__init__(message)
self.status_code = status_code or self.status_code if status_code is not None:
self.quiet = quiet self.status_code = status_code
self.headers = headers
# quiet=None/False/True with None meaning choose by status
if quiet or quiet is None and status_code not in (None, 500):
self.quiet = True
class HTTPException(SanicException): class NotFound(SanicException):
"""
A base class for other exceptions and should not be called directly.
"""
def __init__(
self,
message: Optional[Union[str, bytes]] = None,
*,
quiet: Optional[bool] = None,
context: Optional[Dict[str, Any]] = None,
extra: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
) -> None:
super().__init__(
message,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
class NotFound(HTTPException):
""" """
**Status**: 404 Not Found **Status**: 404 Not Found
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Not Found' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
""" """
status_code = 404 status_code = 404
quiet = True quiet = True
class BadRequest(HTTPException): class BadRequest(SanicException):
""" """
**Status**: 400 Bad Request **Status**: 400 Bad Request
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Bad Request' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
""" """
status_code = 400 status_code = 400
@@ -169,133 +61,51 @@ class BadRequest(HTTPException):
InvalidUsage = BadRequest InvalidUsage = BadRequest
BadURL = BadRequest
class MethodNotAllowed(HTTPException): class BadURL(BadRequest):
...
class MethodNotAllowed(SanicException):
""" """
**Status**: 405 Method Not Allowed **Status**: 405 Method Not Allowed
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Method Not Allowed' will be sent,
defaults to None
:type message: Optional[Union[str, bytes]], optional
:param method: The HTTP method that was used, defaults to an empty string
:type method: Optional[str], optional
:param allowed_methods: The HTTP methods that can be used instead of the
one that was attempted
:type allowed_methods: Optional[Sequence[str]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
""" """
status_code = 405 status_code = 405
quiet = True quiet = True
def __init__( def __init__(self, message, method, allowed_methods):
self, super().__init__(message)
message: Optional[Union[str, bytes]] = None, self.headers = {"Allow": ", ".join(allowed_methods)}
method: str = "",
allowed_methods: Optional[Sequence[str]] = None,
*,
quiet: Optional[bool] = None,
context: Optional[Dict[str, Any]] = None,
extra: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
):
super().__init__(
message,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
if allowed_methods:
self.headers = {
**self.headers,
"Allow": ", ".join(allowed_methods),
}
self.method = method
self.allowed_methods = allowed_methods
MethodNotSupported = MethodNotAllowed MethodNotSupported = MethodNotAllowed
class ServerError(HTTPException): class ServerError(SanicException):
""" """
**Status**: 500 Internal Server Error **Status**: 500 Internal Server Error
A general server-side error has occurred. If no other HTTP exception is
appropriate, then this should be used
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Internal Server Error' will be sent,
defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
""" """
status_code = 500 status_code = 500
InternalServerError = ServerError class ServiceUnavailable(SanicException):
class ServiceUnavailable(HTTPException):
""" """
**Status**: 503 Service Unavailable **Status**: 503 Service Unavailable
The server is currently unavailable (because it is overloaded or The server is currently unavailable (because it is overloaded or
down for maintenance). Generally, this is a temporary state. down for maintenance). Generally, this is a temporary state.
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Bad Request' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
""" """
status_code = 503 status_code = 503
quiet = True quiet = True
class URLBuildError(HTTPException): class URLBuildError(ServerError):
""" """
**Status**: 500 Internal Server Error **Status**: 500 Internal Server Error
An exception used by Sanic internals when unable to build a URL.
""" """
status_code = 500 status_code = 500
@@ -304,77 +114,30 @@ class URLBuildError(HTTPException):
class FileNotFound(NotFound): class FileNotFound(NotFound):
""" """
**Status**: 404 Not Found **Status**: 404 Not Found
A specific form of :class:`.NotFound` that is specifically when looking
for a file on the file system at a known path.
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Not Found' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param path: The path, if any, to the file that could not
be found, defaults to None
:type path: Optional[PathLike], optional
:param relative_url: A relative URL of the file, defaults to None
:type relative_url: Optional[str], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
""" """
def __init__( def __init__(self, message, path, relative_url):
self, super().__init__(message)
message: Optional[Union[str, bytes]] = None,
path: Optional[PathLike] = None,
relative_url: Optional[str] = None,
*,
quiet: Optional[bool] = None,
context: Optional[Dict[str, Any]] = None,
extra: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
):
super().__init__(
message,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
self.path = path self.path = path
self.relative_url = relative_url self.relative_url = relative_url
class RequestTimeout(HTTPException): class RequestTimeout(SanicException):
""" """The Web server (running the Web site) thinks that there has been too
The Web server (running the Web site) thinks that there has been too
long an interval of time between 1) the establishment of an IP long an interval of time between 1) the establishment of an IP
connection (socket) between the client and the server and connection (socket) between the client and the server and
2) the receipt of any data on that socket, so the server has dropped 2) the receipt of any data on that socket, so the server has dropped
the connection. The socket connection has actually been lost - the Web the connection. The socket connection has actually been lost - the Web
server has 'timed out' on that particular socket connection. server has 'timed out' on that particular socket connection.
This is an internal exception thrown by Sanic and should not be used
directly.
""" """
status_code = 408 status_code = 408
quiet = True quiet = True
class PayloadTooLarge(HTTPException): class PayloadTooLarge(SanicException):
""" """
**Status**: 413 Payload Too Large **Status**: 413 Payload Too Large
This is an internal exception thrown by Sanic and should not be used
directly.
""" """
status_code = 413 status_code = 413
@@ -384,126 +147,34 @@ class PayloadTooLarge(HTTPException):
class HeaderNotFound(BadRequest): class HeaderNotFound(BadRequest):
""" """
**Status**: 400 Bad Request **Status**: 400 Bad Request
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Bad Request' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
""" """
class InvalidHeader(BadRequest): class InvalidHeader(BadRequest):
""" """
**Status**: 400 Bad Request **Status**: 400 Bad Request
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Bad Request' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
""" """
class ContentRange(Protocol): class RangeNotSatisfiable(SanicException):
total: int
class RangeNotSatisfiable(HTTPException):
""" """
**Status**: 416 Range Not Satisfiable **Status**: 416 Range Not Satisfiable
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Range Not Satisfiable' will be sent,
defaults to None
:type message: Optional[Union[str, bytes]], optional
:param content_range: An object meeting the :class:`.ContentRange` protocol
that has a ``total`` property, defaults to None
:type content_range: Optional[ContentRange], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
""" """
status_code = 416 status_code = 416
quiet = True quiet = True
def __init__( def __init__(self, message, content_range):
self, super().__init__(message)
message: Optional[Union[str, bytes]] = None, self.headers = {"Content-Range": f"bytes */{content_range.total}"}
content_range: Optional[ContentRange] = None,
*,
quiet: Optional[bool] = None,
context: Optional[Dict[str, Any]] = None,
extra: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
):
super().__init__(
message,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
if content_range is not None:
self.headers = {
**self.headers,
"Content-Range": f"bytes */{content_range.total}",
}
ContentRangeError = RangeNotSatisfiable ContentRangeError = RangeNotSatisfiable
class ExpectationFailed(HTTPException): class ExpectationFailed(SanicException):
""" """
**Status**: 417 Expectation Failed **Status**: 417 Expectation Failed
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Expectation Failed' will be sent,
defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
""" """
status_code = 417 status_code = 417
@@ -513,25 +184,9 @@ class ExpectationFailed(HTTPException):
HeaderExpectationFailed = ExpectationFailed HeaderExpectationFailed = ExpectationFailed
class Forbidden(HTTPException): class Forbidden(SanicException):
""" """
**Status**: 403 Forbidden **Status**: 403 Forbidden
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Forbidden' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
""" """
status_code = 403 status_code = 403
@@ -547,33 +202,20 @@ class InvalidRangeType(RangeNotSatisfiable):
quiet = True quiet = True
class PyFileError(SanicException): class PyFileError(Exception):
def __init__( def __init__(self, file):
self, super().__init__("could not execute config file %s", file)
file,
status_code: Optional[int] = None,
*,
quiet: Optional[bool] = None,
context: Optional[Dict[str, Any]] = None,
extra: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
):
super().__init__(
"could not execute config file %s" % file,
status_code=status_code,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
class Unauthorized(HTTPException): class Unauthorized(SanicException):
""" """
**Status**: 401 Unauthorized **Status**: 401 Unauthorized
When present, additional keyword arguments may be used to complete :param message: Message describing the exception.
the WWW-Authentication header. :param status_code: HTTP Status code.
:param scheme: Name of the authentication scheme to be used.
When present, kwargs is used to complete the WWW-Authentication header.
Examples:: Examples::
@@ -598,58 +240,21 @@ class Unauthorized(HTTPException):
raise Unauthorized("Auth required.", raise Unauthorized("Auth required.",
scheme="Bearer", scheme="Bearer",
realm="Restricted Area") realm="Restricted Area")
:param message: The message to be sent to the client. If ``None``
then the HTTP status 'Bad Request' will be sent, defaults to None
:type message: Optional[Union[str, bytes]], optional
:param scheme: Name of the authentication scheme to be used.
:type scheme: Optional[str], optional
:param quiet: When ``True``, the error traceback will be suppressed
from the logs, defaults to None
:type quiet: Optional[bool], optional
:param context: Additional mapping of key/value data that will be
sent to the client upon exception, defaults to None
:type context: Optional[Dict[str, Any]], optional
:param extra: Additional mapping of key/value data that will NOT be
sent to the client when in PRODUCTION mode, defaults to None
:type extra: Optional[Dict[str, Any]], optional
:param headers: Additional headers that should be sent with the HTTP
response, defaults to None
:type headers: Optional[Dict[str, Any]], optional
""" """
status_code = 401 status_code = 401
quiet = True quiet = True
def __init__( def __init__(self, message, status_code=None, scheme=None, **kwargs):
self, super().__init__(message, status_code)
message: Optional[Union[str, bytes]] = None,
scheme: Optional[str] = None,
*,
quiet: Optional[bool] = None,
context: Optional[Dict[str, Any]] = None,
extra: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, Any]] = None,
**challenges,
):
super().__init__(
message,
quiet=quiet,
context=context,
extra=extra,
headers=headers,
)
# if auth-scheme is specified, set "WWW-Authenticate" header # if auth-scheme is specified, set "WWW-Authenticate" header
if scheme is not None: if scheme is not None:
values = [ values = ['{!s}="{!s}"'.format(k, v) for k, v in kwargs.items()]
'{!s}="{!s}"'.format(k, v) for k, v in challenges.items()
]
challenge = ", ".join(values) challenge = ", ".join(values)
self.headers = { self.headers = {
**self.headers, "WWW-Authenticate": f"{scheme} {challenge}".rstrip()
"WWW-Authenticate": f"{scheme} {challenge}".rstrip(),
} }

View File

@@ -75,4 +75,4 @@ class ContentRangeHandler:
} }
def __bool__(self): def __bool__(self):
return hasattr(self, "size") and self.size > 0 return self.size > 0

View File

@@ -3,12 +3,9 @@ from __future__ import annotations
from typing import Dict, List, Optional, Tuple, Type from typing import Dict, List, Optional, Tuple, Type
from sanic.errorpages import BaseRenderer, TextRenderer, exception_response from sanic.errorpages import BaseRenderer, TextRenderer, exception_response
from sanic.exceptions import ServerError from sanic.log import deprecation, 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:
@@ -46,11 +43,16 @@ class ErrorHandler:
if name is None: if name is None:
name = "__ALL_ROUTES__" name = "__ALL_ROUTES__"
message = ( error_logger.warning(
f"Duplicate exception handler definition on: route={name} " f"Duplicate exception handler definition on: route={name} "
f"and exception={exc}" f"and exception={exc}"
) )
raise ServerError(message) deprecation(
"A duplicate exception handler definition was discovered. "
"This may cause unintended consequences. A warning has been "
"issued now, but it will not be allowed starting in v23.3.",
23.3,
)
self.cached_handlers[key] = handler self.cached_handlers[key] = handler
def add(self, exception, handler, route_names: Optional[List[str]] = None): def add(self, exception, handler, route_names: Optional[List[str]] = None):
@@ -150,7 +152,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: Request, exception: Exception) -> HTTPResponse: def default(self, request, exception):
""" """
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

@@ -19,6 +19,7 @@ OptionsIterable = Iterable[Tuple[str, str]] # May contain duplicate keys
_token, _quoted = r"([\w!#$%&'*+\-.^_`|~]+)", r'"([^"]*)"' _token, _quoted = r"([\w!#$%&'*+\-.^_`|~]+)", r'"([^"]*)"'
_param = re.compile(rf";\s*{_token}=(?:{_token}|{_quoted})", re.ASCII) _param = re.compile(rf";\s*{_token}=(?:{_token}|{_quoted})", re.ASCII)
_firefox_quote_escape = re.compile(r'\\"(?!; |\s*$)')
_ipv6 = "(?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}" _ipv6 = "(?:[0-9A-Fa-f]{0,4}:){2,7}[0-9A-Fa-f]{0,4}"
_ipv6_re = re.compile(_ipv6) _ipv6_re = re.compile(_ipv6)
_host_re = re.compile( _host_re = re.compile(
@@ -32,6 +33,15 @@ _host_re = re.compile(
# For more information, consult ../tests/test_requests.py # For more information, consult ../tests/test_requests.py
def parse_arg_as_accept(f):
def func(self, other, *args, **kwargs):
if not isinstance(other, MediaType) and other:
other = MediaType._parse(other)
return f(self, other, *args, **kwargs)
return func
class MediaType: class MediaType:
"""A media type, as used in the Accept header.""" """A media type, as used in the Accept header."""
@@ -41,67 +51,57 @@ class MediaType:
subtype: str, subtype: str,
**params: str, **params: str,
): ):
self.type = type_ self.type_ = type_
self.subtype = subtype self.subtype = subtype
self.q = float(params.get("q", "1.0")) self.q = float(params.get("q", "1.0"))
self.params = params self.params = params
self.mime = f"{type_}/{subtype}" self.mime = f"{type_}/{subtype}"
self.key = (
-1 * self.q,
-1 * len(self.params),
self.subtype == "*",
self.type == "*",
)
def __repr__(self): def __repr__(self):
return self.mime + "".join(f";{k}={v}" for k, v in self.params.items()) return self.mime + "".join(f";{k}={v}" for k, v in self.params.items())
def __eq__(self, other): def __eq__(self, other):
"""Check for mime (str or MediaType) identical type/subtype. """Check for mime (str or MediaType) identical type/subtype."""
Parameters such as q are not considered."""
if isinstance(other, str): if isinstance(other, str):
# Give a friendly reminder if str contains parameters
if ";" in other:
raise ValueError("Use match() to compare with parameters")
return self.mime == other return self.mime == other
if isinstance(other, MediaType): if isinstance(other, MediaType):
# Ignore parameters silently with MediaType objects
return self.mime == other.mime return self.mime == other.mime
return NotImplemented return NotImplemented
def match( def match(
self, self,
mime_with_params: Union[str, MediaType], mime: str,
allow_type_wildcard=True,
allow_subtype_wildcard=True,
) -> Optional[MediaType]: ) -> Optional[MediaType]:
"""Check if this media type matches the given mime type/subtype. """Check if this media type matches the given mime type/subtype.
Wildcards are supported both ways on both type and subtype. Wildcards are supported both ways on both type and subtype.
If mime contains a semicolon, optionally followed by parameters,
the parameters of the two media types must match exactly.
Note: Use the `==` operator instead to check for literal matches Note: Use the `==` operator instead to check for literal matches
without expanding wildcards. without expanding wildcards.
@param media_type: A type/subtype string to match. @param media_type: A type/subtype string to match.
@return `self` if the media types are compatible, else `None` @return `self` if the media types are compatible, else `None`
""" """
mt = ( mt = MediaType._parse(mime)
MediaType._parse(mime_with_params)
if isinstance(mime_with_params, str)
else mime_with_params
)
return ( return (
self self
if ( if (
mt
# All parameters given in the other media type must match
and all(self.params.get(k) == v for k, v in mt.params.items())
# Subtype match # Subtype match
and ( (self.subtype in (mt.subtype, "*") or mt.subtype == "*")
self.subtype == mt.subtype
or self.subtype == "*"
or mt.subtype == "*"
)
# Type match # Type match
and (self.type_ in (mt.type_, "*") or mt.type_ == "*")
# Allow disabling wildcards (backwards compatibility with tests)
and ( and (
self.type == mt.type or self.type == "*" or mt.type == "*" allow_type_wildcard
or self.type_ != "*"
and mt.type_ != "*"
)
and (
allow_subtype_wildcard
or self.subtype != "*"
and mt.subtype != "*"
) )
) )
else None else None
@@ -110,16 +110,19 @@ class MediaType:
@property @property
def has_wildcard(self) -> bool: def has_wildcard(self) -> bool:
"""Return True if this media type has a wildcard in it.""" """Return True if this media type has a wildcard in it."""
return any(part == "*" for part in (self.subtype, self.type)) return "*" in (self.subtype, self.type_)
@property
def is_wildcard(self) -> bool:
"""Return True if this is the wildcard `*/*`"""
return self.type_ == "*" and self.subtype == "*"
@classmethod @classmethod
def _parse(cls, mime_with_params: str) -> Optional[MediaType]: def _parse(cls, mime_with_params: str) -> MediaType:
mtype = mime_with_params.strip() mtype = mime_with_params.strip()
if "/" not in mime_with_params:
return None
mime, *raw_params = mtype.split(";") media, *raw_params = mtype.split(";")
type_, subtype = mime.split("/", 1) type_, subtype = media.split("/", 1)
if not type_ or not subtype: if not type_ or not subtype:
raise ValueError(f"Invalid media type: {mtype}") raise ValueError(f"Invalid media type: {mtype}")
@@ -133,63 +136,17 @@ class MediaType:
return cls(type_.lstrip(), subtype.rstrip(), **params) return cls(type_.lstrip(), subtype.rstrip(), **params)
class Matched: class Matched(str):
"""A matching result of a MIME string against a header.""" """A matching result of a MIME string against a MediaType."""
def __init__(self, mime: str, header: Optional[MediaType]): def __new__(cls, mime: str, m: Optional[MediaType]):
self.mime = mime return super().__new__(cls, mime)
self.header = header
def __init__(self, mime: str, m: Optional[MediaType]):
self.m = m
def __repr__(self): def __repr__(self):
return f"<{self} matched {self.header}>" if self else "<no match>" return f"<{self} matched {self.m}>" if self else "<no match>"
def __str__(self):
return self.mime
def __bool__(self):
return self.header is not None
def __eq__(self, other: Any) -> bool:
try:
comp, other_accept = self._compare(other)
except TypeError:
return False
return bool(
comp
and (
(self.header and other_accept.header)
or (not self.header and not other_accept.header)
)
)
def _compare(self, other) -> Tuple[bool, Matched]:
if isinstance(other, str):
parsed = Matched.parse(other)
if self.mime == other:
return True, parsed
other = parsed
if isinstance(other, Matched):
return self.header == other.header, other
raise TypeError(
"Comparison not supported between unequal "
f"mime types of '{self.mime}' and '{other}'"
)
def match(self, other: Union[str, Matched]) -> Optional[Matched]:
accept = Matched.parse(other) if isinstance(other, str) else other
if not self.header or not accept.header:
return None
if self.header.match(accept.header):
return accept
return None
@classmethod
def parse(cls, raw: str) -> Matched:
media_type = MediaType._parse(raw)
return cls(raw, media_type)
class AcceptList(list): class AcceptList(list):
@@ -204,15 +161,14 @@ class AcceptList(list):
- operator 'in' for checking explicit matches (wildcards as literals) - operator 'in' for checking explicit matches (wildcards as literals)
""" """
def match(self, *mimes: str, accept_wildcards=True) -> Matched: def match(self, *mimes: str) -> Matched:
"""Find a media type accepted by the client. """Find a media type accepted by the client.
This method can be used to find which of the media types requested by This method can be used to find which of the media types requested by
the client is most preferred against the ones given as arguments. the client is most preferred against the ones given as arguments.
The ordering of preference is set by: The ordering of preference is set by:
1. The order set by RFC 7231, s. 5.3.2, giving a higher priority 1. The q values on the Accept header, and those being equal,
to q values and more specific type definitions,
2. The order of the arguments (first is most preferred), and 2. The order of the arguments (first is most preferred), and
3. The first matching entry on the Accept header. 3. The first matching entry on the Accept header.
@@ -225,41 +181,29 @@ class AcceptList(list):
header entry `MediaType` or `None` is available as the `m` attribute. header entry `MediaType` or `None` is available as the `m` attribute.
@param mimes: Any MIME types to search for in order of preference. @param mimes: Any MIME types to search for in order of preference.
@param accept_wildcards: Match Accept entries with wildcards in them.
@return A match object with the mime string and the MediaType object. @return A match object with the mime string and the MediaType object.
""" """
a = sorted( l = sorted(
(-acc.q, i, j, mime, acc) [
for j, acc in enumerate(self) (-acc.q, i, j, mime, acc) # Sort by -q, i, j
if accept_wildcards or not acc.has_wildcard for j, acc in enumerate(self)
for i, mime in enumerate(mimes) for i, mime in enumerate(mimes)
if acc.match(mime) if acc.match(mime)
]
) )
return Matched(*(a[0][-2:] if a else ("", None))) return Matched(*(l[0][3:] if l else ("", None)))
def __str__(self):
"""Format as Accept header value (parsed, not original)."""
return ", ".join(str(m) for m in self)
def parse_accept(accept: Optional[str]) -> AcceptList: def parse_accept(accept: str) -> AcceptList:
"""Parse an Accept header and order the acceptable media types in """Parse an Accept header and order the acceptable media types in
according to RFC 7231, s. 5.3.2 accorsing to RFC 7231, s. 5.3.2
https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2 https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2
""" """
if not accept: if not accept:
if accept == "": return AcceptList()
return AcceptList() # Empty header, accept nothing
accept = "*/*" # No header means that all types are accepted
try: try:
a = [ a = [MediaType._parse(mtype) for mtype in accept.split(",")]
mt return AcceptList(sorted(a, key=lambda mtype: -mtype.q))
for mt in [MediaType._parse(mtype) for mtype in accept.split(",")]
if mt
]
if not a:
raise ValueError
return AcceptList(sorted(a, key=lambda x: x.key))
except ValueError: except ValueError:
raise InvalidHeader(f"Invalid header value in Accept: {accept}") raise InvalidHeader(f"Invalid header value in Accept: {accept}")
@@ -267,23 +211,19 @@ def parse_accept(accept: Optional[str]) -> AcceptList:
def parse_content_header(value: str) -> Tuple[str, Options]: def parse_content_header(value: str) -> Tuple[str, Options]:
"""Parse content-type and content-disposition header values. """Parse content-type and content-disposition header values.
E.g. `form-data; name=upload; filename="file.txt"` to E.g. 'form-data; name=upload; filename=\"file.txt\"' to
('form-data', {'name': 'upload', 'filename': 'file.txt'}) ('form-data', {'name': 'upload', 'filename': 'file.txt'})
Mostly identical to cgi.parse_header and werkzeug.parse_options_header Mostly identical to cgi.parse_header and werkzeug.parse_options_header
but runs faster and handles special characters better. but runs faster and handles special characters better. Unescapes quotes.
Unescapes %22 to `"` and %0D%0A to `\n` in field values.
""" """
value = _firefox_quote_escape.sub("%22", value)
pos = value.find(";") pos = value.find(";")
if pos == -1: if pos == -1:
options: Dict[str, Union[int, str]] = {} options: Dict[str, Union[int, str]] = {}
else: else:
options = { options = {
m.group(1) m.group(1).lower(): m.group(2) or m.group(3).replace("%22", '"')
.lower(): (m.group(2) or m.group(3))
.replace("%22", '"')
.replace("%0D%0A", "\n")
for m in _param.finditer(value[pos:]) for m in _param.finditer(value[pos:])
} }
value = value[:pos] value = value[:pos]
@@ -436,7 +376,7 @@ def format_http1_response(status: int, headers: HeaderBytesIterable) -> bytes:
def parse_credentials( def parse_credentials(
header: Optional[str], header: Optional[str],
prefixes: Optional[Union[List, Tuple, Set]] = None, prefixes: 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,7 +1,5 @@
"""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
@@ -159,10 +157,6 @@ 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

@@ -240,14 +240,9 @@ class Http(Stream, metaclass=TouchUpMeta):
headers_instance.getone("upgrade", "").lower() == "websocket" headers_instance.getone("upgrade", "").lower() == "websocket"
) )
try:
url_bytes = self.url.encode("ASCII")
except UnicodeEncodeError:
raise BadRequest("URL may only contain US-ASCII characters.")
# Prepare a Request object # Prepare a Request object
request = self.protocol.request_class( request = self.protocol.request_class(
url_bytes=url_bytes, url_bytes=self.url.encode(),
headers=headers_instance, headers=headers_instance,
head=bytes(head), head=bytes(head),
version=protocol[5:], version=protocol[5:],
@@ -433,9 +428,7 @@ class Http(Stream, metaclass=TouchUpMeta):
if self.request is None: if self.request is None:
self.create_empty_request() self.create_empty_request()
request_middleware = not isinstance( request_middleware = not isinstance(exception, ServiceUnavailable)
exception, (ServiceUnavailable, RequestCancelled)
)
try: try:
await app.handle_exception( await app.handle_exception(
self.request, exception, request_middleware self.request, exception, request_middleware
@@ -450,18 +443,9 @@ class Http(Stream, metaclass=TouchUpMeta):
bogus response for error handling use. bogus response for error handling use.
""" """
# Reformat any URL already received with \xHH escapes for better logs
url_bytes = (
self.url.encode(errors="surrogateescape")
.decode("ASCII", errors="backslashreplace")
.encode("ASCII")
if self.url
else b"*"
)
# FIXME: Avoid this by refactoring error handling and response code # FIXME: Avoid this by refactoring error handling and response code
self.request = self.protocol.request_class( self.request = self.protocol.request_class(
url_bytes=url_bytes, url_bytes=self.url.encode() if self.url else b"*",
headers=Header({}), headers=Header({}),
version="1.1", version="1.1",
method="NONE", method="NONE",

View File

@@ -18,12 +18,7 @@ from typing import (
from sanic.compat import Header from sanic.compat import Header
from sanic.constants import LocalCertCreator from sanic.constants import LocalCertCreator
from sanic.exceptions import ( from sanic.exceptions import PayloadTooLarge, SanicException, ServerError
BadRequest,
PayloadTooLarge,
SanicException,
ServerError,
)
from sanic.helpers import has_message_body from sanic.helpers import has_message_body
from sanic.http.constants import Stage from sanic.http.constants import Stage
from sanic.http.stream import Stream from sanic.http.stream import Stream
@@ -338,17 +333,7 @@ class Http3:
return self.receivers[stream_id] return self.receivers[stream_id]
def _make_request(self, event: HeadersReceived) -> Request: def _make_request(self, event: HeadersReceived) -> Request:
try: headers = Header(((k.decode(), v.decode()) for k, v in event.headers))
headers = Header(
(
(k.decode("ASCII"), v.decode(errors="surrogateescape"))
for k, v in event.headers
)
)
except UnicodeDecodeError:
raise BadRequest(
"Header names may only contain US-ASCII characters."
)
method = headers[":method"] method = headers[":method"]
path = headers[":path"] path = headers[":path"]
scheme = headers.pop(":scheme", "") scheme = headers.pop(":scheme", "")
@@ -357,14 +342,9 @@ class Http3:
if authority: if authority:
headers["host"] = authority headers["host"] = authority
try:
url_bytes = path.encode("ASCII")
except UnicodeEncodeError:
raise BadRequest("URL may only contain US-ASCII characters.")
transport = HTTP3Transport(self.protocol) transport = HTTP3Transport(self.protocol)
request = self.protocol.request_class( request = self.protocol.request_class(
url_bytes, path.encode(),
headers, headers,
"3", "3",
method, method,

View File

@@ -159,7 +159,7 @@ class CertSimple(SanicSSLContext):
# try common aliases, rename to cert/key # try common aliases, rename to cert/key
certfile = kw["cert"] = kw.pop("certificate", None) or cert certfile = kw["cert"] = kw.pop("certificate", None) or cert
keyfile = kw["key"] = kw.pop("keyfile", None) or key keyfile = kw["key"] = kw.pop("keyfile", None) or key
password = kw.get("password", None) password = kw.pop("password", None)
if not certfile or not keyfile: if not certfile or not keyfile:
raise ValueError("SSL dict needs filenames for cert and key.") raise ValueError("SSL dict needs filenames for cert and key.")
subject = {} subject = {}

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.helpers import is_atty from sanic.compat 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.
@@ -62,13 +62,13 @@ LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( # no cov
}, },
formatters={ formatters={
"generic": { "generic": {
"format": "%(asctime)s [%(process)s] [%(levelname)s] %(message)s", "format": "%(asctime)s [%(process)d] [%(levelname)s] %(message)s",
"datefmt": "[%Y-%m-%d %H:%M:%S %z]", "datefmt": "[%Y-%m-%d %H:%M:%S %z]",
"class": "logging.Formatter", "class": "logging.Formatter",
}, },
"access": { "access": {
"format": "%(asctime)s - (%(name)s)[%(levelname)s][%(host)s]: " "format": "%(asctime)s - (%(name)s)[%(levelname)s][%(host)s]: "
+ "%(request)s %(message)s %(status)s %(byte)s", + "%(request)s %(message)s %(status)d %(byte)d",
"datefmt": "[%Y-%m-%d %H:%M:%S %z]", "datefmt": "[%Y-%m-%d %H:%M:%S %z]",
"class": "logging.Formatter", "class": "logging.Formatter",
}, },
@@ -126,26 +126,7 @@ logger.addFilter(_verbosity_filter)
def deprecation(message: str, version: float): # no cov def deprecation(message: str, version: float): # no cov
""" version_info = f"[DEPRECATION v{version}] "
Add a deprecation notice
Example when a feature is being removed. In this case, version
should be AT LEAST next version + 2
deprecation("Helpful message", 99.9)
Example when a feature is deprecated but not being removed:
deprecation("Helpful message", 0)
:param message: The message of the notice
:type message: str
:param version: The version when the feature will be removed. If it is
not being removed, then set version=0.
:type version: float
"""
version_display = f" v{version}" if version else ""
version_info = f"[DEPRECATION{version_display}] "
if is_atty(): if is_atty():
version_info = f"{Colors.RED}{version_info}" version_info = f"{Colors.RED}{version_info}"
message = f"{Colors.YELLOW}{message}{Colors.END}" message = f"{Colors.YELLOW}{message}{Colors.END}"

View File

@@ -38,15 +38,3 @@ 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,11 +159,7 @@ 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())
@@ -186,7 +182,7 @@ class RouteMixin(BaseMixin, metaclass=SanicMeta):
handler.is_stream = stream handler.is_stream = stream
if apply: if apply:
self._apply_route(route, overwrite=overwrite) self._apply_route(route)
if static: if static:
return route, handler return route, handler
@@ -550,7 +546,7 @@ class RouteMixin(BaseMixin, metaclass=SanicMeta):
strict_slashes: Optional[bool] = None, strict_slashes: Optional[bool] = None,
version: Optional[Union[int, str, float]] = None, version: Optional[Union[int, str, float]] = None,
name: Optional[str] = None, name: Optional[str] = None,
ignore_body: bool = False, ignore_body: bool = True,
version_prefix: str = "/v", version_prefix: str = "/v",
error_format: Optional[str] = None, error_format: Optional[str] = None,
**ctx_kwargs: Any, **ctx_kwargs: Any,

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 Event, Signal from sanic.signals import Signal
from sanic.types import HashableDict from sanic.types import HashableDict
@@ -80,9 +80,3 @@ 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,13 +16,7 @@ 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 ( from multiprocessing import Manager, Pipe, get_context
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
@@ -31,7 +25,6 @@ from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Any, Any,
Callable, Callable,
ClassVar,
Dict, Dict,
List, List,
Mapping, Mapping,
@@ -48,22 +41,23 @@ 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 from sanic.compat import OS_IS_WINDOWS, StartMethod, is_atty
from sanic.exceptions import ServerKilled from sanic.exceptions import ServerKilled
from sanic.helpers import Default, _default, is_atty from sanic.helpers import Default, _default
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
from sanic.log import Colors, error_logger, logger from sanic.log import Colors, deprecation, error_logger, logger
from sanic.models.handler_types import ListenerType from sanic.models.handler_types import ListenerType
from sanic.server import Signal as ServerSignal from sanic.server import Signal as ServerSignal
from sanic.server import try_use_uvloop from sanic.server import try_use_uvloop
from sanic.server.async_server import AsyncioServer from sanic.server.async_server import AsyncioServer
from sanic.server.events import trigger_events from sanic.server.events import trigger_events
from sanic.server.legacy import watchdog
from sanic.server.loop import try_windows_loop from sanic.server.loop import try_windows_loop
from sanic.server.protocols.http_protocol import HttpProtocol from sanic.server.protocols.http_protocol import HttpProtocol
from sanic.server.protocols.websocket_protocol import WebSocketProtocol from sanic.server.protocols.websocket_protocol import WebSocketProtocol
from sanic.server.runners import serve from sanic.server.runners import serve, serve_multiple, serve_single
from sanic.server.socket import configure_socket, remove_unix_socket from sanic.server.socket import configure_socket, remove_unix_socket
from sanic.worker.loader import AppLoader from sanic.worker.loader import AppLoader
from sanic.worker.manager import WorkerManager from sanic.worker.manager import WorkerManager
@@ -88,17 +82,13 @@ else: # no cov
class StartupMixin(metaclass=SanicMeta): class StartupMixin(metaclass=SanicMeta):
_app_registry: ClassVar[Dict[str, Sanic]] _app_registry: Dict[str, Sanic]
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:
@@ -145,6 +135,7 @@ class StartupMixin(metaclass=SanicMeta):
motd_display: Optional[Dict[str, str]] = None, motd_display: Optional[Dict[str, str]] = None,
auto_tls: bool = False, auto_tls: bool = False,
single_process: bool = False, single_process: bool = False,
legacy: bool = False,
) -> None: ) -> None:
""" """
Run the HTTP Server and listen until keyboard interrupt or term Run the HTTP Server and listen until keyboard interrupt or term
@@ -206,10 +197,13 @@ class StartupMixin(metaclass=SanicMeta):
motd_display=motd_display, motd_display=motd_display,
auto_tls=auto_tls, auto_tls=auto_tls,
single_process=single_process, single_process=single_process,
legacy=legacy,
) )
if single_process: if single_process:
serve = self.__class__.serve_single serve = self.__class__.serve_single
elif legacy:
serve = self.__class__.serve_legacy
else: else:
serve = self.__class__.serve serve = self.__class__.serve
serve(primary=self) # type: ignore serve(primary=self) # type: ignore
@@ -241,6 +235,7 @@ class StartupMixin(metaclass=SanicMeta):
coffee: bool = False, coffee: bool = False,
auto_tls: bool = False, auto_tls: bool = False,
single_process: bool = False, single_process: bool = False,
legacy: bool = False,
) -> None: ) -> None:
if version == 3 and self.state.server_info: if version == 3 and self.state.server_info:
raise RuntimeError( raise RuntimeError(
@@ -269,10 +264,13 @@ class StartupMixin(metaclass=SanicMeta):
"or auto-reload" "or auto-reload"
) )
if register_sys_signals is False and not single_process: if single_process and legacy:
raise RuntimeError("Cannot run single process and legacy mode")
if register_sys_signals is False and not (single_process or legacy):
raise RuntimeError( raise RuntimeError(
"Cannot run Sanic.serve with register_sys_signals=False. " "Cannot run Sanic.serve with register_sys_signals=False. "
"Use Sanic.serve_single." "Use either Sanic.serve_single or Sanic.serve_legacy."
) )
if motd_display: if motd_display:
@@ -702,26 +700,11 @@ 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)
actual = get_start_method() return get_context(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(
@@ -731,7 +714,6 @@ 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:
@@ -829,7 +811,7 @@ class StartupMixin(metaclass=SanicMeta):
ssl = kwargs.get("ssl") ssl = kwargs.get("ssl")
if isinstance(ssl, SanicSSLContext): if isinstance(ssl, SanicSSLContext):
kwargs["ssl"] = ssl.sanic kwargs["ssl"] = kwargs["ssl"].sanic
manager = WorkerManager( manager = WorkerManager(
primary.state.workers, primary.state.workers,
@@ -895,10 +877,7 @@ class StartupMixin(metaclass=SanicMeta):
sync_manager.shutdown() sync_manager.shutdown()
for sock in socks: for sock in socks:
try: sock.shutdown(SHUT_RDWR)
sock.shutdown(SHUT_RDWR)
except OSError:
...
sock.close() sock.close()
socks = [] socks = []
trigger_events(main_stop, loop, primary) trigger_events(main_stop, loop, primary)
@@ -974,6 +953,76 @@ class StartupMixin(metaclass=SanicMeta):
cls._cleanup_env_vars() cls._cleanup_env_vars()
cls._cleanup_apps() cls._cleanup_apps()
@classmethod
def serve_legacy(cls, primary: Optional[Sanic] = None) -> None:
apps = list(cls._app_registry.values())
if not primary:
try:
primary = apps[0]
except IndexError:
raise RuntimeError("Did not find any applications.")
reloader_start = primary.listeners.get("reload_process_start")
reloader_stop = primary.listeners.get("reload_process_stop")
# We want to run auto_reload if ANY of the applications have it enabled
if (
cls.should_auto_reload()
and os.environ.get("SANIC_SERVER_RUNNING") != "true"
): # no cov
loop = new_event_loop()
trigger_events(reloader_start, loop, primary)
reload_dirs: Set[Path] = primary.state.reload_dirs.union(
*(app.state.reload_dirs for app in apps)
)
watchdog(1.0, reload_dirs)
trigger_events(reloader_stop, loop, primary)
return
# This exists primarily for unit testing
if not primary.state.server_info: # no cov
for app in apps:
app.state.server_info.clear()
return
primary_server_info = primary.state.server_info[0]
primary.before_server_start(partial(primary._start_servers, apps=apps))
deprecation(
f"{Colors.YELLOW}Running {Colors.SANIC}Sanic {Colors.YELLOW}w/ "
f"LEGACY manager.{Colors.END} Support for will be dropped in "
"version 23.3.",
23.3,
)
try:
primary_server_info.stage = ServerStage.SERVING
if primary.state.workers > 1 and os.name != "posix": # no cov
logger.warning(
f"Multiprocessing is currently not supported on {os.name},"
" using workers=1 instead"
)
primary.state.workers = 1
if primary.state.workers == 1:
serve_single(primary_server_info.settings)
elif primary.state.workers == 0:
raise RuntimeError("Cannot serve with no workers")
else:
serve_multiple(
primary_server_info.settings, primary.state.workers
)
except BaseException:
error_logger.exception(
"Experienced exception while trying to serve"
)
raise
finally:
primary_server_info.stage = ServerStage.STOPPED
logger.info("Server Stopped")
cls._cleanup_env_vars()
cls._cleanup_apps()
async def _start_servers( async def _start_servers(
self, self,
primary: Sanic, primary: Sanic,

View File

@@ -3,7 +3,7 @@ from functools import partial, wraps
from mimetypes import guess_type from mimetypes import guess_type
from os import PathLike, path from os import PathLike, path
from pathlib import Path, PurePath from pathlib import Path, PurePath
from typing import Optional, Sequence, Set, Union from typing import Optional, Sequence, Set, Union, cast
from urllib.parse import unquote from urllib.parse import unquote
from sanic_routing.route import Route from sanic_routing.route import Route
@@ -14,7 +14,7 @@ from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
from sanic.exceptions import FileNotFound, HeaderNotFound, RangeNotSatisfiable from sanic.exceptions import FileNotFound, HeaderNotFound, RangeNotSatisfiable
from sanic.handlers import ContentRangeHandler from sanic.handlers import ContentRangeHandler
from sanic.handlers.directory import DirectoryHandler from sanic.handlers.directory import DirectoryHandler
from sanic.log import error_logger from sanic.log import deprecation, error_logger
from sanic.mixins.base import BaseMixin from sanic.mixins.base import BaseMixin
from sanic.models.futures import FutureStatic from sanic.models.futures import FutureStatic
from sanic.request import Request from sanic.request import Request
@@ -31,7 +31,7 @@ class StaticMixin(BaseMixin, metaclass=SanicMeta):
def static( def static(
self, self,
uri: str, uri: str,
file_or_directory: Union[PathLike, str], file_or_directory: Union[PathLike, str, bytes],
pattern: str = r"/?.+", pattern: str = r"/?.+",
use_modified_since: bool = True, use_modified_since: bool = True,
use_content_range: bool = False, use_content_range: bool = False,
@@ -94,12 +94,14 @@ class StaticMixin(BaseMixin, metaclass=SanicMeta):
f"Static route must be a valid path, not {file_or_directory}" f"Static route must be a valid path, not {file_or_directory}"
) )
try: if isinstance(file_or_directory, bytes):
file_or_directory = Path(file_or_directory).resolve() deprecation(
except TypeError: "Serving a static directory with a bytes string is "
raise TypeError( "deprecated and will be removed in v22.9.",
"Static file or directory must be a path-like object or string" 22.9,
) )
file_or_directory = cast(str, file_or_directory.decode())
file_or_directory = Path(file_or_directory)
if directory_handler and (directory_view or index): if directory_handler and (directory_view or index):
raise ValueError( raise ValueError(

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import sys
from typing import Any, Awaitable, Callable, MutableMapping, Optional, Union from typing import Any, Awaitable, Callable, MutableMapping, Optional, Union
@@ -15,10 +16,20 @@ 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
self._not_paused = asyncio.Event() # Fixup for 3.8+; Sanic still supports 3.7 where loop is required
self._not_paused.set() loop = loop if sys.version_info[:2] < (3, 8) else None
self._complete = asyncio.Event() # Optional in 3.9, necessary in 3.10 because the parameter "loop"
# was completely removed
if not loop:
self._not_paused = asyncio.Event()
self._not_paused.set()
self._complete = asyncio.Event()
else:
self._not_paused = asyncio.Event(loop=loop)
self._not_paused.set()
self._complete = asyncio.Event(loop=loop)
def pause_writing(self) -> None: def pause_writing(self) -> None:
self._not_paused.clear() self._not_paused.clear()

View File

@@ -3,12 +3,11 @@ from typing import Any, Callable, Coroutine, Optional, TypeVar, Union
import sanic import sanic
from sanic import request from sanic.request 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

@@ -1,19 +1,18 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from html5tagger import HTML, Builder, Document from html5tagger import HTML, Document
from sanic import __version__ as VERSION from sanic import __version__ as VERSION
from sanic.application.logo import SVG_LOGO_SIMPLE from sanic.application.logo import SVG_LOGO
from sanic.pages.css import CSS from sanic.pages.css import CSS
class BasePage(ABC, metaclass=CSS): # no cov class BasePage(ABC, metaclass=CSS): # no cov
TITLE = "Sanic" TITLE = "Unknown"
HEADING = None
CSS: str CSS: str
doc: Builder
def __init__(self, debug: bool = True) -> None: def __init__(self, debug: bool = True) -> None:
self.doc = Document(self.TITLE, lang="en")
self.debug = debug self.debug = debug
@property @property
@@ -21,7 +20,6 @@ class BasePage(ABC, metaclass=CSS): # no cov
return self.CSS return self.CSS
def render(self) -> str: def render(self) -> str:
self.doc = Document(self.TITLE, lang="en", id="sanic")
self._head() self._head()
self._body() self._body()
self._foot() self._foot()
@@ -30,7 +28,7 @@ class BasePage(ABC, metaclass=CSS): # no cov
def _head(self) -> None: def _head(self) -> None:
self.doc.style(HTML(self.style)) self.doc.style(HTML(self.style))
with self.doc.header: with self.doc.header:
self.doc.div(self.HEADING or self.TITLE) self.doc.div(self.TITLE)
def _foot(self) -> None: def _foot(self) -> None:
with self.doc.footer: with self.doc.footer:
@@ -39,23 +37,6 @@ class BasePage(ABC, metaclass=CSS): # no cov
self._sanic_logo() self._sanic_logo()
if self.debug: if self.debug:
self.doc.div(f"Version {VERSION}") self.doc.div(f"Version {VERSION}")
with self.doc.div:
for idx, (title, href) in enumerate(
(
("Docs", "https://sanic.dev"),
("Help", "https://sanic.dev/en/help.html"),
("GitHub", "https://github.com/sanic-org/sanic"),
)
):
if idx > 0:
self.doc(" | ")
self.doc.a(
title,
href=href,
target="_blank",
referrerpolicy="no-referrer",
)
self.doc.div("DEBUG mode")
@abstractmethod @abstractmethod
def _body(self) -> None: def _body(self) -> None:
@@ -63,7 +44,7 @@ class BasePage(ABC, metaclass=CSS): # no cov
def _sanic_logo(self) -> None: def _sanic_logo(self) -> None:
self.doc.a( self.doc.a(
HTML(SVG_LOGO_SIMPLE), HTML(SVG_LOGO),
href="https://sanic.dev", href="https://sanic.dev",
target="_blank", target="_blank",
referrerpolicy="no-referrer", referrerpolicy="no-referrer",

View File

@@ -24,8 +24,8 @@ class CSS(ABCMeta):
def __new__(cls, name, bases, attrs): def __new__(cls, name, bases, attrs):
Page = super().__new__(cls, name, bases, attrs) Page = super().__new__(cls, name, bases, attrs)
# Use a locally defined STYLE or the one from styles directory # Use a locally defined STYLE or the one from styles directory
Page.STYLE = _extract_style(attrs.get("STYLE_FILE"), name) s = _extract_style(attrs.get("STYLE"), name)
Page.STYLE += attrs.get("STYLE_APPEND", "") Page.STYLE = f"\n/* {name} */\n{s.strip()}\n" if s else ""
# Combine with all ancestor styles # Combine with all ancestor styles
Page.CSS = "".join( Page.CSS = "".join(
Class.STYLE Class.STYLE

View File

@@ -1,109 +0,0 @@
from typing import Any, Mapping
import tracerite.html
from html5tagger import E
from tracerite import html_traceback, inspector
from sanic.request import Request
from .base import BasePage
# Avoid showing the request in the traceback variable inspectors
inspector.blacklist_types += (Request,)
ENDUSER_TEXT = """\
We're sorry, but it looks like something went wrong. Please try refreshing \
the page or navigating back to the homepage. If the issue persists, our \
technical team is working to resolve it as soon as possible. We apologize \
for the inconvenience and appreciate your patience.\
"""
class ErrorPage(BasePage):
STYLE_APPEND = tracerite.html.style
def __init__(
self,
debug: bool,
title: str,
text: str,
request: Request,
exc: Exception,
) -> None:
super().__init__(debug)
name = request.app.name.replace("_", " ").strip()
if name.islower():
name = name.title()
self.TITLE = f"Application {name} cannot handle your request"
self.HEADING = E("Application ").strong(name)(
" cannot handle your request"
)
self.title = title
self.text = text
self.request = request
self.exc = exc
self.details_open = not getattr(exc, "quiet", False)
def _head(self) -> None:
self.doc._script(tracerite.html.javascript)
super()._head()
def _body(self) -> None:
debug = self.request.app.debug
route_name = self.request.name or "[route not found]"
with self.doc.main:
self.doc.h1(f"⚠️ {self.title}").p(self.text)
# Show context details if available on the exception
context = getattr(self.exc, "context", None)
if context:
self._key_value_table(
"Issue context", "exception-context", context
)
if not debug:
with self.doc.div(id="enduser"):
self.doc.p(ENDUSER_TEXT).p.a("Front Page", href="/")
return
# Show additional details in debug mode,
# open by default for 500 errors
with self.doc.details(open=self.details_open, class_="smalltext"):
# Show extra details if available on the exception
extra = getattr(self.exc, "extra", None)
if extra:
self._key_value_table(
"Issue extra data", "exception-extra", extra
)
self.doc.summary(
"Details for developers (Sanic debug mode only)"
)
if self.exc:
with self.doc.div(class_="exception-wrapper"):
self.doc.h2(f"Exception in {route_name}:")
self.doc(
html_traceback(self.exc, include_js_css=False)
)
self._key_value_table(
f"{self.request.method} {self.request.path}",
"request-headers",
self.request.headers,
)
def _key_value_table(
self, title: str, table_id: str, data: Mapping[str, Any]
) -> None:
with self.doc.div(class_="key-value-display"):
self.doc.h2(title)
with self.doc.dl(id=table_id, class_="key-value-table smalltext"):
for key, value in data.items():
# Reading values may cause a new exception, so suppress it
try:
value = str(value)
except Exception:
value = E.em("Unable to display value")
self.doc.dt.span(key, class_="nobr key").span(": ").dd(
value
)

View File

@@ -1,93 +1,37 @@
/** BasePage **/
:root {
--sanic: #ff0d68;
--sanic-yellow: #FFE900;
--sanic-background: #efeced;
--sanic-text: #121010;
--sanic-text-lighter: #756169;
--sanic-link: #ff0d68;
--sanic-block-background: #f7f4f6;
--sanic-block-text: #000;
--sanic-block-alt-text: #6b6468;
--sanic-header-background: #272325;
--sanic-header-border: #fff;
--sanic-header-text: #fff;
--sanic-highlight-background: var(--sanic-yellow);
--sanic-highlight-text: var(--sanic-text);
--sanic-tab-background: #f7f4f6;
--sanic-tab-shadow: #f7f6f6;
--sanic-tab-text: #222021;
--sanic-tracerite-var: var(--sanic-text);
--sanic-tracerite-val: #ff0d68;
--sanic-tracerite-type: #6d6a6b;
}
@media (prefers-color-scheme: dark) {
:root {
--sanic-text: #f7f4f6;
--sanic-background: #121010;
--sanic-block-background: #0f0d0e;
--sanic-block-text: #f7f4f6;
--sanic-header-background: #030203;
--sanic-header-border: #000;
--sanic-highlight-text: var(--sanic-background);
--sanic-tab-background: #292728;
--sanic-tab-shadow: #0f0d0e;
--sanic-tab-text: #aea7ab;
}
}
html { html {
font: 16px sans-serif; font: 16px sans-serif;
background: var(--sanic-background); background: #eee;
color: var(--sanic-text); color: #111;
scrollbar-gutter: stable;
overflow: hidden auto;
} }
body { body {
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.25rem;
line-height: 125%;
} }
body>* { body>* {
padding: 1rem 2vw; padding: 1rem 2vw;
} }
@media (max-width: 1000px) { @media (max-width: 1200px) {
body>* { body>* {
padding: 0.5rem 1.5vw; padding: 0.5rem 1.5vw;
} }
html { body {
/* Scale everything by rem of 6px-16px by viewport width */ font-size: 1rem;
font-size: calc(6px + 10 * 100vw / 1000);
} }
} }
main {
/* Make sure the footer is closer to bottom */
min-height: 70vh;
/* Generous padding for readability */
padding: 1rem 2.5rem;
}
.smalltext {
font-size: 1.0rem;
}
.container { .container {
min-width: 600px; min-width: 600px;
max-width: 1600px; max-width: 1600px;
} }
header { header {
background: var(--sanic-header-background); background: #111;
color: var(--sanic-header-text); color: #e1e1e1;
border-bottom: 1px solid var(--sanic-header-border); border-bottom: 1px solid #272727;
text-align: center; text-align: center;
} }
@@ -96,17 +40,20 @@ footer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
font-size: 0.8rem; font-size: 0.8rem;
margin: 2rem; margin-top: 2rem;
line-height: 1.5em;
} }
h1 { h1 {
text-align: left; text-align: left;
} }
a:visited {
color: inherit;
}
a { a {
text-decoration: none; text-decoration: none;
color: var(--sanic-link); color: #88f;
} }
a:hover, a:hover,
@@ -115,32 +62,18 @@ a:focus {
outline: none; outline: none;
} }
#logo {
height: 1.75rem;
padding: 0 0.25rem;
}
span.icon { span.icon {
margin-right: 1rem; margin-right: 1rem;
} }
#logo-simple {
height: 1.75rem;
padding: 0 0.25rem;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
#logo-simple path:last-child { html {
fill: #e1e1e1; background: #111;
color: #ccc;
} }
} }
#sanic pre,
#sanic code {
font-family: "Fira Code",
"Source Code Pro",
Menlo,
Meslo,
Monaco,
Consolas,
Lucida Console,
monospace;
font-size: 0.8rem;
}

View File

@@ -1,4 +1,3 @@
/** DirectoryPage **/
#breadcrumbs>a:hover { #breadcrumbs>a:hover {
text-decoration: none; text-decoration: none;
} }

View File

@@ -1,108 +0,0 @@
/** ErrorPage **/
#enduser {
max-width: 30em;
margin: 5em auto 5em auto;
text-align: justify;
/*text-justify: both;*/
}
#enduser a {
color: var(--sanic-blue);
}
#enduser p:last-child {
text-align: right;
}
summary {
margin-top: 3em;
color: var(--sanic-text-lighter);
cursor: pointer;
}
.tracerite {
--tracerite-var: var(--sanic-tracerite-var);
--tracerite-val: var(--sanic-tracerite-val);
--tracerite-type: var(--sanic-tracerite-type);
--tracerite-exception: var(--sanic);
--tracerite-highlight: var(--sanic-yellow);
--tracerite-tab: var(--sanic-tab-background);
--tracerite-tab-text: var(--sanic-tab-text);
}
.tracerite>h3 {
margin: 0.5rem 0 !important;
}
#sanic .tracerite .traceback-labels button {
font-size: 0.8rem;
line-height: 120%;
background: var(--tracerite-tab);
color: var(--tracerite-tab-text);
transition: 0.3s;
cursor: pointer;
}
.tracerite .traceback-labels {
padding-top: 5px;
}
.tracerite .traceback-labels button:hover {
filter: contrast(150%) brightness(120%) drop-shadow(0 -0 2px var(--sanic-tab-shadow));
}
#sanic .tracerite .tracerite-tooltip::before {
bottom: 1.75em;
}
#sanic .tracerite .traceback-details mark span {
background: var(--sanic-highlight-background);
color: var(--sanic-highlight-text);
}
header {
background: var(--sanic-header-background);
}
h2 {
font-size: 1.3rem;
color: var(--sanic-text);
}
.key-value-display,
.exception-wrapper {
padding: 0.5rem;
margin-top: 1rem;
}
.key-value-display {
background-color: var(--sanic-block-background);
color: var(--sanic-block-text);
}
.key-value-display h2 {
margin-bottom: 0.2em;
}
dl.key-value-table {
width: 100%;
margin: 0;
display: grid;
grid-template-columns: 1fr 5fr;
grid-gap: .3em;
white-space: pre-wrap;
}
dl.key-value-table * {
margin: 0;
}
dl.key-value-table dt {
color: var(--sanic-block-alt-text);
word-break: break-word;
}
dl.key-value-table dd {
/* Better breaking for cookies header and such */
word-break: break-all;
}

View File

@@ -2,22 +2,19 @@ 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,
NamedTuple,
Optional, Optional,
Tuple, Tuple,
Union, Union,
cast,
) )
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
@@ -26,14 +23,17 @@ from sanic.models.http_types import Credentials
if TYPE_CHECKING: if TYPE_CHECKING:
from sanic.app import Sanic
from sanic.config import Config
from sanic.server import ConnInfo from sanic.server import ConnInfo
from sanic.app import Sanic
import email.utils
import unicodedata
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from urllib.parse import parse_qs, parse_qsl, urlunparse from http.cookies import SimpleCookie
from types import SimpleNamespace
from urllib.parse import parse_qs, parse_qsl, unquote, urlunparse
from httptools import parse_url from httptools import parse_url
from httptools.parser.errors import HttpParserInvalidURLError from httptools.parser.errors import HttpParserInvalidURLError
@@ -45,7 +45,6 @@ from sanic.constants import (
IDEMPOTENT_HTTP_METHODS, IDEMPOTENT_HTTP_METHODS,
SAFE_HTTP_METHODS, SAFE_HTTP_METHODS,
) )
from sanic.cookies.request import CookieRequestParameters, parse_cookie
from sanic.exceptions import BadRequest, BadURL, ServerError from sanic.exceptions import BadRequest, BadURL, ServerError
from sanic.headers import ( from sanic.headers import (
AcceptList, AcceptList,
@@ -58,34 +57,37 @@ from sanic.headers import (
parse_xforwarded, parse_xforwarded,
) )
from sanic.http import Stage from sanic.http import Stage
from sanic.log import error_logger from sanic.log import deprecation, error_logger, logger
from sanic.models.protocol_types import TransportProtocol from sanic.models.protocol_types import TransportProtocol
from sanic.response import BaseHTTPResponse, HTTPResponse from sanic.response import BaseHTTPResponse, HTTPResponse
from .form import parse_multipart_form
from .parameters import RequestParameters
try: try:
from ujson import loads as json_loads # type: ignore from ujson import loads as json_loads # type: ignore
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 class RequestParameters(dict):
# by PEP 696 (https://www.python.org/dev/peps/pep-0696/). """
# Therefore, we use typing_extensions.TypeVar for compatibility. Hosts a dict with lists as values where get returns the first
# For more information, see: value of the list and getlist returns the whole shebang
# https://discuss.python.org/t/pep-696-type-defaults-for-typevarlikes """
sanic_type = TypeVar(
"sanic_type", bound=Sanic, default=Sanic[Config, SimpleNamespace] def get(self, name: str, default: Optional[Any] = None) -> Optional[Any]:
) """Return the first value, either the default or actual"""
else: return super().get(name, [default])[0]
sanic_type = TypeVar("sanic_type")
ctx_type = TypeVar("ctx_type") def getlist(
self, name: str, default: Optional[Any] = None
) -> Optional[Any]:
"""
Return the entire list
"""
return super().get(name, default)
class Request(Generic[sanic_type, ctx_type]): class Request:
""" """
Properties of an HTTP request such as URL, headers, etc. Properties of an HTTP request such as URL, headers, etc.
""" """
@@ -96,7 +98,6 @@ class Request(Generic[sanic_type, ctx_type]):
__slots__ = ( __slots__ = (
"__weakref__", "__weakref__",
"_cookies", "_cookies",
"_ctx",
"_id", "_id",
"_ip", "_ip",
"_parsed_url", "_parsed_url",
@@ -113,12 +114,12 @@ class Request(Generic[sanic_type, ctx_type]):
"app", "app",
"body", "body",
"conn_info", "conn_info",
"ctx",
"head", "head",
"headers", "headers",
"method", "method",
"parsed_accept", "parsed_accept",
"parsed_args", "parsed_args",
"parsed_cookies",
"parsed_credentials", "parsed_credentials",
"parsed_files", "parsed_files",
"parsed_form", "parsed_form",
@@ -141,7 +142,7 @@ class Request(Generic[sanic_type, ctx_type]):
version: str, version: str,
method: str, method: str,
transport: TransportProtocol, transport: TransportProtocol,
app: sanic_type, app: Sanic,
head: bytes = b"", head: bytes = b"",
stream_id: int = 0, stream_id: int = 0,
): ):
@@ -149,8 +150,7 @@ class Request(Generic[sanic_type, ctx_type]):
try: try:
self._parsed_url = parse_url(url_bytes) self._parsed_url = parse_url(url_bytes)
except HttpParserInvalidURLError: except HttpParserInvalidURLError:
url = url_bytes.decode(errors="backslashreplace") raise BadURL(f"Bad URL: {url_bytes.decode()}")
raise BadURL(f"Bad URL: {url}")
self._id: Optional[Union[uuid.UUID, str, int]] = None self._id: Optional[Union[uuid.UUID, str, int]] = None
self._name: Optional[str] = None self._name: Optional[str] = None
self._stream_id = stream_id self._stream_id = stream_id
@@ -165,26 +165,26 @@ class Request(Generic[sanic_type, ctx_type]):
# 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: Optional[ctx_type] = None self.ctx = SimpleNamespace()
self.parsed_forwarded: Optional[Options] = None
self.parsed_accept: Optional[AcceptList] = None self.parsed_accept: Optional[AcceptList] = None
self.parsed_credentials: Optional[Credentials] = None
self.parsed_json = None
self.parsed_form: Optional[RequestParameters] = None
self.parsed_files: Optional[RequestParameters] = None
self.parsed_token: Optional[str] = None
self.parsed_args: DefaultDict[ self.parsed_args: DefaultDict[
Tuple[bool, bool, str, str], RequestParameters Tuple[bool, bool, str, str], RequestParameters
] = defaultdict(RequestParameters) ] = defaultdict(RequestParameters)
self.parsed_cookies: Optional[RequestParameters] = None
self.parsed_credentials: Optional[Credentials] = None
self.parsed_files: Optional[RequestParameters] = None
self.parsed_form: Optional[RequestParameters] = None
self.parsed_forwarded: Optional[Options] = None
self.parsed_json = None
self.parsed_not_grouped_args: DefaultDict[ self.parsed_not_grouped_args: DefaultDict[
Tuple[bool, bool, str, str], List[Tuple[str, str]] Tuple[bool, bool, str, str], List[Tuple[str, str]]
] = defaultdict(list) ] = defaultdict(list)
self.parsed_token: Optional[str] = None
self._request_middleware_started = False self._request_middleware_started = False
self._response_middleware_started = False self._response_middleware_started = False
self.responded: bool = False self.responded: bool = False
self.route: Optional[Route] = None self.route: Optional[Route] = None
self.stream: Optional[Stream] = None self.stream: Optional[Stream] = None
self._cookies: Optional[Dict[str, str]] = None
self._match_info: Dict[str, Any] = {} self._match_info: Dict[str, Any] = {}
self._protocol = None self._protocol = None
@@ -192,10 +192,6 @@ class Request(Generic[sanic_type, ctx_type]):
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:
""" """
@@ -226,13 +222,14 @@ class Request(Generic[sanic_type, ctx_type]):
return uuid.uuid4() return uuid.uuid4()
@property @property
def ctx(self) -> ctx_type: def request_middleware_started(self):
""" deprecation(
:return: The current request context "Request.request_middleware_started has been deprecated and will"
""" "be removed. You should set a flag on the request context using"
if not self._ctx: "either middleware or signals if you need this feature.",
self._ctx = self.make_context() 23.3,
return self._ctx )
return self._request_middleware_started
@property @property
def stream_id(self): def stream_id(self):
@@ -503,16 +500,13 @@ class Request(Generic[sanic_type, ctx_type]):
@property @property
def accept(self) -> AcceptList: def accept(self) -> AcceptList:
"""Accepted response content types. """
A convenience handler for easier RFC-compliant matching of MIME types,
parsed as a list that can match wildcards and includes */* by default.
:return: The ``Accept`` header parsed :return: The ``Accept`` header parsed
:rtype: AcceptList :rtype: AcceptContainer
""" """
if self.parsed_accept is None: if self.parsed_accept is None:
self.parsed_accept = parse_accept(self.headers.get("accept")) accept_header = self.headers.getone("accept", "")
self.parsed_accept = parse_accept(accept_header)
return self.parsed_accept return self.parsed_accept
@property @property
@@ -734,21 +728,24 @@ class Request(Generic[sanic_type, ctx_type]):
default values. default values.
""" """
def get_cookies(self) -> RequestParameters:
cookie = self.headers.getone("cookie", "")
self.parsed_cookies = CookieRequestParameters(parse_cookie(cookie))
return self.parsed_cookies
@property @property
def cookies(self) -> RequestParameters: def cookies(self) -> Dict[str, str]:
""" """
:return: Incoming cookies on the request :return: Incoming cookies on the request
:rtype: Dict[str, str] :rtype: Dict[str, str]
""" """
if self.parsed_cookies is None: if self._cookies is None:
self.get_cookies() cookie = self.headers.getone("cookie", None)
return cast(CookieRequestParameters, self.parsed_cookies) if cookie is not None:
cookies: SimpleCookie = SimpleCookie()
cookies.load(cookie)
self._cookies = {
name: cookie.value for name, cookie in cookies.items()
}
else:
self._cookies = {}
return self._cookies
@property @property
def content_type(self) -> str: def content_type(self) -> str:
@@ -838,31 +835,19 @@ class Request(Generic[sanic_type, ctx_type]):
@property @property
def remote_addr(self) -> str: def remote_addr(self) -> str:
""" """
Client IP address, if available from proxy. Client IP address, if available.
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.forwarded.get("for", "")) self._remote_addr = str(
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:
""" """
@@ -1038,3 +1023,101 @@ class Request(Generic[sanic_type, ctx_type]):
:rtype: bool :rtype: bool
""" """
return self.method in CACHEABLE_HTTP_METHODS return self.method in CACHEABLE_HTTP_METHODS
class File(NamedTuple):
"""
Model for defining a file. It is a ``namedtuple``, therefore you can
iterate over the object, or access the parameters by name.
:param type: The mimetype, defaults to text/plain
:param body: Bytes of the file
:param name: The filename
"""
type: str
body: bytes
name: str
def parse_multipart_form(body, boundary):
"""
Parse a request body and returns fields and files
:param body: bytes request body
:param boundary: bytes multipart boundary
:return: fields (RequestParameters), files (RequestParameters)
"""
files = RequestParameters()
fields = RequestParameters()
form_parts = body.split(boundary)
for form_part in form_parts[1:-1]:
file_name = None
content_type = "text/plain"
content_charset = "utf-8"
field_name = None
line_index = 2
line_end_index = 0
while not line_end_index == -1:
line_end_index = form_part.find(b"\r\n", line_index)
form_line = form_part[line_index:line_end_index].decode("utf-8")
line_index = line_end_index + 2
if not form_line:
break
colon_index = form_line.index(":")
idx = colon_index + 2
form_header_field = form_line[0:colon_index].lower()
form_header_value, form_parameters = parse_content_header(
form_line[idx:]
)
if form_header_field == "content-disposition":
field_name = form_parameters.get("name")
file_name = form_parameters.get("filename")
# non-ASCII filenames in RFC2231, "filename*" format
if file_name is None and form_parameters.get("filename*"):
encoding, _, value = email.utils.decode_rfc2231(
form_parameters["filename*"]
)
file_name = unquote(value, encoding=encoding)
# Normalize to NFC (Apple MacOS/iOS send NFD)
# Notes:
# - No effect for Windows, Linux or Android clients which
# already send NFC
# - Python open() is tricky (creates files in NFC no matter
# which form you use)
if file_name is not None:
file_name = unicodedata.normalize("NFC", file_name)
elif form_header_field == "content-type":
content_type = form_header_value
content_charset = form_parameters.get("charset", "utf-8")
if field_name:
post_data = form_part[line_index:-4]
if file_name is None:
value = post_data.decode(content_charset)
if field_name in fields:
fields[field_name].append(value)
else:
fields[field_name] = [value]
else:
form_file = File(
type=content_type, name=file_name, body=post_data
)
if field_name in files:
files[field_name].append(form_file)
else:
files[field_name] = [form_file]
else:
logger.debug(
"Form-data field does not have a 'name' parameter "
"in the Content-Disposition header"
)
return fields, files

View File

@@ -1,11 +0,0 @@
from .form import File, parse_multipart_form
from .parameters import RequestParameters
from .types import Request
__all__ = (
"File",
"parse_multipart_form",
"Request",
"RequestParameters",
)

View File

@@ -1,110 +0,0 @@
from __future__ import annotations
import email.utils
import unicodedata
from typing import NamedTuple
from urllib.parse import unquote
from sanic.headers import parse_content_header
from sanic.log import logger
from .parameters import RequestParameters
class File(NamedTuple):
"""
Model for defining a file. It is a ``namedtuple``, therefore you can
iterate over the object, or access the parameters by name.
:param type: The mimetype, defaults to text/plain
:param body: Bytes of the file
:param name: The filename
"""
type: str
body: bytes
name: str
def parse_multipart_form(body, boundary):
"""
Parse a request body and returns fields and files
:param body: bytes request body
:param boundary: bytes multipart boundary
:return: fields (RequestParameters), files (RequestParameters)
"""
files = {}
fields = {}
form_parts = body.split(boundary)
for form_part in form_parts[1:-1]:
file_name = None
content_type = "text/plain"
content_charset = "utf-8"
field_name = None
line_index = 2
line_end_index = 0
while not line_end_index == -1:
line_end_index = form_part.find(b"\r\n", line_index)
form_line = form_part[line_index:line_end_index].decode("utf-8")
line_index = line_end_index + 2
if not form_line:
break
colon_index = form_line.index(":")
idx = colon_index + 2
form_header_field = form_line[0:colon_index].lower()
form_header_value, form_parameters = parse_content_header(
form_line[idx:]
)
if form_header_field == "content-disposition":
field_name = form_parameters.get("name")
file_name = form_parameters.get("filename")
# non-ASCII filenames in RFC2231, "filename*" format
if file_name is None and form_parameters.get("filename*"):
encoding, _, value = email.utils.decode_rfc2231(
form_parameters["filename*"]
)
file_name = unquote(value, encoding=encoding)
# Normalize to NFC (Apple MacOS/iOS send NFD)
# Notes:
# - No effect for Windows, Linux or Android clients which
# already send NFC
# - Python open() is tricky (creates files in NFC no matter
# which form you use)
if file_name is not None:
file_name = unicodedata.normalize("NFC", file_name)
elif form_header_field == "content-type":
content_type = form_header_value
content_charset = form_parameters.get("charset", "utf-8")
if field_name:
post_data = form_part[line_index:-4]
if file_name is None:
value = post_data.decode(content_charset)
if field_name in fields:
fields[field_name].append(value)
else:
fields[field_name] = [value]
else:
form_file = File(
type=content_type, name=file_name, body=post_data
)
if field_name in files:
files[field_name].append(form_file)
else:
files[field_name] = [form_file]
else:
logger.debug(
"Form-data field does not have a 'name' parameter "
"in the Content-Disposition header"
)
return RequestParameters(fields), RequestParameters(files)

View File

@@ -1,22 +0,0 @@
from __future__ import annotations
from typing import Any, Optional
class RequestParameters(dict):
"""
Hosts a dict with lists as values where get returns the first
value of the list and getlist returns the whole shebang
"""
def get(self, name: str, default: Optional[Any] = None) -> Optional[Any]:
"""Return the first value, either the default or actual"""
return super().get(name, [default])[0]
def getlist(
self, name: str, default: Optional[Any] = None
) -> Optional[Any]:
"""
Return the entire list
"""
return super().get(name, default)

View File

@@ -148,26 +148,7 @@ async def validate_file(
last_modified = datetime.fromtimestamp( last_modified = datetime.fromtimestamp(
float(last_modified), tz=timezone.utc float(last_modified), tz=timezone.utc
).replace(microsecond=0) ).replace(microsecond=0)
if last_modified <= if_modified_since:
if (
last_modified.utcoffset() is None
and if_modified_since.utcoffset() is not None
):
logger.warning(
"Cannot compare tz-aware and tz-naive datetimes. To avoid "
"this conflict Sanic is converting last_modified to UTC."
)
last_modified.replace(tzinfo=timezone.utc)
elif (
last_modified.utcoffset() is not None
and if_modified_since.utcoffset() is None
):
logger.warning(
"Cannot compare tz-aware and tz-naive datetimes. To avoid "
"this conflict Sanic is converting if_modified_since to UTC."
)
if_modified_since.replace(tzinfo=timezone.utc)
if last_modified.timestamp() <= if_modified_since.timestamp():
return HTTPResponse(status=304) return HTTPResponse(status=304)

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from functools import partial from functools import partial
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
@@ -18,7 +17,6 @@ from typing import (
from sanic.compat import Header from sanic.compat import Header
from sanic.cookies import CookieJar from sanic.cookies import CookieJar
from sanic.cookies.response import Cookie, SameSite
from sanic.exceptions import SanicException, ServerError from sanic.exceptions import SanicException, ServerError
from sanic.helpers import ( from sanic.helpers import (
Default, Default,
@@ -38,9 +36,7 @@ else:
try: try:
from ujson import dumps as ujson_dumps from ujson import dumps as json_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.
@@ -162,117 +158,6 @@ class BaseHTTPResponse:
end_stream=end_stream or False, end_stream=end_stream or False,
) )
def add_cookie(
self,
key: str,
value: str,
*,
path: str = "/",
domain: Optional[str] = None,
secure: bool = True,
max_age: Optional[int] = None,
expires: Optional[datetime] = None,
httponly: bool = False,
samesite: Optional[SameSite] = "Lax",
partitioned: bool = False,
comment: Optional[str] = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> Cookie:
"""
Add a cookie to the CookieJar
:param key: Key of the cookie
:type key: str
:param value: Value of the cookie
:type value: str
:param path: Path of the cookie, defaults to None
:type path: Optional[str], optional
:param domain: Domain of the cookie, defaults to None
:type domain: Optional[str], optional
:param secure: Whether to set it as a secure cookie, defaults to True
:type secure: bool
:param max_age: Max age of the cookie in seconds; if set to 0 a
browser should delete it, defaults to None
:type max_age: Optional[int], optional
:param expires: When the cookie expires; if set to None browsers
should set it as a session cookie, defaults to None
:type expires: Optional[datetime], optional
:param httponly: Whether to set it as HTTP only, defaults to False
:type httponly: bool
:param samesite: How to set the samesite property, should be
strict, lax or none (case insensitive), defaults to Lax
:type samesite: Optional[SameSite], optional
:param partitioned: Whether to set it as partitioned, defaults to False
:type partitioned: bool
:param comment: A cookie comment, defaults to None
:type comment: Optional[str], optional
:param host_prefix: Whether to add __Host- as a prefix to the key.
This requires that path="/", domain=None, and secure=True,
defaults to False
:type host_prefix: bool
:param secure_prefix: Whether to add __Secure- as a prefix to the key.
This requires that secure=True, defaults to False
:type secure_prefix: bool
:return: The instance of the created cookie
:rtype: Cookie
"""
return self.cookies.add_cookie(
key=key,
value=value,
path=path,
domain=domain,
secure=secure,
max_age=max_age,
expires=expires,
httponly=httponly,
samesite=samesite,
partitioned=partitioned,
comment=comment,
host_prefix=host_prefix,
secure_prefix=secure_prefix,
)
def delete_cookie(
self,
key: str,
*,
path: str = "/",
domain: Optional[str] = None,
host_prefix: bool = False,
secure_prefix: bool = False,
) -> None:
"""
Delete a cookie
This will effectively set it as Max-Age: 0, which a browser should
interpret it to mean: "delete the cookie".
Since it is a browser/client implementation, your results may vary
depending upon which client is being used.
:param key: The key to be deleted
:type key: str
:param path: Path of the cookie, defaults to None
:type path: Optional[str], optional
:param domain: Domain of the cookie, defaults to None
:type domain: Optional[str], optional
:param host_prefix: Whether to add __Host- as a prefix to the key.
This requires that path="/", domain=None, and secure=True,
defaults to False
:type host_prefix: bool
:param secure_prefix: Whether to add __Secure- as a prefix to the key.
This requires that secure=True, defaults to False
:type secure_prefix: bool
"""
self.cookies.delete_cookie(
key=key,
path=path,
domain=domain,
host_prefix=host_prefix,
secure_prefix=secure_prefix,
)
class HTTPResponse(BaseHTTPResponse): class HTTPResponse(BaseHTTPResponse):
""" """
@@ -347,7 +232,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: str = "application/json", content_type: Optional[str] = None,
dumps: Optional[Callable[..., str]] = None, dumps: Optional[Callable[..., str]] = None,
**kwargs: Any, **kwargs: Any,
): ):
@@ -522,10 +407,6 @@ 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 headers is None:
headers = Header()
elif not isinstance(headers, Header):
headers = Header(headers)
self.streaming_fn = streaming_fn self.streaming_fn = streaming_fn
self.status = status self.status = status
self.headers = headers or Header() self.headers = headers or Header()

View File

@@ -39,15 +39,13 @@ class Router(BaseRouter):
extra={"host": host} if host else None, extra={"host": host} if host else None,
) )
except RoutingNotFound as e: except RoutingNotFound as e:
raise NotFound(f"Requested URL {e.path} not found") from None raise NotFound("Requested URL {} not found".format(e.path))
except NoMethod as e: except NoMethod as e:
raise MethodNotAllowed( raise MethodNotAllowed(
f"Method {method} not allowed for URL {path}", "Method {} not allowed for URL {}".format(method, path),
method=method, method=method,
allowed_methods=tuple(e.allowed_methods) allowed_methods=e.allowed_methods,
if e.allowed_methods )
else None,
) from None
@lru_cache(maxsize=ROUTER_CACHE_SIZE) @lru_cache(maxsize=ROUTER_CACHE_SIZE)
def get( # type: ignore def get( # type: ignore
@@ -63,7 +61,6 @@ class Router(BaseRouter):
correct response correct response
:rtype: Tuple[ Route, RouteHandler, Dict[str, Any]] :rtype: Tuple[ Route, RouteHandler, Dict[str, Any]]
""" """
__tracebackhide__ = True
return self._get(path, method, host) return self._get(path, method, host)
def add( # type: ignore def add( # type: ignore
@@ -75,12 +72,11 @@ 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: Optional[Union[str, float, int]] = None, version: 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]]:
""" """
@@ -123,7 +119,6 @@ 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):
@@ -137,16 +132,7 @@ class Router(BaseRouter):
if host: if host:
params.update({"requirements": {"host": host}}) params.update({"requirements": {"host": host}})
ident = name
if len(hosts) > 1:
ident = (
f"{name}_{host.replace('.', '_')}"
if name
else "__unnamed__"
)
route = super().add(**params) # type: ignore route = super().add(**params) # type: ignore
route.extra.ident = ident
route.extra.ignore_body = ignore_body route.extra.ignore_body = ignore_body
route.extra.stream = stream route.extra.stream = stream
route.extra.hosts = hosts route.extra.hosts = hosts

View File

@@ -2,7 +2,7 @@ from sanic.models.server_types import ConnInfo, Signal
from sanic.server.async_server import AsyncioServer from sanic.server.async_server import AsyncioServer
from sanic.server.loop import try_use_uvloop from sanic.server.loop import try_use_uvloop
from sanic.server.protocols.http_protocol import HttpProtocol from sanic.server.protocols.http_protocol import HttpProtocol
from sanic.server.runners import serve from sanic.server.runners import serve, serve_multiple, serve_single
__all__ = ( __all__ = (
@@ -11,5 +11,7 @@ __all__ = (
"HttpProtocol", "HttpProtocol",
"Signal", "Signal",
"serve", "serve",
"serve_multiple",
"serve_single",
"try_use_uvloop", "try_use_uvloop",
) )

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from inspect import isawaitable from inspect import isawaitable
from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional from typing import TYPE_CHECKING, Any, Callable, Iterable, Optional
if TYPE_CHECKING: if TYPE_CHECKING:
from sanic import Sanic from sanic import Sanic
@@ -11,7 +12,6 @@ def trigger_events(
events: Optional[Iterable[Callable[..., Any]]], events: Optional[Iterable[Callable[..., Any]]],
loop, loop,
app: Optional[Sanic] = None, app: Optional[Sanic] = None,
**kwargs,
): ):
""" """
Trigger event callbacks (functions or async) Trigger event callbacks (functions or async)
@@ -22,12 +22,8 @@ def trigger_events(
if events: if events:
for event in events: for event in events:
try: try:
result = event(**kwargs) if not app else event(app, **kwargs) result = event() if not app else event(app)
except TypeError: except TypeError:
result = ( result = event(loop) if not app else event(app, loop)
event(loop, **kwargs)
if not app
else event(app, loop, **kwargs)
)
if isawaitable(result): if isawaitable(result):
loop.run_until_complete(result) loop.run_until_complete(result)

123
sanic/server/legacy.py Normal file
View File

@@ -0,0 +1,123 @@
import itertools
import os
import signal
import subprocess
import sys
from time import sleep
def _iter_module_files():
"""This iterates over all relevant Python files.
It goes through all
loaded files from modules, all files in folders of already loaded modules
as well as all files reachable through a package.
"""
# The list call is necessary on Python 3 in case the module
# dictionary modifies during iteration.
for module in list(sys.modules.values()):
if module is None:
continue
filename = getattr(module, "__file__", None)
if filename:
old = None
while not os.path.isfile(filename):
old = filename
filename = os.path.dirname(filename)
if filename == old:
break
else:
if filename[-4:] in (".pyc", ".pyo"):
filename = filename[:-1]
yield filename
def _get_args_for_reloading():
"""Returns the executable."""
main_module = sys.modules["__main__"]
mod_spec = getattr(main_module, "__spec__", None)
if sys.argv[0] in ("", "-c"):
raise RuntimeError(
f"Autoreloader cannot work with argv[0]={sys.argv[0]!r}"
)
if mod_spec:
# Parent exe was launched as a module rather than a script
return [sys.executable, "-m", mod_spec.name] + sys.argv[1:]
return [sys.executable] + sys.argv
def restart_with_reloader(changed=None):
"""Create a new process and a subprocess in it with the same arguments as
this one.
"""
reloaded = ",".join(changed) if changed else ""
return subprocess.Popen( # nosec B603
_get_args_for_reloading(),
env={
**os.environ,
"SANIC_SERVER_RUNNING": "true",
"SANIC_RELOADER_PROCESS": "true",
"SANIC_RELOADED_FILES": reloaded,
},
)
def _check_file(filename, mtimes):
need_reload = False
mtime = os.stat(filename).st_mtime
old_time = mtimes.get(filename)
if old_time is None:
mtimes[filename] = mtime
elif mtime > old_time:
mtimes[filename] = mtime
need_reload = True
return need_reload
def watchdog(sleep_interval, reload_dirs):
"""Watch project files, restart worker process if a change happened.
:param sleep_interval: interval in second.
:return: Nothing
"""
def interrupt_self(*args):
raise KeyboardInterrupt
mtimes = {}
signal.signal(signal.SIGTERM, interrupt_self)
if os.name == "nt":
signal.signal(signal.SIGBREAK, interrupt_self)
worker_process = restart_with_reloader()
try:
while True:
changed = set()
for filename in itertools.chain(
_iter_module_files(),
*(d.glob("**/*") for d in reload_dirs),
):
try:
if _check_file(filename, mtimes):
path = (
filename
if isinstance(filename, str)
else filename.resolve()
)
changed.add(str(path))
except OSError:
continue
if changed:
worker_process.terminate()
worker_process.wait()
worker_process = restart_with_reloader(changed)
sleep(sleep_interval)
except KeyboardInterrupt:
pass
finally:
worker_process.terminate()
worker_process.wait()

View File

@@ -10,7 +10,7 @@ except ImportError: # websockets >= 11.0
from websockets.typing import Subprotocol from websockets.typing import Subprotocol
from sanic.exceptions import SanicException from sanic.exceptions import ServerError
from sanic.log import logger from sanic.log import logger
from sanic.server import HttpProtocol from sanic.server import HttpProtocol
@@ -123,7 +123,7 @@ class WebSocketProtocol(HttpProtocol):
"Failed to open a WebSocket connection.\n" "Failed to open a WebSocket connection.\n"
"See server log for more information.\n" "See server log for more information.\n"
) )
raise SanicException(msg, status_code=500) raise ServerError(msg, status_code=500)
if 100 <= resp.status_code <= 299: if 100 <= resp.status_code <= 299:
first_line = ( first_line = (
f"HTTP/1.1 {resp.status_code} {resp.reason_phrase}\r\n" f"HTTP/1.1 {resp.status_code} {resp.reason_phrase}\r\n"
@@ -138,7 +138,7 @@ class WebSocketProtocol(HttpProtocol):
rbody += b"\r\n\r\n" rbody += b"\r\n\r\n"
await super().send(rbody) await super().send(rbody)
else: else:
raise SanicException(resp.body, resp.status_code) raise ServerError(resp.body, resp.status_code)
self.websocket = WebsocketImplProtocol( self.websocket = WebsocketImplProtocol(
ws_proto, ws_proto,
ping_interval=self.websocket_ping_interval, ping_interval=self.websocket_ping_interval,

View File

@@ -1,5 +1,7 @@
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
@@ -7,17 +9,19 @@ from sanic.config import Config
from sanic.exceptions import ServerError from sanic.exceptions import ServerError
from sanic.http.constants import HTTP from sanic.http.constants import HTTP
from sanic.http.tls import get_ssl_context from sanic.http.tls import get_ssl_context
from sanic.server.events import trigger_events
if TYPE_CHECKING: if TYPE_CHECKING:
from sanic.app import Sanic from sanic.app import Sanic
import asyncio import asyncio
import multiprocessing
import os import os
import socket import socket
from functools import partial from functools import partial
from signal import SIG_IGN, SIGINT, SIGTERM from signal import SIG_IGN, SIGINT, SIGTERM, Signals
from signal import signal as signal_func from signal import signal as signal_func
from sanic.application.ext import setup_ext from sanic.application.ext import setup_ext
@@ -27,7 +31,11 @@ from sanic.log import error_logger, server_logger
from sanic.models.server_types import Signal from sanic.models.server_types import Signal
from sanic.server.async_server import AsyncioServer from sanic.server.async_server import AsyncioServer
from sanic.server.protocols.http_protocol import Http3Protocol, HttpProtocol from sanic.server.protocols.http_protocol import Http3Protocol, HttpProtocol
from sanic.server.socket import bind_unix_socket, remove_unix_socket from sanic.server.socket import (
bind_socket,
bind_unix_socket,
remove_unix_socket,
)
try: try:
@@ -249,7 +257,8 @@ 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
app.shutdown_tasks(graceful - start_shutdown) if sys.version_info > (3, 7):
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
@@ -310,6 +319,94 @@ def _serve_http_3(
) )
def serve_single(server_settings):
main_start = server_settings.pop("main_start", None)
main_stop = server_settings.pop("main_stop", None)
if not server_settings.get("run_async"):
# create new event_loop after fork
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
server_settings["loop"] = loop
trigger_events(main_start, server_settings["loop"])
serve(**server_settings)
trigger_events(main_stop, server_settings["loop"])
server_settings["loop"].close()
def serve_multiple(server_settings, workers):
"""Start multiple server processes simultaneously. Stop on interrupt
and terminate signals, and drain connections when complete.
:param server_settings: kw arguments to be passed to the serve function
:param workers: number of workers to launch
:param stop_event: if provided, is used as a stop signal
:return:
"""
server_settings["reuse_port"] = True
server_settings["run_multiple"] = True
main_start = server_settings.pop("main_start", None)
main_stop = server_settings.pop("main_stop", None)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
trigger_events(main_start, loop)
# Create a listening socket or use the one in settings
sock = server_settings.get("sock")
unix = server_settings["unix"]
backlog = server_settings["backlog"]
if unix:
sock = bind_unix_socket(unix, backlog=backlog)
server_settings["unix"] = unix
if sock is None:
sock = bind_socket(
server_settings["host"], server_settings["port"], backlog=backlog
)
sock.set_inheritable(True)
server_settings["sock"] = sock
server_settings["host"] = None
server_settings["port"] = None
processes = []
def sig_handler(signal, frame):
server_logger.info(
"Received signal %s. Shutting down.", Signals(signal).name
)
for process in processes:
os.kill(process.pid, SIGTERM)
signal_func(SIGINT, lambda s, f: sig_handler(s, f))
signal_func(SIGTERM, lambda s, f: sig_handler(s, f))
mp = multiprocessing.get_context("fork")
for _ in range(workers):
process = mp.Process(
target=serve,
kwargs=server_settings,
)
process.daemon = True
process.start()
processes.append(process)
for process in processes:
process.join()
# the above processes will block this until they're stopped
for process in processes:
process.terminate()
trigger_events(main_stop, loop)
sock.close()
loop.close()
remove_unix_socket(unix)
def _build_protocol_kwargs( def _build_protocol_kwargs(
protocol: Type[asyncio.Protocol], config: Config protocol: Type[asyncio.Protocol], config: Config
) -> Dict[str, Union[int, float]]: ) -> Dict[str, Union[int, float]]:

View File

@@ -96,7 +96,6 @@ 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 # type: ignore from websockets.connection import Event, State
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
@@ -29,7 +29,7 @@ except ImportError: # websockets >= 11.0
from websockets.typing import Data from websockets.typing import Data
from sanic.log import error_logger, logger from sanic.log import deprecation, error_logger, logger
from sanic.server.protocols.base_protocol import SanicProtocol from sanic.server.protocols.base_protocol import SanicProtocol
from ...exceptions import ServerError, WebsocketClosed from ...exceptions import ServerError, WebsocketClosed
@@ -99,6 +99,15 @@ class WebsocketImplProtocol:
def subprotocol(self): def subprotocol(self):
return self.ws_proto.subprotocol return self.ws_proto.subprotocol
@property
def connection(self):
deprecation(
"The connection property has been deprecated and will be removed. "
"Please use the ws_proto property instead going forward.",
22.6,
)
return self.ws_proto
def pause_frames(self): def pause_frames(self):
if not self.can_pause: if not self.can_pause:
return False return False

View File

@@ -16,7 +16,6 @@ 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"
@@ -40,7 +39,6 @@ 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,
@@ -170,17 +168,6 @@ 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()
@@ -230,6 +217,14 @@ 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,7 +83,10 @@ 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({"meta": {"action": name}, "result": output}) return json(
{"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

@@ -3,11 +3,8 @@ from __future__ import annotations
import os import os
import sys import sys
from contextlib import suppress
from importlib import import_module from importlib import import_module
from inspect import isfunction
from pathlib import Path from pathlib import Path
from ssl import SSLContext
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union, cast from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union, cast
from sanic.http.tls.context import process_to_context from sanic.http.tls.context import process_to_context
@@ -17,8 +14,6 @@ from sanic.http.tls.creators import MkcertCreator, TrustmeCreator
if TYPE_CHECKING: if TYPE_CHECKING:
from sanic import Sanic as SanicApp from sanic import Sanic as SanicApp
DEFAULT_APP_NAME = "app"
class AppLoader: class AppLoader:
def __init__( def __init__(
@@ -40,11 +35,7 @@ class AppLoader:
if module_input: if module_input:
delimiter = ":" if ":" in module_input else "." delimiter = ":" if ":" in module_input else "."
if ( if module_input.count(delimiter):
delimiter in module_input
and "\\" not in module_input
and "/" not in module_input
):
module_name, app_name = module_input.rsplit(delimiter, 1) module_name, app_name = module_input.rsplit(delimiter, 1)
self.module_name = module_name self.module_name = module_name
self.app_name = app_name self.app_name = app_name
@@ -63,30 +54,21 @@ class AppLoader:
from sanic.app import Sanic from sanic.app import Sanic
from sanic.simple import create_simple_server from sanic.simple import create_simple_server
maybe_path = Path(self.module_input) if self.as_simple:
if self.as_simple or ( path = Path(self.module_input)
maybe_path.is_dir() app = create_simple_server(path)
and ("\\" in self.module_input or "/" in self.module_input)
):
app = create_simple_server(maybe_path)
else: else:
implied_app_name = False if self.module_name == "" and os.path.isdir(self.module_input):
if not self.module_name and not self.app_name: raise ValueError(
self.module_name = self.module_input "App not found.\n"
self.app_name = DEFAULT_APP_NAME " Please use --simple if you are passing a "
implied_app_name = True "directory to sanic.\n"
f" eg. sanic {self.module_input} --simple"
)
module = import_module(self.module_name) module = import_module(self.module_name)
app = getattr(module, self.app_name, None) app = getattr(module, self.app_name, None)
if not app and implied_app_name: if self.as_factory:
raise ValueError(
"Looks like you only supplied a module name. Sanic "
"tried to locate an application instance named "
f"{self.module_name}:app, but was unable to locate "
"an application instance. Please provide a path "
"to a global instance of Sanic(), or a callable that "
"will return a Sanic() application instance."
)
if self.as_factory or isfunction(app):
try: try:
app = app(self.args) app = app(self.args)
except TypeError: except TypeError:
@@ -97,18 +79,21 @@ class AppLoader:
if ( if (
not isinstance(app, Sanic) not isinstance(app, Sanic)
and self.args and self.args
and hasattr(self.args, "target") and hasattr(self.args, "module")
): ):
with suppress(ModuleNotFoundError): if callable(app):
maybe_module = import_module(self.module_input) solution = f"sanic {self.args.module} --factory"
app = getattr(maybe_module, "app", None) raise ValueError(
if not app: "Module is not a Sanic app, it is a "
message = ( f"{app_type_name}\n"
"Module is not a Sanic app, " " If this callable returns a "
f"it is a {app_type_name}\n" f"Sanic instance try: \n{solution}"
f" Perhaps you meant {self.args.target}:app?"
) )
raise ValueError(message)
raise ValueError(
f"Module is not a Sanic app, it is a {app_type_name}\n"
f" Perhaps you meant {self.args.module}:app?"
)
return app return app
@@ -118,16 +103,8 @@ class CertLoader:
"trustme": TrustmeCreator, "trustme": TrustmeCreator,
} }
def __init__( def __init__(self, ssl_data: Dict[str, Union[str, os.PathLike]]):
self,
ssl_data: Optional[
Union[SSLContext, Dict[str, Union[str, os.PathLike]]]
],
):
self._ssl_data = ssl_data self._ssl_data = ssl_data
self._creator_class = None
if not ssl_data or not isinstance(ssl_data, dict):
return
creator_name = cast(str, ssl_data.get("creator")) creator_name = cast(str, ssl_data.get("creator"))

View File

@@ -5,7 +5,7 @@ from itertools import count
from random import choice from random import choice
from signal import SIGINT, SIGTERM, Signals from signal import SIGINT, SIGTERM, Signals
from signal import signal as signal_func from signal import signal as signal_func
from typing import Any, Callable, Dict, List, Optional from typing import Dict, List, Optional
from sanic.compat import OS_IS_WINDOWS from sanic.compat import OS_IS_WINDOWS
from sanic.exceptions import ServerKilled from sanic.exceptions import ServerKilled
@@ -54,36 +54,9 @@ class WorkerManager:
signal_func(SIGINT, self.shutdown_signal) signal_func(SIGINT, self.shutdown_signal)
signal_func(SIGTERM, self.shutdown_signal) signal_func(SIGTERM, self.shutdown_signal)
def manage( def manage(self, ident, func, kwargs, transient=False) -> Worker:
self,
ident: str,
func: Callable[..., Any],
kwargs: Dict[str, Any],
transient: bool = False,
workers: int = 1,
) -> Worker:
"""
Instruct Sanic to manage a custom process.
:param ident: A name for the worker process
:type ident: str
:param func: The function to call in the background process
:type func: Callable[..., Any]
:param kwargs: Arguments to pass to the function
:type kwargs: Dict[str, Any]
:param transient: Whether to mark the process as transient. If True
then the Worker Manager will restart the process along
with any global restart (ex: auto-reload), defaults to False
:type transient: bool, optional
:param workers: The number of worker processes to run, defaults to 1
:type workers: int, optional
:return: The Worker instance
:rtype: Worker
"""
container = self.transient if transient else self.durable container = self.transient if transient else self.durable
worker = Worker( worker = Worker(ident, func, kwargs, self.context, self.worker_state)
ident, func, kwargs, self.context, self.worker_state, workers
)
container[worker.ident] = worker container[worker.ident] = worker
return worker return worker
@@ -312,10 +285,6 @@ class WorkerManager:
def _sync_states(self): def _sync_states(self):
for process in self.processes: for process in self.processes:
try: state = self.worker_state[process.name].get("state")
state = self.worker_state[process.name].get("state")
except KeyError:
process.set_state(ProcessState.TERMINATED, True)
continue
if state and process.state.name != state: if state and process.state.name != state:
process.set_state(ProcessState[state], True) process.set_state(ProcessState[state], True)

View File

@@ -192,17 +192,14 @@ class Worker:
server_settings, server_settings,
context: BaseContext, context: BaseContext,
worker_state: Dict[str, Any], worker_state: Dict[str, Any],
num: int = 1,
): ):
self.ident = ident self.ident = ident
self.num = num
self.context = context self.context = context
self.serve = serve self.serve = serve
self.server_settings = server_settings self.server_settings = server_settings
self.worker_state = worker_state self.worker_state = worker_state
self.processes: Set[WorkerProcess] = set() self.processes: Set[WorkerProcess] = set()
for _ in range(num): self.create_process()
self.create_process()
def create_process(self) -> WorkerProcess: def create_process(self) -> WorkerProcess:
process = WorkerProcess( process = WorkerProcess(

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import os import os
import sys import sys
from asyncio import new_event_loop from asyncio import new_event_loop
from itertools import chain from itertools import chain
from multiprocessing.connection import Connection from multiprocessing.connection import Connection
@@ -63,7 +64,7 @@ class Reloader:
trigger_events(before_trigger, loop, app) trigger_events(before_trigger, loop, app)
self.reload(",".join(changed) if changed else "unknown") self.reload(",".join(changed) if changed else "unknown")
if after_trigger: if after_trigger:
trigger_events(after_trigger, loop, app, changed=changed) trigger_events(after_trigger, loop, app)
sleep(self.interval) sleep(self.interval)
else: else:
if reloader_stop: if reloader_stop:

View File

@@ -73,8 +73,8 @@ def worker_serve(
info.settings["app"] = a info.settings["app"] = a
a.state.server_info.append(info) a.state.server_info.append(info)
if isinstance(ssl, dict) or app.certloader_class is not CertLoader: if isinstance(ssl, dict):
cert_loader = app.certloader_class(ssl or {}) cert_loader = CertLoader(ssl)
ssl = cert_loader.load(app) ssl = cert_loader.load(app)
for info in app.state.server_info: for info in app.state.server_info:
info.settings["ssl"] = ssl info.settings["ssl"] = ssl

View File

@@ -83,11 +83,12 @@ 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.8", "python_requires": ">=3.7",
"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",
@@ -103,7 +104,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>=23.6.0", "sanic-routing>=22.8.0",
"httptools>=0.0.10", "httptools>=0.0.10",
uvloop, uvloop,
ujson, ujson,
@@ -111,12 +112,10 @@ requirements = [
"websockets>=10.0", "websockets>=10.0",
"multidict>=5.0,<7.0", "multidict>=5.0,<7.0",
"html5tagger>=1.2.1", "html5tagger>=1.2.1",
"tracerite>=1.0.0",
"typing-extensions>=4.4.0",
] ]
tests_require = [ tests_require = [
"sanic-testing>=23.6.0", "sanic-testing>=22.9.0",
"pytest==7.1.*", "pytest==7.1.*",
"coverage", "coverage",
"beautifulsoup4", "beautifulsoup4",
@@ -127,7 +126,7 @@ tests_require = [
"black", "black",
"isort>=5.0.0", "isort>=5.0.0",
"bandit", "bandit",
"mypy", "mypy>=0.901,<0.910",
"docutils", "docutils",
"pygments", "pygments",
"uvicorn<0.15.0", "uvicorn<0.15.0",

View File

@@ -1,19 +0,0 @@
-----BEGIN CERTIFICATE-----
MIIDCTCCAfGgAwIBAgIUa7OOlAGQfXOgUgRENJ9GbUgO7kwwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJMTI3LjAuMC4xMB4XDTIzMDMyMDA3MzE1M1oXDTIzMDQx
OTA3MzE1M1owFDESMBAGA1UEAwwJMTI3LjAuMC4xMIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAn2/RqVpzO7GFrgVGiowR5CzcFzf1tSFti1K/WIGr/jsu
NP+1R3sim17pgg6SCOFnUMRS0KnDihkzoeP6z+0tFsrbCH4V1+fq0iud8WgYQrgD
3ttUcHrz04p7wsMoeqndUQoLbyJzP8MpA2XJsoacdIVkuLv2AESGXLhJym/e9HGN
g8bqdz25X0hVTczZW1FN9AZyWWVf9Go6jqC7LCaOnYXAnOkEy2/JHdkeNXYFZHB3
71UemfkCjfp0vlRV8pVpkBGMhRNFphBTfxdqeWiGQwVqrhaJO4M7DJlQHCAPY16P
o9ywnhLDhFHD7KIfTih9XxrdgTowqcwyGX3e3aJpTwIDAQABo1MwUTAdBgNVHQ4E
FgQU5NogMq6mRBeGl4i6hIuUlcR2bVEwHwYDVR0jBBgwFoAU5NogMq6mRBeGl4i6
hIuUlcR2bVEwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAYW34
JY1kd0UO5HE41oxJD4PioQboXXX0al4RgKaUUsPykeHQbK0q0TSYAZLwRjooTVUO
Wvna5bU2mzyULqA2r/Cr/w4zb9xybO3SiHFHcU1RacouauHXROHwRm98i8A73xnH
vHws5BADr2ggnVcPNh4VOQ9ZvBlC7jhgpvMjqOEu5ZPCovhfZYfSsvBDHcD74ZYm
Di9DvqsJmrb23Dv3SUykm3W+Ql2q+JyjFj30rhD89CFwJ9iSlFwTYEwZLHA+mV6p
UKy3I3Fiht1Oc+nIivX5uhRSMbDVvDTVHbjjPujxxFjkiHXMjtwvwfg4Sb6du61q
AjBRFyXbNu4hZkkHOA==
-----END CERTIFICATE-----

View File

@@ -1,30 +0,0 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFLTBXBgkqhkiG9w0BBQ0wSjApBgkqhkiG9w0BBQwwHAQI94UBqjaZlG4CAggA
MAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBCvJhEy+3/+0Ec0gpd5dkP6BIIE
0E7rLplTe9rxK3sR9V0cx8Xn6V+uFhG3p7dzeMDCCKpGo9MEaacF5m+paGnBkMlH
Pz3rRoLA5jqzwXl4US/C5E1Or//2YBgF1XXKi3BPF/bVx/g6vR+xeobf9kQGbqQk
FNPYtP7mpg2dekp5BUsKSosIt8BkknWFvhBeNuGZT/zlMUuq1WpMe4KIh/W9IdNr
HolcuZJWBhQAwGPciWIZRyq48wKa++W7Jdg/aG8FviJQnjaAUv4CyZJHUJnaNwUx
iHOETpzIC+bhF2K+s4g5w68VCj6Jtz78sIBEZKzo7LI5QHdRHqYB5SJ/dGiV+h09
R/rQ/M+24mwHDlRSCxxq0yuDwUuGBlHyATeDCFeE3L5OX8yTLuqYJ6vUa6UbzMYA
8H4l5zfu9RrAhKYa9tD+4ONxMmHziIgmn5zvSXeBwJKfeUbnN4IKWLsSoSVspBRh
zLl51DMAnem4NEjLfIW8WYjhsvSYwd9BYqxXaAiv4Wjx9ZV1yLqFICC7tejpVdRT
afI0qMOfWu4ma6xVBg1ezLgF1wHIPrq6euTvWdnifYQopVICALlltEo5oxQ2i/OM
NY8RyovWujiGNsa3pId9HmZXiLyLXjKPstGWRK4liMyc2EiP099gTdBvrb+VQp+I
EyPavmh3WNhgZGOh3qah39X8HrBprc0PPfSPlxpaWdNMIIMSbcIWWdJEA/e4tcy/
uBaV4H3sNCtBApgrb6B9YUbS9CXNUburJo19T1sk2uCaO12qYfdu2IDEnFf8JiF3
i7nyftotRuoKq2D+V8d0PeMi/vJSo6+eZIn7VNe6ejYf+w0s7sxlpiKVzkslyOhq
n0T4M3ZkSwGIETzgkRRuTY1OK7slhglMgXlQ2FuIUUo6CRg9WjRJvI5rujLzLWfB
hkgP8STirjTV0DUWPFGtUcenvEcZPkYIQcoPHxOJGNW3ZPXNpt4RjbvPLeVzDm0O
WJiay/qhag/bXGqKraO3b6Y7FOzJa8kG4G0XrcFY1s2oCXRqRqYJAtwaEeVCjCSJ
Qy0OZkqcJEU7pv98pLMpG9OWz4Gle77g4KoQUJjQGtmg0MUMoPd0iPRmvkxsYg8E
Q9uZS3m6PpWmmYDY0Ik1w/4avs3skl2mW3dqcZGLEepkjiQSnFABsuvxKd+uIEQy
lyf9FrynXVcUI87LUkuniLRKwZZzFALVuc+BwtO3SA5mvEK22ZEq9QOysbwlpN54
G5xXJKJEeexUSjEUIij4J89RLsXldibhp7YYZ7rFviR6chIqC0V7G6VqAM9TOCrV
PWZXr3ZY5/pCZYs5DYKFJBFMSQ2UT/++VYxdZCeBH75vaxugbS8RdUM+iVDevWpQ
/AnP1FolNAgkVhi3Rw4L16SibkqpEzIi1svPWKMwXdvewA32UidLElhuTWWjI2Wm
veXhmEqwk/7ML4JMI7wHcDQdvSKen0mCL2J9tB7A/pewYyDE0ffIUmjxglOtw30f
ZOlQKhMaKJGXp00U2zsHA2NJRI/hThbJncsnZyvuLei0P42RrF+r64b/0gUH6IZ5
wPUttT815KSNoy+XXXum9YGDYYFoAL+6WVEkl6dgo+X0hcH7DDf5Nkewiq8UcJGh
/69vFIfp+JlpicXzZ+R42LO3T3luC907aFBywF3pmi//
-----END ENCRYPTED PRIVATE KEY-----

View File

@@ -49,6 +49,6 @@ def create_app_with_args(args):
try: try:
logger.info(f"foo={args.foo}") logger.info(f"foo={args.foo}")
except AttributeError: except AttributeError:
logger.info(f"target={args.target}") logger.info(f"module={args.module}")
return app return app

View File

@@ -11,7 +11,7 @@ from aioquic.quic.events import ProtocolNegotiated
from sanic import Request, Sanic from sanic import Request, Sanic
from sanic.compat import Header from sanic.compat import Header
from sanic.config import DEFAULT_CONFIG from sanic.config import DEFAULT_CONFIG
from sanic.exceptions import BadRequest, PayloadTooLarge from sanic.exceptions import PayloadTooLarge
from sanic.http.constants import Stage from sanic.http.constants import Stage
from sanic.http.http3 import Http3, HTTPReceiver from sanic.http.http3 import Http3, HTTPReceiver
from sanic.models.server_types import ConnInfo from sanic.models.server_types import ConnInfo
@@ -292,48 +292,3 @@ def test_request_conn_info(app):
receiver = http3.get_receiver_by_stream_id(1) receiver = http3.get_receiver_by_stream_id(1)
assert isinstance(receiver.request.conn_info, ConnInfo) assert isinstance(receiver.request.conn_info, ConnInfo)
def test_request_header_encoding(app):
protocol = generate_protocol(app)
http3 = Http3(protocol, protocol.transmit)
with pytest.raises(BadRequest) as exc_info:
http3.http_event_received(
HeadersReceived(
[
(b":method", b"GET"),
(b":path", b"/location"),
(b":scheme", b"https"),
(b":authority", b"localhost:8443"),
("foo\u00A0".encode(), b"bar"),
],
1,
False,
)
)
assert exc_info.value.status_code == 400
assert (
str(exc_info.value)
== "Header names may only contain US-ASCII characters."
)
def test_request_url_encoding(app):
protocol = generate_protocol(app)
http3 = Http3(protocol, protocol.transmit)
with pytest.raises(BadRequest) as exc_info:
http3.http_event_received(
HeadersReceived(
[
(b":method", b"GET"),
(b":path", b"/location\xA0"),
(b":scheme", b"https"),
(b":authority", b"localhost:8443"),
(b"foo", b"bar"),
],
1,
False,
)
)
assert exc_info.value.status_code == 400
assert str(exc_info.value) == "URL may only contain US-ASCII characters."

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(TypeError): with pytest.raises(SanicException):
Sanic() Sanic()
@@ -448,7 +448,7 @@ def test_custom_context():
@pytest.mark.parametrize("use", (False, True)) @pytest.mark.parametrize("use", (False, True))
def test_uvloop_config(app: Sanic, monkeypatch, use): def test_uvloop_config(app: Sanic, monkeypatch, use):
@app.get("/test", name="test") @app.get("/test")
def handler(request): def handler(request):
return text("ok") return text("ok")
@@ -571,6 +571,21 @@ def test_cannot_run_single_process_and_workers_or_auto_reload(
app.run(single_process=True, **extra) app.run(single_process=True, **extra)
def test_cannot_run_single_process_and_legacy(app: Sanic):
message = "Cannot run single process and legacy mode"
with pytest.raises(RuntimeError, match=message):
app.run(single_process=True, legacy=True)
def test_cannot_run_without_sys_signals_with_workers(app: Sanic):
message = (
"Cannot run Sanic.serve with register_sys_signals=False. "
"Use either Sanic.serve_single or Sanic.serve_legacy."
)
with pytest.raises(RuntimeError, match=message):
app.run(register_sys_signals=False, single_process=False, legacy=False)
def test_default_configure_logging(): def test_default_configure_logging():
with patch("sanic.app.logging") as mock: with patch("sanic.app.logging") as mock:
Sanic("Test") Sanic("Test")

View File

@@ -2,17 +2,13 @@ import asyncio
import logging import logging
from collections import deque, namedtuple from collections import deque, namedtuple
from unittest.mock import call
import pytest import pytest
import uvicorn import uvicorn
from httpx import Headers
from pytest import MonkeyPatch
from sanic import Sanic from sanic import Sanic
from sanic.application.state import Mode from sanic.application.state import Mode
from sanic.asgi import ASGIApp, Lifespan, MockTransport from sanic.asgi import ASGIApp, MockTransport
from sanic.exceptions import BadRequest, Forbidden, ServiceUnavailable from sanic.exceptions import BadRequest, Forbidden, ServiceUnavailable
from sanic.request import Request from sanic.request import Request
from sanic.response import json, text from sanic.response import json, text
@@ -120,6 +116,10 @@ def test_listeners_triggered(caplog):
stop_message, stop_message,
) not in caplog.record_tuples ) not in caplog.record_tuples
all_tasks = asyncio.all_tasks(asyncio.get_event_loop())
for task in all_tasks:
task.cancel()
assert before_server_start assert before_server_start
assert after_server_start assert after_server_start
assert before_server_stop assert before_server_stop
@@ -218,6 +218,10 @@ def test_listeners_triggered_async(app, caplog):
stop_message, stop_message,
) not in caplog.record_tuples ) not in caplog.record_tuples
all_tasks = asyncio.all_tasks(asyncio.get_event_loop())
for task in all_tasks:
task.cancel()
assert before_server_start assert before_server_start
assert after_server_start assert after_server_start
assert before_server_stop assert before_server_stop
@@ -268,6 +272,10 @@ def test_non_default_uvloop_config_raises_warning(app):
with pytest.warns(UserWarning) as records: with pytest.warns(UserWarning) as records:
server.run() server.run()
all_tasks = asyncio.all_tasks(asyncio.get_event_loop())
for task in all_tasks:
task.cancel()
msg = "" msg = ""
for record in records: for record in records:
_msg = str(record.message) _msg = str(record.message)
@@ -343,7 +351,6 @@ async def test_websocket_text_receive(send, receive, message_stack):
assert text == msg["text"] assert text == msg["text"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_websocket_bytes_receive(send, receive, message_stack): async def test_websocket_bytes_receive(send, receive, message_stack):
msg = {"bytes": b"hello", "type": "websocket.receive"} msg = {"bytes": b"hello", "type": "websocket.receive"}
@@ -354,7 +361,6 @@ async def test_websocket_bytes_receive(send, receive, message_stack):
assert data == msg["bytes"] assert data == msg["bytes"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_websocket_accept_with_no_subprotocols( async def test_websocket_accept_with_no_subprotocols(
send, receive, message_stack send, receive, message_stack
@@ -575,28 +581,15 @@ async def test_error_on_lifespan_exception_start(app, caplog):
async def before_server_start(_): async def before_server_start(_):
1 / 0 1 / 0
recv = AsyncMock( recv = AsyncMock(return_value={"type": "lifespan.startup"})
side_effect=[
{"type": "lifespan.startup"},
{"type": "lifespan.shutdown"},
]
)
send = AsyncMock() send = AsyncMock()
app.asgi = True app.asgi = True
lifespan = Lifespan(app, {"type": "lifespan"}, recv, send)
with caplog.at_level(logging.ERROR): with caplog.at_level(logging.ERROR):
await lifespan() await ASGIApp.create(app, {"type": "lifespan"}, recv, send)
send.assert_has_calls( send.assert_awaited_once_with(
[ {"type": "lifespan.startup.failed", "message": "division by zero"}
call(
{
"type": "lifespan.startup.failed",
"message": "division by zero",
}
)
]
) )
@@ -606,63 +599,13 @@ async def test_error_on_lifespan_exception_stop(app: Sanic):
async def before_server_stop(_): async def before_server_stop(_):
1 / 0 1 / 0
recv = AsyncMock( recv = AsyncMock(return_value={"type": "lifespan.shutdown"})
side_effect=[
{"type": "lifespan.startup"},
{"type": "lifespan.shutdown"},
]
)
send = AsyncMock() send = AsyncMock()
app.asgi = True app.asgi = True
await app._startup() await app._startup()
lifespan = Lifespan(app, {"type": "lifespan"}, recv, send) await ASGIApp.create(app, {"type": "lifespan"}, recv, send)
await lifespan()
send.assert_has_calls( send.assert_awaited_once_with(
[ {"type": "lifespan.shutdown.failed", "message": "division by zero"}
call(
{
"type": "lifespan.shutdown.failed",
"message": "division by zero",
}
)
]
) )
@pytest.mark.asyncio
async def test_asgi_headers_decoding(app: Sanic, monkeypatch: MonkeyPatch):
@app.get("/")
def handler(request: Request):
return text("")
headers_init = Headers.__init__
def mocked_headers_init(self, *args, **kwargs):
if "encoding" in kwargs:
kwargs.pop("encoding")
headers_init(self, encoding="utf-8", *args, **kwargs)
monkeypatch.setattr(Headers, "__init__", mocked_headers_init)
message = "Header names can only contain US-ASCII characters"
with pytest.raises(BadRequest, match=message):
_, response = await app.asgi_client.get("/", headers={"😂": "😅"})
_, response = await app.asgi_client.get("/", headers={"Test-Header": "😅"})
assert response.status_code == 200
@pytest.mark.asyncio
async def test_asgi_url_decoding(app):
@app.get("/dir/<name>", unquote=True)
def _request(request: Request, name):
return text(name)
# 2F should not become a path separator (unquoted later)
_, response = await app.asgi_client.get("/dir/some%2Fpath")
assert response.text == "some/path"
_, response = await app.asgi_client.get("/dir/some%F0%9F%98%80path")
assert response.text == "some😀path"

Some files were not shown because too many files have changed in this diff Show More