Compare commits
89 Commits
v21.6.2
...
pre-regist
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8fa52e2d2 | ||
|
|
266af1e279 | ||
|
|
6d3f1e9982 | ||
|
|
a0a3840094 | ||
|
|
f282865362 | ||
|
|
377c2ada38 | ||
|
|
264453459e | ||
|
|
3d383d7b97 | ||
|
|
c0cc26021b | ||
|
|
96c027bad5 | ||
|
|
b2a1bc69f5 | ||
|
|
426742b3e2 | ||
|
|
ab35121864 | ||
|
|
cf3c205fa5 | ||
|
|
19f6544923 | ||
|
|
f641830d26 | ||
|
|
a8d55e180c | ||
|
|
55c36e0240 | ||
|
|
2c03eee329 | ||
|
|
65e28b8c22 | ||
|
|
dfd33dd63d | ||
|
|
722a6db8d9 | ||
|
|
9c576c74db | ||
|
|
523db190a7 | ||
|
|
95631b9686 | ||
|
|
0860bfe1f1 | ||
|
|
85e7b712b9 | ||
|
|
b731a6b48c | ||
|
|
cde02b5936 | ||
|
|
abeb8d0bc0 | ||
|
|
9a9f72ad64 | ||
|
|
392a497366 | ||
|
|
36e6a6c506 | ||
|
|
a361b345ad | ||
|
|
f5bd6e3b2f | ||
|
|
6c7df68c7c | ||
|
|
5b82884f8b | ||
|
|
f0f81ec458 | ||
|
|
71cc30e5cd | ||
|
|
645310cff6 | ||
|
|
2f30b5748a | ||
|
|
5e1ef96934 | ||
|
|
57e98b62b3 | ||
|
|
3262878ebd | ||
|
|
5e12edbc38 | ||
|
|
50a606adee | ||
|
|
f995612073 | ||
|
|
bc08383acd | ||
|
|
b83a1a184c | ||
|
|
59dd6814f8 | ||
|
|
f7abf3db1b | ||
|
|
cf1d2148ac | ||
|
|
b5f2bd9b0e | ||
|
|
ba2670e99c | ||
|
|
6ffc4d9756 | ||
|
|
595d2c76ac | ||
|
|
d9796e9b1e | ||
|
|
404c5f9f9e | ||
|
|
a937e08ef0 | ||
|
|
ef4f058a6c | ||
|
|
69c5dde9bf | ||
|
|
945885d501 | ||
|
|
9d0b54c90d | ||
|
|
2e5c288fea | ||
|
|
f32ef20b74 | ||
|
|
e2eefaac55 | ||
|
|
e1cfbf0fd9 | ||
|
|
08c5689441 | ||
|
|
8dbda247d6 | ||
|
|
71a631237d | ||
|
|
e22ff3828b | ||
|
|
b1b12e004e | ||
|
|
0ba57d4701 | ||
|
|
54ca6a6178 | ||
|
|
7dd4a78cf2 | ||
|
|
52ff49512a | ||
|
|
4732b6bdfa | ||
|
|
c3b6fa1bba | ||
|
|
94d496afe1 | ||
|
|
7b7a572f9b | ||
|
|
1b8cb742f9 | ||
|
|
3492d180a8 | ||
|
|
021da38373 | ||
|
|
ac784759d5 | ||
|
|
36eda2cd62 | ||
|
|
08a4b3013f | ||
|
|
1dd0332e8b | ||
|
|
a90877ac31 | ||
|
|
8b7ea27a48 |
@@ -1,5 +1,7 @@
|
||||
exclude_patterns:
|
||||
- "sanic/__main__.py"
|
||||
- "sanic/application/logo.py"
|
||||
- "sanic/application/motd.py"
|
||||
- "sanic/reloader_helpers.py"
|
||||
- "sanic/simple.py"
|
||||
- "sanic/utils.py"
|
||||
@@ -8,5 +10,19 @@ exclude_patterns:
|
||||
- "docker/"
|
||||
- "docs/"
|
||||
- "examples/"
|
||||
- "hack/"
|
||||
- "scripts/"
|
||||
- "tests/"
|
||||
checks:
|
||||
argument-count:
|
||||
enabled: false
|
||||
file-lines:
|
||||
config:
|
||||
threshold: 1000
|
||||
method-count:
|
||||
config:
|
||||
threshold: 40
|
||||
complex-logic:
|
||||
enabled: false
|
||||
method-complexity:
|
||||
config:
|
||||
threshold: 10
|
||||
|
||||
@@ -3,6 +3,9 @@ branch = True
|
||||
source = sanic
|
||||
omit =
|
||||
site-packages
|
||||
sanic/application/logo.py
|
||||
sanic/application/motd.py
|
||||
sanic/cli
|
||||
sanic/__main__.py
|
||||
sanic/reloader_helpers.py
|
||||
sanic/simple.py
|
||||
|
||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -5,11 +5,13 @@ on:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
schedule:
|
||||
- cron: '25 16 * * 0'
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
if: github.event.pull_request.draft == false
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
22
.github/workflows/coverage.yml
vendored
22
.github/workflows/coverage.yml
vendored
@@ -1,19 +1,15 @@
|
||||
name: Coverage check
|
||||
# on:
|
||||
# push:
|
||||
# branches:
|
||||
# - main
|
||||
# tags:
|
||||
# - "!*" # Do not execute on tags
|
||||
# paths:
|
||||
# - sanic/*
|
||||
# - tests/*
|
||||
# pull_request:
|
||||
# paths:
|
||||
# - "!*.MD"
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "!*" # Do not execute on tags
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
jobs:
|
||||
test:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
3
.github/workflows/pr-bandit.yml
vendored
3
.github/workflows/pr-bandit.yml
vendored
@@ -3,9 +3,11 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
bandit:
|
||||
if: github.event.pull_request.draft == false
|
||||
name: type-check-${{ matrix.config.python-version }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
@@ -16,6 +18,7 @@ jobs:
|
||||
- { python-version: 3.7, tox-env: security}
|
||||
- { python-version: 3.8, tox-env: security}
|
||||
- { python-version: 3.9, tox-env: security}
|
||||
- { python-version: "3.10", tox-env: security}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
2
.github/workflows/pr-docs.yml
vendored
2
.github/workflows/pr-docs.yml
vendored
@@ -3,9 +3,11 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
docsLinter:
|
||||
if: github.event.pull_request.draft == false
|
||||
name: Lint Documentation
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
|
||||
2
.github/workflows/pr-linter.yml
vendored
2
.github/workflows/pr-linter.yml
vendored
@@ -3,9 +3,11 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
linter:
|
||||
if: github.event.pull_request.draft == false
|
||||
name: lint
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
|
||||
46
.github/workflows/pr-python310.yml
vendored
Normal file
46
.github/workflows/pr-python310.yml
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
name: Python 3.10 Tests
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
testPy310:
|
||||
if: github.event.pull_request.draft == false
|
||||
name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# os: [ubuntu-latest, macos-latest]
|
||||
os: [ubuntu-latest]
|
||||
config:
|
||||
- {
|
||||
python-version: "3.10",
|
||||
tox-env: py310,
|
||||
ignore-error-flake: "false",
|
||||
command-timeout: "0",
|
||||
}
|
||||
- {
|
||||
python-version: "3.10",
|
||||
tox-env: py310-no-ext,
|
||||
ignore-error-flake: "true",
|
||||
command-timeout: "600000",
|
||||
}
|
||||
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 }},-vv=''"
|
||||
experimental-ignore-error: "${{ matrix.config.ignore-error-flake }}"
|
||||
command-timeout: "${{ matrix.config.command-timeout }}"
|
||||
test-failure-retry: "3"
|
||||
10
.github/workflows/pr-python37.yml
vendored
10
.github/workflows/pr-python37.yml
vendored
@@ -3,19 +3,15 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- sanic/*
|
||||
- tests/*
|
||||
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: false
|
||||
fail-fast: true
|
||||
matrix:
|
||||
# os: [ubuntu-latest, macos-latest]
|
||||
os: [ubuntu-latest]
|
||||
|
||||
10
.github/workflows/pr-python38.yml
vendored
10
.github/workflows/pr-python38.yml
vendored
@@ -3,19 +3,15 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- sanic/*
|
||||
- tests/*
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
testPy38:
|
||||
if: github.event.pull_request.draft == false
|
||||
name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
fail-fast: true
|
||||
matrix:
|
||||
# os: [ubuntu-latest, macos-latest]
|
||||
os: [ubuntu-latest]
|
||||
|
||||
10
.github/workflows/pr-python39.yml
vendored
10
.github/workflows/pr-python39.yml
vendored
@@ -3,19 +3,15 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- sanic/*
|
||||
- tests/*
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
testPy39:
|
||||
if: github.event.pull_request.draft == false
|
||||
name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
fail-fast: true
|
||||
matrix:
|
||||
# os: [ubuntu-latest, macos-latest]
|
||||
os: [ubuntu-latest]
|
||||
|
||||
3
.github/workflows/pr-type-check.yml
vendored
3
.github/workflows/pr-type-check.yml
vendored
@@ -3,9 +3,11 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
typeChecking:
|
||||
if: github.event.pull_request.draft == false
|
||||
name: type-check-${{ matrix.config.python-version }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
@@ -16,6 +18,7 @@ jobs:
|
||||
- { python-version: 3.7, tox-env: type-checking}
|
||||
- { python-version: 3.8, tox-env: type-checking}
|
||||
- { python-version: 3.9, tox-env: type-checking}
|
||||
- { python-version: "3.10", tox-env: type-checking}
|
||||
steps:
|
||||
- name: Checkout the repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
65
.github/workflows/pr-windows.yml
vendored
65
.github/workflows/pr-windows.yml
vendored
@@ -1,34 +1,37 @@
|
||||
# name: Run Unit Tests on Windows
|
||||
# on:
|
||||
# pull_request:
|
||||
# branches:
|
||||
# - main
|
||||
name: Run Unit Tests on Windows
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
# jobs:
|
||||
# testsOnWindows:
|
||||
# name: ut-${{ matrix.config.tox-env }}
|
||||
# runs-on: windows-latest
|
||||
# strategy:
|
||||
# fail-fast: false
|
||||
# matrix:
|
||||
# config:
|
||||
# - { python-version: 3.7, tox-env: py37-no-ext }
|
||||
# - { python-version: 3.8, tox-env: py38-no-ext }
|
||||
# - { python-version: 3.9, tox-env: py39-no-ext }
|
||||
# - { python-version: pypy-3.7, tox-env: pypy37-no-ext }
|
||||
jobs:
|
||||
testsOnWindows:
|
||||
if: github.event.pull_request.draft == false
|
||||
name: ut-${{ matrix.config.tox-env }}
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
config:
|
||||
- { python-version: 3.7, tox-env: py37-no-ext }
|
||||
- { python-version: 3.8, tox-env: py38-no-ext }
|
||||
- { python-version: 3.9, tox-env: py39-no-ext }
|
||||
- { python-version: "3.10", tox-env: py310-no-ext }
|
||||
- { python-version: pypy-3.7, tox-env: pypy37-no-ext }
|
||||
|
||||
# steps:
|
||||
# - name: Checkout Repository
|
||||
# uses: actions/checkout@v2
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# - name: Run Unit Tests
|
||||
# uses: ahopkins/custom-actions@pip-extra-args
|
||||
# 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 }}"
|
||||
# experimental-ignore-error: "true"
|
||||
# command-timeout: "600000"
|
||||
# pip-extra-args: "--user"
|
||||
- name: Run Unit Tests
|
||||
uses: ahopkins/custom-actions@pip-extra-args
|
||||
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 }}"
|
||||
experimental-ignore-error: "true"
|
||||
command-timeout: "600000"
|
||||
pip-extra-args: "--user"
|
||||
|
||||
2
.github/workflows/publish-images.yml
vendored
2
.github/workflows/publish-images.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
python-version: ["3.7", "3.8", "3.9"]
|
||||
python-version: ["3.7", "3.8", "3.9", "3.10"]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -1,3 +1,22 @@
|
||||
.. note::
|
||||
|
||||
From v21.9, CHANGELOG files are maintained in ``./docs/sanic/releases``
|
||||
|
||||
Version 21.6.1
|
||||
--------------
|
||||
|
||||
Bugfixes
|
||||
********
|
||||
|
||||
* `#2178 <https://github.com/sanic-org/sanic/pull/2178>`_
|
||||
Update sanic-routing to allow for better splitting of complex URI templates
|
||||
* `#2183 <https://github.com/sanic-org/sanic/pull/2183>`_
|
||||
Proper handling of chunked request bodies to resolve phantom 503 in logs
|
||||
* `#2181 <https://github.com/sanic-org/sanic/pull/2181>`_
|
||||
Resolve regression in exception logging
|
||||
* `#2201 <https://github.com/sanic-org/sanic/pull/2201>`_
|
||||
Cleanup request info in pipelined requests
|
||||
|
||||
Version 21.6.0
|
||||
--------------
|
||||
|
||||
@@ -638,7 +657,7 @@ Improved Documentation
|
||||
Version 20.6.0
|
||||
---------------
|
||||
|
||||
*Released, but unintentionally ommitting PR #1880, so was replaced by 20.6.1*
|
||||
*Released, but unintentionally omitting PR #1880, so was replaced by 20.6.1*
|
||||
|
||||
|
||||
Version 20.3.0
|
||||
@@ -1071,7 +1090,7 @@ Version 18.12
|
||||
* Fix Range header handling for static files (#1402)
|
||||
* Fix the logger and make it work (#1397)
|
||||
* Fix type pikcle->pickle in multiprocessing test
|
||||
* Fix pickling blueprints Change the string passed in the "name" section of the namedtuples in Blueprint to match the name of the Blueprint module attribute name. This allows blueprints to be pickled and unpickled, without errors, which is a requirment of running Sanic in multiprocessing mode in Windows. Added a test for pickling and unpickling blueprints Added a test for pickling and unpickling sanic itself Added a test for enabling multiprocessing on an app with a blueprint (only useful to catch this bug if the tests are run on Windows).
|
||||
* Fix pickling blueprints Change the string passed in the "name" section of the namedtuples in Blueprint to match the name of the Blueprint module attribute name. This allows blueprints to be pickled and unpickled, without errors, which is a requirement of running Sanic in multiprocessing mode in Windows. Added a test for pickling and unpickling blueprints Added a test for pickling and unpickling sanic itself Added a test for enabling multiprocessing on an app with a blueprint (only useful to catch this bug if the tests are run on Windows).
|
||||
* Fix document for logging
|
||||
|
||||
Version 0.8
|
||||
@@ -1110,7 +1129,7 @@ Version 0.8
|
||||
* Content-length header on 204/304 responses (Arnulfo Solís)
|
||||
* Extend WebSocketProtocol arguments and add docs (Bob Olde Hampsink, yunstanford)
|
||||
* Update development status from pre-alpha to beta (Maksim Anisenkov)
|
||||
* KeepAlive Timout log level changed to debug (Arnulfo Solís)
|
||||
* KeepAlive Timeout log level changed to debug (Arnulfo Solís)
|
||||
* Pin pytest to 3.3.2 because of pytest-dev/pytest#3170 (Maksim Aniskenov)
|
||||
* Install Python 3.5 and 3.6 on docker container for tests (Shahin Azad)
|
||||
* Add support for blueprint groups and nesting (Elias Tarhini)
|
||||
|
||||
@@ -19,7 +19,7 @@ a virtual environment already set up, then run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip3 install -e . ".[dev]"
|
||||
pip install -e ".[dev]"
|
||||
|
||||
Dependency Changes
|
||||
------------------
|
||||
|
||||
21
README.rst
21
README.rst
@@ -11,7 +11,7 @@ Sanic | Build fast. Run fast.
|
||||
:stub-columns: 1
|
||||
|
||||
* - Build
|
||||
- | |Py39Test| |Py38Test| |Py37Test| |Codecov|
|
||||
- | |Py39Test| |Py38Test| |Py37Test|
|
||||
* - Docs
|
||||
- | |UserGuide| |Documentation|
|
||||
* - Package
|
||||
@@ -27,8 +27,6 @@ Sanic | Build fast. Run fast.
|
||||
:target: https://community.sanicframework.org/
|
||||
.. |Discord| image:: https://img.shields.io/discord/812221182594121728?logo=discord
|
||||
:target: https://discord.gg/FARQzAEMAA
|
||||
.. |Codecov| image:: https://codecov.io/gh/sanic-org/sanic/branch/master/graph/badge.svg
|
||||
:target: https://codecov.io/gh/sanic-org/sanic
|
||||
.. |Py39Test| image:: https://github.com/sanic-org/sanic/actions/workflows/pr-python39.yml/badge.svg?branch=main
|
||||
:target: https://github.com/sanic-org/sanic/actions/workflows/pr-python39.yml
|
||||
.. |Py38Test| image:: https://github.com/sanic-org/sanic/actions/workflows/pr-python38.yml/badge.svg?branch=main
|
||||
@@ -77,17 +75,11 @@ The goal of the project is to provide a simple way to get up and running a highl
|
||||
Sponsor
|
||||
-------
|
||||
|
||||
|Try CodeStream|
|
||||
Check out `open collective <https://opencollective.com/sanic-org>`_ to learn more about helping to fund Sanic.
|
||||
|
||||
.. |Try CodeStream| image:: https://alt-images.codestream.com/codestream_logo_sanicorg.png
|
||||
:target: https://codestream.com/?utm_source=github&utm_campaign=sanicorg&utm_medium=banner
|
||||
:alt: Try CodeStream
|
||||
Thanks to `Linode <https://www.linode.com>`_ for their contribution towards the development and community of Sanic.
|
||||
|
||||
Manage pull requests and conduct code reviews in your IDE with full source-tree context. Comment on any line, not just the diffs. Use jump-to-definition, your favorite keybindings, and code intelligence with more of your workflow.
|
||||
|
||||
`Learn More <https://codestream.com/?utm_source=github&utm_campaign=sanicorg&utm_medium=banner>`_
|
||||
|
||||
Thank you to our sponsor. Check out `open collective <https://opencollective.com/sanic-org>`_ to learn more about helping to fund Sanic.
|
||||
|Linode|
|
||||
|
||||
Installation
|
||||
------------
|
||||
@@ -172,3 +164,8 @@ Contribution
|
||||
------------
|
||||
|
||||
We are always happy to have new contributions. We have `marked issues good for anyone looking to get started <https://github.com/sanic-org/sanic/issues?q=is%3Aopen+is%3Aissue+label%3Abeginner>`_, and welcome `questions on the forums <https://community.sanicframework.org/>`_. Please take a look at our `Contribution guidelines <https://github.com/sanic-org/sanic/blob/master/CONTRIBUTING.rst>`_.
|
||||
|
||||
.. |Linode| image:: https://www.linode.com/wp-content/uploads/2021/01/Linode-Logo-Black.svg
|
||||
:alt: Linode
|
||||
:target: https://www.linode.com
|
||||
:width: 200px
|
||||
|
||||
20
docs/conf.py
20
docs/conf.py
@@ -10,10 +10,8 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add support for auto-doc
|
||||
import recommonmark
|
||||
|
||||
from recommonmark.transform import AutoStructify
|
||||
# Add support for auto-doc
|
||||
|
||||
|
||||
# Ensure that sanic is present in the path, to allow sphinx-apidoc to
|
||||
@@ -26,7 +24,7 @@ import sanic
|
||||
|
||||
# -- General configuration ------------------------------------------------
|
||||
|
||||
extensions = ["sphinx.ext.autodoc", "recommonmark"]
|
||||
extensions = ["sphinx.ext.autodoc", "m2r2"]
|
||||
|
||||
templates_path = ["_templates"]
|
||||
|
||||
@@ -162,20 +160,6 @@ autodoc_default_options = {
|
||||
"member-order": "groupwise",
|
||||
}
|
||||
|
||||
|
||||
# app setup hook
|
||||
def setup(app):
|
||||
app.add_config_value(
|
||||
"recommonmark_config",
|
||||
{
|
||||
"enable_eval_rst": True,
|
||||
"enable_auto_doc_ref": False,
|
||||
},
|
||||
True,
|
||||
)
|
||||
app.add_transform(AutoStructify)
|
||||
|
||||
|
||||
html_theme_options = {
|
||||
"style_external_links": False,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
📜 Changelog
|
||||
============
|
||||
|
||||
.. mdinclude:: ./releases/21.9.md
|
||||
|
||||
.. include:: ../../CHANGELOG.rst
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
♥️ Contributing
|
||||
==============
|
||||
===============
|
||||
|
||||
.. include:: ../../CONTRIBUTING.rst
|
||||
|
||||
40
docs/sanic/releases/21.9.md
Normal file
40
docs/sanic/releases/21.9.md
Normal file
@@ -0,0 +1,40 @@
|
||||
## Version 21.9
|
||||
|
||||
### Features
|
||||
- [#2158](https://github.com/sanic-org/sanic/pull/2158), [#2248](https://github.com/sanic-org/sanic/pull/2248) Complete overhaul of I/O to websockets
|
||||
- [#2160](https://github.com/sanic-org/sanic/pull/2160) Add new 17 signals into server and request lifecycles
|
||||
- [#2162](https://github.com/sanic-org/sanic/pull/2162) Smarter `auto` fallback formatting upon exception
|
||||
- [#2184](https://github.com/sanic-org/sanic/pull/2184) Introduce implementation for copying a Blueprint
|
||||
- [#2200](https://github.com/sanic-org/sanic/pull/2200) Accept header parsing
|
||||
- [#2207](https://github.com/sanic-org/sanic/pull/2207) Log remote address if available
|
||||
- [#2209](https://github.com/sanic-org/sanic/pull/2209) Add convenience methods to BP groups
|
||||
- [#2216](https://github.com/sanic-org/sanic/pull/2216) Add default messages to SanicExceptions
|
||||
- [#2225](https://github.com/sanic-org/sanic/pull/2225) Type annotation convenience for annotated handlers with path parameters
|
||||
- [#2236](https://github.com/sanic-org/sanic/pull/2236) Allow Falsey (but not-None) responses from route handlers
|
||||
- [#2238](https://github.com/sanic-org/sanic/pull/2238) Add `exception` decorator to Blueprint Groups
|
||||
- [#2244](https://github.com/sanic-org/sanic/pull/2244) Explicit static directive for serving file or dir (ex: `static(..., resource_type="file")`)
|
||||
- [#2245](https://github.com/sanic-org/sanic/pull/2245) Close HTTP loop when connection task cancelled
|
||||
|
||||
### Bugfixes
|
||||
- [#2188](https://github.com/sanic-org/sanic/pull/2188) Fix the handling of the end of a chunked request
|
||||
- [#2195](https://github.com/sanic-org/sanic/pull/2195) Resolve unexpected error handling on static requests
|
||||
- [#2208](https://github.com/sanic-org/sanic/pull/2208) Make blueprint-based exceptions attach and trigger in a more intuitive manner
|
||||
- [#2211](https://github.com/sanic-org/sanic/pull/2211) Fixed for handling exceptions of asgi app call
|
||||
- [#2213](https://github.com/sanic-org/sanic/pull/2213) Fix bug where ws exceptions not being logged
|
||||
- [#2231](https://github.com/sanic-org/sanic/pull/2231) Cleaner closing of tasks by using `abort()` in strategic places to avoid dangling sockets
|
||||
- [#2247](https://github.com/sanic-org/sanic/pull/2247) Fix logging of auto-reload status in debug mode
|
||||
- [#2246](https://github.com/sanic-org/sanic/pull/2246) Account for BP with exception handler but no routes
|
||||
|
||||
### Developer infrastructure
|
||||
- [#2194](https://github.com/sanic-org/sanic/pull/2194) HTTP unit tests with raw client
|
||||
- [#2199](https://github.com/sanic-org/sanic/pull/2199) Switch to codeclimate
|
||||
- [#2214](https://github.com/sanic-org/sanic/pull/2214) Try Reopening Windows Tests
|
||||
- [#2229](https://github.com/sanic-org/sanic/pull/2229) Refactor `HttpProtocol` into a base class
|
||||
- [#2230](https://github.com/sanic-org/sanic/pull/2230) Refactor `server.py` into multi-file module
|
||||
|
||||
### Miscellaneous
|
||||
- [#2173](https://github.com/sanic-org/sanic/pull/2173) Remove Duplicated Dependencies and PEP 517 Support
|
||||
- [#2193](https://github.com/sanic-org/sanic/pull/2193), [#2196](https://github.com/sanic-org/sanic/pull/2196), [#2217](https://github.com/sanic-org/sanic/pull/2217) Type annotation changes
|
||||
|
||||
|
||||
|
||||
@@ -4,12 +4,14 @@ import asyncio
|
||||
|
||||
from sanic import Sanic
|
||||
|
||||
app = Sanic()
|
||||
|
||||
app = Sanic(__name__)
|
||||
|
||||
|
||||
async def notify_server_started_after_five_seconds():
|
||||
await asyncio.sleep(5)
|
||||
print('Server successfully started!')
|
||||
print("Server successfully started!")
|
||||
|
||||
|
||||
app.add_task(notify_server_started_after_five_seconds())
|
||||
|
||||
|
||||
@@ -1,30 +1,29 @@
|
||||
from sanic import Sanic
|
||||
from sanic.response import text
|
||||
from random import randint
|
||||
|
||||
app = Sanic()
|
||||
from sanic import Sanic
|
||||
from sanic.response import text
|
||||
|
||||
|
||||
@app.middleware('request')
|
||||
app = Sanic(__name__)
|
||||
|
||||
|
||||
@app.middleware("request")
|
||||
def append_request(request):
|
||||
# Add new key with random value
|
||||
request['num'] = randint(0, 100)
|
||||
request.ctx.num = randint(0, 100)
|
||||
|
||||
|
||||
@app.get('/pop')
|
||||
@app.get("/pop")
|
||||
def pop_handler(request):
|
||||
# Pop key from request object
|
||||
num = request.pop('num')
|
||||
return text(num)
|
||||
return text(request.ctx.num)
|
||||
|
||||
|
||||
@app.get('/key_exist')
|
||||
@app.get("/key_exist")
|
||||
def key_exist_handler(request):
|
||||
# Check the key is exist or not
|
||||
if 'num' in request:
|
||||
return text('num exist in request')
|
||||
if hasattr(request.ctx, "num"):
|
||||
return text("num exist in request")
|
||||
|
||||
return text('num does not exist in reqeust')
|
||||
return text("num does not exist in request")
|
||||
|
||||
|
||||
app.run(host="0.0.0.0", port=8000, debug=True)
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from sanic import Sanic
|
||||
from functools import wraps
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.response import json
|
||||
|
||||
app = Sanic()
|
||||
|
||||
app = Sanic(__name__)
|
||||
|
||||
|
||||
def check_request_for_authorization_status(request):
|
||||
@@ -27,14 +29,16 @@ def authorized(f):
|
||||
return response
|
||||
else:
|
||||
# the user is not authorized.
|
||||
return json({'status': 'not_authorized'}, 403)
|
||||
return json({"status": "not_authorized"}, 403)
|
||||
|
||||
return decorated_function
|
||||
|
||||
|
||||
@app.route("/")
|
||||
@authorized
|
||||
async def test(request):
|
||||
return json({'status': 'authorized'})
|
||||
return json({"status": "authorized"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000)
|
||||
|
||||
@@ -1,43 +1,53 @@
|
||||
from sanic import Sanic, Blueprint
|
||||
from sanic import Blueprint, Sanic
|
||||
from sanic.response import text
|
||||
'''
|
||||
Demonstrates that blueprint request middleware are executed in the order they
|
||||
|
||||
|
||||
"""
|
||||
Demonstrates that blueprint request middleware are executed in the order they
|
||||
are added. And blueprint response middleware are executed in _reverse_ order.
|
||||
On a valid request, it should print "1 2 3 6 5 4" to terminal
|
||||
'''
|
||||
"""
|
||||
|
||||
app = Sanic(__name__)
|
||||
|
||||
bp = Blueprint("bp_"+__name__)
|
||||
bp = Blueprint("bp_" + __name__)
|
||||
|
||||
@bp.middleware('request')
|
||||
|
||||
@bp.on_request
|
||||
def request_middleware_1(request):
|
||||
print('1')
|
||||
print("1")
|
||||
|
||||
@bp.middleware('request')
|
||||
|
||||
@bp.on_request
|
||||
def request_middleware_2(request):
|
||||
print('2')
|
||||
print("2")
|
||||
|
||||
@bp.middleware('request')
|
||||
|
||||
@bp.on_request
|
||||
def request_middleware_3(request):
|
||||
print('3')
|
||||
print("3")
|
||||
|
||||
@bp.middleware('response')
|
||||
|
||||
@bp.on_response
|
||||
def resp_middleware_4(request, response):
|
||||
print('4')
|
||||
print("4")
|
||||
|
||||
@bp.middleware('response')
|
||||
|
||||
@bp.on_response
|
||||
def resp_middleware_5(request, response):
|
||||
print('5')
|
||||
print("5")
|
||||
|
||||
@bp.middleware('response')
|
||||
|
||||
@bp.on_response
|
||||
def resp_middleware_6(request, response):
|
||||
print('6')
|
||||
print("6")
|
||||
|
||||
@bp.route('/')
|
||||
|
||||
@bp.route("/")
|
||||
def pop_handler(request):
|
||||
return text('hello world')
|
||||
return text("hello world")
|
||||
|
||||
app.blueprint(bp, url_prefix='/bp')
|
||||
|
||||
app.blueprint(bp, url_prefix="/bp")
|
||||
|
||||
app.run(host="0.0.0.0", port=8000, debug=True, auto_reload=False)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from sanic import Blueprint, Sanic
|
||||
from sanic.response import file, json
|
||||
|
||||
|
||||
app = Sanic(__name__)
|
||||
blueprint = Blueprint("name", url_prefix="/my_blueprint")
|
||||
blueprint2 = Blueprint("name2", url_prefix="/my_blueprint2")
|
||||
|
||||
@@ -2,17 +2,20 @@ from asyncio import sleep
|
||||
|
||||
from sanic import Sanic, response
|
||||
|
||||
|
||||
app = Sanic(__name__, strict_slashes=True)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def handler(request):
|
||||
return response.redirect("/sleep/3")
|
||||
|
||||
|
||||
@app.get("/sleep/<t:number>")
|
||||
async def handler2(request, t=0.3):
|
||||
await sleep(t)
|
||||
return response.text(f"Slept {t:.1f} seconds.\n")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000)
|
||||
|
||||
@@ -7,8 +7,10 @@ and pass in an instance of it when we create our Sanic instance. Inside this
|
||||
class' default handler, we can do anything including sending exceptions to
|
||||
an external service.
|
||||
"""
|
||||
from sanic.handlers import ErrorHandler
|
||||
from sanic.exceptions import SanicException
|
||||
from sanic.handlers import ErrorHandler
|
||||
|
||||
|
||||
"""
|
||||
Imports and code relevant for our CustomHandler class
|
||||
(Ordinarily this would be in a separate file)
|
||||
@@ -16,7 +18,6 @@ Imports and code relevant for our CustomHandler class
|
||||
|
||||
|
||||
class CustomHandler(ErrorHandler):
|
||||
|
||||
def default(self, request, exception):
|
||||
# Here, we have access to the exception object
|
||||
# and can do anything with it (log, send to external service, etc)
|
||||
@@ -38,17 +39,17 @@ server's error_handler to an instance of our CustomHandler
|
||||
|
||||
from sanic import Sanic
|
||||
|
||||
app = Sanic(__name__)
|
||||
|
||||
handler = CustomHandler()
|
||||
app.error_handler = handler
|
||||
app = Sanic(__name__, error_handler=handler)
|
||||
|
||||
|
||||
@app.route("/")
|
||||
async def test(request):
|
||||
# Here, something occurs which causes an unexpected exception
|
||||
# This exception will flow to our custom handler.
|
||||
raise SanicException('You Broke It!')
|
||||
raise SanicException("You Broke It!")
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000, debug=True)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from sanic import Sanic, response, text
|
||||
from sanic.handlers import ErrorHandler
|
||||
from sanic.server.async_server import AsyncioServer
|
||||
|
||||
|
||||
HTTP_PORT = 9999
|
||||
@@ -32,20 +34,40 @@ def proxy(request, path):
|
||||
return response.redirect(url)
|
||||
|
||||
|
||||
@https.listener("main_process_start")
|
||||
@https.main_process_start
|
||||
async def start(app, _):
|
||||
global http
|
||||
app.http_server = await http.create_server(
|
||||
http_server = await http.create_server(
|
||||
port=HTTP_PORT, return_asyncio_server=True
|
||||
)
|
||||
app.http_server.after_start()
|
||||
app.add_task(runner(http, http_server))
|
||||
app.ctx.http_server = http_server
|
||||
app.ctx.http = http
|
||||
|
||||
|
||||
@https.listener("main_process_stop")
|
||||
@https.main_process_stop
|
||||
async def stop(app, _):
|
||||
app.http_server.before_stop()
|
||||
await app.http_server.close()
|
||||
app.http_server.after_stop()
|
||||
await app.ctx.http_server.before_stop()
|
||||
await app.ctx.http_server.close()
|
||||
for connection in app.ctx.http_server.connections:
|
||||
connection.close_if_idle()
|
||||
await app.ctx.http_server.after_stop()
|
||||
app.ctx.http = False
|
||||
|
||||
|
||||
async def runner(app: Sanic, app_server: AsyncioServer):
|
||||
app.is_running = True
|
||||
try:
|
||||
app.signalize()
|
||||
app.finalize()
|
||||
ErrorHandler.finalize(app.error_handler)
|
||||
app_server.init = True
|
||||
|
||||
await app_server.before_start()
|
||||
await app_server.after_start()
|
||||
await app_server.serve_forever()
|
||||
finally:
|
||||
app.is_running = False
|
||||
app.is_stopping = True
|
||||
|
||||
|
||||
https.run(port=HTTPS_PORT, debug=True)
|
||||
|
||||
@@ -1,26 +1,30 @@
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.response import json
|
||||
|
||||
import asyncio
|
||||
import aiohttp
|
||||
|
||||
app = Sanic(__name__)
|
||||
|
||||
sem = None
|
||||
|
||||
|
||||
@app.listener('before_server_start')
|
||||
def init(sanic, loop):
|
||||
@app.before_server_start
|
||||
def init(sanic, _):
|
||||
global sem
|
||||
concurrency_per_worker = 4
|
||||
sem = asyncio.Semaphore(concurrency_per_worker, loop=loop)
|
||||
sem = asyncio.Semaphore(concurrency_per_worker)
|
||||
|
||||
|
||||
async def bounded_fetch(session, url):
|
||||
"""
|
||||
Use session object to perform 'get' request on url
|
||||
"""
|
||||
async with sem, session.get(url) as response:
|
||||
return await response.json()
|
||||
async with sem:
|
||||
response = await session.get(url)
|
||||
return response.json()
|
||||
|
||||
|
||||
@app.route("/")
|
||||
@@ -28,9 +32,9 @@ async def test(request):
|
||||
"""
|
||||
Download and serve example JSON
|
||||
"""
|
||||
url = "https://api.github.com/repos/channelcat/sanic"
|
||||
url = "https://api.github.com/repos/sanic-org/sanic"
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with httpx.AsyncClient() as session:
|
||||
response = await bounded_fetch(session, url)
|
||||
return json(response)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
|
||||
import aiotask_context as context
|
||||
from contextvars import ContextVar
|
||||
|
||||
from sanic import Sanic, response
|
||||
|
||||
@@ -11,8 +11,8 @@ log = logging.getLogger(__name__)
|
||||
class RequestIdFilter(logging.Filter):
|
||||
def filter(self, record):
|
||||
try:
|
||||
record.request_id = context.get("X-Request-ID")
|
||||
except ValueError:
|
||||
record.request_id = app.ctx.request_id.get(None) or "n/a"
|
||||
except AttributeError:
|
||||
record.request_id = "n/a"
|
||||
return True
|
||||
|
||||
@@ -49,8 +49,7 @@ app = Sanic(__name__, log_config=LOG_SETTINGS)
|
||||
|
||||
@app.on_request
|
||||
async def set_request_id(request):
|
||||
request_id = request.id
|
||||
context.set("X-Request-ID", request_id)
|
||||
request.app.ctx.request_id.set(request.id)
|
||||
log.info(f"Setting {request.id=}")
|
||||
|
||||
|
||||
@@ -61,14 +60,14 @@ async def set_request_header(request, response):
|
||||
|
||||
@app.route("/")
|
||||
async def test(request):
|
||||
log.debug("X-Request-ID: %s", context.get("X-Request-ID"))
|
||||
log.debug("X-Request-ID: %s", request.id)
|
||||
log.info("Hello from test!")
|
||||
return response.json({"test": True})
|
||||
|
||||
|
||||
@app.before_server_start
|
||||
def setup(app, loop):
|
||||
loop.set_task_factory(context.task_factory)
|
||||
app.ctx.request_id = ContextVar("request_id")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
import socket
|
||||
|
||||
from os import getenv
|
||||
from platform import node
|
||||
from uuid import getnode as get_mac
|
||||
@@ -7,10 +8,11 @@ from uuid import getnode as get_mac
|
||||
from logdna import LogDNAHandler
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.response import json
|
||||
from sanic.request import Request
|
||||
from sanic.response import json
|
||||
|
||||
log = logging.getLogger('logdna')
|
||||
|
||||
log = logging.getLogger("logdna")
|
||||
log.setLevel(logging.INFO)
|
||||
|
||||
|
||||
@@ -30,10 +32,12 @@ logdna_options = {
|
||||
"index_meta": True,
|
||||
"hostname": node(),
|
||||
"ip": get_my_ip_address(),
|
||||
"mac": get_mac_address()
|
||||
"mac": get_mac_address(),
|
||||
}
|
||||
|
||||
logdna_handler = LogDNAHandler(getenv("LOGDNA_API_KEY"), options=logdna_options)
|
||||
logdna_handler = LogDNAHandler(
|
||||
getenv("LOGDNA_API_KEY"), options=logdna_options
|
||||
)
|
||||
|
||||
logdna = logging.getLogger(__name__)
|
||||
logdna.setLevel(logging.INFO)
|
||||
@@ -49,13 +53,8 @@ def log_request(request: Request):
|
||||
|
||||
@app.route("/")
|
||||
def default(request):
|
||||
return json({
|
||||
"response": "I was here"
|
||||
})
|
||||
return json({"response": "I was here"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(
|
||||
host="0.0.0.0",
|
||||
port=getenv("PORT", 8080)
|
||||
)
|
||||
app.run(host="0.0.0.0", port=getenv("PORT", 8080))
|
||||
|
||||
@@ -59,31 +59,31 @@ async def handler_stream(request):
|
||||
return response.stream(body)
|
||||
|
||||
|
||||
@app.listener("before_server_start")
|
||||
@app.before_server_start
|
||||
async def listener_before_server_start(*args, **kwargs):
|
||||
print("before_server_start")
|
||||
|
||||
|
||||
@app.listener("after_server_start")
|
||||
@app.after_server_start
|
||||
async def listener_after_server_start(*args, **kwargs):
|
||||
print("after_server_start")
|
||||
|
||||
|
||||
@app.listener("before_server_stop")
|
||||
@app.before_server_stop
|
||||
async def listener_before_server_stop(*args, **kwargs):
|
||||
print("before_server_stop")
|
||||
|
||||
|
||||
@app.listener("after_server_stop")
|
||||
@app.after_server_stop
|
||||
async def listener_after_server_stop(*args, **kwargs):
|
||||
print("after_server_stop")
|
||||
|
||||
|
||||
@app.middleware("request")
|
||||
@app.on_request
|
||||
async def print_on_request(request):
|
||||
print("print_on_request")
|
||||
|
||||
|
||||
@app.middleware("response")
|
||||
@app.on_response
|
||||
async def print_on_response(request, response):
|
||||
print("print_on_response")
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from sanic import Sanic
|
||||
from sanic import response
|
||||
from signal import signal, SIGINT
|
||||
import asyncio
|
||||
|
||||
import uvloop
|
||||
|
||||
from sanic import Sanic, response
|
||||
|
||||
|
||||
app = Sanic(__name__)
|
||||
|
||||
|
||||
@@ -11,12 +12,19 @@ app = Sanic(__name__)
|
||||
async def test(request):
|
||||
return response.json({"answer": "42"})
|
||||
|
||||
asyncio.set_event_loop(uvloop.new_event_loop())
|
||||
server = app.create_server(host="0.0.0.0", port=8000, return_asyncio_server=True)
|
||||
loop = asyncio.get_event_loop()
|
||||
task = asyncio.ensure_future(server)
|
||||
signal(SIGINT, lambda s, f: loop.stop())
|
||||
try:
|
||||
loop.run_forever()
|
||||
except:
|
||||
loop.stop()
|
||||
|
||||
async def main():
|
||||
server = await app.create_server(
|
||||
port=8000, host="0.0.0.0", return_asyncio_server=True
|
||||
)
|
||||
|
||||
if server is None:
|
||||
return
|
||||
|
||||
await server.startup()
|
||||
await server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.set_event_loop(uvloop.new_event_loop())
|
||||
asyncio.run(main())
|
||||
|
||||
@@ -1,32 +1,62 @@
|
||||
from sanic import Sanic
|
||||
from sanic import response
|
||||
from signal import signal, SIGINT
|
||||
import asyncio
|
||||
|
||||
from signal import SIGINT, signal
|
||||
|
||||
import uvloop
|
||||
|
||||
from sanic import Sanic, response
|
||||
from sanic.server import AsyncioServer
|
||||
|
||||
|
||||
app = Sanic(__name__)
|
||||
|
||||
@app.listener('after_server_start')
|
||||
async def after_start_test(app, loop):
|
||||
print("Async Server Started!")
|
||||
|
||||
@app.before_server_start
|
||||
async def before_server_start(app, loop):
|
||||
print("Async Server starting")
|
||||
|
||||
|
||||
@app.after_server_start
|
||||
async def after_server_start(app, loop):
|
||||
print("Async Server started")
|
||||
|
||||
|
||||
@app.before_server_stop
|
||||
async def before_server_stop(app, loop):
|
||||
print("Async Server stopping")
|
||||
|
||||
|
||||
@app.after_server_stop
|
||||
async def after_server_stop(app, loop):
|
||||
print("Async Server stopped")
|
||||
|
||||
|
||||
@app.route("/")
|
||||
async def test(request):
|
||||
return response.json({"answer": "42"})
|
||||
|
||||
|
||||
asyncio.set_event_loop(uvloop.new_event_loop())
|
||||
serv_coro = app.create_server(host="0.0.0.0", port=8000, return_asyncio_server=True)
|
||||
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 = loop.run_until_complete(serv_task)
|
||||
server.after_start()
|
||||
server: AsyncioServer = loop.run_until_complete(serv_task)
|
||||
loop.run_until_complete(server.startup())
|
||||
|
||||
# When using app.run(), this actually triggers before the serv_coro.
|
||||
# But, in this example, we are using the convenience method, even if it is
|
||||
# out of order.
|
||||
loop.run_until_complete(server.before_start())
|
||||
loop.run_until_complete(server.after_start())
|
||||
try:
|
||||
loop.run_forever()
|
||||
except KeyboardInterrupt as e:
|
||||
except KeyboardInterrupt:
|
||||
loop.stop()
|
||||
finally:
|
||||
server.before_stop()
|
||||
loop.run_until_complete(server.before_stop())
|
||||
|
||||
# Wait for server to close
|
||||
close_task = server.close()
|
||||
@@ -35,4 +65,4 @@ finally:
|
||||
# Complete all tasks on the loop
|
||||
for connection in server.connections:
|
||||
connection.close_if_idle()
|
||||
server.after_stop()
|
||||
loop.run_until_complete(server.after_stop())
|
||||
|
||||
@@ -1,42 +1,41 @@
|
||||
from sanic import Sanic
|
||||
from sanic.views import HTTPMethodView
|
||||
from sanic.response import text
|
||||
from sanic.views import HTTPMethodView
|
||||
|
||||
app = Sanic('some_name')
|
||||
|
||||
app = Sanic("some_name")
|
||||
|
||||
|
||||
class SimpleView(HTTPMethodView):
|
||||
|
||||
def get(self, request):
|
||||
return text('I am get method')
|
||||
return text("I am get method")
|
||||
|
||||
def post(self, request):
|
||||
return text('I am post method')
|
||||
return text("I am post method")
|
||||
|
||||
def put(self, request):
|
||||
return text('I am put method')
|
||||
return text("I am put method")
|
||||
|
||||
def patch(self, request):
|
||||
return text('I am patch method')
|
||||
return text("I am patch method")
|
||||
|
||||
def delete(self, request):
|
||||
return text('I am delete method')
|
||||
return text("I am delete method")
|
||||
|
||||
|
||||
class SimpleAsyncView(HTTPMethodView):
|
||||
|
||||
async def get(self, request):
|
||||
return text('I am async get method')
|
||||
return text("I am async get method")
|
||||
|
||||
async def post(self, request):
|
||||
return text('I am async post method')
|
||||
return text("I am async post method")
|
||||
|
||||
async def put(self, request):
|
||||
return text('I am async put method')
|
||||
return text("I am async put method")
|
||||
|
||||
|
||||
app.add_route(SimpleView.as_view(), '/')
|
||||
app.add_route(SimpleAsyncView.as_view(), '/async')
|
||||
app.add_route(SimpleView.as_view(), "/")
|
||||
app.add_route(SimpleAsyncView.as_view(), "/async")
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000, debug=True)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import os
|
||||
|
||||
from sanic import Sanic
|
||||
from sanic.log import logger as log
|
||||
from sanic import response
|
||||
from sanic import Sanic, response
|
||||
from sanic.exceptions import ServerError
|
||||
from sanic.log import logger as log
|
||||
|
||||
|
||||
app = Sanic(__name__)
|
||||
|
||||
@@ -13,7 +13,7 @@ async def test_async(request):
|
||||
return response.json({"test": True})
|
||||
|
||||
|
||||
@app.route("/sync", methods=['GET', 'POST'])
|
||||
@app.route("/sync", methods=["GET", "POST"])
|
||||
def test_sync(request):
|
||||
return response.json({"test": True})
|
||||
|
||||
@@ -31,6 +31,7 @@ def exception(request):
|
||||
@app.route("/await")
|
||||
async def test_await(request):
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(5)
|
||||
return response.text("I'm feeling sleepy")
|
||||
|
||||
@@ -42,8 +43,10 @@ async def test_file(request):
|
||||
|
||||
@app.route("/file_stream")
|
||||
async def test_file_stream(request):
|
||||
return await response.file_stream(os.path.abspath("setup.py"),
|
||||
chunk_size=1024)
|
||||
return await response.file_stream(
|
||||
os.path.abspath("setup.py"), chunk_size=1024
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------- #
|
||||
# Exceptions
|
||||
@@ -52,14 +55,17 @@ async def test_file_stream(request):
|
||||
|
||||
@app.exception(ServerError)
|
||||
async def test(request, exception):
|
||||
return response.json({"exception": "{}".format(exception), "status": exception.status_code},
|
||||
status=exception.status_code)
|
||||
return response.json(
|
||||
{"exception": str(exception), "status": exception.status_code},
|
||||
status=exception.status_code,
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------- #
|
||||
# Read from request
|
||||
# ----------------------------------------------- #
|
||||
|
||||
|
||||
@app.route("/json")
|
||||
def post_json(request):
|
||||
return response.json({"received": True, "message": request.json})
|
||||
@@ -67,38 +73,51 @@ def post_json(request):
|
||||
|
||||
@app.route("/form")
|
||||
def post_form_json(request):
|
||||
return response.json({"received": True, "form_data": request.form, "test": request.form.get('test')})
|
||||
return response.json(
|
||||
{
|
||||
"received": True,
|
||||
"form_data": request.form,
|
||||
"test": request.form.get("test"),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/query_string")
|
||||
def query_string(request):
|
||||
return response.json({"parsed": True, "args": request.args, "url": request.url,
|
||||
"query_string": request.query_string})
|
||||
return response.json(
|
||||
{
|
||||
"parsed": True,
|
||||
"args": request.args,
|
||||
"url": request.url,
|
||||
"query_string": request.query_string,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------- #
|
||||
# Run Server
|
||||
# ----------------------------------------------- #
|
||||
|
||||
@app.listener('before_server_start')
|
||||
|
||||
@app.before_server_start
|
||||
def before_start(app, loop):
|
||||
log.info("SERVER STARTING")
|
||||
|
||||
|
||||
@app.listener('after_server_start')
|
||||
@app.after_server_start
|
||||
def after_start(app, loop):
|
||||
log.info("OH OH OH OH OHHHHHHHH")
|
||||
|
||||
|
||||
@app.listener('before_server_stop')
|
||||
@app.before_server_stop
|
||||
def before_stop(app, loop):
|
||||
log.info("SERVER STOPPING")
|
||||
|
||||
|
||||
@app.listener('after_server_stop')
|
||||
@app.after_server_stop
|
||||
def after_stop(app, loop):
|
||||
log.info("TRIED EVERYTHING")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000, debug=True)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from sanic import Sanic
|
||||
from sanic import response
|
||||
import socket
|
||||
import os
|
||||
import socket
|
||||
|
||||
from sanic import Sanic, response
|
||||
|
||||
|
||||
app = Sanic(__name__)
|
||||
|
||||
@@ -10,14 +11,15 @@ app = Sanic(__name__)
|
||||
async def test(request):
|
||||
return response.text("OK")
|
||||
|
||||
if __name__ == '__main__':
|
||||
server_address = './uds_socket'
|
||||
|
||||
if __name__ == "__main__":
|
||||
server_address = "./uds_socket"
|
||||
# Make sure the socket does not already exist
|
||||
try:
|
||||
os.unlink(server_address)
|
||||
os.unlink(server_address)
|
||||
except OSError:
|
||||
if os.path.exists(server_address):
|
||||
raise
|
||||
if os.path.exists(server_address):
|
||||
raise
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
sock.bind(server_address)
|
||||
app.run(sock=sock)
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
from sanic import Sanic
|
||||
from sanic import response
|
||||
from sanic import Sanic, response
|
||||
|
||||
|
||||
app = Sanic(__name__)
|
||||
|
||||
|
||||
@app.route('/')
|
||||
@app.route("/")
|
||||
async def index(request):
|
||||
# generate a URL for the endpoint `post_handler`
|
||||
url = app.url_for('post_handler', post_id=5)
|
||||
url = app.url_for("post_handler", post_id=5)
|
||||
# the URL is `/posts/5`, redirect to it
|
||||
return response.redirect(url)
|
||||
|
||||
|
||||
@app.route('/posts/<post_id>')
|
||||
@app.route("/posts/<post_id>")
|
||||
async def post_handler(request, post_id):
|
||||
return response.text('Post - {}'.format(post_id))
|
||||
|
||||
if __name__ == '__main__':
|
||||
return response.text("Post - {}".format(post_id))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000, debug=True)
|
||||
|
||||
@@ -8,7 +8,9 @@ app = Sanic(name="blue-print-group-version-example")
|
||||
bp1 = Blueprint(name="ultron", url_prefix="/ultron")
|
||||
bp2 = Blueprint(name="vision", url_prefix="/vision", strict_slashes=None)
|
||||
|
||||
bpg = Blueprint.group([bp1, bp2], url_prefix="/sentient/robot", version=1, strict_slashes=True)
|
||||
bpg = Blueprint.group(
|
||||
bp1, bp2, url_prefix="/sentient/robot", version=1, strict_slashes=True
|
||||
)
|
||||
|
||||
|
||||
@bp1.get("/name")
|
||||
@@ -31,5 +33,5 @@ async def bp2_revised_name(request):
|
||||
|
||||
app.blueprint(bpg)
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000)
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
from sanic import Sanic
|
||||
from sanic.response import file
|
||||
from sanic.response import redirect
|
||||
|
||||
|
||||
app = Sanic(__name__)
|
||||
|
||||
|
||||
@app.route('/')
|
||||
async def index(request):
|
||||
return await file('websocket.html')
|
||||
app.static("index.html", "websocket.html")
|
||||
|
||||
|
||||
@app.websocket('/feed')
|
||||
@app.route("/")
|
||||
def index(request):
|
||||
return redirect("index.html")
|
||||
|
||||
|
||||
@app.websocket("/feed")
|
||||
async def feed(request, ws):
|
||||
while True:
|
||||
data = 'hello!'
|
||||
print('Sending: ' + data)
|
||||
data = "hello!"
|
||||
print("Sending: " + data)
|
||||
await ws.send(data)
|
||||
data = await ws.recv()
|
||||
print('Received: ' + data)
|
||||
print("Received: " + data)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8000, debug=True)
|
||||
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
FROM catthehacker/ubuntu:act-latest
|
||||
SHELL [ "/bin/bash", "-c" ]
|
||||
ENTRYPOINT []
|
||||
RUN apt-get update
|
||||
RUN apt-get install gcc -y
|
||||
RUN apt-get install -y --no-install-recommends g++
|
||||
3
pyproject.toml
Normal file
3
pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
@@ -1,196 +1,15 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
from argparse import ArgumentParser, RawTextHelpFormatter
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sanic_routing import __version__ as __routing_version__ # type: ignore
|
||||
|
||||
from sanic import __version__
|
||||
from sanic.app import Sanic
|
||||
from sanic.config import BASE_LOGO
|
||||
from sanic.log import error_logger
|
||||
from sanic.simple import create_simple_server
|
||||
from sanic.cli.app import SanicCLI
|
||||
from sanic.compat import OS_IS_WINDOWS, enable_windows_color_support
|
||||
|
||||
|
||||
class SanicArgumentParser(ArgumentParser):
|
||||
def add_bool_arguments(self, *args, **kwargs):
|
||||
group = self.add_mutually_exclusive_group()
|
||||
group.add_argument(*args, action="store_true", **kwargs)
|
||||
kwargs["help"] = f"no {kwargs['help']}\n "
|
||||
group.add_argument(
|
||||
"--no-" + args[0][2:], *args[1:], action="store_false", **kwargs
|
||||
)
|
||||
if OS_IS_WINDOWS:
|
||||
enable_windows_color_support()
|
||||
|
||||
|
||||
def main():
|
||||
parser = SanicArgumentParser(
|
||||
prog="sanic",
|
||||
description=BASE_LOGO,
|
||||
formatter_class=lambda prog: RawTextHelpFormatter(
|
||||
prog, max_help_position=33
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--version",
|
||||
action="version",
|
||||
version=f"Sanic {__version__}; Routing {__routing_version__}",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--factory",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Treat app as an application factory, "
|
||||
"i.e. a () -> <Sanic app> callable"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"-s",
|
||||
"--simple",
|
||||
dest="simple",
|
||||
action="store_true",
|
||||
help="Run Sanic as a Simple Server (module arg should be a path)\n ",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-H",
|
||||
"--host",
|
||||
dest="host",
|
||||
type=str,
|
||||
default="127.0.0.1",
|
||||
help="Host address [default 127.0.0.1]",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-p",
|
||||
"--port",
|
||||
dest="port",
|
||||
type=int,
|
||||
default=8000,
|
||||
help="Port to serve on [default 8000]",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-u",
|
||||
"--unix",
|
||||
dest="unix",
|
||||
type=str,
|
||||
default="",
|
||||
help="location of unix socket\n ",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cert", dest="cert", type=str, help="Location of certificate for SSL"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key", dest="key", type=str, help="location of keyfile for SSL\n "
|
||||
)
|
||||
parser.add_bool_arguments(
|
||||
"--access-logs", dest="access_log", help="display access logs"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-w",
|
||||
"--workers",
|
||||
dest="workers",
|
||||
type=int,
|
||||
default=1,
|
||||
help="number of worker processes [default 1]\n ",
|
||||
)
|
||||
parser.add_argument("-d", "--debug", dest="debug", action="store_true")
|
||||
parser.add_argument(
|
||||
"-r",
|
||||
"--reload",
|
||||
"--auto-reload",
|
||||
dest="auto_reload",
|
||||
action="store_true",
|
||||
help="Watch source directory for file changes and reload on changes",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-R",
|
||||
"--reload-dir",
|
||||
dest="path",
|
||||
action="append",
|
||||
help="Extra directories to watch and reload on changes\n ",
|
||||
)
|
||||
parser.add_argument(
|
||||
"module",
|
||||
help=(
|
||||
"Path to your Sanic app. Example: path.to.server:app\n"
|
||||
"If running a Simple Server, path to directory to serve. "
|
||||
"Example: ./\n"
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
module_path = os.path.abspath(os.getcwd())
|
||||
if module_path not in sys.path:
|
||||
sys.path.append(module_path)
|
||||
|
||||
if args.simple:
|
||||
path = Path(args.module)
|
||||
app = create_simple_server(path)
|
||||
else:
|
||||
delimiter = ":" if ":" in args.module else "."
|
||||
module_name, app_name = args.module.rsplit(delimiter, 1)
|
||||
|
||||
if app_name.endswith("()"):
|
||||
args.factory = True
|
||||
app_name = app_name[:-2]
|
||||
|
||||
module = import_module(module_name)
|
||||
app = getattr(module, app_name, None)
|
||||
if args.factory:
|
||||
app = app()
|
||||
|
||||
app_type_name = type(app).__name__
|
||||
|
||||
if not isinstance(app, Sanic):
|
||||
raise ValueError(
|
||||
f"Module is not a Sanic app, it is a {app_type_name}. "
|
||||
f"Perhaps you meant {args.module}.app?"
|
||||
)
|
||||
if args.cert is not None or args.key is not None:
|
||||
ssl: Optional[Dict[str, Any]] = {
|
||||
"cert": args.cert,
|
||||
"key": args.key,
|
||||
}
|
||||
else:
|
||||
ssl = None
|
||||
|
||||
kwargs = {
|
||||
"host": args.host,
|
||||
"port": args.port,
|
||||
"unix": args.unix,
|
||||
"workers": args.workers,
|
||||
"debug": args.debug,
|
||||
"access_log": args.access_log,
|
||||
"ssl": ssl,
|
||||
}
|
||||
if args.auto_reload:
|
||||
kwargs["auto_reload"] = True
|
||||
|
||||
if args.path:
|
||||
if args.auto_reload or args.debug:
|
||||
kwargs["reload_dir"] = args.path
|
||||
else:
|
||||
error_logger.warning(
|
||||
"Ignoring '--reload-dir' since auto reloading was not "
|
||||
"enabled. If you would like to watch directories for "
|
||||
"changes, consider using --debug or --auto-reload."
|
||||
)
|
||||
|
||||
app.run(**kwargs)
|
||||
except ImportError as e:
|
||||
if module_name.startswith(e.name):
|
||||
error_logger.error(
|
||||
f"No module named {e.name} found.\n"
|
||||
" Example File: project/sanic_server.py -> app\n"
|
||||
" Example Module: project.sanic_server.app"
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
except ValueError:
|
||||
error_logger.exception("Failed to run app")
|
||||
cli = SanicCLI()
|
||||
cli.attach()
|
||||
cli.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "21.6.2"
|
||||
__version__ = "21.12.0dev"
|
||||
|
||||
799
sanic/app.py
799
sanic/app.py
File diff suppressed because it is too large
Load Diff
0
sanic/application/__init__.py
Normal file
0
sanic/application/__init__.py
Normal file
57
sanic/application/logo.py
Normal file
57
sanic/application/logo.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import re
|
||||
import sys
|
||||
|
||||
from os import environ
|
||||
|
||||
|
||||
BASE_LOGO = """
|
||||
|
||||
Sanic
|
||||
Build Fast. Run Fast.
|
||||
|
||||
"""
|
||||
COFFEE_LOGO = """\033[48;2;255;13;104m \033[0m
|
||||
\033[38;2;255;255;255;48;2;255;13;104m ▄████████▄ \033[0m
|
||||
\033[38;2;255;255;255;48;2;255;13;104m ██ ██▀▀▄ \033[0m
|
||||
\033[38;2;255;255;255;48;2;255;13;104m ███████████ █ \033[0m
|
||||
\033[38;2;255;255;255;48;2;255;13;104m ███████████▄▄▀ \033[0m
|
||||
\033[38;2;255;255;255;48;2;255;13;104m ▀███████▀ \033[0m
|
||||
\033[48;2;255;13;104m \033[0m
|
||||
Dark roast. No sugar."""
|
||||
|
||||
COLOR_LOGO = """\033[48;2;255;13;104m \033[0m
|
||||
\033[38;2;255;255;255;48;2;255;13;104m ▄███ █████ ██ \033[0m
|
||||
\033[38;2;255;255;255;48;2;255;13;104m ██ \033[0m
|
||||
\033[38;2;255;255;255;48;2;255;13;104m ▀███████ ███▄ \033[0m
|
||||
\033[38;2;255;255;255;48;2;255;13;104m ██ \033[0m
|
||||
\033[38;2;255;255;255;48;2;255;13;104m ████ ████████▀ \033[0m
|
||||
\033[48;2;255;13;104m \033[0m
|
||||
Build Fast. Run Fast."""
|
||||
|
||||
FULL_COLOR_LOGO = """
|
||||
|
||||
\033[38;2;255;13;104m ▄███ █████ ██ \033[0m ▄█▄ ██ █ █ ▄██████████
|
||||
\033[38;2;255;13;104m ██ \033[0m █ █ █ ██ █ █ ██
|
||||
\033[38;2;255;13;104m ▀███████ ███▄ \033[0m ▀ █ █ ██ ▄ █ ██
|
||||
\033[38;2;255;13;104m ██\033[0m █████████ █ ██ █ █ ▄▄
|
||||
\033[38;2;255;13;104m ████ ████████▀ \033[0m █ █ █ ██ █ ▀██ ███████
|
||||
|
||||
""" # noqa
|
||||
|
||||
ansi_pattern = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
||||
|
||||
|
||||
def get_logo(full=False, coffee=False):
|
||||
logo = (
|
||||
(FULL_COLOR_LOGO if full else (COFFEE_LOGO if coffee else COLOR_LOGO))
|
||||
if sys.stdout.isatty()
|
||||
else BASE_LOGO
|
||||
)
|
||||
|
||||
if (
|
||||
sys.platform == "darwin"
|
||||
and environ.get("TERM_PROGRAM") == "Apple_Terminal"
|
||||
):
|
||||
logo = ansi_pattern.sub("", logo)
|
||||
|
||||
return logo
|
||||
146
sanic/application/motd.py
Normal file
146
sanic/application/motd.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import sys
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from shutil import get_terminal_size
|
||||
from textwrap import indent, wrap
|
||||
from typing import Dict, Optional
|
||||
|
||||
from sanic import __version__
|
||||
from sanic.log import logger
|
||||
|
||||
|
||||
class MOTD(ABC):
|
||||
def __init__(
|
||||
self,
|
||||
logo: Optional[str],
|
||||
serve_location: str,
|
||||
data: Dict[str, str],
|
||||
extra: Dict[str, str],
|
||||
) -> None:
|
||||
self.logo = logo
|
||||
self.serve_location = serve_location
|
||||
self.data = data
|
||||
self.extra = extra
|
||||
self.key_width = 0
|
||||
self.value_width = 0
|
||||
|
||||
@abstractmethod
|
||||
def display(self):
|
||||
... # noqa
|
||||
|
||||
@classmethod
|
||||
def output(
|
||||
cls,
|
||||
logo: Optional[str],
|
||||
serve_location: str,
|
||||
data: Dict[str, str],
|
||||
extra: Dict[str, str],
|
||||
) -> None:
|
||||
motd_class = MOTDTTY if sys.stdout.isatty() else MOTDBasic
|
||||
motd_class(logo, serve_location, data, extra).display()
|
||||
|
||||
|
||||
class MOTDBasic(MOTD):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def display(self):
|
||||
if self.logo:
|
||||
logger.debug(self.logo)
|
||||
lines = [f"Sanic v{__version__}"]
|
||||
if self.serve_location:
|
||||
lines.append(f"Goin' Fast @ {self.serve_location}")
|
||||
lines += [
|
||||
*(f"{key}: {value}" for key, value in self.data.items()),
|
||||
*(f"{key}: {value}" for key, value in self.extra.items()),
|
||||
]
|
||||
for line in lines:
|
||||
logger.info(line)
|
||||
|
||||
|
||||
class MOTDTTY(MOTD):
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.set_variables()
|
||||
|
||||
def set_variables(self): # no cov
|
||||
fallback = (108, 24)
|
||||
terminal_width = max(
|
||||
get_terminal_size(fallback=fallback).columns, fallback[0]
|
||||
)
|
||||
self.max_value_width = terminal_width - fallback[0] + 36
|
||||
|
||||
self.key_width = 4
|
||||
self.value_width = self.max_value_width
|
||||
if self.data:
|
||||
self.key_width = max(map(len, self.data.keys()))
|
||||
self.value_width = min(
|
||||
max(map(len, self.data.values())), self.max_value_width
|
||||
)
|
||||
self.logo_lines = self.logo.split("\n") if self.logo else []
|
||||
self.logo_line_length = 24
|
||||
self.centering_length = (
|
||||
self.key_width + self.value_width + 2 + self.logo_line_length
|
||||
)
|
||||
self.display_length = self.key_width + self.value_width + 2
|
||||
|
||||
def display(self):
|
||||
version = f"Sanic v{__version__}".center(self.centering_length)
|
||||
running = (
|
||||
f"Goin' Fast @ {self.serve_location}"
|
||||
if self.serve_location
|
||||
else ""
|
||||
).center(self.centering_length)
|
||||
length = len(version) + 2 - self.logo_line_length
|
||||
first_filler = "─" * (self.logo_line_length - 1)
|
||||
second_filler = "─" * length
|
||||
display_filler = "─" * (self.display_length + 2)
|
||||
lines = [
|
||||
f"\n┌{first_filler}─{second_filler}┐",
|
||||
f"│ {version} │",
|
||||
f"│ {running} │",
|
||||
f"├{first_filler}┬{second_filler}┤",
|
||||
]
|
||||
|
||||
self._render_data(lines, self.data, 0)
|
||||
if self.extra:
|
||||
logo_part = self._get_logo_part(len(lines) - 4)
|
||||
lines.append(f"| {logo_part} ├{display_filler}┤")
|
||||
self._render_data(lines, self.extra, len(lines) - 4)
|
||||
|
||||
self._render_fill(lines)
|
||||
|
||||
lines.append(f"└{first_filler}┴{second_filler}┘\n")
|
||||
logger.info(indent("\n".join(lines), " "))
|
||||
|
||||
def _render_data(self, lines, data, start):
|
||||
offset = 0
|
||||
for idx, (key, value) in enumerate(data.items(), start=start):
|
||||
key = key.rjust(self.key_width)
|
||||
|
||||
wrapped = wrap(value, self.max_value_width, break_on_hyphens=False)
|
||||
for wrap_index, part in enumerate(wrapped):
|
||||
part = part.ljust(self.value_width)
|
||||
logo_part = self._get_logo_part(idx + offset + wrap_index)
|
||||
display = (
|
||||
f"{key}: {part}"
|
||||
if wrap_index == 0
|
||||
else (" " * len(key) + f" {part}")
|
||||
)
|
||||
lines.append(f"│ {logo_part} │ {display} │")
|
||||
if wrap_index:
|
||||
offset += 1
|
||||
|
||||
def _render_fill(self, lines):
|
||||
filler = " " * self.display_length
|
||||
idx = len(lines) - 5
|
||||
for i in range(1, len(self.logo_lines) - idx):
|
||||
logo_part = self.logo_lines[idx + i]
|
||||
lines.append(f"│ {logo_part} │ {filler} │")
|
||||
|
||||
def _get_logo_part(self, idx):
|
||||
try:
|
||||
logo_part = self.logo_lines[idx]
|
||||
except IndexError:
|
||||
logo_part = " " * (self.logo_line_length - 3)
|
||||
return logo_part
|
||||
74
sanic/application/state.py
Normal file
74
sanic/application/state.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Set, Union
|
||||
|
||||
from sanic.log import logger
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic import Sanic
|
||||
|
||||
|
||||
class StrEnum(str, Enum):
|
||||
def _generate_next_value_(name: str, *args) -> str: # type: ignore
|
||||
return name.lower()
|
||||
|
||||
|
||||
class Server(StrEnum):
|
||||
SANIC = auto()
|
||||
ASGI = auto()
|
||||
GUNICORN = auto()
|
||||
|
||||
|
||||
class Mode(StrEnum):
|
||||
PRODUCTION = auto()
|
||||
DEBUG = auto()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApplicationState:
|
||||
app: Sanic
|
||||
asgi: bool = field(default=False)
|
||||
coffee: bool = field(default=False)
|
||||
fast: bool = field(default=False)
|
||||
host: str = field(default="")
|
||||
mode: Mode = field(default=Mode.PRODUCTION)
|
||||
port: int = field(default=0)
|
||||
reload_dirs: Set[Path] = field(default_factory=set)
|
||||
server: Server = field(default=Server.SANIC)
|
||||
is_running: bool = field(default=False)
|
||||
is_started: bool = field(default=False)
|
||||
is_stopping: bool = field(default=False)
|
||||
verbosity: int = field(default=0)
|
||||
workers: int = field(default=0)
|
||||
|
||||
# This property relates to the ApplicationState instance and should
|
||||
# not be changed except in the __post_init__ method
|
||||
_init: bool = field(default=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._init = True
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
if self._init and name == "_init":
|
||||
raise RuntimeError(
|
||||
"Cannot change the value of _init after instantiation"
|
||||
)
|
||||
super().__setattr__(name, value)
|
||||
if self._init and hasattr(self, f"set_{name}"):
|
||||
getattr(self, f"set_{name}")(value)
|
||||
|
||||
def set_mode(self, value: Union[str, Mode]):
|
||||
if hasattr(self.app, "error_handler"):
|
||||
self.app.error_handler.debug = self.app.debug
|
||||
if getattr(self.app, "configure_logging", False) and self.app.debug:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
|
||||
@property
|
||||
def is_debug(self):
|
||||
return self.mode is Mode.DEBUG
|
||||
@@ -1,6 +1,5 @@
|
||||
import warnings
|
||||
|
||||
from inspect import isawaitable
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
@@ -8,24 +7,32 @@ import sanic.app # noqa
|
||||
|
||||
from sanic.compat import Header
|
||||
from sanic.exceptions import ServerError
|
||||
from sanic.http import Stage
|
||||
from sanic.models.asgi import ASGIReceive, ASGIScope, ASGISend, MockTransport
|
||||
from sanic.request import Request
|
||||
from sanic.response import BaseHTTPResponse
|
||||
from sanic.server import ConnInfo
|
||||
from sanic.websocket import WebSocketConnection
|
||||
from sanic.server.websockets.connection import WebSocketConnection
|
||||
|
||||
|
||||
class Lifespan:
|
||||
def __init__(self, asgi_app: "ASGIApp") -> None:
|
||||
self.asgi_app = asgi_app
|
||||
|
||||
if "before_server_start" in self.asgi_app.sanic_app.listeners:
|
||||
if (
|
||||
"server.init.before"
|
||||
in self.asgi_app.sanic_app.signal_router.name_index
|
||||
):
|
||||
warnings.warn(
|
||||
'You have set a listener for "before_server_start" '
|
||||
"in ASGI mode. "
|
||||
"It will be executed as early as possible, but not before "
|
||||
"the ASGI server is started."
|
||||
)
|
||||
if "after_server_stop" in self.asgi_app.sanic_app.listeners:
|
||||
if (
|
||||
"server.shutdown.after"
|
||||
in self.asgi_app.sanic_app.signal_router.name_index
|
||||
):
|
||||
warnings.warn(
|
||||
'You have set a listener for "after_server_stop" '
|
||||
"in ASGI mode. "
|
||||
@@ -42,19 +49,9 @@ class Lifespan:
|
||||
in sequence since the ASGI lifespan protocol only supports a single
|
||||
startup event.
|
||||
"""
|
||||
self.asgi_app.sanic_app.router.finalize()
|
||||
if self.asgi_app.sanic_app.signal_router.routes:
|
||||
self.asgi_app.sanic_app.signal_router.finalize()
|
||||
listeners = self.asgi_app.sanic_app.listeners.get(
|
||||
"before_server_start", []
|
||||
) + self.asgi_app.sanic_app.listeners.get("after_server_start", [])
|
||||
|
||||
for handler in listeners:
|
||||
response = handler(
|
||||
self.asgi_app.sanic_app, self.asgi_app.sanic_app.loop
|
||||
)
|
||||
if response and isawaitable(response):
|
||||
await response
|
||||
await self.asgi_app.sanic_app._startup()
|
||||
await self.asgi_app.sanic_app._server_event("init", "before")
|
||||
await self.asgi_app.sanic_app._server_event("init", "after")
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""
|
||||
@@ -65,16 +62,8 @@ class Lifespan:
|
||||
in sequence since the ASGI lifespan protocol only supports a single
|
||||
shutdown event.
|
||||
"""
|
||||
listeners = self.asgi_app.sanic_app.listeners.get(
|
||||
"before_server_stop", []
|
||||
) + self.asgi_app.sanic_app.listeners.get("after_server_stop", [])
|
||||
|
||||
for handler in listeners:
|
||||
response = handler(
|
||||
self.asgi_app.sanic_app, self.asgi_app.sanic_app.loop
|
||||
)
|
||||
if response and isawaitable(response):
|
||||
await response
|
||||
await self.asgi_app.sanic_app._server_event("shutdown", "before")
|
||||
await self.asgi_app.sanic_app._server_event("shutdown", "after")
|
||||
|
||||
async def __call__(
|
||||
self, scope: ASGIScope, receive: ASGIReceive, send: ASGISend
|
||||
@@ -96,6 +85,8 @@ class ASGIApp:
|
||||
transport: MockTransport
|
||||
lifespan: Lifespan
|
||||
ws: Optional[WebSocketConnection]
|
||||
stage: Stage
|
||||
response: Optional[BaseHTTPResponse]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.ws = None
|
||||
@@ -108,6 +99,8 @@ class ASGIApp:
|
||||
instance.sanic_app = sanic_app
|
||||
instance.transport = MockTransport(scope, receive, send)
|
||||
instance.transport.loop = sanic_app.loop
|
||||
instance.stage = Stage.IDLE
|
||||
instance.response = None
|
||||
setattr(instance.transport, "add_task", sanic_app.loop.create_task)
|
||||
|
||||
headers = Header(
|
||||
@@ -162,6 +155,8 @@ class ASGIApp:
|
||||
"""
|
||||
Read and stream the body in chunks from an incoming ASGI message.
|
||||
"""
|
||||
if self.stage is Stage.IDLE:
|
||||
self.stage = Stage.REQUEST
|
||||
message = await self.transport.receive()
|
||||
body = message.get("body", b"")
|
||||
if not message.get("more_body", False):
|
||||
@@ -176,11 +171,17 @@ class ASGIApp:
|
||||
if data:
|
||||
yield data
|
||||
|
||||
def respond(self, response):
|
||||
def respond(self, response: BaseHTTPResponse):
|
||||
if self.stage is not Stage.HANDLER:
|
||||
self.stage = Stage.FAILED
|
||||
raise RuntimeError("Response already started")
|
||||
if self.response is not None:
|
||||
self.response.stream = None
|
||||
response.stream, self.response = self, response
|
||||
return response
|
||||
|
||||
async def send(self, data, end_stream):
|
||||
self.stage = Stage.IDLE if end_stream else Stage.RESPONSE
|
||||
if self.response:
|
||||
response, self.response = self.response, None
|
||||
await self.transport.send(
|
||||
@@ -208,6 +209,7 @@ class ASGIApp:
|
||||
Handle the incoming request.
|
||||
"""
|
||||
try:
|
||||
self.stage = Stage.HANDLER
|
||||
await self.sanic_app.handle_request(self.request)
|
||||
except Exception as e:
|
||||
await self.sanic_app.handle_exception(self.request, e)
|
||||
|
||||
@@ -11,7 +11,7 @@ from sanic.mixins.routes import RouteMixin
|
||||
from sanic.mixins.signals import SignalMixin
|
||||
|
||||
|
||||
VALID_NAME = re.compile(r"^[a-zA-Z][a-zA-Z0-9_\-]*$")
|
||||
VALID_NAME = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_\-]*$")
|
||||
|
||||
|
||||
class BaseSanic(
|
||||
@@ -23,7 +23,7 @@ class BaseSanic(
|
||||
):
|
||||
__fake_slots__: Tuple[str, ...]
|
||||
|
||||
def __init__(self, name: str = None, *args, **kwargs) -> None:
|
||||
def __init__(self, name: str = None, *args: Any, **kwargs: Any) -> None:
|
||||
class_name = self.__class__.__name__
|
||||
|
||||
if name is None:
|
||||
@@ -58,7 +58,7 @@ class BaseSanic(
|
||||
if name not in self.__fake_slots__:
|
||||
warn(
|
||||
f"Setting variables on {self.__class__.__name__} instances is "
|
||||
"deprecated and will be removed in version 21.9. You should "
|
||||
"deprecated and will be removed in version 21.12. You should "
|
||||
f"change your {self.__class__.__name__} instance to use "
|
||||
f"instance.ctx.{name} instead.",
|
||||
DeprecationWarning,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import MutableSequence
|
||||
from functools import partial
|
||||
from typing import TYPE_CHECKING, List, Optional, Union
|
||||
|
||||
|
||||
@@ -196,6 +197,27 @@ class BlueprintGroup(MutableSequence):
|
||||
"""
|
||||
self._blueprints.append(value)
|
||||
|
||||
def exception(self, *exceptions, **kwargs):
|
||||
"""
|
||||
A decorator that can be used to implement a global exception handler
|
||||
for all the Blueprints that belong to this Blueprint Group.
|
||||
|
||||
In case of nested Blueprint Groups, the same handler is applied
|
||||
across each of the Blueprints recursively.
|
||||
|
||||
:param args: List of Python exceptions to be caught by the handler
|
||||
:param kwargs: Additional optional arguments to be passed to the
|
||||
exception handler
|
||||
:return: a decorated method to handle global exceptions for any
|
||||
blueprint registered under this group.
|
||||
"""
|
||||
|
||||
def register_exception_handler_for_blueprints(fn):
|
||||
for blueprint in self.blueprints:
|
||||
blueprint.exception(*exceptions, **kwargs)(fn)
|
||||
|
||||
return register_exception_handler_for_blueprints
|
||||
|
||||
def insert(self, index: int, item: Blueprint) -> None:
|
||||
"""
|
||||
The Abstract class `MutableSequence` leverages this insert method to
|
||||
@@ -229,3 +251,15 @@ class BlueprintGroup(MutableSequence):
|
||||
args = list(args)[1:]
|
||||
return register_middleware_for_blueprints(fn)
|
||||
return register_middleware_for_blueprints
|
||||
|
||||
def on_request(self, middleware=None):
|
||||
if callable(middleware):
|
||||
return self.middleware(middleware, "request")
|
||||
else:
|
||||
return partial(self.middleware, attach_to="request")
|
||||
|
||||
def on_response(self, middleware=None):
|
||||
if callable(middleware):
|
||||
return self.middleware(middleware, "response")
|
||||
else:
|
||||
return partial(self.middleware, attach_to="response")
|
||||
|
||||
@@ -3,8 +3,23 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
|
||||
from collections import defaultdict
|
||||
from copy import deepcopy
|
||||
from functools import wraps
|
||||
from inspect import isfunction
|
||||
from itertools import chain
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Set, Union
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
Iterable,
|
||||
List,
|
||||
Optional,
|
||||
Sequence,
|
||||
Set,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
from sanic_routing.exceptions import NotFound # type: ignore
|
||||
from sanic_routing.route import Route # type: ignore
|
||||
@@ -12,6 +27,7 @@ from sanic_routing.route import Route # type: ignore
|
||||
from sanic.base import BaseSanic
|
||||
from sanic.blueprint_group import BlueprintGroup
|
||||
from sanic.exceptions import SanicException
|
||||
from sanic.helpers import Default, _default
|
||||
from sanic.models.futures import FutureRoute, FutureStatic
|
||||
from sanic.models.handler_types import (
|
||||
ListenerType,
|
||||
@@ -24,6 +40,32 @@ if TYPE_CHECKING:
|
||||
from sanic import Sanic # noqa
|
||||
|
||||
|
||||
def lazy(func, as_decorator=True):
|
||||
@wraps(func)
|
||||
def decorator(bp, *args, **kwargs):
|
||||
nonlocal as_decorator
|
||||
kwargs["apply"] = False
|
||||
pass_handler = None
|
||||
|
||||
if args and isfunction(args[0]):
|
||||
as_decorator = False
|
||||
|
||||
def wrapper(handler):
|
||||
future = func(bp, *args, **kwargs)
|
||||
if as_decorator:
|
||||
future = future(handler)
|
||||
|
||||
if bp.registered:
|
||||
for app in bp.apps:
|
||||
bp.register(app, {})
|
||||
|
||||
return future
|
||||
|
||||
return wrapper if as_decorator else wrapper(pass_handler)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class Blueprint(BaseSanic):
|
||||
"""
|
||||
In *Sanic* terminology, a **Blueprint** is a logical collection of
|
||||
@@ -37,10 +79,10 @@ class Blueprint(BaseSanic):
|
||||
|
||||
:param name: unique name of the blueprint
|
||||
:param url_prefix: URL to be prefixed before all route URLs
|
||||
:param host: IP Address of FQDN for the sanic server to use.
|
||||
:param host: IP Address or FQDN for the sanic server to use.
|
||||
:param version: Blueprint Version
|
||||
:param strict_slashes: Enforce the API urls are requested with a
|
||||
training */*
|
||||
trailing */*
|
||||
"""
|
||||
|
||||
__fake_slots__ = (
|
||||
@@ -65,26 +107,21 @@ class Blueprint(BaseSanic):
|
||||
"version_prefix",
|
||||
"websocket_routes",
|
||||
)
|
||||
__pre_registry__: Dict[Union[str, Default], Any] = defaultdict(list)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str = None,
|
||||
url_prefix: Optional[str] = None,
|
||||
host: Optional[str] = None,
|
||||
host: Optional[Union[List[str], str]] = None,
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
version_prefix: str = "/v",
|
||||
):
|
||||
super().__init__(name=name)
|
||||
|
||||
self._apps: Set[Sanic] = set()
|
||||
self.reset()
|
||||
self.ctx = SimpleNamespace()
|
||||
self.exceptions: List[RouteHandler] = []
|
||||
self.host = host
|
||||
self.listeners: Dict[str, List[ListenerType]] = {}
|
||||
self.middlewares: List[MiddlewareType] = []
|
||||
self.routes: List[Route] = []
|
||||
self.statics: List[RouteHandler] = []
|
||||
self.strict_slashes = strict_slashes
|
||||
self.url_prefix = (
|
||||
url_prefix[:-1]
|
||||
@@ -93,7 +130,6 @@ class Blueprint(BaseSanic):
|
||||
)
|
||||
self.version = version
|
||||
self.version_prefix = version_prefix
|
||||
self.websocket_routes: List[Route] = []
|
||||
|
||||
def __repr__(self) -> str:
|
||||
args = ", ".join(
|
||||
@@ -120,38 +156,100 @@ class Blueprint(BaseSanic):
|
||||
)
|
||||
return self._apps
|
||||
|
||||
def route(self, *args, **kwargs):
|
||||
kwargs["apply"] = False
|
||||
return super().route(*args, **kwargs)
|
||||
@property
|
||||
def registered(self) -> bool:
|
||||
return bool(self._apps)
|
||||
|
||||
def static(self, *args, **kwargs):
|
||||
kwargs["apply"] = False
|
||||
return super().static(*args, **kwargs)
|
||||
exception = lazy(BaseSanic.exception)
|
||||
listener = lazy(BaseSanic.listener)
|
||||
middleware = lazy(BaseSanic.middleware)
|
||||
route = lazy(BaseSanic.route)
|
||||
signal = lazy(BaseSanic.signal)
|
||||
static = lazy(BaseSanic.static, as_decorator=False)
|
||||
|
||||
def middleware(self, *args, **kwargs):
|
||||
kwargs["apply"] = False
|
||||
return super().middleware(*args, **kwargs)
|
||||
def reset(self):
|
||||
self._apps: Set[Sanic] = set()
|
||||
self.exceptions: List[RouteHandler] = []
|
||||
self.listeners: Dict[str, List[ListenerType[Any]]] = {}
|
||||
self.middlewares: List[MiddlewareType] = []
|
||||
self.routes: List[Route] = []
|
||||
self.statics: List[RouteHandler] = []
|
||||
self.websocket_routes: List[Route] = []
|
||||
|
||||
def listener(self, *args, **kwargs):
|
||||
kwargs["apply"] = False
|
||||
return super().listener(*args, **kwargs)
|
||||
def copy(
|
||||
self,
|
||||
name: str,
|
||||
url_prefix: Optional[Union[str, Default]] = _default,
|
||||
version: Optional[Union[int, str, float, Default]] = _default,
|
||||
version_prefix: Union[str, Default] = _default,
|
||||
strict_slashes: Optional[Union[bool, Default]] = _default,
|
||||
with_registration: bool = True,
|
||||
with_ctx: bool = False,
|
||||
):
|
||||
"""
|
||||
Copy a blueprint instance with some optional parameters to
|
||||
override the values of attributes in the old instance.
|
||||
|
||||
def exception(self, *args, **kwargs):
|
||||
kwargs["apply"] = False
|
||||
return super().exception(*args, **kwargs)
|
||||
:param name: unique name of the blueprint
|
||||
:param url_prefix: URL to be prefixed before all route URLs
|
||||
:param version: Blueprint Version
|
||||
:param version_prefix: the prefix of the version number shown in the
|
||||
URL.
|
||||
:param strict_slashes: Enforce the API urls are requested with a
|
||||
trailing */*
|
||||
:param with_registration: whether register new blueprint instance with
|
||||
sanic apps that were registered with the old instance or not.
|
||||
:param with_ctx: whether ``ctx`` will be copied or not.
|
||||
"""
|
||||
|
||||
def signal(self, event: str, *args, **kwargs):
|
||||
kwargs["apply"] = False
|
||||
return super().signal(event, *args, **kwargs)
|
||||
attrs_backup = {
|
||||
"_apps": self._apps,
|
||||
"routes": self.routes,
|
||||
"websocket_routes": self.websocket_routes,
|
||||
"middlewares": self.middlewares,
|
||||
"exceptions": self.exceptions,
|
||||
"listeners": self.listeners,
|
||||
"statics": self.statics,
|
||||
}
|
||||
|
||||
self.reset()
|
||||
new_bp = deepcopy(self)
|
||||
new_bp.name = name
|
||||
|
||||
if not isinstance(url_prefix, Default):
|
||||
new_bp.url_prefix = url_prefix
|
||||
if not isinstance(version, Default):
|
||||
new_bp.version = version
|
||||
if not isinstance(strict_slashes, Default):
|
||||
new_bp.strict_slashes = strict_slashes
|
||||
if not isinstance(version_prefix, Default):
|
||||
new_bp.version_prefix = version_prefix
|
||||
|
||||
for key, value in attrs_backup.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
if with_registration and self._apps:
|
||||
if new_bp._future_statics:
|
||||
raise SanicException(
|
||||
"Static routes registered with the old blueprint instance,"
|
||||
" cannot be registered again."
|
||||
)
|
||||
for app in self._apps:
|
||||
app.blueprint(new_bp)
|
||||
|
||||
if not with_ctx:
|
||||
new_bp.ctx = SimpleNamespace()
|
||||
|
||||
return new_bp
|
||||
|
||||
@staticmethod
|
||||
def group(
|
||||
*blueprints,
|
||||
url_prefix="",
|
||||
version=None,
|
||||
strict_slashes=None,
|
||||
*blueprints: Union[Blueprint, BlueprintGroup],
|
||||
url_prefix: Optional[str] = None,
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
version_prefix: str = "/v",
|
||||
):
|
||||
) -> BlueprintGroup:
|
||||
"""
|
||||
Create a list of blueprints, optionally grouping them under a
|
||||
general URL prefix.
|
||||
@@ -196,11 +294,15 @@ class Blueprint(BaseSanic):
|
||||
opt_version = options.get("version", None)
|
||||
opt_strict_slashes = options.get("strict_slashes", None)
|
||||
opt_version_prefix = options.get("version_prefix", self.version_prefix)
|
||||
error_format = options.get(
|
||||
"error_format", app.config.FALLBACK_ERROR_FORMAT
|
||||
)
|
||||
|
||||
routes = []
|
||||
middleware = []
|
||||
exception_handlers = []
|
||||
listeners = defaultdict(list)
|
||||
registered = set()
|
||||
|
||||
# Routes
|
||||
for future in self._future_routes:
|
||||
@@ -227,12 +329,15 @@ class Blueprint(BaseSanic):
|
||||
)
|
||||
|
||||
name = app._generate_name(future.name)
|
||||
host = future.host or self.host
|
||||
if isinstance(host, list):
|
||||
host = tuple(host)
|
||||
|
||||
apply_route = FutureRoute(
|
||||
future.handler,
|
||||
uri[1:] if uri.startswith("//") else uri,
|
||||
future.methods,
|
||||
future.host or self.host,
|
||||
host,
|
||||
strict_slashes,
|
||||
future.stream,
|
||||
version,
|
||||
@@ -243,8 +348,13 @@ class Blueprint(BaseSanic):
|
||||
future.unquote,
|
||||
future.static,
|
||||
version_prefix,
|
||||
error_format,
|
||||
)
|
||||
|
||||
if (self, apply_route) in app._future_registry:
|
||||
continue
|
||||
|
||||
registered.add(apply_route)
|
||||
route = app._apply_route(apply_route)
|
||||
operation = (
|
||||
routes.extend if isinstance(route, list) else routes.append
|
||||
@@ -256,35 +366,64 @@ class Blueprint(BaseSanic):
|
||||
# Prepend the blueprint URI prefix if available
|
||||
uri = url_prefix + future.uri if url_prefix else future.uri
|
||||
apply_route = FutureStatic(uri, *future[1:])
|
||||
|
||||
if (self, apply_route) in app._future_registry:
|
||||
continue
|
||||
|
||||
registered.add(apply_route)
|
||||
route = app._apply_static(apply_route)
|
||||
routes.append(route)
|
||||
|
||||
route_names = [route.name for route in routes if route]
|
||||
|
||||
# Middleware
|
||||
if route_names:
|
||||
# Middleware
|
||||
for future in self._future_middleware:
|
||||
if (self, future) in app._future_registry:
|
||||
continue
|
||||
middleware.append(app._apply_middleware(future, route_names))
|
||||
|
||||
# Exceptions
|
||||
for future in self._future_exceptions:
|
||||
exception_handlers.append(app._apply_exception_handler(future))
|
||||
# Exceptions
|
||||
for future in self._future_exceptions:
|
||||
if (self, future) in app._future_registry:
|
||||
continue
|
||||
exception_handlers.append(
|
||||
app._apply_exception_handler(future, route_names)
|
||||
)
|
||||
|
||||
# Event listeners
|
||||
for listener in self._future_listeners:
|
||||
listeners[listener.event].append(app._apply_listener(listener))
|
||||
for future in self._future_listeners:
|
||||
if (self, future) in app._future_registry:
|
||||
continue
|
||||
listeners[future.event].append(app._apply_listener(future))
|
||||
|
||||
for signal in self._future_signals:
|
||||
signal.condition.update({"blueprint": self.name})
|
||||
app._apply_signal(signal)
|
||||
# Signals
|
||||
for future in self._future_signals:
|
||||
if (self, future) in app._future_registry:
|
||||
continue
|
||||
future.condition.update({"blueprint": self.name})
|
||||
app._apply_signal(future)
|
||||
|
||||
self.routes = [route for route in routes if isinstance(route, Route)]
|
||||
self.websocket_routes = [
|
||||
self.routes += [route for route in routes if isinstance(route, Route)]
|
||||
self.websocket_routes += [
|
||||
route for route in self.routes if route.ctx.websocket
|
||||
]
|
||||
self.middlewares = middleware
|
||||
self.exceptions = exception_handlers
|
||||
self.listeners = dict(listeners)
|
||||
self.middlewares += middleware
|
||||
self.exceptions += exception_handlers
|
||||
self.listeners.update(dict(listeners))
|
||||
|
||||
if self.registered:
|
||||
self.register_futures(
|
||||
self.apps,
|
||||
self,
|
||||
chain(
|
||||
registered,
|
||||
self._future_middleware,
|
||||
self._future_exceptions,
|
||||
self._future_listeners,
|
||||
self._future_signals,
|
||||
),
|
||||
)
|
||||
|
||||
async def dispatch(self, *args, **kwargs):
|
||||
condition = kwargs.pop("condition", {})
|
||||
@@ -316,3 +455,37 @@ class Blueprint(BaseSanic):
|
||||
value = v
|
||||
break
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def register_futures(
|
||||
apps: Set[Sanic], bp: Blueprint, futures: Sequence[Tuple[Any, ...]]
|
||||
):
|
||||
for app in apps:
|
||||
app._future_registry.update(set((bp, item) for item in futures))
|
||||
|
||||
def pre_register(
|
||||
self,
|
||||
name: Union[str, Default],
|
||||
url_prefix: Optional[str] = None,
|
||||
host: Optional[Union[List[str], str]] = None,
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
version_prefix: Union[str, Default] = _default,
|
||||
) -> None:
|
||||
if not hasattr(self.ctx, "_prereg"):
|
||||
self.ctx._prereg = []
|
||||
self.ctx._prereg.append(name)
|
||||
self.__class__.__pre_registry__[name].append(
|
||||
{
|
||||
k: v
|
||||
for k, v in {
|
||||
"bp": self,
|
||||
"url_prefix": url_prefix,
|
||||
"host": host,
|
||||
"version": version,
|
||||
"strict_slashes": strict_slashes,
|
||||
"version_prefix": version_prefix,
|
||||
}.items()
|
||||
if v is not None and v is not _default
|
||||
}
|
||||
)
|
||||
|
||||
0
sanic/cli/__init__.py
Normal file
0
sanic/cli/__init__.py
Normal file
202
sanic/cli/app.py
Normal file
202
sanic/cli/app.py
Normal file
@@ -0,0 +1,202 @@
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from argparse import ArgumentParser, RawTextHelpFormatter
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from textwrap import indent
|
||||
from typing import Any, List, Union
|
||||
|
||||
from sanic.app import Sanic
|
||||
from sanic.application.logo import get_logo
|
||||
from sanic.blueprints import Blueprint
|
||||
from sanic.cli.arguments import Group
|
||||
from sanic.helpers import _default
|
||||
from sanic.log import error_logger
|
||||
from sanic.simple import create_simple_server
|
||||
|
||||
|
||||
class SanicArgumentParser(ArgumentParser):
|
||||
...
|
||||
|
||||
|
||||
class SanicCLI:
|
||||
DEFAULT_APP_NAME = "SANIC"
|
||||
DESCRIPTION = indent(
|
||||
f"""
|
||||
{get_logo(True)}
|
||||
|
||||
To start running a Sanic application, provide a path to the module, where
|
||||
app is a Sanic() instance:
|
||||
|
||||
$ sanic path.to.server:app
|
||||
|
||||
Or, a path to a callable that returns a Sanic() instance:
|
||||
|
||||
$ sanic path.to.factory:create_app --factory
|
||||
|
||||
Or, a path to a directory to run as a simple HTTP server:
|
||||
|
||||
$ sanic ./path/to/static --simple
|
||||
""",
|
||||
prefix=" ",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
width = shutil.get_terminal_size().columns
|
||||
self.parser = SanicArgumentParser(
|
||||
prog="sanic",
|
||||
description=self.DESCRIPTION,
|
||||
formatter_class=lambda prog: RawTextHelpFormatter(
|
||||
prog,
|
||||
max_help_position=36 if width > 96 else 24,
|
||||
indent_increment=4,
|
||||
width=None,
|
||||
),
|
||||
)
|
||||
self.parser._positionals.title = "Required\n========\n Positional"
|
||||
self.parser._optionals.title = "Optional\n========\n General"
|
||||
self.main_process = (
|
||||
os.environ.get("SANIC_RELOADER_PROCESS", "") != "true"
|
||||
)
|
||||
self.args: List[Any] = []
|
||||
|
||||
def attach(self):
|
||||
for group in Group._registry:
|
||||
group.create(self.parser).attach()
|
||||
|
||||
def run(self):
|
||||
# This is to provide backwards compat -v to display version
|
||||
legacy_version = len(sys.argv) == 2 and sys.argv[-1] == "-v"
|
||||
parse_args = ["--version"] if legacy_version else None
|
||||
|
||||
self.args = self.parser.parse_args(args=parse_args)
|
||||
self._precheck()
|
||||
|
||||
try:
|
||||
app = self._get_app()
|
||||
kwargs = self._build_run_kwargs()
|
||||
app.run(**kwargs)
|
||||
except ValueError:
|
||||
error_logger.exception("Failed to run app")
|
||||
|
||||
def _precheck(self):
|
||||
if self.args.debug and self.main_process:
|
||||
error_logger.warning(
|
||||
"Starting in v22.3, --debug will no "
|
||||
"longer automatically run the auto-reloader.\n Switch to "
|
||||
"--dev to continue using that functionality."
|
||||
)
|
||||
|
||||
# # Custom TLS mismatch handling for better diagnostics
|
||||
if self.main_process and (
|
||||
# one of cert/key missing
|
||||
bool(self.args.cert) != bool(self.args.key)
|
||||
# new and old style self.args used together
|
||||
or self.args.tls
|
||||
and self.args.cert
|
||||
# strict host checking without certs would always fail
|
||||
or self.args.tlshost
|
||||
and not self.args.tls
|
||||
and not self.args.cert
|
||||
):
|
||||
self.parser.print_usage(sys.stderr)
|
||||
message = (
|
||||
"TLS certificates must be specified by either of:\n"
|
||||
" --cert certdir/fullchain.pem --key certdir/privkey.pem\n"
|
||||
" --tls certdir (equivalent to the above)"
|
||||
)
|
||||
error_logger.error(message)
|
||||
sys.exit(1)
|
||||
|
||||
def _get_app(self):
|
||||
try:
|
||||
module_path = os.path.abspath(os.getcwd())
|
||||
if module_path not in sys.path:
|
||||
sys.path.append(module_path)
|
||||
|
||||
if self.args.simple:
|
||||
path = Path(self.args.module)
|
||||
app = create_simple_server(path)
|
||||
else:
|
||||
delimiter = ":" if ":" in self.args.module else "."
|
||||
module_name, app_name = self.args.module.rsplit(delimiter, 1)
|
||||
|
||||
if app_name.endswith("()"):
|
||||
self.args.factory = True
|
||||
app_name = app_name[:-2]
|
||||
|
||||
module = import_module(module_name)
|
||||
app = getattr(module, app_name, None)
|
||||
if self.args.factory:
|
||||
app = app()
|
||||
|
||||
app_type_name = type(app).__name__
|
||||
|
||||
if isinstance(app, Blueprint):
|
||||
bp = app
|
||||
name = (
|
||||
bp.ctx._prereg[0]
|
||||
if hasattr(bp.ctx, "_prereg")
|
||||
else _default
|
||||
)
|
||||
if name is _default:
|
||||
name = self.DEFAULT_APP_NAME
|
||||
app = Sanic(name)
|
||||
elif not isinstance(app, Sanic):
|
||||
raise ValueError(
|
||||
f"Module is not a Sanic app, it is a {app_type_name}\n"
|
||||
f" Perhaps you meant {self.args.module}.app?"
|
||||
)
|
||||
except ImportError as e:
|
||||
if module_name.startswith(e.name):
|
||||
error_logger.error(
|
||||
f"No module named {e.name} found.\n"
|
||||
" Example File: project/sanic_server.py -> app\n"
|
||||
" Example Module: project.sanic_server.app"
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
return app
|
||||
|
||||
def _build_run_kwargs(self):
|
||||
ssl: Union[None, dict, str, list] = []
|
||||
if self.args.tlshost:
|
||||
ssl.append(None)
|
||||
if self.args.cert is not None or self.args.key is not None:
|
||||
ssl.append(dict(cert=self.args.cert, key=self.args.key))
|
||||
if self.args.tls:
|
||||
ssl += self.args.tls
|
||||
if not ssl:
|
||||
ssl = None
|
||||
elif len(ssl) == 1 and ssl[0] is not None:
|
||||
# Use only one cert, no TLSSelector.
|
||||
ssl = ssl[0]
|
||||
kwargs = {
|
||||
"access_log": self.args.access_log,
|
||||
"debug": self.args.debug,
|
||||
"fast": self.args.fast,
|
||||
"host": self.args.host,
|
||||
"motd": self.args.motd,
|
||||
"noisy_exceptions": self.args.noisy_exceptions,
|
||||
"port": self.args.port,
|
||||
"ssl": ssl,
|
||||
"unix": self.args.unix,
|
||||
"verbosity": self.args.verbosity or 0,
|
||||
"workers": self.args.workers,
|
||||
}
|
||||
|
||||
if self.args.auto_reload:
|
||||
kwargs["auto_reload"] = True
|
||||
|
||||
if self.args.path:
|
||||
if self.args.auto_reload or self.args.debug:
|
||||
kwargs["reload_dir"] = self.args.path
|
||||
else:
|
||||
error_logger.warning(
|
||||
"Ignoring '--reload-dir' since auto reloading was not "
|
||||
"enabled. If you would like to watch directories for "
|
||||
"changes, consider using --debug or --auto-reload."
|
||||
)
|
||||
return kwargs
|
||||
237
sanic/cli/arguments.py
Normal file
237
sanic/cli/arguments.py
Normal file
@@ -0,0 +1,237 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from argparse import ArgumentParser, _ArgumentGroup
|
||||
from typing import List, Optional, Type, Union
|
||||
|
||||
from sanic_routing import __version__ as __routing_version__ # type: ignore
|
||||
|
||||
from sanic import __version__
|
||||
|
||||
|
||||
class Group:
|
||||
name: Optional[str]
|
||||
container: Union[ArgumentParser, _ArgumentGroup]
|
||||
_registry: List[Type[Group]] = []
|
||||
|
||||
def __init_subclass__(cls) -> None:
|
||||
Group._registry.append(cls)
|
||||
|
||||
def __init__(self, parser: ArgumentParser, title: Optional[str]):
|
||||
self.parser = parser
|
||||
|
||||
if title:
|
||||
self.container = self.parser.add_argument_group(title=f" {title}")
|
||||
else:
|
||||
self.container = self.parser
|
||||
|
||||
@classmethod
|
||||
def create(cls, parser: ArgumentParser):
|
||||
instance = cls(parser, cls.name)
|
||||
return instance
|
||||
|
||||
def add_bool_arguments(self, *args, **kwargs):
|
||||
group = self.container.add_mutually_exclusive_group()
|
||||
kwargs["help"] = kwargs["help"].capitalize()
|
||||
group.add_argument(*args, action="store_true", **kwargs)
|
||||
kwargs["help"] = f"no {kwargs['help'].lower()}".capitalize()
|
||||
group.add_argument(
|
||||
"--no-" + args[0][2:], *args[1:], action="store_false", **kwargs
|
||||
)
|
||||
|
||||
|
||||
class GeneralGroup(Group):
|
||||
name = None
|
||||
|
||||
def attach(self):
|
||||
self.container.add_argument(
|
||||
"--version",
|
||||
action="version",
|
||||
version=f"Sanic {__version__}; Routing {__routing_version__}",
|
||||
)
|
||||
|
||||
self.container.add_argument(
|
||||
"module",
|
||||
help=(
|
||||
"Path to your Sanic app. Example: path.to.server:app\n"
|
||||
"If running a Simple Server, path to directory to serve. "
|
||||
"Example: ./\n"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ApplicationGroup(Group):
|
||||
name = "Application"
|
||||
|
||||
def attach(self):
|
||||
self.container.add_argument(
|
||||
"--factory",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Treat app as an application factory, "
|
||||
"i.e. a () -> <Sanic app> callable"
|
||||
),
|
||||
)
|
||||
self.container.add_argument(
|
||||
"-s",
|
||||
"--simple",
|
||||
dest="simple",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Run Sanic as a Simple Server, and serve the contents of "
|
||||
"a directory\n(module arg should be a path)"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SocketGroup(Group):
|
||||
name = "Socket binding"
|
||||
|
||||
def attach(self):
|
||||
self.container.add_argument(
|
||||
"-H",
|
||||
"--host",
|
||||
dest="host",
|
||||
type=str,
|
||||
default="127.0.0.1",
|
||||
help="Host address [default 127.0.0.1]",
|
||||
)
|
||||
self.container.add_argument(
|
||||
"-p",
|
||||
"--port",
|
||||
dest="port",
|
||||
type=int,
|
||||
default=8000,
|
||||
help="Port to serve on [default 8000]",
|
||||
)
|
||||
self.container.add_argument(
|
||||
"-u",
|
||||
"--unix",
|
||||
dest="unix",
|
||||
type=str,
|
||||
default="",
|
||||
help="location of unix socket",
|
||||
)
|
||||
|
||||
|
||||
class TLSGroup(Group):
|
||||
name = "TLS certificate"
|
||||
|
||||
def attach(self):
|
||||
self.container.add_argument(
|
||||
"--cert",
|
||||
dest="cert",
|
||||
type=str,
|
||||
help="Location of fullchain.pem, bundle.crt or equivalent",
|
||||
)
|
||||
self.container.add_argument(
|
||||
"--key",
|
||||
dest="key",
|
||||
type=str,
|
||||
help="Location of privkey.pem or equivalent .key file",
|
||||
)
|
||||
self.container.add_argument(
|
||||
"--tls",
|
||||
metavar="DIR",
|
||||
type=str,
|
||||
action="append",
|
||||
help=(
|
||||
"TLS certificate folder with fullchain.pem and privkey.pem\n"
|
||||
"May be specified multiple times to choose multiple "
|
||||
"certificates"
|
||||
),
|
||||
)
|
||||
self.container.add_argument(
|
||||
"--tls-strict-host",
|
||||
dest="tlshost",
|
||||
action="store_true",
|
||||
help="Only allow clients that send an SNI matching server certs",
|
||||
)
|
||||
|
||||
|
||||
class WorkerGroup(Group):
|
||||
name = "Worker"
|
||||
|
||||
def attach(self):
|
||||
group = self.container.add_mutually_exclusive_group()
|
||||
group.add_argument(
|
||||
"-w",
|
||||
"--workers",
|
||||
dest="workers",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Number of worker processes [default 1]",
|
||||
)
|
||||
group.add_argument(
|
||||
"--fast",
|
||||
dest="fast",
|
||||
action="store_true",
|
||||
help="Set the number of workers to max allowed",
|
||||
)
|
||||
self.add_bool_arguments(
|
||||
"--access-logs", dest="access_log", help="display access logs"
|
||||
)
|
||||
|
||||
|
||||
class DevelopmentGroup(Group):
|
||||
name = "Development"
|
||||
|
||||
def attach(self):
|
||||
self.container.add_argument(
|
||||
"--debug",
|
||||
dest="debug",
|
||||
action="store_true",
|
||||
help="Run the server in debug mode",
|
||||
)
|
||||
self.container.add_argument(
|
||||
"-d",
|
||||
"--dev",
|
||||
dest="debug",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Currently is an alias for --debug. But starting in v22.3, \n"
|
||||
"--debug will no longer automatically trigger auto_restart. \n"
|
||||
"However, --dev will continue, effectively making it the \n"
|
||||
"same as debug + auto_reload."
|
||||
),
|
||||
)
|
||||
self.container.add_argument(
|
||||
"-r",
|
||||
"--reload",
|
||||
"--auto-reload",
|
||||
dest="auto_reload",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Watch source directory for file changes and reload on "
|
||||
"changes"
|
||||
),
|
||||
)
|
||||
self.container.add_argument(
|
||||
"-R",
|
||||
"--reload-dir",
|
||||
dest="path",
|
||||
action="append",
|
||||
help="Extra directories to watch and reload on changes",
|
||||
)
|
||||
|
||||
|
||||
class OutputGroup(Group):
|
||||
name = "Output"
|
||||
|
||||
def attach(self):
|
||||
self.add_bool_arguments(
|
||||
"--motd",
|
||||
dest="motd",
|
||||
default=True,
|
||||
help="Show the startup display",
|
||||
)
|
||||
self.container.add_argument(
|
||||
"-v",
|
||||
"--verbosity",
|
||||
action="count",
|
||||
help="Control logging noise, eg. -vv or --verbosity=2 [default 0]",
|
||||
)
|
||||
self.add_bool_arguments(
|
||||
"--noisy-exceptions",
|
||||
dest="noisy_exceptions",
|
||||
help="Output stack traces for all exceptions",
|
||||
)
|
||||
@@ -10,6 +10,13 @@ from multidict import CIMultiDict # type: ignore
|
||||
OS_IS_WINDOWS = os.name == "nt"
|
||||
|
||||
|
||||
def enable_windows_color_support():
|
||||
import ctypes
|
||||
|
||||
kernel = ctypes.windll.kernel32
|
||||
kernel.SetConsoleMode(kernel.GetStdHandle(-11), 7)
|
||||
|
||||
|
||||
class Header(CIMultiDict):
|
||||
"""
|
||||
Container used for both request and response headers. It is a subclass of
|
||||
|
||||
124
sanic/config.py
124
sanic/config.py
@@ -1,31 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from inspect import isclass
|
||||
from os import environ
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from typing import TYPE_CHECKING, Any, Dict, Optional, Union
|
||||
from warnings import warn
|
||||
|
||||
from sanic.errorpages import check_error_format
|
||||
from sanic.http import Http
|
||||
from sanic.utils import load_module_from_file_location, str_to_bool
|
||||
|
||||
from .utils import load_module_from_file_location, str_to_bool
|
||||
|
||||
if TYPE_CHECKING: # no cov
|
||||
from sanic import Sanic
|
||||
|
||||
|
||||
SANIC_PREFIX = "SANIC_"
|
||||
BASE_LOGO = """
|
||||
|
||||
Sanic
|
||||
Build Fast. Run Fast.
|
||||
|
||||
"""
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"ACCESS_LOG": True,
|
||||
"AUTO_RELOAD": False,
|
||||
"EVENT_AUTOREGISTER": False,
|
||||
"FALLBACK_ERROR_FORMAT": "html",
|
||||
"FALLBACK_ERROR_FORMAT": "auto",
|
||||
"FORWARDED_FOR_HEADER": "X-Forwarded-For",
|
||||
"FORWARDED_SECRET": None,
|
||||
"GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec
|
||||
"KEEP_ALIVE_TIMEOUT": 5, # 5 seconds
|
||||
"KEEP_ALIVE": True,
|
||||
"MOTD": True,
|
||||
"MOTD_DISPLAY": {},
|
||||
"NOISY_EXCEPTIONS": False,
|
||||
"PROXIES_COUNT": None,
|
||||
"REAL_IP_HEADER": None,
|
||||
"REGISTER": True,
|
||||
@@ -35,17 +40,15 @@ DEFAULT_CONFIG = {
|
||||
"REQUEST_MAX_SIZE": 100000000, # 100 megabytes
|
||||
"REQUEST_TIMEOUT": 60, # 60 seconds
|
||||
"RESPONSE_TIMEOUT": 60, # 60 seconds
|
||||
"WEBSOCKET_MAX_QUEUE": 32,
|
||||
"WEBSOCKET_MAX_SIZE": 2 ** 20, # 1 megabyte
|
||||
"WEBSOCKET_PING_INTERVAL": 20,
|
||||
"WEBSOCKET_PING_TIMEOUT": 20,
|
||||
"WEBSOCKET_READ_LIMIT": 2 ** 16,
|
||||
"WEBSOCKET_WRITE_LIMIT": 2 ** 16,
|
||||
}
|
||||
|
||||
|
||||
class Config(dict):
|
||||
ACCESS_LOG: bool
|
||||
AUTO_RELOAD: bool
|
||||
EVENT_AUTOREGISTER: bool
|
||||
FALLBACK_ERROR_FORMAT: str
|
||||
FORWARDED_FOR_HEADER: str
|
||||
@@ -53,6 +56,9 @@ class Config(dict):
|
||||
GRACEFUL_SHUTDOWN_TIMEOUT: float
|
||||
KEEP_ALIVE_TIMEOUT: int
|
||||
KEEP_ALIVE: bool
|
||||
NOISY_EXCEPTIONS: bool
|
||||
MOTD: bool
|
||||
MOTD_DISPLAY: Dict[str, str]
|
||||
PROXIES_COUNT: Optional[int]
|
||||
REAL_IP_HEADER: Optional[str]
|
||||
REGISTER: bool
|
||||
@@ -62,12 +68,10 @@ class Config(dict):
|
||||
REQUEST_MAX_SIZE: int
|
||||
REQUEST_TIMEOUT: int
|
||||
RESPONSE_TIMEOUT: int
|
||||
WEBSOCKET_MAX_QUEUE: int
|
||||
SERVER_NAME: str
|
||||
WEBSOCKET_MAX_SIZE: int
|
||||
WEBSOCKET_PING_INTERVAL: int
|
||||
WEBSOCKET_PING_TIMEOUT: int
|
||||
WEBSOCKET_READ_LIMIT: int
|
||||
WEBSOCKET_WRITE_LIMIT: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -75,11 +79,14 @@ class Config(dict):
|
||||
load_env: Optional[Union[bool, str]] = True,
|
||||
env_prefix: Optional[str] = SANIC_PREFIX,
|
||||
keep_alive: Optional[bool] = None,
|
||||
*,
|
||||
app: Optional[Sanic] = None,
|
||||
):
|
||||
defaults = defaults or {}
|
||||
super().__init__({**DEFAULT_CONFIG, **defaults})
|
||||
|
||||
self.LOGO = BASE_LOGO
|
||||
self._app = app
|
||||
self._LOGO = ""
|
||||
|
||||
if keep_alive is not None:
|
||||
self.KEEP_ALIVE = keep_alive
|
||||
@@ -100,6 +107,8 @@ class Config(dict):
|
||||
self.load_environment_vars(SANIC_PREFIX)
|
||||
|
||||
self._configure_header_size()
|
||||
self._check_error_format()
|
||||
self._init = True
|
||||
|
||||
def __getattr__(self, attr):
|
||||
try:
|
||||
@@ -107,14 +116,51 @@ class Config(dict):
|
||||
except KeyError as ke:
|
||||
raise AttributeError(f"Config has no '{ke.args[0]}'")
|
||||
|
||||
def __setattr__(self, attr, value):
|
||||
self[attr] = value
|
||||
if attr in (
|
||||
"REQUEST_MAX_HEADER_SIZE",
|
||||
"REQUEST_BUFFER_SIZE",
|
||||
"REQUEST_MAX_SIZE",
|
||||
):
|
||||
self._configure_header_size()
|
||||
def __setattr__(self, attr, value) -> None:
|
||||
self.update({attr: value})
|
||||
|
||||
def __setitem__(self, attr, value) -> None:
|
||||
self.update({attr: value})
|
||||
|
||||
def update(self, *other, **kwargs) -> None:
|
||||
other_mapping = {k: v for item in other for k, v in dict(item).items()}
|
||||
super().update(*other, **kwargs)
|
||||
for attr, value in {**other_mapping, **kwargs}.items():
|
||||
self._post_set(attr, value)
|
||||
|
||||
def _post_set(self, attr, value) -> None:
|
||||
if self.get("_init"):
|
||||
if attr in (
|
||||
"REQUEST_MAX_HEADER_SIZE",
|
||||
"REQUEST_BUFFER_SIZE",
|
||||
"REQUEST_MAX_SIZE",
|
||||
):
|
||||
self._configure_header_size()
|
||||
elif attr == "FALLBACK_ERROR_FORMAT":
|
||||
self._check_error_format()
|
||||
if self.app and value != self.app.error_handler.fallback:
|
||||
if self.app.error_handler.fallback != "auto":
|
||||
warn(
|
||||
"Overriding non-default ErrorHandler fallback "
|
||||
"value. Changing from "
|
||||
f"{self.app.error_handler.fallback} to {value}."
|
||||
)
|
||||
self.app.error_handler.fallback = value
|
||||
elif attr == "LOGO":
|
||||
self._LOGO = value
|
||||
warn(
|
||||
"Setting the config.LOGO is deprecated and will no longer "
|
||||
"be supported starting in v22.6.",
|
||||
DeprecationWarning,
|
||||
)
|
||||
|
||||
@property
|
||||
def app(self):
|
||||
return self._app
|
||||
|
||||
@property
|
||||
def LOGO(self):
|
||||
return self._LOGO
|
||||
|
||||
def _configure_header_size(self):
|
||||
Http.set_header_max_size(
|
||||
@@ -123,13 +169,16 @@ class Config(dict):
|
||||
self.REQUEST_MAX_SIZE,
|
||||
)
|
||||
|
||||
def _check_error_format(self):
|
||||
check_error_format(self.FALLBACK_ERROR_FORMAT)
|
||||
|
||||
def load_environment_vars(self, prefix=SANIC_PREFIX):
|
||||
"""
|
||||
Looks for prefixed environment variables and applies
|
||||
them to the configuration if present. This is called automatically when
|
||||
Sanic starts up to load environment variables into config.
|
||||
Looks for prefixed environment variables and applies them to the
|
||||
configuration if present. This is called automatically when Sanic
|
||||
starts up to load environment variables into config.
|
||||
|
||||
It will automatically hyrdate the following types:
|
||||
It will automatically hydrate the following types:
|
||||
|
||||
- ``int``
|
||||
- ``float``
|
||||
@@ -137,19 +186,18 @@ class Config(dict):
|
||||
|
||||
Anything else will be imported as a ``str``.
|
||||
"""
|
||||
for k, v in environ.items():
|
||||
if k.startswith(prefix):
|
||||
_, config_key = k.split(prefix, 1)
|
||||
for key, value in environ.items():
|
||||
if not key.startswith(prefix):
|
||||
continue
|
||||
|
||||
_, config_key = key.split(prefix, 1)
|
||||
|
||||
for converter in (int, float, str_to_bool, str):
|
||||
try:
|
||||
self[config_key] = int(v)
|
||||
self[config_key] = converter(value)
|
||||
break
|
||||
except ValueError:
|
||||
try:
|
||||
self[config_key] = float(v)
|
||||
except ValueError:
|
||||
try:
|
||||
self[config_key] = str_to_bool(v)
|
||||
except ValueError:
|
||||
self[config_key] = v
|
||||
pass
|
||||
|
||||
def update_config(self, config: Union[bytes, str, dict, Any]):
|
||||
"""
|
||||
|
||||
@@ -25,12 +25,13 @@ from sanic.request import Request
|
||||
from sanic.response import HTTPResponse, html, json, text
|
||||
|
||||
|
||||
dumps: t.Callable[..., str]
|
||||
try:
|
||||
from ujson import dumps
|
||||
|
||||
dumps = partial(dumps, escape_forward_slashes=False)
|
||||
except ImportError: # noqa
|
||||
from json import dumps # type: ignore
|
||||
from json import dumps
|
||||
|
||||
|
||||
FALLBACK_TEXT = (
|
||||
@@ -45,6 +46,8 @@ class BaseRenderer:
|
||||
Base class that all renderers must inherit from.
|
||||
"""
|
||||
|
||||
dumps = staticmethod(dumps)
|
||||
|
||||
def __init__(self, request, exception, debug):
|
||||
self.request = request
|
||||
self.exception = exception
|
||||
@@ -112,14 +115,16 @@ class HTMLRenderer(BaseRenderer):
|
||||
TRACEBACK_STYLE = """
|
||||
html { font-family: sans-serif }
|
||||
h2 { color: #888; }
|
||||
.tb-wrapper p { margin: 0 }
|
||||
.tb-wrapper p, dl, dd { margin: 0 }
|
||||
.frame-border { margin: 1rem }
|
||||
.frame-line > * { padding: 0.3rem 0.6rem }
|
||||
.frame-line { margin-bottom: 0.3rem }
|
||||
.frame-code { font-size: 16px; padding-left: 4ch }
|
||||
.tb-wrapper { border: 1px solid #eee }
|
||||
.tb-header { background: #eee; padding: 0.3rem; font-weight: bold }
|
||||
.frame-descriptor { background: #e2eafb; font-size: 14px }
|
||||
.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>"
|
||||
@@ -138,6 +143,11 @@ class HTMLRenderer(BaseRenderer):
|
||||
"<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"
|
||||
@@ -152,7 +162,7 @@ class HTMLRenderer(BaseRenderer):
|
||||
title=self.title,
|
||||
text=self.text,
|
||||
style=self.TRACEBACK_STYLE,
|
||||
body=self._generate_body(),
|
||||
body=self._generate_body(full=True),
|
||||
),
|
||||
status=self.status,
|
||||
)
|
||||
@@ -163,7 +173,7 @@ class HTMLRenderer(BaseRenderer):
|
||||
title=self.title,
|
||||
text=self.text,
|
||||
style=self.TRACEBACK_STYLE,
|
||||
body="",
|
||||
body=self._generate_body(full=False),
|
||||
),
|
||||
status=self.status,
|
||||
headers=self.headers,
|
||||
@@ -177,27 +187,49 @@ class HTMLRenderer(BaseRenderer):
|
||||
def title(self):
|
||||
return escape(f"⚠️ {super().title}")
|
||||
|
||||
def _generate_body(self):
|
||||
_, exc_value, __ = sys.exc_info()
|
||||
exceptions = []
|
||||
while exc_value:
|
||||
exceptions.append(self._format_exc(exc_value))
|
||||
exc_value = exc_value.__cause__
|
||||
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))
|
||||
|
||||
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> while handling path <code>{path}</code>",
|
||||
"</div>",
|
||||
]
|
||||
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(
|
||||
@@ -224,7 +256,7 @@ class TextRenderer(BaseRenderer):
|
||||
title=self.title,
|
||||
text=self.text,
|
||||
bar=("=" * len(self.title)),
|
||||
body=self._generate_body(),
|
||||
body=self._generate_body(full=True),
|
||||
),
|
||||
status=self.status,
|
||||
)
|
||||
@@ -235,7 +267,7 @@ class TextRenderer(BaseRenderer):
|
||||
title=self.title,
|
||||
text=self.text,
|
||||
bar=("=" * len(self.title)),
|
||||
body="",
|
||||
body=self._generate_body(full=False),
|
||||
),
|
||||
status=self.status,
|
||||
headers=self.headers,
|
||||
@@ -245,21 +277,31 @@ class TextRenderer(BaseRenderer):
|
||||
def title(self):
|
||||
return f"⚠️ {super().title}"
|
||||
|
||||
def _generate_body(self):
|
||||
_, exc_value, __ = sys.exc_info()
|
||||
exceptions = []
|
||||
def _generate_body(self, *, full):
|
||||
lines = []
|
||||
if full:
|
||||
_, exc_value, __ = sys.exc_info()
|
||||
exceptions = []
|
||||
|
||||
lines = [
|
||||
f"{self.exception.__class__.__name__}: {self.exception} while "
|
||||
f"handling path {self.request.path}",
|
||||
f"Traceback of {self.request.app.name} (most recent call last):\n",
|
||||
]
|
||||
lines += [
|
||||
f"{self.exception.__class__.__name__}: {self.exception} while "
|
||||
f"handling path {self.request.path}",
|
||||
f"Traceback of {self.request.app.name} "
|
||||
"(most recent call last):\n",
|
||||
]
|
||||
|
||||
while exc_value:
|
||||
exceptions.append(self._format_exc(exc_value))
|
||||
exc_value = exc_value.__cause__
|
||||
while exc_value:
|
||||
exceptions.append(self._format_exc(exc_value))
|
||||
exc_value = exc_value.__cause__
|
||||
|
||||
return "\n".join(lines + exceptions[::-1])
|
||||
lines += exceptions[::-1]
|
||||
|
||||
for attr, display in (("context", True), ("extra", bool(full))):
|
||||
info = getattr(self.exception, attr, None)
|
||||
if info and display:
|
||||
lines += self._generate_object_display_list(info, attr)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_exc(self, exc):
|
||||
frames = "\n\n".join(
|
||||
@@ -272,6 +314,13 @@ class TextRenderer(BaseRenderer):
|
||||
)
|
||||
return f"{self.SPACER}{exc.__class__.__name__}: {exc}\n{frames}"
|
||||
|
||||
def _generate_object_display_list(self, obj, descriptor):
|
||||
lines = [f"\n{descriptor.title()}"]
|
||||
for key, value in obj.items():
|
||||
display = self.dumps(value)
|
||||
lines.append(f"{self.SPACER * 2}{key}: {display}")
|
||||
return lines
|
||||
|
||||
|
||||
class JSONRenderer(BaseRenderer):
|
||||
"""
|
||||
@@ -280,11 +329,11 @@ class JSONRenderer(BaseRenderer):
|
||||
|
||||
def full(self) -> HTTPResponse:
|
||||
output = self._generate_output(full=True)
|
||||
return json(output, status=self.status, dumps=dumps)
|
||||
return json(output, status=self.status, dumps=self.dumps)
|
||||
|
||||
def minimal(self) -> HTTPResponse:
|
||||
output = self._generate_output(full=False)
|
||||
return json(output, status=self.status, dumps=dumps)
|
||||
return json(output, status=self.status, dumps=self.dumps)
|
||||
|
||||
def _generate_output(self, *, full):
|
||||
output = {
|
||||
@@ -293,6 +342,11 @@ class JSONRenderer(BaseRenderer):
|
||||
"message": self.text,
|
||||
}
|
||||
|
||||
for attr, display in (("context", True), ("extra", bool(full))):
|
||||
info = getattr(self.exception, attr, None)
|
||||
if info and display:
|
||||
output[attr] = info
|
||||
|
||||
if full:
|
||||
_, exc_value, __ = sys.exc_info()
|
||||
exceptions = []
|
||||
@@ -340,41 +394,139 @@ RENDERERS_BY_CONFIG = {
|
||||
}
|
||||
|
||||
RENDERERS_BY_CONTENT_TYPE = {
|
||||
"multipart/form-data": HTMLRenderer,
|
||||
"application/json": JSONRenderer,
|
||||
"text/plain": TextRenderer,
|
||||
"application/json": JSONRenderer,
|
||||
"multipart/form-data": HTMLRenderer,
|
||||
"text/html": HTMLRenderer,
|
||||
}
|
||||
CONTENT_TYPE_BY_RENDERERS = {
|
||||
v: k for k, v in RENDERERS_BY_CONTENT_TYPE.items()
|
||||
}
|
||||
|
||||
RESPONSE_MAPPING = {
|
||||
"empty": "html",
|
||||
"json": "json",
|
||||
"text": "text",
|
||||
"raw": "text",
|
||||
"html": "html",
|
||||
"file": "html",
|
||||
"file_stream": "text",
|
||||
"stream": "text",
|
||||
"redirect": "html",
|
||||
"text/plain": "text",
|
||||
"text/html": "html",
|
||||
"application/json": "json",
|
||||
}
|
||||
|
||||
|
||||
def check_error_format(format):
|
||||
if format not in RENDERERS_BY_CONFIG and format != "auto":
|
||||
raise SanicException(f"Unknown format: {format}")
|
||||
|
||||
|
||||
def exception_response(
|
||||
request: Request,
|
||||
exception: Exception,
|
||||
debug: bool,
|
||||
fallback: str,
|
||||
base: t.Type[BaseRenderer],
|
||||
renderer: t.Type[t.Optional[BaseRenderer]] = None,
|
||||
) -> HTTPResponse:
|
||||
"""
|
||||
Render a response for the default FALLBACK exception handler.
|
||||
"""
|
||||
content_type = None
|
||||
|
||||
if not renderer:
|
||||
renderer = HTMLRenderer
|
||||
# Make sure we have something set
|
||||
renderer = base
|
||||
render_format = fallback
|
||||
|
||||
if request:
|
||||
if request.app.config.FALLBACK_ERROR_FORMAT == "auto":
|
||||
# If there is a request, try and get the format
|
||||
# from the route
|
||||
if request.route:
|
||||
try:
|
||||
renderer = JSONRenderer if request.json else HTMLRenderer
|
||||
except InvalidUsage:
|
||||
if request.route.ctx.error_format:
|
||||
render_format = request.route.ctx.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
|
||||
|
||||
content_type, *_ = request.headers.getone(
|
||||
"content-type", ""
|
||||
).split(";")
|
||||
renderer = RENDERERS_BY_CONTENT_TYPE.get(
|
||||
content_type, renderer
|
||||
)
|
||||
# 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 InvalidUsage:
|
||||
renderer = base
|
||||
else:
|
||||
render_format = request.app.config.FALLBACK_ERROR_FORMAT
|
||||
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)
|
||||
return renderer(request, exception, debug).render()
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
from typing import Optional, Union
|
||||
from typing import Any, Dict, Optional, Union
|
||||
|
||||
from sanic.helpers import STATUS_CODES
|
||||
|
||||
|
||||
class SanicException(Exception):
|
||||
message: str = ""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: Optional[Union[str, bytes]] = None,
|
||||
status_code: Optional[int] = None,
|
||||
quiet: Optional[bool] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
extra: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
|
||||
if message is None and status_code is not None:
|
||||
msg: bytes = STATUS_CODES.get(status_code, b"")
|
||||
message = msg.decode("utf8")
|
||||
self.context = context
|
||||
self.extra = extra
|
||||
if message is None:
|
||||
if self.message:
|
||||
message = self.message
|
||||
elif status_code is not None:
|
||||
msg: bytes = STATUS_CODES.get(status_code, b"")
|
||||
message = msg.decode("utf8")
|
||||
|
||||
super().__init__(message)
|
||||
|
||||
@@ -122,8 +130,11 @@ class HeaderNotFound(InvalidUsage):
|
||||
**Status**: 400 Bad Request
|
||||
"""
|
||||
|
||||
status_code = 400
|
||||
quiet = True
|
||||
|
||||
class InvalidHeader(InvalidUsage):
|
||||
"""
|
||||
**Status**: 400 Bad Request
|
||||
"""
|
||||
|
||||
|
||||
class ContentRangeError(SanicException):
|
||||
@@ -230,6 +241,11 @@ class InvalidSignal(SanicException):
|
||||
pass
|
||||
|
||||
|
||||
class WebsocketClosed(SanicException):
|
||||
quiet = True
|
||||
message = "Client has closed the websocket connection"
|
||||
|
||||
|
||||
def abort(status_code: int, message: Optional[Union[str, bytes]] = None):
|
||||
"""
|
||||
Raise an exception based on SanicException. Returns the HTTP response
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
from traceback import format_exc
|
||||
from inspect import signature
|
||||
from typing import Dict, List, Optional, Tuple, Type
|
||||
from warnings import warn
|
||||
|
||||
from sanic.errorpages import exception_response
|
||||
from sanic.errorpages import BaseRenderer, HTMLRenderer, exception_response
|
||||
from sanic.exceptions import (
|
||||
ContentRangeError,
|
||||
HeaderNotFound,
|
||||
InvalidRangeType,
|
||||
)
|
||||
from sanic.log import error_logger
|
||||
from sanic.models.handler_types import RouteHandler
|
||||
from sanic.response import text
|
||||
|
||||
|
||||
@@ -23,15 +26,53 @@ class ErrorHandler:
|
||||
|
||||
"""
|
||||
|
||||
handlers = None
|
||||
cached_handlers = None
|
||||
|
||||
def __init__(self):
|
||||
self.handlers = []
|
||||
self.cached_handlers = {}
|
||||
# Beginning in v22.3, the base renderer will be TextRenderer
|
||||
def __init__(
|
||||
self, fallback: str = "auto", base: Type[BaseRenderer] = HTMLRenderer
|
||||
):
|
||||
self.handlers: List[Tuple[Type[BaseException], RouteHandler]] = []
|
||||
self.cached_handlers: Dict[
|
||||
Tuple[Type[BaseException], Optional[str]], Optional[RouteHandler]
|
||||
] = {}
|
||||
self.debug = False
|
||||
self.fallback = fallback
|
||||
self.base = base
|
||||
|
||||
def add(self, exception, handler):
|
||||
@classmethod
|
||||
def finalize(cls, error_handler, fallback: Optional[str] = None):
|
||||
if (
|
||||
fallback
|
||||
and fallback != "auto"
|
||||
and error_handler.fallback == "auto"
|
||||
):
|
||||
error_handler.fallback = fallback
|
||||
|
||||
if not isinstance(error_handler, cls):
|
||||
error_logger.warning(
|
||||
f"Error handler is non-conforming: {type(error_handler)}"
|
||||
)
|
||||
|
||||
sig = signature(error_handler.lookup)
|
||||
if len(sig.parameters) == 1:
|
||||
warn(
|
||||
"You are using a deprecated error handler. The lookup "
|
||||
"method should accept two positional parameters: "
|
||||
"(exception, route_name: Optional[str]). "
|
||||
"Until you upgrade your ErrorHandler.lookup, Blueprint "
|
||||
"specific exceptions will not work properly. Beginning "
|
||||
"in v22.3, the legacy style lookup method will not "
|
||||
"work at all.",
|
||||
DeprecationWarning,
|
||||
)
|
||||
error_handler._lookup = error_handler._legacy_lookup
|
||||
|
||||
def _full_lookup(self, exception, route_name: Optional[str] = None):
|
||||
return self.lookup(exception, route_name)
|
||||
|
||||
def _legacy_lookup(self, exception, route_name: Optional[str] = None):
|
||||
return self.lookup(exception)
|
||||
|
||||
def add(self, exception, handler, route_names: Optional[List[str]] = None):
|
||||
"""
|
||||
Add a new exception handler to an already existing handler object.
|
||||
|
||||
@@ -44,11 +85,16 @@ class ErrorHandler:
|
||||
|
||||
:return: None
|
||||
"""
|
||||
# self.handlers to be deprecated and removed in version 21.12
|
||||
# self.handlers is deprecated and will be removed in version 22.3
|
||||
self.handlers.append((exception, handler))
|
||||
self.cached_handlers[exception] = handler
|
||||
|
||||
def lookup(self, exception):
|
||||
if route_names:
|
||||
for route in route_names:
|
||||
self.cached_handlers[(exception, route)] = handler
|
||||
else:
|
||||
self.cached_handlers[(exception, None)] = handler
|
||||
|
||||
def lookup(self, exception, route_name: Optional[str] = None):
|
||||
"""
|
||||
Lookup the existing instance of :class:`ErrorHandler` and fetch the
|
||||
registered handler for a specific type of exception.
|
||||
@@ -63,20 +109,31 @@ class ErrorHandler:
|
||||
:return: Registered function if found ``None`` otherwise
|
||||
"""
|
||||
exception_class = type(exception)
|
||||
if exception_class in self.cached_handlers:
|
||||
return self.cached_handlers[exception_class]
|
||||
|
||||
for ancestor in type.mro(exception_class):
|
||||
if ancestor in self.cached_handlers:
|
||||
handler = self.cached_handlers[ancestor]
|
||||
self.cached_handlers[exception_class] = handler
|
||||
for name in (route_name, None):
|
||||
exception_key = (exception_class, name)
|
||||
handler = self.cached_handlers.get(exception_key)
|
||||
if handler:
|
||||
return handler
|
||||
if ancestor is BaseException:
|
||||
break
|
||||
self.cached_handlers[exception_class] = None
|
||||
|
||||
for name in (route_name, None):
|
||||
for ancestor in type.mro(exception_class):
|
||||
exception_key = (ancestor, name)
|
||||
if exception_key in self.cached_handlers:
|
||||
handler = self.cached_handlers[exception_key]
|
||||
self.cached_handlers[
|
||||
(exception_class, route_name)
|
||||
] = handler
|
||||
return handler
|
||||
|
||||
if ancestor is BaseException:
|
||||
break
|
||||
self.cached_handlers[(exception_class, route_name)] = None
|
||||
handler = None
|
||||
return handler
|
||||
|
||||
_lookup = _full_lookup
|
||||
|
||||
def response(self, request, exception):
|
||||
"""Fetches and executes an exception handler and returns a response
|
||||
object
|
||||
@@ -91,7 +148,8 @@ class ErrorHandler:
|
||||
:return: Wrap the return value obtained from :func:`default`
|
||||
or registered handler for that type of exception.
|
||||
"""
|
||||
handler = self.lookup(exception)
|
||||
route_name = request.name if request else None
|
||||
handler = self._lookup(exception, route_name)
|
||||
response = None
|
||||
try:
|
||||
if handler:
|
||||
@@ -99,7 +157,6 @@ class ErrorHandler:
|
||||
if response is None:
|
||||
response = self.default(request, exception)
|
||||
except Exception:
|
||||
self.log(format_exc())
|
||||
try:
|
||||
url = repr(request.url)
|
||||
except AttributeError:
|
||||
@@ -115,11 +172,6 @@ class ErrorHandler:
|
||||
return text("An error occurred while handling an error", 500)
|
||||
return response
|
||||
|
||||
def log(self, message, level="error"):
|
||||
"""
|
||||
Deprecated, do not use.
|
||||
"""
|
||||
|
||||
def default(self, request, exception):
|
||||
"""
|
||||
Provide a default behavior for the objects of :class:`ErrorHandler`.
|
||||
@@ -135,20 +187,29 @@ class ErrorHandler:
|
||||
:class:`Exception`
|
||||
:return:
|
||||
"""
|
||||
self.log(request, exception)
|
||||
return exception_response(
|
||||
request,
|
||||
exception,
|
||||
debug=self.debug,
|
||||
base=self.base,
|
||||
fallback=self.fallback,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def log(request, exception):
|
||||
quiet = getattr(exception, "quiet", False)
|
||||
if quiet is False:
|
||||
noisy = getattr(request.app.config, "NOISY_EXCEPTIONS", False)
|
||||
if quiet is False or noisy is True:
|
||||
try:
|
||||
url = repr(request.url)
|
||||
except AttributeError:
|
||||
url = "unknown"
|
||||
|
||||
self.log(format_exc())
|
||||
error_logger.exception(
|
||||
"Exception occurred while handling uri: %s", url
|
||||
)
|
||||
|
||||
return exception_response(request, exception, self.debug)
|
||||
|
||||
|
||||
class ContentRangeHandler:
|
||||
"""
|
||||
|
||||
202
sanic/headers.py
202
sanic/headers.py
@@ -1,8 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
|
||||
from urllib.parse import unquote
|
||||
|
||||
from sanic.exceptions import InvalidHeader
|
||||
from sanic.helpers import STATUS_CODES
|
||||
|
||||
|
||||
@@ -25,11 +28,180 @@ _host_re = re.compile(
|
||||
|
||||
# RFC's quoted-pair escapes are mostly ignored by browsers. Chrome, Firefox and
|
||||
# curl all have different escaping, that we try to handle as well as possible,
|
||||
# even though no client espaces in a way that would allow perfect handling.
|
||||
# even though no client escapes in a way that would allow perfect handling.
|
||||
|
||||
# For more information, consult ../tests/test_requests.py
|
||||
|
||||
|
||||
def parse_arg_as_accept(f):
|
||||
def func(self, other, *args, **kwargs):
|
||||
if not isinstance(other, Accept) and other:
|
||||
other = Accept.parse(other)
|
||||
return f(self, other, *args, **kwargs)
|
||||
|
||||
return func
|
||||
|
||||
|
||||
class MediaType(str):
|
||||
def __new__(cls, value: str):
|
||||
return str.__new__(cls, value)
|
||||
|
||||
def __init__(self, value: str) -> None:
|
||||
self.value = value
|
||||
self.is_wildcard = self.check_if_wildcard(value)
|
||||
|
||||
def __eq__(self, other):
|
||||
if self.is_wildcard:
|
||||
return True
|
||||
|
||||
if self.match(other):
|
||||
return True
|
||||
|
||||
other_is_wildcard = (
|
||||
other.is_wildcard
|
||||
if isinstance(other, MediaType)
|
||||
else self.check_if_wildcard(other)
|
||||
)
|
||||
|
||||
return other_is_wildcard
|
||||
|
||||
def match(self, other):
|
||||
other_value = other.value if isinstance(other, MediaType) else other
|
||||
return self.value == other_value
|
||||
|
||||
@staticmethod
|
||||
def check_if_wildcard(value):
|
||||
return value == "*"
|
||||
|
||||
|
||||
class Accept(str):
|
||||
def __new__(cls, value: str, *args, **kwargs):
|
||||
return str.__new__(cls, value)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
value: str,
|
||||
type_: MediaType,
|
||||
subtype: MediaType,
|
||||
*,
|
||||
q: str = "1.0",
|
||||
**kwargs: str,
|
||||
):
|
||||
qvalue = float(q)
|
||||
if qvalue > 1 or qvalue < 0:
|
||||
raise InvalidHeader(
|
||||
f"Accept header qvalue must be between 0 and 1, not: {qvalue}"
|
||||
)
|
||||
self.value = value
|
||||
self.type_ = type_
|
||||
self.subtype = subtype
|
||||
self.qvalue = qvalue
|
||||
self.params = kwargs
|
||||
|
||||
def _compare(self, other, method):
|
||||
try:
|
||||
return method(self.qvalue, other.qvalue)
|
||||
except (AttributeError, TypeError):
|
||||
return NotImplemented
|
||||
|
||||
@parse_arg_as_accept
|
||||
def __lt__(self, other: Union[str, Accept]):
|
||||
return self._compare(other, lambda s, o: s < o)
|
||||
|
||||
@parse_arg_as_accept
|
||||
def __le__(self, other: Union[str, Accept]):
|
||||
return self._compare(other, lambda s, o: s <= o)
|
||||
|
||||
@parse_arg_as_accept
|
||||
def __eq__(self, other: Union[str, Accept]): # type: ignore
|
||||
return self._compare(other, lambda s, o: s == o)
|
||||
|
||||
@parse_arg_as_accept
|
||||
def __ge__(self, other: Union[str, Accept]):
|
||||
return self._compare(other, lambda s, o: s >= o)
|
||||
|
||||
@parse_arg_as_accept
|
||||
def __gt__(self, other: Union[str, Accept]):
|
||||
return self._compare(other, lambda s, o: s > o)
|
||||
|
||||
@parse_arg_as_accept
|
||||
def __ne__(self, other: Union[str, Accept]): # type: ignore
|
||||
return self._compare(other, lambda s, o: s != o)
|
||||
|
||||
@parse_arg_as_accept
|
||||
def match(
|
||||
self,
|
||||
other,
|
||||
*,
|
||||
allow_type_wildcard: bool = True,
|
||||
allow_subtype_wildcard: bool = True,
|
||||
) -> bool:
|
||||
type_match = (
|
||||
self.type_ == other.type_
|
||||
if allow_type_wildcard
|
||||
else (
|
||||
self.type_.match(other.type_)
|
||||
and not self.type_.is_wildcard
|
||||
and not other.type_.is_wildcard
|
||||
)
|
||||
)
|
||||
subtype_match = (
|
||||
self.subtype == other.subtype
|
||||
if allow_subtype_wildcard
|
||||
else (
|
||||
self.subtype.match(other.subtype)
|
||||
and not self.subtype.is_wildcard
|
||||
and not other.subtype.is_wildcard
|
||||
)
|
||||
)
|
||||
|
||||
return type_match and subtype_match
|
||||
|
||||
@classmethod
|
||||
def parse(cls, raw: str) -> Accept:
|
||||
invalid = False
|
||||
mtype = raw.strip()
|
||||
|
||||
try:
|
||||
media, *raw_params = mtype.split(";")
|
||||
type_, subtype = media.split("/")
|
||||
except ValueError:
|
||||
invalid = True
|
||||
|
||||
if invalid or not type_ or not subtype:
|
||||
raise InvalidHeader(f"Header contains invalid Accept value: {raw}")
|
||||
|
||||
params = dict(
|
||||
[
|
||||
(key.strip(), value.strip())
|
||||
for key, value in (param.split("=", 1) for param in raw_params)
|
||||
]
|
||||
)
|
||||
|
||||
return cls(mtype, MediaType(type_), MediaType(subtype), **params)
|
||||
|
||||
|
||||
class AcceptContainer(list):
|
||||
def __contains__(self, o: object) -> bool:
|
||||
return any(item.match(o) for item in self)
|
||||
|
||||
def match(
|
||||
self,
|
||||
o: object,
|
||||
*,
|
||||
allow_type_wildcard: bool = True,
|
||||
allow_subtype_wildcard: bool = True,
|
||||
) -> bool:
|
||||
return any(
|
||||
item.match(
|
||||
o,
|
||||
allow_type_wildcard=allow_type_wildcard,
|
||||
allow_subtype_wildcard=allow_subtype_wildcard,
|
||||
)
|
||||
for item in self
|
||||
)
|
||||
|
||||
|
||||
def parse_content_header(value: str) -> Tuple[str, Options]:
|
||||
"""Parse content-type and content-disposition header values.
|
||||
|
||||
@@ -194,3 +366,31 @@ def format_http1_response(status: int, headers: HeaderBytesIterable) -> bytes:
|
||||
ret += b"%b: %b\r\n" % h
|
||||
ret += b"\r\n"
|
||||
return ret
|
||||
|
||||
|
||||
def _sort_accept_value(accept: Accept):
|
||||
return (
|
||||
accept.qvalue,
|
||||
len(accept.params),
|
||||
accept.subtype != "*",
|
||||
accept.type_ != "*",
|
||||
)
|
||||
|
||||
|
||||
def parse_accept(accept: str) -> AcceptContainer:
|
||||
"""Parse an Accept header and order the acceptable media types in
|
||||
accorsing to RFC 7231, s. 5.3.2
|
||||
https://datatracker.ietf.org/doc/html/rfc7231#section-5.3.2
|
||||
"""
|
||||
media_types = accept.split(",")
|
||||
accept_list: List[Accept] = []
|
||||
|
||||
for mtype in media_types:
|
||||
if not mtype:
|
||||
continue
|
||||
|
||||
accept_list.append(Accept.parse(mtype))
|
||||
|
||||
return AcceptContainer(
|
||||
sorted(accept_list, key=_sort_accept_value, reverse=True)
|
||||
)
|
||||
|
||||
@@ -144,7 +144,7 @@ def import_string(module_name, package=None):
|
||||
import a module or class by string path.
|
||||
|
||||
:module_name: str with path of module or path to import and
|
||||
instanciate a class
|
||||
instantiate a class
|
||||
:returns: a module object or one instance from class if
|
||||
module_name is a valid path to class
|
||||
|
||||
@@ -155,3 +155,17 @@ def import_string(module_name, package=None):
|
||||
if ismodule(obj):
|
||||
return obj
|
||||
return obj()
|
||||
|
||||
|
||||
class Default:
|
||||
"""
|
||||
It is used to replace `None` or `object()` as a sentinel
|
||||
that represents a default value. Sometimes we want to set
|
||||
a value to `None` so we cannot use `None` to represent the
|
||||
default value, and `object()` is hard to be typed.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
_default = Default()
|
||||
|
||||
@@ -21,6 +21,7 @@ from sanic.exceptions import (
|
||||
from sanic.headers import format_http1_response
|
||||
from sanic.helpers import has_message_body
|
||||
from sanic.log import access_logger, error_logger, logger
|
||||
from sanic.touchup import TouchUpMeta
|
||||
|
||||
|
||||
class Stage(Enum):
|
||||
@@ -45,7 +46,7 @@ class Stage(Enum):
|
||||
HTTP_CONTINUE = b"HTTP/1.1 100 Continue\r\n\r\n"
|
||||
|
||||
|
||||
class Http:
|
||||
class Http(metaclass=TouchUpMeta):
|
||||
"""
|
||||
Internal helper for managing the HTTP request/response cycle
|
||||
|
||||
@@ -67,9 +68,15 @@ class Http:
|
||||
HEADER_CEILING = 16_384
|
||||
HEADER_MAX_SIZE = 0
|
||||
|
||||
__touchup__ = (
|
||||
"http1_request_header",
|
||||
"http1_response_header",
|
||||
"read",
|
||||
)
|
||||
__slots__ = [
|
||||
"_send",
|
||||
"_receive_more",
|
||||
"dispatch",
|
||||
"recv_buffer",
|
||||
"protocol",
|
||||
"expecting_continue",
|
||||
@@ -97,7 +104,7 @@ class Http:
|
||||
self.protocol = protocol
|
||||
self.keep_alive = True
|
||||
self.stage: Stage = Stage.IDLE
|
||||
self.init_for_request()
|
||||
self.dispatch = self.protocol.app.dispatch
|
||||
|
||||
def init_for_request(self):
|
||||
"""Init/reset all per-request variables."""
|
||||
@@ -121,14 +128,20 @@ class Http:
|
||||
"""
|
||||
HTTP 1.1 connection handler
|
||||
"""
|
||||
while True: # As long as connection stays keep-alive
|
||||
# Handle requests while the connection stays reusable
|
||||
while self.keep_alive and self.stage is Stage.IDLE:
|
||||
self.init_for_request()
|
||||
# Wait for incoming bytes (in IDLE stage)
|
||||
if not self.recv_buffer:
|
||||
await self._receive_more()
|
||||
self.stage = Stage.REQUEST
|
||||
try:
|
||||
# Receive and handle a request
|
||||
self.stage = Stage.REQUEST
|
||||
self.response_func = self.http1_response_header
|
||||
|
||||
await self.http1_request_header()
|
||||
|
||||
self.stage = Stage.HANDLER
|
||||
self.request.conn_info = self.protocol.conn_info
|
||||
await self.protocol.request_handler(self.request)
|
||||
|
||||
@@ -140,6 +153,12 @@ class Http:
|
||||
await self.response.send(end_stream=True)
|
||||
except CancelledError:
|
||||
# Write an appropriate response before exiting
|
||||
if not self.protocol.transport:
|
||||
logger.info(
|
||||
f"Request: {self.request.method} {self.request.url} "
|
||||
"stopped. Transport is closed."
|
||||
)
|
||||
return
|
||||
e = self.exception or ServiceUnavailable("Cancelled")
|
||||
self.exception = None
|
||||
self.keep_alive = False
|
||||
@@ -173,17 +192,7 @@ class Http:
|
||||
if self.response:
|
||||
self.response.stream = None
|
||||
|
||||
self.init_for_request()
|
||||
|
||||
# Exit and disconnect if no more requests can be taken
|
||||
if self.stage is not Stage.IDLE or not self.keep_alive:
|
||||
break
|
||||
|
||||
# Wait for the next request
|
||||
if not self.recv_buffer:
|
||||
await self._receive_more()
|
||||
|
||||
async def http1_request_header(self):
|
||||
async def http1_request_header(self): # no cov
|
||||
"""
|
||||
Receive and parse request header into self.request.
|
||||
"""
|
||||
@@ -212,6 +221,12 @@ class Http:
|
||||
reqline, *split_headers = raw_headers.split("\r\n")
|
||||
method, self.url, protocol = reqline.split(" ")
|
||||
|
||||
await self.dispatch(
|
||||
"http.lifecycle.read_head",
|
||||
inline=True,
|
||||
context={"head": bytes(head)},
|
||||
)
|
||||
|
||||
if protocol == "HTTP/1.1":
|
||||
self.keep_alive = True
|
||||
elif protocol == "HTTP/1.0":
|
||||
@@ -250,6 +265,11 @@ class Http:
|
||||
transport=self.protocol.transport,
|
||||
app=self.protocol.app,
|
||||
)
|
||||
await self.dispatch(
|
||||
"http.lifecycle.request",
|
||||
inline=True,
|
||||
context={"request": request},
|
||||
)
|
||||
|
||||
# Prepare for request body
|
||||
self.request_bytes_left = self.request_bytes = 0
|
||||
@@ -274,13 +294,12 @@ class Http:
|
||||
|
||||
# Remove header and its trailing CRLF
|
||||
del buf[: pos + 4]
|
||||
self.stage = Stage.HANDLER
|
||||
self.request, request.stream = request, self
|
||||
self.protocol.state["requests_count"] += 1
|
||||
|
||||
async def http1_response_header(
|
||||
self, data: bytes, end_stream: bool
|
||||
) -> None:
|
||||
) -> None: # no cov
|
||||
res = self.response
|
||||
|
||||
# Compatibility with simple response body
|
||||
@@ -452,8 +471,8 @@ class Http:
|
||||
"request": "nil",
|
||||
}
|
||||
if req is not None:
|
||||
if req.ip:
|
||||
extra["host"] = f"{req.ip}:{req.port}"
|
||||
if req.remote_addr or req.ip:
|
||||
extra["host"] = f"{req.remote_addr or req.ip}:{req.port}"
|
||||
extra["request"] = f"{req.method} {req.url}"
|
||||
access_logger.info("", extra=extra)
|
||||
|
||||
@@ -469,7 +488,7 @@ class Http:
|
||||
if data:
|
||||
yield data
|
||||
|
||||
async def read(self) -> Optional[bytes]:
|
||||
async def read(self) -> Optional[bytes]: # no cov
|
||||
"""
|
||||
Read some bytes of request body.
|
||||
"""
|
||||
@@ -543,6 +562,12 @@ class Http:
|
||||
|
||||
self.request_bytes_left -= size
|
||||
|
||||
await self.dispatch(
|
||||
"http.lifecycle.read_body",
|
||||
inline=True,
|
||||
context={"body": data},
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
# Response methods
|
||||
@@ -559,6 +584,11 @@ class Http:
|
||||
self.stage = Stage.FAILED
|
||||
raise RuntimeError("Response already started")
|
||||
|
||||
# Disconnect any earlier but unused response object
|
||||
if self.response is not None:
|
||||
self.response.stream = None
|
||||
|
||||
# Connect and return the response
|
||||
self.response, response.stream = response, self
|
||||
return response
|
||||
|
||||
|
||||
13
sanic/log.py
13
sanic/log.py
@@ -1,8 +1,11 @@
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any, Dict
|
||||
|
||||
LOGGING_CONFIG_DEFAULTS = dict(
|
||||
|
||||
LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict(
|
||||
version=1,
|
||||
disable_existing_loggers=False,
|
||||
loggers={
|
||||
@@ -53,6 +56,14 @@ LOGGING_CONFIG_DEFAULTS = dict(
|
||||
)
|
||||
|
||||
|
||||
class Colors(str, Enum):
|
||||
END = "\033[0m"
|
||||
BLUE = "\033[01;34m"
|
||||
GREEN = "\033[01;32m"
|
||||
YELLOW = "\033[01;33m"
|
||||
RED = "\033[01;31m"
|
||||
|
||||
|
||||
logger = logging.getLogger("sanic.root")
|
||||
"""
|
||||
General Sanic logger
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
from enum import Enum, auto
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Coroutine, List, Optional, Union
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from sanic.models.futures import FutureListener
|
||||
from sanic.models.handler_types import ListenerType, Sanic
|
||||
|
||||
|
||||
class ListenerEvent(str, Enum):
|
||||
def _generate_next_value_(name: str, *args) -> str: # type: ignore
|
||||
return name.lower()
|
||||
|
||||
BEFORE_SERVER_START = auto()
|
||||
AFTER_SERVER_START = auto()
|
||||
BEFORE_SERVER_STOP = auto()
|
||||
AFTER_SERVER_STOP = auto()
|
||||
BEFORE_SERVER_START = "server.init.before"
|
||||
AFTER_SERVER_START = "server.init.after"
|
||||
BEFORE_SERVER_STOP = "server.shutdown.before"
|
||||
AFTER_SERVER_STOP = "server.shutdown.after"
|
||||
MAIN_PROCESS_START = auto()
|
||||
MAIN_PROCESS_STOP = auto()
|
||||
|
||||
@@ -26,12 +27,10 @@ class ListenerMixin:
|
||||
|
||||
def listener(
|
||||
self,
|
||||
listener_or_event: Union[
|
||||
Callable[..., Coroutine[Any, Any, None]], str
|
||||
],
|
||||
listener_or_event: Union[ListenerType[Sanic], str],
|
||||
event_or_none: Optional[str] = None,
|
||||
apply: bool = True,
|
||||
):
|
||||
) -> ListenerType[Sanic]:
|
||||
"""
|
||||
Create a listener from a decorated function.
|
||||
|
||||
@@ -63,20 +62,32 @@ class ListenerMixin:
|
||||
else:
|
||||
return partial(register_listener, event=listener_or_event)
|
||||
|
||||
def main_process_start(self, listener):
|
||||
def main_process_start(
|
||||
self, listener: ListenerType[Sanic]
|
||||
) -> ListenerType[Sanic]:
|
||||
return self.listener(listener, "main_process_start")
|
||||
|
||||
def main_process_stop(self, listener):
|
||||
def main_process_stop(
|
||||
self, listener: ListenerType[Sanic]
|
||||
) -> ListenerType[Sanic]:
|
||||
return self.listener(listener, "main_process_stop")
|
||||
|
||||
def before_server_start(self, listener):
|
||||
def before_server_start(
|
||||
self, listener: ListenerType[Sanic]
|
||||
) -> ListenerType[Sanic]:
|
||||
return self.listener(listener, "before_server_start")
|
||||
|
||||
def after_server_start(self, listener):
|
||||
def after_server_start(
|
||||
self, listener: ListenerType[Sanic]
|
||||
) -> ListenerType[Sanic]:
|
||||
return self.listener(listener, "after_server_start")
|
||||
|
||||
def before_server_stop(self, listener):
|
||||
def before_server_stop(
|
||||
self, listener: ListenerType[Sanic]
|
||||
) -> ListenerType[Sanic]:
|
||||
return self.listener(listener, "before_server_stop")
|
||||
|
||||
def after_server_stop(self, listener):
|
||||
def after_server_stop(
|
||||
self, listener: ListenerType[Sanic]
|
||||
) -> ListenerType[Sanic]:
|
||||
return self.listener(listener, "after_server_stop")
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
from ast import NodeVisitor, Return, parse
|
||||
from functools import partial, wraps
|
||||
from inspect import signature
|
||||
from inspect import getsource, signature
|
||||
from mimetypes import guess_type
|
||||
from os import path
|
||||
from pathlib import PurePath
|
||||
from re import sub
|
||||
from textwrap import dedent
|
||||
from time import gmtime, strftime
|
||||
from typing import Iterable, List, Optional, Set, Union
|
||||
from typing import Any, Callable, Iterable, List, Optional, Set, Tuple, Union
|
||||
from urllib.parse import unquote
|
||||
|
||||
from sanic_routing.route import Route # type: ignore
|
||||
|
||||
from sanic.compat import stat_async
|
||||
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE, HTTP_METHODS
|
||||
from sanic.errorpages import RESPONSE_MAPPING
|
||||
from sanic.exceptions import (
|
||||
ContentRangeError,
|
||||
FileNotFound,
|
||||
@@ -21,10 +24,16 @@ from sanic.exceptions import (
|
||||
from sanic.handlers import ContentRangeHandler
|
||||
from sanic.log import error_logger
|
||||
from sanic.models.futures import FutureRoute, FutureStatic
|
||||
from sanic.models.handler_types import RouteHandler
|
||||
from sanic.response import HTTPResponse, file, file_stream
|
||||
from sanic.views import CompositionView
|
||||
|
||||
|
||||
RouteWrapper = Callable[
|
||||
[RouteHandler], Union[RouteHandler, Tuple[Route, RouteHandler]]
|
||||
]
|
||||
|
||||
|
||||
class RouteMixin:
|
||||
name: str
|
||||
|
||||
@@ -43,7 +52,7 @@ class RouteMixin:
|
||||
self,
|
||||
uri: str,
|
||||
methods: Optional[Iterable[str]] = None,
|
||||
host: Optional[str] = None,
|
||||
host: Optional[Union[str, List[str]]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
stream: bool = False,
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
@@ -55,7 +64,8 @@ class RouteMixin:
|
||||
unquote: bool = False,
|
||||
static: bool = False,
|
||||
version_prefix: str = "/v",
|
||||
):
|
||||
error_format: Optional[str] = None,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
Decorate a function to be registered as a route
|
||||
|
||||
@@ -97,6 +107,7 @@ class RouteMixin:
|
||||
nonlocal websocket
|
||||
nonlocal static
|
||||
nonlocal version_prefix
|
||||
nonlocal error_format
|
||||
|
||||
if isinstance(handler, tuple):
|
||||
# if a handler fn is already wrapped in a route, the handler
|
||||
@@ -115,10 +126,16 @@ class RouteMixin:
|
||||
"Expected either string or Iterable of host strings, "
|
||||
"not %s" % host
|
||||
)
|
||||
|
||||
if isinstance(subprotocols, (list, tuple, set)):
|
||||
if isinstance(subprotocols, list):
|
||||
# Ordered subprotocols, maintain order
|
||||
subprotocols = tuple(subprotocols)
|
||||
elif isinstance(subprotocols, set):
|
||||
# subprotocol is unordered, keep it unordered
|
||||
subprotocols = frozenset(subprotocols)
|
||||
|
||||
if not error_format or error_format == "auto":
|
||||
error_format = self._determine_error_format(handler)
|
||||
|
||||
route = FutureRoute(
|
||||
handler,
|
||||
uri,
|
||||
@@ -134,6 +151,7 @@ class RouteMixin:
|
||||
unquote,
|
||||
static,
|
||||
version_prefix,
|
||||
error_format,
|
||||
)
|
||||
|
||||
self._future_routes.add(route)
|
||||
@@ -168,16 +186,17 @@ class RouteMixin:
|
||||
|
||||
def add_route(
|
||||
self,
|
||||
handler,
|
||||
handler: RouteHandler,
|
||||
uri: str,
|
||||
methods: Iterable[str] = frozenset({"GET"}),
|
||||
host: Optional[str] = None,
|
||||
host: Optional[Union[str, List[str]]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
version: Optional[int] = None,
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
name: Optional[str] = None,
|
||||
stream: bool = False,
|
||||
version_prefix: str = "/v",
|
||||
):
|
||||
error_format: Optional[str] = None,
|
||||
) -> RouteHandler:
|
||||
"""A helper method to register class instance or
|
||||
functions as a handler to the application url
|
||||
routes.
|
||||
@@ -200,7 +219,8 @@ class RouteMixin:
|
||||
methods = set()
|
||||
|
||||
for method in HTTP_METHODS:
|
||||
_handler = getattr(handler.view_class, method.lower(), None)
|
||||
view_class = getattr(handler, "view_class")
|
||||
_handler = getattr(view_class, method.lower(), None)
|
||||
if _handler:
|
||||
methods.add(method)
|
||||
if hasattr(_handler, "is_stream"):
|
||||
@@ -226,6 +246,7 @@ class RouteMixin:
|
||||
version=version,
|
||||
name=name,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
)(handler)
|
||||
return handler
|
||||
|
||||
@@ -233,13 +254,14 @@ class RouteMixin:
|
||||
def get(
|
||||
self,
|
||||
uri: str,
|
||||
host: Optional[str] = None,
|
||||
host: Optional[Union[str, List[str]]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
version: Optional[int] = None,
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
name: Optional[str] = None,
|
||||
ignore_body: bool = True,
|
||||
version_prefix: str = "/v",
|
||||
):
|
||||
error_format: Optional[str] = None,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
Add an API URL under the **GET** *HTTP* method
|
||||
|
||||
@@ -262,18 +284,20 @@ class RouteMixin:
|
||||
name=name,
|
||||
ignore_body=ignore_body,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
)
|
||||
|
||||
def post(
|
||||
self,
|
||||
uri: str,
|
||||
host: Optional[str] = None,
|
||||
host: Optional[Union[str, List[str]]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
stream: bool = False,
|
||||
version: Optional[int] = None,
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
name: Optional[str] = None,
|
||||
version_prefix: str = "/v",
|
||||
):
|
||||
error_format: Optional[str] = None,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
Add an API URL under the **POST** *HTTP* method
|
||||
|
||||
@@ -296,18 +320,20 @@ class RouteMixin:
|
||||
version=version,
|
||||
name=name,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
)
|
||||
|
||||
def put(
|
||||
self,
|
||||
uri: str,
|
||||
host: Optional[str] = None,
|
||||
host: Optional[Union[str, List[str]]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
stream: bool = False,
|
||||
version: Optional[int] = None,
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
name: Optional[str] = None,
|
||||
version_prefix: str = "/v",
|
||||
):
|
||||
error_format: Optional[str] = None,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
Add an API URL under the **PUT** *HTTP* method
|
||||
|
||||
@@ -330,18 +356,20 @@ class RouteMixin:
|
||||
version=version,
|
||||
name=name,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
)
|
||||
|
||||
def head(
|
||||
self,
|
||||
uri: str,
|
||||
host: Optional[str] = None,
|
||||
host: Optional[Union[str, List[str]]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
version: Optional[int] = None,
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
name: Optional[str] = None,
|
||||
ignore_body: bool = True,
|
||||
version_prefix: str = "/v",
|
||||
):
|
||||
error_format: Optional[str] = None,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
Add an API URL under the **HEAD** *HTTP* method
|
||||
|
||||
@@ -372,18 +400,20 @@ class RouteMixin:
|
||||
name=name,
|
||||
ignore_body=ignore_body,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
)
|
||||
|
||||
def options(
|
||||
self,
|
||||
uri: str,
|
||||
host: Optional[str] = None,
|
||||
host: Optional[Union[str, List[str]]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
version: Optional[int] = None,
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
name: Optional[str] = None,
|
||||
ignore_body: bool = True,
|
||||
version_prefix: str = "/v",
|
||||
):
|
||||
error_format: Optional[str] = None,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
Add an API URL under the **OPTIONS** *HTTP* method
|
||||
|
||||
@@ -414,18 +444,20 @@ class RouteMixin:
|
||||
name=name,
|
||||
ignore_body=ignore_body,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
)
|
||||
|
||||
def patch(
|
||||
self,
|
||||
uri: str,
|
||||
host: Optional[str] = None,
|
||||
host: Optional[Union[str, List[str]]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
stream=False,
|
||||
version: Optional[int] = None,
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
name: Optional[str] = None,
|
||||
version_prefix: str = "/v",
|
||||
):
|
||||
error_format: Optional[str] = None,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
Add an API URL under the **PATCH** *HTTP* method
|
||||
|
||||
@@ -458,18 +490,20 @@ class RouteMixin:
|
||||
version=version,
|
||||
name=name,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
)
|
||||
|
||||
def delete(
|
||||
self,
|
||||
uri: str,
|
||||
host: Optional[str] = None,
|
||||
host: Optional[Union[str, List[str]]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
version: Optional[int] = None,
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
name: Optional[str] = None,
|
||||
ignore_body: bool = True,
|
||||
version_prefix: str = "/v",
|
||||
):
|
||||
error_format: Optional[str] = None,
|
||||
) -> RouteWrapper:
|
||||
"""
|
||||
Add an API URL under the **DELETE** *HTTP* method
|
||||
|
||||
@@ -492,18 +526,20 @@ class RouteMixin:
|
||||
name=name,
|
||||
ignore_body=ignore_body,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
)
|
||||
|
||||
def websocket(
|
||||
self,
|
||||
uri: str,
|
||||
host: Optional[str] = None,
|
||||
host: Optional[Union[str, List[str]]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
subprotocols: Optional[List[str]] = None,
|
||||
version: Optional[int] = None,
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
name: Optional[str] = None,
|
||||
apply: bool = True,
|
||||
version_prefix: str = "/v",
|
||||
error_format: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Decorate a function to be registered as a websocket route
|
||||
@@ -530,18 +566,20 @@ class RouteMixin:
|
||||
subprotocols=subprotocols,
|
||||
websocket=True,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
)
|
||||
|
||||
def add_websocket_route(
|
||||
self,
|
||||
handler,
|
||||
uri: str,
|
||||
host: Optional[str] = None,
|
||||
host: Optional[Union[str, List[str]]] = None,
|
||||
strict_slashes: Optional[bool] = None,
|
||||
subprotocols=None,
|
||||
version: Optional[int] = None,
|
||||
version: Optional[Union[int, str, float]] = None,
|
||||
name: Optional[str] = None,
|
||||
version_prefix: str = "/v",
|
||||
error_format: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
A helper method to register a function as a websocket route.
|
||||
@@ -570,6 +608,7 @@ class RouteMixin:
|
||||
version=version,
|
||||
name=name,
|
||||
version_prefix=version_prefix,
|
||||
error_format=error_format,
|
||||
)(handler)
|
||||
|
||||
def static(
|
||||
@@ -585,6 +624,7 @@ class RouteMixin:
|
||||
strict_slashes=None,
|
||||
content_type=None,
|
||||
apply=True,
|
||||
resource_type=None,
|
||||
):
|
||||
"""
|
||||
Register a root to serve files from. The input can either be a
|
||||
@@ -634,6 +674,7 @@ class RouteMixin:
|
||||
host,
|
||||
strict_slashes,
|
||||
content_type,
|
||||
resource_type,
|
||||
)
|
||||
self._future_statics.add(static)
|
||||
|
||||
@@ -777,10 +818,11 @@ class RouteMixin:
|
||||
)
|
||||
except Exception:
|
||||
error_logger.exception(
|
||||
f"Exception in static request handler:\
|
||||
path={file_or_directory}, "
|
||||
f"Exception in static request handler: "
|
||||
f"path={file_or_directory}, "
|
||||
f"relative_url={__file_uri__}"
|
||||
)
|
||||
raise
|
||||
|
||||
def _register_static(
|
||||
self,
|
||||
@@ -828,8 +870,27 @@ class RouteMixin:
|
||||
name = static.name
|
||||
# If we're not trying to match a file directly,
|
||||
# serve from the folder
|
||||
if not path.isfile(file_or_directory):
|
||||
if not static.resource_type:
|
||||
if not path.isfile(file_or_directory):
|
||||
uri += "/<__file_uri__:path>"
|
||||
elif static.resource_type == "dir":
|
||||
if path.isfile(file_or_directory):
|
||||
raise TypeError(
|
||||
"Resource type improperly identified as directory. "
|
||||
f"'{file_or_directory}'"
|
||||
)
|
||||
uri += "/<__file_uri__:path>"
|
||||
elif static.resource_type == "file" and not path.isfile(
|
||||
file_or_directory
|
||||
):
|
||||
raise TypeError(
|
||||
"Resource type improperly identified as file. "
|
||||
f"'{file_or_directory}'"
|
||||
)
|
||||
elif static.resource_type != "file":
|
||||
raise ValueError(
|
||||
"The resource_type should be set to 'file' or 'dir'"
|
||||
)
|
||||
|
||||
# special prefix for static files
|
||||
# if not static.name.startswith("_static_"):
|
||||
@@ -846,7 +907,7 @@ class RouteMixin:
|
||||
)
|
||||
)
|
||||
|
||||
route, _ = self.route(
|
||||
route, _ = self.route( # type: ignore
|
||||
uri=uri,
|
||||
methods=["GET", "HEAD"],
|
||||
name=name,
|
||||
@@ -856,3 +917,43 @@ class RouteMixin:
|
||||
)(_handler)
|
||||
|
||||
return route
|
||||
|
||||
def _determine_error_format(self, handler) -> Optional[str]:
|
||||
if not isinstance(handler, CompositionView):
|
||||
try:
|
||||
src = dedent(getsource(handler))
|
||||
tree = parse(src)
|
||||
http_response_types = self._get_response_types(tree)
|
||||
|
||||
if len(http_response_types) == 1:
|
||||
return next(iter(http_response_types))
|
||||
except (OSError, TypeError):
|
||||
...
|
||||
|
||||
return None
|
||||
|
||||
def _get_response_types(self, node):
|
||||
types = set()
|
||||
|
||||
class HttpResponseVisitor(NodeVisitor):
|
||||
def visit_Return(self, node: Return) -> Any:
|
||||
nonlocal types
|
||||
|
||||
try:
|
||||
checks = [node.value.func.id] # type: ignore
|
||||
if node.value.keywords: # type: ignore
|
||||
checks += [
|
||||
k.value
|
||||
for k in node.value.keywords # type: ignore
|
||||
if k.arg == "content_type"
|
||||
]
|
||||
|
||||
for check in checks:
|
||||
if check in RESPONSE_MAPPING:
|
||||
types.add(RESPONSE_MAPPING[check])
|
||||
except AttributeError:
|
||||
...
|
||||
|
||||
HttpResponseVisitor().visit(node)
|
||||
|
||||
return types
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from typing import Any, Callable, Dict, Optional, Set
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Dict, Optional, Set, Union
|
||||
|
||||
from sanic.models.futures import FutureSignal
|
||||
from sanic.models.handler_types import SignalHandler
|
||||
@@ -19,11 +20,11 @@ class SignalMixin:
|
||||
|
||||
def signal(
|
||||
self,
|
||||
event: str,
|
||||
event: Union[str, Enum],
|
||||
*,
|
||||
apply: bool = True,
|
||||
condition: Dict[str, Any] = None,
|
||||
) -> Callable[[SignalHandler], FutureSignal]:
|
||||
) -> Callable[[SignalHandler], SignalHandler]:
|
||||
"""
|
||||
For creating a signal handler, used similar to a route handler:
|
||||
|
||||
@@ -41,20 +42,18 @@ class SignalMixin:
|
||||
filtering, defaults to None
|
||||
:type condition: Dict[str, Any], optional
|
||||
"""
|
||||
event_value = str(event.value) if isinstance(event, Enum) else event
|
||||
|
||||
def decorator(handler: SignalHandler):
|
||||
nonlocal event
|
||||
nonlocal apply
|
||||
|
||||
future_signal = FutureSignal(
|
||||
handler, event, HashableDict(condition or {})
|
||||
handler, event_value, HashableDict(condition or {})
|
||||
)
|
||||
self._future_signals.add(future_signal)
|
||||
|
||||
if apply:
|
||||
self._apply_signal(future_signal)
|
||||
|
||||
return future_signal
|
||||
return handler
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from typing import Any, Awaitable, Callable, MutableMapping, Optional, Union
|
||||
|
||||
from sanic.exceptions import InvalidUsage
|
||||
from sanic.websocket import WebSocketConnection
|
||||
from sanic.server.websockets.connection import WebSocketConnection
|
||||
|
||||
|
||||
ASGIScope = MutableMapping[str, Any]
|
||||
@@ -14,10 +15,20 @@ ASGIReceive = Callable[[], Awaitable[ASGIMessage]]
|
||||
|
||||
class MockProtocol:
|
||||
def __init__(self, transport: "MockTransport", loop):
|
||||
# This should be refactored when < 3.8 support is dropped
|
||||
self.transport = transport
|
||||
self._not_paused = asyncio.Event(loop=loop)
|
||||
self._not_paused.set()
|
||||
self._complete = asyncio.Event(loop=loop)
|
||||
# Fixup for 3.8+; Sanic still supports 3.7 where loop is required
|
||||
loop = loop if sys.version_info[:2] < (3, 8) else None
|
||||
# Optional in 3.9, necessary in 3.10 because the parameter "loop"
|
||||
# was completely removed
|
||||
if not loop:
|
||||
self._not_paused = asyncio.Event()
|
||||
self._not_paused.set()
|
||||
self._complete = asyncio.Event()
|
||||
else:
|
||||
self._not_paused = asyncio.Event(loop=loop)
|
||||
self._not_paused.set()
|
||||
self._complete = asyncio.Event(loop=loop)
|
||||
|
||||
def pause_writing(self) -> None:
|
||||
self._not_paused.clear()
|
||||
|
||||
@@ -13,7 +13,7 @@ class FutureRoute(NamedTuple):
|
||||
handler: str
|
||||
uri: str
|
||||
methods: Optional[Iterable[str]]
|
||||
host: str
|
||||
host: Union[str, List[str]]
|
||||
strict_slashes: bool
|
||||
stream: bool
|
||||
version: Optional[int]
|
||||
@@ -24,6 +24,7 @@ class FutureRoute(NamedTuple):
|
||||
unquote: bool
|
||||
static: bool
|
||||
version_prefix: str
|
||||
error_format: Optional[str]
|
||||
|
||||
|
||||
class FutureListener(NamedTuple):
|
||||
@@ -52,9 +53,14 @@ class FutureStatic(NamedTuple):
|
||||
host: Optional[str]
|
||||
strict_slashes: Optional[bool]
|
||||
content_type: Optional[bool]
|
||||
resource_type: Optional[str]
|
||||
|
||||
|
||||
class FutureSignal(NamedTuple):
|
||||
handler: SignalHandler
|
||||
event: str
|
||||
condition: Optional[Dict[str, str]]
|
||||
|
||||
|
||||
class FutureRegistry(set):
|
||||
...
|
||||
|
||||
@@ -21,5 +21,5 @@ MiddlewareType = Union[RequestMiddlewareType, ResponseMiddlewareType]
|
||||
ListenerType = Callable[
|
||||
[Sanic, AbstractEventLoop], Optional[Coroutine[Any, Any, None]]
|
||||
]
|
||||
RouteHandler = Callable[..., Coroutine[Any, Any, HTTPResponse]]
|
||||
RouteHandler = Callable[..., Coroutine[Any, Any, Optional[HTTPResponse]]]
|
||||
SignalHandler = Callable[..., Coroutine[Any, Any, None]]
|
||||
|
||||
64
sanic/models/server_types.py
Normal file
64
sanic/models/server_types.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from ssl import SSLObject
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from sanic.models.protocol_types import TransportProtocol
|
||||
|
||||
|
||||
class Signal:
|
||||
stopped = False
|
||||
|
||||
|
||||
class ConnInfo:
|
||||
"""
|
||||
Local and remote addresses and SSL status info.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"client_port",
|
||||
"client",
|
||||
"client_ip",
|
||||
"ctx",
|
||||
"peername",
|
||||
"server_port",
|
||||
"server",
|
||||
"server_name",
|
||||
"sockname",
|
||||
"ssl",
|
||||
"cert",
|
||||
)
|
||||
|
||||
def __init__(self, transport: TransportProtocol, unix=None):
|
||||
self.ctx = SimpleNamespace()
|
||||
self.peername = None
|
||||
self.server = self.client = ""
|
||||
self.server_port = self.client_port = 0
|
||||
self.client_ip = ""
|
||||
self.sockname = addr = transport.get_extra_info("sockname")
|
||||
self.ssl = False
|
||||
self.server_name = ""
|
||||
self.cert: Dict[str, Any] = {}
|
||||
sslobj: Optional[SSLObject] = transport.get_extra_info(
|
||||
"ssl_object"
|
||||
) # type: ignore
|
||||
if sslobj:
|
||||
self.ssl = True
|
||||
self.server_name = getattr(sslobj, "sanic_server_name", None) or ""
|
||||
self.cert = dict(getattr(sslobj.context, "sanic", {}))
|
||||
if isinstance(addr, str): # UNIX socket
|
||||
self.server = unix or addr
|
||||
return
|
||||
|
||||
# IPv4 (ip, port) or IPv6 (ip, port, flowinfo, scopeid)
|
||||
if isinstance(addr, tuple):
|
||||
self.server = addr[0] if len(addr) == 2 else f"[{addr[0]}]"
|
||||
self.server_port = addr[1]
|
||||
# self.server gets non-standard port appended
|
||||
if addr[1] != (443 if self.ssl else 80):
|
||||
self.server = f"{self.server}:{addr[1]}"
|
||||
self.peername = addr = transport.get_extra_info("peername")
|
||||
|
||||
if isinstance(addr, tuple):
|
||||
self.client = addr[0] if len(addr) == 2 else f"[{addr[0]}]"
|
||||
self.client_ip = addr[0]
|
||||
self.client_port = addr[1]
|
||||
@@ -6,9 +6,6 @@ import sys
|
||||
|
||||
from time import sleep
|
||||
|
||||
from sanic.config import BASE_LOGO
|
||||
from sanic.log import logger
|
||||
|
||||
|
||||
def _iter_module_files():
|
||||
"""This iterates over all relevant Python files.
|
||||
@@ -50,13 +47,19 @@ def _get_args_for_reloading():
|
||||
return [sys.executable] + sys.argv
|
||||
|
||||
|
||||
def restart_with_reloader():
|
||||
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(
|
||||
_get_args_for_reloading(),
|
||||
env={**os.environ, "SANIC_SERVER_RUNNING": "true"},
|
||||
env={
|
||||
**os.environ,
|
||||
"SANIC_SERVER_RUNNING": "true",
|
||||
"SANIC_RELOADER_PROCESS": "true",
|
||||
"SANIC_RELOADED_FILES": reloaded,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -91,31 +94,29 @@ def watchdog(sleep_interval, app):
|
||||
|
||||
worker_process = restart_with_reloader()
|
||||
|
||||
if app.config.LOGO:
|
||||
logger.debug(
|
||||
app.config.LOGO if isinstance(app.config.LOGO, str) else BASE_LOGO
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
need_reload = False
|
||||
|
||||
changed = set()
|
||||
for filename in itertools.chain(
|
||||
_iter_module_files(),
|
||||
*(d.glob("**/*") for d in app.reload_dirs),
|
||||
):
|
||||
try:
|
||||
check = _check_file(filename, mtimes)
|
||||
if _check_file(filename, mtimes):
|
||||
path = (
|
||||
filename
|
||||
if isinstance(filename, str)
|
||||
else filename.resolve()
|
||||
)
|
||||
changed.add(str(path))
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
if check:
|
||||
need_reload = True
|
||||
|
||||
if need_reload:
|
||||
if changed:
|
||||
worker_process.terminate()
|
||||
worker_process.wait()
|
||||
worker_process = restart_with_reloader()
|
||||
worker_process = restart_with_reloader(changed)
|
||||
|
||||
sleep(sleep_interval)
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -18,7 +18,6 @@ from sanic_routing.route import Route # type: ignore
|
||||
if TYPE_CHECKING:
|
||||
from sanic.server import ConnInfo
|
||||
from sanic.app import Sanic
|
||||
from sanic.http import Http
|
||||
|
||||
import email.utils
|
||||
import uuid
|
||||
@@ -32,14 +31,17 @@ from httptools import parse_url # type: ignore
|
||||
|
||||
from sanic.compat import CancelledErrors, Header
|
||||
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
|
||||
from sanic.exceptions import InvalidUsage
|
||||
from sanic.exceptions import InvalidUsage, ServerError
|
||||
from sanic.headers import (
|
||||
AcceptContainer,
|
||||
Options,
|
||||
parse_accept,
|
||||
parse_content_header,
|
||||
parse_forwarded,
|
||||
parse_host,
|
||||
parse_xforwarded,
|
||||
)
|
||||
from sanic.http import Http, Stage
|
||||
from sanic.log import error_logger, logger
|
||||
from sanic.models.protocol_types import TransportProtocol
|
||||
from sanic.response import BaseHTTPResponse, HTTPResponse
|
||||
@@ -94,6 +96,7 @@ class Request:
|
||||
"head",
|
||||
"headers",
|
||||
"method",
|
||||
"parsed_accept",
|
||||
"parsed_args",
|
||||
"parsed_not_grouped_args",
|
||||
"parsed_files",
|
||||
@@ -101,6 +104,7 @@ class Request:
|
||||
"parsed_json",
|
||||
"parsed_forwarded",
|
||||
"raw_url",
|
||||
"responded",
|
||||
"request_middleware_started",
|
||||
"route",
|
||||
"stream",
|
||||
@@ -136,6 +140,7 @@ class Request:
|
||||
self.conn_info: Optional[ConnInfo] = None
|
||||
self.ctx = SimpleNamespace()
|
||||
self.parsed_forwarded: Optional[Options] = None
|
||||
self.parsed_accept: Optional[AcceptContainer] = None
|
||||
self.parsed_json = None
|
||||
self.parsed_form = None
|
||||
self.parsed_files = None
|
||||
@@ -151,6 +156,7 @@ class Request:
|
||||
self.stream: Optional[Http] = None
|
||||
self.route: Optional[Route] = None
|
||||
self._protocol = None
|
||||
self.responded: bool = False
|
||||
|
||||
def __repr__(self):
|
||||
class_name = self.__class__.__name__
|
||||
@@ -160,6 +166,21 @@ class Request:
|
||||
def generate_id(*_):
|
||||
return uuid.uuid4()
|
||||
|
||||
def reset_response(self):
|
||||
try:
|
||||
if (
|
||||
self.stream is not None
|
||||
and self.stream.stage is not Stage.HANDLER
|
||||
):
|
||||
raise ServerError(
|
||||
"Cannot reset response because previous response was sent."
|
||||
)
|
||||
self.stream.response.stream = None
|
||||
self.stream.response = None
|
||||
self.responded = False
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
async def respond(
|
||||
self,
|
||||
response: Optional[BaseHTTPResponse] = None,
|
||||
@@ -168,13 +189,19 @@ class Request:
|
||||
headers: Optional[Union[Header, Dict[str, str]]] = None,
|
||||
content_type: Optional[str] = None,
|
||||
):
|
||||
try:
|
||||
if self.stream is not None and self.stream.response:
|
||||
raise ServerError("Second respond call is not allowed.")
|
||||
except AttributeError:
|
||||
pass
|
||||
# This logic of determining which response to use is subject to change
|
||||
if response is None:
|
||||
response = (self.stream and self.stream.response) or HTTPResponse(
|
||||
response = HTTPResponse(
|
||||
status=status,
|
||||
headers=headers,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
# Connect the response
|
||||
if isinstance(response, BaseHTTPResponse) and self.stream:
|
||||
response = self.stream.respond(response)
|
||||
@@ -189,6 +216,7 @@ class Request:
|
||||
error_logger.exception(
|
||||
"Exception occurred in one of response middleware handlers"
|
||||
)
|
||||
self.responded = True
|
||||
return response
|
||||
|
||||
async def receive_body(self):
|
||||
@@ -296,6 +324,13 @@ class Request:
|
||||
|
||||
return self.parsed_json
|
||||
|
||||
@property
|
||||
def accept(self) -> AcceptContainer:
|
||||
if self.parsed_accept is None:
|
||||
accept_header = self.headers.getone("accept", "")
|
||||
self.parsed_accept = parse_accept(accept_header)
|
||||
return self.parsed_accept
|
||||
|
||||
@property
|
||||
def token(self):
|
||||
"""Attempt to return the auth header token.
|
||||
@@ -497,6 +532,10 @@ class Request:
|
||||
"""
|
||||
return self._match_info
|
||||
|
||||
@match_info.setter
|
||||
def match_info(self, value):
|
||||
self._match_info = value
|
||||
|
||||
# Transport properties (obtained from local interface only)
|
||||
|
||||
@property
|
||||
@@ -745,9 +784,10 @@ def parse_multipart_form(body, boundary):
|
||||
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[colon_index + 2 :]
|
||||
form_line[idx:]
|
||||
)
|
||||
|
||||
if form_header_field == "content-disposition":
|
||||
|
||||
@@ -3,6 +3,7 @@ from mimetypes import guess_type
|
||||
from os import path
|
||||
from pathlib import PurePath
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
AnyStr,
|
||||
Callable,
|
||||
@@ -19,11 +20,15 @@ from warnings import warn
|
||||
from sanic.compat import Header, open_async
|
||||
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
|
||||
from sanic.cookies import CookieJar
|
||||
from sanic.exceptions import SanicException, ServerError
|
||||
from sanic.helpers import has_message_body, remove_entity_headers
|
||||
from sanic.http import Http
|
||||
from sanic.models.protocol_types import HTMLProtocol, Range
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic.asgi import ASGIApp
|
||||
|
||||
try:
|
||||
from ujson import dumps as json_dumps
|
||||
except ImportError:
|
||||
@@ -45,7 +50,7 @@ class BaseHTTPResponse:
|
||||
self.asgi: bool = False
|
||||
self.body: Optional[bytes] = None
|
||||
self.content_type: Optional[str] = None
|
||||
self.stream: Http = None
|
||||
self.stream: Optional[Union[Http, ASGIApp]] = None
|
||||
self.status: int = None
|
||||
self.headers = Header({})
|
||||
self._cookies: Optional[CookieJar] = None
|
||||
@@ -101,7 +106,7 @@ class BaseHTTPResponse:
|
||||
|
||||
async def send(
|
||||
self,
|
||||
data: Optional[Union[AnyStr]] = None,
|
||||
data: Optional[AnyStr] = None,
|
||||
end_stream: Optional[bool] = None,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -112,8 +117,17 @@ class BaseHTTPResponse:
|
||||
"""
|
||||
if data is None and end_stream is None:
|
||||
end_stream = True
|
||||
if end_stream and not data and self.stream.send is None:
|
||||
return
|
||||
if self.stream is None:
|
||||
raise SanicException(
|
||||
"No stream is connected to the response object instance."
|
||||
)
|
||||
if self.stream.send is None:
|
||||
if end_stream and not data:
|
||||
return
|
||||
raise ServerError(
|
||||
"Response stream was ended, no more response data is "
|
||||
"allowed to be sent."
|
||||
)
|
||||
data = (
|
||||
data.encode() # type: ignore
|
||||
if hasattr(data, "encode")
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from inspect import signature
|
||||
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
|
||||
from uuid import UUID
|
||||
|
||||
from sanic_routing import BaseRouter # type: ignore
|
||||
from sanic_routing.exceptions import NoMethod # type: ignore
|
||||
@@ -9,6 +13,7 @@ from sanic_routing.exceptions import (
|
||||
from sanic_routing.route import Route # type: ignore
|
||||
|
||||
from sanic.constants import HTTP_METHODS
|
||||
from sanic.errorpages import check_error_format
|
||||
from sanic.exceptions import MethodNotSupported, NotFound, SanicException
|
||||
from sanic.models.handler_types import RouteHandler
|
||||
|
||||
@@ -49,7 +54,7 @@ class Router(BaseRouter):
|
||||
self, path: str, method: str, host: Optional[str]
|
||||
) -> Tuple[Route, RouteHandler, Dict[str, Any]]:
|
||||
"""
|
||||
Retrieve a `Route` object containg the details about how to handle
|
||||
Retrieve a `Route` object containing the details about how to handle
|
||||
a response for a given request
|
||||
|
||||
:param request: the incoming request object
|
||||
@@ -74,6 +79,7 @@ class Router(BaseRouter):
|
||||
unquote: bool = False,
|
||||
static: bool = False,
|
||||
version_prefix: str = "/v",
|
||||
error_format: Optional[str] = None,
|
||||
) -> Union[Route, List[Route]]:
|
||||
"""
|
||||
Add a handler to the router
|
||||
@@ -106,6 +112,8 @@ class Router(BaseRouter):
|
||||
version = str(version).strip("/").lstrip("v")
|
||||
uri = "/".join([f"{version_prefix}{version}", uri.lstrip("/")])
|
||||
|
||||
uri = self._normalize(uri, handler)
|
||||
|
||||
params = dict(
|
||||
path=uri,
|
||||
handler=handler,
|
||||
@@ -131,6 +139,10 @@ class Router(BaseRouter):
|
||||
route.ctx.stream = stream
|
||||
route.ctx.hosts = hosts
|
||||
route.ctx.static = static
|
||||
route.ctx.error_format = error_format
|
||||
|
||||
if error_format:
|
||||
check_error_format(route.ctx.error_format)
|
||||
|
||||
routes.append(route)
|
||||
|
||||
@@ -187,3 +199,24 @@ class Router(BaseRouter):
|
||||
raise SanicException(
|
||||
f"Invalid route: {route}. Parameter names cannot use '__'."
|
||||
)
|
||||
|
||||
def _normalize(self, uri: str, handler: RouteHandler) -> str:
|
||||
if "<" not in uri:
|
||||
return uri
|
||||
|
||||
sig = signature(handler)
|
||||
mapping = {
|
||||
param.name: param.annotation.__name__.lower()
|
||||
for param in sig.parameters.values()
|
||||
if param.annotation in (str, int, float, UUID)
|
||||
}
|
||||
|
||||
reconstruction = []
|
||||
for part in uri.split("/"):
|
||||
if part.startswith("<") and ":" not in part:
|
||||
name = part[1:-1]
|
||||
annotation = mapping.get(name)
|
||||
if annotation:
|
||||
part = f"<{name}:{annotation}>"
|
||||
reconstruction.append(part)
|
||||
return "/".join(reconstruction)
|
||||
|
||||
793
sanic/server.py
793
sanic/server.py
@@ -1,793 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ssl import SSLContext
|
||||
from types import SimpleNamespace
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
Iterable,
|
||||
Optional,
|
||||
Type,
|
||||
Union,
|
||||
)
|
||||
|
||||
from sanic.models.handler_types import ListenerType
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic.app import Sanic
|
||||
|
||||
import asyncio
|
||||
import multiprocessing
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import stat
|
||||
|
||||
from asyncio import CancelledError
|
||||
from asyncio.transports import Transport
|
||||
from functools import partial
|
||||
from inspect import isawaitable
|
||||
from ipaddress import ip_address
|
||||
from signal import SIG_IGN, SIGINT, SIGTERM, Signals
|
||||
from signal import signal as signal_func
|
||||
from time import monotonic as current_time
|
||||
|
||||
from sanic.compat import OS_IS_WINDOWS, ctrlc_workaround_for_windows
|
||||
from sanic.config import Config
|
||||
from sanic.exceptions import RequestTimeout, ServiceUnavailable
|
||||
from sanic.http import Http, Stage
|
||||
from sanic.log import error_logger, logger
|
||||
from sanic.models.protocol_types import TransportProtocol
|
||||
from sanic.request import Request
|
||||
|
||||
|
||||
try:
|
||||
import uvloop # type: ignore
|
||||
|
||||
if not isinstance(asyncio.get_event_loop_policy(), uvloop.EventLoopPolicy):
|
||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
class Signal:
|
||||
stopped = False
|
||||
|
||||
|
||||
class ConnInfo:
|
||||
"""
|
||||
Local and remote addresses and SSL status info.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"client_port",
|
||||
"client",
|
||||
"client_ip",
|
||||
"ctx",
|
||||
"peername",
|
||||
"server_port",
|
||||
"server",
|
||||
"sockname",
|
||||
"ssl",
|
||||
)
|
||||
|
||||
def __init__(self, transport: TransportProtocol, unix=None):
|
||||
self.ctx = SimpleNamespace()
|
||||
self.peername = None
|
||||
self.server = self.client = ""
|
||||
self.server_port = self.client_port = 0
|
||||
self.client_ip = ""
|
||||
self.sockname = addr = transport.get_extra_info("sockname")
|
||||
self.ssl: bool = bool(transport.get_extra_info("sslcontext"))
|
||||
|
||||
if isinstance(addr, str): # UNIX socket
|
||||
self.server = unix or addr
|
||||
return
|
||||
|
||||
# IPv4 (ip, port) or IPv6 (ip, port, flowinfo, scopeid)
|
||||
if isinstance(addr, tuple):
|
||||
self.server = addr[0] if len(addr) == 2 else f"[{addr[0]}]"
|
||||
self.server_port = addr[1]
|
||||
# self.server gets non-standard port appended
|
||||
if addr[1] != (443 if self.ssl else 80):
|
||||
self.server = f"{self.server}:{addr[1]}"
|
||||
self.peername = addr = transport.get_extra_info("peername")
|
||||
|
||||
if isinstance(addr, tuple):
|
||||
self.client = addr[0] if len(addr) == 2 else f"[{addr[0]}]"
|
||||
self.client_ip = addr[0]
|
||||
self.client_port = addr[1]
|
||||
|
||||
|
||||
class HttpProtocol(asyncio.Protocol):
|
||||
"""
|
||||
This class provides a basic HTTP implementation of the sanic framework.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
# app
|
||||
"app",
|
||||
# event loop, connection
|
||||
"loop",
|
||||
"transport",
|
||||
"connections",
|
||||
"signal",
|
||||
"conn_info",
|
||||
"ctx",
|
||||
# request params
|
||||
"request",
|
||||
# request config
|
||||
"request_handler",
|
||||
"request_timeout",
|
||||
"response_timeout",
|
||||
"keep_alive_timeout",
|
||||
"request_max_size",
|
||||
"request_class",
|
||||
"error_handler",
|
||||
# enable or disable access log purpose
|
||||
"access_log",
|
||||
# connection management
|
||||
"state",
|
||||
"url",
|
||||
"_handler_task",
|
||||
"_can_write",
|
||||
"_data_received",
|
||||
"_time",
|
||||
"_task",
|
||||
"_http",
|
||||
"_exception",
|
||||
"recv_buffer",
|
||||
"_unix",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
loop,
|
||||
app: Sanic,
|
||||
signal=None,
|
||||
connections=None,
|
||||
state=None,
|
||||
unix=None,
|
||||
**kwargs,
|
||||
):
|
||||
asyncio.set_event_loop(loop)
|
||||
self.loop = loop
|
||||
self.app: Sanic = app
|
||||
self.url = None
|
||||
self.transport: Optional[Transport] = None
|
||||
self.conn_info: Optional[ConnInfo] = None
|
||||
self.request: Optional[Request] = None
|
||||
self.signal = signal or Signal()
|
||||
self.access_log = self.app.config.ACCESS_LOG
|
||||
self.connections = connections if connections is not None else set()
|
||||
self.request_handler = self.app.handle_request
|
||||
self.error_handler = self.app.error_handler
|
||||
self.request_timeout = self.app.config.REQUEST_TIMEOUT
|
||||
self.response_timeout = self.app.config.RESPONSE_TIMEOUT
|
||||
self.keep_alive_timeout = self.app.config.KEEP_ALIVE_TIMEOUT
|
||||
self.request_max_size = self.app.config.REQUEST_MAX_SIZE
|
||||
self.request_class = self.app.request_class or Request
|
||||
self.state = state if state else {}
|
||||
if "requests_count" not in self.state:
|
||||
self.state["requests_count"] = 0
|
||||
self._data_received = asyncio.Event()
|
||||
self._can_write = asyncio.Event()
|
||||
self._can_write.set()
|
||||
self._exception = None
|
||||
self._unix = unix
|
||||
|
||||
def _setup_connection(self):
|
||||
self._http = Http(self)
|
||||
self._time = current_time()
|
||||
self.check_timeouts()
|
||||
|
||||
async def connection_task(self):
|
||||
"""
|
||||
Run a HTTP connection.
|
||||
|
||||
Timeouts and some additional error handling occur here, while most of
|
||||
everything else happens in class Http or in code called from there.
|
||||
"""
|
||||
try:
|
||||
self._setup_connection()
|
||||
await self._http.http1()
|
||||
except CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
error_logger.exception("protocol.connection_task uncaught")
|
||||
finally:
|
||||
if self.app.debug and self._http:
|
||||
ip = self.transport.get_extra_info("peername")
|
||||
error_logger.error(
|
||||
"Connection lost before response written"
|
||||
f" @ {ip} {self._http.request}"
|
||||
)
|
||||
self._http = None
|
||||
self._task = None
|
||||
try:
|
||||
self.close()
|
||||
except BaseException:
|
||||
error_logger.exception("Closing failed")
|
||||
|
||||
async def receive_more(self):
|
||||
"""
|
||||
Wait until more data is received into the Server protocol's buffer
|
||||
"""
|
||||
self.transport.resume_reading()
|
||||
self._data_received.clear()
|
||||
await self._data_received.wait()
|
||||
|
||||
def check_timeouts(self):
|
||||
"""
|
||||
Runs itself periodically to enforce any expired timeouts.
|
||||
"""
|
||||
try:
|
||||
if not self._task:
|
||||
return
|
||||
duration = current_time() - self._time
|
||||
stage = self._http.stage
|
||||
if stage is Stage.IDLE and duration > self.keep_alive_timeout:
|
||||
logger.debug("KeepAlive Timeout. Closing connection.")
|
||||
elif stage is Stage.REQUEST and duration > self.request_timeout:
|
||||
logger.debug("Request Timeout. Closing connection.")
|
||||
self._http.exception = RequestTimeout("Request Timeout")
|
||||
elif stage is Stage.HANDLER and self._http.upgrade_websocket:
|
||||
logger.debug("Handling websocket. Timeouts disabled.")
|
||||
return
|
||||
elif (
|
||||
stage in (Stage.HANDLER, Stage.RESPONSE, Stage.FAILED)
|
||||
and duration > self.response_timeout
|
||||
):
|
||||
logger.debug("Response Timeout. Closing connection.")
|
||||
self._http.exception = ServiceUnavailable("Response Timeout")
|
||||
else:
|
||||
interval = (
|
||||
min(
|
||||
self.keep_alive_timeout,
|
||||
self.request_timeout,
|
||||
self.response_timeout,
|
||||
)
|
||||
/ 2
|
||||
)
|
||||
self.loop.call_later(max(0.1, interval), self.check_timeouts)
|
||||
return
|
||||
self._task.cancel()
|
||||
except Exception:
|
||||
error_logger.exception("protocol.check_timeouts")
|
||||
|
||||
async def send(self, data):
|
||||
"""
|
||||
Writes data with backpressure control.
|
||||
"""
|
||||
await self._can_write.wait()
|
||||
if self.transport.is_closing():
|
||||
raise CancelledError
|
||||
self.transport.write(data)
|
||||
self._time = current_time()
|
||||
|
||||
def close_if_idle(self) -> bool:
|
||||
"""
|
||||
Close the connection if a request is not being sent or received
|
||||
|
||||
:return: boolean - True if closed, false if staying open
|
||||
"""
|
||||
if self._http is None or self._http.stage is Stage.IDLE:
|
||||
self.close()
|
||||
return True
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Force close the connection.
|
||||
"""
|
||||
# Cause a call to connection_lost where further cleanup occurs
|
||||
if self.transport:
|
||||
self.transport.close()
|
||||
self.transport = None
|
||||
|
||||
# -------------------------------------------- #
|
||||
# Only asyncio.Protocol callbacks below this
|
||||
# -------------------------------------------- #
|
||||
|
||||
def connection_made(self, transport):
|
||||
try:
|
||||
# TODO: Benchmark to find suitable write buffer limits
|
||||
transport.set_write_buffer_limits(low=16384, high=65536)
|
||||
self.connections.add(self)
|
||||
self.transport = transport
|
||||
self._task = self.loop.create_task(self.connection_task())
|
||||
self.recv_buffer = bytearray()
|
||||
self.conn_info = ConnInfo(self.transport, unix=self._unix)
|
||||
except Exception:
|
||||
error_logger.exception("protocol.connect_made")
|
||||
|
||||
def connection_lost(self, exc):
|
||||
try:
|
||||
self.connections.discard(self)
|
||||
self.resume_writing()
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
except Exception:
|
||||
error_logger.exception("protocol.connection_lost")
|
||||
|
||||
def pause_writing(self):
|
||||
self._can_write.clear()
|
||||
|
||||
def resume_writing(self):
|
||||
self._can_write.set()
|
||||
|
||||
def data_received(self, data: bytes):
|
||||
try:
|
||||
self._time = current_time()
|
||||
if not data:
|
||||
return self.close()
|
||||
self.recv_buffer += data
|
||||
|
||||
if (
|
||||
len(self.recv_buffer) > self.app.config.REQUEST_BUFFER_SIZE
|
||||
and self.transport
|
||||
):
|
||||
self.transport.pause_reading()
|
||||
|
||||
if self._data_received:
|
||||
self._data_received.set()
|
||||
except Exception:
|
||||
error_logger.exception("protocol.data_received")
|
||||
|
||||
|
||||
def trigger_events(events: Optional[Iterable[Callable[..., Any]]], loop):
|
||||
"""
|
||||
Trigger event callbacks (functions or async)
|
||||
|
||||
:param events: one or more sync or async functions to execute
|
||||
:param loop: event loop
|
||||
"""
|
||||
if events:
|
||||
for event in events:
|
||||
result = event(loop)
|
||||
if isawaitable(result):
|
||||
loop.run_until_complete(result)
|
||||
|
||||
|
||||
class AsyncioServer:
|
||||
"""
|
||||
Wraps an asyncio server with functionality that might be useful to
|
||||
a user who needs to manage the server lifecycle manually.
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"loop",
|
||||
"serve_coro",
|
||||
"_after_start",
|
||||
"_before_stop",
|
||||
"_after_stop",
|
||||
"server",
|
||||
"connections",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
loop,
|
||||
serve_coro,
|
||||
connections,
|
||||
after_start: Optional[Iterable[ListenerType]],
|
||||
before_stop: Optional[Iterable[ListenerType]],
|
||||
after_stop: Optional[Iterable[ListenerType]],
|
||||
):
|
||||
# Note, Sanic already called "before_server_start" events
|
||||
# before this helper was even created. So we don't need it here.
|
||||
self.loop = loop
|
||||
self.serve_coro = serve_coro
|
||||
self._after_start = after_start
|
||||
self._before_stop = before_stop
|
||||
self._after_stop = after_stop
|
||||
self.server = None
|
||||
self.connections = connections
|
||||
|
||||
def after_start(self):
|
||||
"""
|
||||
Trigger "after_server_start" events
|
||||
"""
|
||||
trigger_events(self._after_start, self.loop)
|
||||
|
||||
def before_stop(self):
|
||||
"""
|
||||
Trigger "before_server_stop" events
|
||||
"""
|
||||
trigger_events(self._before_stop, self.loop)
|
||||
|
||||
def after_stop(self):
|
||||
"""
|
||||
Trigger "after_server_stop" events
|
||||
"""
|
||||
trigger_events(self._after_stop, self.loop)
|
||||
|
||||
def is_serving(self) -> bool:
|
||||
if self.server:
|
||||
return self.server.is_serving()
|
||||
return False
|
||||
|
||||
def wait_closed(self):
|
||||
if self.server:
|
||||
return self.server.wait_closed()
|
||||
|
||||
def close(self):
|
||||
if self.server:
|
||||
self.server.close()
|
||||
coro = self.wait_closed()
|
||||
task = asyncio.ensure_future(coro, loop=self.loop)
|
||||
return task
|
||||
|
||||
def start_serving(self):
|
||||
if self.server:
|
||||
try:
|
||||
return self.server.start_serving()
|
||||
except AttributeError:
|
||||
raise NotImplementedError(
|
||||
"server.start_serving not available in this version "
|
||||
"of asyncio or uvloop."
|
||||
)
|
||||
|
||||
def serve_forever(self):
|
||||
if self.server:
|
||||
try:
|
||||
return self.server.serve_forever()
|
||||
except AttributeError:
|
||||
raise NotImplementedError(
|
||||
"server.serve_forever not available in this version "
|
||||
"of asyncio or uvloop."
|
||||
)
|
||||
|
||||
def __await__(self):
|
||||
"""
|
||||
Starts the asyncio server, returns AsyncServerCoro
|
||||
"""
|
||||
task = asyncio.ensure_future(self.serve_coro)
|
||||
while not task.done():
|
||||
yield
|
||||
self.server = task.result()
|
||||
return self
|
||||
|
||||
|
||||
def serve(
|
||||
host,
|
||||
port,
|
||||
app,
|
||||
before_start: Optional[Iterable[ListenerType]] = None,
|
||||
after_start: Optional[Iterable[ListenerType]] = None,
|
||||
before_stop: Optional[Iterable[ListenerType]] = None,
|
||||
after_stop: Optional[Iterable[ListenerType]] = None,
|
||||
ssl: Optional[SSLContext] = None,
|
||||
sock: Optional[socket.socket] = None,
|
||||
unix: Optional[str] = None,
|
||||
reuse_port: bool = False,
|
||||
loop=None,
|
||||
protocol: Type[asyncio.Protocol] = HttpProtocol,
|
||||
backlog: int = 100,
|
||||
register_sys_signals: bool = True,
|
||||
run_multiple: bool = False,
|
||||
run_async: bool = False,
|
||||
connections=None,
|
||||
signal=Signal(),
|
||||
state=None,
|
||||
asyncio_server_kwargs=None,
|
||||
):
|
||||
"""Start asynchronous HTTP Server on an individual process.
|
||||
|
||||
:param host: Address to host on
|
||||
:param port: Port to host on
|
||||
:param before_start: function to be executed before the server starts
|
||||
listening. Takes arguments `app` instance and `loop`
|
||||
:param after_start: function to be executed after the server starts
|
||||
listening. Takes arguments `app` instance and `loop`
|
||||
:param before_stop: function to be executed when a stop signal is
|
||||
received before it is respected. Takes arguments
|
||||
`app` instance and `loop`
|
||||
:param after_stop: function to be executed when a stop signal is
|
||||
received after it is respected. Takes arguments
|
||||
`app` instance and `loop`
|
||||
:param ssl: SSLContext
|
||||
:param sock: Socket for the server to accept connections from
|
||||
:param unix: Unix socket to listen on instead of TCP port
|
||||
:param reuse_port: `True` for multiple workers
|
||||
:param loop: asyncio compatible event loop
|
||||
:param run_async: bool: Do not create a new event loop for the server,
|
||||
and return an AsyncServer object rather than running it
|
||||
:param asyncio_server_kwargs: key-value args for asyncio/uvloop
|
||||
create_server method
|
||||
:return: Nothing
|
||||
"""
|
||||
if not run_async and not loop:
|
||||
# create new event_loop after fork
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
if app.debug:
|
||||
loop.set_debug(app.debug)
|
||||
|
||||
app.asgi = False
|
||||
|
||||
connections = connections if connections is not None else set()
|
||||
protocol_kwargs = _build_protocol_kwargs(protocol, app.config)
|
||||
server = partial(
|
||||
protocol,
|
||||
loop=loop,
|
||||
connections=connections,
|
||||
signal=signal,
|
||||
app=app,
|
||||
state=state,
|
||||
unix=unix,
|
||||
**protocol_kwargs,
|
||||
)
|
||||
asyncio_server_kwargs = (
|
||||
asyncio_server_kwargs if asyncio_server_kwargs else {}
|
||||
)
|
||||
# UNIX sockets are always bound by us (to preserve semantics between modes)
|
||||
if unix:
|
||||
sock = bind_unix_socket(unix, backlog=backlog)
|
||||
server_coroutine = loop.create_server(
|
||||
server,
|
||||
None if sock else host,
|
||||
None if sock else port,
|
||||
ssl=ssl,
|
||||
reuse_port=reuse_port,
|
||||
sock=sock,
|
||||
backlog=backlog,
|
||||
**asyncio_server_kwargs,
|
||||
)
|
||||
|
||||
if run_async:
|
||||
return AsyncioServer(
|
||||
loop=loop,
|
||||
serve_coro=server_coroutine,
|
||||
connections=connections,
|
||||
after_start=after_start,
|
||||
before_stop=before_stop,
|
||||
after_stop=after_stop,
|
||||
)
|
||||
|
||||
trigger_events(before_start, loop)
|
||||
|
||||
try:
|
||||
http_server = loop.run_until_complete(server_coroutine)
|
||||
except BaseException:
|
||||
error_logger.exception("Unable to start server")
|
||||
return
|
||||
|
||||
trigger_events(after_start, loop)
|
||||
|
||||
# Ignore SIGINT when run_multiple
|
||||
if run_multiple:
|
||||
signal_func(SIGINT, SIG_IGN)
|
||||
|
||||
# Register signals for graceful termination
|
||||
if register_sys_signals:
|
||||
if OS_IS_WINDOWS:
|
||||
ctrlc_workaround_for_windows(app)
|
||||
else:
|
||||
for _signal in [SIGTERM] if run_multiple else [SIGINT, SIGTERM]:
|
||||
loop.add_signal_handler(_signal, app.stop)
|
||||
pid = os.getpid()
|
||||
try:
|
||||
logger.info("Starting worker [%s]", pid)
|
||||
loop.run_forever()
|
||||
finally:
|
||||
logger.info("Stopping worker [%s]", pid)
|
||||
|
||||
# Run the on_stop function if provided
|
||||
trigger_events(before_stop, loop)
|
||||
|
||||
# Wait for event loop to finish and all connections to drain
|
||||
http_server.close()
|
||||
loop.run_until_complete(http_server.wait_closed())
|
||||
|
||||
# Complete all tasks on the loop
|
||||
signal.stopped = True
|
||||
for connection in connections:
|
||||
connection.close_if_idle()
|
||||
|
||||
# Gracefully shutdown timeout.
|
||||
# We should provide graceful_shutdown_timeout,
|
||||
# instead of letting connection hangs forever.
|
||||
# Let's roughly calcucate time.
|
||||
graceful = app.config.GRACEFUL_SHUTDOWN_TIMEOUT
|
||||
start_shutdown: float = 0
|
||||
while connections and (start_shutdown < graceful):
|
||||
loop.run_until_complete(asyncio.sleep(0.1))
|
||||
start_shutdown = start_shutdown + 0.1
|
||||
|
||||
# Force close non-idle connection after waiting for
|
||||
# graceful_shutdown_timeout
|
||||
coros = []
|
||||
for conn in connections:
|
||||
if hasattr(conn, "websocket") and conn.websocket:
|
||||
coros.append(conn.websocket.close_connection())
|
||||
else:
|
||||
conn.close()
|
||||
|
||||
_shutdown = asyncio.gather(*coros)
|
||||
loop.run_until_complete(_shutdown)
|
||||
|
||||
trigger_events(after_stop, loop)
|
||||
|
||||
remove_unix_socket(unix)
|
||||
|
||||
|
||||
def _build_protocol_kwargs(
|
||||
protocol: Type[asyncio.Protocol], config: Config
|
||||
) -> Dict[str, Union[int, float]]:
|
||||
if hasattr(protocol, "websocket_handshake"):
|
||||
return {
|
||||
"websocket_max_size": config.WEBSOCKET_MAX_SIZE,
|
||||
"websocket_max_queue": config.WEBSOCKET_MAX_QUEUE,
|
||||
"websocket_read_limit": config.WEBSOCKET_READ_LIMIT,
|
||||
"websocket_write_limit": config.WEBSOCKET_WRITE_LIMIT,
|
||||
"websocket_ping_timeout": config.WEBSOCKET_PING_TIMEOUT,
|
||||
"websocket_ping_interval": config.WEBSOCKET_PING_INTERVAL,
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
def bind_socket(host: str, port: int, *, backlog=100) -> socket.socket:
|
||||
"""Create TCP server socket.
|
||||
:param host: IPv4, IPv6 or hostname may be specified
|
||||
:param port: TCP port number
|
||||
:param backlog: Maximum number of connections to queue
|
||||
:return: socket.socket object
|
||||
"""
|
||||
try: # IP address: family must be specified for IPv6 at least
|
||||
ip = ip_address(host)
|
||||
host = str(ip)
|
||||
sock = socket.socket(
|
||||
socket.AF_INET6 if ip.version == 6 else socket.AF_INET
|
||||
)
|
||||
except ValueError: # Hostname, may become AF_INET or AF_INET6
|
||||
sock = socket.socket()
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((host, port))
|
||||
sock.listen(backlog)
|
||||
return sock
|
||||
|
||||
|
||||
def bind_unix_socket(path: str, *, mode=0o666, backlog=100) -> socket.socket:
|
||||
"""Create unix socket.
|
||||
:param path: filesystem path
|
||||
:param backlog: Maximum number of connections to queue
|
||||
:return: socket.socket object
|
||||
"""
|
||||
"""Open or atomically replace existing socket with zero downtime."""
|
||||
# Sanitise and pre-verify socket path
|
||||
path = os.path.abspath(path)
|
||||
folder = os.path.dirname(path)
|
||||
if not os.path.isdir(folder):
|
||||
raise FileNotFoundError(f"Socket folder does not exist: {folder}")
|
||||
try:
|
||||
if not stat.S_ISSOCK(os.stat(path, follow_symlinks=False).st_mode):
|
||||
raise FileExistsError(f"Existing file is not a socket: {path}")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
# Create new socket with a random temporary name
|
||||
tmp_path = f"{path}.{secrets.token_urlsafe()}"
|
||||
sock = socket.socket(socket.AF_UNIX)
|
||||
try:
|
||||
# Critical section begins (filename races)
|
||||
sock.bind(tmp_path)
|
||||
try:
|
||||
os.chmod(tmp_path, mode)
|
||||
# Start listening before rename to avoid connection failures
|
||||
sock.listen(backlog)
|
||||
os.rename(tmp_path, path)
|
||||
except: # noqa: E722
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
finally:
|
||||
raise
|
||||
except: # noqa: E722
|
||||
try:
|
||||
sock.close()
|
||||
finally:
|
||||
raise
|
||||
return sock
|
||||
|
||||
|
||||
def remove_unix_socket(path: Optional[str]) -> None:
|
||||
"""Remove dead unix socket during server exit."""
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
if stat.S_ISSOCK(os.stat(path, follow_symlinks=False).st_mode):
|
||||
# Is it actually dead (doesn't belong to a new server instance)?
|
||||
with socket.socket(socket.AF_UNIX) as testsock:
|
||||
try:
|
||||
testsock.connect(path)
|
||||
except ConnectionRefusedError:
|
||||
os.unlink(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
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):
|
||||
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)
|
||||
26
sanic/server/__init__.py
Normal file
26
sanic/server/__init__.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import asyncio
|
||||
|
||||
from sanic.models.server_types import ConnInfo, Signal
|
||||
from sanic.server.async_server import AsyncioServer
|
||||
from sanic.server.protocols.http_protocol import HttpProtocol
|
||||
from sanic.server.runners import serve, serve_multiple, serve_single
|
||||
|
||||
|
||||
try:
|
||||
import uvloop # type: ignore
|
||||
|
||||
if not isinstance(asyncio.get_event_loop_policy(), uvloop.EventLoopPolicy):
|
||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
__all__ = (
|
||||
"AsyncioServer",
|
||||
"ConnInfo",
|
||||
"HttpProtocol",
|
||||
"Signal",
|
||||
"serve",
|
||||
"serve_multiple",
|
||||
"serve_single",
|
||||
)
|
||||
132
sanic/server/async_server.py
Normal file
132
sanic/server/async_server.py
Normal file
@@ -0,0 +1,132 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from warnings import warn
|
||||
|
||||
from sanic.exceptions import SanicException
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic import Sanic
|
||||
|
||||
|
||||
class AsyncioServer:
|
||||
"""
|
||||
Wraps an asyncio server with functionality that might be useful to
|
||||
a user who needs to manage the server lifecycle manually.
|
||||
"""
|
||||
|
||||
__slots__ = ("app", "connections", "loop", "serve_coro", "server")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: Sanic,
|
||||
loop,
|
||||
serve_coro,
|
||||
connections,
|
||||
):
|
||||
# Note, Sanic already called "before_server_start" events
|
||||
# before this helper was even created. So we don't need it here.
|
||||
self.app = app
|
||||
self.connections = connections
|
||||
self.loop = loop
|
||||
self.serve_coro = serve_coro
|
||||
self.server = None
|
||||
|
||||
@property
|
||||
def init(self):
|
||||
warn(
|
||||
"AsyncioServer.init has been deprecated and will be removed "
|
||||
"in v22.6. Use Sanic.state.is_started instead.",
|
||||
DeprecationWarning,
|
||||
)
|
||||
return self.app.state.is_started
|
||||
|
||||
def startup(self):
|
||||
"""
|
||||
Trigger "before_server_start" events
|
||||
"""
|
||||
return self.app._startup()
|
||||
|
||||
def before_start(self):
|
||||
"""
|
||||
Trigger "before_server_start" events
|
||||
"""
|
||||
return self._server_event("init", "before")
|
||||
|
||||
def after_start(self):
|
||||
"""
|
||||
Trigger "after_server_start" events
|
||||
"""
|
||||
return self._server_event("init", "after")
|
||||
|
||||
def before_stop(self):
|
||||
"""
|
||||
Trigger "before_server_stop" events
|
||||
"""
|
||||
return self._server_event("shutdown", "before")
|
||||
|
||||
def after_stop(self):
|
||||
"""
|
||||
Trigger "after_server_stop" events
|
||||
"""
|
||||
return self._server_event("shutdown", "after")
|
||||
|
||||
def is_serving(self) -> bool:
|
||||
if self.server:
|
||||
return self.server.is_serving()
|
||||
return False
|
||||
|
||||
def wait_closed(self):
|
||||
if self.server:
|
||||
return self.server.wait_closed()
|
||||
|
||||
def close(self):
|
||||
if self.server:
|
||||
self.server.close()
|
||||
coro = self.wait_closed()
|
||||
task = asyncio.ensure_future(coro, loop=self.loop)
|
||||
return task
|
||||
|
||||
def start_serving(self):
|
||||
return self._serve(self.server.start_serving)
|
||||
|
||||
def serve_forever(self):
|
||||
return self._serve(self.server.serve_forever)
|
||||
|
||||
def _serve(self, serve_func):
|
||||
if self.server:
|
||||
if not self.app.state.is_started:
|
||||
raise SanicException(
|
||||
"Cannot run Sanic server without first running "
|
||||
"await server.startup()"
|
||||
)
|
||||
|
||||
try:
|
||||
return serve_func()
|
||||
except AttributeError:
|
||||
name = serve_func.__name__
|
||||
raise NotImplementedError(
|
||||
f"server.{name} not available in this version "
|
||||
"of asyncio or uvloop."
|
||||
)
|
||||
|
||||
def _server_event(self, concern: str, action: str):
|
||||
if not self.app.state.is_started:
|
||||
raise SanicException(
|
||||
"Cannot dispatch server event without "
|
||||
"first running await server.startup()"
|
||||
)
|
||||
return self.app._server_event(concern, action, loop=self.loop)
|
||||
|
||||
def __await__(self):
|
||||
"""
|
||||
Starts the asyncio server, returns AsyncServerCoro
|
||||
"""
|
||||
task = asyncio.ensure_future(self.serve_coro)
|
||||
while not task.done():
|
||||
yield
|
||||
self.server = task.result()
|
||||
return self
|
||||
16
sanic/server/events.py
Normal file
16
sanic/server/events.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from inspect import isawaitable
|
||||
from typing import Any, Callable, Iterable, Optional
|
||||
|
||||
|
||||
def trigger_events(events: Optional[Iterable[Callable[..., Any]]], loop):
|
||||
"""
|
||||
Trigger event callbacks (functions or async)
|
||||
|
||||
:param events: one or more sync or async functions to execute
|
||||
:param loop: event loop
|
||||
"""
|
||||
if events:
|
||||
for event in events:
|
||||
result = event(loop)
|
||||
if isawaitable(result):
|
||||
loop.run_until_complete(result)
|
||||
0
sanic/server/protocols/__init__.py
Normal file
0
sanic/server/protocols/__init__.py
Normal file
143
sanic/server/protocols/base_protocol.py
Normal file
143
sanic/server/protocols/base_protocol.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic.app import Sanic
|
||||
|
||||
import asyncio
|
||||
|
||||
from asyncio import CancelledError
|
||||
from asyncio.transports import Transport
|
||||
from time import monotonic as current_time
|
||||
|
||||
from sanic.log import error_logger
|
||||
from sanic.models.server_types import ConnInfo, Signal
|
||||
|
||||
|
||||
class SanicProtocol(asyncio.Protocol):
|
||||
__slots__ = (
|
||||
"app",
|
||||
# event loop, connection
|
||||
"loop",
|
||||
"transport",
|
||||
"connections",
|
||||
"conn_info",
|
||||
"signal",
|
||||
"_can_write",
|
||||
"_time",
|
||||
"_task",
|
||||
"_unix",
|
||||
"_data_received",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
loop,
|
||||
app: Sanic,
|
||||
signal=None,
|
||||
connections=None,
|
||||
unix=None,
|
||||
**kwargs,
|
||||
):
|
||||
asyncio.set_event_loop(loop)
|
||||
self.loop = loop
|
||||
self.app: Sanic = app
|
||||
self.signal = signal or Signal()
|
||||
self.transport: Optional[Transport] = None
|
||||
self.connections = connections if connections is not None else set()
|
||||
self.conn_info: Optional[ConnInfo] = None
|
||||
self._can_write = asyncio.Event()
|
||||
self._can_write.set()
|
||||
self._unix = unix
|
||||
self._time = 0.0 # type: float
|
||||
self._task = None # type: Optional[asyncio.Task]
|
||||
self._data_received = asyncio.Event()
|
||||
|
||||
@property
|
||||
def ctx(self):
|
||||
if self.conn_info is not None:
|
||||
return self.conn_info.ctx
|
||||
else:
|
||||
return None
|
||||
|
||||
async def send(self, data):
|
||||
"""
|
||||
Generic data write implementation with backpressure control.
|
||||
"""
|
||||
await self._can_write.wait()
|
||||
if self.transport.is_closing():
|
||||
raise CancelledError
|
||||
self.transport.write(data)
|
||||
self._time = current_time()
|
||||
|
||||
async def receive_more(self):
|
||||
"""
|
||||
Wait until more data is received into the Server protocol's buffer
|
||||
"""
|
||||
self.transport.resume_reading()
|
||||
self._data_received.clear()
|
||||
await self._data_received.wait()
|
||||
|
||||
def close(self, timeout: Optional[float] = None):
|
||||
"""
|
||||
Attempt close the connection.
|
||||
"""
|
||||
# Cause a call to connection_lost where further cleanup occurs
|
||||
if self.transport:
|
||||
self.transport.close()
|
||||
if timeout is None:
|
||||
timeout = self.app.config.GRACEFUL_SHUTDOWN_TIMEOUT
|
||||
self.loop.call_later(timeout, self.abort)
|
||||
|
||||
def abort(self):
|
||||
"""
|
||||
Force close the connection.
|
||||
"""
|
||||
# Cause a call to connection_lost where further cleanup occurs
|
||||
if self.transport:
|
||||
self.transport.abort()
|
||||
self.transport = None
|
||||
|
||||
# asyncio.Protocol API Callbacks #
|
||||
# ------------------------------ #
|
||||
def connection_made(self, transport):
|
||||
"""
|
||||
Generic connection-made, with no connection_task, and no recv_buffer.
|
||||
Override this for protocol-specific connection implementations.
|
||||
"""
|
||||
try:
|
||||
transport.set_write_buffer_limits(low=16384, high=65536)
|
||||
self.connections.add(self)
|
||||
self.transport = transport
|
||||
self.conn_info = ConnInfo(self.transport, unix=self._unix)
|
||||
except Exception:
|
||||
error_logger.exception("protocol.connect_made")
|
||||
|
||||
def connection_lost(self, exc):
|
||||
try:
|
||||
self.connections.discard(self)
|
||||
self.resume_writing()
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
except BaseException:
|
||||
error_logger.exception("protocol.connection_lost")
|
||||
|
||||
def pause_writing(self):
|
||||
self._can_write.clear()
|
||||
|
||||
def resume_writing(self):
|
||||
self._can_write.set()
|
||||
|
||||
def data_received(self, data: bytes):
|
||||
try:
|
||||
self._time = current_time()
|
||||
if not data:
|
||||
return self.close()
|
||||
|
||||
if self._data_received:
|
||||
self._data_received.set()
|
||||
except BaseException:
|
||||
error_logger.exception("protocol.data_received")
|
||||
238
sanic/server/protocols/http_protocol.py
Normal file
238
sanic/server/protocols/http_protocol.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
from sanic.touchup.meta import TouchUpMeta
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic.app import Sanic
|
||||
|
||||
from asyncio import CancelledError
|
||||
from time import monotonic as current_time
|
||||
|
||||
from sanic.exceptions import RequestTimeout, ServiceUnavailable
|
||||
from sanic.http import Http, Stage
|
||||
from sanic.log import error_logger, logger
|
||||
from sanic.models.server_types import ConnInfo
|
||||
from sanic.request import Request
|
||||
from sanic.server.protocols.base_protocol import SanicProtocol
|
||||
|
||||
|
||||
class HttpProtocol(SanicProtocol, metaclass=TouchUpMeta):
|
||||
"""
|
||||
This class provides implements the HTTP 1.1 protocol on top of our
|
||||
Sanic Server transport
|
||||
"""
|
||||
|
||||
__touchup__ = (
|
||||
"send",
|
||||
"connection_task",
|
||||
)
|
||||
__slots__ = (
|
||||
# request params
|
||||
"request",
|
||||
# request config
|
||||
"request_handler",
|
||||
"request_timeout",
|
||||
"response_timeout",
|
||||
"keep_alive_timeout",
|
||||
"request_max_size",
|
||||
"request_class",
|
||||
"error_handler",
|
||||
# enable or disable access log purpose
|
||||
"access_log",
|
||||
# connection management
|
||||
"state",
|
||||
"url",
|
||||
"_handler_task",
|
||||
"_http",
|
||||
"_exception",
|
||||
"recv_buffer",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
loop,
|
||||
app: Sanic,
|
||||
signal=None,
|
||||
connections=None,
|
||||
state=None,
|
||||
unix=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
loop=loop,
|
||||
app=app,
|
||||
signal=signal,
|
||||
connections=connections,
|
||||
unix=unix,
|
||||
)
|
||||
self.url = None
|
||||
self.request: Optional[Request] = None
|
||||
self.access_log = self.app.config.ACCESS_LOG
|
||||
self.request_handler = self.app.handle_request
|
||||
self.error_handler = self.app.error_handler
|
||||
self.request_timeout = self.app.config.REQUEST_TIMEOUT
|
||||
self.response_timeout = self.app.config.RESPONSE_TIMEOUT
|
||||
self.keep_alive_timeout = self.app.config.KEEP_ALIVE_TIMEOUT
|
||||
self.request_max_size = self.app.config.REQUEST_MAX_SIZE
|
||||
self.request_class = self.app.request_class or Request
|
||||
self.state = state if state else {}
|
||||
if "requests_count" not in self.state:
|
||||
self.state["requests_count"] = 0
|
||||
self._exception = None
|
||||
|
||||
def _setup_connection(self):
|
||||
self._http = Http(self)
|
||||
self._time = current_time()
|
||||
self.check_timeouts()
|
||||
|
||||
async def connection_task(self): # no cov
|
||||
"""
|
||||
Run a HTTP connection.
|
||||
|
||||
Timeouts and some additional error handling occur here, while most of
|
||||
everything else happens in class Http or in code called from there.
|
||||
"""
|
||||
try:
|
||||
self._setup_connection()
|
||||
await self.app.dispatch(
|
||||
"http.lifecycle.begin",
|
||||
inline=True,
|
||||
context={"conn_info": self.conn_info},
|
||||
)
|
||||
await self._http.http1()
|
||||
except CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
error_logger.exception("protocol.connection_task uncaught")
|
||||
finally:
|
||||
if (
|
||||
self.app.debug
|
||||
and self._http
|
||||
and self.transport
|
||||
and not self._http.upgrade_websocket
|
||||
):
|
||||
ip = self.transport.get_extra_info("peername")
|
||||
error_logger.error(
|
||||
"Connection lost before response written"
|
||||
f" @ {ip} {self._http.request}"
|
||||
)
|
||||
self._http = None
|
||||
self._task = None
|
||||
try:
|
||||
self.close()
|
||||
except BaseException:
|
||||
error_logger.exception("Closing failed")
|
||||
finally:
|
||||
await self.app.dispatch(
|
||||
"http.lifecycle.complete",
|
||||
inline=True,
|
||||
context={"conn_info": self.conn_info},
|
||||
)
|
||||
# Important to keep this Ellipsis here for the TouchUp module
|
||||
...
|
||||
|
||||
def check_timeouts(self):
|
||||
"""
|
||||
Runs itself periodically to enforce any expired timeouts.
|
||||
"""
|
||||
try:
|
||||
if not self._task:
|
||||
return
|
||||
duration = current_time() - self._time
|
||||
stage = self._http.stage
|
||||
if stage is Stage.IDLE and duration > self.keep_alive_timeout:
|
||||
logger.debug("KeepAlive Timeout. Closing connection.")
|
||||
elif stage is Stage.REQUEST and duration > self.request_timeout:
|
||||
logger.debug("Request Timeout. Closing connection.")
|
||||
self._http.exception = RequestTimeout("Request Timeout")
|
||||
elif stage is Stage.HANDLER and self._http.upgrade_websocket:
|
||||
logger.debug("Handling websocket. Timeouts disabled.")
|
||||
return
|
||||
elif (
|
||||
stage in (Stage.HANDLER, Stage.RESPONSE, Stage.FAILED)
|
||||
and duration > self.response_timeout
|
||||
):
|
||||
logger.debug("Response Timeout. Closing connection.")
|
||||
self._http.exception = ServiceUnavailable("Response Timeout")
|
||||
else:
|
||||
interval = (
|
||||
min(
|
||||
self.keep_alive_timeout,
|
||||
self.request_timeout,
|
||||
self.response_timeout,
|
||||
)
|
||||
/ 2
|
||||
)
|
||||
self.loop.call_later(max(0.1, interval), self.check_timeouts)
|
||||
return
|
||||
self._task.cancel()
|
||||
except Exception:
|
||||
error_logger.exception("protocol.check_timeouts")
|
||||
|
||||
async def send(self, data): # no cov
|
||||
"""
|
||||
Writes HTTP data with backpressure control.
|
||||
"""
|
||||
await self._can_write.wait()
|
||||
if self.transport.is_closing():
|
||||
raise CancelledError
|
||||
await self.app.dispatch(
|
||||
"http.lifecycle.send",
|
||||
inline=True,
|
||||
context={"data": data},
|
||||
)
|
||||
self.transport.write(data)
|
||||
self._time = current_time()
|
||||
|
||||
def close_if_idle(self) -> bool:
|
||||
"""
|
||||
Close the connection if a request is not being sent or received
|
||||
|
||||
:return: boolean - True if closed, false if staying open
|
||||
"""
|
||||
if self._http is None or self._http.stage is Stage.IDLE:
|
||||
self.close()
|
||||
return True
|
||||
return False
|
||||
|
||||
# -------------------------------------------- #
|
||||
# Only asyncio.Protocol callbacks below this
|
||||
# -------------------------------------------- #
|
||||
|
||||
def connection_made(self, transport):
|
||||
"""
|
||||
HTTP-protocol-specific new connection handler
|
||||
"""
|
||||
try:
|
||||
# TODO: Benchmark to find suitable write buffer limits
|
||||
transport.set_write_buffer_limits(low=16384, high=65536)
|
||||
self.connections.add(self)
|
||||
self.transport = transport
|
||||
self._task = self.loop.create_task(self.connection_task())
|
||||
self.recv_buffer = bytearray()
|
||||
self.conn_info = ConnInfo(self.transport, unix=self._unix)
|
||||
except Exception:
|
||||
error_logger.exception("protocol.connect_made")
|
||||
|
||||
def data_received(self, data: bytes):
|
||||
|
||||
try:
|
||||
self._time = current_time()
|
||||
if not data:
|
||||
return self.close()
|
||||
self.recv_buffer += data
|
||||
|
||||
if (
|
||||
len(self.recv_buffer) >= self.app.config.REQUEST_BUFFER_SIZE
|
||||
and self.transport
|
||||
):
|
||||
self.transport.pause_reading()
|
||||
|
||||
if self._data_received:
|
||||
self._data_received.set()
|
||||
except Exception:
|
||||
error_logger.exception("protocol.data_received")
|
||||
161
sanic/server/protocols/websocket_protocol.py
Normal file
161
sanic/server/protocols/websocket_protocol.py
Normal file
@@ -0,0 +1,161 @@
|
||||
from typing import TYPE_CHECKING, Optional, Sequence, cast
|
||||
from warnings import warn
|
||||
|
||||
from websockets.connection import CLOSED, CLOSING, OPEN
|
||||
from websockets.server import ServerConnection
|
||||
from websockets.typing import Subprotocol
|
||||
|
||||
from sanic.exceptions import ServerError
|
||||
from sanic.log import error_logger
|
||||
from sanic.server import HttpProtocol
|
||||
|
||||
from ..websockets.impl import WebsocketImplProtocol
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from websockets import http11
|
||||
|
||||
|
||||
class WebSocketProtocol(HttpProtocol):
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
websocket_timeout: float = 10.0,
|
||||
websocket_max_size: Optional[int] = None,
|
||||
websocket_max_queue: Optional[int] = None, # max_queue is deprecated
|
||||
websocket_read_limit: Optional[int] = None, # read_limit is deprecated
|
||||
websocket_write_limit: Optional[int] = None, # write_limit deprecated
|
||||
websocket_ping_interval: Optional[float] = 20.0,
|
||||
websocket_ping_timeout: Optional[float] = 20.0,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.websocket: Optional[WebsocketImplProtocol] = None
|
||||
self.websocket_timeout = websocket_timeout
|
||||
self.websocket_max_size = websocket_max_size
|
||||
if websocket_max_queue is not None and websocket_max_queue > 0:
|
||||
# TODO: Reminder remove this warning in v22.3
|
||||
warn(
|
||||
"Websocket no longer uses queueing, so websocket_max_queue"
|
||||
" is no longer required.",
|
||||
DeprecationWarning,
|
||||
)
|
||||
if websocket_read_limit is not None and websocket_read_limit > 0:
|
||||
# TODO: Reminder remove this warning in v22.3
|
||||
warn(
|
||||
"Websocket no longer uses read buffers, so "
|
||||
"websocket_read_limit is not required.",
|
||||
DeprecationWarning,
|
||||
)
|
||||
if websocket_write_limit is not None and websocket_write_limit > 0:
|
||||
# TODO: Reminder remove this warning in v22.3
|
||||
warn(
|
||||
"Websocket no longer uses write buffers, so "
|
||||
"websocket_write_limit is not required.",
|
||||
DeprecationWarning,
|
||||
)
|
||||
self.websocket_ping_interval = websocket_ping_interval
|
||||
self.websocket_ping_timeout = websocket_ping_timeout
|
||||
|
||||
def connection_lost(self, exc):
|
||||
if self.websocket is not None:
|
||||
self.websocket.connection_lost(exc)
|
||||
super().connection_lost(exc)
|
||||
|
||||
def data_received(self, data):
|
||||
if self.websocket is not None:
|
||||
self.websocket.data_received(data)
|
||||
else:
|
||||
# Pass it to HttpProtocol handler first
|
||||
# That will (hopefully) upgrade it to a websocket.
|
||||
super().data_received(data)
|
||||
|
||||
def eof_received(self) -> Optional[bool]:
|
||||
if self.websocket is not None:
|
||||
return self.websocket.eof_received()
|
||||
else:
|
||||
return False
|
||||
|
||||
def close(self, timeout: Optional[float] = None):
|
||||
# Called by HttpProtocol at the end of connection_task
|
||||
# If we've upgraded to websocket, we do our own closing
|
||||
if self.websocket is not None:
|
||||
# Note, we don't want to use websocket.close()
|
||||
# That is used for user's application code to send a
|
||||
# websocket close packet. This is different.
|
||||
self.websocket.end_connection(1001)
|
||||
else:
|
||||
super().close()
|
||||
|
||||
def close_if_idle(self):
|
||||
# Called by Sanic Server when shutting down
|
||||
# If we've upgraded to websocket, shut it down
|
||||
if self.websocket is not None:
|
||||
if self.websocket.connection.state in (CLOSING, CLOSED):
|
||||
return True
|
||||
elif self.websocket.loop is not None:
|
||||
self.websocket.loop.create_task(self.websocket.close(1001))
|
||||
else:
|
||||
self.websocket.end_connection(1001)
|
||||
else:
|
||||
return super().close_if_idle()
|
||||
|
||||
async def websocket_handshake(
|
||||
self, request, subprotocols: Optional[Sequence[str]] = None
|
||||
):
|
||||
# let the websockets package do the handshake with the client
|
||||
try:
|
||||
if subprotocols is not None:
|
||||
# subprotocols can be a set or frozenset,
|
||||
# but ServerConnection needs a list
|
||||
subprotocols = cast(
|
||||
Optional[Sequence[Subprotocol]],
|
||||
list(
|
||||
[
|
||||
Subprotocol(subprotocol)
|
||||
for subprotocol in subprotocols
|
||||
]
|
||||
),
|
||||
)
|
||||
ws_conn = ServerConnection(
|
||||
max_size=self.websocket_max_size,
|
||||
subprotocols=subprotocols,
|
||||
state=OPEN,
|
||||
logger=error_logger,
|
||||
)
|
||||
resp: "http11.Response" = ws_conn.accept(request)
|
||||
except Exception:
|
||||
msg = (
|
||||
"Failed to open a WebSocket connection.\n"
|
||||
"See server log for more information.\n"
|
||||
)
|
||||
raise ServerError(msg, status_code=500)
|
||||
if 100 <= resp.status_code <= 299:
|
||||
first_line = (
|
||||
f"HTTP/1.1 {resp.status_code} {resp.reason_phrase}\r\n"
|
||||
).encode()
|
||||
rbody = bytearray(first_line)
|
||||
rbody += (
|
||||
"".join([f"{k}: {v}\r\n" for k, v in resp.headers.items()])
|
||||
).encode()
|
||||
rbody += b"\r\n"
|
||||
if resp.body is not None:
|
||||
rbody += resp.body
|
||||
rbody += b"\r\n\r\n"
|
||||
await super().send(rbody)
|
||||
else:
|
||||
raise ServerError(resp.body, resp.status_code)
|
||||
self.websocket = WebsocketImplProtocol(
|
||||
ws_conn,
|
||||
ping_interval=self.websocket_ping_interval,
|
||||
ping_timeout=self.websocket_ping_timeout,
|
||||
close_timeout=self.websocket_timeout,
|
||||
)
|
||||
loop = (
|
||||
request.transport.loop
|
||||
if hasattr(request, "transport")
|
||||
and hasattr(request.transport, "loop")
|
||||
else None
|
||||
)
|
||||
await self.websocket.connection_made(self, loop=loop)
|
||||
return self.websocket
|
||||
283
sanic/server/runners.py
Normal file
283
sanic/server/runners.py
Normal file
@@ -0,0 +1,283 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ssl import SSLContext
|
||||
from typing import TYPE_CHECKING, Dict, Optional, Type, Union
|
||||
|
||||
from sanic.config import Config
|
||||
from sanic.server.events import trigger_events
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic.app import Sanic
|
||||
|
||||
import asyncio
|
||||
import multiprocessing
|
||||
import os
|
||||
import socket
|
||||
|
||||
from functools import partial
|
||||
from signal import SIG_IGN, SIGINT, SIGTERM, Signals
|
||||
from signal import signal as signal_func
|
||||
|
||||
from sanic.compat import OS_IS_WINDOWS, ctrlc_workaround_for_windows
|
||||
from sanic.log import error_logger, logger
|
||||
from sanic.models.server_types import Signal
|
||||
from sanic.server.async_server import AsyncioServer
|
||||
from sanic.server.protocols.http_protocol import HttpProtocol
|
||||
from sanic.server.socket import (
|
||||
bind_socket,
|
||||
bind_unix_socket,
|
||||
remove_unix_socket,
|
||||
)
|
||||
|
||||
|
||||
def serve(
|
||||
host,
|
||||
port,
|
||||
app: Sanic,
|
||||
ssl: Optional[SSLContext] = None,
|
||||
sock: Optional[socket.socket] = None,
|
||||
unix: Optional[str] = None,
|
||||
reuse_port: bool = False,
|
||||
loop=None,
|
||||
protocol: Type[asyncio.Protocol] = HttpProtocol,
|
||||
backlog: int = 100,
|
||||
register_sys_signals: bool = True,
|
||||
run_multiple: bool = False,
|
||||
run_async: bool = False,
|
||||
connections=None,
|
||||
signal=Signal(),
|
||||
state=None,
|
||||
asyncio_server_kwargs=None,
|
||||
):
|
||||
"""Start asynchronous HTTP Server on an individual process.
|
||||
|
||||
:param host: Address to host on
|
||||
:param port: Port to host on
|
||||
:param before_start: function to be executed before the server starts
|
||||
listening. Takes arguments `app` instance and `loop`
|
||||
:param after_start: function to be executed after the server starts
|
||||
listening. Takes arguments `app` instance and `loop`
|
||||
:param before_stop: function to be executed when a stop signal is
|
||||
received before it is respected. Takes arguments
|
||||
`app` instance and `loop`
|
||||
:param after_stop: function to be executed when a stop signal is
|
||||
received after it is respected. Takes arguments
|
||||
`app` instance and `loop`
|
||||
:param ssl: SSLContext
|
||||
:param sock: Socket for the server to accept connections from
|
||||
:param unix: Unix socket to listen on instead of TCP port
|
||||
:param reuse_port: `True` for multiple workers
|
||||
:param loop: asyncio compatible event loop
|
||||
:param run_async: bool: Do not create a new event loop for the server,
|
||||
and return an AsyncServer object rather than running it
|
||||
:param asyncio_server_kwargs: key-value args for asyncio/uvloop
|
||||
create_server method
|
||||
:return: Nothing
|
||||
"""
|
||||
if not run_async and not loop:
|
||||
# create new event_loop after fork
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
if app.debug:
|
||||
loop.set_debug(app.debug)
|
||||
|
||||
app.asgi = False
|
||||
|
||||
connections = connections if connections is not None else set()
|
||||
protocol_kwargs = _build_protocol_kwargs(protocol, app.config)
|
||||
server = partial(
|
||||
protocol,
|
||||
loop=loop,
|
||||
connections=connections,
|
||||
signal=signal,
|
||||
app=app,
|
||||
state=state,
|
||||
unix=unix,
|
||||
**protocol_kwargs,
|
||||
)
|
||||
asyncio_server_kwargs = (
|
||||
asyncio_server_kwargs if asyncio_server_kwargs else {}
|
||||
)
|
||||
# UNIX sockets are always bound by us (to preserve semantics between modes)
|
||||
if unix:
|
||||
sock = bind_unix_socket(unix, backlog=backlog)
|
||||
server_coroutine = loop.create_server(
|
||||
server,
|
||||
None if sock else host,
|
||||
None if sock else port,
|
||||
ssl=ssl,
|
||||
reuse_port=reuse_port,
|
||||
sock=sock,
|
||||
backlog=backlog,
|
||||
**asyncio_server_kwargs,
|
||||
)
|
||||
|
||||
if run_async:
|
||||
return AsyncioServer(
|
||||
app=app,
|
||||
loop=loop,
|
||||
serve_coro=server_coroutine,
|
||||
connections=connections,
|
||||
)
|
||||
|
||||
loop.run_until_complete(app._startup())
|
||||
loop.run_until_complete(app._server_event("init", "before"))
|
||||
|
||||
try:
|
||||
http_server = loop.run_until_complete(server_coroutine)
|
||||
except BaseException:
|
||||
error_logger.exception("Unable to start server")
|
||||
return
|
||||
|
||||
# Ignore SIGINT when run_multiple
|
||||
if run_multiple:
|
||||
signal_func(SIGINT, SIG_IGN)
|
||||
os.environ["SANIC_WORKER_PROCESS"] = "true"
|
||||
|
||||
# Register signals for graceful termination
|
||||
if register_sys_signals:
|
||||
if OS_IS_WINDOWS:
|
||||
ctrlc_workaround_for_windows(app)
|
||||
else:
|
||||
for _signal in [SIGTERM] if run_multiple else [SIGINT, SIGTERM]:
|
||||
loop.add_signal_handler(_signal, app.stop)
|
||||
|
||||
loop.run_until_complete(app._server_event("init", "after"))
|
||||
pid = os.getpid()
|
||||
try:
|
||||
logger.info("Starting worker [%s]", pid)
|
||||
loop.run_forever()
|
||||
finally:
|
||||
logger.info("Stopping worker [%s]", pid)
|
||||
|
||||
# Run the on_stop function if provided
|
||||
loop.run_until_complete(app._server_event("shutdown", "before"))
|
||||
|
||||
# Wait for event loop to finish and all connections to drain
|
||||
http_server.close()
|
||||
loop.run_until_complete(http_server.wait_closed())
|
||||
|
||||
# Complete all tasks on the loop
|
||||
signal.stopped = True
|
||||
for connection in connections:
|
||||
connection.close_if_idle()
|
||||
|
||||
# Gracefully shutdown timeout.
|
||||
# We should provide graceful_shutdown_timeout,
|
||||
# instead of letting connection hangs forever.
|
||||
# Let's roughly calcucate time.
|
||||
graceful = app.config.GRACEFUL_SHUTDOWN_TIMEOUT
|
||||
start_shutdown: float = 0
|
||||
while connections and (start_shutdown < graceful):
|
||||
loop.run_until_complete(asyncio.sleep(0.1))
|
||||
start_shutdown = start_shutdown + 0.1
|
||||
|
||||
# Force close non-idle connection after waiting for
|
||||
# graceful_shutdown_timeout
|
||||
for conn in connections:
|
||||
if hasattr(conn, "websocket") and conn.websocket:
|
||||
conn.websocket.fail_connection(code=1001)
|
||||
else:
|
||||
conn.abort()
|
||||
loop.run_until_complete(app._server_event("shutdown", "after"))
|
||||
remove_unix_socket(unix)
|
||||
|
||||
|
||||
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):
|
||||
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(
|
||||
protocol: Type[asyncio.Protocol], config: Config
|
||||
) -> Dict[str, Union[int, float]]:
|
||||
if hasattr(protocol, "websocket_handshake"):
|
||||
return {
|
||||
"websocket_max_size": config.WEBSOCKET_MAX_SIZE,
|
||||
"websocket_ping_timeout": config.WEBSOCKET_PING_TIMEOUT,
|
||||
"websocket_ping_interval": config.WEBSOCKET_PING_INTERVAL,
|
||||
}
|
||||
return {}
|
||||
87
sanic/server/socket.py
Normal file
87
sanic/server/socket.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import stat
|
||||
|
||||
from ipaddress import ip_address
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def bind_socket(host: str, port: int, *, backlog=100) -> socket.socket:
|
||||
"""Create TCP server socket.
|
||||
:param host: IPv4, IPv6 or hostname may be specified
|
||||
:param port: TCP port number
|
||||
:param backlog: Maximum number of connections to queue
|
||||
:return: socket.socket object
|
||||
"""
|
||||
try: # IP address: family must be specified for IPv6 at least
|
||||
ip = ip_address(host)
|
||||
host = str(ip)
|
||||
sock = socket.socket(
|
||||
socket.AF_INET6 if ip.version == 6 else socket.AF_INET
|
||||
)
|
||||
except ValueError: # Hostname, may become AF_INET or AF_INET6
|
||||
sock = socket.socket()
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind((host, port))
|
||||
sock.listen(backlog)
|
||||
return sock
|
||||
|
||||
|
||||
def bind_unix_socket(path: str, *, mode=0o666, backlog=100) -> socket.socket:
|
||||
"""Create unix socket.
|
||||
:param path: filesystem path
|
||||
:param backlog: Maximum number of connections to queue
|
||||
:return: socket.socket object
|
||||
"""
|
||||
"""Open or atomically replace existing socket with zero downtime."""
|
||||
# Sanitise and pre-verify socket path
|
||||
path = os.path.abspath(path)
|
||||
folder = os.path.dirname(path)
|
||||
if not os.path.isdir(folder):
|
||||
raise FileNotFoundError(f"Socket folder does not exist: {folder}")
|
||||
try:
|
||||
if not stat.S_ISSOCK(os.stat(path, follow_symlinks=False).st_mode):
|
||||
raise FileExistsError(f"Existing file is not a socket: {path}")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
# Create new socket with a random temporary name
|
||||
tmp_path = f"{path}.{secrets.token_urlsafe()}"
|
||||
sock = socket.socket(socket.AF_UNIX)
|
||||
try:
|
||||
# Critical section begins (filename races)
|
||||
sock.bind(tmp_path)
|
||||
try:
|
||||
os.chmod(tmp_path, mode)
|
||||
# Start listening before rename to avoid connection failures
|
||||
sock.listen(backlog)
|
||||
os.rename(tmp_path, path)
|
||||
except: # noqa: E722
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
finally:
|
||||
raise
|
||||
except: # noqa: E722
|
||||
try:
|
||||
sock.close()
|
||||
finally:
|
||||
raise
|
||||
return sock
|
||||
|
||||
|
||||
def remove_unix_socket(path: Optional[str]) -> None:
|
||||
"""Remove dead unix socket during server exit."""
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
if stat.S_ISSOCK(os.stat(path, follow_symlinks=False).st_mode):
|
||||
# Is it actually dead (doesn't belong to a new server instance)?
|
||||
with socket.socket(socket.AF_UNIX) as testsock:
|
||||
try:
|
||||
testsock.connect(path)
|
||||
except ConnectionRefusedError:
|
||||
os.unlink(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
0
sanic/server/websockets/__init__.py
Normal file
0
sanic/server/websockets/__init__.py
Normal file
82
sanic/server/websockets/connection.py
Normal file
82
sanic/server/websockets/connection.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from typing import (
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
MutableMapping,
|
||||
Optional,
|
||||
Union,
|
||||
)
|
||||
|
||||
|
||||
ASIMessage = MutableMapping[str, Any]
|
||||
|
||||
|
||||
class WebSocketConnection:
|
||||
"""
|
||||
This is for ASGI Connections.
|
||||
It provides an interface similar to WebsocketProtocol, but
|
||||
sends/receives over an ASGI connection.
|
||||
"""
|
||||
|
||||
# TODO
|
||||
# - Implement ping/pong
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
send: Callable[[ASIMessage], Awaitable[None]],
|
||||
receive: Callable[[], Awaitable[ASIMessage]],
|
||||
subprotocols: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
self._send = send
|
||||
self._receive = receive
|
||||
self._subprotocols = subprotocols or []
|
||||
|
||||
async def send(self, data: Union[str, bytes], *args, **kwargs) -> None:
|
||||
message: Dict[str, Union[str, bytes]] = {"type": "websocket.send"}
|
||||
|
||||
if isinstance(data, bytes):
|
||||
message.update({"bytes": data})
|
||||
else:
|
||||
message.update({"text": str(data)})
|
||||
|
||||
await self._send(message)
|
||||
|
||||
async def recv(self, *args, **kwargs) -> Optional[str]:
|
||||
message = await self._receive()
|
||||
|
||||
if message["type"] == "websocket.receive":
|
||||
return message["text"]
|
||||
elif message["type"] == "websocket.disconnect":
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
receive = recv
|
||||
|
||||
async def accept(self, subprotocols: Optional[List[str]] = None) -> None:
|
||||
subprotocol = None
|
||||
if subprotocols:
|
||||
for subp in subprotocols:
|
||||
if subp in self.subprotocols:
|
||||
subprotocol = subp
|
||||
break
|
||||
|
||||
await self._send(
|
||||
{
|
||||
"type": "websocket.accept",
|
||||
"subprotocol": subprotocol,
|
||||
}
|
||||
)
|
||||
|
||||
async def close(self, code: int = 1000, reason: str = "") -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def subprotocols(self):
|
||||
return self._subprotocols
|
||||
|
||||
@subprotocols.setter
|
||||
def subprotocols(self, subprotocols: Optional[List[str]] = None):
|
||||
self._subprotocols = subprotocols or []
|
||||
294
sanic/server/websockets/frame.py
Normal file
294
sanic/server/websockets/frame.py
Normal file
@@ -0,0 +1,294 @@
|
||||
import asyncio
|
||||
import codecs
|
||||
|
||||
from typing import TYPE_CHECKING, AsyncIterator, List, Optional
|
||||
|
||||
from websockets.frames import Frame, Opcode
|
||||
from websockets.typing import Data
|
||||
|
||||
from sanic.exceptions import ServerError
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .impl import WebsocketImplProtocol
|
||||
|
||||
UTF8Decoder = codecs.getincrementaldecoder("utf-8")
|
||||
|
||||
|
||||
class WebsocketFrameAssembler:
|
||||
"""
|
||||
Assemble a message from frames.
|
||||
Code borrowed from aaugustin/websockets project:
|
||||
https://github.com/aaugustin/websockets/blob/6eb98dd8fa5b2c896b9f6be7e8d117708da82a39/src/websockets/sync/messages.py
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"protocol",
|
||||
"read_mutex",
|
||||
"write_mutex",
|
||||
"message_complete",
|
||||
"message_fetched",
|
||||
"get_in_progress",
|
||||
"decoder",
|
||||
"completed_queue",
|
||||
"chunks",
|
||||
"chunks_queue",
|
||||
"paused",
|
||||
"get_id",
|
||||
"put_id",
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
protocol: "WebsocketImplProtocol"
|
||||
read_mutex: asyncio.Lock
|
||||
write_mutex: asyncio.Lock
|
||||
message_complete: asyncio.Event
|
||||
message_fetched: asyncio.Event
|
||||
completed_queue: asyncio.Queue
|
||||
get_in_progress: bool
|
||||
decoder: Optional[codecs.IncrementalDecoder]
|
||||
# For streaming chunks rather than messages:
|
||||
chunks: List[Data]
|
||||
chunks_queue: Optional[asyncio.Queue[Optional[Data]]]
|
||||
paused: bool
|
||||
|
||||
def __init__(self, protocol) -> None:
|
||||
|
||||
self.protocol = protocol
|
||||
|
||||
self.read_mutex = asyncio.Lock()
|
||||
self.write_mutex = asyncio.Lock()
|
||||
|
||||
self.completed_queue = asyncio.Queue(
|
||||
maxsize=1
|
||||
) # type: asyncio.Queue[Data]
|
||||
|
||||
# put() sets this event to tell get() that a message can be fetched.
|
||||
self.message_complete = asyncio.Event()
|
||||
# get() sets this event to let put()
|
||||
self.message_fetched = asyncio.Event()
|
||||
|
||||
# This flag prevents concurrent calls to get() by user code.
|
||||
self.get_in_progress = False
|
||||
|
||||
# Decoder for text frames, None for binary frames.
|
||||
self.decoder = None
|
||||
|
||||
# Buffer data from frames belonging to the same message.
|
||||
self.chunks = []
|
||||
|
||||
# When switching from "buffering" to "streaming", we use a thread-safe
|
||||
# queue for transferring frames from the writing thread (library code)
|
||||
# to the reading thread (user code). We're buffering when chunks_queue
|
||||
# is None and streaming when it's a Queue. None is a sentinel
|
||||
# value marking the end of the stream, superseding message_complete.
|
||||
|
||||
# Stream data from frames belonging to the same message.
|
||||
self.chunks_queue = None
|
||||
|
||||
# Flag to indicate we've paused the protocol
|
||||
self.paused = False
|
||||
|
||||
async def get(self, timeout: Optional[float] = None) -> Optional[Data]:
|
||||
"""
|
||||
Read the next message.
|
||||
:meth:`get` returns a single :class:`str` or :class:`bytes`.
|
||||
If the :message was fragmented, :meth:`get` waits until the last frame
|
||||
is received, then it reassembles the message.
|
||||
If ``timeout`` is set and elapses before a complete message is
|
||||
received, :meth:`get` returns ``None``.
|
||||
"""
|
||||
async with self.read_mutex:
|
||||
if timeout is not None and timeout <= 0:
|
||||
if not self.message_complete.is_set():
|
||||
return None
|
||||
if self.get_in_progress:
|
||||
# This should be guarded against with the read_mutex,
|
||||
# exception is only here as a failsafe
|
||||
raise ServerError(
|
||||
"Called get() on Websocket frame assembler "
|
||||
"while asynchronous get is already in progress."
|
||||
)
|
||||
self.get_in_progress = True
|
||||
|
||||
# If the message_complete event isn't set yet, release the lock to
|
||||
# allow put() to run and eventually set it.
|
||||
# Locking with get_in_progress ensures only one task can get here.
|
||||
if timeout is None:
|
||||
completed = await self.message_complete.wait()
|
||||
elif timeout <= 0:
|
||||
completed = self.message_complete.is_set()
|
||||
else:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self.message_complete.wait(), timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
...
|
||||
finally:
|
||||
completed = self.message_complete.is_set()
|
||||
|
||||
# Unpause the transport, if its paused
|
||||
if self.paused:
|
||||
self.protocol.resume_frames()
|
||||
self.paused = False
|
||||
if not self.get_in_progress:
|
||||
# This should be guarded against with the read_mutex,
|
||||
# exception is here as a failsafe
|
||||
raise ServerError(
|
||||
"State of Websocket frame assembler was modified while an "
|
||||
"asynchronous get was in progress."
|
||||
)
|
||||
self.get_in_progress = False
|
||||
|
||||
# Waiting for a complete message timed out.
|
||||
if not completed:
|
||||
return None
|
||||
if not self.message_complete.is_set():
|
||||
return None
|
||||
|
||||
self.message_complete.clear()
|
||||
|
||||
joiner: Data = b"" if self.decoder is None else ""
|
||||
# mypy cannot figure out that chunks have the proper type.
|
||||
message: Data = joiner.join(self.chunks) # type: ignore
|
||||
if self.message_fetched.is_set():
|
||||
# This should be guarded against with the read_mutex,
|
||||
# and get_in_progress check, this exception is here
|
||||
# as a failsafe
|
||||
raise ServerError(
|
||||
"Websocket get() found a message when "
|
||||
"state was already fetched."
|
||||
)
|
||||
self.message_fetched.set()
|
||||
self.chunks = []
|
||||
# this should already be None, but set it here for safety
|
||||
self.chunks_queue = None
|
||||
return message
|
||||
|
||||
async def get_iter(self) -> AsyncIterator[Data]:
|
||||
"""
|
||||
Stream the next message.
|
||||
Iterating the return value of :meth:`get_iter` yields a :class:`str`
|
||||
or :class:`bytes` for each frame in the message.
|
||||
"""
|
||||
async with self.read_mutex:
|
||||
if self.get_in_progress:
|
||||
# This should be guarded against with the read_mutex,
|
||||
# exception is only here as a failsafe
|
||||
raise ServerError(
|
||||
"Called get_iter on Websocket frame assembler "
|
||||
"while asynchronous get is already in progress."
|
||||
)
|
||||
self.get_in_progress = True
|
||||
|
||||
chunks = self.chunks
|
||||
self.chunks = []
|
||||
self.chunks_queue = asyncio.Queue()
|
||||
|
||||
# Sending None in chunk_queue supersedes setting message_complete
|
||||
# when switching to "streaming". If message is already complete
|
||||
# when the switch happens, put() didn't send None, so we have to.
|
||||
if self.message_complete.is_set():
|
||||
await self.chunks_queue.put(None)
|
||||
|
||||
# Locking with get_in_progress ensures only one task can get here
|
||||
for c in chunks:
|
||||
yield c
|
||||
while True:
|
||||
chunk = await self.chunks_queue.get()
|
||||
if chunk is None:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
# Unpause the transport, if its paused
|
||||
if self.paused:
|
||||
self.protocol.resume_frames()
|
||||
self.paused = False
|
||||
if not self.get_in_progress:
|
||||
# This should be guarded against with the read_mutex,
|
||||
# exception is here as a failsafe
|
||||
raise ServerError(
|
||||
"State of Websocket frame assembler was modified while an "
|
||||
"asynchronous get was in progress."
|
||||
)
|
||||
self.get_in_progress = False
|
||||
if not self.message_complete.is_set():
|
||||
# This should be guarded against with the read_mutex,
|
||||
# exception is here as a failsafe
|
||||
raise ServerError(
|
||||
"Websocket frame assembler chunks queue ended before "
|
||||
"message was complete."
|
||||
)
|
||||
self.message_complete.clear()
|
||||
if self.message_fetched.is_set():
|
||||
# This should be guarded against with the read_mutex,
|
||||
# and get_in_progress check, this exception is
|
||||
# here as a failsafe
|
||||
raise ServerError(
|
||||
"Websocket get_iter() found a message when state was "
|
||||
"already fetched."
|
||||
)
|
||||
|
||||
self.message_fetched.set()
|
||||
# this should already be empty, but set it here for safety
|
||||
self.chunks = []
|
||||
self.chunks_queue = None
|
||||
|
||||
async def put(self, frame: Frame) -> None:
|
||||
"""
|
||||
Add ``frame`` to the next message.
|
||||
When ``frame`` is the final frame in a message, :meth:`put` waits
|
||||
until the message is fetched, either by calling :meth:`get` or by
|
||||
iterating the return value of :meth:`get_iter`.
|
||||
:meth:`put` assumes that the stream of frames respects the protocol.
|
||||
If it doesn't, the behavior is undefined.
|
||||
"""
|
||||
|
||||
async with self.write_mutex:
|
||||
if frame.opcode is Opcode.TEXT:
|
||||
self.decoder = UTF8Decoder(errors="strict")
|
||||
elif frame.opcode is Opcode.BINARY:
|
||||
self.decoder = None
|
||||
elif frame.opcode is Opcode.CONT:
|
||||
pass
|
||||
else:
|
||||
# Ignore control frames.
|
||||
return
|
||||
data: Data
|
||||
if self.decoder is not None:
|
||||
data = self.decoder.decode(frame.data, frame.fin)
|
||||
else:
|
||||
data = frame.data
|
||||
if self.chunks_queue is None:
|
||||
self.chunks.append(data)
|
||||
else:
|
||||
await self.chunks_queue.put(data)
|
||||
|
||||
if not frame.fin:
|
||||
return
|
||||
if not self.get_in_progress:
|
||||
# nobody is waiting for this frame, so try to pause subsequent
|
||||
# frames at the protocol level
|
||||
self.paused = self.protocol.pause_frames()
|
||||
# Message is complete. Wait until it's fetched to return.
|
||||
|
||||
if self.chunks_queue is not None:
|
||||
await self.chunks_queue.put(None)
|
||||
if self.message_complete.is_set():
|
||||
# This should be guarded against with the write_mutex
|
||||
raise ServerError(
|
||||
"Websocket put() got a new message when a message was "
|
||||
"already in its chamber."
|
||||
)
|
||||
self.message_complete.set() # Signal to get() it can serve the
|
||||
if self.message_fetched.is_set():
|
||||
# This should be guarded against with the write_mutex
|
||||
raise ServerError(
|
||||
"Websocket put() got a new message when the previous "
|
||||
"message was not yet fetched."
|
||||
)
|
||||
|
||||
# Allow get() to run and eventually set the event.
|
||||
await self.message_fetched.wait()
|
||||
self.message_fetched.clear()
|
||||
self.decoder = None
|
||||
834
sanic/server/websockets/impl.py
Normal file
834
sanic/server/websockets/impl.py
Normal file
@@ -0,0 +1,834 @@
|
||||
import asyncio
|
||||
import random
|
||||
import struct
|
||||
|
||||
from typing import (
|
||||
AsyncIterator,
|
||||
Dict,
|
||||
Iterable,
|
||||
Mapping,
|
||||
Optional,
|
||||
Sequence,
|
||||
Union,
|
||||
)
|
||||
|
||||
from websockets.connection import CLOSED, CLOSING, OPEN, Event
|
||||
from websockets.exceptions import ConnectionClosed, ConnectionClosedError
|
||||
from websockets.frames import Frame, Opcode
|
||||
from websockets.server import ServerConnection
|
||||
from websockets.typing import Data
|
||||
|
||||
from sanic.log import error_logger, logger
|
||||
from sanic.server.protocols.base_protocol import SanicProtocol
|
||||
|
||||
from ...exceptions import ServerError, WebsocketClosed
|
||||
from .frame import WebsocketFrameAssembler
|
||||
|
||||
|
||||
class WebsocketImplProtocol:
|
||||
connection: ServerConnection
|
||||
io_proto: Optional[SanicProtocol]
|
||||
loop: Optional[asyncio.AbstractEventLoop]
|
||||
max_queue: int
|
||||
close_timeout: float
|
||||
ping_interval: Optional[float]
|
||||
ping_timeout: Optional[float]
|
||||
assembler: WebsocketFrameAssembler
|
||||
# Dict[bytes, asyncio.Future[None]]
|
||||
pings: Dict[bytes, asyncio.Future]
|
||||
conn_mutex: asyncio.Lock
|
||||
recv_lock: asyncio.Lock
|
||||
recv_cancel: Optional[asyncio.Future]
|
||||
process_event_mutex: asyncio.Lock
|
||||
can_pause: bool
|
||||
# Optional[asyncio.Future[None]]
|
||||
data_finished_fut: Optional[asyncio.Future]
|
||||
# Optional[asyncio.Future[None]]
|
||||
pause_frame_fut: Optional[asyncio.Future]
|
||||
# Optional[asyncio.Future[None]]
|
||||
connection_lost_waiter: Optional[asyncio.Future]
|
||||
keepalive_ping_task: Optional[asyncio.Task]
|
||||
auto_closer_task: Optional[asyncio.Task]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connection,
|
||||
max_queue=None,
|
||||
ping_interval: Optional[float] = 20,
|
||||
ping_timeout: Optional[float] = 20,
|
||||
close_timeout: float = 10,
|
||||
loop=None,
|
||||
):
|
||||
self.connection = connection
|
||||
self.io_proto = None
|
||||
self.loop = None
|
||||
self.max_queue = max_queue
|
||||
self.close_timeout = close_timeout
|
||||
self.ping_interval = ping_interval
|
||||
self.ping_timeout = ping_timeout
|
||||
self.assembler = WebsocketFrameAssembler(self)
|
||||
self.pings = {}
|
||||
self.conn_mutex = asyncio.Lock()
|
||||
self.recv_lock = asyncio.Lock()
|
||||
self.recv_cancel = None
|
||||
self.process_event_mutex = asyncio.Lock()
|
||||
self.data_finished_fut = None
|
||||
self.can_pause = True
|
||||
self.pause_frame_fut = None
|
||||
self.keepalive_ping_task = None
|
||||
self.auto_closer_task = None
|
||||
self.connection_lost_waiter = None
|
||||
|
||||
@property
|
||||
def subprotocol(self):
|
||||
return self.connection.subprotocol
|
||||
|
||||
def pause_frames(self):
|
||||
if not self.can_pause:
|
||||
return False
|
||||
if self.pause_frame_fut:
|
||||
logger.debug("Websocket connection already paused.")
|
||||
return False
|
||||
if (not self.loop) or (not self.io_proto):
|
||||
return False
|
||||
if self.io_proto.transport:
|
||||
self.io_proto.transport.pause_reading()
|
||||
self.pause_frame_fut = self.loop.create_future()
|
||||
logger.debug("Websocket connection paused.")
|
||||
return True
|
||||
|
||||
def resume_frames(self):
|
||||
if not self.pause_frame_fut:
|
||||
logger.debug("Websocket connection not paused.")
|
||||
return False
|
||||
if (not self.loop) or (not self.io_proto):
|
||||
logger.debug(
|
||||
"Websocket attempting to resume reading frames, "
|
||||
"but connection is gone."
|
||||
)
|
||||
return False
|
||||
if self.io_proto.transport:
|
||||
self.io_proto.transport.resume_reading()
|
||||
self.pause_frame_fut.set_result(None)
|
||||
self.pause_frame_fut = None
|
||||
logger.debug("Websocket connection unpaused.")
|
||||
return True
|
||||
|
||||
async def connection_made(
|
||||
self,
|
||||
io_proto: SanicProtocol,
|
||||
loop: Optional[asyncio.AbstractEventLoop] = None,
|
||||
):
|
||||
if not loop:
|
||||
try:
|
||||
loop = getattr(io_proto, "loop")
|
||||
except AttributeError:
|
||||
loop = asyncio.get_event_loop()
|
||||
if not loop:
|
||||
# This catch is for mypy type checker
|
||||
# to assert loop is not None here.
|
||||
raise ServerError("Connection received with no asyncio loop.")
|
||||
if self.auto_closer_task:
|
||||
raise ServerError(
|
||||
"Cannot call connection_made more than once "
|
||||
"on a websocket connection."
|
||||
)
|
||||
self.loop = loop
|
||||
self.io_proto = io_proto
|
||||
self.connection_lost_waiter = self.loop.create_future()
|
||||
self.data_finished_fut = asyncio.shield(self.loop.create_future())
|
||||
|
||||
if self.ping_interval:
|
||||
self.keepalive_ping_task = asyncio.create_task(
|
||||
self.keepalive_ping()
|
||||
)
|
||||
self.auto_closer_task = asyncio.create_task(
|
||||
self.auto_close_connection()
|
||||
)
|
||||
|
||||
async def wait_for_connection_lost(self, timeout=None) -> bool:
|
||||
"""
|
||||
Wait until the TCP connection is closed or ``timeout`` elapses.
|
||||
If timeout is None, wait forever.
|
||||
Recommend you should pass in self.close_timeout as timeout
|
||||
|
||||
Return ``True`` if the connection is closed and ``False`` otherwise.
|
||||
|
||||
"""
|
||||
if not self.connection_lost_waiter:
|
||||
return False
|
||||
if self.connection_lost_waiter.done():
|
||||
return True
|
||||
else:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(self.connection_lost_waiter), timeout
|
||||
)
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
# Re-check self.connection_lost_waiter.done() synchronously
|
||||
# because connection_lost() could run between the moment the
|
||||
# timeout occurs and the moment this coroutine resumes running
|
||||
return self.connection_lost_waiter.done()
|
||||
|
||||
async def process_events(self, events: Sequence[Event]) -> None:
|
||||
"""
|
||||
Process a list of incoming events.
|
||||
"""
|
||||
# Wrapped in a mutex lock, to prevent other incoming events
|
||||
# from processing at the same time
|
||||
async with self.process_event_mutex:
|
||||
for event in events:
|
||||
if not isinstance(event, Frame):
|
||||
# Event is not a frame. Ignore it.
|
||||
continue
|
||||
if event.opcode == Opcode.PONG:
|
||||
await self.process_pong(event)
|
||||
elif event.opcode == Opcode.CLOSE:
|
||||
if self.recv_cancel:
|
||||
self.recv_cancel.cancel()
|
||||
else:
|
||||
await self.assembler.put(event)
|
||||
|
||||
async def process_pong(self, frame: Frame) -> None:
|
||||
if frame.data in self.pings:
|
||||
# Acknowledge all pings up to the one matching this pong.
|
||||
ping_ids = []
|
||||
for ping_id, ping in self.pings.items():
|
||||
ping_ids.append(ping_id)
|
||||
if not ping.done():
|
||||
ping.set_result(None)
|
||||
if ping_id == frame.data:
|
||||
break
|
||||
else: # noqa
|
||||
raise ServerError("ping_id is not in self.pings")
|
||||
# Remove acknowledged pings from self.pings.
|
||||
for ping_id in ping_ids:
|
||||
del self.pings[ping_id]
|
||||
|
||||
async def keepalive_ping(self) -> None:
|
||||
"""
|
||||
Send a Ping frame and wait for a Pong frame at regular intervals.
|
||||
This coroutine exits when the connection terminates and one of the
|
||||
following happens:
|
||||
- :meth:`ping` raises :exc:`ConnectionClosed`, or
|
||||
- :meth:`auto_close_connection` cancels :attr:`keepalive_ping_task`.
|
||||
"""
|
||||
if self.ping_interval is None:
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(self.ping_interval)
|
||||
|
||||
# ping() raises CancelledError if the connection is closed,
|
||||
# when auto_close_connection() cancels keepalive_ping_task.
|
||||
|
||||
# ping() raises ConnectionClosed if the connection is lost,
|
||||
# when connection_lost() calls abort_pings().
|
||||
|
||||
ping_waiter = await self.ping()
|
||||
|
||||
if self.ping_timeout is not None:
|
||||
try:
|
||||
await asyncio.wait_for(ping_waiter, self.ping_timeout)
|
||||
except asyncio.TimeoutError:
|
||||
error_logger.warning(
|
||||
"Websocket timed out waiting for pong"
|
||||
)
|
||||
self.fail_connection(1011)
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
# It is expected for this task to be cancelled during during
|
||||
# normal operation, when the connection is closed.
|
||||
logger.debug("Websocket keepalive ping task was cancelled.")
|
||||
except (ConnectionClosed, WebsocketClosed):
|
||||
logger.debug("Websocket closed. Keepalive ping task exiting.")
|
||||
except Exception as e:
|
||||
error_logger.warning(
|
||||
"Unexpected exception in websocket keepalive ping task."
|
||||
)
|
||||
logger.debug(str(e))
|
||||
|
||||
def _force_disconnect(self) -> bool:
|
||||
"""
|
||||
Internal methdod used by end_connection and fail_connection
|
||||
only when the graceful auto-closer cannot be used
|
||||
"""
|
||||
if self.auto_closer_task and not self.auto_closer_task.done():
|
||||
self.auto_closer_task.cancel()
|
||||
if self.data_finished_fut and not self.data_finished_fut.done():
|
||||
self.data_finished_fut.cancel()
|
||||
self.data_finished_fut = None
|
||||
if self.keepalive_ping_task and not self.keepalive_ping_task.done():
|
||||
self.keepalive_ping_task.cancel()
|
||||
self.keepalive_ping_task = None
|
||||
if self.loop and self.io_proto and self.io_proto.transport:
|
||||
self.io_proto.transport.close()
|
||||
self.loop.call_later(
|
||||
self.close_timeout, self.io_proto.transport.abort
|
||||
)
|
||||
# We were never open, or already closed
|
||||
return True
|
||||
|
||||
def fail_connection(self, code: int = 1006, reason: str = "") -> bool:
|
||||
"""
|
||||
Fail the WebSocket Connection
|
||||
This requires:
|
||||
1. Stopping all processing of incoming data, which means cancelling
|
||||
pausing the underlying io protocol. The close code will be 1006
|
||||
unless a close frame was received earlier.
|
||||
2. Sending a close frame with an appropriate code if the opening
|
||||
handshake succeeded and the other side is likely to process it.
|
||||
3. Closing the connection. :meth:`auto_close_connection` takes care
|
||||
of this.
|
||||
(The specification describes these steps in the opposite order.)
|
||||
"""
|
||||
if self.io_proto and self.io_proto.transport:
|
||||
# Stop new data coming in
|
||||
# In Python Version 3.7: pause_reading is idempotent
|
||||
# ut can be called when the transport is already paused or closed
|
||||
self.io_proto.transport.pause_reading()
|
||||
|
||||
# Keeping fail_connection() synchronous guarantees it can't
|
||||
# get stuck and simplifies the implementation of the callers.
|
||||
# Not draining the write buffer is acceptable in this context.
|
||||
|
||||
# clear the send buffer
|
||||
_ = self.connection.data_to_send()
|
||||
# If we're not already CLOSED or CLOSING, then send the close.
|
||||
if self.connection.state is OPEN:
|
||||
if code in (1000, 1001):
|
||||
self.connection.send_close(code, reason)
|
||||
else:
|
||||
self.connection.fail(code, reason)
|
||||
try:
|
||||
data_to_send = self.connection.data_to_send()
|
||||
while (
|
||||
len(data_to_send)
|
||||
and self.io_proto
|
||||
and self.io_proto.transport
|
||||
):
|
||||
frame_data = data_to_send.pop(0)
|
||||
self.io_proto.transport.write(frame_data)
|
||||
except Exception:
|
||||
# sending close frames may fail if the
|
||||
# transport closes during this period
|
||||
...
|
||||
if code == 1006:
|
||||
# Special case: 1006 consider the transport already closed
|
||||
self.connection.state = CLOSED
|
||||
if self.data_finished_fut and not self.data_finished_fut.done():
|
||||
# We have a graceful auto-closer. Use it to close the connection.
|
||||
self.data_finished_fut.cancel()
|
||||
self.data_finished_fut = None
|
||||
if (not self.auto_closer_task) or self.auto_closer_task.done():
|
||||
return self._force_disconnect()
|
||||
return False
|
||||
|
||||
def end_connection(self, code=1000, reason=""):
|
||||
# This is like slightly more graceful form of fail_connection
|
||||
# Use this instead of close() when you need an immediate
|
||||
# close and cannot await websocket.close() handshake.
|
||||
|
||||
if code == 1006 or not self.io_proto or not self.io_proto.transport:
|
||||
return self.fail_connection(code, reason)
|
||||
|
||||
# Stop new data coming in
|
||||
# In Python Version 3.7: pause_reading is idempotent
|
||||
# i.e. it can be called when the transport is already paused or closed.
|
||||
self.io_proto.transport.pause_reading()
|
||||
if self.connection.state == OPEN:
|
||||
data_to_send = self.connection.data_to_send()
|
||||
self.connection.send_close(code, reason)
|
||||
data_to_send.extend(self.connection.data_to_send())
|
||||
try:
|
||||
while (
|
||||
len(data_to_send)
|
||||
and self.io_proto
|
||||
and self.io_proto.transport
|
||||
):
|
||||
frame_data = data_to_send.pop(0)
|
||||
self.io_proto.transport.write(frame_data)
|
||||
except Exception:
|
||||
# sending close frames may fail if the
|
||||
# transport closes during this period
|
||||
# But that doesn't matter at this point
|
||||
...
|
||||
if self.data_finished_fut and not self.data_finished_fut.done():
|
||||
# We have the ability to signal the auto-closer
|
||||
# try to trigger it to auto-close the connection
|
||||
self.data_finished_fut.cancel()
|
||||
self.data_finished_fut = None
|
||||
if (not self.auto_closer_task) or self.auto_closer_task.done():
|
||||
# Auto-closer is not running, do force disconnect
|
||||
return self._force_disconnect()
|
||||
return False
|
||||
|
||||
async def auto_close_connection(self) -> None:
|
||||
"""
|
||||
Close the WebSocket Connection
|
||||
When the opening handshake succeeds, :meth:`connection_open` starts
|
||||
this coroutine in a task. It waits for the data transfer phase to
|
||||
complete then it closes the TCP connection cleanly.
|
||||
When the opening handshake fails, :meth:`fail_connection` does the
|
||||
same. There's no data transfer phase in that case.
|
||||
"""
|
||||
try:
|
||||
# Wait for the data transfer phase to complete.
|
||||
if self.data_finished_fut:
|
||||
try:
|
||||
await self.data_finished_fut
|
||||
logger.debug(
|
||||
"Websocket task finished. Closing the connection."
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# Cancelled error is called when data phase is cancelled
|
||||
# if an error occurred or the client closed the connection
|
||||
logger.debug(
|
||||
"Websocket handler cancelled. Closing the connection."
|
||||
)
|
||||
|
||||
# Cancel the keepalive ping task.
|
||||
if self.keepalive_ping_task:
|
||||
self.keepalive_ping_task.cancel()
|
||||
self.keepalive_ping_task = None
|
||||
|
||||
# Half-close the TCP connection if possible (when there's no TLS).
|
||||
if (
|
||||
self.io_proto
|
||||
and self.io_proto.transport
|
||||
and self.io_proto.transport.can_write_eof()
|
||||
):
|
||||
logger.debug("Websocket half-closing TCP connection")
|
||||
self.io_proto.transport.write_eof()
|
||||
if self.connection_lost_waiter:
|
||||
if await self.wait_for_connection_lost(timeout=0):
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
...
|
||||
finally:
|
||||
# The try/finally ensures that the transport never remains open,
|
||||
# even if this coroutine is cancelled (for example).
|
||||
if (not self.io_proto) or (not self.io_proto.transport):
|
||||
# we were never open, or done. Can't do any finalization.
|
||||
return
|
||||
elif (
|
||||
self.connection_lost_waiter
|
||||
and self.connection_lost_waiter.done()
|
||||
):
|
||||
# connection confirmed closed already, proceed to abort waiter
|
||||
...
|
||||
elif self.io_proto.transport.is_closing():
|
||||
# Connection is already closing (due to half-close above)
|
||||
# proceed to abort waiter
|
||||
...
|
||||
else:
|
||||
self.io_proto.transport.close()
|
||||
if not self.connection_lost_waiter:
|
||||
# Our connection monitor task isn't running.
|
||||
try:
|
||||
await asyncio.sleep(self.close_timeout)
|
||||
except asyncio.CancelledError:
|
||||
...
|
||||
if self.io_proto and self.io_proto.transport:
|
||||
self.io_proto.transport.abort()
|
||||
else:
|
||||
if await self.wait_for_connection_lost(
|
||||
timeout=self.close_timeout
|
||||
):
|
||||
# Connection aborted before the timeout expired.
|
||||
return
|
||||
error_logger.warning(
|
||||
"Timeout waiting for TCP connection to close. Aborting"
|
||||
)
|
||||
if self.io_proto and self.io_proto.transport:
|
||||
self.io_proto.transport.abort()
|
||||
|
||||
def abort_pings(self) -> None:
|
||||
"""
|
||||
Raise ConnectionClosed in pending keepalive pings.
|
||||
They'll never receive a pong once the connection is closed.
|
||||
"""
|
||||
if self.connection.state is not CLOSED:
|
||||
raise ServerError(
|
||||
"Webscoket about_pings should only be called "
|
||||
"after connection state is changed to CLOSED"
|
||||
)
|
||||
|
||||
for ping in self.pings.values():
|
||||
ping.set_exception(ConnectionClosedError(None, None))
|
||||
# If the exception is never retrieved, it will be logged when ping
|
||||
# is garbage-collected. This is confusing for users.
|
||||
# Given that ping is done (with an exception), canceling it does
|
||||
# nothing, but it prevents logging the exception.
|
||||
ping.cancel()
|
||||
|
||||
async def close(self, code: int = 1000, reason: str = "") -> None:
|
||||
"""
|
||||
Perform the closing handshake.
|
||||
This is a websocket-protocol level close.
|
||||
:meth:`close` waits for the other end to complete the handshake and
|
||||
for the TCP connection to terminate.
|
||||
:meth:`close` is idempotent: it doesn't do anything once the
|
||||
connection is closed.
|
||||
:param code: WebSocket close code
|
||||
:param reason: WebSocket close reason
|
||||
"""
|
||||
if code == 1006:
|
||||
self.fail_connection(code, reason)
|
||||
return
|
||||
async with self.conn_mutex:
|
||||
if self.connection.state is OPEN:
|
||||
self.connection.send_close(code, reason)
|
||||
data_to_send = self.connection.data_to_send()
|
||||
await self.send_data(data_to_send)
|
||||
|
||||
async def recv(self, timeout: Optional[float] = None) -> Optional[Data]:
|
||||
"""
|
||||
Receive the next message.
|
||||
Return a :class:`str` for a text frame and :class:`bytes` for a binary
|
||||
frame.
|
||||
When the end of the message stream is reached, :meth:`recv` raises
|
||||
:exc:`~websockets.exceptions.ConnectionClosed`. Specifically, it
|
||||
raises :exc:`~websockets.exceptions.ConnectionClosedOK` after a normal
|
||||
connection closure and
|
||||
:exc:`~websockets.exceptions.ConnectionClosedError` after a protocol
|
||||
error or a network failure.
|
||||
If ``timeout`` is ``None``, block until a message is received. Else,
|
||||
if no message is received within ``timeout`` seconds, return ``None``.
|
||||
Set ``timeout`` to ``0`` to check if a message was already received.
|
||||
:raises ~websockets.exceptions.ConnectionClosed: when the
|
||||
connection is closed
|
||||
:raises asyncio.CancelledError: if the websocket closes while waiting
|
||||
:raises ServerError: if two tasks call :meth:`recv` or
|
||||
:meth:`recv_streaming` concurrently
|
||||
"""
|
||||
|
||||
if self.recv_lock.locked():
|
||||
raise ServerError(
|
||||
"cannot call recv while another task is "
|
||||
"already waiting for the next message"
|
||||
)
|
||||
await self.recv_lock.acquire()
|
||||
if self.connection.state is CLOSED:
|
||||
self.recv_lock.release()
|
||||
raise WebsocketClosed(
|
||||
"Cannot receive from websocket interface after it is closed."
|
||||
)
|
||||
try:
|
||||
self.recv_cancel = asyncio.Future()
|
||||
done, pending = await asyncio.wait(
|
||||
(self.recv_cancel, self.assembler.get(timeout)),
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
done_task = next(iter(done))
|
||||
if done_task is self.recv_cancel:
|
||||
# recv was cancelled
|
||||
for p in pending:
|
||||
p.cancel()
|
||||
raise asyncio.CancelledError()
|
||||
else:
|
||||
self.recv_cancel.cancel()
|
||||
return done_task.result()
|
||||
finally:
|
||||
self.recv_cancel = None
|
||||
self.recv_lock.release()
|
||||
|
||||
async def recv_burst(self, max_recv=256) -> Sequence[Data]:
|
||||
"""
|
||||
Receive the messages which have arrived since last checking.
|
||||
Return a :class:`list` containing :class:`str` for a text frame
|
||||
and :class:`bytes` for a binary frame.
|
||||
When the end of the message stream is reached, :meth:`recv_burst`
|
||||
raises :exc:`~websockets.exceptions.ConnectionClosed`. Specifically,
|
||||
it raises :exc:`~websockets.exceptions.ConnectionClosedOK` after a
|
||||
normal connection closure and
|
||||
:exc:`~websockets.exceptions.ConnectionClosedError` after a protocol
|
||||
error or a network failure.
|
||||
:raises ~websockets.exceptions.ConnectionClosed: when the
|
||||
connection is closed
|
||||
:raises ServerError: if two tasks call :meth:`recv_burst` or
|
||||
:meth:`recv_streaming` concurrently
|
||||
"""
|
||||
|
||||
if self.recv_lock.locked():
|
||||
raise ServerError(
|
||||
"cannot call recv_burst while another task is already waiting "
|
||||
"for the next message"
|
||||
)
|
||||
await self.recv_lock.acquire()
|
||||
if self.connection.state is CLOSED:
|
||||
self.recv_lock.release()
|
||||
raise WebsocketClosed(
|
||||
"Cannot receive from websocket interface after it is closed."
|
||||
)
|
||||
messages = []
|
||||
try:
|
||||
# Prevent pausing the transport when we're
|
||||
# receiving a burst of messages
|
||||
self.can_pause = False
|
||||
self.recv_cancel = asyncio.Future()
|
||||
while True:
|
||||
done, pending = await asyncio.wait(
|
||||
(self.recv_cancel, self.assembler.get(timeout=0)),
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
done_task = next(iter(done))
|
||||
if done_task is self.recv_cancel:
|
||||
# recv_burst was cancelled
|
||||
for p in pending:
|
||||
p.cancel()
|
||||
raise asyncio.CancelledError()
|
||||
m = done_task.result()
|
||||
if m is None:
|
||||
# None left in the burst. This is good!
|
||||
break
|
||||
messages.append(m)
|
||||
if len(messages) >= max_recv:
|
||||
# Too much data in the pipe. Hit our burst limit.
|
||||
break
|
||||
# Allow an eventloop iteration for the
|
||||
# next message to pass into the Assembler
|
||||
await asyncio.sleep(0)
|
||||
self.recv_cancel.cancel()
|
||||
finally:
|
||||
self.recv_cancel = None
|
||||
self.can_pause = True
|
||||
self.recv_lock.release()
|
||||
return messages
|
||||
|
||||
async def recv_streaming(self) -> AsyncIterator[Data]:
|
||||
"""
|
||||
Receive the next message frame by frame.
|
||||
Return an iterator of :class:`str` for a text frame and :class:`bytes`
|
||||
for a binary frame. The iterator should be exhausted, or else the
|
||||
connection will become unusable.
|
||||
With the exception of the return value, :meth:`recv_streaming` behaves
|
||||
like :meth:`recv`.
|
||||
"""
|
||||
if self.recv_lock.locked():
|
||||
raise ServerError(
|
||||
"Cannot call recv_streaming while another task "
|
||||
"is already waiting for the next message"
|
||||
)
|
||||
await self.recv_lock.acquire()
|
||||
if self.connection.state is CLOSED:
|
||||
self.recv_lock.release()
|
||||
raise WebsocketClosed(
|
||||
"Cannot receive from websocket interface after it is closed."
|
||||
)
|
||||
try:
|
||||
cancelled = False
|
||||
self.recv_cancel = asyncio.Future()
|
||||
self.can_pause = False
|
||||
async for m in self.assembler.get_iter():
|
||||
if self.recv_cancel.done():
|
||||
cancelled = True
|
||||
break
|
||||
yield m
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError()
|
||||
finally:
|
||||
self.can_pause = True
|
||||
self.recv_cancel = None
|
||||
self.recv_lock.release()
|
||||
|
||||
async def send(self, message: Union[Data, Iterable[Data]]) -> None:
|
||||
"""
|
||||
Send a message.
|
||||
A string (:class:`str`) is sent as a `Text frame`_. A bytestring or
|
||||
bytes-like object (:class:`bytes`, :class:`bytearray`, or
|
||||
:class:`memoryview`) is sent as a `Binary frame`_.
|
||||
.. _Text frame: https://tools.ietf.org/html/rfc6455#section-5.6
|
||||
.. _Binary frame: https://tools.ietf.org/html/rfc6455#section-5.6
|
||||
:meth:`send` also accepts an iterable of strings, bytestrings, or
|
||||
bytes-like objects. In that case the message is fragmented. Each item
|
||||
is treated as a message fragment and sent in its own frame. All items
|
||||
must be of the same type, or else :meth:`send` will raise a
|
||||
:exc:`TypeError` and the connection will be closed.
|
||||
:meth:`send` rejects dict-like objects because this is often an error.
|
||||
If you wish to send the keys of a dict-like object as fragments, call
|
||||
its :meth:`~dict.keys` method and pass the result to :meth:`send`.
|
||||
:raises TypeError: for unsupported inputs
|
||||
"""
|
||||
async with self.conn_mutex:
|
||||
|
||||
if self.connection.state in (CLOSED, CLOSING):
|
||||
raise WebsocketClosed(
|
||||
"Cannot write to websocket interface after it is closed."
|
||||
)
|
||||
if (not self.data_finished_fut) or self.data_finished_fut.done():
|
||||
raise ServerError(
|
||||
"Cannot write to websocket interface after it is finished."
|
||||
)
|
||||
|
||||
# Unfragmented message -- this case must be handled first because
|
||||
# strings and bytes-like objects are iterable.
|
||||
|
||||
if isinstance(message, str):
|
||||
self.connection.send_text(message.encode("utf-8"))
|
||||
await self.send_data(self.connection.data_to_send())
|
||||
|
||||
elif isinstance(message, (bytes, bytearray, memoryview)):
|
||||
self.connection.send_binary(message)
|
||||
await self.send_data(self.connection.data_to_send())
|
||||
|
||||
elif isinstance(message, Mapping):
|
||||
# Catch a common mistake -- passing a dict to send().
|
||||
raise TypeError("data is a dict-like object")
|
||||
|
||||
elif isinstance(message, Iterable):
|
||||
# Fragmented message -- regular iterator.
|
||||
raise NotImplementedError(
|
||||
"Fragmented websocket messages are not supported."
|
||||
)
|
||||
else:
|
||||
raise TypeError("Websocket data must be bytes, str.")
|
||||
|
||||
async def ping(self, data: Optional[Data] = None) -> asyncio.Future:
|
||||
"""
|
||||
Send a ping.
|
||||
Return an :class:`~asyncio.Future` that will be resolved when the
|
||||
corresponding pong is received. You can ignore it if you don't intend
|
||||
to wait.
|
||||
A ping may serve as a keepalive or as a check that the remote endpoint
|
||||
received all messages up to this point::
|
||||
await pong_event = ws.ping()
|
||||
await pong_event # only if you want to wait for the pong
|
||||
By default, the ping contains four random bytes. This payload may be
|
||||
overridden with the optional ``data`` argument which must be a string
|
||||
(which will be encoded to UTF-8) or a bytes-like object.
|
||||
"""
|
||||
async with self.conn_mutex:
|
||||
if self.connection.state in (CLOSED, CLOSING):
|
||||
raise WebsocketClosed(
|
||||
"Cannot send a ping when the websocket interface "
|
||||
"is closed."
|
||||
)
|
||||
if (not self.io_proto) or (not self.io_proto.loop):
|
||||
raise ServerError(
|
||||
"Cannot send a ping when the websocket has no I/O "
|
||||
"protocol attached."
|
||||
)
|
||||
if data is not None:
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8")
|
||||
elif isinstance(data, (bytearray, memoryview)):
|
||||
data = bytes(data)
|
||||
|
||||
# Protect against duplicates if a payload is explicitly set.
|
||||
if data in self.pings:
|
||||
raise ValueError(
|
||||
"already waiting for a pong with the same data"
|
||||
)
|
||||
|
||||
# Generate a unique random payload otherwise.
|
||||
while data is None or data in self.pings:
|
||||
data = struct.pack("!I", random.getrandbits(32))
|
||||
|
||||
self.pings[data] = self.io_proto.loop.create_future()
|
||||
|
||||
self.connection.send_ping(data)
|
||||
await self.send_data(self.connection.data_to_send())
|
||||
|
||||
return asyncio.shield(self.pings[data])
|
||||
|
||||
async def pong(self, data: Data = b"") -> None:
|
||||
"""
|
||||
Send a pong.
|
||||
An unsolicited pong may serve as a unidirectional heartbeat.
|
||||
The payload may be set with the optional ``data`` argument which must
|
||||
be a string (which will be encoded to UTF-8) or a bytes-like object.
|
||||
"""
|
||||
async with self.conn_mutex:
|
||||
if self.connection.state in (CLOSED, CLOSING):
|
||||
# Cannot send pong after transport is shutting down
|
||||
return
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8")
|
||||
elif isinstance(data, (bytearray, memoryview)):
|
||||
data = bytes(data)
|
||||
self.connection.send_pong(data)
|
||||
await self.send_data(self.connection.data_to_send())
|
||||
|
||||
async def send_data(self, data_to_send):
|
||||
for data in data_to_send:
|
||||
if data:
|
||||
await self.io_proto.send(data)
|
||||
else:
|
||||
# Send an EOF - We don't actually send it,
|
||||
# just trigger to autoclose the connection
|
||||
if (
|
||||
self.auto_closer_task
|
||||
and not self.auto_closer_task.done()
|
||||
and self.data_finished_fut
|
||||
and not self.data_finished_fut.done()
|
||||
):
|
||||
# Auto-close the connection
|
||||
self.data_finished_fut.set_result(None)
|
||||
else:
|
||||
# This will fail the connection appropriately
|
||||
SanicProtocol.close(self.io_proto, timeout=1.0)
|
||||
|
||||
async def async_data_received(self, data_to_send, events_to_process):
|
||||
if self.connection.state in (OPEN, CLOSING) and len(data_to_send) > 0:
|
||||
# receiving data can generate data to send (eg, pong for a ping)
|
||||
# send connection.data_to_send()
|
||||
await self.send_data(data_to_send)
|
||||
if len(events_to_process) > 0:
|
||||
await self.process_events(events_to_process)
|
||||
|
||||
def data_received(self, data):
|
||||
self.connection.receive_data(data)
|
||||
data_to_send = self.connection.data_to_send()
|
||||
events_to_process = self.connection.events_received()
|
||||
if len(data_to_send) > 0 or len(events_to_process) > 0:
|
||||
asyncio.create_task(
|
||||
self.async_data_received(data_to_send, events_to_process)
|
||||
)
|
||||
|
||||
async def async_eof_received(self, data_to_send, events_to_process):
|
||||
# receiving EOF can generate data to send
|
||||
# send connection.data_to_send()
|
||||
if self.connection.state in (OPEN, CLOSING) and len(data_to_send) > 0:
|
||||
await self.send_data(data_to_send)
|
||||
if len(events_to_process) > 0:
|
||||
await self.process_events(events_to_process)
|
||||
if self.recv_cancel:
|
||||
self.recv_cancel.cancel()
|
||||
if (
|
||||
self.auto_closer_task
|
||||
and not self.auto_closer_task.done()
|
||||
and self.data_finished_fut
|
||||
and not self.data_finished_fut.done()
|
||||
):
|
||||
# Auto-close the connection
|
||||
self.data_finished_fut.set_result(None)
|
||||
# Cancel the running handler if its waiting
|
||||
else:
|
||||
# This will fail the connection appropriately
|
||||
SanicProtocol.close(self.io_proto, timeout=1.0)
|
||||
|
||||
def eof_received(self) -> Optional[bool]:
|
||||
self.connection.receive_eof()
|
||||
data_to_send = self.connection.data_to_send()
|
||||
events_to_process = self.connection.events_received()
|
||||
asyncio.create_task(
|
||||
self.async_eof_received(data_to_send, events_to_process)
|
||||
)
|
||||
return False
|
||||
|
||||
def connection_lost(self, exc):
|
||||
"""
|
||||
The WebSocket Connection is Closed.
|
||||
"""
|
||||
if not self.connection.state == CLOSED:
|
||||
# signal to the websocket connection handler
|
||||
# we've lost the connection
|
||||
self.connection.fail(code=1006)
|
||||
self.connection.state = CLOSED
|
||||
|
||||
self.abort_pings()
|
||||
if self.connection_lost_waiter:
|
||||
self.connection_lost_waiter.set_result(None)
|
||||
116
sanic/signals.py
116
sanic/signals.py
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from enum import Enum
|
||||
from inspect import isawaitable
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
@@ -10,13 +11,57 @@ from sanic_routing.exceptions import NotFound # type: ignore
|
||||
from sanic_routing.utils import path_to_parts # type: ignore
|
||||
|
||||
from sanic.exceptions import InvalidSignal
|
||||
from sanic.log import error_logger, logger
|
||||
from sanic.models.handler_types import SignalHandler
|
||||
|
||||
|
||||
RESERVED_NAMESPACES = (
|
||||
"server",
|
||||
"http",
|
||||
)
|
||||
class Event(Enum):
|
||||
SERVER_INIT_AFTER = "server.init.after"
|
||||
SERVER_INIT_BEFORE = "server.init.before"
|
||||
SERVER_SHUTDOWN_AFTER = "server.shutdown.after"
|
||||
SERVER_SHUTDOWN_BEFORE = "server.shutdown.before"
|
||||
HTTP_LIFECYCLE_BEGIN = "http.lifecycle.begin"
|
||||
HTTP_LIFECYCLE_COMPLETE = "http.lifecycle.complete"
|
||||
HTTP_LIFECYCLE_EXCEPTION = "http.lifecycle.exception"
|
||||
HTTP_LIFECYCLE_HANDLE = "http.lifecycle.handle"
|
||||
HTTP_LIFECYCLE_READ_BODY = "http.lifecycle.read_body"
|
||||
HTTP_LIFECYCLE_READ_HEAD = "http.lifecycle.read_head"
|
||||
HTTP_LIFECYCLE_REQUEST = "http.lifecycle.request"
|
||||
HTTP_LIFECYCLE_RESPONSE = "http.lifecycle.response"
|
||||
HTTP_ROUTING_AFTER = "http.routing.after"
|
||||
HTTP_ROUTING_BEFORE = "http.routing.before"
|
||||
HTTP_LIFECYCLE_SEND = "http.lifecycle.send"
|
||||
HTTP_MIDDLEWARE_AFTER = "http.middleware.after"
|
||||
HTTP_MIDDLEWARE_BEFORE = "http.middleware.before"
|
||||
|
||||
|
||||
RESERVED_NAMESPACES = {
|
||||
"server": (
|
||||
Event.SERVER_INIT_AFTER.value,
|
||||
Event.SERVER_INIT_BEFORE.value,
|
||||
Event.SERVER_SHUTDOWN_AFTER.value,
|
||||
Event.SERVER_SHUTDOWN_BEFORE.value,
|
||||
),
|
||||
"http": (
|
||||
Event.HTTP_LIFECYCLE_BEGIN.value,
|
||||
Event.HTTP_LIFECYCLE_COMPLETE.value,
|
||||
Event.HTTP_LIFECYCLE_EXCEPTION.value,
|
||||
Event.HTTP_LIFECYCLE_HANDLE.value,
|
||||
Event.HTTP_LIFECYCLE_READ_BODY.value,
|
||||
Event.HTTP_LIFECYCLE_READ_HEAD.value,
|
||||
Event.HTTP_LIFECYCLE_REQUEST.value,
|
||||
Event.HTTP_LIFECYCLE_RESPONSE.value,
|
||||
Event.HTTP_ROUTING_AFTER.value,
|
||||
Event.HTTP_ROUTING_BEFORE.value,
|
||||
Event.HTTP_LIFECYCLE_SEND.value,
|
||||
Event.HTTP_MIDDLEWARE_AFTER.value,
|
||||
Event.HTTP_MIDDLEWARE_BEFORE.value,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _blank():
|
||||
...
|
||||
|
||||
|
||||
class Signal(Route):
|
||||
@@ -59,8 +104,13 @@ class SignalRouter(BaseRouter):
|
||||
terms.append(extra)
|
||||
raise NotFound(message % tuple(terms))
|
||||
|
||||
# Regex routes evaluate and can extract params directly. They are set
|
||||
# on param_basket["__params__"]
|
||||
params = param_basket["__params__"]
|
||||
if not params:
|
||||
# If param_basket["__params__"] does not exist, we might have
|
||||
# param_basket["__matches__"], which are indexed based matches
|
||||
# on path segments. They should already be cast types.
|
||||
params = {
|
||||
param.name: param_basket["__matches__"][idx]
|
||||
for idx, param in group.params.items()
|
||||
@@ -73,8 +123,18 @@ class SignalRouter(BaseRouter):
|
||||
event: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
condition: Optional[Dict[str, str]] = None,
|
||||
) -> None:
|
||||
group, handlers, params = self.get(event, condition=condition)
|
||||
fail_not_found: bool = True,
|
||||
reverse: bool = False,
|
||||
) -> Any:
|
||||
try:
|
||||
group, handlers, params = self.get(event, condition=condition)
|
||||
except NotFound as e:
|
||||
if fail_not_found:
|
||||
raise e
|
||||
else:
|
||||
if self.ctx.app.debug and self.ctx.app.state.verbosity >= 1:
|
||||
error_logger.warning(str(e))
|
||||
return None
|
||||
|
||||
events = [signal.ctx.event for signal in group]
|
||||
for signal_event in events:
|
||||
@@ -82,12 +142,19 @@ class SignalRouter(BaseRouter):
|
||||
if context:
|
||||
params.update(context)
|
||||
|
||||
if not reverse:
|
||||
handlers = handlers[::-1]
|
||||
try:
|
||||
for handler in handlers:
|
||||
if condition is None or condition == handler.__requirements__:
|
||||
maybe_coroutine = handler(**params)
|
||||
if isawaitable(maybe_coroutine):
|
||||
await maybe_coroutine
|
||||
retval = await maybe_coroutine
|
||||
if retval:
|
||||
return retval
|
||||
elif maybe_coroutine:
|
||||
return maybe_coroutine
|
||||
return None
|
||||
finally:
|
||||
for signal_event in events:
|
||||
signal_event.clear()
|
||||
@@ -98,14 +165,23 @@ class SignalRouter(BaseRouter):
|
||||
*,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
condition: Optional[Dict[str, str]] = None,
|
||||
) -> asyncio.Task:
|
||||
task = self.ctx.loop.create_task(
|
||||
self._dispatch(
|
||||
event,
|
||||
context=context,
|
||||
condition=condition,
|
||||
)
|
||||
fail_not_found: bool = True,
|
||||
inline: bool = False,
|
||||
reverse: bool = False,
|
||||
) -> Union[asyncio.Task, Any]:
|
||||
dispatch = self._dispatch(
|
||||
event,
|
||||
context=context,
|
||||
condition=condition,
|
||||
fail_not_found=fail_not_found and inline,
|
||||
reverse=reverse,
|
||||
)
|
||||
logger.debug(f"Dispatching signal: {event}")
|
||||
|
||||
if inline:
|
||||
return await dispatch
|
||||
|
||||
task = asyncio.get_running_loop().create_task(dispatch)
|
||||
await asyncio.sleep(0)
|
||||
return task
|
||||
|
||||
@@ -131,7 +207,9 @@ class SignalRouter(BaseRouter):
|
||||
append=True,
|
||||
) # type: ignore
|
||||
|
||||
def finalize(self, do_compile: bool = True):
|
||||
def finalize(self, do_compile: bool = True, do_optimize: bool = False):
|
||||
self.add(_blank, "sanic.__signal__.__init__")
|
||||
|
||||
try:
|
||||
self.ctx.loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
@@ -140,7 +218,7 @@ class SignalRouter(BaseRouter):
|
||||
for signal in self.routes:
|
||||
signal.ctx.event = asyncio.Event()
|
||||
|
||||
return super().finalize(do_compile=do_compile)
|
||||
return super().finalize(do_compile=do_compile, do_optimize=do_optimize)
|
||||
|
||||
def _build_event_parts(self, event: str) -> Tuple[str, str, str]:
|
||||
parts = path_to_parts(event, self.delimiter)
|
||||
@@ -151,7 +229,11 @@ class SignalRouter(BaseRouter):
|
||||
):
|
||||
raise InvalidSignal("Invalid signal event: %s" % event)
|
||||
|
||||
if parts[0] in RESERVED_NAMESPACES:
|
||||
if (
|
||||
parts[0] in RESERVED_NAMESPACES
|
||||
and event not in RESERVED_NAMESPACES[parts[0]]
|
||||
and not (parts[2].startswith("<") and parts[2].endswith(">"))
|
||||
):
|
||||
raise InvalidSignal(
|
||||
"Cannot declare reserved signal event: %s" % event
|
||||
)
|
||||
|
||||
196
sanic/tls.py
Normal file
196
sanic/tls.py
Normal file
@@ -0,0 +1,196 @@
|
||||
import os
|
||||
import ssl
|
||||
|
||||
from typing import Iterable, Optional, Union
|
||||
|
||||
from sanic.log import logger
|
||||
|
||||
|
||||
# Only allow secure ciphers, notably leaving out AES-CBC mode
|
||||
# OpenSSL chooses ECDSA or RSA depending on the cert in use
|
||||
CIPHERS_TLS12 = [
|
||||
"ECDHE-ECDSA-CHACHA20-POLY1305",
|
||||
"ECDHE-ECDSA-AES256-GCM-SHA384",
|
||||
"ECDHE-ECDSA-AES128-GCM-SHA256",
|
||||
"ECDHE-RSA-CHACHA20-POLY1305",
|
||||
"ECDHE-RSA-AES256-GCM-SHA384",
|
||||
"ECDHE-RSA-AES128-GCM-SHA256",
|
||||
]
|
||||
|
||||
|
||||
def create_context(
|
||||
certfile: Optional[str] = None,
|
||||
keyfile: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
) -> ssl.SSLContext:
|
||||
"""Create a context with secure crypto and HTTP/1.1 in protocols."""
|
||||
context = ssl.create_default_context(purpose=ssl.Purpose.CLIENT_AUTH)
|
||||
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||
context.set_ciphers(":".join(CIPHERS_TLS12))
|
||||
context.set_alpn_protocols(["http/1.1"])
|
||||
context.sni_callback = server_name_callback
|
||||
if certfile and keyfile:
|
||||
context.load_cert_chain(certfile, keyfile, password)
|
||||
return context
|
||||
|
||||
|
||||
def shorthand_to_ctx(
|
||||
ctxdef: Union[None, ssl.SSLContext, dict, str]
|
||||
) -> Optional[ssl.SSLContext]:
|
||||
"""Convert an ssl argument shorthand to an SSLContext object."""
|
||||
if ctxdef is None or isinstance(ctxdef, ssl.SSLContext):
|
||||
return ctxdef
|
||||
if isinstance(ctxdef, str):
|
||||
return load_cert_dir(ctxdef)
|
||||
if isinstance(ctxdef, dict):
|
||||
return CertSimple(**ctxdef)
|
||||
raise ValueError(
|
||||
f"Invalid ssl argument {type(ctxdef)}."
|
||||
" Expecting a list of certdirs, a dict or an SSLContext."
|
||||
)
|
||||
|
||||
|
||||
def process_to_context(
|
||||
ssldef: Union[None, ssl.SSLContext, dict, str, list, tuple]
|
||||
) -> Optional[ssl.SSLContext]:
|
||||
"""Process app.run ssl argument from easy formats to full SSLContext."""
|
||||
return (
|
||||
CertSelector(map(shorthand_to_ctx, ssldef))
|
||||
if isinstance(ssldef, (list, tuple))
|
||||
else shorthand_to_ctx(ssldef)
|
||||
)
|
||||
|
||||
|
||||
def load_cert_dir(p: str) -> ssl.SSLContext:
|
||||
if os.path.isfile(p):
|
||||
raise ValueError(f"Certificate folder expected but {p} is a file.")
|
||||
keyfile = os.path.join(p, "privkey.pem")
|
||||
certfile = os.path.join(p, "fullchain.pem")
|
||||
if not os.access(keyfile, os.R_OK):
|
||||
raise ValueError(
|
||||
f"Certificate not found or permission denied {keyfile}"
|
||||
)
|
||||
if not os.access(certfile, os.R_OK):
|
||||
raise ValueError(
|
||||
f"Certificate not found or permission denied {certfile}"
|
||||
)
|
||||
return CertSimple(certfile, keyfile)
|
||||
|
||||
|
||||
class CertSimple(ssl.SSLContext):
|
||||
"""A wrapper for creating SSLContext with a sanic attribute."""
|
||||
|
||||
def __new__(cls, cert, key, **kw):
|
||||
# try common aliases, rename to cert/key
|
||||
certfile = kw["cert"] = kw.pop("certificate", None) or cert
|
||||
keyfile = kw["key"] = kw.pop("keyfile", None) or key
|
||||
password = kw.pop("password", None)
|
||||
if not certfile or not keyfile:
|
||||
raise ValueError("SSL dict needs filenames for cert and key.")
|
||||
subject = {}
|
||||
if "names" not in kw:
|
||||
cert = ssl._ssl._test_decode_cert(certfile) # type: ignore
|
||||
kw["names"] = [
|
||||
name
|
||||
for t, name in cert["subjectAltName"]
|
||||
if t in ["DNS", "IP Address"]
|
||||
]
|
||||
subject = {k: v for item in cert["subject"] for k, v in item}
|
||||
self = create_context(certfile, keyfile, password)
|
||||
self.__class__ = cls
|
||||
self.sanic = {**subject, **kw}
|
||||
return self
|
||||
|
||||
def __init__(self, cert, key, **kw):
|
||||
pass # Do not call super().__init__ because it is already initialized
|
||||
|
||||
|
||||
class CertSelector(ssl.SSLContext):
|
||||
"""Automatically select SSL certificate based on the hostname that the
|
||||
client is trying to access, via SSL SNI. Paths to certificate folders
|
||||
with privkey.pem and fullchain.pem in them should be provided, and
|
||||
will be matched in the order given whenever there is a new connection.
|
||||
"""
|
||||
|
||||
def __new__(cls, ctxs):
|
||||
return super().__new__(cls)
|
||||
|
||||
def __init__(self, ctxs: Iterable[Optional[ssl.SSLContext]]):
|
||||
super().__init__()
|
||||
self.sni_callback = selector_sni_callback # type: ignore
|
||||
self.sanic_select = []
|
||||
self.sanic_fallback = None
|
||||
all_names = []
|
||||
for i, ctx in enumerate(ctxs):
|
||||
if not ctx:
|
||||
continue
|
||||
names = dict(getattr(ctx, "sanic", {})).get("names", [])
|
||||
all_names += names
|
||||
self.sanic_select.append(ctx)
|
||||
if i == 0:
|
||||
self.sanic_fallback = ctx
|
||||
if not all_names:
|
||||
raise ValueError(
|
||||
"No certificates with SubjectAlternativeNames found."
|
||||
)
|
||||
logger.info(f"Certificate vhosts: {', '.join(all_names)}")
|
||||
|
||||
|
||||
def find_cert(self: CertSelector, server_name: str):
|
||||
"""Find the first certificate that matches the given SNI.
|
||||
|
||||
:raises ssl.CertificateError: No matching certificate found.
|
||||
:return: A matching ssl.SSLContext object if found."""
|
||||
if not server_name:
|
||||
if self.sanic_fallback:
|
||||
return self.sanic_fallback
|
||||
raise ValueError(
|
||||
"The client provided no SNI to match for certificate."
|
||||
)
|
||||
for ctx in self.sanic_select:
|
||||
if match_hostname(ctx, server_name):
|
||||
return ctx
|
||||
if self.sanic_fallback:
|
||||
return self.sanic_fallback
|
||||
raise ValueError(f"No certificate found matching hostname {server_name!r}")
|
||||
|
||||
|
||||
def match_hostname(
|
||||
ctx: Union[ssl.SSLContext, CertSelector], hostname: str
|
||||
) -> bool:
|
||||
"""Match names from CertSelector against a received hostname."""
|
||||
# Local certs are considered trusted, so this can be less pedantic
|
||||
# and thus faster than the deprecated ssl.match_hostname function is.
|
||||
names = dict(getattr(ctx, "sanic", {})).get("names", [])
|
||||
hostname = hostname.lower()
|
||||
for name in names:
|
||||
if name.startswith("*."):
|
||||
if hostname.split(".", 1)[-1] == name[2:]:
|
||||
return True
|
||||
elif name == hostname:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def selector_sni_callback(
|
||||
sslobj: ssl.SSLObject, server_name: str, ctx: CertSelector
|
||||
) -> Optional[int]:
|
||||
"""Select a certificate matching the SNI."""
|
||||
# Call server_name_callback to store the SNI on sslobj
|
||||
server_name_callback(sslobj, server_name, ctx)
|
||||
# Find a new context matching the hostname
|
||||
try:
|
||||
sslobj.context = find_cert(ctx, server_name)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Rejecting TLS connection: {e}")
|
||||
# This would show ERR_SSL_UNRECOGNIZED_NAME_ALERT on client side if
|
||||
# asyncio/uvloop did proper SSL shutdown. They don't.
|
||||
return ssl.ALERT_DESCRIPTION_UNRECOGNIZED_NAME
|
||||
return None # mypy complains without explicit return
|
||||
|
||||
|
||||
def server_name_callback(
|
||||
sslobj: ssl.SSLObject, server_name: str, ctx: ssl.SSLContext
|
||||
) -> None:
|
||||
"""Store the received SNI as sslobj.sanic_server_name."""
|
||||
sslobj.sanic_server_name = server_name # type: ignore
|
||||
8
sanic/touchup/__init__.py
Normal file
8
sanic/touchup/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from .meta import TouchUpMeta
|
||||
from .service import TouchUp
|
||||
|
||||
|
||||
__all__ = (
|
||||
"TouchUp",
|
||||
"TouchUpMeta",
|
||||
)
|
||||
22
sanic/touchup/meta.py
Normal file
22
sanic/touchup/meta.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from sanic.exceptions import SanicException
|
||||
|
||||
from .service import TouchUp
|
||||
|
||||
|
||||
class TouchUpMeta(type):
|
||||
def __new__(cls, name, bases, attrs, **kwargs):
|
||||
gen_class = super().__new__(cls, name, bases, attrs, **kwargs)
|
||||
|
||||
methods = attrs.get("__touchup__")
|
||||
attrs["__touched__"] = False
|
||||
if methods:
|
||||
|
||||
for method in methods:
|
||||
if method not in attrs:
|
||||
raise SanicException(
|
||||
"Cannot perform touchup on non-existent method: "
|
||||
f"{name}.{method}"
|
||||
)
|
||||
TouchUp.register(gen_class, method)
|
||||
|
||||
return gen_class
|
||||
5
sanic/touchup/schemes/__init__.py
Normal file
5
sanic/touchup/schemes/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .base import BaseScheme
|
||||
from .ode import OptionalDispatchEvent # noqa
|
||||
|
||||
|
||||
__all__ = ("BaseScheme",)
|
||||
20
sanic/touchup/schemes/base.py
Normal file
20
sanic/touchup/schemes/base.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Set, Type
|
||||
|
||||
|
||||
class BaseScheme(ABC):
|
||||
ident: str
|
||||
_registry: Set[Type] = set()
|
||||
|
||||
def __init__(self, app) -> None:
|
||||
self.app = app
|
||||
|
||||
@abstractmethod
|
||||
def run(self, method, module_globals) -> None:
|
||||
...
|
||||
|
||||
def __init_subclass__(cls):
|
||||
BaseScheme._registry.add(cls)
|
||||
|
||||
def __call__(self, method, module_globals):
|
||||
return self.run(method, module_globals)
|
||||
71
sanic/touchup/schemes/ode.py
Normal file
71
sanic/touchup/schemes/ode.py
Normal file
@@ -0,0 +1,71 @@
|
||||
from ast import Attribute, Await, Dict, Expr, NodeTransformer, parse
|
||||
from inspect import getsource
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
|
||||
from sanic.log import logger
|
||||
|
||||
from .base import BaseScheme
|
||||
|
||||
|
||||
class OptionalDispatchEvent(BaseScheme):
|
||||
ident = "ODE"
|
||||
|
||||
def __init__(self, app) -> None:
|
||||
super().__init__(app)
|
||||
|
||||
self._registered_events = [
|
||||
signal.path for signal in app.signal_router.routes
|
||||
]
|
||||
|
||||
def run(self, method, module_globals):
|
||||
raw_source = getsource(method)
|
||||
src = dedent(raw_source)
|
||||
tree = parse(src)
|
||||
node = RemoveDispatch(
|
||||
self._registered_events, self.app.state.verbosity
|
||||
).visit(tree)
|
||||
compiled_src = compile(node, method.__name__, "exec")
|
||||
exec_locals: Dict[str, Any] = {}
|
||||
exec(compiled_src, module_globals, exec_locals) # nosec
|
||||
|
||||
return exec_locals[method.__name__]
|
||||
|
||||
|
||||
class RemoveDispatch(NodeTransformer):
|
||||
def __init__(self, registered_events, verbosity: int = 0) -> None:
|
||||
self._registered_events = registered_events
|
||||
self._verbosity = verbosity
|
||||
|
||||
def visit_Expr(self, node: Expr) -> Any:
|
||||
call = node.value
|
||||
if isinstance(call, Await):
|
||||
call = call.value
|
||||
|
||||
func = getattr(call, "func", None)
|
||||
args = getattr(call, "args", None)
|
||||
if not func or not args:
|
||||
return node
|
||||
|
||||
if isinstance(func, Attribute) and func.attr == "dispatch":
|
||||
event = args[0]
|
||||
if hasattr(event, "s"):
|
||||
event_name = getattr(event, "value", event.s)
|
||||
if self._not_registered(event_name):
|
||||
if self._verbosity >= 2:
|
||||
logger.debug(f"Disabling event: {event_name}")
|
||||
return None
|
||||
return node
|
||||
|
||||
def _not_registered(self, event_name):
|
||||
dynamic = []
|
||||
for event in self._registered_events:
|
||||
if event.endswith(">"):
|
||||
namespace_concern, _ = event.rsplit(".", 1)
|
||||
dynamic.append(namespace_concern)
|
||||
|
||||
namespace_concern, _ = event_name.rsplit(".", 1)
|
||||
return (
|
||||
event_name not in self._registered_events
|
||||
and namespace_concern not in dynamic
|
||||
)
|
||||
33
sanic/touchup/service.py
Normal file
33
sanic/touchup/service.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from inspect import getmembers, getmodule
|
||||
from typing import Set, Tuple, Type
|
||||
|
||||
from .schemes import BaseScheme
|
||||
|
||||
|
||||
class TouchUp:
|
||||
_registry: Set[Tuple[Type, str]] = set()
|
||||
|
||||
@classmethod
|
||||
def run(cls, app):
|
||||
for target, method_name in cls._registry:
|
||||
method = getattr(target, method_name)
|
||||
|
||||
if app.test_mode:
|
||||
placeholder = f"_{method_name}"
|
||||
if hasattr(target, placeholder):
|
||||
method = getattr(target, placeholder)
|
||||
else:
|
||||
setattr(target, placeholder, method)
|
||||
|
||||
module = getmodule(target)
|
||||
module_globals = dict(getmembers(module))
|
||||
|
||||
for scheme in BaseScheme._registry:
|
||||
modified = scheme(app)(method, module_globals)
|
||||
setattr(target, method_name, modified)
|
||||
|
||||
target.__touched__ = True
|
||||
|
||||
@classmethod
|
||||
def register(cls, target, method_name):
|
||||
cls._registry.add((target, method_name))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user