Compare commits

...

78 Commits

Author SHA1 Message Date
Néstor Pérez
fc82b2334b Fix JSONResponse default content type (#2738)
Fix JSONResponse default content type (#2737)
2023-07-10 11:57:27 +03:00
Adam Hopkins
4ad8168bb0 Version 22.12 release notes (#2637) 2022-12-27 16:50:36 +02:00
Adam Hopkins
28f5b3c301 Add better inspector arg parsing (#2642) 2022-12-26 12:27:40 +02:00
Adam Hopkins
c573019e7f ASGI websocket recv text or bytes (#2640) 2022-12-25 13:52:07 +02:00
Adam Hopkins
029f564032 Pass unquote thru add_route (#2639) 2022-12-21 10:45:23 +02:00
Adam Hopkins
2abe66b670 Add priority to register_middleware method (#2636) 2022-12-19 19:14:46 +02:00
Adam Hopkins
911485d52e Fix Windows sock share (#2635) 2022-12-18 15:04:10 +02:00
Adam Hopkins
4744a89c33 Fix double ctrl-c kill (#2634) 2022-12-18 14:40:38 +02:00
Adam Hopkins
f7040ccec8 Implement restart ordering (#2632) 2022-12-18 14:09:17 +02:00
Adam Hopkins
518152d97e Reload interval on class variable (#2633) 2022-12-18 13:36:54 +02:00
Adam Hopkins
0e44e9cacb Move to HTTP Inspector (#2626) 2022-12-18 10:29:58 +02:00
Adam Hopkins
bfb54b0969 Test for 3.11 support (#2612)
Co-authored-by: Zhiwei <zhi.wei.liang@outlook.com>
2022-12-17 23:46:22 +02:00
Zhiwei
154863d6c6 Method Signal Handler Test (#2630) 2022-12-17 20:38:46 +02:00
Adam Hopkins
a3ff0c13b7 ASGI lifespan failure on exception (#2627) 2022-12-16 08:56:07 +02:00
Mary
95ee518aec Replace deprecated distutils.strtobool (#2628) 2022-12-16 07:48:41 +02:00
Zhiwei
71d3d87bcc Deprecate Conditions and Triggers Saved in handler Callable; Save Condition in signal.extra Instead (#2608) 2022-12-15 12:32:07 +02:00
Adam Hopkins
b276b91c21 Allow fork in limited cases (#2624) 2022-12-15 11:49:26 +02:00
Adam Hopkins
064168f3c8 Add a SIGKILL to second ctrl+c (#2621) 2022-12-14 23:51:11 +02:00
Adam Hopkins
db39e127bf Scale workers (#2617) 2022-12-13 09:28:23 +02:00
L. Kärkkäinen
13e9ab7ba9 Filename normalisation of form-data/multipart file uploads (umlauts on Apple clients) (#2625)
Co-authored-by: L. Karkkainen <tronic@users.noreply.github.com>
2022-12-13 08:36:21 +02:00
Adam Hopkins
92e7463721 Add a restart mechanism to all workers in the multiplexer (#2622) 2022-12-11 11:33:42 +02:00
Néstor Pérez
8e720365c2 Add JSONResponse class (#2569)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2022-12-11 10:37:45 +02:00
Adam Hopkins
d4041161c7 Ensure middleware executes once per request timeout (#2615) 2022-12-07 23:07:17 +02:00
Adam Hopkins
f32437bf13 Kill server early on worker error (#2610) 2022-12-07 14:42:17 +02:00
LiraNuna
0909e94527 Corrected Colors enum under Python 3.11 (#2590)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
Fixes https://github.com/sanic-org/sanic/issues/2589
2022-11-29 12:17:48 +02:00
Adam Hopkins
aef2673c38 Force socket shutdown before close (#2607)
Co-authored-by: Zhiwei <zhi.wei.liang@outlook.com>
2022-11-29 12:04:22 +02:00
Aymeric Augustin
4c14910d5b Add compatibility with websockets 11.0. (#2609)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2022-11-29 11:45:18 +02:00
Adam Hopkins
beae35f921 Ignore recent failures on bad TLS tests (#2611) 2022-11-29 10:51:51 +02:00
Zhiwei
ad4e526c77 Require uvloop >= 0.15.0 (#2598) 2022-11-13 15:32:04 +02:00
Adam Hopkins
4422d0c34d Mergeback from current-release 2022-10-31 13:24:47 +02:00
Adam Hopkins
ad9183d21d Merge branch 'main' of github.com:sanic-org/sanic into current-release 2022-10-31 13:22:47 +02:00
Adam Hopkins
d70636ba2e Add GenericCreator for loading SSL certs in processes (#2578) 2022-10-31 13:22:30 +02:00
Adam Hopkins
da23f85675 Set version 2022-10-31 13:20:17 +02:00
Adam Hopkins
3f4663b9f8 Resolve edge case in nested BP Groups (#2592) 2022-10-31 12:58:41 +02:00
Adam Hopkins
65d7447cf6 Add interval sleep in reloader (#2595) 2022-10-31 12:34:01 +02:00
Adam Hopkins
5369291c27 22.9 Docs (#2556) 2022-10-31 11:47:23 +02:00
Ryu Juheon
1c4925edf7 fix: sideeffects created by changing fork to spawn (#2591) 2022-10-27 20:39:17 +03:00
Santi Cardozo
6b9edfd05c improve error message if no apps found in registry (#2585) 2022-10-25 16:54:44 +03:00
Adam Hopkins
97f33f42df Update SECURITY.md 2022-10-25 13:05:13 +03:00
Adam Hopkins
15a588a90c Upgrade markdown templates to issue forms (#2588) 2022-10-25 13:04:11 +03:00
Ryu Juheon
82421e7efc docs: sanic now supports windows. (#2582) 2022-10-21 14:31:22 +03:00
Adam Hopkins
f891995b48 Start v22.12 2022-09-29 13:04:46 +03:00
Adam Hopkins
5052321801 Remove deprecated items (#2555) 2022-09-29 01:07:09 +03:00
Adam Hopkins
23ce4eaaa4 Merge branch 'main' of github.com:sanic-org/sanic 2022-09-23 00:16:27 +03:00
Adam Hopkins
23a430c4ad Set version properly 2022-09-23 00:16:10 +03:00
Adam Hopkins
ec158ffa69 Additional logger and support for multiprocess manager (#2551) 2022-09-23 00:01:33 +03:00
Adam Hopkins
6e32270036 Begin middleware revamp (#2550) 2022-09-22 00:43:42 +03:00
Zhiwei
43ba381e7b Refactor _static_request_handler (#2533)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2022-09-21 00:45:03 +03:00
Zhiwei
16503319e5 Make WebsocketImplProtocol async iterable (#2490) 2022-09-21 00:20:32 +03:00
Adam Hopkins
389363ab71 Better request cancel handling (#2513) 2022-09-19 16:04:09 +03:00
Adam Hopkins
7f894c45b3 Add deprecation warning filter (#2546) 2022-09-18 18:54:35 +03:00
Adam Hopkins
4726cf1910 Sanic Server WorkerManager refactor (#2499)
Co-authored-by: Néstor Pérez <25409753+prryplatypus@users.noreply.github.com>
2022-09-18 17:17:23 +03:00
Adam Hopkins
d352a4155e Add signals before and after handler execution (#2540) 2022-09-15 15:49:21 +03:00
Adam Hopkins
e5010286b4 Raise warning and deprecation notice on violations (#2537) 2022-09-15 15:24:46 +03:00
Adam Hopkins
358498db96 Do not apply double slash to Blueprint and static dirs (#2515) 2022-09-15 14:43:20 +03:00
monosans
e4999401ab Improve and fix some type annotations (#2536) 2022-09-13 08:53:48 +03:00
Néstor Pérez
c8df0aa2cb Fix easter egg through CLI (#2542) 2022-09-12 01:44:21 +03:00
Adam Hopkins
5fb207176b Update bug_report.md 2022-08-29 12:47:01 +03:00
Adam Hopkins
a12b560478 Update feature_request.md 2022-08-29 12:46:39 +03:00
Zhiwei
753ee992a6 Validate File When Requested (#2526)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2022-08-18 12:05:05 +03:00
Hunt Zhan
09089b1bd3 Resolve issue 2529 (#2530) 2022-08-18 08:58:07 +03:00
Adam Hopkins
7ddbe5e844 Update config.yml 2022-08-17 10:44:02 +03:00
Adam Hopkins
ab5a7038af Update SECURITY.md 2022-08-17 10:42:22 +03:00
Adam Hopkins
4f3c780dc3 Update feature_request.md 2022-08-17 10:38:15 +03:00
Adam Hopkins
71f7765a4c Update bug_report.md 2022-08-17 10:37:35 +03:00
Adam Hopkins
0392d1dcfc Always show server location in ASGI (#2522)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
Co-authored-by: Zhiwei Liang <zhi.wei.liang@outlook.com>
Co-authored-by: Néstor Pérez <25409753+prryplatypus@users.noreply.github.com>
2022-08-11 10:00:35 +03:00
Adam Hopkins
7827b1b41d Add Request properties for HTTP method info (#2516) 2022-08-10 21:12:09 +03:00
Adam Hopkins
8e9342e188 Warn on duplicate route names (#2525) 2022-08-10 20:36:47 +03:00
Adam Hopkins
2f6f2bfa76 Rename code of conduct 2022-08-02 13:56:53 +03:00
Ryu Juheon
dee09d7fff style: add some type hints (#2517) 2022-08-02 08:47:59 +03:00
Adam Hopkins
9cf38a0a83 MERGEBACK (#2495) (#2512)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
Co-authored-by: Zhiwei Liang <zhi.wei.liang@outlook.com>
Co-authored-by: Néstor Pérez <25409753+prryplatypus@users.noreply.github.com>
2022-07-31 15:50:46 +03:00
Adam Hopkins
3def3d3569 Use path.parts instead of match (#2508) 2022-07-31 12:54:42 +03:00
Adam Hopkins
e100a14fd4 Use pathlib for path resolution (#2506) 2022-07-31 08:49:02 +03:00
Adam Hopkins
2fa28f1711 Fix dotted test 2022-07-28 10:07:30 +03:00
Néstor Pérez
9d415e4ec6 Prevent directory traversion with static files (#2495)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
Co-authored-by: Zhiwei Liang <zhi.wei.liang@outlook.com>
2022-07-28 09:45:45 +03:00
Tim Gates
312ab298fd docs: Fix a few typos (#2502)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2022-07-24 22:47:39 +03:00
Zhiwei
2fc21ad576 Replace Unsupported Python Version Number from the Contributing Doc (#2505)
Co-authored-by: Adam Hopkins <adam@amhopkins.com>
2022-07-24 22:33:05 +03:00
Zhiwei
8f6c87c3d6 Fix Test Cases: test_http for Py3.9+, test_json_response_json for ujson 5.4.0+, and test_zero_downtime; Test Case Type Annotations (#2504) 2022-07-24 22:07:54 +03:00
151 changed files with 7844 additions and 1927 deletions

View File

@@ -4,8 +4,8 @@ source = sanic
omit =
site-packages
sanic/__main__.py
sanic/server/legacy.py
sanic/compat.py
sanic/reloader_helpers.py
sanic/simple.py
sanic/utils.py
sanic/cli
@@ -21,12 +21,4 @@ exclude_lines =
NOQA
pragma: no cover
TYPE_CHECKING
omit =
site-packages
sanic/__main__.py
sanic/compat.py
sanic/reloader_helpers.py
sanic/simple.py
sanic/utils.py
sanic/cli
skip_empty = True

66
.github/ISSUE_TEMPLATE/bug-report.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: 🐞 Bug report
description: Create a report to help us improve
labels: ["bug", "triage"]
body:
- type: checkboxes
id: existing
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the bug you encountered.
options:
- label: I have searched the existing issues
required: true
- type: textarea
id: description
attributes:
label: Describe the bug
description: A clear and concise description of what the bug is, make sure to paste any exceptions and tracebacks using markdown code-block syntax to make it easier to read.
validations:
required: true
- type: textarea
id: code
attributes:
label: Code snippet
description: Relevant source code, make sure to remove what is not necessary.
validations:
required: false
- type: textarea
id: expected
attributes:
label: Expected Behavior
description: A concise description of what you expected to happen.
validations:
required: false
- type: dropdown
id: running
attributes:
label: How do you run Sanic?
options:
- Sanic CLI
- As a module
- As a script (`app.run` or `Sanic.serve`)
- ASGI
validations:
required: true
- type: input
id: os
attributes:
label: Operating System
description: What OS?
validations:
required: true
- type: input
id: version
attributes:
label: Sanic Version
description: Check startup logs or try `sanic --version`
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional context
description: Add any other context about the problem here.
validations:
required: false

View File

@@ -1,25 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
---
**Describe the bug**
A clear and concise description of what the bug is, make sure to paste any exceptions and tracebacks.
**Code snippet**
Relevant source code, make sure to remove what is not necessary.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Environment (please complete the following information):**
- OS: [e.g. iOS]
- Version [e.g. 0.8.3]
**Additional context**
Add any other context about the problem here.

View File

@@ -1,5 +1,8 @@
blank_issues_enabled: true
blank_issues_enabled: false
contact_links:
- name: Questions and Help
url: https://community.sanicframework.org/c/questions-and-help
about: Do you need help with Sanic? Ask your questions here.
- name: Discussion and Support
url: https://discord.gg/FARQzAEMAA
about: For live discussion and support, checkout the Sanic Discord server.

View File

@@ -0,0 +1,34 @@
name: 🌟 Feature request
description: Suggest an enhancement for Sanic
labels: ["feature request"]
body:
- type: checkboxes
id: existing
attributes:
label: Is there an existing issue for this?
description: Please search to see if an issue already exists for the enhancement you are proposing.
options:
- label: I have searched the existing issues
required: true
- type: textarea
id: description
attributes:
label: Is your feature request related to a problem? Please describe.
description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
validations:
required: false
- type: textarea
id: code
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: additional
attributes:
label: Additional context
description: Add any other context about the problem here.
validations:
required: false

View File

@@ -1,16 +0,0 @@
---
name: Feature request
about: Suggest an idea for Sanic
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Additional context**
Add any other context or sample code about the feature request here.

View File

@@ -20,6 +20,7 @@ jobs:
- { python-version: 3.8, tox-env: security}
- { python-version: 3.9, tox-env: security}
- { python-version: "3.10", tox-env: security}
- { python-version: "3.11", tox-env: security}
steps:
- name: Checkout the repository
uses: actions/checkout@v2

View File

@@ -14,7 +14,7 @@ jobs:
strategy:
matrix:
config:
- {python-version: "3.8", tox-env: "docs"}
- {python-version: "3.10", tox-env: "docs"}
fail-fast: false

View File

@@ -16,7 +16,7 @@ jobs:
matrix:
os: [ubuntu-latest]
config:
- { python-version: 3.8, tox-env: lint}
- { python-version: "3.10", tox-env: lint}
steps:
- name: Checkout the repository
uses: actions/checkout@v2

47
.github/workflows/pr-python311.yml vendored Normal file
View File

@@ -0,0 +1,47 @@
name: Python 3.11 Tests
on:
pull_request:
branches:
- main
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]
jobs:
testPy311:
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.11",
tox-env: py311,
ignore-error-flake: "false",
command-timeout: "0",
}
- {
python-version: "3.11",
tox-env: py311-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"

View File

@@ -20,6 +20,7 @@ jobs:
- { python-version: 3.8, tox-env: type-checking}
- { python-version: 3.9, tox-env: type-checking}
- { python-version: "3.10", tox-env: type-checking}
- { python-version: "3.11", tox-env: type-checking}
steps:
- name: Checkout the repository
uses: actions/checkout@v2

View File

@@ -19,6 +19,7 @@ jobs:
- { python-version: 3.8, tox-env: py38-no-ext }
- { python-version: 3.9, tox-env: py39-no-ext }
- { python-version: "3.10", tox-env: py310-no-ext }
- { python-version: "3.11", tox-env: py310-no-ext }
- { python-version: pypy-3.7, tox-env: pypy37-no-ext }
steps:

View File

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

View File

@@ -11,7 +11,7 @@ jobs:
strategy:
fail-fast: true
matrix:
python-version: ["3.8"]
python-version: ["3.10"]
steps:
- name: Checkout Repository

View File

@@ -313,8 +313,8 @@ Version 21.3.0
`#2074 <https://github.com/sanic-org/sanic/pull/2074>`_
Performance adjustments in ``handle_request_``
Version 20.12.3 🔷
------------------
Version 20.12.3
---------------
`Current LTS version`
@@ -350,8 +350,8 @@ Version 19.12.5
`#2027 <https://github.com/sanic-org/sanic/pull/2027>`_
Remove old chardet requirement, add in hard multidict requirement
Version 20.12.0 🔹
-----------------
Version 20.12.0
---------------
**Features**

View File

@@ -55,7 +55,7 @@ further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at sanic-maintainers@googlegroups.com. All
reported by contacting the project team at adam@sanicframework.org. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.

View File

@@ -71,9 +71,9 @@ To execute only unittests, run ``tox`` with environment like so:
.. code-block:: bash
tox -e py36 -v -- tests/test_config.py
# or
tox -e py37 -v -- tests/test_config.py
# or
tox -e py310 -v -- tests/test_config.py
Run lint checks
---------------

View File

@@ -102,9 +102,6 @@ Installation
If you are running on a clean install of Fedora 28 or above, please make sure you have the ``redhat-rpm-config`` package installed in case if you want to
use ``sanic`` with ``ujson`` dependency.
.. note::
Windows support is currently "experimental" and on a best-effort basis. Multiple workers are also not currently supported on Windows (see `Issue #1517 <https://github.com/sanic-org/sanic/issues/1517>`_), but setting ``workers=1`` should launch the server successfully.
Hello World Example
-------------------

View File

@@ -4,31 +4,42 @@
Sanic releases long term support release once a year in December. LTS releases receive bug and security updates for **24 months**. Interim releases throughout the year occur every three months, and are supported until the subsequent interim release.
| Version | LTS | Supported |
| ------- | ------------- | ------------------ |
| 20.12 | until 2022-12 | :heavy_check_mark: |
| 20.9 | | :x: |
| 20.6 | | :x: |
| 20.3 | | :x: |
| 19.12 | until 2021-12 | :white_check_mark: |
| 19.9 | | :x: |
| 19.6 | | :x: |
| 19.3 | | :x: |
| 18.12 | | :x: |
| 0.8.3 | | :x: |
| 0.7.0 | | :x: |
| 0.6.0 | | :x: |
| 0.5.4 | | :x: |
| 0.4.1 | | :x: |
| 0.3.1 | | :x: |
| 0.2.0 | | :x: |
| 0.1.9 | | :x: |
:white_check_mark: = security/bug fixes
:heavy_check_mark: = full support
| Version | LTS | Supported |
| ------- | ------------- | ----------------------- |
| 22.12 | until 2024-12 | :white_check_mark: |
| 22.9 | | :x: |
| 22.6 | | :x: |
| 22.3 | | :x: |
| 21.12 | until 2023-12 | :ballot_box_with_check: |
| 21.9 | | :x: |
| 21.6 | | :x: |
| 21.3 | | :x: |
| 20.12 | | :x: |
| 20.9 | | :x: |
| 20.6 | | :x: |
| 20.3 | | :x: |
| 19.12 | | :x: |
| 19.9 | | :x: |
| 19.6 | | :x: |
| 19.3 | | :x: |
| 18.12 | | :x: |
| 0.8.3 | | :x: |
| 0.7.0 | | :x: |
| 0.6.0 | | :x: |
| 0.5.4 | | :x: |
| 0.4.1 | | :x: |
| 0.3.1 | | :x: |
| 0.2.0 | | :x: |
| 0.1.9 | | :x: |
:ballot_box_with_check: = security/bug fixes
:white_check_mark: = full support
## Reporting a Vulnerability
If you discover a security vulnerability, we ask that you **do not** create an issue on GitHub. Instead, please [send a message to the core-devs](https://community.sanicframework.org/g/core-devs) on the community forums. Once logged in, you can send a message to the core-devs by clicking the message button.
Alternatively, you can send a private message to Adam Hopkins on Discord. Find him on the [Sanic discord server](https://discord.gg/FARQzAEMAA).
This will help to not publicize the issue until the team can address it and resolve it.

View File

@@ -4,6 +4,7 @@ coverage:
default:
target: auto
threshold: 0.75
informational: true
project:
default:
target: auto
@@ -14,7 +15,6 @@ codecov:
ignore:
- "sanic/__main__.py"
- "sanic/compat.py"
- "sanic/reloader_helpers.py"
- "sanic/simple.py"
- "sanic/utils.py"
- "sanic/cli"

View File

@@ -2,3 +2,12 @@
.wy-nav-top {
background: #444444;
}
#changelog section {
padding-left: 3rem;
}
#changelog section h2,
#changelog section h3 {
margin-left: -3rem;
}

View File

@@ -1,6 +1,8 @@
📜 Changelog
============
.. mdinclude:: ./releases/22/22.12.md
.. mdinclude:: ./releases/22/22.9.md
.. mdinclude:: ./releases/22/22.6.md
.. mdinclude:: ./releases/22/22.3.md
.. mdinclude:: ./releases/21/21.12.md

View File

@@ -0,0 +1,55 @@
## Version 22.12.0 🔶
_Current version_
### Features
- [#2569](https://github.com/sanic-org/sanic/pull/2569) Add `JSONResponse` class with some convenient methods when updating a response object
- [#2598](https://github.com/sanic-org/sanic/pull/2598) Change `uvloop` requirement to `>=0.15.0`
- [#2609](https://github.com/sanic-org/sanic/pull/2609) Add compatibility with `websockets` v11.0
- [#2610](https://github.com/sanic-org/sanic/pull/2610) Kill server early on worker error
- Raise deadlock timeout to 30s
- [#2617](https://github.com/sanic-org/sanic/pull/2617) Scale number of running server workers
- [#2621](https://github.com/sanic-org/sanic/pull/2621) [#2634](https://github.com/sanic-org/sanic/pull/2634) Send `SIGKILL` on subsequent `ctrl+c` to force worker exit
- [#2622](https://github.com/sanic-org/sanic/pull/2622) Add API to restart all workers from the multiplexer
- [#2624](https://github.com/sanic-org/sanic/pull/2624) Default to `spawn` for all subprocesses unless specifically set:
```python
from sanic import Sanic
Sanic.start_method = "fork"
```
- [#2625](https://github.com/sanic-org/sanic/pull/2625) Filename normalisation of form-data/multipart file uploads
- [#2626](https://github.com/sanic-org/sanic/pull/2626) Move to HTTP Inspector:
- Remote access to inspect running Sanic instances
- TLS support for encrypted calls to Inspector
- Authentication to Inspector with API key
- Ability to extend Inspector with custom commands
- [#2632](https://github.com/sanic-org/sanic/pull/2632) Control order of restart operations
- [#2633](https://github.com/sanic-org/sanic/pull/2633) Move reload interval to class variable
- [#2636](https://github.com/sanic-org/sanic/pull/2636) Add `priority` to `register_middleware` method
- [#2639](https://github.com/sanic-org/sanic/pull/2639) Add `unquote` to `add_route` method
- [#2640](https://github.com/sanic-org/sanic/pull/2640) ASGI websockets to receive `text` or `bytes`
### Bugfixes
- [#2607](https://github.com/sanic-org/sanic/pull/2607) Force socket shutdown before close to allow rebinding
- [#2590](https://github.com/sanic-org/sanic/pull/2590) Use actual `StrEnum` in Python 3.11+
- [#2615](https://github.com/sanic-org/sanic/pull/2615) Ensure middleware executes only once per request timeout
- [#2627](https://github.com/sanic-org/sanic/pull/2627) Crash ASGI application on lifespan failure
- [#2635](https://github.com/sanic-org/sanic/pull/2635) Resolve error with low-level server creation on Windows
### Deprecations and Removals
- [#2608](https://github.com/sanic-org/sanic/pull/2608) [#2630](https://github.com/sanic-org/sanic/pull/2630) Signal conditions and triggers saved on `signal.extra`
- [#2626](https://github.com/sanic-org/sanic/pull/2626) Move to HTTP Inspector
- 🚨 *BREAKING CHANGE*: Moves the Inspector to a Sanic app from a simple TCP socket with a custom protocol
- *DEPRECATE*: The `--inspect*` commands have been deprecated in favor of `inspect ...` commands
- [#2628](https://github.com/sanic-org/sanic/pull/2628) Replace deprecated `distutils.strtobool`
### Developer infrastructure
- [#2612](https://github.com/sanic-org/sanic/pull/2612) Add CI testing for Python 3.11

View File

@@ -1,6 +1,17 @@
## Version 22.6.0 🔶
## Version 22.6.2
_Current version_
### Bugfixes
- [#2522](https://github.com/sanic-org/sanic/pull/2522) Always show server location in ASGI
## Version 22.6.1
### Bugfixes
- [#2477](https://github.com/sanic-org/sanic/pull/2477) Sanic static directory fails when folder name ends with ".."
## Version 22.6.0
### Features
- [#2378](https://github.com/sanic-org/sanic/pull/2378) Introduce HTTP/3 and autogeneration of TLS certificates in `DEBUG` mode

View File

@@ -0,0 +1,74 @@
## Version 22.9.1
### Features
- [#2585](https://github.com/sanic-org/sanic/pull/2585) Improved error message when no applications have been registered
### Bugfixes
- [#2578](https://github.com/sanic-org/sanic/pull/2578) Add certificate loader for in process certificate creation
- [#2591](https://github.com/sanic-org/sanic/pull/2591) Do not use sentinel identity for `spawn` compatibility
- [#2592](https://github.com/sanic-org/sanic/pull/2592) Fix properties in nested blueprint groups
- [#2595](https://github.com/sanic-org/sanic/pull/2595) Introduce sleep interval on new worker reloader
### Deprecations and Removals
### Developer infrastructure
- [#2588](https://github.com/sanic-org/sanic/pull/2588) Markdown templates on issue forms
### Improved Documentation
- [#2556](https://github.com/sanic-org/sanic/pull/2556) v22.9 documentation
- [#2582](https://github.com/sanic-org/sanic/pull/2582) Cleanup documentation on Windows support
## Version 22.9.0
### Features
- [#2445](https://github.com/sanic-org/sanic/pull/2445) Add custom loads function
- [#2490](https://github.com/sanic-org/sanic/pull/2490) Make `WebsocketImplProtocol` async iterable
- [#2499](https://github.com/sanic-org/sanic/pull/2499) Sanic Server WorkerManager refactor
- [#2506](https://github.com/sanic-org/sanic/pull/2506) Use `pathlib` for path resolution (for static file serving)
- [#2508](https://github.com/sanic-org/sanic/pull/2508) Use `path.parts` instead of `match` (for static file serving)
- [#2513](https://github.com/sanic-org/sanic/pull/2513) Better request cancel handling
- [#2516](https://github.com/sanic-org/sanic/pull/2516) Add request properties for HTTP method info:
- `request.is_safe`
- `request.is_idempotent`
- `request.is_cacheable`
- *See* [MDN docs](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods) *for more information about when these apply*
- [#2522](https://github.com/sanic-org/sanic/pull/2522) Always show server location in ASGI
- [#2526](https://github.com/sanic-org/sanic/pull/2526) Cache control support for static files for returning 304 when appropriate
- [#2533](https://github.com/sanic-org/sanic/pull/2533) Refactor `_static_request_handler`
- [#2540](https://github.com/sanic-org/sanic/pull/2540) Add signals before and after handler execution
- `http.handler.before`
- `http.handler.after`
- [#2542](https://github.com/sanic-org/sanic/pull/2542) Add *[redacted]* to CLI :)
- [#2546](https://github.com/sanic-org/sanic/pull/2546) Add deprecation warning filter
- [#2550](https://github.com/sanic-org/sanic/pull/2550) Middleware priority and performance enhancements
### Bugfixes
- [#2495](https://github.com/sanic-org/sanic/pull/2495) Prevent directory traversion with static files
- [#2515](https://github.com/sanic-org/sanic/pull/2515) Do not apply double slash to paths in certain static dirs in Blueprints
### Deprecations and Removals
- [#2525](https://github.com/sanic-org/sanic/pull/2525) Warn on duplicate route names, will be prevented outright in v23.3
- [#2537](https://github.com/sanic-org/sanic/pull/2537) Raise warning and deprecation notice on duplicate exceptions, will be prevented outright in v23.3
### Developer infrastructure
- [#2504](https://github.com/sanic-org/sanic/pull/2504) Cleanup test suite
- [#2505](https://github.com/sanic-org/sanic/pull/2505) Replace Unsupported Python Version Number from the Contributing Doc
- [#2530](https://github.com/sanic-org/sanic/pull/2530) Do not include tests folder in installed package resolver
### Improved Documentation
- [#2502](https://github.com/sanic-org/sanic/pull/2502) Fix a few typos
- [#2517](https://github.com/sanic-org/sanic/pull/2517) [#2536](https://github.com/sanic-org/sanic/pull/2536) Add some type hints

View File

@@ -1,6 +1,3 @@
import os
import socket
from sanic import Sanic, response
@@ -13,13 +10,4 @@ async def test(request):
if __name__ == "__main__":
server_address = "./uds_socket"
# Make sure the socket does not already exist
try:
os.unlink(server_address)
except OSError:
if os.path.exists(server_address):
raise
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
sock.bind(server_address)
app.run(sock=sock)
app.run(unix="./uds_socket")

View File

@@ -22,5 +22,6 @@ module = [
"httptools.*",
"trustme.*",
"sanic_routing.*",
"aioquic.*",
]
ignore_missing_imports = true

View File

@@ -3,7 +3,15 @@ from sanic.app import Sanic
from sanic.blueprints import Blueprint
from sanic.constants import HTTPMethod
from sanic.request import Request
from sanic.response import HTTPResponse, html, json, text
from sanic.response import (
HTTPResponse,
empty,
file,
html,
json,
redirect,
text,
)
from sanic.server.websockets.impl import WebsocketImplProtocol as Websocket
@@ -15,7 +23,10 @@ __all__ = (
"HTTPResponse",
"Request",
"Websocket",
"empty",
"file",
"html",
"json",
"redirect",
"text",
)

View File

@@ -6,10 +6,10 @@ if OS_IS_WINDOWS:
enable_windows_color_support()
def main():
def main(args=None):
cli = SanicCLI()
cli.attach()
cli.run()
cli.run(args)
if __name__ == "__main__":

View File

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

View File

@@ -19,6 +19,7 @@ from collections import defaultdict, deque
from contextlib import suppress
from functools import partial
from inspect import isawaitable
from os import environ
from socket import socket
from traceback import format_exc
from types import SimpleNamespace
@@ -41,13 +42,12 @@ from typing import (
Union,
)
from urllib.parse import urlencode, urlunparse
from warnings import filterwarnings
from sanic_routing.exceptions import FinalizationError, NotFound
from sanic_routing.route import Route
from sanic.application.ext import setup_ext
from sanic.application.state import ApplicationState, Mode, ServerStage
from sanic.application.state import ApplicationState, ServerStage
from sanic.asgi import ASGIApp
from sanic.base.root import BaseSanic
from sanic.blueprint_group import BlueprintGroup
@@ -61,7 +61,7 @@ from sanic.exceptions import (
URLBuildError,
)
from sanic.handlers import ErrorHandler
from sanic.helpers import _default
from sanic.helpers import Default, _default
from sanic.http import Stage
from sanic.log import (
LOGGING_CONFIG_DEFAULTS,
@@ -69,8 +69,9 @@ from sanic.log import (
error_logger,
logger,
)
from sanic.middleware import Middleware, MiddlewareLocation
from sanic.mixins.listeners import ListenerEvent
from sanic.mixins.runner import RunnerMixin
from sanic.mixins.startup import StartupMixin
from sanic.models.futures import (
FutureException,
FutureListener,
@@ -88,6 +89,9 @@ from sanic.router import Router
from sanic.server.websockets.impl import ConnectionClosed
from sanic.signals import Signal, SignalRouter
from sanic.touchup import TouchUp, TouchUpMeta
from sanic.types.shared_ctx import SharedContext
from sanic.worker.inspector import Inspector
from sanic.worker.manager import WorkerManager
if TYPE_CHECKING:
@@ -101,10 +105,8 @@ if TYPE_CHECKING:
if OS_IS_WINDOWS: # no cov
enable_windows_color_support()
filterwarnings("once", category=DeprecationWarning)
class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
"""
The main application instance
"""
@@ -128,6 +130,8 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
"_future_routes",
"_future_signals",
"_future_statics",
"_inspector",
"_manager",
"_state",
"_task_registry",
"_test_client",
@@ -137,14 +141,17 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
"configure_logging",
"ctx",
"error_handler",
"inspector_class",
"go_fast",
"listeners",
"multiplexer",
"named_request_middleware",
"named_response_middleware",
"request_class",
"request_middleware",
"response_middleware",
"router",
"shared_ctx",
"signal_router",
"sock",
"strict_slashes",
@@ -153,12 +160,11 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
)
_app_registry: Dict[str, "Sanic"] = {}
_uvloop_setting = None # TODO: Remove in v22.6
test_mode = False
def __init__(
self,
name: str = None,
name: Optional[str] = None,
config: Optional[Config] = None,
ctx: Optional[Any] = None,
router: Optional[Router] = None,
@@ -171,9 +177,10 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
configure_logging: bool = True,
dumps: Optional[Callable[..., AnyStr]] = None,
loads: Optional[Callable[..., Any]] = None,
inspector: bool = False,
inspector_class: Optional[Type[Inspector]] = None,
) -> None:
super().__init__(name=name)
# logging
if configure_logging:
dict_config = log_config or LOGGING_CONFIG_DEFAULTS
@@ -187,12 +194,16 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
# First setup config
self.config: Config = config or Config(env_prefix=env_prefix)
if inspector:
self.config.INSPECTOR = inspector
# Then we can do the rest
self._asgi_client: Any = None
self._blueprint_order: List[Blueprint] = []
self._delayed_tasks: List[str] = []
self._future_registry: FutureRegistry = FutureRegistry()
self._inspector: Optional[Inspector] = None
self._manager: Optional[WorkerManager] = None
self._state: ApplicationState = ApplicationState(app=self)
self._task_registry: Dict[str, Task] = {}
self._test_client: Any = None
@@ -203,6 +214,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
self.configure_logging: bool = configure_logging
self.ctx: Any = ctx or SimpleNamespace()
self.error_handler: ErrorHandler = error_handler or ErrorHandler()
self.inspector_class: Type[Inspector] = inspector_class or Inspector
self.listeners: Dict[str, List[ListenerType[Any]]] = defaultdict(list)
self.named_request_middleware: Dict[str, Deque[MiddlewareType]] = {}
self.named_response_middleware: Dict[str, Deque[MiddlewareType]] = {}
@@ -210,6 +222,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
self.request_middleware: Deque[MiddlewareType] = deque()
self.response_middleware: Deque[MiddlewareType] = deque()
self.router: Router = router or Router()
self.shared_ctx: SharedContext = SharedContext()
self.signal_router: SignalRouter = signal_router or SignalRouter()
self.sock: Optional[socket] = None
self.strict_slashes: bool = strict_slashes
@@ -243,7 +256,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
)
try:
return get_running_loop()
except RuntimeError:
except RuntimeError: # no cov
if sys.version_info > (3, 10):
return asyncio.get_event_loop_policy().get_event_loop()
else:
@@ -282,8 +295,12 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
return listener
def register_middleware(
self, middleware: MiddlewareType, attach_to: str = "request"
) -> MiddlewareType:
self,
middleware: Union[MiddlewareType, Middleware],
attach_to: str = "request",
*,
priority: Union[Default, int] = _default,
) -> Union[MiddlewareType, Middleware]:
"""
Register an application level middleware that will be attached
to all the API URLs registered under this application.
@@ -299,19 +316,37 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
**response** - Invoke before the response is returned back
:return: decorated method
"""
if attach_to == "request":
retval = middleware
location = MiddlewareLocation[attach_to.upper()]
if not isinstance(middleware, Middleware):
middleware = Middleware(
middleware,
location=location,
priority=priority if isinstance(priority, int) else 0,
)
elif middleware.priority != priority and isinstance(priority, int):
middleware = Middleware(
middleware.func,
location=middleware.location,
priority=priority,
)
if location is MiddlewareLocation.REQUEST:
if middleware not in self.request_middleware:
self.request_middleware.append(middleware)
if attach_to == "response":
if location is MiddlewareLocation.RESPONSE:
if middleware not in self.response_middleware:
self.response_middleware.appendleft(middleware)
return middleware
return retval
def register_named_middleware(
self,
middleware: MiddlewareType,
route_names: Iterable[str],
attach_to: str = "request",
*,
priority: Union[Default, int] = _default,
):
"""
Method for attaching middleware to specific routes. This is mainly an
@@ -325,19 +360,35 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
defaults to "request"
:type attach_to: str, optional
"""
if attach_to == "request":
retval = middleware
location = MiddlewareLocation[attach_to.upper()]
if not isinstance(middleware, Middleware):
middleware = Middleware(
middleware,
location=location,
priority=priority if isinstance(priority, int) else 0,
)
elif middleware.priority != priority and isinstance(priority, int):
middleware = Middleware(
middleware.func,
location=middleware.location,
priority=priority,
)
if location is MiddlewareLocation.REQUEST:
for _rn in route_names:
if _rn not in self.named_request_middleware:
self.named_request_middleware[_rn] = deque()
if middleware not in self.named_request_middleware[_rn]:
self.named_request_middleware[_rn].append(middleware)
if attach_to == "response":
if location is MiddlewareLocation.RESPONSE:
for _rn in route_names:
if _rn not in self.named_response_middleware:
self.named_response_middleware[_rn] = deque()
if middleware not in self.named_response_middleware[_rn]:
self.named_response_middleware[_rn].appendleft(middleware)
return middleware
return retval
def _apply_exception_handler(
self,
@@ -384,8 +435,8 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
routes = [routes]
for r in routes:
r.ctx.websocket = websocket
r.ctx.static = params.get("static", False)
r.extra.websocket = websocket
r.extra.static = params.get("static", False)
r.ctx.__dict__.update(ctx)
return routes
@@ -458,9 +509,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
def blueprint(
self,
blueprint: Union[
Blueprint, List[Blueprint], Tuple[Blueprint], BlueprintGroup
],
blueprint: Union[Blueprint, Iterable[Blueprint], BlueprintGroup],
**options: Any,
):
"""Register a blueprint on the application.
@@ -469,21 +518,20 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
:param options: option dictionary with blueprint defaults
:return: Nothing
"""
if isinstance(blueprint, (list, tuple, BlueprintGroup)):
if isinstance(blueprint, (Iterable, BlueprintGroup)):
for item in blueprint:
params = {**options}
if isinstance(blueprint, BlueprintGroup):
if blueprint.url_prefix:
merge_from = [
options.get("url_prefix", ""),
blueprint.url_prefix,
]
if not isinstance(item, BlueprintGroup):
merge_from.append(item.url_prefix or "")
merged_prefix = "/".join(
u.strip("/") for u in merge_from
).rstrip("/")
params["url_prefix"] = f"/{merged_prefix}"
merge_from = [
options.get("url_prefix", ""),
blueprint.url_prefix or "",
]
if not isinstance(item, BlueprintGroup):
merge_from.append(item.url_prefix or "")
merged_prefix = "/".join(
u.strip("/") for u in merge_from if u
).rstrip("/")
params["url_prefix"] = f"/{merged_prefix}"
for _attr in ["version", "strict_slashes"]:
if getattr(item, _attr) is None:
@@ -581,7 +629,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
uri = route.path
if getattr(route.ctx, "static", None):
if getattr(route.extra, "static", None):
filename = kwargs.pop("filename", "")
# it's static folder
if "__file_uri__" in uri:
@@ -614,18 +662,18 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
host = kwargs.pop("_host", None)
external = kwargs.pop("_external", False) or bool(host)
scheme = kwargs.pop("_scheme", "")
if route.ctx.hosts and external:
if not host and len(route.ctx.hosts) > 1:
if route.extra.hosts and external:
if not host and len(route.extra.hosts) > 1:
raise ValueError(
f"Host is ambiguous: {', '.join(route.ctx.hosts)}"
f"Host is ambiguous: {', '.join(route.extra.hosts)}"
)
elif host and host not in route.ctx.hosts:
elif host and host not in route.extra.hosts:
raise ValueError(
f"Requested host ({host}) is not available for this "
f"route: {route.ctx.hosts}"
f"route: {route.extra.hosts}"
)
elif not host:
host = list(route.ctx.hosts)[0]
host = list(route.extra.hosts)[0]
if scheme and not external:
raise ValueError("When specifying _scheme, _external must be True")
@@ -701,7 +749,10 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
# -------------------------------------------------------------------- #
async def handle_exception(
self, request: Request, exception: BaseException
self,
request: Request,
exception: BaseException,
run_middleware: bool = True,
): # no cov
"""
A handler that catches specific exceptions and outputs a response.
@@ -710,6 +761,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
:param exception: The exception that was raised
:raises ServerError: response 500
"""
response = None
await self.dispatch(
"http.lifecycle.exception",
inline=True,
@@ -750,9 +802,11 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
# -------------------------------------------- #
# Request Middleware
# -------------------------------------------- #
response = await self._run_request_middleware(
request, request_name=None
)
if run_middleware:
middleware = (
request.route and request.route.extra.request_middleware
) or self.request_middleware
response = await self._run_request_middleware(request, middleware)
# No middleware results
if not response:
try:
@@ -832,9 +886,14 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
# Define `response` var here to remove warnings about
# allocation before assignment below.
response = None
response: Optional[
Union[
BaseHTTPResponse,
Coroutine[Any, Any, Optional[BaseHTTPResponse]],
]
] = None
run_middleware = True
try:
await self.dispatch(
"http.routing.before",
inline=True,
@@ -864,9 +923,8 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
if (
request.stream
and request.stream.request_body
and not route.ctx.ignore_body
and not route.extra.ignore_body
):
if hasattr(handler, "is_stream"):
# Streaming handler: lift the size limit
request.stream.request_max_size = float("inf")
@@ -877,9 +935,11 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
# -------------------------------------------- #
# Request Middleware
# -------------------------------------------- #
response = await self._run_request_middleware(
request, request_name=route.name
)
run_middleware = False
if request.route.extra.request_middleware:
response = await self._run_request_middleware(
request, request.route.extra.request_middleware
)
# No middleware results
if not response:
@@ -896,9 +956,19 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
)
# Run response handler
await self.dispatch(
"http.handler.before",
inline=True,
context={"request": request},
)
response = handler(request, **request.match_info)
if isawaitable(response):
response = await response
await self.dispatch(
"http.handler.after",
inline=True,
context={"request": request},
)
if request.responded:
if response is not None:
@@ -910,7 +980,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
if request.stream is not None:
response = request.stream.response
elif response is not None:
response = await request.respond(response)
response = await request.respond(response) # type: ignore
elif not hasattr(handler, "is_websocket"):
response = request.stream.response # type: ignore
@@ -928,7 +998,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
...
await response.send(end_stream=True)
elif isinstance(response, ResponseStream):
resp = await response(request)
resp = await response(request) # type: ignore
await self.dispatch(
"http.lifecycle.response",
inline=True,
@@ -937,7 +1007,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
"response": resp,
},
)
await response.eof()
await response.eof() # type: ignore
else:
if not hasattr(handler, "is_websocket"):
raise ServerError(
@@ -949,7 +1019,9 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
raise
except Exception as e:
# Response Generation Failed
await self.handle_exception(request, e)
await self.handle_exception(
request, e, run_middleware=run_middleware
)
async def _websocket_handler(
self, handler, request, *args, subprotocols=None, **kwargs
@@ -999,7 +1071,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
def asgi_client(self): # noqa
"""
A testing client that uses ASGI to reach into the application to
execute hanlers.
execute handlers.
:return: testing client
:rtype: :class:`SanicASGITestClient`
@@ -1018,86 +1090,72 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
# -------------------------------------------------------------------- #
async def _run_request_middleware(
self, request, request_name=None
self, request, middleware_collection
): # no cov
# The if improves speed. I don't know why
named_middleware = self.named_request_middleware.get(
request_name, deque()
)
applicable_middleware = self.request_middleware + named_middleware
request._request_middleware_started = True
# request.request_middleware_started is meant as a stop-gap solution
# until RFC 1630 is adopted
if applicable_middleware and not request.request_middleware_started:
request.request_middleware_started = True
for middleware in middleware_collection:
await self.dispatch(
"http.middleware.before",
inline=True,
context={
"request": request,
"response": None,
},
condition={"attach_to": "request"},
)
for middleware in applicable_middleware:
await self.dispatch(
"http.middleware.before",
inline=True,
context={
"request": request,
"response": None,
},
condition={"attach_to": "request"},
)
response = middleware(request)
if isawaitable(response):
response = await response
response = middleware(request)
if isawaitable(response):
response = await response
await self.dispatch(
"http.middleware.after",
inline=True,
context={
"request": request,
"response": None,
},
condition={"attach_to": "request"},
)
await self.dispatch(
"http.middleware.after",
inline=True,
context={
"request": request,
"response": None,
},
condition={"attach_to": "request"},
)
if response:
return response
if response:
return response
return None
async def _run_response_middleware(
self, request, response, request_name=None
self, request, response, middleware_collection
): # no cov
named_middleware = self.named_response_middleware.get(
request_name, deque()
)
applicable_middleware = self.response_middleware + named_middleware
if applicable_middleware:
for middleware in applicable_middleware:
await self.dispatch(
"http.middleware.before",
inline=True,
context={
"request": request,
"response": response,
},
condition={"attach_to": "response"},
)
for middleware in middleware_collection:
await self.dispatch(
"http.middleware.before",
inline=True,
context={
"request": request,
"response": response,
},
condition={"attach_to": "response"},
)
_response = middleware(request, response)
if isawaitable(_response):
_response = await _response
_response = middleware(request, response)
if isawaitable(_response):
_response = await _response
await self.dispatch(
"http.middleware.after",
inline=True,
context={
"request": request,
"response": _response if _response else response,
},
condition={"attach_to": "response"},
)
await self.dispatch(
"http.middleware.after",
inline=True,
context={
"request": request,
"response": _response if _response else response,
},
condition={"attach_to": "response"},
)
if _response:
response = _response
if isinstance(response, BaseHTTPResponse):
response = request.stream.respond(response)
break
if _response:
response = _response
if isinstance(response, BaseHTTPResponse):
response = request.stream.respond(response)
break
return response
def _build_endpoint_name(self, *parts):
@@ -1184,7 +1242,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
*,
name: Optional[str] = None,
register: bool = True,
) -> Optional[Task]:
) -> Optional[Task[Any]]:
"""
Schedule a task to run later, after the loop has started.
Different from asyncio.ensure_future in that it does not
@@ -1194,7 +1252,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
`See user guide re: background tasks
<https://sanicframework.org/guide/basics/tasks.html#background-tasks>`__
:param task: future, couroutine or awaitable
:param task: future, coroutine or awaitable
"""
try:
loop = self.loop # Will raise SanicError if loop is not started
@@ -1315,7 +1373,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
self.config.update_config(config)
@property
def asgi(self):
def asgi(self) -> bool:
return self.state.asgi
@asgi.setter
@@ -1326,18 +1384,6 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
def debug(self):
return self.state.is_debug
@debug.setter
def debug(self, value: bool):
deprecation(
"Setting the value of a Sanic application's debug value directly "
"is deprecated and will be removed in v22.9. Please set it using "
"the CLI, app.run, app.prepare, or directly set "
"app.state.mode to Mode.DEBUG.",
22.9,
)
mode = Mode.DEBUG if value else Mode.PRODUCTION
self.state.mode = mode
@property
def auto_reload(self):
return self.config.AUTO_RELOAD
@@ -1345,6 +1391,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
@auto_reload.setter
def auto_reload(self, value: bool):
self.config.AUTO_RELOAD = value
self.state.auto_reload = value
@property
def state(self) -> ApplicationState: # type: ignore
@@ -1353,58 +1400,6 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
"""
return self._state
@property
def is_running(self):
deprecation(
"Use of the is_running property is no longer used by Sanic "
"internally. The property is now deprecated and will be removed "
"in version 22.9. You may continue to set the property for your "
"own needs until that time. If you would like to check whether "
"the application is operational, please use app.state.stage. More "
"information is available at ___.",
22.9,
)
return self.state.is_running
@is_running.setter
def is_running(self, value: bool):
deprecation(
"Use of the is_running property is no longer used by Sanic "
"internally. The property is now deprecated and will be removed "
"in version 22.9. You may continue to set the property for your "
"own needs until that time. If you would like to check whether "
"the application is operational, please use app.state.stage. More "
"information is available at ___.",
22.9,
)
self.state.is_running = value
@property
def is_stopping(self):
deprecation(
"Use of the is_stopping property is no longer used by Sanic "
"internally. The property is now deprecated and will be removed "
"in version 22.9. You may continue to set the property for your "
"own needs until that time. If you would like to check whether "
"the application is operational, please use app.state.stage. More "
"information is available at ___.",
22.9,
)
return self.state.is_stopping
@is_stopping.setter
def is_stopping(self, value: bool):
deprecation(
"Use of the is_stopping property is no longer used by Sanic "
"internally. The property is now deprecated and will be removed "
"in version 22.9. You may continue to set the property for your "
"own needs until that time. If you would like to check whether "
"the application is operational, please use app.state.stage. More "
"information is available at ___.",
22.9,
)
self.state.is_stopping = value
@property
def reload_dirs(self):
return self.state.reload_dirs
@@ -1462,6 +1457,18 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
cls._app_registry[name] = app
@classmethod
def unregister_app(cls, app: "Sanic") -> None:
"""
Unregister a Sanic instance
"""
if not isinstance(app, cls):
raise SanicException("Registered app must be an instance of Sanic")
name = app.name
if name in cls._app_registry:
del cls._app_registry[name]
@classmethod
def get_app(
cls, name: Optional[str] = None, *, force_create: bool = False
@@ -1481,9 +1488,28 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
try:
return cls._app_registry[name]
except KeyError:
if name == "__main__":
return cls.get_app("__mp_main__", force_create=force_create)
if force_create:
return cls(name)
raise SanicException(f'Sanic app name "{name}" not found.')
raise SanicException(
f"Sanic app name '{name}' not found.\n"
"App instantiation must occur outside "
"if __name__ == '__main__' "
"block or by using an AppLoader.\nSee "
"https://sanic.dev/en/guide/deployment/app-loader.html"
" for more details."
)
@classmethod
def _check_uvloop_conflict(cls) -> None:
values = {app.config.USE_UVLOOP for app in cls._app_registry.values()}
if len(values) > 1:
error_logger.warning(
"It looks like you're running several apps with different "
"uvloop settings. This is not supported and may lead to "
"unintended behaviour."
)
# -------------------------------------------------------------------- #
# Lifecycle
@@ -1495,6 +1521,7 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
except FinalizationError as e:
if not Sanic.test_mode:
raise e
self.finalize_middleware()
def signalize(self, allow_fail_builtin=True):
self.signal_router.allow_fail_builtin = allow_fail_builtin
@@ -1514,24 +1541,26 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
if self.state.is_debug and self.config.TOUCHUP is not True:
self.config.TOUCHUP = False
elif self.config.TOUCHUP is _default:
elif isinstance(self.config.TOUCHUP, Default):
self.config.TOUCHUP = True
# Setup routers
self.signalize(self.config.TOUCHUP)
self.finalize()
# TODO: Replace in v22.6 to check against apps in app registry
if (
self.__class__._uvloop_setting is not None
and self.__class__._uvloop_setting != self.config.USE_UVLOOP
):
error_logger.warning(
"It looks like you're running several apps with different "
"uvloop settings. This is not supported and may lead to "
"unintended behaviour."
route_names = [route.name for route in self.router.routes]
duplicates = {
name for name in route_names if route_names.count(name) > 1
}
if duplicates:
names = ", ".join(duplicates)
deprecation(
f"Duplicate route names detected: {names}. In the future, "
"Sanic will enforce uniqueness in route naming.",
23.3,
)
self.__class__._uvloop_setting = self.config.USE_UVLOOP
Sanic._check_uvloop_conflict()
# Startup time optimizations
if self.state.primary:
@@ -1542,6 +1571,10 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
self.state.is_started = True
def ack(self):
if hasattr(self, "multiplexer"):
self.multiplexer.ack()
async def _server_event(
self,
concern: str,
@@ -1570,3 +1603,43 @@ class Sanic(BaseSanic, RunnerMixin, metaclass=TouchUpMeta):
"loop": loop,
},
)
# -------------------------------------------------------------------- #
# Process Management
# -------------------------------------------------------------------- #
def refresh(
self,
passthru: Optional[Dict[str, Any]] = None,
):
registered = self.__class__.get_app(self.name)
if self is not registered:
if not registered.state.server_info:
registered.state.server_info = self.state.server_info
self = registered
if passthru:
for attr, info in passthru.items():
if isinstance(info, dict):
for key, value in info.items():
setattr(getattr(self, attr), key, value)
else:
setattr(self, attr, info)
if hasattr(self, "multiplexer"):
self.shared_ctx.lock()
return self
@property
def inspector(self):
if environ.get("SANIC_WORKER_PROCESS") or not self._inspector:
raise SanicException(
"Can only access the inspector from the main process"
)
return self._inspector
@property
def manager(self):
if environ.get("SANIC_WORKER_PROCESS") or not self._manager:
raise SanicException(
"Can only access the manager from the main process"
)
return self._manager

View File

@@ -1,15 +1,24 @@
from enum import Enum, IntEnum, auto
class StrEnum(str, Enum):
class StrEnum(str, Enum): # no cov
def _generate_next_value_(name: str, *args) -> str: # type: ignore
return name.lower()
def __eq__(self, value: object) -> bool:
value = str(value).upper()
return super().__eq__(value)
def __hash__(self) -> int:
return hash(self.value)
def __str__(self) -> str:
return self.value
class Server(StrEnum):
SANIC = auto()
ASGI = auto()
GUNICORN = auto()
class Mode(StrEnum):

View File

@@ -8,11 +8,6 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING:
from sanic import Sanic
try:
from sanic_ext import Extend # type: ignore
except ImportError:
...
def setup_ext(app: Sanic, *, fail: bool = False, **kwargs):
if not app.config.AUTO_EXTEND:
@@ -22,7 +17,7 @@ def setup_ext(app: Sanic, *, fail: bool = False, **kwargs):
with suppress(ModuleNotFoundError):
sanic_ext = import_module("sanic_ext")
if not sanic_ext:
if not sanic_ext: # no cov
if fail:
raise RuntimeError(
"Sanic Extensions is not installed. You can add it to your "
@@ -33,7 +28,7 @@ def setup_ext(app: Sanic, *, fail: bool = False, **kwargs):
return
if not getattr(app, "_ext", None):
Ext: Extend = getattr(sanic_ext, "Extend")
Ext = getattr(sanic_ext, "Extend")
app._ext = Ext(app, **kwargs)
return app.ext

View File

@@ -80,20 +80,23 @@ class MOTDTTY(MOTD):
)
self.display_length = self.key_width + self.value_width + 2
def display(self):
version = f"Sanic v{__version__}".center(self.centering_length)
def display(self, version=True, action="Goin' Fast", out=None):
if not out:
out = logger.info
header = "Sanic"
if version:
header += f" v{__version__}"
header = header.center(self.centering_length)
running = (
f"Goin' Fast @ {self.serve_location}"
if self.serve_location
else ""
f"{action} @ {self.serve_location}" if self.serve_location else ""
).center(self.centering_length)
length = len(version) + 2 - self.logo_line_length
length = len(header) + 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"{header}",
f"{running}",
f"{first_filler}{second_filler}",
]
@@ -107,7 +110,7 @@ class MOTDTTY(MOTD):
self._render_fill(lines)
lines.append(f"{first_filler}{second_filler}\n")
logger.info(indent("\n".join(lines), " "))
out(indent("\n".join(lines), " "))
def _render_data(self, lines, data, start):
offset = 0

View File

@@ -7,9 +7,9 @@ from urllib.parse import quote
from sanic.compat import Header
from sanic.exceptions import ServerError
from sanic.helpers import _default
from sanic.helpers import Default
from sanic.http import Stage
from sanic.log import logger
from sanic.log import error_logger, logger
from sanic.models.asgi import ASGIReceive, ASGIScope, ASGISend, MockTransport
from sanic.request import Request
from sanic.response import BaseHTTPResponse
@@ -61,7 +61,7 @@ class Lifespan:
await self.asgi_app.sanic_app._server_event("init", "before")
await self.asgi_app.sanic_app._server_event("init", "after")
if self.asgi_app.sanic_app.config.USE_UVLOOP is not _default:
if not isinstance(self.asgi_app.sanic_app.config.USE_UVLOOP, Default):
warnings.warn(
"You have set the USE_UVLOOP configuration option, but Sanic "
"cannot control the event loop when running in ASGI mode."
@@ -85,13 +85,27 @@ class Lifespan:
) -> None:
message = await receive()
if message["type"] == "lifespan.startup":
await self.startup()
await send({"type": "lifespan.startup.complete"})
try:
await self.startup()
except Exception as e:
error_logger.exception(e)
await send(
{"type": "lifespan.startup.failed", "message": str(e)}
)
else:
await send({"type": "lifespan.startup.complete"})
message = await receive()
if message["type"] == "lifespan.shutdown":
await self.shutdown()
await send({"type": "lifespan.shutdown.complete"})
try:
await self.shutdown()
except Exception as e:
error_logger.exception(e)
await send(
{"type": "lifespan.shutdown.failed", "message": str(e)}
)
else:
await send({"type": "lifespan.shutdown.complete"})
class ASGIApp:
@@ -234,4 +248,7 @@ class ASGIApp:
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)
try:
await self.sanic_app.handle_exception(self.request, e)
except Exception as exc:
await self.sanic_app.handle_exception(self.request, exc, False)

View File

@@ -1,6 +1,6 @@
import re
from typing import Any
from typing import Any, Optional
from sanic.base.meta import SanicMeta
from sanic.exceptions import SanicException
@@ -24,7 +24,9 @@ class BaseSanic(
):
__slots__ = ("name",)
def __init__(self, name: str = None, *args: Any, **kwargs: Any) -> None:
def __init__(
self, name: Optional[str] = None, *args: Any, **kwargs: Any
) -> None:
class_name = self.__class__.__name__
if name is None:

View File

@@ -308,7 +308,7 @@ class Blueprint(BaseSanic):
# prefixed properly in the router
future.handler.__blueprintname__ = self.name
# Prepend the blueprint URI prefix if available
uri = url_prefix + future.uri if url_prefix else future.uri
uri = self._setup_uri(future.uri, url_prefix)
version_prefix = self.version_prefix
for prefix in (
@@ -333,7 +333,7 @@ class Blueprint(BaseSanic):
apply_route = FutureRoute(
future.handler,
uri[1:] if uri.startswith("//") else uri,
uri,
future.methods,
host,
strict_slashes,
@@ -363,7 +363,7 @@ class Blueprint(BaseSanic):
# Static Files
for future in self._future_statics:
# Prepend the blueprint URI prefix if available
uri = url_prefix + future.uri if url_prefix else future.uri
uri = self._setup_uri(future.uri, url_prefix)
apply_route = FutureStatic(uri, *future[1:])
if (self, apply_route) in app._future_registry:
@@ -406,7 +406,7 @@ class Blueprint(BaseSanic):
self.routes += [route for route in routes if isinstance(route, Route)]
self.websocket_routes += [
route for route in self.routes if route.ctx.websocket
route for route in self.routes if route.extra.websocket
]
self.middlewares += middleware
self.exceptions += exception_handlers
@@ -442,7 +442,7 @@ class Blueprint(BaseSanic):
events.add(signal.ctx.event)
return asyncio.wait(
[event.wait() for event in events],
[asyncio.create_task(event.wait()) for event in events],
return_when=asyncio.FIRST_COMPLETED,
timeout=timeout,
)
@@ -456,6 +456,18 @@ class Blueprint(BaseSanic):
break
return value
@staticmethod
def _setup_uri(base: str, prefix: Optional[str]):
uri = base
if prefix:
uri = prefix
if base.startswith("/") and prefix.endswith("/"):
uri += base[1:]
else:
uri += base
return uri[1:] if uri.startswith("//") else uri
@staticmethod
def register_futures(
apps: Set[Sanic], bp: Blueprint, futures: Sequence[Tuple[Any, ...]]

View File

@@ -1,22 +1,21 @@
import logging
import os
import shutil
import sys
from argparse import ArgumentParser, RawTextHelpFormatter
from importlib import import_module
from pathlib import Path
from argparse import Namespace
from functools import partial
from textwrap import indent
from typing import Any, List, Union
from typing import List, Union, cast
from sanic.app import Sanic
from sanic.application.logo import get_logo
from sanic.cli.arguments import Group
from sanic.log import error_logger
from sanic.simple import create_simple_server
class SanicArgumentParser(ArgumentParser):
...
from sanic.cli.base import SanicArgumentParser, SanicHelpFormatter
from sanic.cli.inspector import make_inspector_parser
from sanic.cli.inspector_client import InspectorClient
from sanic.log import Colors, error_logger
from sanic.worker.loader import AppLoader
class SanicCLI:
@@ -45,7 +44,7 @@ Or, a path to a directory to run as a simple HTTP server:
self.parser = SanicArgumentParser(
prog="sanic",
description=self.DESCRIPTION,
formatter_class=lambda prog: RawTextHelpFormatter(
formatter_class=lambda prog: SanicHelpFormatter(
prog,
max_help_position=36 if width > 96 else 24,
indent_increment=4,
@@ -57,22 +56,37 @@ Or, a path to a directory to run as a simple HTTP server:
self.main_process = (
os.environ.get("SANIC_RELOADER_PROCESS", "") != "true"
)
self.args: List[Any] = []
self.args: Namespace = Namespace()
self.groups: List[Group] = []
self.inspecting = False
def attach(self):
if len(sys.argv) > 1 and sys.argv[1] == "inspect":
self.inspecting = True
self.parser.description = get_logo(True)
make_inspector_parser(self.parser)
return
for group in Group._registry:
instance = group.create(self.parser)
instance.attach()
self.groups.append(instance)
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
def run(self, parse_args=None):
if self.inspecting:
self._inspector()
return
legacy_version = False
if not parse_args:
parsed, unknown = self.parser.parse_known_args()
# 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
elif parse_args == ["-v"]:
parse_args = ["--version"]
if not legacy_version:
parsed, unknown = self.parser.parse_known_args(args=parse_args)
if unknown and parsed.factory:
for arg in unknown:
if arg.startswith("--"):
@@ -80,20 +94,90 @@ Or, a path to a directory to run as a simple HTTP server:
self.args = self.parser.parse_args(args=parse_args)
self._precheck()
app_loader = AppLoader(
self.args.module, self.args.factory, self.args.simple, self.args
)
if self.args.inspect or self.args.inspect_raw or self.args.trigger:
self._inspector_legacy(app_loader)
return
try:
app = self._get_app()
app = self._get_app(app_loader)
kwargs = self._build_run_kwargs()
except ValueError:
error_logger.exception("Failed to run app")
except ValueError as e:
error_logger.exception(f"Failed to run app: {e}")
else:
for http_version in self.args.http:
app.prepare(**kwargs, version=http_version)
if self.args.single:
serve = Sanic.serve_single
elif self.args.legacy:
serve = Sanic.serve_legacy
else:
serve = partial(Sanic.serve, app_loader=app_loader)
serve(app)
Sanic.serve()
def _inspector_legacy(self, app_loader: AppLoader):
host = port = None
module = cast(str, self.args.module)
if ":" in module:
maybe_host, maybe_port = module.rsplit(":", 1)
if maybe_port.isnumeric():
host, port = maybe_host, int(maybe_port)
if not host:
app = self._get_app(app_loader)
host, port = app.config.INSPECTOR_HOST, app.config.INSPECTOR_PORT
action = self.args.trigger or "info"
InspectorClient(
str(host), int(port or 6457), False, self.args.inspect_raw, ""
).do(action)
sys.stdout.write(
f"\n{Colors.BOLD}{Colors.YELLOW}WARNING:{Colors.END} "
"You are using the legacy CLI command that will be removed in "
f"{Colors.RED}v23.3{Colors.END}. See "
"https://sanic.dev/en/guide/release-notes/v22.12.html"
"#deprecations-and-removals or checkout the new "
"style commands:\n\n\t"
f"{Colors.YELLOW}sanic inspect --help{Colors.END}\n"
)
def _inspector(self):
args = sys.argv[2:]
self.args, unknown = self.parser.parse_known_args(args=args)
if unknown:
for arg in unknown:
if arg.startswith("--"):
try:
key, value = arg.split("=")
key = key.lstrip("-")
except ValueError:
value = False if arg.startswith("--no-") else True
key = (
arg.replace("--no-", "")
.lstrip("-")
.replace("-", "_")
)
setattr(self.args, key, value)
kwargs = {**self.args.__dict__}
host = kwargs.pop("host")
port = kwargs.pop("port")
secure = kwargs.pop("secure")
raw = kwargs.pop("raw")
action = kwargs.pop("action") or "info"
api_key = kwargs.pop("api_key")
positional = kwargs.pop("positional", None)
if action == "<custom>" and positional:
action = positional[0]
if len(positional) > 1:
kwargs["args"] = positional[1:]
InspectorClient(host, port, secure, raw, api_key).do(action, **kwargs)
def _precheck(self):
# # Custom TLS mismatch handling for better diagnostics
# 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)
@@ -113,58 +197,14 @@ Or, a path to a directory to run as a simple HTTP server:
)
error_logger.error(message)
sys.exit(1)
if self.args.inspect or self.args.inspect_raw:
logging.disable(logging.CRITICAL)
def _get_app(self):
def _get_app(self, app_loader: AppLoader):
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 module_name == "" and os.path.isdir(self.args.module):
raise ValueError(
"App not found.\n"
" Please use --simple if you are passing a "
"directory to sanic.\n"
f" eg. sanic {self.args.module} --simple"
)
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:
try:
app = app(self.args)
except TypeError:
app = app()
app_type_name = type(app).__name__
if not isinstance(app, Sanic):
if callable(app):
solution = f"sanic {self.args.module} --factory"
raise ValueError(
"Module is not a Sanic app, it is a "
f"{app_type_name}\n"
" If this callable returns a "
f"Sanic instance try: \n{solution}"
)
raise ValueError(
f"Module is not a Sanic app, it is a {app_type_name}\n"
f" Perhaps you meant {self.args.module}:app?"
)
app = app_loader.load()
except ImportError as e:
if module_name.startswith(e.name):
if app_loader.module_name.startswith(e.name): # type: ignore
error_logger.error(
f"No module named {e.name} found.\n"
" Example File: project/sanic_server.py -> app\n"
@@ -190,8 +230,10 @@ Or, a path to a directory to run as a simple HTTP server:
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,
"coffee": self.args.coffee,
"debug": self.args.debug,
"fast": self.args.fast,
"host": self.args.host,
@@ -203,6 +245,8 @@ Or, a path to a directory to run as a simple HTTP server:
"verbosity": self.args.verbosity or 0,
"workers": self.args.workers,
"auto_tls": self.args.auto_tls,
"single_process": self.args.single,
"legacy": self.args.legacy,
}
for maybe_arg in ("auto_reload", "dev"):

View File

@@ -30,7 +30,7 @@ class Group:
instance = cls(parser, cls.name)
return instance
def add_bool_arguments(self, *args, **kwargs):
def add_bool_arguments(self, *args, nullable=False, **kwargs):
group = self.container.add_mutually_exclusive_group()
kwargs["help"] = kwargs["help"].capitalize()
group.add_argument(*args, action="store_true", **kwargs)
@@ -38,6 +38,9 @@ class Group:
group.add_argument(
"--no-" + args[0][2:], *args[1:], action="store_false", **kwargs
)
if nullable:
params = {args[0][2:].replace("-", "_"): None}
group.set_defaults(**params)
def prepare(self, args) -> None:
...
@@ -67,7 +70,8 @@ class ApplicationGroup(Group):
name = "Application"
def attach(self):
self.container.add_argument(
group = self.container.add_mutually_exclusive_group()
group.add_argument(
"--factory",
action="store_true",
help=(
@@ -75,7 +79,7 @@ class ApplicationGroup(Group):
"i.e. a () -> <Sanic app> callable"
),
)
self.container.add_argument(
group.add_argument(
"-s",
"--simple",
dest="simple",
@@ -85,6 +89,32 @@ class ApplicationGroup(Group):
"a directory\n(module arg should be a path)"
),
)
group.add_argument(
"--inspect",
dest="inspect",
action="store_true",
help=("Inspect the state of a running instance, human readable"),
)
group.add_argument(
"--inspect-raw",
dest="inspect_raw",
action="store_true",
help=("Inspect the state of a running instance, JSON output"),
)
group.add_argument(
"--trigger-reload",
dest="trigger",
action="store_const",
const="reload",
help=("Trigger worker processes to reload"),
)
group.add_argument(
"--trigger-shutdown",
dest="trigger",
action="store_const",
const="shutdown",
help=("Trigger all processes to shutdown"),
)
class HTTPVersionGroup(Group):
@@ -207,8 +237,22 @@ class WorkerGroup(Group):
action="store_true",
help="Set the number of workers to max allowed",
)
group.add_argument(
"--single-process",
dest="single",
action="store_true",
help="Do not use multiprocessing, run server in a single process",
)
self.container.add_argument(
"--legacy",
action="store_true",
help="Use the legacy server manager",
)
self.add_bool_arguments(
"--access-logs", dest="access_log", help="display access logs"
"--access-logs",
dest="access_log",
help="display access logs",
default=None,
)
@@ -262,6 +306,12 @@ class OutputGroup(Group):
name = "Output"
def attach(self):
self.add_bool_arguments(
"--coffee",
dest="coffee",
default=False,
help="Uhm, coffee?",
)
self.add_bool_arguments(
"--motd",
dest="motd",

35
sanic/cli/base.py Normal file
View File

@@ -0,0 +1,35 @@
from argparse import (
SUPPRESS,
Action,
ArgumentParser,
RawTextHelpFormatter,
_SubParsersAction,
)
from typing import Any
class SanicArgumentParser(ArgumentParser):
def _check_value(self, action: Action, value: Any) -> None:
if isinstance(action, SanicSubParsersAction):
return
super()._check_value(action, value)
class SanicHelpFormatter(RawTextHelpFormatter):
def add_usage(self, usage, actions, groups, prefix=None):
if not usage:
usage = SUPPRESS
# Add one linebreak, but not two
self.add_text("\x1b[1A")
super().add_usage(usage, actions, groups, prefix)
class SanicSubParsersAction(_SubParsersAction):
def __call__(self, parser, namespace, values, option_string=None):
self._name_parser_map
parser_name = values[0]
if parser_name not in self._name_parser_map:
self._name_parser_map[parser_name] = parser
values = ["<custom>", *values]
super().__call__(parser, namespace, values, option_string)

105
sanic/cli/inspector.py Normal file
View File

@@ -0,0 +1,105 @@
from argparse import ArgumentParser
from sanic.application.logo import get_logo
from sanic.cli.base import SanicHelpFormatter, SanicSubParsersAction
def _add_shared(parser: ArgumentParser) -> None:
parser.add_argument(
"--host",
"-H",
default="localhost",
help="Inspector host address [default 127.0.0.1]",
)
parser.add_argument(
"--port",
"-p",
default=6457,
type=int,
help="Inspector port [default 6457]",
)
parser.add_argument(
"--secure",
"-s",
action="store_true",
help="Whether to access the Inspector via TLS encryption",
)
parser.add_argument("--api-key", "-k", help="Inspector authentication key")
parser.add_argument(
"--raw",
action="store_true",
help="Whether to output the raw response information",
)
class InspectorSubParser(ArgumentParser):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
_add_shared(self)
if not self.description:
self.description = ""
self.description = get_logo(True) + self.description
def make_inspector_parser(parser: ArgumentParser) -> None:
_add_shared(parser)
subparsers = parser.add_subparsers(
action=SanicSubParsersAction,
dest="action",
description=(
"Run one or none of the below subcommands. Using inspect without "
"a subcommand will fetch general information about the state "
"of the application instance.\n\n"
"Or, you can optionally follow inspect with a subcommand. "
"If you have created a custom "
"Inspector instance, then you can run custom commands. See "
"https://sanic.dev/en/guide/deployment/inspector.html "
"for more details."
),
title=" Subcommands",
parser_class=InspectorSubParser,
)
reloader = subparsers.add_parser(
"reload",
help="Trigger a reload of the server workers",
formatter_class=SanicHelpFormatter,
)
reloader.add_argument(
"--zero-downtime",
action="store_true",
help=(
"Whether to wait for the new process to be online before "
"terminating the old"
),
)
subparsers.add_parser(
"shutdown",
help="Shutdown the application and all processes",
formatter_class=SanicHelpFormatter,
)
scale = subparsers.add_parser(
"scale",
help="Scale the number of workers",
formatter_class=SanicHelpFormatter,
)
scale.add_argument(
"replicas",
type=int,
help="Number of workers requested",
)
custom = subparsers.add_parser(
"<custom>",
help="Run a custom command",
description=(
"keyword arguments:\n When running a custom command, you can "
"add keyword arguments by appending them to your command\n\n"
"\tsanic inspect foo --one=1 --two=2"
),
formatter_class=SanicHelpFormatter,
)
custom.add_argument(
"positional",
nargs="*",
help="Add one or more non-keyword args to your custom command",
)

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
import sys
from http.client import RemoteDisconnected
from textwrap import indent
from typing import Any, Dict, Optional
from urllib.error import URLError
from urllib.request import Request as URequest
from urllib.request import urlopen
from sanic.application.logo import get_logo
from sanic.application.motd import MOTDTTY
from sanic.log import Colors
try: # no cov
from ujson import dumps, loads
except ModuleNotFoundError: # no cov
from json import dumps, loads # type: ignore
class InspectorClient:
def __init__(
self,
host: str,
port: int,
secure: bool,
raw: bool,
api_key: Optional[str],
) -> None:
self.scheme = "https" if secure else "http"
self.host = host
self.port = port
self.raw = raw
self.api_key = api_key
for scheme in ("http", "https"):
full = f"{scheme}://"
if self.host.startswith(full):
self.scheme = scheme
self.host = self.host[len(full) :] # noqa E203
def do(self, action: str, **kwargs: Any) -> None:
if action == "info":
self.info()
return
result = self.request(action, **kwargs).get("result")
if result:
out = (
dumps(result)
if isinstance(result, (list, dict))
else str(result)
)
sys.stdout.write(out + "\n")
def info(self) -> None:
out = sys.stdout.write
response = self.request("", "GET")
if self.raw or not response:
return
data = response["result"]
display = data.pop("info")
extra = display.pop("extra", {})
display["packages"] = ", ".join(display["packages"])
MOTDTTY(get_logo(), self.base_url, display, extra).display(
version=False,
action="Inspecting",
out=out,
)
for name, info in data["workers"].items():
info = "\n".join(
f"\t{key}: {Colors.BLUE}{value}{Colors.END}"
for key, value in info.items()
)
out(
"\n"
+ indent(
"\n".join(
[
f"{Colors.BOLD}{Colors.SANIC}{name}{Colors.END}",
info,
]
),
" ",
)
+ "\n"
)
def request(self, action: str, method: str = "POST", **kwargs: Any) -> Any:
url = f"{self.base_url}/{action}"
params: Dict[str, Any] = {"method": method, "headers": {}}
if kwargs:
params["data"] = dumps(kwargs).encode()
params["headers"]["content-type"] = "application/json"
if self.api_key:
params["headers"]["authorization"] = f"Bearer {self.api_key}"
request = URequest(url, **params)
try:
with urlopen(request) as response: # nosec B310
raw = response.read()
loaded = loads(raw)
if self.raw:
sys.stdout.write(dumps(loaded.get("result")) + "\n")
return {}
return loaded
except (URLError, RemoteDisconnected) as e:
sys.stderr.write(
f"{Colors.RED}Could not connect to inspector at: "
f"{Colors.YELLOW}{self.base_url}{Colors.END}\n"
"Either the application is not running, or it did not start "
f"an inspector instance.\n{e}\n"
)
sys.exit(1)
@property
def base_url(self):
return f"{self.scheme}://{self.host}:{self.port}"

View File

@@ -3,10 +3,23 @@ import os
import signal
import sys
from typing import Awaitable
from contextlib import contextmanager
from enum import Enum
from typing import Awaitable, Union
from multidict import CIMultiDict # type: ignore
from sanic.helpers import Default
if sys.version_info < (3, 8): # no cov
StartMethod = Union[Default, str]
else: # no cov
from typing import Literal
StartMethod = Union[
Default, Literal["fork"], Literal["forkserver"], Literal["spawn"]
]
OS_IS_WINDOWS = os.name == "nt"
UVLOOP_INSTALLED = False
@@ -18,6 +31,40 @@ try:
except ImportError:
pass
# Python 3.11 changed the way Enum formatting works for mixed-in types.
if sys.version_info < (3, 11, 0):
class StrEnum(str, Enum):
pass
else:
from enum import StrEnum # type: ignore # noqa
class UpperStrEnum(StrEnum):
def _generate_next_value_(name, start, count, last_values):
return name.upper()
def __eq__(self, value: object) -> bool:
value = str(value).upper()
return super().__eq__(value)
def __hash__(self) -> int:
return hash(self.value)
def __str__(self) -> str:
return self.value
@contextmanager
def use_context(method: StartMethod):
from sanic import Sanic
orig = Sanic.start_method
Sanic.start_method = method
yield
Sanic.start_method = orig
def enable_windows_color_support():
import ctypes

View File

@@ -1,30 +1,55 @@
from __future__ import annotations
import sys
from abc import ABCMeta
from inspect import getmembers, isclass, isdatadescriptor
from os import environ
from pathlib import Path
from typing import Any, Callable, Dict, Optional, Sequence, Union
from warnings import filterwarnings
from sanic.constants import LocalCertCreator
from sanic.errorpages import DEFAULT_FORMAT, check_error_format
from sanic.helpers import Default, _default
from sanic.http import Http
from sanic.log import deprecation, error_logger
from sanic.log import error_logger
from sanic.utils import load_module_from_file_location, str_to_bool
if sys.version_info >= (3, 8):
from typing import Literal
FilterWarningType = Union[
Literal["default"],
Literal["error"],
Literal["ignore"],
Literal["always"],
Literal["module"],
Literal["once"],
]
else:
FilterWarningType = str
SANIC_PREFIX = "SANIC_"
DEFAULT_CONFIG = {
"_FALLBACK_ERROR_FORMAT": _default,
"ACCESS_LOG": True,
"ACCESS_LOG": False,
"AUTO_EXTEND": True,
"AUTO_RELOAD": False,
"EVENT_AUTOREGISTER": False,
"DEPRECATION_FILTER": "once",
"FORWARDED_FOR_HEADER": "X-Forwarded-For",
"FORWARDED_SECRET": None,
"GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec
"INSPECTOR": False,
"INSPECTOR_HOST": "localhost",
"INSPECTOR_PORT": 6457,
"INSPECTOR_TLS_KEY": _default,
"INSPECTOR_TLS_CERT": _default,
"INSPECTOR_API_KEY": "",
"KEEP_ALIVE_TIMEOUT": 5, # 5 seconds
"KEEP_ALIVE": True,
"LOCAL_CERT_CREATOR": LocalCertCreator.AUTO,
@@ -50,12 +75,8 @@ DEFAULT_CONFIG = {
"WEBSOCKET_PING_TIMEOUT": 20,
}
# These values will be removed from the Config object in v22.6 and moved
# to the application state
DEPRECATED_CONFIG = ("SERVER_RUNNING", "RELOADER_PROCESS", "RELOADED_FILES")
class DescriptorMeta(type):
class DescriptorMeta(ABCMeta):
def __init__(cls, *_):
cls.__setters__ = {name for name, _ in getmembers(cls, cls._is_setter)}
@@ -69,9 +90,16 @@ class Config(dict, metaclass=DescriptorMeta):
AUTO_EXTEND: bool
AUTO_RELOAD: bool
EVENT_AUTOREGISTER: bool
DEPRECATION_FILTER: FilterWarningType
FORWARDED_FOR_HEADER: str
FORWARDED_SECRET: Optional[str]
GRACEFUL_SHUTDOWN_TIMEOUT: float
INSPECTOR: bool
INSPECTOR_HOST: str
INSPECTOR_PORT: int
INSPECTOR_TLS_KEY: Union[Path, str, Default]
INSPECTOR_TLS_CERT: Union[Path, str, Default]
INSPECTOR_API_KEY: str
KEEP_ALIVE_TIMEOUT: int
KEEP_ALIVE: bool
LOCAL_CERT_CREATOR: Union[str, LocalCertCreator]
@@ -99,7 +127,9 @@ class Config(dict, metaclass=DescriptorMeta):
def __init__(
self,
defaults: Dict[str, Union[str, bool, int, float, None]] = None,
defaults: Optional[
Dict[str, Union[str, bool, int, float, None]]
] = None,
env_prefix: Optional[str] = SANIC_PREFIX,
keep_alive: Optional[bool] = None,
*,
@@ -107,6 +137,7 @@ class Config(dict, metaclass=DescriptorMeta):
):
defaults = defaults or {}
super().__init__({**DEFAULT_CONFIG, **defaults})
self._configure_warnings()
self._converters = [str, str_to_bool, float, int]
@@ -127,19 +158,19 @@ class Config(dict, metaclass=DescriptorMeta):
self._check_error_format()
self._init = True
def __getattr__(self, attr):
def __getattr__(self, attr: Any):
try:
return self[attr]
except KeyError as ke:
raise AttributeError(f"Config has no '{ke.args[0]}'")
def __setattr__(self, attr, value) -> None:
def __setattr__(self, attr: str, value: Any) -> None:
self.update({attr: value})
def __setitem__(self, attr, value) -> None:
def __setitem__(self, attr: str, value: Any) -> None:
self.update({attr: value})
def update(self, *other, **kwargs) -> None:
def update(self, *other: Any, **kwargs: Any) -> None:
kwargs.update({k: v for item in other for k, v in dict(item).items()})
setters: Dict[str, Any] = {
k: kwargs.pop(k)
@@ -172,10 +203,12 @@ class Config(dict, metaclass=DescriptorMeta):
self.LOCAL_CERT_CREATOR = LocalCertCreator[
self.LOCAL_CERT_CREATOR.upper()
]
elif attr == "DEPRECATION_FILTER":
self._configure_warnings()
@property
def FALLBACK_ERROR_FORMAT(self) -> str:
if self._FALLBACK_ERROR_FORMAT is _default:
if isinstance(self._FALLBACK_ERROR_FORMAT, Default):
return DEFAULT_FORMAT
return self._FALLBACK_ERROR_FORMAT
@@ -183,7 +216,7 @@ class Config(dict, metaclass=DescriptorMeta):
def FALLBACK_ERROR_FORMAT(self, value):
self._check_error_format(value)
if (
self._FALLBACK_ERROR_FORMAT is not _default
not isinstance(self._FALLBACK_ERROR_FORMAT, Default)
and value != self._FALLBACK_ERROR_FORMAT
):
error_logger.warning(
@@ -199,6 +232,13 @@ class Config(dict, metaclass=DescriptorMeta):
self.REQUEST_MAX_SIZE,
)
def _configure_warnings(self):
filterwarnings(
self.DEPRECATION_FILTER,
category=DeprecationWarning,
module=r"sanic.*",
)
def _check_error_format(self, format: Optional[str] = None):
check_error_format(format or self.FALLBACK_ERROR_FORMAT)
@@ -206,7 +246,9 @@ class Config(dict, metaclass=DescriptorMeta):
"""
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.
starts up to load environment variables into config. Environment
variables should start with the defined prefix and should only
contain uppercase letters.
It will automatically hydrate the following types:
@@ -232,12 +274,9 @@ class Config(dict, metaclass=DescriptorMeta):
`See user guide re: config
<https://sanicframework.org/guide/deployment/configuration.html>`__
"""
lower_case_var_found = False
for key, value in environ.items():
if not key.startswith(prefix):
if not key.startswith(prefix) or not key.isupper():
continue
if not key.isupper():
lower_case_var_found = True
_, config_key = key.split(prefix, 1)
@@ -247,12 +286,6 @@ class Config(dict, metaclass=DescriptorMeta):
break
except ValueError:
pass
if lower_case_var_found:
deprecation(
"Lowercase environment variables will not be "
"loaded into Sanic config beginning in v22.9.",
22.9,
)
def update_config(self, config: Union[bytes, str, dict, Any]):
"""

View File

@@ -1,20 +1,9 @@
from enum import Enum, auto
from enum import auto
from sanic.compat import UpperStrEnum
class HTTPMethod(str, Enum):
def _generate_next_value_(name, start, count, last_values):
return name.upper()
def __eq__(self, value: object) -> bool:
value = str(value).upper()
return super().__eq__(value)
def __hash__(self) -> int:
return hash(self.value)
def __str__(self) -> str:
return self.value
class HTTPMethod(UpperStrEnum):
GET = auto()
POST = auto()
PUT = auto()
@@ -24,16 +13,22 @@ class HTTPMethod(str, Enum):
DELETE = auto()
class LocalCertCreator(str, Enum):
def _generate_next_value_(name, start, count, last_values):
return name.upper()
class LocalCertCreator(UpperStrEnum):
AUTO = auto()
TRUSTME = auto()
MKCERT = auto()
HTTP_METHODS = tuple(HTTPMethod.__members__.values())
SAFE_HTTP_METHODS = (HTTPMethod.GET, HTTPMethod.HEAD, HTTPMethod.OPTIONS)
IDEMPOTENT_HTTP_METHODS = (
HTTPMethod.GET,
HTTPMethod.HEAD,
HTTPMethod.PUT,
HTTPMethod.DELETE,
HTTPMethod.OPTIONS,
)
CACHEABLE_HTTP_METHODS = (HTTPMethod.GET, HTTPMethod.HEAD)
DEFAULT_HTTP_CONTENT_TYPE = "application/octet-stream"
DEFAULT_LOCAL_TLS_KEY = "key.pem"
DEFAULT_LOCAL_TLS_CERT = "cert.pem"

View File

@@ -448,8 +448,8 @@ def exception_response(
# from the route
if request.route:
try:
if request.route.ctx.error_format:
render_format = request.route.ctx.error_format
if request.route.extra.error_format:
render_format = request.route.extra.error_format
except AttributeError:
...

View File

@@ -1,8 +1,17 @@
from asyncio import CancelledError
from typing import Any, Dict, Optional, Union
from sanic.helpers import STATUS_CODES
class RequestCancelled(CancelledError):
quiet = True
class ServerKilled(Exception):
...
class SanicException(Exception):
message: str = ""

View File

@@ -36,17 +36,31 @@ class ErrorHandler:
self.debug = False
self.base = base
@classmethod
def finalize(cls, *args, **kwargs):
deprecation(
"ErrorHandler.finalize is deprecated and no longer needed. "
"Please remove update your code to remove it. ",
22.12,
)
def _full_lookup(self, exception, route_name: Optional[str] = None):
return self.lookup(exception, route_name)
def _add(
self,
key: Tuple[Type[BaseException], Optional[str]],
handler: RouteHandler,
) -> None:
if key in self.cached_handlers:
exc, name = key
if name is None:
name = "__ALL_ROUTES__"
error_logger.warning(
f"Duplicate exception handler definition on: route={name} "
f"and exception={exc}"
)
deprecation(
"A duplicate exception handler definition was discovered. "
"This may cause unintended consequences. A warning has been "
"issued now, but it will not be allowed starting in v23.3.",
23.3,
)
self.cached_handlers[key] = handler
def add(self, exception, handler, route_names: Optional[List[str]] = None):
"""
Add a new exception handler to an already existing handler object.
@@ -62,9 +76,9 @@ class ErrorHandler:
"""
if route_names:
for route in route_names:
self.cached_handlers[(exception, route)] = handler
self._add((exception, route), handler)
else:
self.cached_handlers[(exception, None)] = handler
self._add((exception, None), handler)
def lookup(self, exception, route_name: Optional[str] = None):
"""

View File

@@ -14,6 +14,7 @@ from sanic.exceptions import (
BadRequest,
ExpectationFailed,
PayloadTooLarge,
RequestCancelled,
ServerError,
ServiceUnavailable,
)
@@ -70,7 +71,6 @@ class Http(Stream, metaclass=TouchUpMeta):
"request_body",
"request_bytes",
"request_bytes_left",
"request_max_size",
"response",
"response_func",
"response_size",
@@ -132,7 +132,7 @@ class Http(Stream, metaclass=TouchUpMeta):
if self.stage is Stage.RESPONSE:
await self.response.send(end_stream=True)
except CancelledError:
except CancelledError as exc:
# Write an appropriate response before exiting
if not self.protocol.transport:
logger.info(
@@ -140,7 +140,11 @@ class Http(Stream, metaclass=TouchUpMeta):
"stopped. Transport is closed."
)
return
e = self.exception or ServiceUnavailable("Cancelled")
e = (
RequestCancelled()
if self.protocol.conn_info.lost
else (self.exception or exc)
)
self.exception = None
self.keep_alive = False
await self.error_response(e)
@@ -424,7 +428,13 @@ class Http(Stream, metaclass=TouchUpMeta):
if self.request is None:
self.create_empty_request()
await app.handle_exception(self.request, exception)
request_middleware = not isinstance(exception, ServiceUnavailable)
try:
await app.handle_exception(
self.request, exception, request_middleware
)
except Exception as e:
await app.handle_exception(self.request, e, False)
def create_empty_request(self) -> None:
"""

View File

@@ -22,7 +22,7 @@ from sanic.exceptions import PayloadTooLarge, SanicException, ServerError
from sanic.helpers import has_message_body
from sanic.http.constants import Stage
from sanic.http.stream import Stream
from sanic.http.tls.context import CertSelector, CertSimple, SanicSSLContext
from sanic.http.tls.context import CertSelector, SanicSSLContext
from sanic.log import Colors, logger
from sanic.models.protocol_types import TransportProtocol
from sanic.models.server_types import ConnInfo
@@ -378,7 +378,7 @@ def get_config(
app: Sanic, ssl: Union[SanicSSLContext, CertSelector, SSLContext]
):
# TODO:
# - proper selection needed if servince with multiple certs insted of
# - proper selection needed if service with multiple certs insted of
# just taking the first
if isinstance(ssl, CertSelector):
ssl = cast(SanicSSLContext, ssl.sanic_select[0])
@@ -389,8 +389,8 @@ def get_config(
"should be able to use mkcert instead. For more information, see: "
"https://github.com/aiortc/aioquic/issues/295."
)
if not isinstance(ssl, CertSimple):
raise SanicException("SSLContext is not CertSimple")
if not isinstance(ssl, SanicSSLContext):
raise SanicException("SSLContext is not SanicSSLContext")
config = QuicConfiguration(
alpn_protocols=H3_ALPN + H0_ALPN + ["siduck"],

View File

@@ -19,7 +19,7 @@ class Stream:
request_max_size: Union[int, float]
__touchup__: Tuple[str, ...] = tuple()
__slots__ = ()
__slots__ = ("request_max_size",)
def respond(
self, response: BaseHTTPResponse

View File

@@ -24,13 +24,15 @@ def create_context(
certfile: Optional[str] = None,
keyfile: Optional[str] = None,
password: Optional[str] = None,
purpose: ssl.Purpose = ssl.Purpose.CLIENT_AUTH,
) -> 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 = ssl.create_default_context(purpose=purpose)
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 purpose is ssl.Purpose.CLIENT_AUTH:
context.sni_callback = server_name_callback
if certfile and keyfile:
context.load_cert_chain(certfile, keyfile, password)
return context

View File

@@ -72,7 +72,8 @@ def get_ssl_context(
"without passing a TLS certificate. If you are developing "
"locally, please enable DEVELOPMENT mode and Sanic will "
"generate a localhost TLS certificate. For more information "
"please see: ___."
"please see: https://sanic.dev/en/guide/deployment/development."
"html#automatic-tls-certificate."
)
creator = CertCreator.select(
@@ -125,7 +126,6 @@ class CertCreator(ABC):
local_tls_key,
local_tls_cert,
) -> CertCreator:
creator: Optional[CertCreator] = None
cert_creator_options: Tuple[
@@ -151,7 +151,8 @@ class CertCreator(ABC):
raise SanicException(
"Sanic could not find package to create a TLS certificate. "
"You must have either mkcert or trustme installed. See "
"_____ for more details."
"https://sanic.dev/en/guide/deployment/development.html"
"#automatic-tls-certificate for more details."
)
return creator
@@ -203,7 +204,8 @@ class MkcertCreator(CertCreator):
"to proceed. Installation instructions can be found here: "
"https://github.com/FiloSottile/mkcert.\n"
"Find out more information about your options here: "
"_____"
"https://sanic.dev/en/guide/deployment/development.html#"
"automatic-tls-certificate"
) from e
def generate_cert(self, localhost: str) -> ssl.SSLContext:
@@ -240,7 +242,12 @@ class MkcertCreator(CertCreator):
self.cert_path.unlink()
self.tmpdir.rmdir()
return CertSimple(self.cert_path, self.key_path)
context = CertSimple(self.cert_path, self.key_path)
context.sanic["creator"] = "mkcert"
context.sanic["localhost"] = localhost
SanicSSLContext.create_from_ssl_context(context)
return context
class TrustmeCreator(CertCreator):
@@ -255,24 +262,28 @@ class TrustmeCreator(CertCreator):
"to proceed. Installation instructions can be found here: "
"https://github.com/python-trio/trustme.\n"
"Find out more information about your options here: "
"_____"
"https://sanic.dev/en/guide/deployment/development.html#"
"automatic-tls-certificate"
)
def generate_cert(self, localhost: str) -> ssl.SSLContext:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
sanic_context = SanicSSLContext.create_from_ssl_context(context)
sanic_context.sanic = {
context = SanicSSLContext.create_from_ssl_context(
ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
)
context.sanic = {
"cert": self.cert_path.absolute(),
"key": self.key_path.absolute(),
}
ca = trustme.CA()
server_cert = ca.issue_cert(localhost)
server_cert.configure_cert(sanic_context)
server_cert.configure_cert(context)
ca.configure_trust(context)
ca.cert_pem.write_to_path(str(self.cert_path.absolute()))
server_cert.private_key_and_cert_chain_pem.write_to_path(
str(self.key_path.absolute())
)
context.sanic["creator"] = "trustme"
context.sanic["localhost"] = localhost
return context

View File

@@ -2,12 +2,23 @@ import logging
import sys
from enum import Enum
from typing import Any, Dict
from typing import TYPE_CHECKING, Any, Dict
from warnings import warn
from sanic.compat import is_atty
# Python 3.11 changed the way Enum formatting works for mixed-in types.
if sys.version_info < (3, 11, 0):
class StrEnum(str, Enum):
pass
else:
if not TYPE_CHECKING:
from enum import StrEnum
LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( # no cov
version=1,
disable_existing_loggers=False,
@@ -25,6 +36,12 @@ LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( # no cov
"propagate": True,
"qualname": "sanic.access",
},
"sanic.server": {
"level": "INFO",
"handlers": ["console"],
"propagate": True,
"qualname": "sanic.server",
},
},
handlers={
"console": {
@@ -62,12 +79,13 @@ Defult logging configuration
"""
class Colors(str, Enum): # no cov
class Colors(StrEnum): # no cov
END = "\033[0m"
BLUE = "\033[01;34m"
GREEN = "\033[01;32m"
PURPLE = "\033[01;35m"
RED = "\033[01;31m"
BOLD = "\033[1m"
BLUE = "\033[34m"
GREEN = "\033[32m"
PURPLE = "\033[35m"
RED = "\033[31m"
SANIC = "\033[38;2;255;13;104m"
YELLOW = "\033[01;33m"
@@ -100,6 +118,12 @@ Logger used by Sanic for access logging
"""
access_logger.addFilter(_verbosity_filter)
server_logger = logging.getLogger("sanic.server") # no cov
"""
Logger used by Sanic for server related messages
"""
logger.addFilter(_verbosity_filter)
def deprecation(message: str, version: float): # no cov
version_info = f"[DEPRECATION v{version}] "

69
sanic/middleware.py Normal file
View File

@@ -0,0 +1,69 @@
from __future__ import annotations
from collections import deque
from enum import IntEnum, auto
from itertools import count
from typing import Deque, Sequence, Union
from sanic.models.handler_types import MiddlewareType
class MiddlewareLocation(IntEnum):
REQUEST = auto()
RESPONSE = auto()
class Middleware:
_counter = count()
__slots__ = ("func", "priority", "location", "definition")
def __init__(
self,
func: MiddlewareType,
location: MiddlewareLocation,
priority: int = 0,
) -> None:
self.func = func
self.priority = priority
self.location = location
self.definition = next(Middleware._counter)
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
def __hash__(self) -> int:
return hash(self.func)
def __repr__(self) -> str:
return (
f"{self.__class__.__name__}("
f"func=<function {self.func.__name__}>, "
f"priority={self.priority}, "
f"location={self.location.name})"
)
@property
def order(self):
return (self.priority, -self.definition)
@classmethod
def convert(
cls,
*middleware_collections: Sequence[Union[Middleware, MiddlewareType]],
location: MiddlewareLocation,
) -> Deque[Middleware]:
return deque(
[
middleware
if isinstance(middleware, Middleware)
else Middleware(middleware, location)
for collection in middleware_collections
for middleware in collection
]
)
@classmethod
def reset_count(cls):
cls._counter = count()
cls.count = next(cls._counter)

View File

@@ -17,9 +17,12 @@ class ListenerEvent(str, Enum):
BEFORE_SERVER_STOP = "server.shutdown.before"
AFTER_SERVER_STOP = "server.shutdown.after"
MAIN_PROCESS_START = auto()
MAIN_PROCESS_READY = auto()
MAIN_PROCESS_STOP = auto()
RELOAD_PROCESS_START = auto()
RELOAD_PROCESS_STOP = auto()
BEFORE_RELOAD_TRIGGER = auto()
AFTER_RELOAD_TRIGGER = auto()
class ListenerMixin(metaclass=SanicMeta):
@@ -98,6 +101,11 @@ class ListenerMixin(metaclass=SanicMeta):
) -> ListenerType[Sanic]:
return self.listener(listener, "main_process_start")
def main_process_ready(
self, listener: ListenerType[Sanic]
) -> ListenerType[Sanic]:
return self.listener(listener, "main_process_ready")
def main_process_stop(
self, listener: ListenerType[Sanic]
) -> ListenerType[Sanic]:
@@ -113,6 +121,16 @@ class ListenerMixin(metaclass=SanicMeta):
) -> ListenerType[Sanic]:
return self.listener(listener, "reload_process_stop")
def before_reload_trigger(
self, listener: ListenerType[Sanic]
) -> ListenerType[Sanic]:
return self.listener(listener, "before_reload_trigger")
def after_reload_trigger(
self, listener: ListenerType[Sanic]
) -> ListenerType[Sanic]:
return self.listener(listener, "after_reload_trigger")
def before_server_start(
self, listener: ListenerType[Sanic]
) -> ListenerType[Sanic]:

View File

@@ -1,11 +1,17 @@
from collections import deque
from functools import partial
from operator import attrgetter
from typing import List
from sanic.base.meta import SanicMeta
from sanic.middleware import Middleware, MiddlewareLocation
from sanic.models.futures import FutureMiddleware
from sanic.router import Router
class MiddlewareMixin(metaclass=SanicMeta):
router: Router
def __init__(self, *args, **kwargs) -> None:
self._future_middleware: List[FutureMiddleware] = []
@@ -13,7 +19,12 @@ class MiddlewareMixin(metaclass=SanicMeta):
raise NotImplementedError # noqa
def middleware(
self, middleware_or_request, attach_to="request", apply=True
self,
middleware_or_request,
attach_to="request",
apply=True,
*,
priority=0
):
"""
Decorate and register middleware to be called before a request
@@ -30,6 +41,12 @@ class MiddlewareMixin(metaclass=SanicMeta):
def register_middleware(middleware, attach_to="request"):
nonlocal apply
location = (
MiddlewareLocation.REQUEST
if attach_to == "request"
else MiddlewareLocation.RESPONSE
)
middleware = Middleware(middleware, location, priority=priority)
future_middleware = FutureMiddleware(middleware, attach_to)
self._future_middleware.append(future_middleware)
if apply:
@@ -46,7 +63,7 @@ class MiddlewareMixin(metaclass=SanicMeta):
register_middleware, attach_to=middleware_or_request
)
def on_request(self, middleware=None):
def on_request(self, middleware=None, *, priority=0):
"""Register a middleware to be called before a request is handled.
This is the same as *@app.middleware('request')*.
@@ -54,11 +71,13 @@ class MiddlewareMixin(metaclass=SanicMeta):
:param: middleware: A callable that takes in request.
"""
if callable(middleware):
return self.middleware(middleware, "request")
return self.middleware(middleware, "request", priority=priority)
else:
return partial(self.middleware, attach_to="request")
return partial(
self.middleware, attach_to="request", priority=priority
)
def on_response(self, middleware=None):
def on_response(self, middleware=None, *, priority=0):
"""Register a middleware to be called after a response is created.
This is the same as *@app.middleware('response')*.
@@ -67,6 +86,57 @@ class MiddlewareMixin(metaclass=SanicMeta):
A callable that takes in a request and its response.
"""
if callable(middleware):
return self.middleware(middleware, "response")
return self.middleware(middleware, "response", priority=priority)
else:
return partial(self.middleware, attach_to="response")
return partial(
self.middleware, attach_to="response", priority=priority
)
def finalize_middleware(self):
for route in self.router.routes:
request_middleware = Middleware.convert(
self.request_middleware,
self.named_request_middleware.get(route.name, deque()),
location=MiddlewareLocation.REQUEST,
)
response_middleware = Middleware.convert(
self.response_middleware,
self.named_response_middleware.get(route.name, deque()),
location=MiddlewareLocation.RESPONSE,
)
route.extra.request_middleware = deque(
sorted(
request_middleware,
key=attrgetter("order"),
reverse=True,
)
)
route.extra.response_middleware = deque(
sorted(
response_middleware,
key=attrgetter("order"),
reverse=True,
)[::-1]
)
request_middleware = Middleware.convert(
self.request_middleware,
location=MiddlewareLocation.REQUEST,
)
response_middleware = Middleware.convert(
self.response_middleware,
location=MiddlewareLocation.RESPONSE,
)
self.request_middleware = deque(
sorted(
request_middleware,
key=attrgetter("order"),
reverse=True,
)
)
self.response_middleware = deque(
sorted(
response_middleware,
key=attrgetter("order"),
reverse=True,
)[::-1]
)

View File

@@ -1,16 +1,16 @@
from ast import NodeVisitor, Return, parse
from contextlib import suppress
from email.utils import formatdate
from functools import partial, wraps
from inspect import getsource, signature
from mimetypes import guess_type
from os import path
from pathlib import PurePath
from re import sub
from pathlib import Path, PurePath
from textwrap import dedent
from time import gmtime, strftime
from typing import (
Any,
Callable,
Dict,
Iterable,
List,
Optional,
@@ -27,31 +27,18 @@ from sanic.base.meta import SanicMeta
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 (
BadRequest,
FileNotFound,
HeaderNotFound,
RangeNotSatisfiable,
)
from sanic.exceptions import FileNotFound, HeaderNotFound, RangeNotSatisfiable
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.response import HTTPResponse, file, file_stream, validate_file
from sanic.types import HashableDict
RouteWrapper = Callable[
[RouteHandler], Union[RouteHandler, Tuple[Route, RouteHandler]]
]
RESTRICTED_ROUTE_CONTEXT = (
"ignore_body",
"stream",
"hosts",
"static",
"error_format",
"websocket",
)
class RouteMixin(metaclass=SanicMeta):
@@ -231,7 +218,8 @@ class RouteMixin(metaclass=SanicMeta):
stream: bool = False,
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
unquote: bool = False,
**ctx_kwargs: Any,
) -> RouteHandler:
"""A helper method to register class instance or
functions as a handler to the application url
@@ -277,6 +265,7 @@ class RouteMixin(metaclass=SanicMeta):
name=name,
version_prefix=version_prefix,
error_format=error_format,
unquote=unquote,
**ctx_kwargs,
)(handler)
return handler
@@ -292,7 +281,7 @@ class RouteMixin(metaclass=SanicMeta):
ignore_body: bool = True,
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
**ctx_kwargs: Any,
) -> RouteHandler:
"""
Add an API URL under the **GET** *HTTP* method
@@ -335,7 +324,7 @@ class RouteMixin(metaclass=SanicMeta):
name: Optional[str] = None,
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
**ctx_kwargs: Any,
) -> RouteHandler:
"""
Add an API URL under the **POST** *HTTP* method
@@ -378,7 +367,7 @@ class RouteMixin(metaclass=SanicMeta):
name: Optional[str] = None,
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
**ctx_kwargs: Any,
) -> RouteHandler:
"""
Add an API URL under the **PUT** *HTTP* method
@@ -421,7 +410,7 @@ class RouteMixin(metaclass=SanicMeta):
ignore_body: bool = True,
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
**ctx_kwargs: Any,
) -> RouteHandler:
"""
Add an API URL under the **HEAD** *HTTP* method
@@ -472,7 +461,7 @@ class RouteMixin(metaclass=SanicMeta):
ignore_body: bool = True,
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
**ctx_kwargs: Any,
) -> RouteHandler:
"""
Add an API URL under the **OPTIONS** *HTTP* method
@@ -523,7 +512,7 @@ class RouteMixin(metaclass=SanicMeta):
name: Optional[str] = None,
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
**ctx_kwargs: Any,
) -> RouteHandler:
"""
Add an API URL under the **PATCH** *HTTP* method
@@ -576,7 +565,7 @@ class RouteMixin(metaclass=SanicMeta):
ignore_body: bool = True,
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
**ctx_kwargs: Any,
) -> RouteHandler:
"""
Add an API URL under the **DELETE** *HTTP* method
@@ -620,7 +609,7 @@ class RouteMixin(metaclass=SanicMeta):
apply: bool = True,
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
**ctx_kwargs: Any,
):
"""
Decorate a function to be registered as a websocket route
@@ -664,7 +653,7 @@ class RouteMixin(metaclass=SanicMeta):
name: Optional[str] = None,
version_prefix: str = "/v",
error_format: Optional[str] = None,
**ctx_kwargs,
**ctx_kwargs: Any,
):
"""
A helper method to register a function as a websocket route.
@@ -701,18 +690,18 @@ class RouteMixin(metaclass=SanicMeta):
def static(
self,
uri,
uri: str,
file_or_directory: Union[str, bytes, PurePath],
pattern=r"/?.+",
use_modified_since=True,
use_content_range=False,
stream_large_files=False,
name="static",
host=None,
strict_slashes=None,
content_type=None,
apply=True,
resource_type=None,
pattern: str = r"/?.+",
use_modified_since: bool = True,
use_content_range: bool = False,
stream_large_files: bool = False,
name: str = "static",
host: Optional[str] = None,
strict_slashes: Optional[bool] = None,
content_type: Optional[bool] = None,
apply: bool = True,
resource_type: Optional[str] = None,
):
"""
Register a root to serve files from. The input can either be a
@@ -796,6 +785,37 @@ class RouteMixin(metaclass=SanicMeta):
return name
async def _get_file_path(self, file_or_directory, __file_uri__, not_found):
file_path_raw = Path(unquote(file_or_directory))
root_path = file_path = file_path_raw.resolve()
if __file_uri__:
# Strip all / that in the beginning of the URL to help prevent
# python from herping a derp and treating the uri as an
# absolute path
unquoted_file_uri = unquote(__file_uri__).lstrip("/")
file_path_raw = Path(file_or_directory, unquoted_file_uri)
file_path = file_path_raw.resolve()
if (
file_path < root_path and not file_path_raw.is_symlink()
) or ".." in file_path_raw.parts:
error_logger.exception(
f"File not found: path={file_or_directory}, "
f"relative_url={__file_uri__}"
)
raise not_found
try:
file_path.relative_to(root_path)
except ValueError:
if not file_path_raw.is_symlink():
error_logger.exception(
f"File not found: path={file_or_directory}, "
f"relative_url={__file_uri__}"
)
raise not_found
return file_path
async def _static_request_handler(
self,
file_or_directory,
@@ -806,32 +826,17 @@ class RouteMixin(metaclass=SanicMeta):
content_type=None,
__file_uri__=None,
):
# Using this to determine if the URL is trying to break out of the path
# served. os.path.realpath seems to be very slow
if __file_uri__ and "../" in __file_uri__:
raise BadRequest("Invalid URL")
# Merge served directory and requested file if provided
# Strip all / that in the beginning of the URL to help prevent python
# from herping a derp and treating the uri as an absolute path
root_path = file_path = file_or_directory
if __file_uri__:
file_path = path.join(
file_or_directory, sub("^[/]*", "", __file_uri__)
)
not_found = FileNotFound(
"File not found",
path=file_or_directory,
relative_url=__file_uri__,
)
# Merge served directory and requested file if provided
file_path = await self._get_file_path(
file_or_directory, __file_uri__, not_found
)
# URL decode the path sent by the browser otherwise we won't be able to
# match filenames which got encoded (filenames with spaces etc)
file_path = path.abspath(unquote(file_path))
if not file_path.startswith(path.abspath(unquote(root_path))):
error_logger.exception(
f"File not found: path={file_or_directory}, "
f"relative_url={__file_uri__}"
)
raise FileNotFound(
"File not found",
path=file_or_directory,
relative_url=__file_uri__,
)
try:
headers = {}
# Check if the client has been sent this file before
@@ -839,15 +844,13 @@ class RouteMixin(metaclass=SanicMeta):
stats = None
if use_modified_since:
stats = await stat_async(file_path)
modified_since = strftime(
"%a, %d %b %Y %H:%M:%S GMT", gmtime(stats.st_mtime)
modified_since = stats.st_mtime
response = await validate_file(request.headers, modified_since)
if response:
return response
headers["Last-Modified"] = formatdate(
modified_since, usegmt=True
)
if (
request.headers.getone("if-modified-since", None)
== modified_since
):
return HTTPResponse(status=304)
headers["Last-Modified"] = modified_since
_range = None
if use_content_range:
_range = None
@@ -862,8 +865,7 @@ class RouteMixin(metaclass=SanicMeta):
pass
else:
del headers["Content-Length"]
for key, value in _range.headers.items():
headers[key] = value
headers.update(_range.headers)
if "content-type" not in headers:
content_type = (
@@ -899,11 +901,7 @@ class RouteMixin(metaclass=SanicMeta):
except RangeNotSatisfiable:
raise
except FileNotFoundError:
raise FileNotFound(
"File not found",
path=file_or_directory,
relative_url=__file_uri__,
)
raise not_found
except Exception:
error_logger.exception(
f"Exception in static request handler: "
@@ -960,6 +958,7 @@ class RouteMixin(metaclass=SanicMeta):
# serve from the folder
if not static.resource_type:
if not path.isfile(file_or_directory):
uri = uri.rstrip("/")
uri += "/<__file_uri__:path>"
elif static.resource_type == "dir":
if path.isfile(file_or_directory):
@@ -967,6 +966,7 @@ class RouteMixin(metaclass=SanicMeta):
"Resource type improperly identified as directory. "
f"'{file_or_directory}'"
)
uri = uri.rstrip("/")
uri += "/<__file_uri__:path>"
elif static.resource_type == "file" and not path.isfile(
file_or_directory
@@ -1041,24 +1041,12 @@ class RouteMixin(metaclass=SanicMeta):
return types
def _build_route_context(self, raw):
def _build_route_context(self, raw: Dict[str, Any]) -> HashableDict:
ctx_kwargs = {
key.replace("ctx_", ""): raw.pop(key)
for key in {**raw}.keys()
if key.startswith("ctx_")
}
restricted = [
key for key in ctx_kwargs.keys() if key in RESTRICTED_ROUTE_CONTEXT
]
if restricted:
restricted_arguments = ", ".join(restricted)
raise AttributeError(
"Cannot use restricted route context: "
f"{restricted_arguments}. This limitation is only in place "
"until v22.9 when the restricted names will no longer be in"
"conflict. See https://github.com/sanic-org/sanic/issues/2303 "
"for more information."
)
if raw:
unexpected_arguments = ", ".join(raw.keys())
raise TypeError(

View File

@@ -20,7 +20,7 @@ class SignalMixin(metaclass=SanicMeta):
event: Union[str, Enum],
*,
apply: bool = True,
condition: Dict[str, Any] = None,
condition: Optional[Dict[str, Any]] = None,
exclusive: bool = True,
) -> Callable[[SignalHandler], SignalHandler]:
"""
@@ -64,7 +64,7 @@ class SignalMixin(metaclass=SanicMeta):
self,
handler: Optional[Callable[..., Any]],
event: str,
condition: Dict[str, Any] = None,
condition: Optional[Dict[str, Any]] = None,
exclusive: bool = True,
):
if not handler:

View File

@@ -16,14 +16,18 @@ from asyncio import (
from contextlib import suppress
from functools import partial
from importlib import import_module
from multiprocessing import Manager, Pipe, get_context
from multiprocessing.context import BaseContext
from pathlib import Path
from socket import socket
from socket import SHUT_RDWR, socket
from ssl import SSLContext
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
List,
Mapping,
Optional,
Set,
Tuple,
@@ -32,24 +36,34 @@ from typing import (
cast,
)
from sanic import reloader_helpers
from sanic.application.ext import setup_ext
from sanic.application.logo import get_logo
from sanic.application.motd import MOTD
from sanic.application.state import ApplicationServerInfo, Mode, ServerStage
from sanic.base.meta import SanicMeta
from sanic.compat import OS_IS_WINDOWS, is_atty
from sanic.helpers import _default
from sanic.compat import OS_IS_WINDOWS, StartMethod, is_atty
from sanic.exceptions import ServerKilled
from sanic.helpers import Default, _default
from sanic.http.constants import HTTP
from sanic.http.tls import get_ssl_context, process_to_context
from sanic.http.tls.context import SanicSSLContext
from sanic.log import Colors, deprecation, error_logger, logger
from sanic.models.handler_types import ListenerType
from sanic.server import Signal as ServerSignal
from sanic.server import try_use_uvloop
from sanic.server.async_server import AsyncioServer
from sanic.server.events import trigger_events
from sanic.server.legacy import watchdog
from sanic.server.loop import try_windows_loop
from sanic.server.protocols.http_protocol import HttpProtocol
from sanic.server.protocols.websocket_protocol import WebSocketProtocol
from sanic.server.runners import serve, serve_multiple, serve_single
from sanic.server.socket import configure_socket, remove_unix_socket
from sanic.worker.loader import AppLoader
from sanic.worker.manager import WorkerManager
from sanic.worker.multiplexer import WorkerMultiplexer
from sanic.worker.reloader import Reloader
from sanic.worker.serve import worker_serve
if TYPE_CHECKING:
@@ -59,20 +73,37 @@ if TYPE_CHECKING:
SANIC_PACKAGES = ("sanic-routing", "sanic-testing", "sanic-ext")
if sys.version_info < (3, 8):
if sys.version_info < (3, 8): # no cov
HTTPVersion = Union[HTTP, int]
else:
else: # no cov
from typing import Literal
HTTPVersion = Union[HTTP, Literal[1], Literal[3]]
class RunnerMixin(metaclass=SanicMeta):
class StartupMixin(metaclass=SanicMeta):
_app_registry: Dict[str, Sanic]
config: Config
listeners: Dict[str, List[ListenerType[Any]]]
state: ApplicationState
websocket_enabled: bool
multiplexer: WorkerMultiplexer
start_method: StartMethod = _default
def setup_loop(self):
if not self.asgi:
if self.config.USE_UVLOOP is True or (
isinstance(self.config.USE_UVLOOP, Default)
and not OS_IS_WINDOWS
):
try_use_uvloop()
elif OS_IS_WINDOWS:
try_windows_loop()
@property
def m(self) -> WorkerMultiplexer:
"""Interface for interacting with the worker processes"""
return self.multiplexer
def make_coffee(self, *args, **kwargs):
self.state.coffee = True
@@ -95,7 +126,7 @@ class RunnerMixin(metaclass=SanicMeta):
register_sys_signals: bool = True,
access_log: Optional[bool] = None,
unix: Optional[str] = None,
loop: AbstractEventLoop = None,
loop: Optional[AbstractEventLoop] = None,
reload_dir: Optional[Union[List[str], str]] = None,
noisy_exceptions: Optional[bool] = None,
motd: bool = True,
@@ -103,6 +134,8 @@ class RunnerMixin(metaclass=SanicMeta):
verbosity: int = 0,
motd_display: Optional[Dict[str, str]] = None,
auto_tls: bool = False,
single_process: bool = False,
legacy: bool = False,
) -> None:
"""
Run the HTTP Server and listen until keyboard interrupt or term
@@ -163,9 +196,17 @@ class RunnerMixin(metaclass=SanicMeta):
verbosity=verbosity,
motd_display=motd_display,
auto_tls=auto_tls,
single_process=single_process,
legacy=legacy,
)
self.__class__.serve(primary=self) # type: ignore
if single_process:
serve = self.__class__.serve_single
elif legacy:
serve = self.__class__.serve_legacy
else:
serve = self.__class__.serve
serve(primary=self) # type: ignore
def prepare(
self,
@@ -184,14 +225,17 @@ class RunnerMixin(metaclass=SanicMeta):
register_sys_signals: bool = True,
access_log: Optional[bool] = None,
unix: Optional[str] = None,
loop: AbstractEventLoop = None,
loop: Optional[AbstractEventLoop] = None,
reload_dir: Optional[Union[List[str], str]] = None,
noisy_exceptions: Optional[bool] = None,
motd: bool = True,
fast: bool = False,
verbosity: int = 0,
motd_display: Optional[Dict[str, str]] = None,
coffee: bool = False,
auto_tls: bool = False,
single_process: bool = False,
legacy: bool = False,
) -> None:
if version == 3 and self.state.server_info:
raise RuntimeError(
@@ -204,6 +248,9 @@ class RunnerMixin(metaclass=SanicMeta):
debug = True
auto_reload = True
if debug and access_log is None:
access_log = True
self.state.verbosity = verbosity
if not self.state.auto_reload:
self.state.auto_reload = bool(auto_reload)
@@ -211,6 +258,21 @@ class RunnerMixin(metaclass=SanicMeta):
if fast and workers != 1:
raise RuntimeError("You cannot use both fast=True and workers=X")
if single_process and (fast or (workers > 1) or auto_reload):
raise RuntimeError(
"Single process cannot be run with multiple workers "
"or auto-reload"
)
if single_process and legacy:
raise RuntimeError("Cannot run single process and legacy mode")
if register_sys_signals is False and not (single_process or legacy):
raise RuntimeError(
"Cannot run Sanic.serve with register_sys_signals=False. "
"Use either Sanic.serve_single or Sanic.serve_legacy."
)
if motd_display:
self.config.MOTD_DISPLAY.update(motd_display)
@@ -234,12 +296,6 @@ class RunnerMixin(metaclass=SanicMeta):
"#asynchronous-support"
)
if (
self.__class__.should_auto_reload()
and os.environ.get("SANIC_SERVER_RUNNING") != "true"
): # no cov
return
if sock is None:
host, port = self.get_address(host, port, version, auto_tls)
@@ -265,6 +321,9 @@ class RunnerMixin(metaclass=SanicMeta):
except AttributeError: # no cov
workers = os.cpu_count() or 1
if coffee:
self.state.coffee = True
server_settings = self._helper(
host=host,
port=port,
@@ -283,10 +342,10 @@ class RunnerMixin(metaclass=SanicMeta):
ApplicationServerInfo(settings=server_settings)
)
if self.config.USE_UVLOOP is True or (
self.config.USE_UVLOOP is _default and not OS_IS_WINDOWS
):
try_use_uvloop()
# if self.config.USE_UVLOOP is True or (
# self.config.USE_UVLOOP is _default and not OS_IS_WINDOWS
# ):
# try_use_uvloop()
async def create_server(
self,
@@ -296,12 +355,12 @@ class RunnerMixin(metaclass=SanicMeta):
debug: bool = False,
ssl: Union[None, SSLContext, dict, str, list, tuple] = None,
sock: Optional[socket] = None,
protocol: Type[Protocol] = None,
protocol: Optional[Type[Protocol]] = None,
backlog: int = 100,
access_log: Optional[bool] = None,
unix: Optional[str] = None,
return_asyncio_server: bool = False,
asyncio_server_kwargs: Dict[str, Any] = None,
asyncio_server_kwargs: Optional[Dict[str, Any]] = None,
noisy_exceptions: Optional[bool] = None,
) -> Optional[AsyncioServer]:
"""
@@ -375,7 +434,7 @@ class RunnerMixin(metaclass=SanicMeta):
run_async=return_asyncio_server,
)
if self.config.USE_UVLOOP is not _default:
if not isinstance(self.config.USE_UVLOOP, Default):
error_logger.warning(
"You are trying to change the uvloop configuration, but "
"this is only effective when using the run(...) method. "
@@ -395,18 +454,23 @@ class RunnerMixin(metaclass=SanicMeta):
asyncio_server_kwargs=asyncio_server_kwargs, **server_settings
)
def stop(self):
def stop(self, terminate: bool = True, unregister: bool = False):
"""
This kills the Sanic
"""
if terminate and hasattr(self, "multiplexer"):
self.multiplexer.terminate()
if self.state.stage is not ServerStage.STOPPED:
self.shutdown_tasks(timeout=0)
self.shutdown_tasks(timeout=0) # type: ignore
for task in all_tasks():
with suppress(AttributeError):
if task.get_name() == "RunServer":
task.cancel()
get_event_loop().stop()
if unregister:
self.__class__.unregister_app(self) # type: ignore
def _helper(
self,
host: Optional[str] = None,
@@ -417,7 +481,7 @@ class RunnerMixin(metaclass=SanicMeta):
sock: Optional[socket] = None,
unix: Optional[str] = None,
workers: int = 1,
loop: AbstractEventLoop = None,
loop: Optional[AbstractEventLoop] = None,
protocol: Type[Protocol] = HttpProtocol,
backlog: int = 100,
register_sys_signals: bool = True,
@@ -468,7 +532,11 @@ class RunnerMixin(metaclass=SanicMeta):
self.motd(server_settings=server_settings)
if is_atty() and not self.state.is_debug:
if (
is_atty()
and not self.state.is_debug
and not os.environ.get("SANIC_IGNORE_PRODUCTION_WARNING")
):
error_logger.warning(
f"{Colors.YELLOW}Sanic is running in PRODUCTION mode. "
"Consider using '--debug' or '--dev' while actively "
@@ -494,85 +562,94 @@ class RunnerMixin(metaclass=SanicMeta):
def motd(
self,
serve_location: str = "",
server_settings: Optional[Dict[str, Any]] = None,
):
if serve_location:
deprecation(
"Specifying a serve_location in the MOTD is deprecated and "
"will be removed.",
22.9,
)
else:
serve_location = self.get_server_location(server_settings)
if (
os.environ.get("SANIC_WORKER_NAME")
or os.environ.get("SANIC_MOTD_OUTPUT")
or os.environ.get("SANIC_WORKER_PROCESS")
or os.environ.get("SANIC_SERVER_RUNNING")
):
return
serve_location = self.get_server_location(server_settings)
if self.config.MOTD:
mode = [f"{self.state.mode},"]
if self.state.fast:
mode.append("goin' fast")
if self.state.asgi:
mode.append("ASGI")
else:
if self.state.workers == 1:
mode.append("single worker")
else:
mode.append(f"w/ {self.state.workers} workers")
if server_settings:
server = ", ".join(
(
self.state.server,
server_settings["version"].display(), # type: ignore
)
)
else:
server = ""
display = {
"mode": " ".join(mode),
"server": server,
"python": platform.python_version(),
"platform": platform.platform(),
}
extra = {}
if self.config.AUTO_RELOAD:
reload_display = "enabled"
if self.state.reload_dirs:
reload_display += ", ".join(
[
"",
*(
str(path.absolute())
for path in self.state.reload_dirs
),
]
)
display["auto-reload"] = reload_display
packages = []
for package_name in SANIC_PACKAGES:
module_name = package_name.replace("-", "_")
try:
module = import_module(module_name)
packages.append(
f"{package_name}=={module.__version__}" # type: ignore
)
except ImportError:
...
if packages:
display["packages"] = ", ".join(packages)
if self.config.MOTD_DISPLAY:
extra.update(self.config.MOTD_DISPLAY)
logo = get_logo(coffee=self.state.coffee)
display, extra = self.get_motd_data(server_settings)
MOTD.output(logo, serve_location, display, extra)
def get_motd_data(
self, server_settings: Optional[Dict[str, Any]] = None
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
mode = [f"{self.state.mode},"]
if self.state.fast:
mode.append("goin' fast")
if self.state.asgi:
mode.append("ASGI")
else:
if self.state.workers == 1:
mode.append("single worker")
else:
mode.append(f"w/ {self.state.workers} workers")
if server_settings:
server = ", ".join(
(
self.state.server,
server_settings["version"].display(), # type: ignore
)
)
else:
server = "ASGI" if self.asgi else "unknown" # type: ignore
display = {
"mode": " ".join(mode),
"server": server,
"python": platform.python_version(),
"platform": platform.platform(),
}
extra = {}
if self.config.AUTO_RELOAD:
reload_display = "enabled"
if self.state.reload_dirs:
reload_display += ", ".join(
[
"",
*(
str(path.absolute())
for path in self.state.reload_dirs
),
]
)
display["auto-reload"] = reload_display
packages = []
for package_name in SANIC_PACKAGES:
module_name = package_name.replace("-", "_")
try:
module = import_module(module_name)
packages.append(
f"{package_name}=={module.__version__}" # type: ignore
)
except ImportError: # no cov
...
if packages:
display["packages"] = ", ".join(packages)
if self.config.MOTD_DISPLAY:
extra.update(self.config.MOTD_DISPLAY)
return display, extra
@property
def serve_location(self) -> str:
server_settings = self.state.server_info[0].settings
return self.get_server_location(server_settings)
try:
server_settings = self.state.server_info[0].settings
return self.get_server_location(server_settings)
except IndexError:
location = "ASGI" if self.asgi else "unknown" # type: ignore
return f"http://<{location}>"
@staticmethod
def get_server_location(
@@ -583,24 +660,20 @@ class RunnerMixin(metaclass=SanicMeta):
if not server_settings:
return serve_location
if server_settings["ssl"] is not None:
host = server_settings["host"]
port = server_settings["port"]
if server_settings.get("ssl") is not None:
proto = "https"
if server_settings["unix"]:
if server_settings.get("unix"):
serve_location = f'{server_settings["unix"]} {proto}://...'
elif server_settings["sock"]:
serve_location = (
f'{server_settings["sock"].getsockname()} {proto}://...'
)
elif server_settings["host"] and server_settings["port"]:
elif server_settings.get("sock"):
host, port, *_ = server_settings["sock"].getsockname()
if not serve_location and host and port:
# colon(:) is legal for a host only in an ipv6 address
display_host = (
f'[{server_settings["host"]}]'
if ":" in server_settings["host"]
else server_settings["host"]
)
serve_location = (
f'{proto}://{display_host}:{server_settings["port"]}'
)
display_host = f"[{host}]" if ":" in host else host
serve_location = f"{proto}://{display_host}:{port}"
return serve_location
@@ -620,7 +693,268 @@ class RunnerMixin(metaclass=SanicMeta):
return any(app.state.auto_reload for app in cls._app_registry.values())
@classmethod
def serve(cls, primary: Optional[Sanic] = None) -> None:
def _get_startup_method(cls) -> str:
return (
cls.start_method
if not isinstance(cls.start_method, Default)
else "spawn"
)
@classmethod
def _get_context(cls) -> BaseContext:
method = cls._get_startup_method()
logger.debug("Creating multiprocessing context using '%s'", method)
return get_context(method)
@classmethod
def serve(
cls,
primary: Optional[Sanic] = None,
*,
app_loader: Optional[AppLoader] = None,
factory: Optional[Callable[[], Sanic]] = None,
) -> None:
os.environ["SANIC_MOTD_OUTPUT"] = "true"
apps = list(cls._app_registry.values())
if factory:
primary = factory()
else:
if not primary:
if app_loader:
primary = app_loader.load()
if not primary:
try:
primary = apps[0]
except IndexError:
raise RuntimeError(
"Did not find any applications."
) from None
# This exists primarily for unit testing
if not primary.state.server_info: # no cov
for app in apps:
app.state.server_info.clear()
return
try:
primary_server_info = primary.state.server_info[0]
except IndexError:
raise RuntimeError(
f"No server information found for {primary.name}. Perhaps you "
"need to run app.prepare(...)?"
) from None
socks = []
sync_manager = Manager()
setup_ext(primary)
exit_code = 0
try:
primary_server_info.settings.pop("main_start", None)
primary_server_info.settings.pop("main_stop", None)
main_start = primary.listeners.get("main_process_start")
main_stop = primary.listeners.get("main_process_stop")
app = primary_server_info.settings.pop("app")
app.setup_loop()
loop = new_event_loop()
trigger_events(main_start, loop, primary)
socks = [
sock
for sock in [
configure_socket(server_info.settings)
for app in apps
for server_info in app.state.server_info
]
if sock
]
primary_server_info.settings["run_multiple"] = True
monitor_sub, monitor_pub = Pipe(True)
worker_state: Mapping[str, Any] = sync_manager.dict()
kwargs: Dict[str, Any] = {
**primary_server_info.settings,
"monitor_publisher": monitor_pub,
"worker_state": worker_state,
}
if not app_loader:
if factory:
app_loader = AppLoader(factory=factory)
else:
app_loader = AppLoader(
factory=partial(cls.get_app, app.name) # type: ignore
)
kwargs["app_name"] = app.name
kwargs["app_loader"] = app_loader
kwargs["server_info"] = {}
kwargs["passthru"] = {
"auto_reload": app.auto_reload,
"state": {
"verbosity": app.state.verbosity,
"mode": app.state.mode,
},
"config": {
"ACCESS_LOG": app.config.ACCESS_LOG,
"NOISY_EXCEPTIONS": app.config.NOISY_EXCEPTIONS,
},
"shared_ctx": app.shared_ctx.__dict__,
}
for app in apps:
kwargs["server_info"][app.name] = []
for server_info in app.state.server_info:
server_info.settings = {
k: v
for k, v in server_info.settings.items()
if k not in ("main_start", "main_stop", "app", "ssl")
}
kwargs["server_info"][app.name].append(server_info)
ssl = kwargs.get("ssl")
if isinstance(ssl, SanicSSLContext):
kwargs["ssl"] = kwargs["ssl"].sanic
manager = WorkerManager(
primary.state.workers,
worker_serve,
kwargs,
cls._get_context(),
(monitor_pub, monitor_sub),
worker_state,
)
if cls.should_auto_reload():
reload_dirs: Set[Path] = primary.state.reload_dirs.union(
*(app.state.reload_dirs for app in apps)
)
reloader = Reloader(monitor_pub, 0, reload_dirs, app_loader)
manager.manage("Reloader", reloader, {}, transient=False)
inspector = None
if primary.config.INSPECTOR:
display, extra = primary.get_motd_data()
packages = [
pkg.strip() for pkg in display["packages"].split(",")
]
module = import_module("sanic")
sanic_version = f"sanic=={module.__version__}" # type: ignore
app_info = {
**display,
"packages": [sanic_version, *packages],
"extra": extra,
}
inspector = primary.inspector_class(
monitor_pub,
app_info,
worker_state,
primary.config.INSPECTOR_HOST,
primary.config.INSPECTOR_PORT,
primary.config.INSPECTOR_API_KEY,
primary.config.INSPECTOR_TLS_KEY,
primary.config.INSPECTOR_TLS_CERT,
)
manager.manage("Inspector", inspector, {}, transient=False)
primary._inspector = inspector
primary._manager = manager
ready = primary.listeners["main_process_ready"]
trigger_events(ready, loop, primary)
manager.run()
except ServerKilled:
exit_code = 1
except BaseException:
kwargs = primary_server_info.settings
error_logger.exception(
"Experienced exception while trying to serve"
)
raise
finally:
logger.info("Server Stopped")
for app in apps:
app.state.server_info.clear()
app.router.reset()
app.signal_router.reset()
sync_manager.shutdown()
for sock in socks:
sock.shutdown(SHUT_RDWR)
sock.close()
socks = []
trigger_events(main_stop, loop, primary)
loop.close()
cls._cleanup_env_vars()
cls._cleanup_apps()
unix = kwargs.get("unix")
if unix:
remove_unix_socket(unix)
if exit_code:
os._exit(exit_code)
@classmethod
def serve_single(cls, primary: Optional[Sanic] = None) -> None:
os.environ["SANIC_MOTD_OUTPUT"] = "true"
apps = list(cls._app_registry.values())
if not primary:
try:
primary = apps[0]
except IndexError:
raise RuntimeError("Did not find any applications.")
# This exists primarily for unit testing
if not primary.state.server_info: # no cov
for app in apps:
app.state.server_info.clear()
return
primary_server_info = primary.state.server_info[0]
primary.before_server_start(partial(primary._start_servers, apps=apps))
kwargs = {
k: v
for k, v in primary_server_info.settings.items()
if k
not in (
"main_start",
"main_stop",
"app",
)
}
kwargs["app_name"] = primary.name
kwargs["app_loader"] = None
sock = configure_socket(kwargs)
kwargs["server_info"] = {}
kwargs["server_info"][primary.name] = []
for server_info in primary.state.server_info:
server_info.settings = {
k: v
for k, v in server_info.settings.items()
if k not in ("main_start", "main_stop", "app")
}
kwargs["server_info"][primary.name].append(server_info)
try:
worker_serve(monitor_publisher=None, **kwargs)
except BaseException:
error_logger.exception(
"Experienced exception while trying to serve"
)
raise
finally:
logger.info("Server Stopped")
for app in apps:
app.state.server_info.clear()
app.router.reset()
app.signal_router.reset()
if sock:
sock.close()
cls._cleanup_env_vars()
cls._cleanup_apps()
@classmethod
def serve_legacy(cls, primary: Optional[Sanic] = None) -> None:
apps = list(cls._app_registry.values())
if not primary:
@@ -641,7 +975,7 @@ class RunnerMixin(metaclass=SanicMeta):
reload_dirs: Set[Path] = primary.state.reload_dirs.union(
*(app.state.reload_dirs for app in apps)
)
reloader_helpers.watchdog(1.0, reload_dirs)
watchdog(1.0, reload_dirs)
trigger_events(reloader_stop, loop, primary)
return
@@ -654,11 +988,17 @@ class RunnerMixin(metaclass=SanicMeta):
primary_server_info = primary.state.server_info[0]
primary.before_server_start(partial(primary._start_servers, apps=apps))
deprecation(
f"{Colors.YELLOW}Running {Colors.SANIC}Sanic {Colors.YELLOW}w/ "
f"LEGACY manager.{Colors.END} Support for will be dropped in "
"version 23.3.",
23.3,
)
try:
primary_server_info.stage = ServerStage.SERVING
if primary.state.workers > 1 and os.name != "posix": # no cov
logger.warn(
logger.warning(
f"Multiprocessing is currently not supported on {os.name},"
" using workers=1 instead"
)
@@ -679,10 +1019,9 @@ class RunnerMixin(metaclass=SanicMeta):
finally:
primary_server_info.stage = ServerStage.STOPPED
logger.info("Server Stopped")
for app in apps:
app.state.server_info.clear()
app.router.reset()
app.signal_router.reset()
cls._cleanup_env_vars()
cls._cleanup_apps()
async def _start_servers(
self,
@@ -720,7 +1059,7 @@ class RunnerMixin(metaclass=SanicMeta):
*server_info.settings.pop("main_start", []),
*server_info.settings.pop("main_stop", []),
]
if handlers:
if handlers: # no cov
error_logger.warning(
f"Sanic found {len(handlers)} listener(s) on "
"secondary applications attached to the main "
@@ -733,12 +1072,15 @@ class RunnerMixin(metaclass=SanicMeta):
if not server_info.settings["loop"]:
server_info.settings["loop"] = get_running_loop()
serve_args: Dict[str, Any] = {
**server_info.settings,
"run_async": True,
"reuse_port": bool(primary.state.workers - 1),
}
if "app" not in serve_args:
serve_args["app"] = app
try:
server_info.server = await serve(
**server_info.settings,
run_async=True,
reuse_port=bool(primary.state.workers - 1),
)
server_info.server = await serve(**serve_args)
except OSError as e: # no cov
first_message = (
"An OSError was detected on startup. "
@@ -764,10 +1106,9 @@ class RunnerMixin(metaclass=SanicMeta):
async def _run_server(
self,
app: RunnerMixin,
app: StartupMixin,
server_info: ApplicationServerInfo,
) -> None:
) -> None: # no cov
try:
# We should never get to this point without a server
# This is primarily to keep mypy happy
@@ -790,3 +1131,26 @@ class RunnerMixin(metaclass=SanicMeta):
finally:
server_info.stage = ServerStage.STOPPED
server_info.server = None
@staticmethod
def _cleanup_env_vars():
variables = (
"SANIC_RELOADER_PROCESS",
"SANIC_IGNORE_PRODUCTION_WARNING",
"SANIC_WORKER_NAME",
"SANIC_MOTD_OUTPUT",
"SANIC_WORKER_PROCESS",
"SANIC_SERVER_RUNNING",
)
for var in variables:
try:
del os.environ[var]
except KeyError:
...
@classmethod
def _cleanup_apps(cls):
for app in cls._app_registry.values():
app.state.server_info.clear()
app.router.reset()
app.signal_router.reset()

View File

@@ -21,6 +21,7 @@ class ConnInfo:
"client",
"client_ip",
"ctx",
"lost",
"peername",
"server_port",
"server",
@@ -33,6 +34,7 @@ class ConnInfo:
def __init__(self, transport: TransportProtocol, unix=None):
self.ctx = SimpleNamespace()
self.lost = False
self.peername = None
self.server = self.client = ""
self.server_port = self.client_port = 0

View File

@@ -27,6 +27,7 @@ if TYPE_CHECKING:
from sanic.app import Sanic
import email.utils
import unicodedata
import uuid
from collections import defaultdict
@@ -38,7 +39,12 @@ from httptools import parse_url
from httptools.parser.errors import HttpParserInvalidURLError
from sanic.compat import CancelledErrors, Header
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
from sanic.constants import (
CACHEABLE_HTTP_METHODS,
DEFAULT_HTTP_CONTENT_TYPE,
IDEMPOTENT_HTTP_METHODS,
SAFE_HTTP_METHODS,
)
from sanic.exceptions import BadRequest, BadURL, ServerError
from sanic.headers import (
AcceptContainer,
@@ -51,7 +57,7 @@ from sanic.headers import (
parse_xforwarded,
)
from sanic.http import Stage
from sanic.log import error_logger, logger
from sanic.log import deprecation, error_logger, logger
from sanic.models.protocol_types import TransportProtocol
from sanic.response import BaseHTTPResponse, HTTPResponse
@@ -98,6 +104,8 @@ class Request:
"_port",
"_protocol",
"_remote_addr",
"_request_middleware_started",
"_response_middleware_started",
"_scheme",
"_socket",
"_stream_id",
@@ -121,7 +129,6 @@ class Request:
"parsed_token",
"raw_url",
"responded",
"request_middleware_started",
"route",
"stream",
"transport",
@@ -139,7 +146,6 @@ class Request:
head: bytes = b"",
stream_id: int = 0,
):
self.raw_url = url_bytes
try:
self._parsed_url = parse_url(url_bytes)
@@ -173,7 +179,8 @@ class Request:
self.parsed_not_grouped_args: DefaultDict[
Tuple[bool, bool, str, str], List[Tuple[str, str]]
] = defaultdict(list)
self.request_middleware_started = False
self._request_middleware_started = False
self._response_middleware_started = False
self.responded: bool = False
self.route: Optional[Route] = None
self.stream: Optional[Stream] = None
@@ -188,7 +195,7 @@ class Request:
@classmethod
def get_current(cls) -> Request:
"""
Retrieve the currrent request object
Retrieve the current request object
This implements `Context Variables
<https://docs.python.org/3/library/contextvars.html>`_
@@ -214,6 +221,16 @@ class Request:
def generate_id(*_):
return uuid.uuid4()
@property
def request_middleware_started(self):
deprecation(
"Request.request_middleware_started has been deprecated and will"
"be removed. You should set a flag on the request context using"
"either middleware or signals if you need this feature.",
23.3,
)
return self._request_middleware_started
@property
def stream_id(self):
"""
@@ -319,9 +336,14 @@ class Request:
response = await response # type: ignore
# Run response middleware
try:
response = await self.app._run_response_middleware(
self, response, request_name=self.name
)
middleware = (
self.route and self.route.extra.response_middleware
) or self.app.response_middleware
if middleware and not self._response_middleware_started:
self._response_middleware_started = True
response = await self.app._run_response_middleware(
self, response, middleware
)
except CancelledErrors:
raise
except Exception:
@@ -975,6 +997,33 @@ class Request:
return self.transport.scope
@property
def is_safe(self) -> bool:
"""
:return: Whether the HTTP method is safe.
See https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.1
:rtype: bool
"""
return self.method in SAFE_HTTP_METHODS
@property
def is_idempotent(self) -> bool:
"""
:return: Whether the HTTP method is iempotent.
See https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.2
:rtype: bool
"""
return self.method in IDEMPOTENT_HTTP_METHODS
@property
def is_cacheable(self) -> bool:
"""
:return: Whether the HTTP method is cacheable.
See https://datatracker.ietf.org/doc/html/rfc7231#section-4.2.3
:rtype: bool
"""
return self.method in CACHEABLE_HTTP_METHODS
class File(NamedTuple):
"""
@@ -1035,6 +1084,16 @@ def parse_multipart_form(body, boundary):
form_parameters["filename*"]
)
file_name = unquote(value, encoding=encoding)
# Normalize to NFC (Apple MacOS/iOS send NFD)
# Notes:
# - No effect for Windows, Linux or Android clients which
# already send NFC
# - Python open() is tricky (creates files in NFC no matter
# which form you use)
if file_name is not None:
file_name = unicodedata.normalize("NFC", file_name)
elif form_header_field == "content-type":
content_type = form_header_value
content_charset = form_parameters.get("charset", "utf-8")

View File

@@ -0,0 +1,36 @@
from .convenience import (
empty,
file,
file_stream,
html,
json,
raw,
redirect,
text,
validate_file,
)
from .types import (
BaseHTTPResponse,
HTTPResponse,
JSONResponse,
ResponseStream,
json_dumps,
)
__all__ = (
"BaseHTTPResponse",
"HTTPResponse",
"JSONResponse",
"ResponseStream",
"empty",
"json",
"text",
"raw",
"html",
"validate_file",
"file",
"redirect",
"file_stream",
"json_dumps",
)

View File

@@ -1,216 +1,25 @@
from __future__ import annotations
from datetime import datetime
from email.utils import formatdate
from functools import partial
from datetime import datetime, timezone
from email.utils import formatdate, parsedate_to_datetime
from mimetypes import guess_type
from os import path
from pathlib import PurePath
from time import time
from typing import (
TYPE_CHECKING,
Any,
AnyStr,
Callable,
Coroutine,
Dict,
Iterator,
Optional,
Tuple,
TypeVar,
Union,
)
from typing import Any, AnyStr, Callable, Dict, Optional, Union
from urllib.parse import quote_plus
from sanic.compat import Header, open_async, stat_async
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
from sanic.cookies import CookieJar
from sanic.exceptions import SanicException, ServerError
from sanic.helpers import (
Default,
_default,
has_message_body,
remove_entity_headers,
)
from sanic.http import Http
from sanic.helpers import Default, _default
from sanic.log import logger
from sanic.models.protocol_types import HTMLProtocol, Range
if TYPE_CHECKING:
from sanic.asgi import ASGIApp
from sanic.http.http3 import HTTPReceiver
from sanic.request import Request
else:
Request = TypeVar("Request")
try:
from ujson import dumps as json_dumps
except ImportError:
# This is done in order to ensure that the JSON response is
# kept consistent across both ujson and inbuilt json usage.
from json import dumps
json_dumps = partial(dumps, separators=(",", ":"))
class BaseHTTPResponse:
"""
The base class for all HTTP Responses
"""
__slots__ = (
"asgi",
"body",
"content_type",
"stream",
"status",
"headers",
"_cookies",
)
_dumps = json_dumps
def __init__(self):
self.asgi: bool = False
self.body: Optional[bytes] = None
self.content_type: Optional[str] = None
self.stream: Optional[Union[Http, ASGIApp, HTTPReceiver]] = None
self.status: int = None
self.headers = Header({})
self._cookies: Optional[CookieJar] = None
def __repr__(self):
class_name = self.__class__.__name__
return f"<{class_name}: {self.status} {self.content_type}>"
def _encode_body(self, data: Optional[AnyStr]):
if data is None:
return b""
return (
data.encode() if hasattr(data, "encode") else data # type: ignore
)
@property
def cookies(self) -> CookieJar:
"""
The response cookies. Cookies should be set and written as follows:
.. code-block:: python
response.cookies["test"] = "It worked!"
response.cookies["test"]["domain"] = ".yummy-yummy-cookie.com"
response.cookies["test"]["httponly"] = True
`See user guide re: cookies
<https://sanicframework.org/guide/basics/cookies.html>`__
:return: the cookie jar
:rtype: CookieJar
"""
if self._cookies is None:
self._cookies = CookieJar(self.headers)
return self._cookies
@property
def processed_headers(self) -> Iterator[Tuple[bytes, bytes]]:
"""
Obtain a list of header tuples encoded in bytes for sending.
Add and remove headers based on status and content_type.
:return: response headers
:rtype: Tuple[Tuple[bytes, bytes], ...]
"""
# TODO: Make a blacklist set of header names and then filter with that
if self.status in (304, 412): # Not Modified, Precondition Failed
self.headers = remove_entity_headers(self.headers)
if has_message_body(self.status):
self.headers.setdefault("content-type", self.content_type)
# Encode headers into bytes
return (
(name.encode("ascii"), f"{value}".encode(errors="surrogateescape"))
for name, value in self.headers.items()
)
async def send(
self,
data: Optional[AnyStr] = None,
end_stream: Optional[bool] = None,
) -> None:
"""
Send any pending response headers and the given data as body.
:param data: str or bytes to be written
:param end_stream: whether to close the stream after this block
"""
if data is None and end_stream is None:
end_stream = True
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")
else data or b""
)
await self.stream.send(
data, # type: ignore
end_stream=end_stream or False,
)
class HTTPResponse(BaseHTTPResponse):
"""
HTTP response to be sent back to the client.
:param body: the body content to be returned
:type body: Optional[bytes]
:param status: HTTP response number. **Default=200**
:type status: int
:param headers: headers to be returned
:type headers: Optional;
:param content_type: content type to be returned (as a header)
:type content_type: Optional[str]
"""
__slots__ = ()
def __init__(
self,
body: Optional[AnyStr] = None,
status: int = 200,
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None,
):
super().__init__()
self.content_type: Optional[str] = content_type
self.body = self._encode_body(body)
self.status = status
self.headers = Header(headers or {})
self._cookies = None
async def eof(self):
await self.send("", True)
async def __aenter__(self):
return self.send
async def __aexit__(self, *_):
await self.eof()
from .types import HTTPResponse, JSONResponse, ResponseStream
def empty(
status=204, headers: Optional[Dict[str, str]] = None
status: int = 204, headers: Optional[Dict[str, str]] = None
) -> HTTPResponse:
"""
Returns an empty response to the client.
@@ -227,8 +36,8 @@ def json(
headers: Optional[Dict[str, str]] = None,
content_type: str = "application/json",
dumps: Optional[Callable[..., str]] = None,
**kwargs,
) -> HTTPResponse:
**kwargs: Any,
) -> JSONResponse:
"""
Returns response object with body in json format.
@@ -237,13 +46,14 @@ def json(
:param headers: Custom Headers.
:param kwargs: Remaining arguments that are passed to the json encoder.
"""
if not dumps:
dumps = BaseHTTPResponse._dumps
return HTTPResponse(
dumps(body, **kwargs),
headers=headers,
return JSONResponse(
body,
status=status,
headers=headers,
content_type=content_type,
dumps=dumps,
**kwargs,
)
@@ -319,9 +129,34 @@ def html(
)
async def validate_file(
request_headers: Header, last_modified: Union[datetime, float, int]
):
try:
if_modified_since = request_headers.getone("If-Modified-Since")
except KeyError:
return
try:
if_modified_since = parsedate_to_datetime(if_modified_since)
except (TypeError, ValueError):
logger.warning(
"Ignorning invalid If-Modified-Since header received: " "'%s'",
if_modified_since,
)
return
if not isinstance(last_modified, datetime):
last_modified = datetime.fromtimestamp(
float(last_modified), tz=timezone.utc
).replace(microsecond=0)
if last_modified <= if_modified_since:
return HTTPResponse(status=304)
async def file(
location: Union[str, PurePath],
status: int = 200,
request_headers: Optional[Header] = None,
validate_when_requested: bool = True,
mime_type: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
filename: Optional[str] = None,
@@ -331,7 +166,12 @@ async def file(
_range: Optional[Range] = None,
) -> HTTPResponse:
"""Return a response object with file data.
:param status: HTTP response code. Won't enforce the passed in
status if only a part of the content will be sent (206)
or file is being validated (304).
:param request_headers: The request headers.
:param validate_when_requested: If True, will validate the
file when requested.
:param location: Location of file on system.
:param mime_type: Specific mime_type.
:param headers: Custom Headers.
@@ -341,11 +181,6 @@ async def file(
:param no_store: Any cache should not store this response.
:param _range:
"""
headers = headers or {}
if filename:
headers.setdefault(
"Content-Disposition", f'attachment; filename="{filename}"'
)
if isinstance(last_modified, datetime):
last_modified = last_modified.replace(microsecond=0).timestamp()
@@ -353,9 +188,24 @@ async def file(
stat = await stat_async(location)
last_modified = stat.st_mtime
if (
validate_when_requested
and request_headers is not None
and last_modified
):
response = await validate_file(request_headers, last_modified)
if response:
return response
headers = headers or {}
if last_modified:
headers.setdefault(
"last-modified", formatdate(last_modified, usegmt=True)
"Last-Modified", formatdate(last_modified, usegmt=True)
)
if filename:
headers.setdefault(
"Content-Disposition", f'attachment; filename="{filename}"'
)
if no_store:
@@ -424,80 +274,6 @@ def redirect(
)
class ResponseStream:
"""
ResponseStream is a compat layer to bridge the gap after the deprecation
of StreamingHTTPResponse. It will be removed when:
- file_stream is moved to new style streaming
- file and file_stream are combined into a single API
"""
__slots__ = (
"_cookies",
"content_type",
"headers",
"request",
"response",
"status",
"streaming_fn",
)
def __init__(
self,
streaming_fn: Callable[
[Union[BaseHTTPResponse, ResponseStream]],
Coroutine[Any, Any, None],
],
status: int = 200,
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None,
):
self.streaming_fn = streaming_fn
self.status = status
self.headers = headers or Header()
self.content_type = content_type
self.request: Optional[Request] = None
self._cookies: Optional[CookieJar] = None
async def write(self, message: str):
await self.response.send(message)
async def stream(self) -> HTTPResponse:
if not self.request:
raise ServerError("Attempted response to unknown request")
self.response = await self.request.respond(
headers=self.headers,
status=self.status,
content_type=self.content_type,
)
await self.streaming_fn(self)
return self.response
async def eof(self) -> None:
await self.response.eof()
@property
def cookies(self) -> CookieJar:
if self._cookies is None:
self._cookies = CookieJar(self.headers)
return self._cookies
@property
def processed_headers(self):
return self.response.processed_headers
@property
def body(self):
return self.response.body
def __call__(self, request: Request) -> ResponseStream:
self.request = request
return self
def __await__(self):
return self.stream().__await__()
async def file_stream(
location: Union[str, PurePath],
status: int = 200,

453
sanic/response/types.py Normal file
View File

@@ -0,0 +1,453 @@
from __future__ import annotations
from functools import partial
from typing import (
TYPE_CHECKING,
Any,
AnyStr,
Callable,
Coroutine,
Dict,
Iterator,
Optional,
Tuple,
TypeVar,
Union,
)
from sanic.compat import Header
from sanic.cookies import CookieJar
from sanic.exceptions import SanicException, ServerError
from sanic.helpers import (
Default,
_default,
has_message_body,
remove_entity_headers,
)
from sanic.http import Http
if TYPE_CHECKING:
from sanic.asgi import ASGIApp
from sanic.http.http3 import HTTPReceiver
from sanic.request import Request
else:
Request = TypeVar("Request")
try:
from ujson import dumps as json_dumps
except ImportError:
# This is done in order to ensure that the JSON response is
# kept consistent across both ujson and inbuilt json usage.
from json import dumps
json_dumps = partial(dumps, separators=(",", ":"))
class BaseHTTPResponse:
"""
The base class for all HTTP Responses
"""
__slots__ = (
"asgi",
"body",
"content_type",
"stream",
"status",
"headers",
"_cookies",
)
_dumps = json_dumps
def __init__(self):
self.asgi: bool = False
self.body: Optional[bytes] = None
self.content_type: Optional[str] = None
self.stream: Optional[Union[Http, ASGIApp, HTTPReceiver]] = None
self.status: int = None
self.headers = Header({})
self._cookies: Optional[CookieJar] = None
def __repr__(self):
class_name = self.__class__.__name__
return f"<{class_name}: {self.status} {self.content_type}>"
def _encode_body(self, data: Optional[AnyStr]):
if data is None:
return b""
return (
data.encode() if hasattr(data, "encode") else data # type: ignore
)
@property
def cookies(self) -> CookieJar:
"""
The response cookies. Cookies should be set and written as follows:
.. code-block:: python
response.cookies["test"] = "It worked!"
response.cookies["test"]["domain"] = ".yummy-yummy-cookie.com"
response.cookies["test"]["httponly"] = True
`See user guide re: cookies
<https://sanic.dev/en/guide/basics/cookies.html>`
:return: the cookie jar
:rtype: CookieJar
"""
if self._cookies is None:
self._cookies = CookieJar(self.headers)
return self._cookies
@property
def processed_headers(self) -> Iterator[Tuple[bytes, bytes]]:
"""
Obtain a list of header tuples encoded in bytes for sending.
Add and remove headers based on status and content_type.
:return: response headers
:rtype: Tuple[Tuple[bytes, bytes], ...]
"""
# TODO: Make a blacklist set of header names and then filter with that
if self.status in (304, 412): # Not Modified, Precondition Failed
self.headers = remove_entity_headers(self.headers)
if has_message_body(self.status):
self.headers.setdefault("content-type", self.content_type)
# Encode headers into bytes
return (
(name.encode("ascii"), f"{value}".encode(errors="surrogateescape"))
for name, value in self.headers.items()
)
async def send(
self,
data: Optional[AnyStr] = None,
end_stream: Optional[bool] = None,
) -> None:
"""
Send any pending response headers and the given data as body.
:param data: str or bytes to be written
:param end_stream: whether to close the stream after this block
"""
if data is None and end_stream is None:
end_stream = True
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")
else data or b""
)
await self.stream.send(
data, # type: ignore
end_stream=end_stream or False,
)
class HTTPResponse(BaseHTTPResponse):
"""
HTTP response to be sent back to the client.
:param body: the body content to be returned
:type body: Optional[bytes]
:param status: HTTP response number. **Default=200**
:type status: int
:param headers: headers to be returned
:type headers: Optional;
:param content_type: content type to be returned (as a header)
:type content_type: Optional[str]
"""
__slots__ = ()
def __init__(
self,
body: Optional[Any] = None,
status: int = 200,
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None,
):
super().__init__()
self.content_type: Optional[str] = content_type
self.body = self._encode_body(body)
self.status = status
self.headers = Header(headers or {})
self._cookies = None
async def eof(self):
await self.send("", True)
async def __aenter__(self):
return self.send
async def __aexit__(self, *_):
await self.eof()
class JSONResponse(HTTPResponse):
"""
HTTP response to be sent back to the client, when the response
is of json type. Offers several utilities to manipulate common
json data types.
:param body: the body content to be returned
:type body: Optional[Any]
:param status: HTTP response number. **Default=200**
:type status: int
:param headers: headers to be returned
:type headers: Optional
:param content_type: content type to be returned (as a header)
:type content_type: Optional[str]
:param dumps: json.dumps function to use
:type dumps: Optional[Callable]
"""
__slots__ = (
"_body",
"_body_manually_set",
"_initialized",
"_raw_body",
"_use_dumps",
"_use_dumps_kwargs",
)
def __init__(
self,
body: Optional[Any] = None,
status: int = 200,
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: str = "application/json",
dumps: Optional[Callable[..., str]] = None,
**kwargs: Any,
):
self._initialized = False
self._body_manually_set = False
self._use_dumps = dumps or BaseHTTPResponse._dumps
self._use_dumps_kwargs = kwargs
self._raw_body = body
super().__init__(
self._encode_body(self._use_dumps(body, **self._use_dumps_kwargs)),
headers=headers,
status=status,
content_type=content_type,
)
self._initialized = True
def _check_body_not_manually_set(self):
if self._body_manually_set:
raise SanicException(
"Cannot use raw_body after body has been manually set."
)
@property
def raw_body(self) -> Optional[Any]:
"""Returns the raw body, as long as body has not been manually
set previously.
NOTE: This object should not be mutated, as it will not be
reflected in the response body. If you need to mutate the
response body, consider using one of the provided methods in
this class or alternatively call set_body() with the mutated
object afterwards or set the raw_body property to it.
"""
self._check_body_not_manually_set()
return self._raw_body
@raw_body.setter
def raw_body(self, value: Any):
self._body_manually_set = False
self._body = self._encode_body(
self._use_dumps(value, **self._use_dumps_kwargs)
)
self._raw_body = value
@property # type: ignore
def body(self) -> Optional[bytes]: # type: ignore
return self._body
@body.setter
def body(self, value: Optional[bytes]):
self._body = value
if not self._initialized:
return
self._body_manually_set = True
def set_body(
self,
body: Any,
dumps: Optional[Callable[..., str]] = None,
**dumps_kwargs: Any,
) -> None:
"""Sets a new response body using the given dumps function
and kwargs, or falling back to the defaults given when
creating the object if none are specified.
"""
self._body_manually_set = False
self._raw_body = body
use_dumps = dumps or self._use_dumps
use_dumps_kwargs = dumps_kwargs if dumps else self._use_dumps_kwargs
self._body = self._encode_body(use_dumps(body, **use_dumps_kwargs))
def append(self, value: Any) -> None:
"""Appends a value to the response raw_body, ensuring that
body is kept up to date. This can only be used if raw_body
is a list.
"""
self._check_body_not_manually_set()
if not isinstance(self._raw_body, list):
raise SanicException("Cannot append to a non-list object.")
self._raw_body.append(value)
self.raw_body = self._raw_body
def extend(self, value: Any) -> None:
"""Extends the response's raw_body with the given values, ensuring
that body is kept up to date. This can only be used if raw_body is
a list.
"""
self._check_body_not_manually_set()
if not isinstance(self._raw_body, list):
raise SanicException("Cannot extend a non-list object.")
self._raw_body.extend(value)
self.raw_body = self._raw_body
def update(self, *args, **kwargs) -> None:
"""Updates the response's raw_body with the given values, ensuring
that body is kept up to date. This can only be used if raw_body is
a dict.
"""
self._check_body_not_manually_set()
if not isinstance(self._raw_body, dict):
raise SanicException("Cannot update a non-dict object.")
self._raw_body.update(*args, **kwargs)
self.raw_body = self._raw_body
def pop(self, key: Any, default: Any = _default) -> Any:
"""Pops a key from the response's raw_body, ensuring that body is
kept up to date. This can only be used if raw_body is a dict or a
list.
"""
self._check_body_not_manually_set()
if not isinstance(self._raw_body, (list, dict)):
raise SanicException(
"Cannot pop from a non-list and non-dict object."
)
if isinstance(default, Default):
value = self._raw_body.pop(key)
elif isinstance(self._raw_body, list):
raise TypeError("pop doesn't accept a default argument for lists")
else:
value = self._raw_body.pop(key, default)
self.raw_body = self._raw_body
return value
class ResponseStream:
"""
ResponseStream is a compat layer to bridge the gap after the deprecation
of StreamingHTTPResponse. It will be removed when:
- file_stream is moved to new style streaming
- file and file_stream are combined into a single API
"""
__slots__ = (
"_cookies",
"content_type",
"headers",
"request",
"response",
"status",
"streaming_fn",
)
def __init__(
self,
streaming_fn: Callable[
[Union[BaseHTTPResponse, ResponseStream]],
Coroutine[Any, Any, None],
],
status: int = 200,
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: Optional[str] = None,
):
self.streaming_fn = streaming_fn
self.status = status
self.headers = headers or Header()
self.content_type = content_type
self.request: Optional[Request] = None
self._cookies: Optional[CookieJar] = None
async def write(self, message: str):
await self.response.send(message)
async def stream(self) -> HTTPResponse:
if not self.request:
raise ServerError("Attempted response to unknown request")
self.response = await self.request.respond(
headers=self.headers,
status=self.status,
content_type=self.content_type,
)
await self.streaming_fn(self)
return self.response
async def eof(self) -> None:
await self.response.eof()
@property
def cookies(self) -> CookieJar:
if self._cookies is None:
self._cookies = CookieJar(self.headers)
return self._cookies
@property
def processed_headers(self):
return self.response.processed_headers
@property
def body(self):
return self.response.body
def __call__(self, request: Request) -> ResponseStream:
self.request = request
return self
def __await__(self):
return self.stream().__await__()

View File

@@ -133,14 +133,14 @@ class Router(BaseRouter):
params.update({"requirements": {"host": host}})
route = super().add(**params) # type: ignore
route.ctx.ignore_body = ignore_body
route.ctx.stream = stream
route.ctx.hosts = hosts
route.ctx.static = static
route.ctx.error_format = error_format
route.extra.ignore_body = ignore_body
route.extra.stream = stream
route.extra.hosts = hosts
route.extra.static = static
route.extra.error_format = error_format
if error_format:
check_error_format(route.ctx.error_format)
check_error_format(route.extra.error_format)
routes.append(route)

View File

@@ -9,7 +9,6 @@ from time import sleep
def _iter_module_files():
"""This iterates over all relevant Python files.
It goes through all
loaded files from modules, all files in folders of already loaded modules
as well as all files reachable through a package.
@@ -52,7 +51,7 @@ def restart_with_reloader(changed=None):
this one.
"""
reloaded = ",".join(changed) if changed else ""
return subprocess.Popen(
return subprocess.Popen( # nosec B603
_get_args_for_reloading(),
env={
**os.environ,
@@ -79,7 +78,6 @@ def _check_file(filename, mtimes):
def watchdog(sleep_interval, reload_dirs):
"""Watch project files, restart worker process if a change happened.
:param sleep_interval: interval in second.
:return: Nothing
"""
@@ -96,7 +94,6 @@ def watchdog(sleep_interval, reload_dirs):
try:
while True:
changed = set()
for filename in itertools.chain(
_iter_module_files(),

View File

@@ -1,10 +1,11 @@
import asyncio
import sys
from distutils.util import strtobool
from os import getenv
from sanic.compat import OS_IS_WINDOWS
from sanic.log import error_logger
from sanic.utils import str_to_bool
def try_use_uvloop() -> None:
@@ -34,7 +35,7 @@ def try_use_uvloop() -> None:
)
return
uvloop_install_removed = strtobool(getenv("SANIC_NO_UVLOOP", "no"))
uvloop_install_removed = str_to_bool(getenv("SANIC_NO_UVLOOP", "no"))
if uvloop_install_removed:
error_logger.info(
"You are requesting to run Sanic using uvloop, but the "
@@ -47,3 +48,19 @@ def try_use_uvloop() -> None:
if not isinstance(asyncio.get_event_loop_policy(), uvloop.EventLoopPolicy):
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
def try_windows_loop():
if not OS_IS_WINDOWS:
error_logger.warning(
"You are trying to use an event loop policy that is not "
"compatible with your system. You can simply let Sanic handle "
"selecting the best loop for you. Sanic will now continue to run "
"using the default event loop."
)
return
if sys.version_info >= (3, 8) and not isinstance(
asyncio.get_event_loop_policy(), asyncio.WindowsSelectorEventLoopPolicy
):
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())

View File

@@ -2,13 +2,14 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional
from sanic.exceptions import RequestCancelled
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
@@ -69,7 +70,7 @@ class SanicProtocol(asyncio.Protocol):
"""
await self._can_write.wait()
if self.transport.is_closing():
raise CancelledError
raise RequestCancelled
self.transport.write(data)
self._time = current_time()
@@ -120,6 +121,7 @@ class SanicProtocol(asyncio.Protocol):
try:
self.connections.discard(self)
self.resume_writing()
self.conn_info.lost = True
if self._task:
self._task.cancel()
except BaseException:

View File

@@ -15,7 +15,11 @@ import sys
from asyncio import CancelledError
from time import monotonic as current_time
from sanic.exceptions import RequestTimeout, ServiceUnavailable
from sanic.exceptions import (
RequestCancelled,
RequestTimeout,
ServiceUnavailable,
)
from sanic.http import Http, Stage
from sanic.log import Colors, error_logger, logger
from sanic.models.server_types import ConnInfo
@@ -225,7 +229,7 @@ class HttpProtocol(HttpProtocolMixin, SanicProtocol, metaclass=TouchUpMeta):
"""
await self._can_write.wait()
if self.transport.is_closing():
raise CancelledError
raise RequestCancelled
await self.app.dispatch(
"http.lifecycle.send",
inline=True,
@@ -265,7 +269,6 @@ class HttpProtocol(HttpProtocolMixin, SanicProtocol, metaclass=TouchUpMeta):
error_logger.exception("protocol.connect_made")
def data_received(self, data: bytes):
try:
self._time = current_time()
if not data:

View File

@@ -1,7 +1,13 @@
from typing import TYPE_CHECKING, Optional, Sequence, cast
from websockets.connection import CLOSED, CLOSING, OPEN
from websockets.server import ServerConnection
try: # websockets < 11.0
from websockets.connection import State
from websockets.server import ServerConnection as ServerProtocol
except ImportError: # websockets >= 11.0
from websockets.protocol import State # type: ignore
from websockets.server import ServerProtocol # type: ignore
from websockets.typing import Subprotocol
from sanic.exceptions import ServerError
@@ -15,6 +21,11 @@ if TYPE_CHECKING:
from websockets import http11
OPEN = State.OPEN
CLOSING = State.CLOSING
CLOSED = State.CLOSED
class WebSocketProtocol(HttpProtocol):
__slots__ = (
"websocket",
@@ -74,7 +85,7 @@ class WebSocketProtocol(HttpProtocol):
# 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):
if self.websocket.ws_proto.state in (CLOSING, CLOSED):
return True
elif self.websocket.loop is not None:
self.websocket.loop.create_task(self.websocket.close(1001))
@@ -90,7 +101,7 @@ class WebSocketProtocol(HttpProtocol):
try:
if subprotocols is not None:
# subprotocols can be a set or frozenset,
# but ServerConnection needs a list
# but ServerProtocol needs a list
subprotocols = cast(
Optional[Sequence[Subprotocol]],
list(
@@ -100,13 +111,13 @@ class WebSocketProtocol(HttpProtocol):
]
),
)
ws_conn = ServerConnection(
ws_proto = ServerProtocol(
max_size=self.websocket_max_size,
subprotocols=subprotocols,
state=OPEN,
logger=logger,
)
resp: "http11.Response" = ws_conn.accept(request)
resp: "http11.Response" = ws_proto.accept(request)
except Exception:
msg = (
"Failed to open a WebSocket connection.\n"
@@ -129,7 +140,7 @@ class WebSocketProtocol(HttpProtocol):
else:
raise ServerError(resp.body, resp.status_code)
self.websocket = WebsocketImplProtocol(
ws_conn,
ws_proto,
ping_interval=self.websocket_ping_interval,
ping_timeout=self.websocket_ping_timeout,
close_timeout=self.websocket_timeout,

View File

@@ -27,7 +27,7 @@ from signal import signal as signal_func
from sanic.application.ext import setup_ext
from sanic.compat import OS_IS_WINDOWS, ctrlc_workaround_for_windows
from sanic.http.http3 import SessionTicketStore, get_config
from sanic.log import error_logger, logger
from sanic.log import error_logger, server_logger
from sanic.models.server_types import Signal
from sanic.server.async_server import AsyncioServer
from sanic.server.protocols.http_protocol import Http3Protocol, HttpProtocol
@@ -129,7 +129,7 @@ def _setup_system_signals(
run_multiple: bool,
register_sys_signals: bool,
loop: asyncio.AbstractEventLoop,
) -> None:
) -> None: # no cov
# Ignore SIGINT when run_multiple
if run_multiple:
signal_func(SIGINT, SIG_IGN)
@@ -141,18 +141,20 @@ def _setup_system_signals(
ctrlc_workaround_for_windows(app)
else:
for _signal in [SIGTERM] if run_multiple else [SIGINT, SIGTERM]:
loop.add_signal_handler(_signal, app.stop)
loop.add_signal_handler(
_signal, partial(app.stop, terminate=False)
)
def _run_server_forever(loop, before_stop, after_stop, cleanup, unix):
pid = os.getpid()
try:
logger.info("Starting worker [%s]", pid)
server_logger.info("Starting worker [%s]", pid)
loop.run_forever()
except KeyboardInterrupt:
pass
finally:
logger.info("Stopping worker [%s]", pid)
server_logger.info("Stopping worker [%s]", pid)
loop.run_until_complete(before_stop())
@@ -161,6 +163,7 @@ def _run_server_forever(loop, before_stop, after_stop, cleanup, unix):
loop.run_until_complete(after_stop())
remove_unix_socket(unix)
loop.close()
def _serve_http_1(
@@ -197,8 +200,12 @@ def _serve_http_1(
asyncio_server_kwargs = (
asyncio_server_kwargs if asyncio_server_kwargs else {}
)
if OS_IS_WINDOWS and sock:
pid = os.getpid()
sock = sock.share(pid)
sock = socket.fromshare(sock)
# UNIX sockets are always bound by us (to preserve semantics between modes)
if unix:
elif unix:
sock = bind_unix_socket(unix, backlog=backlog)
server_coroutine = loop.create_server(
server,
@@ -222,6 +229,7 @@ def _serve_http_1(
loop.run_until_complete(app._startup())
loop.run_until_complete(app._server_event("init", "before"))
app.ack()
try:
http_server = loop.run_until_complete(server_coroutine)
@@ -299,6 +307,7 @@ def _serve_http_3(
server = AsyncioServer(app, loop, coro, [])
loop.run_until_complete(server.startup())
loop.run_until_complete(server.before_start())
app.ack()
loop.run_until_complete(server)
_setup_system_signals(app, run_multiple, register_sys_signals, loop)
loop.run_until_complete(server.after_start())
@@ -365,7 +374,9 @@ def serve_multiple(server_settings, workers):
processes = []
def sig_handler(signal, frame):
logger.info("Received signal %s. Shutting down.", Signals(signal).name)
server_logger.info(
"Received signal %s. Shutting down.", Signals(signal).name
)
for process in processes:
os.kill(process.pid, SIGTERM)

View File

@@ -6,7 +6,10 @@ import socket
import stat
from ipaddress import ip_address
from typing import Optional
from typing import Any, Dict, Optional
from sanic.exceptions import ServerError
from sanic.http.constants import HTTP
def bind_socket(host: str, port: int, *, backlog=100) -> socket.socket:
@@ -16,6 +19,8 @@ def bind_socket(host: str, port: int, *, backlog=100) -> socket.socket:
:param backlog: Maximum number of connections to queue
:return: socket.socket object
"""
location = (host, port)
# socket.share, socket.fromshare
try: # IP address: family must be specified for IPv6 at least
ip = ip_address(host)
host = str(ip)
@@ -25,8 +30,9 @@ def bind_socket(host: str, port: int, *, backlog=100) -> socket.socket:
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.bind(location)
sock.listen(backlog)
sock.set_inheritable(True)
return sock
@@ -36,7 +42,7 @@ def bind_unix_socket(path: str, *, mode=0o666, backlog=100) -> socket.socket:
: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)
@@ -85,3 +91,40 @@ def remove_unix_socket(path: Optional[str]) -> None:
os.unlink(path)
except FileNotFoundError:
pass
def configure_socket(
server_settings: Dict[str, Any]
) -> Optional[socket.SocketType]:
# Create a listening socket or use the one in settings
if server_settings.get("version") is HTTP.VERSION_3:
return None
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:
try:
sock = bind_socket(
server_settings["host"],
server_settings["port"],
backlog=backlog,
)
except OSError as e: # no cov
error = ServerError(
f"Sanic server could not start: {e}.\n\n"
"This may have happened if you are running Sanic in the "
"global scope and not inside of a "
'`if __name__ == "__main__"` block.\n\nSee more information: '
"https://sanic.dev/en/guide/deployment/manager.html#"
"how-sanic-server-starts-processes\n"
)
error.quiet = True
raise error
sock.set_inheritable(True)
server_settings["sock"] = sock
server_settings["host"] = None
server_settings["port"] = None
return sock

View File

@@ -9,8 +9,10 @@ from typing import (
Union,
)
from sanic.exceptions import InvalidUsage
ASIMessage = MutableMapping[str, Any]
ASGIMessage = MutableMapping[str, Any]
class WebSocketConnection:
@@ -25,8 +27,8 @@ class WebSocketConnection:
def __init__(
self,
send: Callable[[ASIMessage], Awaitable[None]],
receive: Callable[[], Awaitable[ASIMessage]],
send: Callable[[ASGIMessage], Awaitable[None]],
receive: Callable[[], Awaitable[ASGIMessage]],
subprotocols: Optional[List[str]] = None,
) -> None:
self._send = send
@@ -47,7 +49,13 @@ class WebSocketConnection:
message = await self._receive()
if message["type"] == "websocket.receive":
return message["text"]
try:
return message["text"]
except KeyError:
try:
return message["bytes"].decode()
except KeyError:
raise InvalidUsage("Bad ASGI message received")
elif message["type"] == "websocket.disconnect":
pass

View File

@@ -52,7 +52,6 @@ class WebsocketFrameAssembler:
paused: bool
def __init__(self, protocol) -> None:
self.protocol = protocol
self.read_mutex = asyncio.Lock()

View File

@@ -12,21 +12,37 @@ from typing import (
Union,
)
from websockets.connection import CLOSED, CLOSING, OPEN, Event
from websockets.exceptions import ConnectionClosed, ConnectionClosedError
from websockets.exceptions import (
ConnectionClosed,
ConnectionClosedError,
ConnectionClosedOK,
)
from websockets.frames import Frame, Opcode
from websockets.server import ServerConnection
try: # websockets < 11.0
from websockets.connection import Event, State
from websockets.server import ServerConnection as ServerProtocol
except ImportError: # websockets >= 11.0
from websockets.protocol import Event, State # type: ignore
from websockets.server import ServerProtocol # type: ignore
from websockets.typing import Data
from sanic.log import error_logger, logger
from sanic.log import deprecation, error_logger, logger
from sanic.server.protocols.base_protocol import SanicProtocol
from ...exceptions import ServerError, WebsocketClosed
from .frame import WebsocketFrameAssembler
OPEN = State.OPEN
CLOSING = State.CLOSING
CLOSED = State.CLOSED
class WebsocketImplProtocol:
connection: ServerConnection
ws_proto: ServerProtocol
io_proto: Optional[SanicProtocol]
loop: Optional[asyncio.AbstractEventLoop]
max_queue: int
@@ -52,14 +68,14 @@ class WebsocketImplProtocol:
def __init__(
self,
connection,
ws_proto,
max_queue=None,
ping_interval: Optional[float] = 20,
ping_timeout: Optional[float] = 20,
close_timeout: float = 10,
loop=None,
):
self.connection = connection
self.ws_proto = ws_proto
self.io_proto = None
self.loop = None
self.max_queue = max_queue
@@ -81,7 +97,16 @@ class WebsocketImplProtocol:
@property
def subprotocol(self):
return self.connection.subprotocol
return self.ws_proto.subprotocol
@property
def connection(self):
deprecation(
"The connection property has been deprecated and will be removed. "
"Please use the ws_proto property instead going forward.",
22.6,
)
return self.ws_proto
def pause_frames(self):
if not self.can_pause:
@@ -252,7 +277,7 @@ class WebsocketImplProtocol:
def _force_disconnect(self) -> bool:
"""
Internal methdod used by end_connection and fail_connection
Internal method 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():
@@ -295,15 +320,15 @@ class WebsocketImplProtocol:
# Not draining the write buffer is acceptable in this context.
# clear the send buffer
_ = self.connection.data_to_send()
_ = self.ws_proto.data_to_send()
# If we're not already CLOSED or CLOSING, then send the close.
if self.connection.state is OPEN:
if self.ws_proto.state is OPEN:
if code in (1000, 1001):
self.connection.send_close(code, reason)
self.ws_proto.send_close(code, reason)
else:
self.connection.fail(code, reason)
self.ws_proto.fail(code, reason)
try:
data_to_send = self.connection.data_to_send()
data_to_send = self.ws_proto.data_to_send()
while (
len(data_to_send)
and self.io_proto
@@ -317,7 +342,7 @@ class WebsocketImplProtocol:
...
if code == 1006:
# Special case: 1006 consider the transport already closed
self.connection.state = CLOSED
self.ws_proto.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()
@@ -338,10 +363,10 @@ class WebsocketImplProtocol:
# 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())
if self.ws_proto.state == OPEN:
data_to_send = self.ws_proto.data_to_send()
self.ws_proto.send_close(code, reason)
data_to_send.extend(self.ws_proto.data_to_send())
try:
while (
len(data_to_send)
@@ -450,7 +475,7 @@ class WebsocketImplProtocol:
Raise ConnectionClosed in pending keepalive pings.
They'll never receive a pong once the connection is closed.
"""
if self.connection.state is not CLOSED:
if self.ws_proto.state is not CLOSED:
raise ServerError(
"Webscoket about_pings should only be called "
"after connection state is changed to CLOSED"
@@ -479,9 +504,9 @@ class WebsocketImplProtocol:
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()
if self.ws_proto.state is OPEN:
self.ws_proto.send_close(code, reason)
data_to_send = self.ws_proto.data_to_send()
await self.send_data(data_to_send)
async def recv(self, timeout: Optional[float] = None) -> Optional[Data]:
@@ -511,7 +536,7 @@ class WebsocketImplProtocol:
"already waiting for the next message"
)
await self.recv_lock.acquire()
if self.connection.state is CLOSED:
if self.ws_proto.state is CLOSED:
self.recv_lock.release()
raise WebsocketClosed(
"Cannot receive from websocket interface after it is closed."
@@ -562,7 +587,7 @@ class WebsocketImplProtocol:
"for the next message"
)
await self.recv_lock.acquire()
if self.connection.state is CLOSED:
if self.ws_proto.state is CLOSED:
self.recv_lock.release()
raise WebsocketClosed(
"Cannot receive from websocket interface after it is closed."
@@ -621,7 +646,7 @@ class WebsocketImplProtocol:
"is already waiting for the next message"
)
await self.recv_lock.acquire()
if self.connection.state is CLOSED:
if self.ws_proto.state is CLOSED:
self.recv_lock.release()
raise WebsocketClosed(
"Cannot receive from websocket interface after it is closed."
@@ -661,8 +686,7 @@ class WebsocketImplProtocol:
:raises TypeError: for unsupported inputs
"""
async with self.conn_mutex:
if self.connection.state in (CLOSED, CLOSING):
if self.ws_proto.state in (CLOSED, CLOSING):
raise WebsocketClosed(
"Cannot write to websocket interface after it is closed."
)
@@ -675,12 +699,12 @@ class WebsocketImplProtocol:
# 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())
self.ws_proto.send_text(message.encode("utf-8"))
await self.send_data(self.ws_proto.data_to_send())
elif isinstance(message, (bytes, bytearray, memoryview)):
self.connection.send_binary(message)
await self.send_data(self.connection.data_to_send())
self.ws_proto.send_binary(message)
await self.send_data(self.ws_proto.data_to_send())
elif isinstance(message, Mapping):
# Catch a common mistake -- passing a dict to send().
@@ -709,7 +733,7 @@ class WebsocketImplProtocol:
(which will be encoded to UTF-8) or a bytes-like object.
"""
async with self.conn_mutex:
if self.connection.state in (CLOSED, CLOSING):
if self.ws_proto.state in (CLOSED, CLOSING):
raise WebsocketClosed(
"Cannot send a ping when the websocket interface "
"is closed."
@@ -737,8 +761,8 @@ class WebsocketImplProtocol:
self.pings[data] = self.io_proto.loop.create_future()
self.connection.send_ping(data)
await self.send_data(self.connection.data_to_send())
self.ws_proto.send_ping(data)
await self.send_data(self.ws_proto.data_to_send())
return asyncio.shield(self.pings[data])
@@ -750,15 +774,15 @@ class WebsocketImplProtocol:
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):
if self.ws_proto.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())
self.ws_proto.send_pong(data)
await self.send_data(self.ws_proto.data_to_send())
async def send_data(self, data_to_send):
for data in data_to_send:
@@ -780,7 +804,7 @@ class WebsocketImplProtocol:
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:
if self.ws_proto.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)
@@ -788,9 +812,9 @@ class WebsocketImplProtocol:
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()
self.ws_proto.receive_data(data)
data_to_send = self.ws_proto.data_to_send()
events_to_process = self.ws_proto.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)
@@ -799,7 +823,7 @@ class WebsocketImplProtocol:
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:
if self.ws_proto.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)
@@ -819,9 +843,9 @@ class WebsocketImplProtocol:
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()
self.ws_proto.receive_eof()
data_to_send = self.ws_proto.data_to_send()
events_to_process = self.ws_proto.events_received()
asyncio.create_task(
self.async_eof_received(data_to_send, events_to_process)
)
@@ -831,12 +855,19 @@ class WebsocketImplProtocol:
"""
The WebSocket Connection is Closed.
"""
if not self.connection.state == CLOSED:
if not self.ws_proto.state == CLOSED:
# signal to the websocket connection handler
# we've lost the connection
self.connection.fail(code=1006)
self.connection.state = CLOSED
self.ws_proto.fail(code=1006)
self.ws_proto.state = CLOSED
self.abort_pings()
if self.connection_lost_waiter:
self.connection_lost_waiter.set_result(None)
async def __aiter__(self):
try:
while True:
yield await self.recv()
except ConnectionClosedOK:
return

View File

@@ -30,6 +30,8 @@ class Event(Enum):
HTTP_LIFECYCLE_RESPONSE = "http.lifecycle.response"
HTTP_ROUTING_AFTER = "http.routing.after"
HTTP_ROUTING_BEFORE = "http.routing.before"
HTTP_HANDLER_AFTER = "http.handler.after"
HTTP_HANDLER_BEFORE = "http.handler.before"
HTTP_LIFECYCLE_SEND = "http.lifecycle.send"
HTTP_MIDDLEWARE_AFTER = "http.middleware.after"
HTTP_MIDDLEWARE_BEFORE = "http.middleware.before"
@@ -53,6 +55,8 @@ RESERVED_NAMESPACES = {
Event.HTTP_LIFECYCLE_RESPONSE.value,
Event.HTTP_ROUTING_AFTER.value,
Event.HTTP_ROUTING_BEFORE.value,
Event.HTTP_HANDLER_AFTER.value,
Event.HTTP_HANDLER_BEFORE.value,
Event.HTTP_LIFECYCLE_SEND.value,
Event.HTTP_MIDDLEWARE_AFTER.value,
Event.HTTP_MIDDLEWARE_BEFORE.value,
@@ -150,13 +154,11 @@ class SignalRouter(BaseRouter):
try:
for signal in signals:
params.pop("__trigger__", None)
requirements = signal.extra.requirements
if (
(condition is None and signal.ctx.exclusive is False)
or (
condition is None
and not signal.handler.__requirements__
)
or (condition == signal.handler.__requirements__)
or (condition is None and not requirements)
or (condition == requirements)
) and (signal.ctx.trigger or event == signal.ctx.definition):
maybe_coroutine = signal.handler(**params)
if isawaitable(maybe_coroutine):
@@ -187,7 +189,7 @@ class SignalRouter(BaseRouter):
fail_not_found=fail_not_found and inline,
reverse=reverse,
)
logger.debug(f"Dispatching signal: {event}")
logger.debug(f"Dispatching signal: {event}", extra={"verbosity": 1})
if inline:
return await dispatch
@@ -215,8 +217,13 @@ class SignalRouter(BaseRouter):
if not trigger:
event = ".".join([*parts[:2], "<__trigger__>"])
handler.__requirements__ = condition # type: ignore
handler.__trigger__ = trigger # type: ignore
try:
# Attaching __requirements__ and __trigger__ to the handler
# is deprecated and will be removed in v23.6.
handler.__requirements__ = condition # type: ignore
handler.__trigger__ = trigger # type: ignore
except AttributeError:
pass
signal = super().add(
event,
@@ -228,6 +235,7 @@ class SignalRouter(BaseRouter):
signal.ctx.exclusive = exclusive
signal.ctx.trigger = trigger
signal.ctx.definition = event_definition
signal.extra.requirements = condition
return cast(Signal, signal)

View File

@@ -11,7 +11,6 @@ class TouchUpMeta(SanicMeta):
methods = attrs.get("__touchup__")
attrs["__touched__"] = False
if methods:
for method in methods:
if method not in attrs:
raise SanicException(

57
sanic/types/shared_ctx.py Normal file
View File

@@ -0,0 +1,57 @@
import os
from types import SimpleNamespace
from typing import Any, Iterable
from sanic.log import Colors, error_logger
class SharedContext(SimpleNamespace):
SAFE = ("_lock",)
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._lock = False
def __setattr__(self, name: str, value: Any) -> None:
if self.is_locked:
raise RuntimeError(
f"Cannot set {name} on locked SharedContext object"
)
if not os.environ.get("SANIC_WORKER_NAME"):
to_check: Iterable[Any]
if not isinstance(value, (tuple, frozenset)):
to_check = [value]
else:
to_check = value
for item in to_check:
self._check(name, item)
super().__setattr__(name, value)
def _check(self, name: str, value: Any) -> None:
if name in self.SAFE:
return
try:
module = value.__module__
except AttributeError:
module = ""
if not any(
module.startswith(prefix)
for prefix in ("multiprocessing", "ctypes")
):
error_logger.warning(
f"{Colors.YELLOW}Unsafe object {Colors.PURPLE}{name} "
f"{Colors.YELLOW}with type {Colors.PURPLE}{type(value)} "
f"{Colors.YELLOW}was added to shared_ctx. It may not "
"not function as intended. Consider using the regular "
f"ctx.\nFor more information, please see https://sanic.dev/en"
"/guide/deployment/manager.html#using-shared-context-between-"
f"worker-processes.{Colors.END}"
)
@property
def is_locked(self) -> bool:
return getattr(self, "_lock", False)
def lock(self) -> None:
self._lock = True

View File

@@ -75,7 +75,6 @@ def load_module_from_file_location(
location = location.decode(encoding)
if isinstance(location, Path) or "/" in location or "$" in location:
if not isinstance(location, Path):
# A) Check if location contains any environment variables
# in format ${some_env_var}.

0
sanic/worker/__init__.py Normal file
View File

18
sanic/worker/constants.py Normal file
View File

@@ -0,0 +1,18 @@
from enum import IntEnum, auto
from sanic.compat import UpperStrEnum
class RestartOrder(UpperStrEnum):
SHUTDOWN_FIRST = auto()
STARTUP_FIRST = auto()
class ProcessState(IntEnum):
IDLE = auto()
RESTARTING = auto()
STARTING = auto()
STARTED = auto()
ACKED = auto()
JOINED = auto()
TERMINATED = auto()

123
sanic/worker/inspector.py Normal file
View File

@@ -0,0 +1,123 @@
from __future__ import annotations
from datetime import datetime
from inspect import isawaitable
from multiprocessing.connection import Connection
from os import environ
from pathlib import Path
from typing import Any, Dict, Mapping, Union
from sanic.exceptions import Unauthorized
from sanic.helpers import Default
from sanic.log import logger
from sanic.request import Request
from sanic.response import json
class Inspector:
def __init__(
self,
publisher: Connection,
app_info: Dict[str, Any],
worker_state: Mapping[str, Any],
host: str,
port: int,
api_key: str,
tls_key: Union[Path, str, Default],
tls_cert: Union[Path, str, Default],
):
self._publisher = publisher
self.app_info = app_info
self.worker_state = worker_state
self.host = host
self.port = port
self.api_key = api_key
self.tls_key = tls_key
self.tls_cert = tls_cert
def __call__(self, run=True, **_) -> Inspector:
from sanic import Sanic
self.app = Sanic("Inspector")
self._setup()
if run:
self.app.run(
host=self.host,
port=self.port,
single_process=True,
ssl={"key": self.tls_key, "cert": self.tls_cert}
if not isinstance(self.tls_key, Default)
and not isinstance(self.tls_cert, Default)
else None,
)
return self
def _setup(self):
self.app.get("/")(self._info)
self.app.post("/<action:str>")(self._action)
if self.api_key:
self.app.on_request(self._authentication)
environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "true"
def _authentication(self, request: Request) -> None:
if request.token != self.api_key:
raise Unauthorized("Bad API key")
async def _action(self, request: Request, action: str):
logger.info("Incoming inspector action: %s", action)
output: Any = None
method = getattr(self, action, None)
if method:
kwargs = {}
if request.body:
kwargs = request.json
args = kwargs.pop("args", ())
output = method(*args, **kwargs)
if isawaitable(output):
output = await output
return await self._respond(request, output)
async def _info(self, request: Request):
return await self._respond(request, self._state_to_json())
async def _respond(self, request: Request, output: Any):
name = request.match_info.get("action", "info")
return json(
{"meta": {"action": name}, "result": output},
escape_forward_slashes=False,
)
def _state_to_json(self) -> Dict[str, Any]:
output = {"info": self.app_info}
output["workers"] = self._make_safe(dict(self.worker_state))
return output
@staticmethod
def _make_safe(obj: Dict[str, Any]) -> Dict[str, Any]:
for key, value in obj.items():
if isinstance(value, dict):
obj[key] = Inspector._make_safe(value)
elif isinstance(value, datetime):
obj[key] = value.isoformat()
return obj
def reload(self, zero_downtime: bool = False) -> None:
message = "__ALL_PROCESSES__:"
if zero_downtime:
message += ":STARTUP_FIRST"
self._publisher.send(message)
def scale(self, replicas) -> str:
num_workers = 1
if replicas:
num_workers = int(replicas)
log_msg = f"Scaling to {num_workers}"
logger.info(log_msg)
message = f"__SCALE__:{num_workers}"
self._publisher.send(message)
return log_msg
def shutdown(self) -> None:
message = "__TERMINATE__"
self._publisher.send(message)

127
sanic/worker/loader.py Normal file
View File

@@ -0,0 +1,127 @@
from __future__ import annotations
import os
import sys
from importlib import import_module
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union, cast
from sanic.http.tls.context import process_to_context
from sanic.http.tls.creators import MkcertCreator, TrustmeCreator
if TYPE_CHECKING:
from sanic import Sanic as SanicApp
class AppLoader:
def __init__(
self,
module_input: str = "",
as_factory: bool = False,
as_simple: bool = False,
args: Any = None,
factory: Optional[Callable[[], SanicApp]] = None,
) -> None:
self.module_input = module_input
self.module_name = ""
self.app_name = ""
self.as_factory = as_factory
self.as_simple = as_simple
self.args = args
self.factory = factory
self.cwd = os.getcwd()
if module_input:
delimiter = ":" if ":" in module_input else "."
if module_input.count(delimiter):
module_name, app_name = module_input.rsplit(delimiter, 1)
self.module_name = module_name
self.app_name = app_name
if self.app_name.endswith("()"):
self.as_factory = True
self.app_name = self.app_name[:-2]
def load(self) -> SanicApp:
module_path = os.path.abspath(self.cwd)
if module_path not in sys.path:
sys.path.append(module_path)
if self.factory:
return self.factory()
else:
from sanic.app import Sanic
from sanic.simple import create_simple_server
if self.as_simple:
path = Path(self.module_input)
app = create_simple_server(path)
else:
if self.module_name == "" and os.path.isdir(self.module_input):
raise ValueError(
"App not found.\n"
" Please use --simple if you are passing a "
"directory to sanic.\n"
f" eg. sanic {self.module_input} --simple"
)
module = import_module(self.module_name)
app = getattr(module, self.app_name, None)
if self.as_factory:
try:
app = app(self.args)
except TypeError:
app = app()
app_type_name = type(app).__name__
if (
not isinstance(app, Sanic)
and self.args
and hasattr(self.args, "module")
):
if callable(app):
solution = f"sanic {self.args.module} --factory"
raise ValueError(
"Module is not a Sanic app, it is a "
f"{app_type_name}\n"
" If this callable returns a "
f"Sanic instance try: \n{solution}"
)
raise ValueError(
f"Module is not a Sanic app, it is a {app_type_name}\n"
f" Perhaps you meant {self.args.module}:app?"
)
return app
class CertLoader:
_creators = {
"mkcert": MkcertCreator,
"trustme": TrustmeCreator,
}
def __init__(self, ssl_data: Dict[str, Union[str, os.PathLike]]):
self._ssl_data = ssl_data
creator_name = cast(str, ssl_data.get("creator"))
self._creator_class = self._creators.get(creator_name)
if not creator_name:
return
if not self._creator_class:
raise RuntimeError(f"Unknown certificate creator: {creator_name}")
self._key = ssl_data["key"]
self._cert = ssl_data["cert"]
self._localhost = cast(str, ssl_data["localhost"])
def load(self, app: SanicApp):
if not self._creator_class:
return process_to_context(self._ssl_data)
creator = self._creator_class(app, self._key, self._cert)
return creator.generate_cert(self._localhost)

290
sanic/worker/manager.py Normal file
View File

@@ -0,0 +1,290 @@
import os
from contextlib import suppress
from itertools import count
from random import choice
from signal import SIGINT, SIGTERM, Signals
from signal import signal as signal_func
from typing import Dict, List, Optional
from sanic.compat import OS_IS_WINDOWS
from sanic.exceptions import ServerKilled
from sanic.log import error_logger, logger
from sanic.worker.constants import RestartOrder
from sanic.worker.process import ProcessState, Worker, WorkerProcess
if not OS_IS_WINDOWS:
from signal import SIGKILL
else:
SIGKILL = SIGINT
class WorkerManager:
THRESHOLD = WorkerProcess.THRESHOLD
MAIN_IDENT = "Sanic-Main"
def __init__(
self,
number: int,
serve,
server_settings,
context,
monitor_pubsub,
worker_state,
):
self.num_server = number
self.context = context
self.transient: Dict[str, Worker] = {}
self.durable: Dict[str, Worker] = {}
self.monitor_publisher, self.monitor_subscriber = monitor_pubsub
self.worker_state = worker_state
self.worker_state[self.MAIN_IDENT] = {"pid": self.pid}
self._shutting_down = False
self._serve = serve
self._server_settings = server_settings
self._server_count = count()
if number == 0:
raise RuntimeError("Cannot serve with no workers")
for _ in range(number):
self.create_server()
signal_func(SIGINT, self.shutdown_signal)
signal_func(SIGTERM, self.shutdown_signal)
def manage(self, ident, func, kwargs, transient=False) -> Worker:
container = self.transient if transient else self.durable
worker = Worker(ident, func, kwargs, self.context, self.worker_state)
container[worker.ident] = worker
return worker
def create_server(self) -> Worker:
server_number = next(self._server_count)
return self.manage(
f"{WorkerProcess.SERVER_LABEL}-{server_number}",
self._serve,
self._server_settings,
transient=True,
)
def shutdown_server(self, ident: Optional[str] = None) -> None:
if not ident:
servers = [
worker
for worker in self.transient.values()
if worker.ident.startswith(WorkerProcess.SERVER_LABEL)
]
if not servers:
error_logger.error(
"Server shutdown failed because a server was not found."
)
return
worker = choice(servers) # nosec B311
else:
worker = self.transient[ident]
for process in worker.processes:
process.terminate()
del self.transient[worker.ident]
def run(self):
self.start()
self.monitor()
self.join()
self.terminate()
def start(self):
for process in self.processes:
process.start()
def join(self):
logger.debug("Joining processes", extra={"verbosity": 1})
joined = set()
for process in self.processes:
logger.debug(
f"Found {process.pid} - {process.state.name}",
extra={"verbosity": 1},
)
if process.state < ProcessState.JOINED:
logger.debug(f"Joining {process.pid}", extra={"verbosity": 1})
joined.add(process.pid)
process.join()
if joined:
self.join()
def terminate(self):
if not self._shutting_down:
for process in self.processes:
process.terminate()
def restart(
self,
process_names: Optional[List[str]] = None,
restart_order=RestartOrder.SHUTDOWN_FIRST,
**kwargs,
):
for process in self.transient_processes:
if not process_names or process.name in process_names:
process.restart(restart_order=restart_order, **kwargs)
def scale(self, num_worker: int):
if num_worker <= 0:
raise ValueError("Cannot scale to 0 workers.")
change = num_worker - self.num_server
if change == 0:
logger.info(
f"No change needed. There are already {num_worker} workers."
)
return
logger.info(f"Scaling from {self.num_server} to {num_worker} workers")
if change > 0:
for _ in range(change):
worker = self.create_server()
for process in worker.processes:
process.start()
else:
for _ in range(abs(change)):
self.shutdown_server()
self.num_server = num_worker
def monitor(self):
self.wait_for_ack()
while True:
try:
if self.monitor_subscriber.poll(0.1):
message = self.monitor_subscriber.recv()
logger.debug(
f"Monitor message: {message}", extra={"verbosity": 2}
)
if not message:
break
elif message == "__TERMINATE__":
self.shutdown()
break
logger.debug(
"Incoming monitor message: %s",
message,
extra={"verbosity": 1},
)
split_message = message.split(":", 2)
if message.startswith("__SCALE__"):
self.scale(int(split_message[-1]))
continue
processes = split_message[0]
reloaded_files = (
split_message[1] if len(split_message) > 1 else None
)
process_names = [
name.strip() for name in processes.split(",")
]
if "__ALL_PROCESSES__" in process_names:
process_names = None
order = (
RestartOrder.STARTUP_FIRST
if "STARTUP_FIRST" in split_message
else RestartOrder.SHUTDOWN_FIRST
)
self.restart(
process_names=process_names,
reloaded_files=reloaded_files,
restart_order=order,
)
self._sync_states()
except InterruptedError:
if not OS_IS_WINDOWS:
raise
break
def wait_for_ack(self): # no cov
misses = 0
message = (
"It seems that one or more of your workers failed to come "
"online in the allowed time. Sanic is shutting down to avoid a "
f"deadlock. The current threshold is {self.THRESHOLD / 10}s. "
"If this problem persists, please check out the documentation "
"https://sanic.dev/en/guide/deployment/manager.html#worker-ack."
)
while not self._all_workers_ack():
if self.monitor_subscriber.poll(0.1):
monitor_msg = self.monitor_subscriber.recv()
if monitor_msg != "__TERMINATE_EARLY__":
self.monitor_publisher.send(monitor_msg)
continue
misses = self.THRESHOLD
message = (
"One of your worker processes terminated before startup "
"was completed. Please solve any errors experienced "
"during startup. If you do not see an exception traceback "
"in your error logs, try running Sanic in in a single "
"process using --single-process or single_process=True. "
"Once you are confident that the server is able to start "
"without errors you can switch back to multiprocess mode."
)
misses += 1
if misses > self.THRESHOLD:
error_logger.error(
"Not all workers acknowledged a successful startup. "
"Shutting down.\n\n" + message
)
self.kill()
@property
def workers(self) -> List[Worker]:
return list(self.transient.values()) + list(self.durable.values())
@property
def processes(self):
for worker in self.workers:
for process in worker.processes:
yield process
@property
def transient_processes(self):
for worker in self.transient.values():
for process in worker.processes:
yield process
def kill(self):
for process in self.processes:
logger.info("Killing %s [%s]", process.name, process.pid)
os.kill(process.pid, SIGKILL)
raise ServerKilled
def shutdown_signal(self, signal, frame):
if self._shutting_down:
logger.info("Shutdown interrupted. Killing.")
with suppress(ServerKilled):
self.kill()
logger.info("Received signal %s. Shutting down.", Signals(signal).name)
self.monitor_publisher.send(None)
self.shutdown()
def shutdown(self):
for process in self.processes:
if process.is_alive():
process.terminate()
self._shutting_down = True
@property
def pid(self):
return os.getpid()
def _all_workers_ack(self):
acked = [
worker_state.get("state") == ProcessState.ACKED.name
for worker_state in self.worker_state.values()
if worker_state.get("server")
]
return all(acked) and len(acked) == self.num_server
def _sync_states(self):
for process in self.processes:
state = self.worker_state[process.name].get("state")
if state and process.state.name != state:
process.set_state(ProcessState[state], True)

View File

@@ -0,0 +1,74 @@
from multiprocessing.connection import Connection
from os import environ, getpid
from typing import Any, Dict
from sanic.log import Colors, logger
from sanic.worker.process import ProcessState
from sanic.worker.state import WorkerState
class WorkerMultiplexer:
def __init__(
self,
monitor_publisher: Connection,
worker_state: Dict[str, Any],
):
self._monitor_publisher = monitor_publisher
self._state = WorkerState(worker_state, self.name)
def ack(self):
logger.debug(
f"{Colors.BLUE}Process ack: {Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name,
self.pid,
)
self._state._state[self.name] = {
**self._state._state[self.name],
"state": ProcessState.ACKED.name,
}
def restart(
self,
name: str = "",
all_workers: bool = False,
zero_downtime: bool = False,
):
if name and all_workers:
raise ValueError(
"Ambiguous restart with both a named process and"
" all_workers=True"
)
if not name:
name = "__ALL_PROCESSES__:" if all_workers else self.name
if not name.endswith(":"):
name += ":"
if zero_downtime:
name += ":STARTUP_FIRST"
self._monitor_publisher.send(name)
reload = restart # no cov
def scale(self, num_workers: int):
message = f"__SCALE__:{num_workers}"
self._monitor_publisher.send(message)
def terminate(self, early: bool = False):
message = "__TERMINATE_EARLY__" if early else "__TERMINATE__"
self._monitor_publisher.send(message)
@property
def pid(self) -> int:
return getpid()
@property
def name(self) -> str:
return environ.get("SANIC_WORKER_NAME", "")
@property
def state(self):
return self._state
@property
def workers(self) -> Dict[str, Any]:
return self.state.full()

217
sanic/worker/process.py Normal file
View File

@@ -0,0 +1,217 @@
import os
from datetime import datetime, timezone
from multiprocessing.context import BaseContext
from signal import SIGINT
from threading import Thread
from time import sleep
from typing import Any, Dict, Set
from sanic.log import Colors, logger
from sanic.worker.constants import ProcessState, RestartOrder
def get_now():
now = datetime.now(tz=timezone.utc)
return now
class WorkerProcess:
THRESHOLD = 300 # == 30 seconds
SERVER_LABEL = "Server"
def __init__(self, factory, name, target, kwargs, worker_state):
self.state = ProcessState.IDLE
self.factory = factory
self.name = name
self.target = target
self.kwargs = kwargs
self.worker_state = worker_state
if self.name not in self.worker_state:
self.worker_state[self.name] = {
"server": self.SERVER_LABEL in self.name
}
self.spawn()
def set_state(self, state: ProcessState, force=False):
if not force and state < self.state:
raise Exception("...")
self.state = state
self.worker_state[self.name] = {
**self.worker_state[self.name],
"state": self.state.name,
}
def start(self):
os.environ["SANIC_WORKER_NAME"] = self.name
logger.debug(
f"{Colors.BLUE}Starting a process: {Colors.BOLD}"
f"{Colors.SANIC}%s{Colors.END}",
self.name,
)
self.set_state(ProcessState.STARTING)
self._current_process.start()
self.set_state(ProcessState.STARTED)
if not self.worker_state[self.name].get("starts"):
self.worker_state[self.name] = {
**self.worker_state[self.name],
"pid": self.pid,
"start_at": get_now(),
"starts": 1,
}
del os.environ["SANIC_WORKER_NAME"]
def join(self):
self.set_state(ProcessState.JOINED)
self._current_process.join()
def terminate(self):
if self.state is not ProcessState.TERMINATED:
logger.debug(
f"{Colors.BLUE}Terminating a process: "
f"{Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name,
self.pid,
)
self.set_state(ProcessState.TERMINATED, force=True)
try:
os.kill(self.pid, SIGINT)
del self.worker_state[self.name]
except (KeyError, AttributeError, ProcessLookupError):
...
def restart(self, restart_order=RestartOrder.SHUTDOWN_FIRST, **kwargs):
logger.debug(
f"{Colors.BLUE}Restarting a process: {Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name,
self.pid,
)
self.set_state(ProcessState.RESTARTING, force=True)
if restart_order is RestartOrder.SHUTDOWN_FIRST:
self._terminate_now()
else:
self._old_process = self._current_process
self.kwargs.update(
{"config": {k.upper(): v for k, v in kwargs.items()}}
)
try:
self.spawn()
self.start()
except AttributeError:
raise RuntimeError("Restart failed")
if restart_order is RestartOrder.STARTUP_FIRST:
self._terminate_soon()
self.worker_state[self.name] = {
**self.worker_state[self.name],
"pid": self.pid,
"starts": self.worker_state[self.name]["starts"] + 1,
"restart_at": get_now(),
}
def is_alive(self):
try:
return self._current_process.is_alive()
except AssertionError:
return False
def spawn(self):
if self.state not in (ProcessState.IDLE, ProcessState.RESTARTING):
raise Exception("Cannot spawn a worker process until it is idle.")
self._current_process = self.factory(
name=self.name,
target=self.target,
kwargs=self.kwargs,
daemon=True,
)
@property
def pid(self):
return self._current_process.pid
def _terminate_now(self):
logger.debug(
f"{Colors.BLUE}Begin restart termination: "
f"{Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name,
self._current_process.pid,
)
self._current_process.terminate()
def _terminate_soon(self):
logger.debug(
f"{Colors.BLUE}Begin restart termination: "
f"{Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name,
self._current_process.pid,
)
termination_thread = Thread(target=self._wait_to_terminate)
termination_thread.start()
def _wait_to_terminate(self):
logger.debug(
f"{Colors.BLUE}Waiting for process to be acked: "
f"{Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name,
self._old_process.pid,
)
misses = 0
while self.state is not ProcessState.ACKED:
sleep(0.1)
misses += 1
if misses > self.THRESHOLD:
raise TimeoutError(
f"Worker {self.name} failed to come ack within "
f"{self.THRESHOLD / 10} seconds"
)
else:
logger.debug(
f"{Colors.BLUE}Process acked. Terminating: "
f"{Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name,
self._old_process.pid,
)
self._old_process.terminate()
delattr(self, "_old_process")
class Worker:
WORKER_PREFIX = "Sanic-"
def __init__(
self,
ident: str,
serve,
server_settings,
context: BaseContext,
worker_state: Dict[str, Any],
):
self.ident = ident
self.context = context
self.serve = serve
self.server_settings = server_settings
self.worker_state = worker_state
self.processes: Set[WorkerProcess] = set()
self.create_process()
def create_process(self) -> WorkerProcess:
process = WorkerProcess(
# Need to ignore this typing error - The problem is the
# BaseContext itself has no Process. But, all of its
# implementations do. We can safely ignore as it is a typing
# issue in the standard lib.
factory=self.context.Process, # type: ignore
name=f"{self.WORKER_PREFIX}{self.ident}-{len(self.processes)}",
target=self.serve,
kwargs={**self.server_settings},
worker_state=self.worker_state,
)
self.processes.add(process)
return process

123
sanic/worker/reloader.py Normal file
View File

@@ -0,0 +1,123 @@
from __future__ import annotations
import os
import sys
from asyncio import new_event_loop
from itertools import chain
from multiprocessing.connection import Connection
from pathlib import Path
from signal import SIGINT, SIGTERM
from signal import signal as signal_func
from time import sleep
from typing import Dict, Set
from sanic.server.events import trigger_events
from sanic.worker.loader import AppLoader
class Reloader:
INTERVAL = 1.0 # seconds
def __init__(
self,
publisher: Connection,
interval: float,
reload_dirs: Set[Path],
app_loader: AppLoader,
):
self._publisher = publisher
self.interval = interval or self.INTERVAL
self.reload_dirs = reload_dirs
self.run = True
self.app_loader = app_loader
def __call__(self) -> None:
app = self.app_loader.load()
signal_func(SIGINT, self.stop)
signal_func(SIGTERM, self.stop)
mtimes: Dict[str, float] = {}
reloader_start = app.listeners.get("reload_process_start")
reloader_stop = app.listeners.get("reload_process_stop")
before_trigger = app.listeners.get("before_reload_trigger")
after_trigger = app.listeners.get("after_reload_trigger")
loop = new_event_loop()
if reloader_start:
trigger_events(reloader_start, loop, app)
while self.run:
changed = set()
for filename in self.files():
try:
if self.check_file(filename, mtimes):
path = (
filename
if isinstance(filename, str)
else filename.resolve()
)
changed.add(str(path))
except OSError:
continue
if changed:
if before_trigger:
trigger_events(before_trigger, loop, app)
self.reload(",".join(changed) if changed else "unknown")
if after_trigger:
trigger_events(after_trigger, loop, app)
sleep(self.interval)
else:
if reloader_stop:
trigger_events(reloader_stop, loop, app)
def stop(self, *_):
self.run = False
def reload(self, reloaded_files):
message = f"__ALL_PROCESSES__:{reloaded_files}"
self._publisher.send(message)
def files(self):
return chain(
self.python_files(),
*(d.glob("**/*") for d in self.reload_dirs),
)
def python_files(self): # no cov
"""This iterates over all relevant Python files.
It goes through all
loaded files from modules, all files in folders of already loaded
modules as well as all files reachable through a package.
"""
# The list call is necessary on Python 3 in case the module
# dictionary modifies during iteration.
for module in list(sys.modules.values()):
if module is None:
continue
filename = getattr(module, "__file__", None)
if filename:
old = None
while not os.path.isfile(filename):
old = filename
filename = os.path.dirname(filename)
if filename == old:
break
else:
if filename[-4:] in (".pyc", ".pyo"):
filename = filename[:-1]
yield filename
@staticmethod
def check_file(filename, mtimes) -> bool:
need_reload = False
mtime = os.stat(filename).st_mtime
old_time = mtimes.get(filename)
if old_time is None:
mtimes[filename] = mtime
elif mtime > old_time:
mtimes[filename] = mtime
need_reload = True
return need_reload

143
sanic/worker/serve.py Normal file
View File

@@ -0,0 +1,143 @@
import asyncio
import os
import socket
import warnings
from functools import partial
from multiprocessing.connection import Connection
from ssl import SSLContext
from typing import Any, Dict, List, Optional, Type, Union
from sanic.application.constants import ServerStage
from sanic.application.state import ApplicationServerInfo
from sanic.http.constants import HTTP
from sanic.log import error_logger
from sanic.models.server_types import Signal
from sanic.server.protocols.http_protocol import HttpProtocol
from sanic.server.runners import _serve_http_1, _serve_http_3
from sanic.worker.loader import AppLoader, CertLoader
from sanic.worker.multiplexer import WorkerMultiplexer
from sanic.worker.process import Worker, WorkerProcess
def worker_serve(
host,
port,
app_name: str,
monitor_publisher: Optional[Connection],
app_loader: AppLoader,
worker_state: Optional[Dict[str, Any]] = None,
server_info: Optional[Dict[str, List[ApplicationServerInfo]]] = None,
ssl: Optional[
Union[SSLContext, Dict[str, Union[str, os.PathLike]]]
] = 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,
version=HTTP.VERSION_1,
config=None,
passthru: Optional[Dict[str, Any]] = None,
):
try:
from sanic import Sanic
if app_loader:
app = app_loader.load()
else:
app = Sanic.get_app(app_name)
app.refresh(passthru)
app.setup_loop()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
# Hydrate server info if needed
if server_info:
for app_name, server_info_objects in server_info.items():
a = Sanic.get_app(app_name)
if not a.state.server_info:
a.state.server_info = []
for info in server_info_objects:
if not info.settings.get("app"):
info.settings["app"] = a
a.state.server_info.append(info)
if isinstance(ssl, dict):
cert_loader = CertLoader(ssl)
ssl = cert_loader.load(app)
for info in app.state.server_info:
info.settings["ssl"] = ssl
# When in a worker process, do some init
worker_name = os.environ.get("SANIC_WORKER_NAME")
if worker_name and worker_name.startswith(
Worker.WORKER_PREFIX + WorkerProcess.SERVER_LABEL
):
# Hydrate apps with any passed server info
if monitor_publisher is None:
raise RuntimeError(
"No restart publisher found in worker process"
)
if worker_state is None:
raise RuntimeError("No worker state found in worker process")
# Run secondary servers
apps = list(Sanic._app_registry.values())
app.before_server_start(partial(app._start_servers, apps=apps))
for a in apps:
a.multiplexer = WorkerMultiplexer(
monitor_publisher, worker_state
)
if app.debug:
loop.set_debug(app.debug)
app.asgi = False
if app.state.server_info:
primary_server_info = app.state.server_info[0]
primary_server_info.stage = ServerStage.SERVING
if config:
app.update_config(config)
if version is HTTP.VERSION_3:
return _serve_http_3(host, port, app, loop, ssl)
return _serve_http_1(
host,
port,
app,
ssl,
sock,
unix,
reuse_port,
loop,
protocol,
backlog,
register_sys_signals,
run_multiple,
run_async,
connections,
signal,
state,
asyncio_server_kwargs,
)
except Exception as e:
warnings.simplefilter("ignore", category=RuntimeWarning)
if monitor_publisher:
error_logger.exception(e)
multiplexer = WorkerMultiplexer(monitor_publisher, {})
multiplexer.terminate(True)
else:
raise e

85
sanic/worker/state.py Normal file
View File

@@ -0,0 +1,85 @@
from collections.abc import Mapping
from typing import Any, Dict, ItemsView, Iterator, KeysView, List
from typing import Mapping as MappingType
from typing import ValuesView
dict
class WorkerState(Mapping):
RESTRICTED = (
"health",
"pid",
"requests",
"restart_at",
"server",
"start_at",
"starts",
"state",
)
def __init__(self, state: Dict[str, Any], current: str) -> None:
self._name = current
self._state = state
def __getitem__(self, key: str) -> Any:
return self._state[self._name][key]
def __setitem__(self, key: str, value: Any) -> None:
if key in self.RESTRICTED:
self._write_error([key])
self._state[self._name] = {
**self._state[self._name],
key: value,
}
def __delitem__(self, key: str) -> None:
if key in self.RESTRICTED:
self._write_error([key])
self._state[self._name] = {
k: v for k, v in self._state[self._name].items() if k != key
}
def __iter__(self) -> Iterator[Any]:
return iter(self._state[self._name])
def __len__(self) -> int:
return len(self._state[self._name])
def __repr__(self) -> str:
return repr(self._state[self._name])
def __eq__(self, other: object) -> bool:
return self._state[self._name] == other
def keys(self) -> KeysView[str]:
return self._state[self._name].keys()
def values(self) -> ValuesView[Any]:
return self._state[self._name].values()
def items(self) -> ItemsView[str, Any]:
return self._state[self._name].items()
def update(self, mapping: MappingType[str, Any]) -> None:
if any(k in self.RESTRICTED for k in mapping.keys()):
self._write_error(
[k for k in mapping.keys() if k in self.RESTRICTED]
)
self._state[self._name] = {
**self._state[self._name],
**mapping,
}
def pop(self) -> None:
raise NotImplementedError
def full(self) -> Dict[str, Any]:
return dict(self._state)
def _write_error(self, keys: List[str]) -> None:
raise LookupError(
f"Cannot set restricted key{'s' if len(keys) > 1 else ''} on "
f"WorkerState: {', '.join(keys)}"
)

View File

@@ -6,8 +6,6 @@ import os
import re
import sys
from distutils.util import strtobool
from setuptools import find_packages, setup
from setuptools.command.test import test as TestCommand
@@ -37,6 +35,25 @@ def open_local(paths, mode="r", encoding="utf8"):
return codecs.open(path, mode, encoding)
def str_to_bool(val: str) -> bool:
val = val.lower()
if val in {
"y",
"yes",
"yep",
"yup",
"t",
"true",
"on",
"enable",
"enabled",
"1",
}:
return True
elif val in {"n", "no", "f", "false", "off", "disable", "disabled", "0"}:
return False
else:
raise ValueError(f"Invalid truth value {val}")
with open_local(["sanic", "__version__.py"], encoding="latin1") as fp:
try:
@@ -61,7 +78,7 @@ setup_kwargs = {
"Build fast. Run fast."
),
"long_description": long_description,
"packages": find_packages(),
"packages": find_packages(exclude=("tests", "tests.*")),
"package_data": {"sanic": ["py.typed"]},
"platforms": "any",
"python_requires": ">=3.7",
@@ -73,6 +90,7 @@ setup_kwargs = {
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
],
"entry_points": {"console_scripts": ["sanic = sanic.__main__:main"]},
}
@@ -81,10 +99,10 @@ env_dependency = (
'; sys_platform != "win32" ' 'and implementation_name == "cpython"'
)
ujson = "ujson>=1.35" + env_dependency
uvloop = "uvloop>=0.5.3" + env_dependency
uvloop = "uvloop>=0.15.0" + env_dependency
types_ujson = "types-ujson" + env_dependency
requirements = [
"sanic-routing>=22.3.0,<22.6.0",
"sanic-routing>=22.8.0",
"httptools>=0.0.10",
uvloop,
ujson,
@@ -94,14 +112,11 @@ requirements = [
]
tests_require = [
"sanic-testing>=22.3.0",
"pytest==6.2.5",
"coverage==5.3",
"gunicorn==20.0.4",
"pytest-cov",
"sanic-testing>=22.9.0",
"pytest==7.1.*",
"coverage",
"beautifulsoup4",
"pytest-sanic",
"pytest-sugar",
"pytest-benchmark",
"chardet==3.*",
"flake8",
@@ -134,13 +149,13 @@ dev_require = tests_require + [
all_require = list(set(dev_require + docs_require))
if strtobool(os.environ.get("SANIC_NO_UJSON", "no")):
if str_to_bool(os.environ.get("SANIC_NO_UJSON", "no")):
print("Installing without uJSON")
requirements.remove(ujson)
tests_require.remove(types_ujson)
# 'nt' means windows OS
if strtobool(os.environ.get("SANIC_NO_UVLOOP", "no")):
if str_to_bool(os.environ.get("SANIC_NO_UVLOOP", "no")):
print("Installing without uvLoop")
requirements.remove(uvloop)

View File

@@ -8,8 +8,8 @@ import uuid
from contextlib import suppress
from logging import LogRecord
from typing import List, Tuple
from unittest.mock import MagicMock
from typing import Any, Dict, List, Tuple
from unittest.mock import MagicMock, Mock, patch
import pytest
@@ -54,7 +54,7 @@ TYPE_TO_GENERATOR_MAP = {
"uuid": lambda: str(uuid.uuid1()),
}
CACHE = {}
CACHE: Dict[str, Any] = {}
class RouteStringGenerator:
@@ -126,7 +126,7 @@ def sanic_router(app):
except RouteExists:
pass
router.finalize()
return router, added_router
return router, tuple(added_router)
return _setup
@@ -147,6 +147,7 @@ def app(request):
for target, method_name in TouchUp._registry:
CACHE[method_name] = getattr(target, method_name)
app = Sanic(slugify.sub("-", request.node.name))
yield app
for target, method_name in TouchUp._registry:
setattr(target, method_name, CACHE[method_name])
@@ -220,3 +221,14 @@ def sanic_ext(ext_instance): # noqa
yield sanic_ext
with suppress(KeyError):
del sys.modules["sanic_ext"]
@pytest.fixture
def urlopen():
urlopen = Mock()
urlopen.return_value = urlopen
urlopen.__enter__ = Mock(return_value=urlopen)
urlopen.__exit__ = Mock()
urlopen.read = Mock()
with patch("sanic.cli.inspector_client.urlopen", urlopen):
yield urlopen

View File

@@ -1,6 +1,8 @@
import json
from sanic import Sanic, text
from sanic.application.constants import Mode
from sanic.config import Config
from sanic.log import LOGGING_CONFIG_DEFAULTS, logger
@@ -16,7 +18,7 @@ async def handler(request):
return text(request.ip)
@app.before_server_start
@app.main_process_start
async def app_info_dump(app: Sanic, _):
app_data = {
"access_log": app.config.ACCESS_LOG,
@@ -27,6 +29,13 @@ async def app_info_dump(app: Sanic, _):
logger.info(json.dumps(app_data))
@app.main_process_stop
async def app_cleanup(app: Sanic, _):
app.state.auto_reload = False
app.state.mode = Mode.PRODUCTION
app.config = Config()
@app.after_server_start
async def shutdown(app: Sanic, _):
app.stop()
@@ -38,8 +47,8 @@ def create_app():
def create_app_with_args(args):
try:
print(f"foo={args.foo}")
logger.info(f"foo={args.foo}")
except AttributeError:
print(f"module={args.module}")
logger.info(f"module={args.module}")
return app

View File

@@ -35,6 +35,7 @@ def test_server_starts_http3(app: Sanic, version, caplog):
"cert": localhost_dir / "fullchain.pem",
"key": localhost_dir / "privkey.pem",
},
single_process=True,
)
assert ev.is_set()
@@ -69,7 +70,7 @@ def test_server_starts_http1_and_http3(app: Sanic, caplog):
},
)
with caplog.at_level(logging.INFO):
Sanic.serve()
Sanic.serve_single()
assert (
"sanic.root",

View File

@@ -4,6 +4,7 @@ import re
from collections import Counter
from inspect import isawaitable
from os import environ
from unittest.mock import Mock, patch
import pytest
@@ -14,8 +15,10 @@ from sanic import Sanic
from sanic.compat import OS_IS_WINDOWS
from sanic.config import Config
from sanic.exceptions import SanicException
from sanic.helpers import _default
from sanic.helpers import Default
from sanic.log import LOGGING_CONFIG_DEFAULTS
from sanic.response import text
from sanic.router import Route
@pytest.fixture(autouse=True)
@@ -23,7 +26,7 @@ def clear_app_registry():
Sanic._app_registry = {}
def test_app_loop_running(app):
def test_app_loop_running(app: Sanic):
@app.get("/test")
async def handler(request):
assert isinstance(app.loop, asyncio.AbstractEventLoop)
@@ -33,7 +36,7 @@ def test_app_loop_running(app):
assert response.text == "pass"
def test_create_asyncio_server(app):
def test_create_asyncio_server(app: Sanic):
loop = asyncio.get_event_loop()
asyncio_srv_coro = app.create_server(return_asyncio_server=True)
assert isawaitable(asyncio_srv_coro)
@@ -41,7 +44,7 @@ def test_create_asyncio_server(app):
assert srv.is_serving() is True
def test_asyncio_server_no_start_serving(app):
def test_asyncio_server_no_start_serving(app: Sanic):
loop = asyncio.get_event_loop()
asyncio_srv_coro = app.create_server(
port=43123,
@@ -52,7 +55,7 @@ def test_asyncio_server_no_start_serving(app):
assert srv.is_serving() is False
def test_asyncio_server_start_serving(app):
def test_asyncio_server_start_serving(app: Sanic):
loop = asyncio.get_event_loop()
asyncio_srv_coro = app.create_server(
port=43124,
@@ -69,7 +72,7 @@ def test_asyncio_server_start_serving(app):
# Looks like we can't easily test `serve_forever()`
def test_create_server_main(app, caplog):
def test_create_server_main(app: Sanic, caplog):
app.listener("main_process_start")(lambda *_: ...)
loop = asyncio.get_event_loop()
with caplog.at_level(logging.INFO):
@@ -83,7 +86,7 @@ def test_create_server_main(app, caplog):
) in caplog.record_tuples
def test_create_server_no_startup(app):
def test_create_server_no_startup(app: Sanic):
loop = asyncio.get_event_loop()
asyncio_srv_coro = app.create_server(
port=43124,
@@ -98,7 +101,7 @@ def test_create_server_no_startup(app):
loop.run_until_complete(srv.start_serving())
def test_create_server_main_convenience(app, caplog):
def test_create_server_main_convenience(app: Sanic, caplog):
app.main_process_start(lambda *_: ...)
loop = asyncio.get_event_loop()
with caplog.at_level(logging.INFO):
@@ -112,7 +115,7 @@ def test_create_server_main_convenience(app, caplog):
) in caplog.record_tuples
def test_app_loop_not_running(app):
def test_app_loop_not_running(app: Sanic):
with pytest.raises(SanicException) as excinfo:
app.loop
@@ -122,7 +125,7 @@ def test_app_loop_not_running(app):
)
def test_app_run_raise_type_error(app):
def test_app_run_raise_type_error(app: Sanic):
with pytest.raises(TypeError) as excinfo:
app.run(loop="loop")
@@ -135,7 +138,7 @@ def test_app_run_raise_type_error(app):
)
def test_app_route_raise_value_error(app):
def test_app_route_raise_value_error(app: Sanic):
with pytest.raises(ValueError) as excinfo:
@@ -149,11 +152,15 @@ def test_app_route_raise_value_error(app):
)
def test_app_handle_request_handler_is_none(app, monkeypatch):
def mockreturn(*args, **kwargs):
return Mock(), None, {}
def test_app_handle_request_handler_is_none(app: Sanic, monkeypatch):
app.config.TOUCHUP = False
route = Mock(spec=Route)
route.extra.request_middleware = []
route.extra.response_middleware = []
def mockreturn(*args, **kwargs):
return route, None, {}
# Not sure how to make app.router.get() return None, so use mock here.
monkeypatch.setattr(app.router, "get", mockreturn)
@app.get("/test")
@@ -170,7 +177,7 @@ def test_app_handle_request_handler_is_none(app, monkeypatch):
@pytest.mark.parametrize("websocket_enabled", [True, False])
@pytest.mark.parametrize("enable", [True, False])
def test_app_enable_websocket(app, websocket_enabled, enable):
def test_app_enable_websocket(app: Sanic, websocket_enabled, enable):
app.websocket_enabled = websocket_enabled
app.enable_websocket(enable=enable)
@@ -180,11 +187,11 @@ def test_app_enable_websocket(app, websocket_enabled, enable):
async def handler(request, ws):
await ws.send("test")
assert app.websocket_enabled == True
assert app.websocket_enabled is True
@patch("sanic.mixins.runner.WebSocketProtocol")
def test_app_websocket_parameters(websocket_protocol_mock, app):
@patch("sanic.mixins.startup.WebSocketProtocol")
def test_app_websocket_parameters(websocket_protocol_mock, app: Sanic):
app.config.WEBSOCKET_MAX_SIZE = 44
app.config.WEBSOCKET_PING_TIMEOUT = 48
app.config.WEBSOCKET_PING_INTERVAL = 50
@@ -194,9 +201,10 @@ def test_app_websocket_parameters(websocket_protocol_mock, app):
await ws.send("test")
try:
# This will fail because WebSocketProtocol is mocked and only the call kwargs matter
# This will fail because WebSocketProtocol is mocked and only the
# call kwargs matter
app.test_client.get("/ws")
except:
except Exception:
pass
websocket_protocol_call_args = websocket_protocol_mock.call_args
@@ -212,11 +220,10 @@ def test_app_websocket_parameters(websocket_protocol_mock, app):
)
def test_handle_request_with_nested_exception(app, monkeypatch):
def test_handle_request_with_nested_exception(app: Sanic, monkeypatch):
err_msg = "Mock Exception"
# Not sure how to raise an exception in app.error_handler.response(), use mock here
def mock_error_handler_response(*args, **kwargs):
raise Exception(err_msg)
@@ -233,11 +240,10 @@ def test_handle_request_with_nested_exception(app, monkeypatch):
assert response.text == "An error occurred while handling an error"
def test_handle_request_with_nested_exception_debug(app, monkeypatch):
def test_handle_request_with_nested_exception_debug(app: Sanic, monkeypatch):
err_msg = "Mock Exception"
# Not sure how to raise an exception in app.error_handler.response(), use mock here
def mock_error_handler_response(*args, **kwargs):
raise Exception(err_msg)
@@ -252,13 +258,14 @@ def test_handle_request_with_nested_exception_debug(app, monkeypatch):
request, response = app.test_client.get("/", debug=True)
assert response.status == 500
assert response.text.startswith(
f"Error while handling error: {err_msg}\nStack: Traceback (most recent call last):\n"
f"Error while handling error: {err_msg}\n"
"Stack: Traceback (most recent call last):\n"
)
def test_handle_request_with_nested_sanic_exception(app, monkeypatch, caplog):
# Not sure how to raise an exception in app.error_handler.response(), use mock here
def test_handle_request_with_nested_sanic_exception(
app: Sanic, monkeypatch, caplog
):
def mock_error_handler_response(*args, **kwargs):
raise SanicException("Mock SanicException")
@@ -301,8 +308,12 @@ def test_app_has_test_mode_sync():
def test_app_registry():
assert len(Sanic._app_registry) == 0
instance = Sanic("test")
assert len(Sanic._app_registry) == 1
assert Sanic._app_registry["test"] is instance
Sanic.unregister_app(instance)
assert len(Sanic._app_registry) == 0
def test_app_registry_wrong_type():
@@ -336,7 +347,15 @@ def test_app_registry_retrieval_from_multiple():
def test_get_app_does_not_exist():
with pytest.raises(
SanicException, match='Sanic app name "does-not-exist" not found.'
SanicException,
match=(
"Sanic app name 'does-not-exist' not found.\n"
"App instantiation must occur outside "
"if __name__ == '__main__' "
"block or by using an AppLoader.\nSee "
"https://sanic.dev/en/guide/deployment/app-loader.html"
" for more details."
),
):
Sanic.get_app("does-not-exist")
@@ -371,7 +390,7 @@ def test_get_app_default_ambiguous():
Sanic.get_app()
def test_app_set_attribute_warning(app):
def test_app_set_attribute_warning(app: Sanic):
message = (
"Setting variables on Sanic instances is not allowed. You should "
"change your Sanic instance to use instance.ctx.foo instead."
@@ -380,7 +399,7 @@ def test_app_set_attribute_warning(app):
app.foo = 1
def test_app_set_context(app):
def test_app_set_context(app: Sanic):
app.ctx.foo = 1
retrieved = Sanic.get_app(app.name)
@@ -426,13 +445,13 @@ def test_custom_context():
@pytest.mark.parametrize("use", (False, True))
def test_uvloop_config(app, monkeypatch, use):
def test_uvloop_config(app: Sanic, monkeypatch, use):
@app.get("/test")
def handler(request):
return text("ok")
try_use_uvloop = Mock()
monkeypatch.setattr(sanic.mixins.runner, "try_use_uvloop", try_use_uvloop)
monkeypatch.setattr(sanic.mixins.startup, "try_use_uvloop", try_use_uvloop)
# Default config
app.test_client.get("/test")
@@ -458,7 +477,7 @@ def test_uvloop_cannot_never_called_with_create_server(caplog, monkeypatch):
apps[2].config.USE_UVLOOP = True
try_use_uvloop = Mock()
monkeypatch.setattr(sanic.mixins.runner, "try_use_uvloop", try_use_uvloop)
monkeypatch.setattr(sanic.mixins.startup, "try_use_uvloop", try_use_uvloop)
loop = asyncio.get_event_loop()
@@ -480,7 +499,9 @@ def test_uvloop_cannot_never_called_with_create_server(caplog, monkeypatch):
)
counter = Counter([(r[1], r[2]) for r in caplog.record_tuples])
modified = sum(1 for app in apps if app.config.USE_UVLOOP is not _default)
modified = sum(
1 for app in apps if not isinstance(app.config.USE_UVLOOP, Default)
)
assert counter[(logging.WARNING, message)] == modified
@@ -514,15 +535,136 @@ def test_multiple_uvloop_configs_display_warning(caplog):
counter = Counter([(r[1], r[2]) for r in caplog.record_tuples])
assert counter[(logging.WARNING, message)] == 2
assert counter[(logging.WARNING, message)] == 3
def test_cannot_run_fast_and_workers(app):
def test_cannot_run_fast_and_workers(app: Sanic):
message = "You cannot use both fast=True and workers=X"
with pytest.raises(RuntimeError, match=message):
app.run(fast=True, workers=4)
def test_no_workers(app):
def test_no_workers(app: Sanic):
with pytest.raises(RuntimeError, match="Cannot serve with no workers"):
app.run(workers=0)
@pytest.mark.parametrize(
"extra",
(
{"fast": True},
{"workers": 2},
{"auto_reload": True},
),
)
def test_cannot_run_single_process_and_workers_or_auto_reload(
app: Sanic, extra
):
message = (
"Single process cannot be run with multiple workers or auto-reload"
)
with pytest.raises(RuntimeError, match=message):
app.run(single_process=True, **extra)
def test_cannot_run_single_process_and_legacy(app: Sanic):
message = "Cannot run single process and legacy mode"
with pytest.raises(RuntimeError, match=message):
app.run(single_process=True, legacy=True)
def test_cannot_run_without_sys_signals_with_workers(app: Sanic):
message = (
"Cannot run Sanic.serve with register_sys_signals=False. "
"Use either Sanic.serve_single or Sanic.serve_legacy."
)
with pytest.raises(RuntimeError, match=message):
app.run(register_sys_signals=False, single_process=False, legacy=False)
def test_default_configure_logging():
with patch("sanic.app.logging") as mock:
Sanic("Test")
mock.config.dictConfig.assert_called_with(LOGGING_CONFIG_DEFAULTS)
def test_custom_configure_logging():
with patch("sanic.app.logging") as mock:
Sanic("Test", log_config={"foo": "bar"})
mock.config.dictConfig.assert_called_with({"foo": "bar"})
def test_disable_configure_logging():
with patch("sanic.app.logging") as mock:
Sanic("Test", configure_logging=False)
mock.config.dictConfig.assert_not_called()
@pytest.mark.parametrize("inspector", (True, False))
def test_inspector(inspector):
app = Sanic("Test", inspector=inspector)
assert app.config.INSPECTOR is inspector
def test_build_endpoint_name():
app = Sanic("Test")
name = app._build_endpoint_name("foo", "bar")
assert name == "Test.foo.bar"
def test_manager_in_main_process_only(app: Sanic):
message = "Can only access the manager from the main process"
with pytest.raises(SanicException, match=message):
app.manager
app._manager = 1
environ["SANIC_WORKER_PROCESS"] = "ok"
with pytest.raises(SanicException, match=message):
app.manager
del environ["SANIC_WORKER_PROCESS"]
assert app.manager == 1
def test_inspector_in_main_process_only(app: Sanic):
message = "Can only access the inspector from the main process"
with pytest.raises(SanicException, match=message):
app.inspector
app._inspector = 1
environ["SANIC_WORKER_PROCESS"] = "ok"
with pytest.raises(SanicException, match=message):
app.inspector
del environ["SANIC_WORKER_PROCESS"]
assert app.inspector == 1
def test_stop_trigger_terminate(app: Sanic):
app.multiplexer = Mock()
app.stop()
app.multiplexer.terminate.assert_called_once()
app.multiplexer.reset_mock()
assert len(Sanic._app_registry) == 1
Sanic._app_registry.clear()
app.stop(terminate=True)
app.multiplexer.terminate.assert_called_once()
app.multiplexer.reset_mock()
assert len(Sanic._app_registry) == 0
Sanic._app_registry.clear()
app.stop(unregister=False)
app.multiplexer.terminate.assert_called_once()

View File

@@ -8,7 +8,7 @@ import uvicorn
from sanic import Sanic
from sanic.application.state import Mode
from sanic.asgi import MockTransport
from sanic.asgi import ASGIApp, MockTransport
from sanic.exceptions import BadRequest, Forbidden, ServiceUnavailable
from sanic.request import Request
from sanic.response import json, text
@@ -16,6 +16,12 @@ from sanic.server.websockets.connection import WebSocketConnection
from sanic.signals import RESERVED_NAMESPACES
try:
from unittest.mock import AsyncMock
except ImportError:
from tests.asyncmock import AsyncMock # type: ignore
@pytest.fixture
def message_stack():
return deque()
@@ -531,6 +537,8 @@ async def test_signals_triggered(app):
"http.lifecycle.handle",
"http.routing.before",
"http.routing.after",
"http.handler.before",
"http.handler.after",
"http.lifecycle.response",
# "http.lifecycle.send",
# "http.lifecycle.complete",
@@ -546,3 +554,49 @@ async def test_signals_triggered(app):
assert response.status_code == 200
assert response.text == "test_signals_triggered"
assert signals_triggered == signals_expected
@pytest.mark.asyncio
async def test_asgi_serve_location(app):
@app.get("/")
def _request(request: Request):
return text(request.app.serve_location)
_, response = await app.asgi_client.get("/")
assert response.text == "http://<ASGI>"
@pytest.mark.asyncio
async def test_error_on_lifespan_exception_start(app, caplog):
@app.before_server_start
async def before_server_start(_):
1 / 0
recv = AsyncMock(return_value={"type": "lifespan.startup"})
send = AsyncMock()
app.asgi = True
with caplog.at_level(logging.ERROR):
await ASGIApp.create(app, {"type": "lifespan"}, recv, send)
send.assert_awaited_once_with(
{"type": "lifespan.startup.failed", "message": "division by zero"}
)
@pytest.mark.asyncio
async def test_error_on_lifespan_exception_stop(app: Sanic):
@app.before_server_stop
async def before_server_stop(_):
1 / 0
recv = AsyncMock(return_value={"type": "lifespan.shutdown"})
send = AsyncMock()
app.asgi = True
await app._startup()
await ASGIApp.create(app, {"type": "lifespan"}, recv, send)
send.assert_awaited_once_with(
{"type": "lifespan.shutdown.failed", "message": "division by zero"}
)

View File

@@ -1,13 +1,16 @@
import asyncio
from sanic import Sanic
def test_bad_request_response(app):
def test_bad_request_response(app: Sanic):
lines = []
app.get("/")(lambda x: ...)
@app.listener("after_server_start")
async def _request(sanic, loop):
nonlocal lines
connect = asyncio.open_connection("127.0.0.1", 42101)
reader, writer = await connect
writer.write(b"not http\r\n\r\n")
@@ -18,6 +21,6 @@ def test_bad_request_response(app):
lines.append(line)
app.stop()
app.run(host="127.0.0.1", port=42101, debug=False)
app.run(host="127.0.0.1", port=42101, debug=False, single_process=True)
assert lines[0] == b"HTTP/1.1 400 Bad Request\r\n"
assert b"Bad Request" in lines[-2]

View File

@@ -323,3 +323,20 @@ def test_bp_group_properties():
assert "api/v1/grouped/bp2/" in routes
assert "api/v1/primary/grouped/bp1" in routes
assert "api/v1/primary/grouped/bp2" in routes
def test_nested_bp_group_properties():
one = Blueprint("one", url_prefix="/one")
two = Blueprint.group(one)
three = Blueprint.group(two, url_prefix="/three")
@one.route("/four")
def handler(request):
return text("pi")
app = Sanic("PropTest")
app.blueprint(three)
app.router.finalize()
routes = [route.path for route in app.router.routes]
assert routes == ["three/one/four"]

View File

@@ -17,7 +17,7 @@ from sanic.response import json, text
# ------------------------------------------------------------ #
def test_bp(app):
def test_bp(app: Sanic):
bp = Blueprint("test_text")
@bp.route("/")
@@ -30,7 +30,7 @@ def test_bp(app):
assert response.text == "Hello"
def test_bp_app_access(app):
def test_bp_app_access(app: Sanic):
bp = Blueprint("test")
with pytest.raises(
@@ -87,7 +87,7 @@ def test_versioned_routes_get(app, method):
assert response.status == 200
def test_bp_strict_slash(app):
def test_bp_strict_slash(app: Sanic):
bp = Blueprint("test_text")
@bp.get("/get", strict_slashes=True)
@@ -114,7 +114,7 @@ def test_bp_strict_slash(app):
assert response.status == 404
def test_bp_strict_slash_default_value(app):
def test_bp_strict_slash_default_value(app: Sanic):
bp = Blueprint("test_text", strict_slashes=True)
@bp.get("/get")
@@ -134,7 +134,7 @@ def test_bp_strict_slash_default_value(app):
assert response.status == 404
def test_bp_strict_slash_without_passing_default_value(app):
def test_bp_strict_slash_without_passing_default_value(app: Sanic):
bp = Blueprint("test_text")
@bp.get("/get")
@@ -154,7 +154,7 @@ def test_bp_strict_slash_without_passing_default_value(app):
assert response.text == "OK"
def test_bp_strict_slash_default_value_can_be_overwritten(app):
def test_bp_strict_slash_default_value_can_be_overwritten(app: Sanic):
bp = Blueprint("test_text", strict_slashes=True)
@bp.get("/get", strict_slashes=False)
@@ -174,7 +174,7 @@ def test_bp_strict_slash_default_value_can_be_overwritten(app):
assert response.text == "OK"
def test_bp_with_url_prefix(app):
def test_bp_with_url_prefix(app: Sanic):
bp = Blueprint("test_text", url_prefix="/test1")
@bp.route("/")
@@ -187,7 +187,7 @@ def test_bp_with_url_prefix(app):
assert response.text == "Hello"
def test_several_bp_with_url_prefix(app):
def test_several_bp_with_url_prefix(app: Sanic):
bp = Blueprint("test_text", url_prefix="/test1")
bp2 = Blueprint("test_text2", url_prefix="/test2")
@@ -208,7 +208,7 @@ def test_several_bp_with_url_prefix(app):
assert response.text == "Hello2"
def test_bp_with_host(app):
def test_bp_with_host(app: Sanic):
bp = Blueprint("test_bp_host", url_prefix="/test1", host="example.com")
@bp.route("/")
@@ -230,7 +230,7 @@ def test_bp_with_host(app):
assert response.body == b"Hello subdomain!"
def test_several_bp_with_host(app):
def test_several_bp_with_host(app: Sanic):
bp = Blueprint(
"test_text",
url_prefix="/test",
@@ -274,7 +274,7 @@ def test_several_bp_with_host(app):
assert response.text == "Hello3"
def test_bp_with_host_list(app):
def test_bp_with_host_list(app: Sanic):
bp = Blueprint(
"test_bp_host",
url_prefix="/test1",
@@ -304,7 +304,7 @@ def test_bp_with_host_list(app):
assert response.text == "Hello subdomain!"
def test_several_bp_with_host_list(app):
def test_several_bp_with_host_list(app: Sanic):
bp = Blueprint(
"test_text",
url_prefix="/test",
@@ -356,7 +356,7 @@ def test_several_bp_with_host_list(app):
assert response.text == "Hello3"
def test_bp_middleware(app):
def test_bp_middleware(app: Sanic):
blueprint = Blueprint("test_bp_middleware")
@blueprint.middleware("response")
@@ -375,7 +375,7 @@ def test_bp_middleware(app):
assert response.text == "FAIL"
def test_bp_middleware_with_route(app):
def test_bp_middleware_with_route(app: Sanic):
blueprint = Blueprint("test_bp_middleware")
@blueprint.middleware("response")
@@ -398,7 +398,7 @@ def test_bp_middleware_with_route(app):
assert response.text == "OK"
def test_bp_middleware_order(app):
def test_bp_middleware_order(app: Sanic):
blueprint = Blueprint("test_bp_middleware_order")
order = []
@@ -438,7 +438,7 @@ def test_bp_middleware_order(app):
assert order == [1, 2, 3, 4, 5, 6]
def test_bp_exception_handler(app):
def test_bp_exception_handler(app: Sanic):
blueprint = Blueprint("test_middleware")
@blueprint.route("/1")
@@ -470,7 +470,7 @@ def test_bp_exception_handler(app):
assert response.status == 200
def test_bp_exception_handler_applied(app):
def test_bp_exception_handler_applied(app: Sanic):
class Error(Exception):
pass
@@ -500,7 +500,7 @@ def test_bp_exception_handler_applied(app):
assert response.status == 500
def test_bp_exception_handler_not_applied(app):
def test_bp_exception_handler_not_applied(app: Sanic):
class Error(Exception):
pass
@@ -522,7 +522,7 @@ def test_bp_exception_handler_not_applied(app):
assert response.status == 500
def test_bp_listeners(app):
def test_bp_listeners(app: Sanic):
app.route("/")(lambda x: x)
blueprint = Blueprint("test_middleware")
@@ -559,7 +559,7 @@ def test_bp_listeners(app):
assert order == [1, 2, 3, 4, 5, 6]
def test_bp_static(app):
def test_bp_static(app: Sanic):
current_file = inspect.getfile(inspect.currentframe())
with open(current_file, "rb") as file:
current_file_contents = file.read()
@@ -597,7 +597,7 @@ def test_bp_static_content_type(app, file_name):
assert response.headers["Content-Type"] == "text/html; charset=utf-8"
def test_bp_shorthand(app):
def test_bp_shorthand(app: Sanic):
blueprint = Blueprint("test_shorhand_routes")
ev = asyncio.Event()
@@ -682,7 +682,7 @@ def test_bp_shorthand(app):
assert ev.is_set()
def test_bp_group(app):
def test_bp_group(app: Sanic):
deep_0 = Blueprint("deep_0", url_prefix="/deep")
deep_1 = Blueprint("deep_1", url_prefix="/deep1")
@@ -722,7 +722,7 @@ def test_bp_group(app):
assert response.text == "D1B_OK"
def test_bp_group_with_default_url_prefix(app):
def test_bp_group_with_default_url_prefix(app: Sanic):
from sanic.response import json
bp_resources = Blueprint("bp_resources")
@@ -873,7 +873,7 @@ def test_websocket_route(app: Sanic):
assert event.is_set()
def test_duplicate_blueprint(app):
def test_duplicate_blueprint(app: Sanic):
bp_name = "bp"
bp = Blueprint(bp_name)
bp1 = Blueprint(bp_name)
@@ -1056,7 +1056,7 @@ def test_bp_set_attribute_warning():
bp.foo = 1
def test_early_registration(app):
def test_early_registration(app: Sanic):
assert len(app.router.routes) == 0
bp = Blueprint("bp")
@@ -1082,3 +1082,29 @@ def test_early_registration(app):
for path in ("one", "two", "three"):
_, response = app.test_client.get(f"/{path}")
assert response.text == path
def test_remove_double_slashes_defined_on_bp(app: Sanic):
bp = Blueprint("bp", url_prefix="/foo/", strict_slashes=True)
@bp.get("/")
async def handler(_):
...
app.blueprint(bp)
app.router.finalize()
assert app.router.routes[0].path == "foo/"
def test_remove_double_slashes_defined_on_register(app: Sanic):
bp = Blueprint("bp")
@bp.get("/")
async def index(_):
...
app.blueprint(bp, url_prefix="/foo/", strict_slashes=True)
app.router.finalize()
assert app.router.routes[0].path == "foo/"

View File

@@ -0,0 +1,21 @@
import asyncio
from asyncio import CancelledError
import pytest
from sanic import Request, Sanic, json
def test_can_raise_in_handler(app: Sanic):
@app.get("/")
async def handler(request: Request):
raise CancelledError("STOP!!")
@app.exception(CancelledError)
async def handle_cancel(request: Request, exc: CancelledError):
return json({"message": exc.args[0]}, status=418)
_, response = app.test_client.get("/")
assert response.status == 418
assert response.json["message"] == "STOP!!"

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