Compare commits

..

49 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
106 changed files with 3649 additions and 1634 deletions

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,27 +0,0 @@
---
name: Bug report
about: Create a report to help us improve
labels: ["bug"]
---
**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):**
<!-- Please provide the information below. Instead, you can copy and paste the message that Sanic shows on startup. If you do, please remember to format it with ``` -->
- OS:
- Sanic Version:
**Additional context**
<!-- Add any other context about the problem here. -->

View File

@@ -1,4 +1,4 @@
blank_issues_enabled: true blank_issues_enabled: false
contact_links: contact_links:
- name: Questions and Help - name: Questions and Help
url: https://community.sanicframework.org/c/questions-and-help url: https://community.sanicframework.org/c/questions-and-help

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,17 +0,0 @@
---
name: Feature request
about: Suggest an idea for Sanic
labels: ["feature request"]
---
**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.8, tox-env: security}
- { python-version: 3.9, tox-env: security} - { python-version: 3.9, tox-env: security}
- { python-version: "3.10", tox-env: security} - { python-version: "3.10", tox-env: security}
- { python-version: "3.11", tox-env: security}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v2 uses: actions/checkout@v2

View File

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

View File

@@ -16,7 +16,7 @@ jobs:
matrix: matrix:
os: [ubuntu-latest] os: [ubuntu-latest]
config: config:
- { python-version: 3.8, tox-env: lint} - { python-version: "3.10", tox-env: lint}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v2 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.8, tox-env: type-checking}
- { python-version: 3.9, tox-env: type-checking} - { python-version: 3.9, tox-env: type-checking}
- { python-version: "3.10", tox-env: type-checking} - { python-version: "3.10", tox-env: type-checking}
- { python-version: "3.11", tox-env: type-checking}
steps: steps:
- name: Checkout the repository - name: Checkout the repository
uses: actions/checkout@v2 uses: actions/checkout@v2

View File

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

View File

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

View File

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

View File

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

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 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. 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 Hello World Example
------------------- -------------------

View File

@@ -7,13 +7,15 @@ Sanic releases long term support release once a year in December. LTS releases r
| Version | LTS | Supported | | Version | LTS | Supported |
| ------- | ------------- | ----------------------- | | ------- | ------------- | ----------------------- |
| 22.6 | | :white_check_mark: | | 22.12 | until 2024-12 | :white_check_mark: |
| 22.9 | | :x: |
| 22.6 | | :x: |
| 22.3 | | :x: | | 22.3 | | :x: |
| 21.12 | until 2023-12 | :white_check_mark: | | 21.12 | until 2023-12 | :ballot_box_with_check: |
| 21.9 | | :x: | | 21.9 | | :x: |
| 21.6 | | :x: | | 21.6 | | :x: |
| 21.3 | | :x: | | 21.3 | | :x: |
| 20.12 | until 2022-12 | :ballot_box_with_check: | | 20.12 | | :x: |
| 20.9 | | :x: | | 20.9 | | :x: |
| 20.6 | | :x: | | 20.6 | | :x: |
| 20.3 | | :x: | | 20.3 | | :x: |

View File

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

View File

@@ -1,6 +1,8 @@
📜 Changelog 📜 Changelog
============ ============
.. mdinclude:: ./releases/22/22.12.md
.. mdinclude:: ./releases/22/22.9.md
.. mdinclude:: ./releases/22/22.6.md .. mdinclude:: ./releases/22/22.6.md
.. mdinclude:: ./releases/22/22.3.md .. mdinclude:: ./releases/22/22.3.md
.. mdinclude:: ./releases/21/21.12.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 ### Features
- [#2378](https://github.com/sanic-org/sanic/pull/2378) Introduce HTTP/3 and autogeneration of TLS certificates in `DEBUG` mode - [#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

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

View File

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

View File

@@ -21,6 +21,7 @@ from functools import partial
from inspect import isawaitable from inspect import isawaitable
from os import environ from os import environ
from socket import socket from socket import socket
from traceback import format_exc
from types import SimpleNamespace from types import SimpleNamespace
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
@@ -46,16 +47,21 @@ from sanic_routing.exceptions import FinalizationError, NotFound
from sanic_routing.route import Route from sanic_routing.route import Route
from sanic.application.ext import setup_ext from sanic.application.ext import setup_ext
from sanic.application.state import ApplicationState, Mode, ServerStage from sanic.application.state import ApplicationState, ServerStage
from sanic.asgi import ASGIApp from sanic.asgi import ASGIApp
from sanic.base.root import BaseSanic from sanic.base.root import BaseSanic
from sanic.blueprint_group import BlueprintGroup from sanic.blueprint_group import BlueprintGroup
from sanic.blueprints import Blueprint from sanic.blueprints import Blueprint
from sanic.compat import OS_IS_WINDOWS, enable_windows_color_support from sanic.compat import OS_IS_WINDOWS, enable_windows_color_support
from sanic.config import SANIC_PREFIX, Config from sanic.config import SANIC_PREFIX, Config
from sanic.exceptions import BadRequest, SanicException, URLBuildError from sanic.exceptions import (
BadRequest,
SanicException,
ServerError,
URLBuildError,
)
from sanic.handlers import ErrorHandler from sanic.handlers import ErrorHandler
from sanic.helpers import _default from sanic.helpers import Default, _default
from sanic.http import Stage from sanic.http import Stage
from sanic.log import ( from sanic.log import (
LOGGING_CONFIG_DEFAULTS, LOGGING_CONFIG_DEFAULTS,
@@ -63,6 +69,7 @@ from sanic.log import (
error_logger, error_logger,
logger, logger,
) )
from sanic.middleware import Middleware, MiddlewareLocation
from sanic.mixins.listeners import ListenerEvent from sanic.mixins.listeners import ListenerEvent
from sanic.mixins.startup import StartupMixin from sanic.mixins.startup import StartupMixin
from sanic.models.futures import ( from sanic.models.futures import (
@@ -77,7 +84,7 @@ from sanic.models.futures import (
from sanic.models.handler_types import ListenerType, MiddlewareType from sanic.models.handler_types import ListenerType, MiddlewareType
from sanic.models.handler_types import Sanic as SanicVar from sanic.models.handler_types import Sanic as SanicVar
from sanic.request import Request from sanic.request import Request
from sanic.response import BaseHTTPResponse from sanic.response import BaseHTTPResponse, HTTPResponse, ResponseStream
from sanic.router import Router from sanic.router import Router
from sanic.server.websockets.impl import ConnectionClosed from sanic.server.websockets.impl import ConnectionClosed
from sanic.signals import Signal, SignalRouter from sanic.signals import Signal, SignalRouter
@@ -134,6 +141,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
"configure_logging", "configure_logging",
"ctx", "ctx",
"error_handler", "error_handler",
"inspector_class",
"go_fast", "go_fast",
"listeners", "listeners",
"multiplexer", "multiplexer",
@@ -152,12 +160,11 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
) )
_app_registry: Dict[str, "Sanic"] = {} _app_registry: Dict[str, "Sanic"] = {}
_uvloop_setting = None # TODO: Remove in v22.6
test_mode = False test_mode = False
def __init__( def __init__(
self, self,
name: str = None, name: Optional[str] = None,
config: Optional[Config] = None, config: Optional[Config] = None,
ctx: Optional[Any] = None, ctx: Optional[Any] = None,
router: Optional[Router] = None, router: Optional[Router] = None,
@@ -171,6 +178,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
dumps: Optional[Callable[..., AnyStr]] = None, dumps: Optional[Callable[..., AnyStr]] = None,
loads: Optional[Callable[..., Any]] = None, loads: Optional[Callable[..., Any]] = None,
inspector: bool = False, inspector: bool = False,
inspector_class: Optional[Type[Inspector]] = None,
) -> None: ) -> None:
super().__init__(name=name) super().__init__(name=name)
# logging # logging
@@ -206,6 +214,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
self.configure_logging: bool = configure_logging self.configure_logging: bool = configure_logging
self.ctx: Any = ctx or SimpleNamespace() self.ctx: Any = ctx or SimpleNamespace()
self.error_handler: ErrorHandler = error_handler or ErrorHandler() self.error_handler: ErrorHandler = error_handler or ErrorHandler()
self.inspector_class: Type[Inspector] = inspector_class or Inspector
self.listeners: Dict[str, List[ListenerType[Any]]] = defaultdict(list) self.listeners: Dict[str, List[ListenerType[Any]]] = defaultdict(list)
self.named_request_middleware: Dict[str, Deque[MiddlewareType]] = {} self.named_request_middleware: Dict[str, Deque[MiddlewareType]] = {}
self.named_response_middleware: Dict[str, Deque[MiddlewareType]] = {} self.named_response_middleware: Dict[str, Deque[MiddlewareType]] = {}
@@ -286,8 +295,12 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
return listener return listener
def register_middleware( def register_middleware(
self, middleware: MiddlewareType, attach_to: str = "request" self,
) -> MiddlewareType: 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 Register an application level middleware that will be attached
to all the API URLs registered under this application. to all the API URLs registered under this application.
@@ -303,19 +316,37 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
**response** - Invoke before the response is returned back **response** - Invoke before the response is returned back
:return: decorated method :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: if middleware not in self.request_middleware:
self.request_middleware.append(middleware) self.request_middleware.append(middleware)
if attach_to == "response": if location is MiddlewareLocation.RESPONSE:
if middleware not in self.response_middleware: if middleware not in self.response_middleware:
self.response_middleware.appendleft(middleware) self.response_middleware.appendleft(middleware)
return middleware return retval
def register_named_middleware( def register_named_middleware(
self, self,
middleware: MiddlewareType, middleware: MiddlewareType,
route_names: Iterable[str], route_names: Iterable[str],
attach_to: str = "request", attach_to: str = "request",
*,
priority: Union[Default, int] = _default,
): ):
""" """
Method for attaching middleware to specific routes. This is mainly an Method for attaching middleware to specific routes. This is mainly an
@@ -329,19 +360,35 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
defaults to "request" defaults to "request"
:type attach_to: str, optional :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: for _rn in route_names:
if _rn not in self.named_request_middleware: if _rn not in self.named_request_middleware:
self.named_request_middleware[_rn] = deque() self.named_request_middleware[_rn] = deque()
if middleware not in self.named_request_middleware[_rn]: if middleware not in self.named_request_middleware[_rn]:
self.named_request_middleware[_rn].append(middleware) self.named_request_middleware[_rn].append(middleware)
if attach_to == "response": if location is MiddlewareLocation.RESPONSE:
for _rn in route_names: for _rn in route_names:
if _rn not in self.named_response_middleware: if _rn not in self.named_response_middleware:
self.named_response_middleware[_rn] = deque() self.named_response_middleware[_rn] = deque()
if middleware not in self.named_response_middleware[_rn]: if middleware not in self.named_response_middleware[_rn]:
self.named_response_middleware[_rn].appendleft(middleware) self.named_response_middleware[_rn].appendleft(middleware)
return middleware return retval
def _apply_exception_handler( def _apply_exception_handler(
self, self,
@@ -388,8 +435,8 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
routes = [routes] routes = [routes]
for r in routes: for r in routes:
r.ctx.websocket = websocket r.extra.websocket = websocket
r.ctx.static = params.get("static", False) r.extra.static = params.get("static", False)
r.ctx.__dict__.update(ctx) r.ctx.__dict__.update(ctx)
return routes return routes
@@ -475,17 +522,16 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
for item in blueprint: for item in blueprint:
params = {**options} params = {**options}
if isinstance(blueprint, BlueprintGroup): if isinstance(blueprint, BlueprintGroup):
if blueprint.url_prefix: merge_from = [
merge_from = [ options.get("url_prefix", ""),
options.get("url_prefix", ""), blueprint.url_prefix or "",
blueprint.url_prefix, ]
] if not isinstance(item, BlueprintGroup):
if not isinstance(item, BlueprintGroup): merge_from.append(item.url_prefix or "")
merge_from.append(item.url_prefix or "") merged_prefix = "/".join(
merged_prefix = "/".join( u.strip("/") for u in merge_from if u
u.strip("/") for u in merge_from ).rstrip("/")
).rstrip("/") params["url_prefix"] = f"/{merged_prefix}"
params["url_prefix"] = f"/{merged_prefix}"
for _attr in ["version", "strict_slashes"]: for _attr in ["version", "strict_slashes"]:
if getattr(item, _attr) is None: if getattr(item, _attr) is None:
@@ -583,7 +629,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
uri = route.path uri = route.path
if getattr(route.ctx, "static", None): if getattr(route.extra, "static", None):
filename = kwargs.pop("filename", "") filename = kwargs.pop("filename", "")
# it's static folder # it's static folder
if "__file_uri__" in uri: if "__file_uri__" in uri:
@@ -616,18 +662,18 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
host = kwargs.pop("_host", None) host = kwargs.pop("_host", None)
external = kwargs.pop("_external", False) or bool(host) external = kwargs.pop("_external", False) or bool(host)
scheme = kwargs.pop("_scheme", "") scheme = kwargs.pop("_scheme", "")
if route.ctx.hosts and external: if route.extra.hosts and external:
if not host and len(route.ctx.hosts) > 1: if not host and len(route.extra.hosts) > 1:
raise ValueError( 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( raise ValueError(
f"Requested host ({host}) is not available for this " f"Requested host ({host}) is not available for this "
f"route: {route.ctx.hosts}" f"route: {route.extra.hosts}"
) )
elif not host: elif not host:
host = list(route.ctx.hosts)[0] host = list(route.extra.hosts)[0]
if scheme and not external: if scheme and not external:
raise ValueError("When specifying _scheme, _external must be True") raise ValueError("When specifying _scheme, _external must be True")
@@ -708,10 +754,274 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
exception: BaseException, exception: BaseException,
run_middleware: bool = True, run_middleware: bool = True,
): # no cov ): # no cov
raise NotImplementedError """
A handler that catches specific exceptions and outputs a response.
:param request: The current request object
:param exception: The exception that was raised
:raises ServerError: response 500
"""
response = None
await self.dispatch(
"http.lifecycle.exception",
inline=True,
context={"request": request, "exception": exception},
)
if (
request.stream is not None
and request.stream.stage is not Stage.HANDLER
):
error_logger.exception(exception, exc_info=True)
logger.error(
"The error response will not be sent to the client for "
f'the following exception:"{exception}". A previous response '
"has at least partially been sent."
)
handler = self.error_handler._lookup(
exception, request.name if request else None
)
if handler:
logger.warning(
"An error occurred while handling the request after at "
"least some part of the response was sent to the client. "
"The response from your custom exception handler "
f"{handler.__name__} will not be sent to the client."
"Exception handlers should only be used to generate the "
"exception responses. If you would like to perform any "
"other action on a raised exception, consider using a "
"signal handler like "
'`@app.signal("http.lifecycle.exception")`\n'
"For further information, please see the docs: "
"https://sanicframework.org/en/guide/advanced/"
"signals.html",
)
return
# -------------------------------------------- #
# Request Middleware
# -------------------------------------------- #
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:
response = self.error_handler.response(request, exception)
if isawaitable(response):
response = await response
except Exception as e:
if isinstance(e, SanicException):
response = self.error_handler.default(request, e)
elif self.debug:
response = HTTPResponse(
(
f"Error while handling error: {e}\n"
f"Stack: {format_exc()}"
),
status=500,
)
else:
response = HTTPResponse(
"An error occurred while handling an error", status=500
)
if response is not None:
try:
request.reset_response()
response = await request.respond(response)
except BaseException:
# Skip response middleware
if request.stream:
request.stream.respond(response)
await response.send(end_stream=True)
raise
else:
if request.stream:
response = request.stream.response
# Marked for cleanup and DRY with handle_request/handle_exception
# when ResponseStream is no longer supporder
if isinstance(response, BaseHTTPResponse):
await self.dispatch(
"http.lifecycle.response",
inline=True,
context={
"request": request,
"response": response,
},
)
await response.send(end_stream=True)
elif isinstance(response, ResponseStream):
resp = await response(request)
await self.dispatch(
"http.lifecycle.response",
inline=True,
context={
"request": request,
"response": resp,
},
)
await response.eof()
else:
raise ServerError(
f"Invalid response type {response!r} (need HTTPResponse)"
)
async def handle_request(self, request: Request): # no cov async def handle_request(self, request: Request): # no cov
raise NotImplementedError """Take a request from the HTTP Server and return a response object
to be sent back The HTTP Server only expects a response object, so
exception handling must be done here
:param request: HTTP Request object
:return: Nothing
"""
await self.dispatch(
"http.lifecycle.handle",
inline=True,
context={"request": request},
)
# Define `response` var here to remove warnings about
# allocation before assignment below.
response: Optional[
Union[
BaseHTTPResponse,
Coroutine[Any, Any, Optional[BaseHTTPResponse]],
]
] = None
run_middleware = True
try:
await self.dispatch(
"http.routing.before",
inline=True,
context={"request": request},
)
# Fetch handler from router
route, handler, kwargs = self.router.get(
request.path,
request.method,
request.headers.getone("host", None),
)
request._match_info = {**kwargs}
request.route = route
await self.dispatch(
"http.routing.after",
inline=True,
context={
"request": request,
"route": route,
"kwargs": kwargs,
"handler": handler,
},
)
if (
request.stream
and request.stream.request_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")
else:
# Non-streaming handler: preload body
await request.receive_body()
# -------------------------------------------- #
# Request Middleware
# -------------------------------------------- #
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:
# -------------------------------------------- #
# Execute Handler
# -------------------------------------------- #
if handler is None:
raise ServerError(
(
"'None' was returned while requesting a "
"handler from the router"
)
)
# 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:
error_logger.error(
"The response object returned by the route handler "
"will not be sent to client. The request has already "
"been responded to."
)
if request.stream is not None:
response = request.stream.response
elif response is not None:
response = await request.respond(response) # type: ignore
elif not hasattr(handler, "is_websocket"):
response = request.stream.response # type: ignore
# Marked for cleanup and DRY with handle_request/handle_exception
# when ResponseStream is no longer supporder
if isinstance(response, BaseHTTPResponse):
await self.dispatch(
"http.lifecycle.response",
inline=True,
context={
"request": request,
"response": response,
},
)
...
await response.send(end_stream=True)
elif isinstance(response, ResponseStream):
resp = await response(request) # type: ignore
await self.dispatch(
"http.lifecycle.response",
inline=True,
context={
"request": request,
"response": resp,
},
)
await response.eof() # type: ignore
else:
if not hasattr(handler, "is_websocket"):
raise ServerError(
f"Invalid response type {response!r} "
"(need HTTPResponse)"
)
except CancelledError:
raise
except Exception as e:
# Response Generation Failed
await self.handle_exception(
request, e, run_middleware=run_middleware
)
async def _websocket_handler( async def _websocket_handler(
self, handler, request, *args, subprotocols=None, **kwargs self, handler, request, *args, subprotocols=None, **kwargs
@@ -1074,18 +1384,6 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
def debug(self): def debug(self):
return self.state.is_debug 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 @property
def auto_reload(self): def auto_reload(self):
return self.config.AUTO_RELOAD return self.config.AUTO_RELOAD
@@ -1102,58 +1400,6 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
""" """
return self._state 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 @property
def reload_dirs(self): def reload_dirs(self):
return self.state.reload_dirs return self.state.reload_dirs
@@ -1246,7 +1492,24 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
return cls.get_app("__mp_main__", force_create=force_create) return cls.get_app("__mp_main__", force_create=force_create)
if force_create: if force_create:
return cls(name) 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 # Lifecycle
@@ -1278,7 +1541,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
if self.state.is_debug and self.config.TOUCHUP is not True: if self.state.is_debug and self.config.TOUCHUP is not True:
self.config.TOUCHUP = False self.config.TOUCHUP = False
elif self.config.TOUCHUP is _default: elif isinstance(self.config.TOUCHUP, Default):
self.config.TOUCHUP = True self.config.TOUCHUP = True
# Setup routers # Setup routers
@@ -1297,17 +1560,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
23.3, 23.3,
) )
# TODO: Replace in v22.6 to check against apps in app registry Sanic._check_uvloop_conflict()
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."
)
self.__class__._uvloop_setting = self.config.USE_UVLOOP
# Startup time optimizations # Startup time optimizations
if self.state.primary: if self.state.primary:
@@ -1318,6 +1571,7 @@ class Sanic(BaseSanic, StartupMixin, metaclass=TouchUpMeta):
self.state.is_started = True self.state.is_started = True
def ack(self):
if hasattr(self, "multiplexer"): if hasattr(self, "multiplexer"):
self.multiplexer.ack() self.multiplexer.ack()

View File

@@ -8,11 +8,6 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from sanic import Sanic from sanic import Sanic
try:
from sanic_ext import Extend # type: ignore
except ImportError:
...
def setup_ext(app: Sanic, *, fail: bool = False, **kwargs): def setup_ext(app: Sanic, *, fail: bool = False, **kwargs):
if not app.config.AUTO_EXTEND: if not app.config.AUTO_EXTEND:
@@ -33,7 +28,7 @@ def setup_ext(app: Sanic, *, fail: bool = False, **kwargs):
return return
if not getattr(app, "_ext", None): if not getattr(app, "_ext", None):
Ext: Extend = getattr(sanic_ext, "Extend") Ext = getattr(sanic_ext, "Extend")
app._ext = Ext(app, **kwargs) app._ext = Ext(app, **kwargs)
return app.ext return app.ext

View File

@@ -7,10 +7,9 @@ from urllib.parse import quote
from sanic.compat import Header from sanic.compat import Header
from sanic.exceptions import ServerError from sanic.exceptions import ServerError
from sanic.handlers import RequestManager from sanic.helpers import Default
from sanic.helpers import _default
from sanic.http import Stage 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.models.asgi import ASGIReceive, ASGIScope, ASGISend, MockTransport
from sanic.request import Request from sanic.request import Request
from sanic.response import BaseHTTPResponse from sanic.response import BaseHTTPResponse
@@ -62,7 +61,7 @@ class Lifespan:
await self.asgi_app.sanic_app._server_event("init", "before") await self.asgi_app.sanic_app._server_event("init", "before")
await self.asgi_app.sanic_app._server_event("init", "after") 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( warnings.warn(
"You have set the USE_UVLOOP configuration option, but Sanic " "You have set the USE_UVLOOP configuration option, but Sanic "
"cannot control the event loop when running in ASGI mode." "cannot control the event loop when running in ASGI mode."
@@ -86,13 +85,27 @@ class Lifespan:
) -> None: ) -> None:
message = await receive() message = await receive()
if message["type"] == "lifespan.startup": if message["type"] == "lifespan.startup":
await self.startup() try:
await send({"type": "lifespan.startup.complete"}) 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() message = await receive()
if message["type"] == "lifespan.shutdown": if message["type"] == "lifespan.shutdown":
await self.shutdown() try:
await send({"type": "lifespan.shutdown.complete"}) 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: class ASGIApp:
@@ -231,9 +244,11 @@ class ASGIApp:
""" """
Handle the incoming request. Handle the incoming request.
""" """
manager = RequestManager.create(self.request)
try: try:
self.stage = Stage.HANDLER self.stage = Stage.HANDLER
await manager.handle() await self.sanic_app.handle_request(self.request)
except Exception as e: except Exception as e:
await manager.error(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 import re
from typing import Any from typing import Any, Optional
from sanic.base.meta import SanicMeta from sanic.base.meta import SanicMeta
from sanic.exceptions import SanicException from sanic.exceptions import SanicException
@@ -24,7 +24,9 @@ class BaseSanic(
): ):
__slots__ = ("name",) __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__ class_name = self.__class__.__name__
if name is None: if name is None:

View File

@@ -406,7 +406,7 @@ class Blueprint(BaseSanic):
self.routes += [route for route in routes if isinstance(route, Route)] self.routes += [route for route in routes if isinstance(route, Route)]
self.websocket_routes += [ 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.middlewares += middleware
self.exceptions += exception_handlers self.exceptions += exception_handlers
@@ -442,7 +442,7 @@ class Blueprint(BaseSanic):
events.add(signal.ctx.event) events.add(signal.ctx.event)
return asyncio.wait( return asyncio.wait(
[event.wait() for event in events], [asyncio.create_task(event.wait()) for event in events],
return_when=asyncio.FIRST_COMPLETED, return_when=asyncio.FIRST_COMPLETED,
timeout=timeout, timeout=timeout,
) )

View File

@@ -3,23 +3,21 @@ import os
import shutil import shutil
import sys import sys
from argparse import ArgumentParser, RawTextHelpFormatter from argparse import Namespace
from functools import partial from functools import partial
from textwrap import indent from textwrap import indent
from typing import Any, List, Union from typing import List, Union, cast
from sanic.app import Sanic from sanic.app import Sanic
from sanic.application.logo import get_logo from sanic.application.logo import get_logo
from sanic.cli.arguments import Group from sanic.cli.arguments import Group
from sanic.log import error_logger from sanic.cli.base import SanicArgumentParser, SanicHelpFormatter
from sanic.worker.inspector import inspect 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 from sanic.worker.loader import AppLoader
class SanicArgumentParser(ArgumentParser):
...
class SanicCLI: class SanicCLI:
DESCRIPTION = indent( DESCRIPTION = indent(
f""" f"""
@@ -46,7 +44,7 @@ Or, a path to a directory to run as a simple HTTP server:
self.parser = SanicArgumentParser( self.parser = SanicArgumentParser(
prog="sanic", prog="sanic",
description=self.DESCRIPTION, description=self.DESCRIPTION,
formatter_class=lambda prog: RawTextHelpFormatter( formatter_class=lambda prog: SanicHelpFormatter(
prog, prog,
max_help_position=36 if width > 96 else 24, max_help_position=36 if width > 96 else 24,
indent_increment=4, indent_increment=4,
@@ -58,16 +56,27 @@ Or, a path to a directory to run as a simple HTTP server:
self.main_process = ( self.main_process = (
os.environ.get("SANIC_RELOADER_PROCESS", "") != "true" os.environ.get("SANIC_RELOADER_PROCESS", "") != "true"
) )
self.args: List[Any] = [] self.args: Namespace = Namespace()
self.groups: List[Group] = [] self.groups: List[Group] = []
self.inspecting = False
def attach(self): 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: for group in Group._registry:
instance = group.create(self.parser) instance = group.create(self.parser)
instance.attach() instance.attach()
self.groups.append(instance) self.groups.append(instance)
def run(self, parse_args=None): def run(self, parse_args=None):
if self.inspecting:
self._inspector()
return
legacy_version = False legacy_version = False
if not parse_args: if not parse_args:
# This is to provide backwards compat -v to display version # This is to provide backwards compat -v to display version
@@ -86,36 +95,21 @@ Or, a path to a directory to run as a simple HTTP server:
self.args = self.parser.parse_args(args=parse_args) self.args = self.parser.parse_args(args=parse_args)
self._precheck() self._precheck()
app_loader = AppLoader( app_loader = AppLoader(
self.args.module, self.args.module, self.args.factory, self.args.simple, self.args
self.args.factory,
self.args.simple,
self.args,
) )
if self.args.inspect or self.args.inspect_raw or self.args.trigger:
self._inspector_legacy(app_loader)
return
try: try:
app = self._get_app(app_loader) app = self._get_app(app_loader)
kwargs = self._build_run_kwargs() kwargs = self._build_run_kwargs()
except ValueError as e: except ValueError as e:
error_logger.exception(f"Failed to run app: {e}") error_logger.exception(f"Failed to run app: {e}")
else: else:
if self.args.inspect or self.args.inspect_raw or self.args.trigger: for http_version in self.args.http:
os.environ["SANIC_IGNORE_PRODUCTION_WARNING"] = "true" app.prepare(**kwargs, version=http_version)
else:
for http_version in self.args.http:
app.prepare(**kwargs, version=http_version)
if self.args.inspect or self.args.inspect_raw or self.args.trigger:
action = self.args.trigger or (
"raw" if self.args.inspect_raw else "pretty"
)
inspect(
app.config.INSPECTOR_HOST,
app.config.INSPECTOR_PORT,
action,
)
del os.environ["SANIC_IGNORE_PRODUCTION_WARNING"]
return
if self.args.single: if self.args.single:
serve = Sanic.serve_single serve = Sanic.serve_single
elif self.args.legacy: elif self.args.legacy:
@@ -124,6 +118,64 @@ Or, a path to a directory to run as a simple HTTP server:
serve = partial(Sanic.serve, app_loader=app_loader) serve = partial(Sanic.serve, app_loader=app_loader)
serve(app) serve(app)
def _inspector_legacy(self, app_loader: AppLoader):
host = port = None
module = cast(str, self.args.module)
if ":" in module:
maybe_host, maybe_port = module.rsplit(":", 1)
if maybe_port.isnumeric():
host, port = maybe_host, int(maybe_port)
if not host:
app = self._get_app(app_loader)
host, port = app.config.INSPECTOR_HOST, app.config.INSPECTOR_PORT
action = self.args.trigger or "info"
InspectorClient(
str(host), int(port or 6457), False, self.args.inspect_raw, ""
).do(action)
sys.stdout.write(
f"\n{Colors.BOLD}{Colors.YELLOW}WARNING:{Colors.END} "
"You are using the legacy CLI command that will be removed in "
f"{Colors.RED}v23.3{Colors.END}. See "
"https://sanic.dev/en/guide/release-notes/v22.12.html"
"#deprecations-and-removals or checkout the new "
"style commands:\n\n\t"
f"{Colors.YELLOW}sanic inspect --help{Colors.END}\n"
)
def _inspector(self):
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): def _precheck(self):
# Custom TLS mismatch handling for better diagnostics # Custom TLS mismatch handling for better diagnostics
if self.main_process and ( if self.main_process and (

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 signal
import sys 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 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" OS_IS_WINDOWS = os.name == "nt"
UVLOOP_INSTALLED = False UVLOOP_INSTALLED = False
@@ -18,6 +31,40 @@ try:
except ImportError: except ImportError:
pass 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(): def enable_windows_color_support():
import ctypes import ctypes

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import sys import sys
from abc import ABCMeta
from inspect import getmembers, isclass, isdatadescriptor from inspect import getmembers, isclass, isdatadescriptor
from os import environ from os import environ
from pathlib import Path from pathlib import Path
@@ -12,7 +13,7 @@ from sanic.constants import LocalCertCreator
from sanic.errorpages import DEFAULT_FORMAT, check_error_format from sanic.errorpages import DEFAULT_FORMAT, check_error_format
from sanic.helpers import Default, _default from sanic.helpers import Default, _default
from sanic.http import Http 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 from sanic.utils import load_module_from_file_location, str_to_bool
@@ -46,6 +47,9 @@ DEFAULT_CONFIG = {
"INSPECTOR": False, "INSPECTOR": False,
"INSPECTOR_HOST": "localhost", "INSPECTOR_HOST": "localhost",
"INSPECTOR_PORT": 6457, "INSPECTOR_PORT": 6457,
"INSPECTOR_TLS_KEY": _default,
"INSPECTOR_TLS_CERT": _default,
"INSPECTOR_API_KEY": "",
"KEEP_ALIVE_TIMEOUT": 5, # 5 seconds "KEEP_ALIVE_TIMEOUT": 5, # 5 seconds
"KEEP_ALIVE": True, "KEEP_ALIVE": True,
"LOCAL_CERT_CREATOR": LocalCertCreator.AUTO, "LOCAL_CERT_CREATOR": LocalCertCreator.AUTO,
@@ -71,12 +75,8 @@ DEFAULT_CONFIG = {
"WEBSOCKET_PING_TIMEOUT": 20, "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(ABCMeta):
class DescriptorMeta(type):
def __init__(cls, *_): def __init__(cls, *_):
cls.__setters__ = {name for name, _ in getmembers(cls, cls._is_setter)} cls.__setters__ = {name for name, _ in getmembers(cls, cls._is_setter)}
@@ -97,6 +97,9 @@ class Config(dict, metaclass=DescriptorMeta):
INSPECTOR: bool INSPECTOR: bool
INSPECTOR_HOST: str INSPECTOR_HOST: str
INSPECTOR_PORT: int 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_TIMEOUT: int
KEEP_ALIVE: bool KEEP_ALIVE: bool
LOCAL_CERT_CREATOR: Union[str, LocalCertCreator] LOCAL_CERT_CREATOR: Union[str, LocalCertCreator]
@@ -124,7 +127,9 @@ class Config(dict, metaclass=DescriptorMeta):
def __init__( def __init__(
self, 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, env_prefix: Optional[str] = SANIC_PREFIX,
keep_alive: Optional[bool] = None, keep_alive: Optional[bool] = None,
*, *,
@@ -132,6 +137,7 @@ class Config(dict, metaclass=DescriptorMeta):
): ):
defaults = defaults or {} defaults = defaults or {}
super().__init__({**DEFAULT_CONFIG, **defaults}) super().__init__({**DEFAULT_CONFIG, **defaults})
self._configure_warnings()
self._converters = [str, str_to_bool, float, int] self._converters = [str, str_to_bool, float, int]
@@ -149,7 +155,6 @@ class Config(dict, metaclass=DescriptorMeta):
self.load_environment_vars(SANIC_PREFIX) self.load_environment_vars(SANIC_PREFIX)
self._configure_header_size() self._configure_header_size()
self._configure_warnings()
self._check_error_format() self._check_error_format()
self._init = True self._init = True
@@ -203,7 +208,7 @@ class Config(dict, metaclass=DescriptorMeta):
@property @property
def FALLBACK_ERROR_FORMAT(self) -> str: def FALLBACK_ERROR_FORMAT(self) -> str:
if self._FALLBACK_ERROR_FORMAT is _default: if isinstance(self._FALLBACK_ERROR_FORMAT, Default):
return DEFAULT_FORMAT return DEFAULT_FORMAT
return self._FALLBACK_ERROR_FORMAT return self._FALLBACK_ERROR_FORMAT
@@ -211,7 +216,7 @@ class Config(dict, metaclass=DescriptorMeta):
def FALLBACK_ERROR_FORMAT(self, value): def FALLBACK_ERROR_FORMAT(self, value):
self._check_error_format(value) self._check_error_format(value)
if ( if (
self._FALLBACK_ERROR_FORMAT is not _default not isinstance(self._FALLBACK_ERROR_FORMAT, Default)
and value != self._FALLBACK_ERROR_FORMAT and value != self._FALLBACK_ERROR_FORMAT
): ):
error_logger.warning( error_logger.warning(
@@ -241,7 +246,9 @@ class Config(dict, metaclass=DescriptorMeta):
""" """
Looks for prefixed environment variables and applies them to the Looks for prefixed environment variables and applies them to the
configuration if present. This is called automatically when Sanic 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: It will automatically hydrate the following types:
@@ -267,12 +274,9 @@ class Config(dict, metaclass=DescriptorMeta):
`See user guide re: config `See user guide re: config
<https://sanicframework.org/guide/deployment/configuration.html>`__ <https://sanicframework.org/guide/deployment/configuration.html>`__
""" """
lower_case_var_found = False
for key, value in environ.items(): for key, value in environ.items():
if not key.startswith(prefix): if not key.startswith(prefix) or not key.isupper():
continue continue
if not key.isupper():
lower_case_var_found = True
_, config_key = key.split(prefix, 1) _, config_key = key.split(prefix, 1)
@@ -282,12 +286,6 @@ class Config(dict, metaclass=DescriptorMeta):
break break
except ValueError: except ValueError:
pass 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]): 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): class HTTPMethod(UpperStrEnum):
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
GET = auto() GET = auto()
POST = auto() POST = auto()
PUT = auto() PUT = auto()
@@ -24,10 +13,7 @@ class HTTPMethod(str, Enum):
DELETE = auto() DELETE = auto()
class LocalCertCreator(str, Enum): class LocalCertCreator(UpperStrEnum):
def _generate_next_value_(name, start, count, last_values):
return name.upper()
AUTO = auto() AUTO = auto()
TRUSTME = auto() TRUSTME = auto()
MKCERT = auto() MKCERT = auto()

View File

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

View File

@@ -8,6 +8,10 @@ class RequestCancelled(CancelledError):
quiet = True quiet = True
class ServerKilled(Exception):
...
class SanicException(Exception): class SanicException(Exception):
message: str = "" message: str = ""

View File

@@ -1,317 +1,16 @@
from __future__ import annotations from __future__ import annotations
from functools import partial
from inspect import isawaitable
from traceback import format_exc
from typing import Dict, List, Optional, Tuple, Type from typing import Dict, List, Optional, Tuple, Type
from sanic_routing import Route
from sanic.errorpages import BaseRenderer, TextRenderer, exception_response from sanic.errorpages import BaseRenderer, TextRenderer, exception_response
from sanic.exceptions import ( from sanic.exceptions import (
HeaderNotFound, HeaderNotFound,
InvalidRangeType, InvalidRangeType,
RangeNotSatisfiable, RangeNotSatisfiable,
SanicException,
ServerError,
) )
from sanic.http.constants import Stage from sanic.log import deprecation, error_logger
from sanic.log import deprecation, error_logger, logger
from sanic.models.handler_types import RouteHandler from sanic.models.handler_types import RouteHandler
from sanic.request import Request from sanic.response import text
from sanic.response import BaseHTTPResponse, HTTPResponse, ResponseStream, text
from sanic.touchup import TouchUpMeta
class RequestHandler:
def __init__(self, func, request_middleware, response_middleware):
self.func = func.func if isinstance(func, RequestHandler) else func
self.request_middleware = request_middleware
self.response_middleware = response_middleware
def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs)
class RequestManager(metaclass=TouchUpMeta):
__touchup__ = (
"cleanup",
"run_request_middleware",
"run_response_middleware",
)
__slots__ = (
"handler",
"request_middleware_run",
"request_middleware",
"request",
"response_middleware_run",
"response_middleware",
)
request: Request
def __init__(self, request: Request):
self.request_middleware_run = False
self.response_middleware_run = False
self.handler = self._noop
self.set_request(request)
@classmethod
def create(cls, request: Request) -> RequestManager:
return cls(request)
def set_request(self, request: Request):
request._manager = self
self.request = request
self.request_middleware = request.app.request_middleware
self.response_middleware = request.app.response_middleware
async def handle(self):
route = self.resolve_route()
if self.handler is None:
await self.error(
ServerError(
(
"'None' was returned while requesting a "
"handler from the router"
)
)
)
return
if (
self.request.stream
and self.request.stream.request_body
and not route.ctx.ignore_body
):
await self.receive_body()
await self.lifecycle(
partial(self.handler, self.request, **self.request.match_info)
)
async def lifecycle(self, handler, raise_exception: bool = False):
response: Optional[BaseHTTPResponse] = None
if not self.request_middleware_run and self.request_middleware:
response = await self.run(
self.run_request_middleware, raise_exception
)
if not response:
# Run response handler
response = await self.run(handler, raise_exception)
if not self.response_middleware_run and self.response_middleware:
response = await self.run(
partial(self.run_response_middleware, response),
raise_exception,
)
await self.cleanup(response)
async def run(
self, operation, raise_exception: bool = False
) -> Optional[BaseHTTPResponse]:
try:
response = operation()
if isawaitable(response):
response = await response
except Exception as e:
if raise_exception:
raise
response = await self.error(e)
return response
async def error(self, exception: Exception):
error_handler = self.request.app.error_handler
if (
self.request.stream is not None
and self.request.stream.stage is not Stage.HANDLER
):
error_logger.exception(exception, exc_info=True)
logger.error(
"The error response will not be sent to the client for "
f'the following exception:"{exception}". A previous response '
"has at least partially been sent."
)
handler = error_handler._lookup(
exception, self.request.name if self.request else None
)
if handler:
logger.warning(
"An error occurred while handling the request after at "
"least some part of the response was sent to the client. "
"The response from your custom exception handler "
f"{handler.__name__} will not be sent to the client."
"Exception handlers should only be used to generate the "
"exception responses. If you would like to perform any "
"other action on a raised exception, consider using a "
"signal handler like "
'`@app.signal("http.lifecycle.exception")`\n'
"For further information, please see the docs: "
"https://sanicframework.org/en/guide/advanced/"
"signals.html",
)
return
try:
await self.lifecycle(
partial(error_handler.response, self.request, exception), True
)
except Exception as e:
if isinstance(e, SanicException):
response = error_handler.default(self.request, e)
elif self.request.app.debug:
response = HTTPResponse(
(
f"Error while handling error: {e}\n"
f"Stack: {format_exc()}"
),
status=500,
)
else:
error_logger.exception(e)
response = HTTPResponse(
"An error occurred while handling an error", status=500
)
return response
return None
async def cleanup(self, response: Optional[BaseHTTPResponse]):
if self.request.responded:
if response is not None:
error_logger.error(
"The response object returned by the route handler "
"will not be sent to client. The request has already "
"been responded to."
)
if self.request.stream is not None:
response = self.request.stream.response
elif response is not None:
self.request.reset_response()
response = await self.request.respond(response) # type: ignore
elif not hasattr(self.handler, "is_websocket"):
response = self.request.stream.response # type: ignore
if isinstance(response, BaseHTTPResponse):
await self.request.app.dispatch(
"http.lifecycle.response",
inline=True,
context={"request": self.request, "response": response},
)
await response.send(end_stream=True)
elif isinstance(response, ResponseStream):
await response(self.request) # type: ignore
await response.eof() # type: ignore
await self.request.app.dispatch(
"http.lifecycle.response",
inline=True,
context={"request": self.request, "response": response},
)
else:
if not hasattr(self.handler, "is_websocket"):
raise ServerError(
f"Invalid response type {response!r} "
"(need HTTPResponse)"
)
async def receive_body(self):
if hasattr(self.handler, "is_stream"):
# Streaming handler: lift the size limit
self.request.stream.request_max_size = float("inf")
else:
# Non-streaming handler: preload body
await self.request.receive_body()
async def run_request_middleware(self) -> Optional[BaseHTTPResponse]:
self.request._request_middleware_started = True
self.request_middleware_run = True
for middleware in self.request_middleware:
await self.request.app.dispatch(
"http.middleware.before",
inline=True,
context={"request": self.request, "response": None},
condition={"attach_to": "request"},
)
try:
response = await self.run(partial(middleware, self.request))
except Exception:
error_logger.exception(
"Exception occurred in one of request middleware handlers"
)
raise
await self.request.app.dispatch(
"http.middleware.after",
inline=True,
context={"request": self.request, "response": None},
condition={"attach_to": "request"},
)
if response:
return response
return None
async def run_response_middleware(
self, response: BaseHTTPResponse
) -> BaseHTTPResponse:
self.response_middleware_run = True
for middleware in self.response_middleware:
await self.request.app.dispatch(
"http.middleware.before",
inline=True,
context={"request": self.request, "response": None},
condition={"attach_to": "request"},
)
try:
resp = await self.run(
partial(middleware, self.request, response), True
)
except Exception as e:
error_logger.exception(
"Exception occurred in one of response middleware handlers"
)
await self.error(e)
resp = None
await self.request.app.dispatch(
"http.middleware.after",
inline=True,
context={"request": self.request, "response": None},
condition={"attach_to": "request"},
)
if resp:
return resp
return response
def resolve_route(self) -> Route:
# Fetch handler from router
route, handler, kwargs = self.request.app.router.get(
self.request.path,
self.request.method,
self.request.headers.getone("host", None),
)
self.request._match_info = {**kwargs}
self.request.route = route
self.handler = handler
if handler and handler.request_middleware:
self.request_middleware = handler.request_middleware
if handler and handler.response_middleware:
self.response_middleware = handler.response_middleware
return route
@staticmethod
def _noop(_):
...
class ErrorHandler: class ErrorHandler:
@@ -337,14 +36,6 @@ class ErrorHandler:
self.debug = False self.debug = False
self.base = base 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): def _full_lookup(self, exception, route_name: Optional[str] = None):
return self.lookup(exception, route_name) return self.lookup(exception, route_name)

View File

@@ -16,6 +16,7 @@ from sanic.exceptions import (
PayloadTooLarge, PayloadTooLarge,
RequestCancelled, RequestCancelled,
ServerError, ServerError,
ServiceUnavailable,
) )
from sanic.headers import format_http1_response from sanic.headers import format_http1_response
from sanic.helpers import has_message_body from sanic.helpers import has_message_body
@@ -70,7 +71,6 @@ class Http(Stream, metaclass=TouchUpMeta):
"request_body", "request_body",
"request_bytes", "request_bytes",
"request_bytes_left", "request_bytes_left",
"request_max_size",
"response", "response",
"response_func", "response_func",
"response_size", "response_size",
@@ -124,8 +124,7 @@ class Http(Stream, metaclass=TouchUpMeta):
self.stage = Stage.HANDLER self.stage = Stage.HANDLER
self.request.conn_info = self.protocol.conn_info self.request.conn_info = self.protocol.conn_info
await self.protocol.request_handler(self.request)
await self.request.manager.handle()
# Handler finished, response should've been sent # Handler finished, response should've been sent
if self.stage is Stage.HANDLER and not self.upgrade_websocket: if self.stage is Stage.HANDLER and not self.upgrade_websocket:
@@ -251,7 +250,6 @@ class Http(Stream, metaclass=TouchUpMeta):
transport=self.protocol.transport, transport=self.protocol.transport,
app=self.protocol.app, app=self.protocol.app,
) )
self.protocol.request_handler.create(request)
self.protocol.request_class._current.set(request) self.protocol.request_class._current.set(request)
await self.dispatch( await self.dispatch(
"http.lifecycle.request", "http.lifecycle.request",
@@ -425,11 +423,18 @@ class Http(Stream, metaclass=TouchUpMeta):
# From request and handler states we can respond, otherwise be silent # From request and handler states we can respond, otherwise be silent
if self.stage is Stage.HANDLER: if self.stage is Stage.HANDLER:
app = self.protocol.app
if self.request is None: if self.request is None:
self.create_empty_request() self.create_empty_request()
self.protocol.request_handler.create(self.request)
await self.request.manager.error(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: def create_empty_request(self) -> None:
""" """

View File

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

View File

@@ -24,13 +24,15 @@ def create_context(
certfile: Optional[str] = None, certfile: Optional[str] = None,
keyfile: Optional[str] = None, keyfile: Optional[str] = None,
password: Optional[str] = None, password: Optional[str] = None,
purpose: ssl.Purpose = ssl.Purpose.CLIENT_AUTH,
) -> ssl.SSLContext: ) -> ssl.SSLContext:
"""Create a context with secure crypto and HTTP/1.1 in protocols.""" """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.minimum_version = ssl.TLSVersion.TLSv1_2
context.set_ciphers(":".join(CIPHERS_TLS12)) context.set_ciphers(":".join(CIPHERS_TLS12))
context.set_alpn_protocols(["http/1.1"]) 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: if certfile and keyfile:
context.load_cert_chain(certfile, keyfile, password) context.load_cert_chain(certfile, keyfile, password)
return context return context

View File

@@ -72,7 +72,8 @@ def get_ssl_context(
"without passing a TLS certificate. If you are developing " "without passing a TLS certificate. If you are developing "
"locally, please enable DEVELOPMENT mode and Sanic will " "locally, please enable DEVELOPMENT mode and Sanic will "
"generate a localhost TLS certificate. For more information " "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( creator = CertCreator.select(
@@ -125,7 +126,6 @@ class CertCreator(ABC):
local_tls_key, local_tls_key,
local_tls_cert, local_tls_cert,
) -> CertCreator: ) -> CertCreator:
creator: Optional[CertCreator] = None creator: Optional[CertCreator] = None
cert_creator_options: Tuple[ cert_creator_options: Tuple[
@@ -151,7 +151,8 @@ class CertCreator(ABC):
raise SanicException( raise SanicException(
"Sanic could not find package to create a TLS certificate. " "Sanic could not find package to create a TLS certificate. "
"You must have either mkcert or trustme installed. See " "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 return creator
@@ -203,7 +204,8 @@ class MkcertCreator(CertCreator):
"to proceed. Installation instructions can be found here: " "to proceed. Installation instructions can be found here: "
"https://github.com/FiloSottile/mkcert.\n" "https://github.com/FiloSottile/mkcert.\n"
"Find out more information about your options here: " "Find out more information about your options here: "
"_____" "https://sanic.dev/en/guide/deployment/development.html#"
"automatic-tls-certificate"
) from e ) from e
def generate_cert(self, localhost: str) -> ssl.SSLContext: def generate_cert(self, localhost: str) -> ssl.SSLContext:
@@ -260,7 +262,8 @@ class TrustmeCreator(CertCreator):
"to proceed. Installation instructions can be found here: " "to proceed. Installation instructions can be found here: "
"https://github.com/python-trio/trustme.\n" "https://github.com/python-trio/trustme.\n"
"Find out more information about your options here: " "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: def generate_cert(self, localhost: str) -> ssl.SSLContext:

View File

@@ -2,12 +2,23 @@ import logging
import sys import sys
from enum import Enum from enum import Enum
from typing import Any, Dict from typing import TYPE_CHECKING, Any, Dict
from warnings import warn from warnings import warn
from sanic.compat import is_atty from sanic.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 LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( # no cov
version=1, version=1,
disable_existing_loggers=False, disable_existing_loggers=False,
@@ -25,6 +36,12 @@ LOGGING_CONFIG_DEFAULTS: Dict[str, Any] = dict( # no cov
"propagate": True, "propagate": True,
"qualname": "sanic.access", "qualname": "sanic.access",
}, },
"sanic.server": {
"level": "INFO",
"handlers": ["console"],
"propagate": True,
"qualname": "sanic.server",
},
}, },
handlers={ handlers={
"console": { "console": {
@@ -62,7 +79,7 @@ Defult logging configuration
""" """
class Colors(str, Enum): # no cov class Colors(StrEnum): # no cov
END = "\033[0m" END = "\033[0m"
BOLD = "\033[1m" BOLD = "\033[1m"
BLUE = "\033[34m" BLUE = "\033[34m"
@@ -101,6 +118,12 @@ Logger used by Sanic for access logging
""" """
access_logger.addFilter(_verbosity_filter) 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 def deprecation(message: str, version: float): # no cov
version_info = f"[DEPRECATION v{version}] " version_info = f"[DEPRECATION v{version}] "

View File

@@ -21,7 +21,7 @@ class Middleware:
def __init__( def __init__(
self, self,
func: MiddlewareType, func: MiddlewareType,
location: MiddlewareLocation = MiddlewareLocation.REQUEST, location: MiddlewareLocation,
priority: int = 0, priority: int = 0,
) -> None: ) -> None:
self.func = func self.func = func
@@ -32,11 +32,13 @@ class Middleware:
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
return self.func(*args, **kwargs) return self.func(*args, **kwargs)
def __hash__(self) -> int:
return hash(self.func)
def __repr__(self) -> str: def __repr__(self) -> str:
name = getattr(self.func, "__name__", str(self.func))
return ( return (
f"{self.__class__.__name__}(" f"{self.__class__.__name__}("
f"func=<function {name}>, " f"func=<function {self.func.__name__}>, "
f"priority={self.priority}, " f"priority={self.priority}, "
f"location={self.location.name})" f"location={self.location.name})"
) )
@@ -64,3 +66,4 @@ class Middleware:
@classmethod @classmethod
def reset_count(cls): def reset_count(cls):
cls._counter = count() cls._counter = count()
cls.count = next(cls._counter)

View File

@@ -4,7 +4,6 @@ from operator import attrgetter
from typing import List from typing import List
from sanic.base.meta import SanicMeta from sanic.base.meta import SanicMeta
from sanic.handlers import RequestHandler
from sanic.middleware import Middleware, MiddlewareLocation from sanic.middleware import Middleware, MiddlewareLocation
from sanic.models.futures import FutureMiddleware from sanic.models.futures import FutureMiddleware
from sanic.router import Router from sanic.router import Router
@@ -105,23 +104,19 @@ class MiddlewareMixin(metaclass=SanicMeta):
self.named_response_middleware.get(route.name, deque()), self.named_response_middleware.get(route.name, deque()),
location=MiddlewareLocation.RESPONSE, location=MiddlewareLocation.RESPONSE,
) )
route.extra.request_middleware = deque(
route.handler = RequestHandler( sorted(
route.handler, request_middleware,
deque( key=attrgetter("order"),
sorted( reverse=True,
request_middleware, )
key=attrgetter("order"), )
reverse=True, route.extra.response_middleware = deque(
) sorted(
), response_middleware,
deque( key=attrgetter("order"),
sorted( reverse=True,
response_middleware, )[::-1]
key=attrgetter("order"),
reverse=True,
)[::-1]
),
) )
request_middleware = Middleware.convert( request_middleware = Middleware.convert(
self.request_middleware, self.request_middleware,

View File

@@ -1,15 +1,16 @@
from ast import NodeVisitor, Return, parse from ast import NodeVisitor, Return, parse
from contextlib import suppress from contextlib import suppress
from email.utils import formatdate
from functools import partial, wraps from functools import partial, wraps
from inspect import getsource, signature from inspect import getsource, signature
from mimetypes import guess_type from mimetypes import guess_type
from os import path from os import path
from pathlib import Path, PurePath from pathlib import Path, PurePath
from textwrap import dedent from textwrap import dedent
from time import gmtime, strftime
from typing import ( from typing import (
Any, Any,
Callable, Callable,
Dict,
Iterable, Iterable,
List, List,
Optional, Optional,
@@ -31,21 +32,13 @@ from sanic.handlers import ContentRangeHandler
from sanic.log import error_logger from sanic.log import error_logger
from sanic.models.futures import FutureRoute, FutureStatic from sanic.models.futures import FutureRoute, FutureStatic
from sanic.models.handler_types import RouteHandler 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 from sanic.types import HashableDict
RouteWrapper = Callable[ RouteWrapper = Callable[
[RouteHandler], Union[RouteHandler, Tuple[Route, RouteHandler]] [RouteHandler], Union[RouteHandler, Tuple[Route, RouteHandler]]
] ]
RESTRICTED_ROUTE_CONTEXT = (
"ignore_body",
"stream",
"hosts",
"static",
"error_format",
"websocket",
)
class RouteMixin(metaclass=SanicMeta): class RouteMixin(metaclass=SanicMeta):
@@ -225,6 +218,7 @@ class RouteMixin(metaclass=SanicMeta):
stream: bool = False, stream: bool = False,
version_prefix: str = "/v", version_prefix: str = "/v",
error_format: Optional[str] = None, error_format: Optional[str] = None,
unquote: bool = False,
**ctx_kwargs: Any, **ctx_kwargs: Any,
) -> RouteHandler: ) -> RouteHandler:
"""A helper method to register class instance or """A helper method to register class instance or
@@ -271,6 +265,7 @@ class RouteMixin(metaclass=SanicMeta):
name=name, name=name,
version_prefix=version_prefix, version_prefix=version_prefix,
error_format=error_format, error_format=error_format,
unquote=unquote,
**ctx_kwargs, **ctx_kwargs,
)(handler) )(handler)
return handler return handler
@@ -790,24 +785,9 @@ class RouteMixin(metaclass=SanicMeta):
return name return name
async def _static_request_handler( async def _get_file_path(self, file_or_directory, __file_uri__, not_found):
self,
file_or_directory,
use_modified_since,
use_content_range,
stream_large_files,
request,
content_type=None,
__file_uri__=None,
):
# Merge served directory and requested file if provided
file_path_raw = Path(unquote(file_or_directory)) file_path_raw = Path(unquote(file_or_directory))
root_path = file_path = file_path_raw.resolve() root_path = file_path = file_path_raw.resolve()
not_found = FileNotFound(
"File not found",
path=file_or_directory,
relative_url=__file_uri__,
)
if __file_uri__: if __file_uri__:
# Strip all / that in the beginning of the URL to help prevent # Strip all / that in the beginning of the URL to help prevent
@@ -834,6 +814,29 @@ class RouteMixin(metaclass=SanicMeta):
f"relative_url={__file_uri__}" f"relative_url={__file_uri__}"
) )
raise not_found raise not_found
return file_path
async def _static_request_handler(
self,
file_or_directory,
use_modified_since,
use_content_range,
stream_large_files,
request,
content_type=None,
__file_uri__=None,
):
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
)
try: try:
headers = {} headers = {}
# Check if the client has been sent this file before # Check if the client has been sent this file before
@@ -841,15 +844,13 @@ class RouteMixin(metaclass=SanicMeta):
stats = None stats = None
if use_modified_since: if use_modified_since:
stats = await stat_async(file_path) stats = await stat_async(file_path)
modified_since = strftime( modified_since = stats.st_mtime
"%a, %d %b %Y %H:%M:%S GMT", gmtime(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 _range = None
if use_content_range: if use_content_range:
_range = None _range = None
@@ -864,8 +865,7 @@ class RouteMixin(metaclass=SanicMeta):
pass pass
else: else:
del headers["Content-Length"] del headers["Content-Length"]
for key, value in _range.headers.items(): headers.update(_range.headers)
headers[key] = value
if "content-type" not in headers: if "content-type" not in headers:
content_type = ( content_type = (
@@ -1041,24 +1041,12 @@ class RouteMixin(metaclass=SanicMeta):
return types return types
def _build_route_context(self, raw): def _build_route_context(self, raw: Dict[str, Any]) -> HashableDict:
ctx_kwargs = { ctx_kwargs = {
key.replace("ctx_", ""): raw.pop(key) key.replace("ctx_", ""): raw.pop(key)
for key in {**raw}.keys() for key in {**raw}.keys()
if key.startswith("ctx_") 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: if raw:
unexpected_arguments = ", ".join(raw.keys()) unexpected_arguments = ", ".join(raw.keys())
raise TypeError( raise TypeError(

View File

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

View File

@@ -19,7 +19,7 @@ from importlib import import_module
from multiprocessing import Manager, Pipe, get_context from multiprocessing import Manager, Pipe, get_context
from multiprocessing.context import BaseContext from multiprocessing.context import BaseContext
from pathlib import Path from pathlib import Path
from socket import socket from socket import SHUT_RDWR, socket
from ssl import SSLContext from ssl import SSLContext
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
@@ -27,6 +27,7 @@ from typing import (
Callable, Callable,
Dict, Dict,
List, List,
Mapping,
Optional, Optional,
Set, Set,
Tuple, Tuple,
@@ -35,12 +36,14 @@ from typing import (
cast, cast,
) )
from sanic.application.ext import setup_ext
from sanic.application.logo import get_logo from sanic.application.logo import get_logo
from sanic.application.motd import MOTD from sanic.application.motd import MOTD
from sanic.application.state import ApplicationServerInfo, Mode, ServerStage from sanic.application.state import ApplicationServerInfo, Mode, ServerStage
from sanic.base.meta import SanicMeta from sanic.base.meta import SanicMeta
from sanic.compat import OS_IS_WINDOWS, is_atty from sanic.compat import OS_IS_WINDOWS, StartMethod, is_atty
from sanic.helpers import _default from sanic.exceptions import ServerKilled
from sanic.helpers import Default, _default
from sanic.http.constants import HTTP from sanic.http.constants import HTTP
from sanic.http.tls import get_ssl_context, process_to_context from sanic.http.tls import get_ssl_context, process_to_context
from sanic.http.tls.context import SanicSSLContext from sanic.http.tls.context import SanicSSLContext
@@ -56,7 +59,6 @@ from sanic.server.protocols.http_protocol import HttpProtocol
from sanic.server.protocols.websocket_protocol import WebSocketProtocol from sanic.server.protocols.websocket_protocol import WebSocketProtocol
from sanic.server.runners import serve, serve_multiple, serve_single from sanic.server.runners import serve, serve_multiple, serve_single
from sanic.server.socket import configure_socket, remove_unix_socket from sanic.server.socket import configure_socket, remove_unix_socket
from sanic.worker.inspector import Inspector
from sanic.worker.loader import AppLoader from sanic.worker.loader import AppLoader
from sanic.worker.manager import WorkerManager from sanic.worker.manager import WorkerManager
from sanic.worker.multiplexer import WorkerMultiplexer from sanic.worker.multiplexer import WorkerMultiplexer
@@ -86,11 +88,13 @@ class StartupMixin(metaclass=SanicMeta):
state: ApplicationState state: ApplicationState
websocket_enabled: bool websocket_enabled: bool
multiplexer: WorkerMultiplexer multiplexer: WorkerMultiplexer
start_method: StartMethod = _default
def setup_loop(self): def setup_loop(self):
if not self.asgi: if not self.asgi:
if self.config.USE_UVLOOP is True or ( if self.config.USE_UVLOOP is True or (
self.config.USE_UVLOOP is _default and not OS_IS_WINDOWS isinstance(self.config.USE_UVLOOP, Default)
and not OS_IS_WINDOWS
): ):
try_use_uvloop() try_use_uvloop()
elif OS_IS_WINDOWS: elif OS_IS_WINDOWS:
@@ -122,7 +126,7 @@ class StartupMixin(metaclass=SanicMeta):
register_sys_signals: bool = True, register_sys_signals: bool = True,
access_log: Optional[bool] = None, access_log: Optional[bool] = None,
unix: Optional[str] = None, unix: Optional[str] = None,
loop: AbstractEventLoop = None, loop: Optional[AbstractEventLoop] = None,
reload_dir: Optional[Union[List[str], str]] = None, reload_dir: Optional[Union[List[str], str]] = None,
noisy_exceptions: Optional[bool] = None, noisy_exceptions: Optional[bool] = None,
motd: bool = True, motd: bool = True,
@@ -221,7 +225,7 @@ class StartupMixin(metaclass=SanicMeta):
register_sys_signals: bool = True, register_sys_signals: bool = True,
access_log: Optional[bool] = None, access_log: Optional[bool] = None,
unix: Optional[str] = None, unix: Optional[str] = None,
loop: AbstractEventLoop = None, loop: Optional[AbstractEventLoop] = None,
reload_dir: Optional[Union[List[str], str]] = None, reload_dir: Optional[Union[List[str], str]] = None,
noisy_exceptions: Optional[bool] = None, noisy_exceptions: Optional[bool] = None,
motd: bool = True, motd: bool = True,
@@ -351,12 +355,12 @@ class StartupMixin(metaclass=SanicMeta):
debug: bool = False, debug: bool = False,
ssl: Union[None, SSLContext, dict, str, list, tuple] = None, ssl: Union[None, SSLContext, dict, str, list, tuple] = None,
sock: Optional[socket] = None, sock: Optional[socket] = None,
protocol: Type[Protocol] = None, protocol: Optional[Type[Protocol]] = None,
backlog: int = 100, backlog: int = 100,
access_log: Optional[bool] = None, access_log: Optional[bool] = None,
unix: Optional[str] = None, unix: Optional[str] = None,
return_asyncio_server: bool = False, 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, noisy_exceptions: Optional[bool] = None,
) -> Optional[AsyncioServer]: ) -> Optional[AsyncioServer]:
""" """
@@ -430,7 +434,7 @@ class StartupMixin(metaclass=SanicMeta):
run_async=return_asyncio_server, run_async=return_asyncio_server,
) )
if self.config.USE_UVLOOP is not _default: if not isinstance(self.config.USE_UVLOOP, Default):
error_logger.warning( error_logger.warning(
"You are trying to change the uvloop configuration, but " "You are trying to change the uvloop configuration, but "
"this is only effective when using the run(...) method. " "this is only effective when using the run(...) method. "
@@ -477,7 +481,7 @@ class StartupMixin(metaclass=SanicMeta):
sock: Optional[socket] = None, sock: Optional[socket] = None,
unix: Optional[str] = None, unix: Optional[str] = None,
workers: int = 1, workers: int = 1,
loop: AbstractEventLoop = None, loop: Optional[AbstractEventLoop] = None,
protocol: Type[Protocol] = HttpProtocol, protocol: Type[Protocol] = HttpProtocol,
backlog: int = 100, backlog: int = 100,
register_sys_signals: bool = True, register_sys_signals: bool = True,
@@ -558,7 +562,6 @@ class StartupMixin(metaclass=SanicMeta):
def motd( def motd(
self, self,
serve_location: str = "",
server_settings: Optional[Dict[str, Any]] = None, server_settings: Optional[Dict[str, Any]] = None,
): ):
if ( if (
@@ -568,14 +571,7 @@ class StartupMixin(metaclass=SanicMeta):
or os.environ.get("SANIC_SERVER_RUNNING") or os.environ.get("SANIC_SERVER_RUNNING")
): ):
return return
if serve_location: serve_location = self.get_server_location(server_settings)
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 self.config.MOTD: if self.config.MOTD:
logo = get_logo(coffee=self.state.coffee) logo = get_logo(coffee=self.state.coffee)
display, extra = self.get_motd_data(server_settings) display, extra = self.get_motd_data(server_settings)
@@ -697,12 +693,17 @@ class StartupMixin(metaclass=SanicMeta):
return any(app.state.auto_reload for app in cls._app_registry.values()) return any(app.state.auto_reload for app in cls._app_registry.values())
@classmethod @classmethod
def _get_context(cls) -> BaseContext: def _get_startup_method(cls) -> str:
method = ( return (
"spawn" cls.start_method
if "linux" not in sys.platform or cls.should_auto_reload() if not isinstance(cls.start_method, Default)
else "fork" 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) return get_context(method)
@classmethod @classmethod
@@ -740,15 +741,18 @@ class StartupMixin(metaclass=SanicMeta):
except IndexError: except IndexError:
raise RuntimeError( raise RuntimeError(
f"No server information found for {primary.name}. Perhaps you " f"No server information found for {primary.name}. Perhaps you "
"need to run app.prepare(...)?\n" "need to run app.prepare(...)?"
"See ____ for more information."
) from None ) from None
socks = [] socks = []
sync_manager = Manager() sync_manager = Manager()
setup_ext(primary)
exit_code = 0
try: try:
main_start = primary_server_info.settings.pop("main_start", None) primary_server_info.settings.pop("main_start", None)
main_stop = primary_server_info.settings.pop("main_stop", 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 = primary_server_info.settings.pop("app")
app.setup_loop() app.setup_loop()
loop = new_event_loop() loop = new_event_loop()
@@ -765,7 +769,7 @@ class StartupMixin(metaclass=SanicMeta):
] ]
primary_server_info.settings["run_multiple"] = True primary_server_info.settings["run_multiple"] = True
monitor_sub, monitor_pub = Pipe(True) monitor_sub, monitor_pub = Pipe(True)
worker_state: Dict[str, Any] = sync_manager.dict() worker_state: Mapping[str, Any] = sync_manager.dict()
kwargs: Dict[str, Any] = { kwargs: Dict[str, Any] = {
**primary_server_info.settings, **primary_server_info.settings,
"monitor_publisher": monitor_pub, "monitor_publisher": monitor_pub,
@@ -821,7 +825,7 @@ class StartupMixin(metaclass=SanicMeta):
reload_dirs: Set[Path] = primary.state.reload_dirs.union( reload_dirs: Set[Path] = primary.state.reload_dirs.union(
*(app.state.reload_dirs for app in apps) *(app.state.reload_dirs for app in apps)
) )
reloader = Reloader(monitor_pub, 1.0, reload_dirs, app_loader) reloader = Reloader(monitor_pub, 0, reload_dirs, app_loader)
manager.manage("Reloader", reloader, {}, transient=False) manager.manage("Reloader", reloader, {}, transient=False)
inspector = None inspector = None
@@ -837,12 +841,15 @@ class StartupMixin(metaclass=SanicMeta):
"packages": [sanic_version, *packages], "packages": [sanic_version, *packages],
"extra": extra, "extra": extra,
} }
inspector = Inspector( inspector = primary.inspector_class(
monitor_pub, monitor_pub,
app_info, app_info,
worker_state, worker_state,
primary.config.INSPECTOR_HOST, primary.config.INSPECTOR_HOST,
primary.config.INSPECTOR_PORT, 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) manager.manage("Inspector", inspector, {}, transient=False)
@@ -853,6 +860,8 @@ class StartupMixin(metaclass=SanicMeta):
trigger_events(ready, loop, primary) trigger_events(ready, loop, primary)
manager.run() manager.run()
except ServerKilled:
exit_code = 1
except BaseException: except BaseException:
kwargs = primary_server_info.settings kwargs = primary_server_info.settings
error_logger.exception( error_logger.exception(
@@ -868,6 +877,7 @@ class StartupMixin(metaclass=SanicMeta):
sync_manager.shutdown() sync_manager.shutdown()
for sock in socks: for sock in socks:
sock.shutdown(SHUT_RDWR)
sock.close() sock.close()
socks = [] socks = []
trigger_events(main_stop, loop, primary) trigger_events(main_stop, loop, primary)
@@ -877,6 +887,8 @@ class StartupMixin(metaclass=SanicMeta):
unix = kwargs.get("unix") unix = kwargs.get("unix")
if unix: if unix:
remove_unix_socket(unix) remove_unix_socket(unix)
if exit_code:
os._exit(exit_code)
@classmethod @classmethod
def serve_single(cls, primary: Optional[Sanic] = None) -> None: def serve_single(cls, primary: Optional[Sanic] = None) -> None:
@@ -1097,7 +1109,6 @@ class StartupMixin(metaclass=SanicMeta):
app: StartupMixin, app: StartupMixin,
server_info: ApplicationServerInfo, server_info: ApplicationServerInfo,
) -> None: # no cov ) -> None: # no cov
try: try:
# We should never get to this point without a server # We should never get to this point without a server
# This is primarily to keep mypy happy # This is primarily to keep mypy happy

View File

@@ -1,7 +1,6 @@
from __future__ import annotations from __future__ import annotations
from contextvars import ContextVar from contextvars import ContextVar
from functools import partial
from inspect import isawaitable from inspect import isawaitable
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
@@ -24,11 +23,11 @@ from sanic.models.http_types import Credentials
if TYPE_CHECKING: if TYPE_CHECKING:
from sanic.handlers import RequestManager
from sanic.server import ConnInfo from sanic.server import ConnInfo
from sanic.app import Sanic from sanic.app import Sanic
import email.utils import email.utils
import unicodedata
import uuid import uuid
from collections import defaultdict from collections import defaultdict
@@ -39,7 +38,7 @@ from urllib.parse import parse_qs, parse_qsl, unquote, urlunparse
from httptools import parse_url from httptools import parse_url
from httptools.parser.errors import HttpParserInvalidURLError from httptools.parser.errors import HttpParserInvalidURLError
from sanic.compat import Header from sanic.compat import CancelledErrors, Header
from sanic.constants import ( from sanic.constants import (
CACHEABLE_HTTP_METHODS, CACHEABLE_HTTP_METHODS,
DEFAULT_HTTP_CONTENT_TYPE, DEFAULT_HTTP_CONTENT_TYPE,
@@ -101,12 +100,12 @@ class Request:
"_cookies", "_cookies",
"_id", "_id",
"_ip", "_ip",
"_manager",
"_parsed_url", "_parsed_url",
"_port", "_port",
"_protocol", "_protocol",
"_remote_addr", "_remote_addr",
"_request_middleware_started", "_request_middleware_started",
"_response_middleware_started",
"_scheme", "_scheme",
"_socket", "_socket",
"_stream_id", "_stream_id",
@@ -147,7 +146,6 @@ class Request:
head: bytes = b"", head: bytes = b"",
stream_id: int = 0, stream_id: int = 0,
): ):
self.raw_url = url_bytes self.raw_url = url_bytes
try: try:
self._parsed_url = parse_url(url_bytes) self._parsed_url = parse_url(url_bytes)
@@ -182,10 +180,10 @@ class Request:
Tuple[bool, bool, str, str], List[Tuple[str, str]] Tuple[bool, bool, str, str], List[Tuple[str, str]]
] = defaultdict(list) ] = defaultdict(list)
self._request_middleware_started = False self._request_middleware_started = False
self._response_middleware_started = False
self.responded: bool = False self.responded: bool = False
self.route: Optional[Route] = None self.route: Optional[Route] = None
self.stream: Optional[Stream] = None self.stream: Optional[Stream] = None
self._manager: Optional[RequestManager] = None
self._cookies: Optional[Dict[str, str]] = None self._cookies: Optional[Dict[str, str]] = None
self._match_info: Dict[str, Any] = {} self._match_info: Dict[str, Any] = {}
self._protocol = None self._protocol = None
@@ -229,7 +227,7 @@ class Request:
"Request.request_middleware_started has been deprecated and will" "Request.request_middleware_started has been deprecated and will"
"be removed. You should set a flag on the request context using" "be removed. You should set a flag on the request context using"
"either middleware or signals if you need this feature.", "either middleware or signals if you need this feature.",
22.3, 23.3,
) )
return self._request_middleware_started return self._request_middleware_started
@@ -247,10 +245,6 @@ class Request:
) )
return self._stream_id return self._stream_id
@property
def manager(self):
return self._manager
def reset_response(self): def reset_response(self):
try: try:
if ( if (
@@ -341,13 +335,20 @@ class Request:
if isawaitable(response): if isawaitable(response):
response = await response # type: ignore response = await response # type: ignore
# Run response middleware # Run response middleware
if ( try:
self._manager middleware = (
and not self._manager.response_middleware_run self.route and self.route.extra.response_middleware
and self._manager.response_middleware ) or self.app.response_middleware
): if middleware and not self._response_middleware_started:
response = await self._manager.run( self._response_middleware_started = True
partial(self._manager.run_response_middleware, response) response = await self.app._run_response_middleware(
self, response, middleware
)
except CancelledErrors:
raise
except Exception:
error_logger.exception(
"Exception occurred in one of response middleware handlers"
) )
self.responded = True self.responded = True
return response return response
@@ -1083,6 +1084,16 @@ def parse_multipart_form(body, boundary):
form_parameters["filename*"] form_parameters["filename*"]
) )
file_name = unquote(value, encoding=encoding) 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": elif form_header_field == "content-type":
content_type = form_header_value content_type = form_header_value
content_charset = form_parameters.get("charset", "utf-8") 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

@@ -2,212 +2,20 @@ from __future__ import annotations
from datetime import datetime, timezone from datetime import datetime, timezone
from email.utils import formatdate, parsedate_to_datetime from email.utils import formatdate, parsedate_to_datetime
from functools import partial
from mimetypes import guess_type from mimetypes import guess_type
from os import path from os import path
from pathlib import PurePath from pathlib import PurePath
from time import time from time import time
from typing import ( from typing import Any, AnyStr, Callable, Dict, Optional, Union
TYPE_CHECKING,
Any,
AnyStr,
Callable,
Coroutine,
Dict,
Iterator,
Optional,
Tuple,
TypeVar,
Union,
)
from urllib.parse import quote_plus from urllib.parse import quote_plus
from sanic.compat import Header, open_async, stat_async from sanic.compat import Header, open_async, stat_async
from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE from sanic.constants import DEFAULT_HTTP_CONTENT_TYPE
from sanic.cookies import CookieJar from sanic.helpers import Default, _default
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.log import logger from sanic.log import logger
from sanic.models.protocol_types import HTMLProtocol, Range from sanic.models.protocol_types import HTMLProtocol, Range
from .types import HTTPResponse, JSONResponse, ResponseStream
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()
def empty( def empty(
@@ -229,7 +37,7 @@ def json(
content_type: str = "application/json", content_type: str = "application/json",
dumps: Optional[Callable[..., str]] = None, dumps: Optional[Callable[..., str]] = None,
**kwargs: Any, **kwargs: Any,
) -> HTTPResponse: ) -> JSONResponse:
""" """
Returns response object with body in json format. Returns response object with body in json format.
@@ -238,13 +46,14 @@ def json(
:param headers: Custom Headers. :param headers: Custom Headers.
:param kwargs: Remaining arguments that are passed to the json encoder. :param kwargs: Remaining arguments that are passed to the json encoder.
""" """
if not dumps:
dumps = BaseHTTPResponse._dumps return JSONResponse(
return HTTPResponse( body,
dumps(body, **kwargs),
headers=headers,
status=status, status=status,
headers=headers,
content_type=content_type, content_type=content_type,
dumps=dumps,
**kwargs,
) )
@@ -465,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( async def file_stream(
location: Union[str, PurePath], location: Union[str, PurePath],
status: int = 200, 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

@@ -13,7 +13,6 @@ from sanic_routing.route import Route
from sanic.constants import HTTP_METHODS from sanic.constants import HTTP_METHODS
from sanic.errorpages import check_error_format from sanic.errorpages import check_error_format
from sanic.exceptions import MethodNotAllowed, NotFound, SanicException from sanic.exceptions import MethodNotAllowed, NotFound, SanicException
from sanic.handlers import RequestHandler
from sanic.models.handler_types import RouteHandler from sanic.models.handler_types import RouteHandler
@@ -32,11 +31,9 @@ class Router(BaseRouter):
def _get( def _get(
self, path: str, method: str, host: Optional[str] self, path: str, method: str, host: Optional[str]
) -> Tuple[Route, RequestHandler, Dict[str, Any]]: ) -> Tuple[Route, RouteHandler, Dict[str, Any]]:
try: try:
# We know this will always be RequestHandler, so we can ignore return self.resolve(
# typing issue here
return self.resolve( # type: ignore
path=path, path=path,
method=method, method=method,
extra={"host": host} if host else None, extra={"host": host} if host else None,
@@ -53,7 +50,7 @@ class Router(BaseRouter):
@lru_cache(maxsize=ROUTER_CACHE_SIZE) @lru_cache(maxsize=ROUTER_CACHE_SIZE)
def get( # type: ignore def get( # type: ignore
self, path: str, method: str, host: Optional[str] self, path: str, method: str, host: Optional[str]
) -> Tuple[Route, RequestHandler, Dict[str, Any]]: ) -> Tuple[Route, RouteHandler, Dict[str, Any]]:
""" """
Retrieve a `Route` object containing the details about how to handle Retrieve a `Route` object containing the details about how to handle
a response for a given request a response for a given request
@@ -62,7 +59,7 @@ class Router(BaseRouter):
:type request: Request :type request: Request
:return: details needed for handling the request and returning the :return: details needed for handling the request and returning the
correct response correct response
:rtype: Tuple[ Route, RequestHandler, Dict[str, Any]] :rtype: Tuple[ Route, RouteHandler, Dict[str, Any]]
""" """
return self._get(path, method, host) return self._get(path, method, host)
@@ -117,7 +114,7 @@ class Router(BaseRouter):
params = dict( params = dict(
path=uri, path=uri,
handler=RequestHandler(handler, [], []), handler=handler,
methods=frozenset(map(str, methods)) if methods else None, methods=frozenset(map(str, methods)) if methods else None,
name=name, name=name,
strict=strict_slashes, strict=strict_slashes,
@@ -136,14 +133,14 @@ class Router(BaseRouter):
params.update({"requirements": {"host": host}}) params.update({"requirements": {"host": host}})
route = super().add(**params) # type: ignore route = super().add(**params) # type: ignore
route.ctx.ignore_body = ignore_body route.extra.ignore_body = ignore_body
route.ctx.stream = stream route.extra.stream = stream
route.ctx.hosts = hosts route.extra.hosts = hosts
route.ctx.static = static route.extra.static = static
route.ctx.error_format = error_format route.extra.error_format = error_format
if error_format: if error_format:
check_error_format(route.ctx.error_format) check_error_format(route.extra.error_format)
routes.append(route) routes.append(route)

View File

@@ -94,7 +94,6 @@ def watchdog(sleep_interval, reload_dirs):
try: try:
while True: while True:
changed = set() changed = set()
for filename in itertools.chain( for filename in itertools.chain(
_iter_module_files(), _iter_module_files(),

View File

@@ -1,11 +1,11 @@
import asyncio import asyncio
import sys import sys
from distutils.util import strtobool
from os import getenv from os import getenv
from sanic.compat import OS_IS_WINDOWS from sanic.compat import OS_IS_WINDOWS
from sanic.log import error_logger from sanic.log import error_logger
from sanic.utils import str_to_bool
def try_use_uvloop() -> None: def try_use_uvloop() -> None:
@@ -35,7 +35,7 @@ def try_use_uvloop() -> None:
) )
return 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: if uvloop_install_removed:
error_logger.info( error_logger.info(
"You are requesting to run Sanic using uvloop, but the " "You are requesting to run Sanic using uvloop, but the "

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Optional from typing import TYPE_CHECKING, Optional
from sanic.handlers import RequestManager
from sanic.http.constants import HTTP from sanic.http.constants import HTTP
from sanic.http.http3 import Http3 from sanic.http.http3 import Http3
from sanic.touchup.meta import TouchUpMeta from sanic.touchup.meta import TouchUpMeta
@@ -58,7 +57,7 @@ class HttpProtocolMixin:
def _setup(self): def _setup(self):
self.request: Optional[Request] = None self.request: Optional[Request] = None
self.access_log = self.app.config.ACCESS_LOG self.access_log = self.app.config.ACCESS_LOG
self.request_handler = RequestManager self.request_handler = self.app.handle_request
self.error_handler = self.app.error_handler self.error_handler = self.app.error_handler
self.request_timeout = self.app.config.REQUEST_TIMEOUT self.request_timeout = self.app.config.REQUEST_TIMEOUT
self.response_timeout = self.app.config.RESPONSE_TIMEOUT self.response_timeout = self.app.config.RESPONSE_TIMEOUT

View File

@@ -1,7 +1,13 @@
from typing import TYPE_CHECKING, Optional, Sequence, cast 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 websockets.typing import Subprotocol
from sanic.exceptions import ServerError from sanic.exceptions import ServerError
@@ -15,6 +21,11 @@ if TYPE_CHECKING:
from websockets import http11 from websockets import http11
OPEN = State.OPEN
CLOSING = State.CLOSING
CLOSED = State.CLOSED
class WebSocketProtocol(HttpProtocol): class WebSocketProtocol(HttpProtocol):
__slots__ = ( __slots__ = (
"websocket", "websocket",
@@ -74,7 +85,7 @@ class WebSocketProtocol(HttpProtocol):
# Called by Sanic Server when shutting down # Called by Sanic Server when shutting down
# If we've upgraded to websocket, shut it down # If we've upgraded to websocket, shut it down
if self.websocket is not None: 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 return True
elif self.websocket.loop is not None: elif self.websocket.loop is not None:
self.websocket.loop.create_task(self.websocket.close(1001)) self.websocket.loop.create_task(self.websocket.close(1001))
@@ -90,7 +101,7 @@ class WebSocketProtocol(HttpProtocol):
try: try:
if subprotocols is not None: if subprotocols is not None:
# subprotocols can be a set or frozenset, # subprotocols can be a set or frozenset,
# but ServerConnection needs a list # but ServerProtocol needs a list
subprotocols = cast( subprotocols = cast(
Optional[Sequence[Subprotocol]], Optional[Sequence[Subprotocol]],
list( list(
@@ -100,13 +111,13 @@ class WebSocketProtocol(HttpProtocol):
] ]
), ),
) )
ws_conn = ServerConnection( ws_proto = ServerProtocol(
max_size=self.websocket_max_size, max_size=self.websocket_max_size,
subprotocols=subprotocols, subprotocols=subprotocols,
state=OPEN, state=OPEN,
logger=logger, logger=logger,
) )
resp: "http11.Response" = ws_conn.accept(request) resp: "http11.Response" = ws_proto.accept(request)
except Exception: except Exception:
msg = ( msg = (
"Failed to open a WebSocket connection.\n" "Failed to open a WebSocket connection.\n"
@@ -129,7 +140,7 @@ class WebSocketProtocol(HttpProtocol):
else: else:
raise ServerError(resp.body, resp.status_code) raise ServerError(resp.body, resp.status_code)
self.websocket = WebsocketImplProtocol( self.websocket = WebsocketImplProtocol(
ws_conn, ws_proto,
ping_interval=self.websocket_ping_interval, ping_interval=self.websocket_ping_interval,
ping_timeout=self.websocket_ping_timeout, ping_timeout=self.websocket_ping_timeout,
close_timeout=self.websocket_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.application.ext import setup_ext
from sanic.compat import OS_IS_WINDOWS, ctrlc_workaround_for_windows from sanic.compat import OS_IS_WINDOWS, ctrlc_workaround_for_windows
from sanic.http.http3 import SessionTicketStore, get_config 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.models.server_types import Signal
from sanic.server.async_server import AsyncioServer from sanic.server.async_server import AsyncioServer
from sanic.server.protocols.http_protocol import Http3Protocol, HttpProtocol from sanic.server.protocols.http_protocol import Http3Protocol, HttpProtocol
@@ -149,12 +149,12 @@ def _setup_system_signals(
def _run_server_forever(loop, before_stop, after_stop, cleanup, unix): def _run_server_forever(loop, before_stop, after_stop, cleanup, unix):
pid = os.getpid() pid = os.getpid()
try: try:
logger.info("Starting worker [%s]", pid) server_logger.info("Starting worker [%s]", pid)
loop.run_forever() loop.run_forever()
except KeyboardInterrupt: except KeyboardInterrupt:
pass pass
finally: finally:
logger.info("Stopping worker [%s]", pid) server_logger.info("Stopping worker [%s]", pid)
loop.run_until_complete(before_stop()) loop.run_until_complete(before_stop())
@@ -200,7 +200,7 @@ def _serve_http_1(
asyncio_server_kwargs = ( asyncio_server_kwargs = (
asyncio_server_kwargs if asyncio_server_kwargs else {} asyncio_server_kwargs if asyncio_server_kwargs else {}
) )
if OS_IS_WINDOWS: if OS_IS_WINDOWS and sock:
pid = os.getpid() pid = os.getpid()
sock = sock.share(pid) sock = sock.share(pid)
sock = socket.fromshare(sock) sock = socket.fromshare(sock)
@@ -229,6 +229,7 @@ def _serve_http_1(
loop.run_until_complete(app._startup()) loop.run_until_complete(app._startup())
loop.run_until_complete(app._server_event("init", "before")) loop.run_until_complete(app._server_event("init", "before"))
app.ack()
try: try:
http_server = loop.run_until_complete(server_coroutine) http_server = loop.run_until_complete(server_coroutine)
@@ -306,6 +307,7 @@ def _serve_http_3(
server = AsyncioServer(app, loop, coro, []) server = AsyncioServer(app, loop, coro, [])
loop.run_until_complete(server.startup()) loop.run_until_complete(server.startup())
loop.run_until_complete(server.before_start()) loop.run_until_complete(server.before_start())
app.ack()
loop.run_until_complete(server) loop.run_until_complete(server)
_setup_system_signals(app, run_multiple, register_sys_signals, loop) _setup_system_signals(app, run_multiple, register_sys_signals, loop)
loop.run_until_complete(server.after_start()) loop.run_until_complete(server.after_start())
@@ -372,7 +374,9 @@ def serve_multiple(server_settings, workers):
processes = [] processes = []
def sig_handler(signal, frame): 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: for process in processes:
os.kill(process.pid, SIGTERM) os.kill(process.pid, SIGTERM)

View File

@@ -113,13 +113,16 @@ def configure_socket(
backlog=backlog, backlog=backlog,
) )
except OSError as e: # no cov except OSError as e: # no cov
raise ServerError( error = ServerError(
f"Sanic server could not start: {e}.\n" f"Sanic server could not start: {e}.\n\n"
"This may have happened if you are running Sanic in the " "This may have happened if you are running Sanic in the "
"global scope and not inside of a " "global scope and not inside of a "
'`if __name__ == "__main__"` block. See more information: ' '`if __name__ == "__main__"` block.\n\nSee more information: '
"____." "https://sanic.dev/en/guide/deployment/manager.html#"
) from e "how-sanic-server-starts-processes\n"
)
error.quiet = True
raise error
sock.set_inheritable(True) sock.set_inheritable(True)
server_settings["sock"] = sock server_settings["sock"] = sock
server_settings["host"] = None server_settings["host"] = None

View File

@@ -9,8 +9,10 @@ from typing import (
Union, Union,
) )
from sanic.exceptions import InvalidUsage
ASIMessage = MutableMapping[str, Any]
ASGIMessage = MutableMapping[str, Any]
class WebSocketConnection: class WebSocketConnection:
@@ -25,8 +27,8 @@ class WebSocketConnection:
def __init__( def __init__(
self, self,
send: Callable[[ASIMessage], Awaitable[None]], send: Callable[[ASGIMessage], Awaitable[None]],
receive: Callable[[], Awaitable[ASIMessage]], receive: Callable[[], Awaitable[ASGIMessage]],
subprotocols: Optional[List[str]] = None, subprotocols: Optional[List[str]] = None,
) -> None: ) -> None:
self._send = send self._send = send
@@ -47,7 +49,13 @@ class WebSocketConnection:
message = await self._receive() message = await self._receive()
if message["type"] == "websocket.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": elif message["type"] == "websocket.disconnect":
pass pass

View File

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

View File

@@ -12,21 +12,37 @@ from typing import (
Union, Union,
) )
from websockets.connection import CLOSED, CLOSING, OPEN, Event from websockets.exceptions import (
from websockets.exceptions import ConnectionClosed, ConnectionClosedError ConnectionClosed,
ConnectionClosedError,
ConnectionClosedOK,
)
from websockets.frames import Frame, Opcode 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 websockets.typing import Data
from sanic.log import error_logger, logger from sanic.log import deprecation, error_logger, logger
from sanic.server.protocols.base_protocol import SanicProtocol from sanic.server.protocols.base_protocol import SanicProtocol
from ...exceptions import ServerError, WebsocketClosed from ...exceptions import ServerError, WebsocketClosed
from .frame import WebsocketFrameAssembler from .frame import WebsocketFrameAssembler
OPEN = State.OPEN
CLOSING = State.CLOSING
CLOSED = State.CLOSED
class WebsocketImplProtocol: class WebsocketImplProtocol:
connection: ServerConnection ws_proto: ServerProtocol
io_proto: Optional[SanicProtocol] io_proto: Optional[SanicProtocol]
loop: Optional[asyncio.AbstractEventLoop] loop: Optional[asyncio.AbstractEventLoop]
max_queue: int max_queue: int
@@ -52,14 +68,14 @@ class WebsocketImplProtocol:
def __init__( def __init__(
self, self,
connection, ws_proto,
max_queue=None, max_queue=None,
ping_interval: Optional[float] = 20, ping_interval: Optional[float] = 20,
ping_timeout: Optional[float] = 20, ping_timeout: Optional[float] = 20,
close_timeout: float = 10, close_timeout: float = 10,
loop=None, loop=None,
): ):
self.connection = connection self.ws_proto = ws_proto
self.io_proto = None self.io_proto = None
self.loop = None self.loop = None
self.max_queue = max_queue self.max_queue = max_queue
@@ -81,7 +97,16 @@ class WebsocketImplProtocol:
@property @property
def subprotocol(self): 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): def pause_frames(self):
if not self.can_pause: if not self.can_pause:
@@ -295,15 +320,15 @@ class WebsocketImplProtocol:
# Not draining the write buffer is acceptable in this context. # Not draining the write buffer is acceptable in this context.
# clear the send buffer # 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 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): if code in (1000, 1001):
self.connection.send_close(code, reason) self.ws_proto.send_close(code, reason)
else: else:
self.connection.fail(code, reason) self.ws_proto.fail(code, reason)
try: try:
data_to_send = self.connection.data_to_send() data_to_send = self.ws_proto.data_to_send()
while ( while (
len(data_to_send) len(data_to_send)
and self.io_proto and self.io_proto
@@ -317,7 +342,7 @@ class WebsocketImplProtocol:
... ...
if code == 1006: if code == 1006:
# Special case: 1006 consider the transport already closed # 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(): if self.data_finished_fut and not self.data_finished_fut.done():
# We have a graceful auto-closer. Use it to close the connection. # We have a graceful auto-closer. Use it to close the connection.
self.data_finished_fut.cancel() self.data_finished_fut.cancel()
@@ -338,10 +363,10 @@ class WebsocketImplProtocol:
# In Python Version 3.7: pause_reading is idempotent # In Python Version 3.7: pause_reading is idempotent
# i.e. it can be called when the transport is already paused or closed. # i.e. it can be called when the transport is already paused or closed.
self.io_proto.transport.pause_reading() self.io_proto.transport.pause_reading()
if self.connection.state == OPEN: if self.ws_proto.state == OPEN:
data_to_send = self.connection.data_to_send() data_to_send = self.ws_proto.data_to_send()
self.connection.send_close(code, reason) self.ws_proto.send_close(code, reason)
data_to_send.extend(self.connection.data_to_send()) data_to_send.extend(self.ws_proto.data_to_send())
try: try:
while ( while (
len(data_to_send) len(data_to_send)
@@ -450,7 +475,7 @@ class WebsocketImplProtocol:
Raise ConnectionClosed in pending keepalive pings. Raise ConnectionClosed in pending keepalive pings.
They'll never receive a pong once the connection is closed. 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( raise ServerError(
"Webscoket about_pings should only be called " "Webscoket about_pings should only be called "
"after connection state is changed to CLOSED" "after connection state is changed to CLOSED"
@@ -479,9 +504,9 @@ class WebsocketImplProtocol:
self.fail_connection(code, reason) self.fail_connection(code, reason)
return return
async with self.conn_mutex: async with self.conn_mutex:
if self.connection.state is OPEN: if self.ws_proto.state is OPEN:
self.connection.send_close(code, reason) self.ws_proto.send_close(code, reason)
data_to_send = self.connection.data_to_send() data_to_send = self.ws_proto.data_to_send()
await self.send_data(data_to_send) await self.send_data(data_to_send)
async def recv(self, timeout: Optional[float] = None) -> Optional[Data]: async def recv(self, timeout: Optional[float] = None) -> Optional[Data]:
@@ -511,7 +536,7 @@ class WebsocketImplProtocol:
"already waiting for the next message" "already waiting for the next message"
) )
await self.recv_lock.acquire() await self.recv_lock.acquire()
if self.connection.state is CLOSED: if self.ws_proto.state is CLOSED:
self.recv_lock.release() self.recv_lock.release()
raise WebsocketClosed( raise WebsocketClosed(
"Cannot receive from websocket interface after it is closed." "Cannot receive from websocket interface after it is closed."
@@ -562,7 +587,7 @@ class WebsocketImplProtocol:
"for the next message" "for the next message"
) )
await self.recv_lock.acquire() await self.recv_lock.acquire()
if self.connection.state is CLOSED: if self.ws_proto.state is CLOSED:
self.recv_lock.release() self.recv_lock.release()
raise WebsocketClosed( raise WebsocketClosed(
"Cannot receive from websocket interface after it is closed." "Cannot receive from websocket interface after it is closed."
@@ -621,7 +646,7 @@ class WebsocketImplProtocol:
"is already waiting for the next message" "is already waiting for the next message"
) )
await self.recv_lock.acquire() await self.recv_lock.acquire()
if self.connection.state is CLOSED: if self.ws_proto.state is CLOSED:
self.recv_lock.release() self.recv_lock.release()
raise WebsocketClosed( raise WebsocketClosed(
"Cannot receive from websocket interface after it is closed." "Cannot receive from websocket interface after it is closed."
@@ -661,8 +686,7 @@ class WebsocketImplProtocol:
:raises TypeError: for unsupported inputs :raises TypeError: for unsupported inputs
""" """
async with self.conn_mutex: async with self.conn_mutex:
if self.ws_proto.state in (CLOSED, CLOSING):
if self.connection.state in (CLOSED, CLOSING):
raise WebsocketClosed( raise WebsocketClosed(
"Cannot write to websocket interface after it is closed." "Cannot write to websocket interface after it is closed."
) )
@@ -675,12 +699,12 @@ class WebsocketImplProtocol:
# strings and bytes-like objects are iterable. # strings and bytes-like objects are iterable.
if isinstance(message, str): if isinstance(message, str):
self.connection.send_text(message.encode("utf-8")) self.ws_proto.send_text(message.encode("utf-8"))
await self.send_data(self.connection.data_to_send()) await self.send_data(self.ws_proto.data_to_send())
elif isinstance(message, (bytes, bytearray, memoryview)): elif isinstance(message, (bytes, bytearray, memoryview)):
self.connection.send_binary(message) self.ws_proto.send_binary(message)
await self.send_data(self.connection.data_to_send()) await self.send_data(self.ws_proto.data_to_send())
elif isinstance(message, Mapping): elif isinstance(message, Mapping):
# Catch a common mistake -- passing a dict to send(). # 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. (which will be encoded to UTF-8) or a bytes-like object.
""" """
async with self.conn_mutex: async with self.conn_mutex:
if self.connection.state in (CLOSED, CLOSING): if self.ws_proto.state in (CLOSED, CLOSING):
raise WebsocketClosed( raise WebsocketClosed(
"Cannot send a ping when the websocket interface " "Cannot send a ping when the websocket interface "
"is closed." "is closed."
@@ -737,8 +761,8 @@ class WebsocketImplProtocol:
self.pings[data] = self.io_proto.loop.create_future() self.pings[data] = self.io_proto.loop.create_future()
self.connection.send_ping(data) self.ws_proto.send_ping(data)
await self.send_data(self.connection.data_to_send()) await self.send_data(self.ws_proto.data_to_send())
return asyncio.shield(self.pings[data]) 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. be a string (which will be encoded to UTF-8) or a bytes-like object.
""" """
async with self.conn_mutex: 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 # Cannot send pong after transport is shutting down
return return
if isinstance(data, str): if isinstance(data, str):
data = data.encode("utf-8") data = data.encode("utf-8")
elif isinstance(data, (bytearray, memoryview)): elif isinstance(data, (bytearray, memoryview)):
data = bytes(data) data = bytes(data)
self.connection.send_pong(data) self.ws_proto.send_pong(data)
await self.send_data(self.connection.data_to_send()) await self.send_data(self.ws_proto.data_to_send())
async def send_data(self, data_to_send): async def send_data(self, data_to_send):
for data in data_to_send: for data in data_to_send:
@@ -780,7 +804,7 @@ class WebsocketImplProtocol:
SanicProtocol.close(self.io_proto, timeout=1.0) SanicProtocol.close(self.io_proto, timeout=1.0)
async def async_data_received(self, data_to_send, events_to_process): 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) # receiving data can generate data to send (eg, pong for a ping)
# send connection.data_to_send() # send connection.data_to_send()
await self.send_data(data_to_send) await self.send_data(data_to_send)
@@ -788,9 +812,9 @@ class WebsocketImplProtocol:
await self.process_events(events_to_process) await self.process_events(events_to_process)
def data_received(self, data): def data_received(self, data):
self.connection.receive_data(data) self.ws_proto.receive_data(data)
data_to_send = self.connection.data_to_send() data_to_send = self.ws_proto.data_to_send()
events_to_process = self.connection.events_received() events_to_process = self.ws_proto.events_received()
if len(data_to_send) > 0 or len(events_to_process) > 0: if len(data_to_send) > 0 or len(events_to_process) > 0:
asyncio.create_task( asyncio.create_task(
self.async_data_received(data_to_send, events_to_process) 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): async def async_eof_received(self, data_to_send, events_to_process):
# receiving EOF can generate data to send # receiving EOF can generate data to send
# send connection.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) await self.send_data(data_to_send)
if len(events_to_process) > 0: if len(events_to_process) > 0:
await self.process_events(events_to_process) await self.process_events(events_to_process)
@@ -819,9 +843,9 @@ class WebsocketImplProtocol:
SanicProtocol.close(self.io_proto, timeout=1.0) SanicProtocol.close(self.io_proto, timeout=1.0)
def eof_received(self) -> Optional[bool]: def eof_received(self) -> Optional[bool]:
self.connection.receive_eof() self.ws_proto.receive_eof()
data_to_send = self.connection.data_to_send() data_to_send = self.ws_proto.data_to_send()
events_to_process = self.connection.events_received() events_to_process = self.ws_proto.events_received()
asyncio.create_task( asyncio.create_task(
self.async_eof_received(data_to_send, events_to_process) self.async_eof_received(data_to_send, events_to_process)
) )
@@ -831,12 +855,19 @@ class WebsocketImplProtocol:
""" """
The WebSocket Connection is Closed. The WebSocket Connection is Closed.
""" """
if not self.connection.state == CLOSED: if not self.ws_proto.state == CLOSED:
# signal to the websocket connection handler # signal to the websocket connection handler
# we've lost the connection # we've lost the connection
self.connection.fail(code=1006) self.ws_proto.fail(code=1006)
self.connection.state = CLOSED self.ws_proto.state = CLOSED
self.abort_pings() self.abort_pings()
if self.connection_lost_waiter: if self.connection_lost_waiter:
self.connection_lost_waiter.set_result(None) self.connection_lost_waiter.set_result(None)
async def __aiter__(self):
try:
while True:
yield await self.recv()
except ConnectionClosedOK:
return

View File

@@ -154,9 +154,7 @@ class SignalRouter(BaseRouter):
try: try:
for signal in signals: for signal in signals:
params.pop("__trigger__", None) params.pop("__trigger__", None)
requirements = getattr( requirements = signal.extra.requirements
signal.handler, "__requirements__", None
)
if ( if (
(condition is None and signal.ctx.exclusive is False) (condition is None and signal.ctx.exclusive is False)
or (condition is None and not requirements) or (condition is None and not requirements)
@@ -219,8 +217,13 @@ class SignalRouter(BaseRouter):
if not trigger: if not trigger:
event = ".".join([*parts[:2], "<__trigger__>"]) event = ".".join([*parts[:2], "<__trigger__>"])
handler.__requirements__ = condition # type: ignore try:
handler.__trigger__ = trigger # type: ignore # Attaching __requirements__ and __trigger__ to the handler
# is deprecated and will be removed in v23.6.
handler.__requirements__ = condition # type: ignore
handler.__trigger__ = trigger # type: ignore
except AttributeError:
pass
signal = super().add( signal = super().add(
event, event,
@@ -232,6 +235,7 @@ class SignalRouter(BaseRouter):
signal.ctx.exclusive = exclusive signal.ctx.exclusive = exclusive
signal.ctx.trigger = trigger signal.ctx.trigger = trigger
signal.ctx.definition = event_definition signal.ctx.definition = event_definition
signal.extra.requirements = condition
return cast(Signal, signal) return cast(Signal, signal)

View File

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

View File

@@ -44,7 +44,9 @@ class SharedContext(SimpleNamespace):
f"{Colors.YELLOW}with type {Colors.PURPLE}{type(value)} " f"{Colors.YELLOW}with type {Colors.PURPLE}{type(value)} "
f"{Colors.YELLOW}was added to shared_ctx. It may not " f"{Colors.YELLOW}was added to shared_ctx. It may not "
"not function as intended. Consider using the regular " "not function as intended. Consider using the regular "
f"ctx. For more information, please see ____.{Colors.END}" 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 @property

View File

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

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

View File

@@ -1,23 +1,17 @@
import sys from __future__ import annotations
from datetime import datetime from datetime import datetime
from inspect import isawaitable
from multiprocessing.connection import Connection from multiprocessing.connection import Connection
from signal import SIGINT, SIGTERM from os import environ
from signal import signal as signal_func from pathlib import Path
from socket import AF_INET, SOCK_STREAM, socket, timeout from typing import Any, Dict, Mapping, Union
from textwrap import indent
from typing import Any, Dict
from sanic.application.logo import get_logo from sanic.exceptions import Unauthorized
from sanic.application.motd import MOTDTTY from sanic.helpers import Default
from sanic.log import Colors, error_logger, logger from sanic.log import logger
from sanic.server.socket import configure_socket from sanic.request import Request
from sanic.response import json
try: # no cov
from ujson import dumps, loads
except ModuleNotFoundError: # no cov
from json import dumps, loads # type: ignore
class Inspector: class Inspector:
@@ -25,117 +19,105 @@ class Inspector:
self, self,
publisher: Connection, publisher: Connection,
app_info: Dict[str, Any], app_info: Dict[str, Any],
worker_state: Dict[str, Any], worker_state: Mapping[str, Any],
host: str, host: str,
port: int, port: int,
api_key: str,
tls_key: Union[Path, str, Default],
tls_cert: Union[Path, str, Default],
): ):
self._publisher = publisher self._publisher = publisher
self.run = True
self.app_info = app_info self.app_info = app_info
self.worker_state = worker_state self.worker_state = worker_state
self.host = host self.host = host
self.port = port self.port = port
self.api_key = api_key
self.tls_key = tls_key
self.tls_cert = tls_cert
def __call__(self) -> None: def __call__(self, run=True, **_) -> Inspector:
sock = configure_socket( from sanic import Sanic
{"host": self.host, "port": self.port, "unix": None, "backlog": 1}
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,
) )
assert sock
signal_func(SIGINT, self.stop)
signal_func(SIGTERM, self.stop)
logger.info(f"Inspector started on: {sock.getsockname()}") def _state_to_json(self) -> Dict[str, Any]:
sock.settimeout(0.5)
try:
while self.run:
try:
conn, _ = sock.accept()
except timeout:
continue
else:
action = conn.recv(64)
if action == b"reload":
conn.send(b"\n")
self.reload()
elif action == b"shutdown":
conn.send(b"\n")
self.shutdown()
else:
data = dumps(self.state_to_json())
conn.send(data.encode())
conn.close()
finally:
logger.debug("Inspector closing")
sock.close()
def stop(self, *_):
self.run = False
def state_to_json(self):
output = {"info": self.app_info} output = {"info": self.app_info}
output["workers"] = self._make_safe(dict(self.worker_state)) output["workers"] = self._make_safe(dict(self.worker_state))
return output return output
def reload(self): @staticmethod
message = "__ALL_PROCESSES__:" def _make_safe(obj: Dict[str, Any]) -> Dict[str, Any]:
self._publisher.send(message)
def shutdown(self):
message = "__TERMINATE__"
self._publisher.send(message)
def _make_safe(self, obj: Dict[str, Any]) -> Dict[str, Any]:
for key, value in obj.items(): for key, value in obj.items():
if isinstance(value, dict): if isinstance(value, dict):
obj[key] = self._make_safe(value) obj[key] = Inspector._make_safe(value)
elif isinstance(value, datetime): elif isinstance(value, datetime):
obj[key] = value.isoformat() obj[key] = value.isoformat()
return obj return obj
def reload(self, zero_downtime: bool = False) -> None:
message = "__ALL_PROCESSES__:"
if zero_downtime:
message += ":STARTUP_FIRST"
self._publisher.send(message)
def inspect(host: str, port: int, action: str): def scale(self, replicas) -> str:
out = sys.stdout.write num_workers = 1
with socket(AF_INET, SOCK_STREAM) as sock: if replicas:
try: num_workers = int(replicas)
sock.connect((host, port)) log_msg = f"Scaling to {num_workers}"
except ConnectionRefusedError: logger.info(log_msg)
error_logger.error( message = f"__SCALE__:{num_workers}"
f"{Colors.RED}Could not connect to inspector at: " self._publisher.send(message)
f"{Colors.YELLOW}{(host, port)}{Colors.END}\n" return log_msg
"Either the application is not running, or it did not start "
"an inspector instance." def shutdown(self) -> None:
) message = "__TERMINATE__"
sock.close() self._publisher.send(message)
sys.exit(1)
sock.sendall(action.encode())
data = sock.recv(4096)
if action == "raw":
out(data.decode())
elif action == "pretty":
loaded = loads(data)
display = loaded.pop("info")
extra = display.pop("extra", {})
display["packages"] = ", ".join(display["packages"])
MOTDTTY(get_logo(), f"{host}:{port}", display, extra).display(
version=False,
action="Inspecting",
out=out,
)
for name, info in loaded["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"
)

View File

@@ -5,18 +5,10 @@ import sys
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
from typing import ( from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union, cast
TYPE_CHECKING,
Any,
Callable,
Dict,
Optional,
Type,
Union,
cast,
)
from sanic.http.tls.creators import CertCreator, MkcertCreator, TrustmeCreator from sanic.http.tls.context import process_to_context
from sanic.http.tls.creators import MkcertCreator, TrustmeCreator
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -106,21 +98,30 @@ class AppLoader:
class CertLoader: class CertLoader:
_creator_class: Type[CertCreator] _creators = {
"mkcert": MkcertCreator,
"trustme": TrustmeCreator,
}
def __init__(self, ssl_data: Dict[str, Union[str, os.PathLike]]): def __init__(self, ssl_data: Dict[str, Union[str, os.PathLike]]):
creator_name = ssl_data.get("creator") self._ssl_data = ssl_data
if creator_name not in ("mkcert", "trustme"):
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}") raise RuntimeError(f"Unknown certificate creator: {creator_name}")
elif creator_name == "mkcert":
self._creator_class = MkcertCreator
elif creator_name == "trustme":
self._creator_class = TrustmeCreator
self._key = ssl_data["key"] self._key = ssl_data["key"]
self._cert = ssl_data["cert"] self._cert = ssl_data["cert"]
self._localhost = cast(str, ssl_data["localhost"]) self._localhost = cast(str, ssl_data["localhost"])
def load(self, app: SanicApp): 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) creator = self._creator_class(app, self._key, self._cert)
return creator.generate_cert(self._localhost) return creator.generate_cert(self._localhost)

View File

@@ -1,13 +1,16 @@
import os import os
import sys
from contextlib import suppress
from itertools import count
from random import choice
from signal import SIGINT, SIGTERM, Signals from signal import SIGINT, SIGTERM, Signals
from signal import signal as signal_func from signal import signal as signal_func
from time import sleep from typing import Dict, List, Optional
from typing import List, Optional
from sanic.compat import OS_IS_WINDOWS from sanic.compat import OS_IS_WINDOWS
from sanic.exceptions import ServerKilled
from sanic.log import error_logger, logger from sanic.log import error_logger, logger
from sanic.worker.constants import RestartOrder
from sanic.worker.process import ProcessState, Worker, WorkerProcess from sanic.worker.process import ProcessState, Worker, WorkerProcess
@@ -18,7 +21,8 @@ else:
class WorkerManager: class WorkerManager:
THRESHOLD = 50 THRESHOLD = WorkerProcess.THRESHOLD
MAIN_IDENT = "Sanic-Main"
def __init__( def __init__(
self, self,
@@ -31,39 +35,66 @@ class WorkerManager:
): ):
self.num_server = number self.num_server = number
self.context = context self.context = context
self.transient: List[Worker] = [] self.transient: Dict[str, Worker] = {}
self.durable: List[Worker] = [] self.durable: Dict[str, Worker] = {}
self.monitor_publisher, self.monitor_subscriber = monitor_pubsub self.monitor_publisher, self.monitor_subscriber = monitor_pubsub
self.worker_state = worker_state self.worker_state = worker_state
self.worker_state["Sanic-Main"] = {"pid": self.pid} self.worker_state[self.MAIN_IDENT] = {"pid": self.pid}
self.terminated = False self._shutting_down = False
self._serve = serve
self._server_settings = server_settings
self._server_count = count()
if number == 0: if number == 0:
raise RuntimeError("Cannot serve with no workers") raise RuntimeError("Cannot serve with no workers")
for i in range(number): for _ in range(number):
self.manage( self.create_server()
f"{WorkerProcess.SERVER_LABEL}-{i}",
serve,
server_settings,
transient=True,
)
signal_func(SIGINT, self.shutdown_signal) signal_func(SIGINT, self.shutdown_signal)
signal_func(SIGTERM, self.shutdown_signal) signal_func(SIGTERM, self.shutdown_signal)
def manage(self, ident, func, kwargs, transient=False): def manage(self, ident, func, kwargs, transient=False) -> Worker:
container = self.transient if transient else self.durable container = self.transient if transient else self.durable
container.append( worker = Worker(ident, func, kwargs, self.context, self.worker_state)
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): def run(self):
self.start() self.start()
self.monitor() self.monitor()
self.join() self.join()
self.terminate() self.terminate()
# self.kill()
def start(self): def start(self):
for process in self.processes: for process in self.processes:
@@ -85,15 +116,41 @@ class WorkerManager:
self.join() self.join()
def terminate(self): def terminate(self):
if not self.terminated: if not self._shutting_down:
for process in self.processes: for process in self.processes:
process.terminate() process.terminate()
self.terminated = True
def restart(self, process_names: Optional[List[str]] = None, **kwargs): def restart(
self,
process_names: Optional[List[str]] = None,
restart_order=RestartOrder.SHUTDOWN_FIRST,
**kwargs,
):
for process in self.transient_processes: for process in self.transient_processes:
if not process_names or process.name in process_names: if not process_names or process.name in process_names:
process.restart(**kwargs) 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): def monitor(self):
self.wait_for_ack() self.wait_for_ack()
@@ -109,7 +166,15 @@ class WorkerManager:
elif message == "__TERMINATE__": elif message == "__TERMINATE__":
self.shutdown() self.shutdown()
break break
split_message = message.split(":", 1) 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] processes = split_message[0]
reloaded_files = ( reloaded_files = (
split_message[1] if len(split_message) > 1 else None split_message[1] if len(split_message) > 1 else None
@@ -119,10 +184,17 @@ class WorkerManager:
] ]
if "__ALL_PROCESSES__" in process_names: if "__ALL_PROCESSES__" in process_names:
process_names = None process_names = None
order = (
RestartOrder.STARTUP_FIRST
if "STARTUP_FIRST" in split_message
else RestartOrder.SHUTDOWN_FIRST
)
self.restart( self.restart(
process_names=process_names, process_names=process_names,
reloaded_files=reloaded_files, reloaded_files=reloaded_files,
restart_order=order,
) )
self._sync_states()
except InterruptedError: except InterruptedError:
if not OS_IS_WINDOWS: if not OS_IS_WINDOWS:
raise raise
@@ -130,17 +202,40 @@ class WorkerManager:
def wait_for_ack(self): # no cov def wait_for_ack(self): # no cov
misses = 0 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(): while not self._all_workers_ack():
sleep(0.1) 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 misses += 1
if misses > self.THRESHOLD: if misses > self.THRESHOLD:
error_logger.error("Not all workers are ack. Shutting down.") error_logger.error(
"Not all workers acknowledged a successful startup. "
"Shutting down.\n\n" + message
)
self.kill() self.kill()
sys.exit(1)
@property @property
def workers(self): def workers(self) -> List[Worker]:
return self.transient + self.durable return list(self.transient.values()) + list(self.durable.values())
@property @property
def processes(self): def processes(self):
@@ -150,15 +245,22 @@ class WorkerManager:
@property @property
def transient_processes(self): def transient_processes(self):
for worker in self.transient: for worker in self.transient.values():
for process in worker.processes: for process in worker.processes:
yield process yield process
def kill(self): def kill(self):
for process in self.processes: for process in self.processes:
logger.info("Killing %s [%s]", process.name, process.pid)
os.kill(process.pid, SIGKILL) os.kill(process.pid, SIGKILL)
raise ServerKilled
def shutdown_signal(self, signal, frame): 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) logger.info("Received signal %s. Shutting down.", Signals(signal).name)
self.monitor_publisher.send(None) self.monitor_publisher.send(None)
self.shutdown() self.shutdown()
@@ -167,6 +269,7 @@ class WorkerManager:
for process in self.processes: for process in self.processes:
if process.is_alive(): if process.is_alive():
process.terminate() process.terminate()
self._shutting_down = True
@property @property
def pid(self): def pid(self):
@@ -179,3 +282,9 @@ class WorkerManager:
if worker_state.get("server") if worker_state.get("server")
] ]
return all(acked) and len(acked) == self.num_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

@@ -2,6 +2,7 @@ from multiprocessing.connection import Connection
from os import environ, getpid from os import environ, getpid
from typing import Any, Dict from typing import Any, Dict
from sanic.log import Colors, logger
from sanic.worker.process import ProcessState from sanic.worker.process import ProcessState
from sanic.worker.state import WorkerState from sanic.worker.state import WorkerState
@@ -16,20 +17,45 @@ class WorkerMultiplexer:
self._state = WorkerState(worker_state, self.name) self._state = WorkerState(worker_state, self.name)
def ack(self): 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] = {
**self._state._state[self.name], **self._state._state[self.name],
"state": ProcessState.ACKED.name, "state": ProcessState.ACKED.name,
} }
def restart(self, name: str = ""): 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: if not name:
name = self.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) self._monitor_publisher.send(name)
reload = restart # no cov reload = restart # no cov
def terminate(self): def scale(self, num_workers: int):
self._monitor_publisher.send("__TERMINATE__") 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 @property
def pid(self) -> int: def pid(self) -> int:

View File

@@ -1,12 +1,14 @@
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
from enum import IntEnum, auto
from multiprocessing.context import BaseContext from multiprocessing.context import BaseContext
from signal import SIGINT from signal import SIGINT
from threading import Thread
from time import sleep
from typing import Any, Dict, Set from typing import Any, Dict, Set
from sanic.log import Colors, logger from sanic.log import Colors, logger
from sanic.worker.constants import ProcessState, RestartOrder
def get_now(): def get_now():
@@ -14,15 +16,8 @@ def get_now():
return now return now
class ProcessState(IntEnum):
IDLE = auto()
STARTED = auto()
ACKED = auto()
JOINED = auto()
TERMINATED = auto()
class WorkerProcess: class WorkerProcess:
THRESHOLD = 300 # == 30 seconds
SERVER_LABEL = "Server" SERVER_LABEL = "Server"
def __init__(self, factory, name, target, kwargs, worker_state): def __init__(self, factory, name, target, kwargs, worker_state):
@@ -54,8 +49,9 @@ class WorkerProcess:
f"{Colors.SANIC}%s{Colors.END}", f"{Colors.SANIC}%s{Colors.END}",
self.name, self.name,
) )
self.set_state(ProcessState.STARTING)
self._current_process.start()
self.set_state(ProcessState.STARTED) self.set_state(ProcessState.STARTED)
self._process.start()
if not self.worker_state[self.name].get("starts"): if not self.worker_state[self.name].get("starts"):
self.worker_state[self.name] = { self.worker_state[self.name] = {
**self.worker_state[self.name], **self.worker_state[self.name],
@@ -67,7 +63,7 @@ class WorkerProcess:
def join(self): def join(self):
self.set_state(ProcessState.JOINED) self.set_state(ProcessState.JOINED)
self._process.join() self._current_process.join()
def terminate(self): def terminate(self):
if self.state is not ProcessState.TERMINATED: if self.state is not ProcessState.TERMINATED:
@@ -80,21 +76,23 @@ class WorkerProcess:
) )
self.set_state(ProcessState.TERMINATED, force=True) self.set_state(ProcessState.TERMINATED, force=True)
try: try:
# self._process.terminate()
os.kill(self.pid, SIGINT) os.kill(self.pid, SIGINT)
del self.worker_state[self.name] del self.worker_state[self.name]
except (KeyError, AttributeError, ProcessLookupError): except (KeyError, AttributeError, ProcessLookupError):
... ...
def restart(self, **kwargs): def restart(self, restart_order=RestartOrder.SHUTDOWN_FIRST, **kwargs):
logger.debug( logger.debug(
f"{Colors.BLUE}Restarting a process: {Colors.BOLD}{Colors.SANIC}" f"{Colors.BLUE}Restarting a process: {Colors.BOLD}{Colors.SANIC}"
f"%s {Colors.BLUE}[%s]{Colors.END}", f"%s {Colors.BLUE}[%s]{Colors.END}",
self.name, self.name,
self.pid, self.pid,
) )
self._process.terminate() self.set_state(ProcessState.RESTARTING, force=True)
self.set_state(ProcessState.IDLE, force=True) if restart_order is RestartOrder.SHUTDOWN_FIRST:
self._terminate_now()
else:
self._old_process = self._current_process
self.kwargs.update( self.kwargs.update(
{"config": {k.upper(): v for k, v in kwargs.items()}} {"config": {k.upper(): v for k, v in kwargs.items()}}
) )
@@ -104,6 +102,9 @@ class WorkerProcess:
except AttributeError: except AttributeError:
raise RuntimeError("Restart failed") raise RuntimeError("Restart failed")
if restart_order is RestartOrder.STARTUP_FIRST:
self._terminate_soon()
self.worker_state[self.name] = { self.worker_state[self.name] = {
**self.worker_state[self.name], **self.worker_state[self.name],
"pid": self.pid, "pid": self.pid,
@@ -113,14 +114,14 @@ class WorkerProcess:
def is_alive(self): def is_alive(self):
try: try:
return self._process.is_alive() return self._current_process.is_alive()
except AssertionError: except AssertionError:
return False return False
def spawn(self): def spawn(self):
if self.state is not ProcessState.IDLE: if self.state not in (ProcessState.IDLE, ProcessState.RESTARTING):
raise Exception("Cannot spawn a worker process until it is idle.") raise Exception("Cannot spawn a worker process until it is idle.")
self._process = self.factory( self._current_process = self.factory(
name=self.name, name=self.name,
target=self.target, target=self.target,
kwargs=self.kwargs, kwargs=self.kwargs,
@@ -129,10 +130,61 @@ class WorkerProcess:
@property @property
def pid(self): def pid(self):
return self._process.pid 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: class Worker:
WORKER_PREFIX = "Sanic-"
def __init__( def __init__(
self, self,
ident: str, ident: str,
@@ -151,8 +203,12 @@ class Worker:
def create_process(self) -> WorkerProcess: def create_process(self) -> WorkerProcess:
process = WorkerProcess( process = WorkerProcess(
factory=self.context.Process, # Need to ignore this typing error - The problem is the
name=f"Sanic-{self.ident}-{len(self.processes)}", # 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, target=self.serve,
kwargs={**self.server_settings}, kwargs={**self.server_settings},
worker_state=self.worker_state, worker_state=self.worker_state,

View File

@@ -9,6 +9,7 @@ from multiprocessing.connection import Connection
from pathlib import Path from pathlib import Path
from signal import SIGINT, SIGTERM from signal import SIGINT, SIGTERM
from signal import signal as signal_func from signal import signal as signal_func
from time import sleep
from typing import Dict, Set from typing import Dict, Set
from sanic.server.events import trigger_events from sanic.server.events import trigger_events
@@ -16,6 +17,8 @@ from sanic.worker.loader import AppLoader
class Reloader: class Reloader:
INTERVAL = 1.0 # seconds
def __init__( def __init__(
self, self,
publisher: Connection, publisher: Connection,
@@ -24,7 +27,7 @@ class Reloader:
app_loader: AppLoader, app_loader: AppLoader,
): ):
self._publisher = publisher self._publisher = publisher
self.interval = interval self.interval = interval or self.INTERVAL
self.reload_dirs = reload_dirs self.reload_dirs = reload_dirs
self.run = True self.run = True
self.app_loader = app_loader self.app_loader = app_loader
@@ -62,6 +65,7 @@ class Reloader:
self.reload(",".join(changed) if changed else "unknown") self.reload(",".join(changed) if changed else "unknown")
if after_trigger: if after_trigger:
trigger_events(after_trigger, loop, app) trigger_events(after_trigger, loop, app)
sleep(self.interval)
else: else:
if reloader_stop: if reloader_stop:
trigger_events(reloader_stop, loop, app) trigger_events(reloader_stop, loop, app)

View File

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

View File

@@ -6,8 +6,6 @@ import os
import re import re
import sys import sys
from distutils.util import strtobool
from setuptools import find_packages, setup from setuptools import find_packages, setup
from setuptools.command.test import test as TestCommand 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) 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: with open_local(["sanic", "__version__.py"], encoding="latin1") as fp:
try: try:
@@ -73,6 +90,7 @@ setup_kwargs = {
"Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
], ],
"entry_points": {"console_scripts": ["sanic = sanic.__main__:main"]}, "entry_points": {"console_scripts": ["sanic = sanic.__main__:main"]},
} }
@@ -81,7 +99,7 @@ env_dependency = (
'; sys_platform != "win32" ' 'and implementation_name == "cpython"' '; sys_platform != "win32" ' 'and implementation_name == "cpython"'
) )
ujson = "ujson>=1.35" + env_dependency 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 types_ujson = "types-ujson" + env_dependency
requirements = [ requirements = [
"sanic-routing>=22.8.0", "sanic-routing>=22.8.0",
@@ -94,8 +112,8 @@ requirements = [
] ]
tests_require = [ tests_require = [
"sanic-testing>=22.9.0b1", "sanic-testing>=22.9.0",
"pytest", "pytest==7.1.*",
"coverage", "coverage",
"beautifulsoup4", "beautifulsoup4",
"pytest-sanic", "pytest-sanic",
@@ -131,13 +149,13 @@ dev_require = tests_require + [
all_require = list(set(dev_require + docs_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") print("Installing without uJSON")
requirements.remove(ujson) requirements.remove(ujson)
tests_require.remove(types_ujson) tests_require.remove(types_ujson)
# 'nt' means windows OS # '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") print("Installing without uvLoop")
requirements.remove(uvloop) requirements.remove(uvloop)

View File

@@ -8,8 +8,8 @@ import uuid
from contextlib import suppress from contextlib import suppress
from logging import LogRecord from logging import LogRecord
from typing import List, Tuple from typing import Any, Dict, List, Tuple
from unittest.mock import MagicMock from unittest.mock import MagicMock, Mock, patch
import pytest import pytest
@@ -54,7 +54,7 @@ TYPE_TO_GENERATOR_MAP = {
"uuid": lambda: str(uuid.uuid1()), "uuid": lambda: str(uuid.uuid1()),
} }
CACHE = {} CACHE: Dict[str, Any] = {}
class RouteStringGenerator: class RouteStringGenerator:
@@ -147,6 +147,7 @@ def app(request):
for target, method_name in TouchUp._registry: for target, method_name in TouchUp._registry:
CACHE[method_name] = getattr(target, method_name) CACHE[method_name] = getattr(target, method_name)
app = Sanic(slugify.sub("-", request.node.name)) app = Sanic(slugify.sub("-", request.node.name))
yield app yield app
for target, method_name in TouchUp._registry: for target, method_name in TouchUp._registry:
setattr(target, method_name, CACHE[method_name]) setattr(target, method_name, CACHE[method_name])
@@ -220,3 +221,14 @@ def sanic_ext(ext_instance): # noqa
yield sanic_ext yield sanic_ext
with suppress(KeyError): with suppress(KeyError):
del sys.modules["sanic_ext"] 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

@@ -15,9 +15,10 @@ from sanic import Sanic
from sanic.compat import OS_IS_WINDOWS from sanic.compat import OS_IS_WINDOWS
from sanic.config import Config from sanic.config import Config
from sanic.exceptions import SanicException from sanic.exceptions import SanicException
from sanic.helpers import _default from sanic.helpers import Default
from sanic.log import LOGGING_CONFIG_DEFAULTS from sanic.log import LOGGING_CONFIG_DEFAULTS
from sanic.response import text from sanic.response import text
from sanic.router import Route
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -152,11 +153,13 @@ def test_app_route_raise_value_error(app: Sanic):
def test_app_handle_request_handler_is_none(app: Sanic, monkeypatch): def test_app_handle_request_handler_is_none(app: Sanic, monkeypatch):
mock = Mock() app.config.TOUCHUP = False
mock.handler = None route = Mock(spec=Route)
route.extra.request_middleware = []
route.extra.response_middleware = []
def mockreturn(*args, **kwargs): def mockreturn(*args, **kwargs):
return mock, None, {} return route, None, {}
monkeypatch.setattr(app.router, "get", mockreturn) monkeypatch.setattr(app.router, "get", mockreturn)
@@ -344,7 +347,15 @@ def test_app_registry_retrieval_from_multiple():
def test_get_app_does_not_exist(): def test_get_app_does_not_exist():
with pytest.raises( 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") Sanic.get_app("does-not-exist")
@@ -488,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]) 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 assert counter[(logging.WARNING, message)] == modified
@@ -522,7 +535,7 @@ def test_multiple_uvloop_configs_display_warning(caplog):
counter = Counter([(r[1], r[2]) for r in caplog.record_tuples]) 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: Sanic): def test_cannot_run_fast_and_workers(app: Sanic):

View File

@@ -8,7 +8,7 @@ import uvicorn
from sanic import Sanic from sanic import Sanic
from sanic.application.state import Mode 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.exceptions import BadRequest, Forbidden, ServiceUnavailable
from sanic.request import Request from sanic.request import Request
from sanic.response import json, text from sanic.response import json, text
@@ -16,6 +16,12 @@ from sanic.server.websockets.connection import WebSocketConnection
from sanic.signals import RESERVED_NAMESPACES from sanic.signals import RESERVED_NAMESPACES
try:
from unittest.mock import AsyncMock
except ImportError:
from tests.asyncmock import AsyncMock # type: ignore
@pytest.fixture @pytest.fixture
def message_stack(): def message_stack():
return deque() return deque()
@@ -558,3 +564,39 @@ async def test_asgi_serve_location(app):
_, response = await app.asgi_client.get("/") _, response = await app.asgi_client.get("/")
assert response.text == "http://<ASGI>" 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

@@ -323,3 +323,20 @@ def test_bp_group_properties():
assert "api/v1/grouped/bp2/" in routes assert "api/v1/grouped/bp2/" in routes
assert "api/v1/primary/grouped/bp1" in routes assert "api/v1/primary/grouped/bp1" in routes
assert "api/v1/primary/grouped/bp2" 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

@@ -4,6 +4,7 @@ import sys
from pathlib import Path from pathlib import Path
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
from unittest.mock import patch
import pytest import pytest
@@ -11,6 +12,7 @@ from sanic_routing import __version__ as __routing_version__
from sanic import __version__ from sanic import __version__
from sanic.__main__ import main from sanic.__main__ import main
from sanic.cli.inspector_client import InspectorClient
@pytest.fixture(scope="module", autouse=True) @pytest.fixture(scope="module", autouse=True)
@@ -117,7 +119,13 @@ def test_error_with_path_as_instance_without_simple_arg(caplog):
), ),
) )
def test_tls_options(cmd: Tuple[str, ...], caplog): def test_tls_options(cmd: Tuple[str, ...], caplog):
command = ["fake.server.app", *cmd, "--port=9999", "--debug"] command = [
"fake.server.app",
*cmd,
"--port=9999",
"--debug",
"--single-process",
]
lines = capture(command, caplog) lines = capture(command, caplog)
assert "Goin' Fast @ https://127.0.0.1:9999" in lines assert "Goin' Fast @ https://127.0.0.1:9999" in lines
@@ -286,3 +294,50 @@ def test_noisy_exceptions(cmd: str, expected: bool, caplog):
info = read_app_info(lines) info = read_app_info(lines)
assert info["noisy_exceptions"] is expected assert info["noisy_exceptions"] is expected
def test_inspector_inspect(urlopen, caplog, capsys):
urlopen.read.return_value = json.dumps(
{
"result": {
"info": {
"packages": ["foo"],
},
"extra": {
"more": "data",
},
"workers": {"Worker-Name": {"some": "state"}},
}
}
).encode()
with patch("sys.argv", ["sanic", "inspect"]):
capture(["inspect"], caplog)
captured = capsys.readouterr()
assert "Inspecting @ http://localhost:6457" in captured.out
assert "Worker-Name" in captured.out
assert captured.err == ""
@pytest.mark.parametrize(
"command,params",
(
(["reload"], {"zero_downtime": False}),
(["reload", "--zero-downtime"], {"zero_downtime": True}),
(["shutdown"], {}),
(["scale", "9"], {"replicas": 9}),
(["foo", "--bar=something"], {"bar": "something"}),
(["foo", "--bar"], {"bar": True}),
(["foo", "--no-bar"], {"bar": False}),
(["foo", "positional"], {"args": ["positional"]}),
(
["foo", "positional", "--bar=something"],
{"args": ["positional"], "bar": "something"},
),
),
)
def test_inspector_command(command, params):
with patch.object(InspectorClient, "request") as client:
with patch("sys.argv", ["sanic", "inspect", *command]):
main()
client.assert_called_once_with(command[0], **params)

View File

@@ -39,7 +39,7 @@ def test_logo_true(app, caplog):
with patch("sys.stdout.isatty") as isatty: with patch("sys.stdout.isatty") as isatty:
isatty.return_value = True isatty.return_value = True
with caplog.at_level(logging.DEBUG): with caplog.at_level(logging.DEBUG):
app.make_coffee() app.make_coffee(single_process=True)
# Only in the regular logo # Only in the regular logo
assert " ▄███ █████ ██ " not in caplog.text assert " ▄███ █████ ██ " not in caplog.text

View File

@@ -125,14 +125,9 @@ def test_env_w_custom_converter():
def test_env_lowercase(): def test_env_lowercase():
with pytest.warns(None) as record: environ["SANIC_test_answer"] = "42"
environ["SANIC_test_answer"] = "42" app = Sanic(name="Test")
app = Sanic(name="Test") assert "test_answer" not in app.config
assert app.config.test_answer == 42
assert str(record[0].message) == (
"[DEPRECATION v22.9] Lowercase environment variables will not be "
"loaded into Sanic config beginning in v22.9."
)
del environ["SANIC_test_answer"] del environ["SANIC_test_answer"]

View File

@@ -2,7 +2,6 @@ import asyncio
import sys import sys
from threading import Event from threading import Event
from unittest.mock import Mock
import pytest import pytest
@@ -75,7 +74,7 @@ def test_create_named_task(app):
app.stop() app.stop()
app.run() app.run(single_process=True)
def test_named_task_called(app): def test_named_task_called(app):

View File

@@ -97,15 +97,15 @@ def test_auto_fallback_with_content_type(app):
def test_route_error_format_set_on_auto(app): def test_route_error_format_set_on_auto(app):
@app.get("/text") @app.get("/text")
def text_response(request): def text_response(request):
return text(request.route.ctx.error_format) return text(request.route.extra.error_format)
@app.get("/json") @app.get("/json")
def json_response(request): def json_response(request):
return json({"format": request.route.ctx.error_format}) return json({"format": request.route.extra.error_format})
@app.get("/html") @app.get("/html")
def html_response(request): def html_response(request):
return html(request.route.ctx.error_format) return html(request.route.extra.error_format)
_, response = app.test_client.get("/text") _, response = app.test_client.get("/text")
assert response.text == "text" assert response.text == "text"

View File

@@ -10,7 +10,7 @@ import pytest
import sanic import sanic
from sanic import Sanic from sanic import Sanic
from sanic.log import LOGGING_CONFIG_DEFAULTS, logger from sanic.log import LOGGING_CONFIG_DEFAULTS, Colors, logger
from sanic.response import text from sanic.response import text
@@ -250,3 +250,14 @@ def test_verbosity(app, caplog, app_verbosity, log_verbosity, exists):
if app_verbosity == 0: if app_verbosity == 0:
assert ("sanic.root", logging.INFO, "DEFAULT") in caplog.record_tuples assert ("sanic.root", logging.INFO, "DEFAULT") in caplog.record_tuples
def test_colors_enum_format():
assert f"{Colors.END}" == Colors.END.value
assert f"{Colors.BOLD}" == Colors.BOLD.value
assert f"{Colors.BLUE}" == Colors.BLUE.value
assert f"{Colors.GREEN}" == Colors.GREEN.value
assert f"{Colors.PURPLE}" == Colors.PURPLE.value
assert f"{Colors.RED}" == Colors.RED.value
assert f"{Colors.SANIC}" == Colors.SANIC.value
assert f"{Colors.YELLOW}" == Colors.YELLOW.value

View File

@@ -1,23 +1,13 @@
import logging import logging
from asyncio import CancelledError from asyncio import CancelledError, sleep
from itertools import count from itertools import count
from unittest.mock import Mock
import pytest
from sanic.exceptions import NotFound from sanic.exceptions import NotFound
from sanic.middleware import Middleware
from sanic.request import Request from sanic.request import Request
from sanic.response import HTTPResponse, json, text from sanic.response import HTTPResponse, json, text
@pytest.fixture(autouse=True)
def reset_middleware():
yield
Middleware.reset_count()
# ------------------------------------------------------------ # # ------------------------------------------------------------ #
# GET # GET
# ------------------------------------------------------------ # # ------------------------------------------------------------ #
@@ -193,7 +183,7 @@ def test_middleware_response_raise_exception(app, caplog):
with caplog.at_level(logging.ERROR): with caplog.at_level(logging.ERROR):
reqrequest, response = app.test_client.get("/fail") reqrequest, response = app.test_client.get("/fail")
assert response.status == 500 assert response.status == 404
# 404 errors are not logged # 404 errors are not logged
assert ( assert (
"sanic.error", "sanic.error",
@@ -333,10 +323,27 @@ def test_middleware_return_response(app):
assert request_middleware_run_count == 1 assert request_middleware_run_count == 1
def test_middleware_object(): def test_middleware_run_on_timeout(app):
mock = Mock() app.config.RESPONSE_TIMEOUT = 0.1
middleware = Middleware(mock) response_middleware_run_count = 0
middleware(1, 2, 3, answer=42) request_middleware_run_count = 0
mock.assert_called_once_with(1, 2, 3, answer=42) @app.on_response
assert middleware.order == (0, 0) def response(_, response):
nonlocal response_middleware_run_count
response_middleware_run_count += 1
@app.on_request
def request(_):
nonlocal request_middleware_run_count
request_middleware_run_count += 1
@app.get("/")
async def handler(request):
resp1 = await request.respond()
await sleep(1)
return resp1
app.test_client.get("/")
assert request_middleware_run_count == 1
assert response_middleware_run_count == 1

View File

@@ -3,6 +3,7 @@ from functools import partial
import pytest import pytest
from sanic import Sanic from sanic import Sanic
from sanic.middleware import Middleware, MiddlewareLocation
from sanic.response import json from sanic.response import json
@@ -33,6 +34,92 @@ PRIORITY_TEST_CASES = (
) )
@pytest.fixture(autouse=True)
def reset_middleware():
yield
Middleware.reset_count()
def test_add_register_priority(app: Sanic):
def foo(*_):
...
app.register_middleware(foo, priority=999)
assert len(app.request_middleware) == 1
assert len(app.response_middleware) == 0
assert app.request_middleware[0].priority == 999 # type: ignore
app.register_middleware(foo, attach_to="response", priority=999)
assert len(app.request_middleware) == 1
assert len(app.response_middleware) == 1
assert app.response_middleware[0].priority == 999 # type: ignore
def test_add_register_named_priority(app: Sanic):
def foo(*_):
...
app.register_named_middleware(foo, route_names=["foo"], priority=999)
assert len(app.named_request_middleware) == 1
assert len(app.named_response_middleware) == 0
assert app.named_request_middleware["foo"][0].priority == 999 # type: ignore
app.register_named_middleware(
foo, attach_to="response", route_names=["foo"], priority=999
)
assert len(app.named_request_middleware) == 1
assert len(app.named_response_middleware) == 1
assert app.named_response_middleware["foo"][0].priority == 999 # type: ignore
def test_add_decorator_priority(app: Sanic):
def foo(*_):
...
app.middleware(foo, priority=999)
assert len(app.request_middleware) == 1
assert len(app.response_middleware) == 0
assert app.request_middleware[0].priority == 999 # type: ignore
app.middleware(foo, attach_to="response", priority=999)
assert len(app.request_middleware) == 1
assert len(app.response_middleware) == 1
assert app.response_middleware[0].priority == 999 # type: ignore
def test_add_convenience_priority(app: Sanic):
def foo(*_):
...
app.on_request(foo, priority=999)
assert len(app.request_middleware) == 1
assert len(app.response_middleware) == 0
assert app.request_middleware[0].priority == 999 # type: ignore
app.on_response(foo, priority=999)
assert len(app.request_middleware) == 1
assert len(app.response_middleware) == 1
assert app.response_middleware[0].priority == 999 # type: ignore
def test_add_conflicting_priority(app: Sanic):
def foo(*_):
...
middleware = Middleware(foo, MiddlewareLocation.REQUEST, priority=998)
app.register_middleware(middleware=middleware, priority=999)
assert app.request_middleware[0].priority == 999 # type: ignore
middleware.priority == 998
def test_add_conflicting_priority_named(app: Sanic):
def foo(*_):
...
middleware = Middleware(foo, MiddlewareLocation.REQUEST, priority=998)
app.register_named_middleware(
middleware=middleware, route_names=["foo"], priority=999
)
assert app.named_request_middleware["foo"][0].priority == 999 # type: ignore
middleware.priority == 998
@pytest.mark.parametrize( @pytest.mark.parametrize(
"expected,priorities", "expected,priorities",
PRIORITY_TEST_CASES, PRIORITY_TEST_CASES,

View File

@@ -3,6 +3,7 @@ import multiprocessing
import pickle import pickle
import random import random
import signal import signal
import sys
from asyncio import sleep from asyncio import sleep
@@ -11,6 +12,7 @@ import pytest
from sanic_testing.testing import HOST, PORT from sanic_testing.testing import HOST, PORT
from sanic import Blueprint, text from sanic import Blueprint, text
from sanic.compat import use_context
from sanic.log import logger from sanic.log import logger
from sanic.server.socket import configure_socket from sanic.server.socket import configure_socket
@@ -20,6 +22,10 @@ from sanic.server.socket import configure_socket
reason="SIGALRM is not implemented for this platform, we have to come " reason="SIGALRM is not implemented for this platform, we have to come "
"up with another timeout strategy to test these", "up with another timeout strategy to test these",
) )
@pytest.mark.skipif(
sys.platform not in ("linux", "darwin"),
reason="This test requires fork context",
)
def test_multiprocessing(app): def test_multiprocessing(app):
"""Tests that the number of children we produce is correct""" """Tests that the number of children we produce is correct"""
# Selects a number at random so we can spot check # Selects a number at random so we can spot check
@@ -37,7 +43,8 @@ def test_multiprocessing(app):
signal.signal(signal.SIGALRM, stop_on_alarm) signal.signal(signal.SIGALRM, stop_on_alarm)
signal.alarm(2) signal.alarm(2)
app.run(HOST, 4120, workers=num_workers, debug=True) with use_context("fork"):
app.run(HOST, 4120, workers=num_workers, debug=True)
assert len(process_list) == num_workers + 1 assert len(process_list) == num_workers + 1
@@ -136,6 +143,10 @@ def test_multiprocessing_legacy_unix(app):
not hasattr(signal, "SIGALRM"), not hasattr(signal, "SIGALRM"),
reason="SIGALRM is not implemented for this platform", reason="SIGALRM is not implemented for this platform",
) )
@pytest.mark.skipif(
sys.platform not in ("linux", "darwin"),
reason="This test requires fork context",
)
def test_multiprocessing_with_blueprint(app): def test_multiprocessing_with_blueprint(app):
# Selects a number at random so we can spot check # Selects a number at random so we can spot check
num_workers = random.choice(range(2, multiprocessing.cpu_count() * 2 + 1)) num_workers = random.choice(range(2, multiprocessing.cpu_count() * 2 + 1))
@@ -155,7 +166,8 @@ def test_multiprocessing_with_blueprint(app):
bp = Blueprint("test_text") bp = Blueprint("test_text")
app.blueprint(bp) app.blueprint(bp)
app.run(HOST, 4121, workers=num_workers, debug=True) with use_context("fork"):
app.run(HOST, 4121, workers=num_workers, debug=True)
assert len(process_list) == num_workers + 1 assert len(process_list) == num_workers + 1
@@ -213,6 +225,10 @@ def test_pickle_app_with_static(app, protocol):
up_p_app.run(single_process=True) up_p_app.run(single_process=True)
@pytest.mark.skipif(
sys.platform not in ("linux", "darwin"),
reason="This test requires fork context",
)
def test_main_process_event(app, caplog): def test_main_process_event(app, caplog):
# Selects a number at random so we can spot check # Selects a number at random so we can spot check
num_workers = random.choice(range(2, multiprocessing.cpu_count() * 2 + 1)) num_workers = random.choice(range(2, multiprocessing.cpu_count() * 2 + 1))
@@ -235,8 +251,9 @@ def test_main_process_event(app, caplog):
def main_process_stop2(app, loop): def main_process_stop2(app, loop):
logger.info("main_process_stop") logger.info("main_process_stop")
with caplog.at_level(logging.INFO): with use_context("fork"):
app.run(HOST, PORT, workers=num_workers) with caplog.at_level(logging.INFO):
app.run(HOST, PORT, workers=num_workers)
assert ( assert (
caplog.record_tuples.count(("sanic.root", 20, "main_process_start")) caplog.record_tuples.count(("sanic.root", 20, "main_process_start"))

View File

@@ -1,8 +1,5 @@
import asyncio import asyncio
from contextlib import closing
from socket import socket
import pytest import pytest
from sanic import Sanic from sanic import Sanic
@@ -623,6 +620,4 @@ def test_streaming_echo():
res = await read_chunk() res = await read_chunk()
assert res == None assert res == None
# Use random port for tests app.run(access_log=False, single_process=True)
with closing(socket()) as sock:
app.run(access_log=False)

View File

@@ -1293,6 +1293,24 @@ async def test_request_string_representation_asgi(app):
"------sanic--\r\n", "------sanic--\r\n",
"filename_\u00A0_test", "filename_\u00A0_test",
), ),
# Umlaut using NFC normalization (Windows, Linux, Android)
(
"------sanic\r\n"
'content-disposition: form-data; filename*="utf-8\'\'filename_%C3%A4_test"; name="test"\r\n'
"\r\n"
"OK\r\n"
"------sanic--\r\n",
"filename_\u00E4_test",
),
# Umlaut using NFD normalization (MacOS client)
(
"------sanic\r\n"
'content-disposition: form-data; filename*="utf-8\'\'filename_a%CC%88_test"; name="test"\r\n'
"\r\n"
"OK\r\n"
"------sanic--\r\n",
"filename_\u00E4_test", # Sanic should normalize to NFC
),
], ],
) )
def test_request_multipart_files(app, payload, filename): def test_request_multipart_files(app, payload, filename):

View File

@@ -4,8 +4,8 @@ import os
import time import time
from collections import namedtuple from collections import namedtuple
from datetime import datetime from datetime import datetime, timedelta
from email.utils import formatdate from email.utils import formatdate, parsedate_to_datetime
from logging import ERROR, LogRecord from logging import ERROR, LogRecord
from mimetypes import guess_type from mimetypes import guess_type
from pathlib import Path from pathlib import Path
@@ -665,13 +665,11 @@ def test_multiple_responses(
with caplog.at_level(ERROR): with caplog.at_level(ERROR):
_, response = app.test_client.get("/4") _, response = app.test_client.get("/4")
print(response.json)
assert response.status == 200 assert response.status == 200
assert "foo" not in response.text assert "foo" not in response.text
assert "one" in response.headers assert "one" in response.headers
assert response.headers["one"] == "one" assert response.headers["one"] == "one"
print(response.headers)
assert message_in_records(caplog.records, error_msg2) assert message_in_records(caplog.records, error_msg2)
with caplog.at_level(ERROR): with caplog.at_level(ERROR):
@@ -841,10 +839,10 @@ def test_file_validate(app: Sanic, static_file_directory: str):
time.sleep(1) time.sleep(1)
with open(file_path, "a") as f: with open(file_path, "a") as f:
f.write("bar\n") f.write("bar\n")
_, response = app.test_client.get( _, response = app.test_client.get(
"/validate", headers={"If-Modified-Since": last_modified} "/validate", headers={"If-Modified-Since": last_modified}
) )
assert response.status == 200 assert response.status == 200
assert response.body == b"foo\nbar\n" assert response.body == b"foo\nbar\n"
@@ -921,3 +919,28 @@ def test_file_validating_304_response(
) )
assert response.status == 304 assert response.status == 304
assert response.body == b"" assert response.body == b""
@pytest.mark.parametrize(
"file_name", ["test.file", "decode me.txt", "python.png"]
)
def test_file_validating_304_response(
app: Sanic, file_name: str, static_file_directory: str
):
app.static("static", Path(static_file_directory) / file_name)
_, response = app.test_client.get("/static")
assert response.status == 200
assert response.body == get_file_content(static_file_directory, file_name)
last_modified = parsedate_to_datetime(response.headers["Last-Modified"])
last_modified += timedelta(seconds=1)
_, response = app.test_client.get(
"/static",
headers={
"if-modified-since": formatdate(
last_modified.timestamp(), usegmt=True
)
},
)
assert response.status == 304
assert response.body == b""

224
tests/test_response_json.py Normal file
View File

@@ -0,0 +1,224 @@
import json
from functools import partial
from unittest.mock import Mock
import pytest
from sanic import Request, Sanic
from sanic.exceptions import SanicException
from sanic.response import json as json_response
from sanic.response.types import JSONResponse
JSON_BODY = {"ok": True}
json_dumps = partial(json.dumps, separators=(",", ":"))
@pytest.fixture
def json_app(app: Sanic):
@app.get("/json")
async def handle(request: Request):
return json_response(JSON_BODY)
return app
def test_body_can_be_retrieved(json_app: Sanic):
_, resp = json_app.test_client.get("/json")
assert resp.body == json_dumps(JSON_BODY).encode()
def test_body_can_be_set(json_app: Sanic):
new_body = b'{"hello":"world"}'
@json_app.on_response
def set_body(request: Request, response: JSONResponse):
response.body = new_body
_, resp = json_app.test_client.get("/json")
assert resp.body == new_body
def test_raw_body_can_be_retrieved(json_app: Sanic):
@json_app.on_response
def check_body(request: Request, response: JSONResponse):
assert response.raw_body == JSON_BODY
json_app.test_client.get("/json")
def test_raw_body_can_be_set(json_app: Sanic):
new_body = {"hello": "world"}
@json_app.on_response
def set_body(request: Request, response: JSONResponse):
response.raw_body = new_body
assert response.raw_body == new_body
assert response.body == json_dumps(new_body).encode()
json_app.test_client.get("/json")
def test_raw_body_cant_be_retrieved_after_body_set(json_app: Sanic):
new_body = b'{"hello":"world"}'
@json_app.on_response
def check_raw_body(request: Request, response: JSONResponse):
response.body = new_body
with pytest.raises(SanicException):
response.raw_body
json_app.test_client.get("/json")
def test_raw_body_can_be_reset_after_body_set(json_app: Sanic):
new_body = b'{"hello":"world"}'
new_new_body = {"lorem": "ipsum"}
@json_app.on_response
def set_bodies(request: Request, response: JSONResponse):
response.body = new_body
response.raw_body = new_new_body
_, resp = json_app.test_client.get("/json")
assert resp.body == json_dumps(new_new_body).encode()
def test_set_body_method(json_app: Sanic):
new_body = {"lorem": "ipsum"}
@json_app.on_response
def set_body(request: Request, response: JSONResponse):
response.set_body(new_body)
_, resp = json_app.test_client.get("/json")
assert resp.body == json_dumps(new_body).encode()
def test_set_body_method_after_body_set(json_app: Sanic):
new_body = b'{"hello":"world"}'
new_new_body = {"lorem": "ipsum"}
@json_app.on_response
def set_body(request: Request, response: JSONResponse):
response.body = new_body
response.set_body(new_new_body)
_, resp = json_app.test_client.get("/json")
assert resp.body == json_dumps(new_new_body).encode()
def test_custom_dumps_and_kwargs(json_app: Sanic):
custom_dumps = Mock(return_value="custom")
@json_app.get("/json-custom")
async def handle_custom(request: Request):
return json_response(JSON_BODY, dumps=custom_dumps, prry="platypus")
_, resp = json_app.test_client.get("/json-custom")
assert resp.body == "custom".encode()
custom_dumps.assert_called_once_with(JSON_BODY, prry="platypus")
def test_override_dumps_and_kwargs(json_app: Sanic):
custom_dumps_1 = Mock(return_value="custom1")
custom_dumps_2 = Mock(return_value="custom2")
@json_app.get("/json-custom")
async def handle_custom(request: Request):
return json_response(JSON_BODY, dumps=custom_dumps_1, prry="platypus")
@json_app.on_response
def set_body(request: Request, response: JSONResponse):
response.set_body(JSON_BODY, dumps=custom_dumps_2, platypus="prry")
_, resp = json_app.test_client.get("/json-custom")
assert resp.body == "custom2".encode()
custom_dumps_1.assert_called_once_with(JSON_BODY, prry="platypus")
custom_dumps_2.assert_called_once_with(JSON_BODY, platypus="prry")
def test_append(json_app: Sanic):
@json_app.get("/json-append")
async def handler_append(request: Request):
return json_response(["a", "b"], status=200)
@json_app.on_response
def do_append(request: Request, response: JSONResponse):
response.append("c")
_, resp = json_app.test_client.get("/json-append")
assert resp.body == json_dumps(["a", "b", "c"]).encode()
def test_extend(json_app: Sanic):
@json_app.get("/json-extend")
async def handler_extend(request: Request):
return json_response(["a", "b"], status=200)
@json_app.on_response
def do_extend(request: Request, response: JSONResponse):
response.extend(["c", "d"])
_, resp = json_app.test_client.get("/json-extend")
assert resp.body == json_dumps(["a", "b", "c", "d"]).encode()
def test_update(json_app: Sanic):
@json_app.get("/json-update")
async def handler_update(request: Request):
return json_response({"a": "b"}, status=200)
@json_app.on_response
def do_update(request: Request, response: JSONResponse):
response.update({"c": "d"}, e="f")
_, resp = json_app.test_client.get("/json-update")
assert resp.body == json_dumps({"a": "b", "c": "d", "e": "f"}).encode()
def test_pop_dict(json_app: Sanic):
@json_app.get("/json-pop")
async def handler_pop(request: Request):
return json_response({"a": "b", "c": "d"}, status=200)
@json_app.on_response
def do_pop(request: Request, response: JSONResponse):
val = response.pop("c")
assert val == "d"
val_default = response.pop("e", "f")
assert val_default == "f"
_, resp = json_app.test_client.get("/json-pop")
assert resp.body == json_dumps({"a": "b"}).encode()
def test_pop_list(json_app: Sanic):
@json_app.get("/json-pop")
async def handler_pop(request: Request):
return json_response(["a", "b"], status=200)
@json_app.on_response
def do_pop(request: Request, response: JSONResponse):
val = response.pop(0)
assert val == "a"
with pytest.raises(
TypeError, match="pop doesn't accept a default argument for lists"
):
response.pop(21, "nah nah")
_, resp = json_app.test_client.get("/json-pop")
assert resp.body == json_dumps(["b"]).encode()
def test_json_response_class_sets_proper_content_type(json_app: Sanic):
@json_app.get("/json-class")
async def handler(request: Request):
return JSONResponse(JSON_BODY)
_, resp = json_app.test_client.get("/json-class")
assert resp.headers["content-type"] == "application/json"

View File

@@ -803,6 +803,21 @@ def test_static_add_route(app, strict_slashes):
assert response.text == "OK2" assert response.text == "OK2"
@pytest.mark.parametrize("unquote", [True, False, None])
def test_unquote_add_route(app, unquote):
async def handler1(_, foo):
return text(foo)
app.add_route(handler1, "/<foo>", unquote=unquote)
value = "" if unquote else r"%E5%95%8A"
_, response = app.test_client.get("/啊")
assert response.text == value
_, response = app.test_client.get(r"/%E5%95%8A")
assert response.text == value
def test_dynamic_add_route(app): def test_dynamic_add_route(app):
results = [] results = []

View File

@@ -7,7 +7,7 @@ import pytest
from sanic_routing.exceptions import NotFound from sanic_routing.exceptions import NotFound
from sanic import Blueprint from sanic import Blueprint, Sanic, empty
from sanic.exceptions import InvalidSignal, SanicException from sanic.exceptions import InvalidSignal, SanicException
@@ -20,6 +20,31 @@ def test_add_signal(app):
assert len(app.signal_router.routes) == 1 assert len(app.signal_router.routes) == 1
def test_add_signal_method_handler(app):
counter = 0
class TestSanic(Sanic):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_signal(
self.after_routing_signal_handler, "http.routing.after"
)
def after_routing_signal_handler(self, *args, **kwargs):
nonlocal counter
counter += 1
app = TestSanic("Test")
assert len(app.signal_router.routes) == 1
@app.route("/")
async def handler(_):
return empty()
app.test_client.get("/")
assert counter == 1
def test_add_signal_decorator(app): def test_add_signal_decorator(app):
@app.signal("foo.bar.baz") @app.signal("foo.bar.baz")
def sync_signal(*_): def sync_signal(*_):
@@ -289,10 +314,10 @@ async def test_dispatch_signal_triggers_event_on_bp(app):
waiter = bp.event("foo.bar.baz") waiter = bp.event("foo.bar.baz")
assert isawaitable(waiter) assert isawaitable(waiter)
fut = asyncio.ensure_future(do_wait()) fut = do_wait()
for signal in signal_group: for signal in signal_group:
signal.ctx.event.set() signal.ctx.event.set()
await fut await asyncio.gather(fut)
assert bp_counter == 1 assert bp_counter == 1

View File

@@ -503,9 +503,10 @@ def test_stack_trace_on_not_found(app, static_file_directory, caplog):
counter = Counter([(r[0], r[1]) for r in caplog.record_tuples]) counter = Counter([(r[0], r[1]) for r in caplog.record_tuples])
assert response.status == 404 assert response.status == 404
assert counter[("sanic.root", logging.INFO)] == 11 assert counter[("sanic.root", logging.INFO)] == 9
assert counter[("sanic.root", logging.ERROR)] == 0 assert counter[("sanic.root", logging.ERROR)] == 0
assert counter[("sanic.error", logging.ERROR)] == 0 assert counter[("sanic.error", logging.ERROR)] == 0
assert counter[("sanic.server", logging.INFO)] == 2
def test_no_stack_trace_on_not_found(app, static_file_directory, caplog): def test_no_stack_trace_on_not_found(app, static_file_directory, caplog):
@@ -521,9 +522,10 @@ def test_no_stack_trace_on_not_found(app, static_file_directory, caplog):
counter = Counter([(r[0], r[1]) for r in caplog.record_tuples]) counter = Counter([(r[0], r[1]) for r in caplog.record_tuples])
assert response.status == 404 assert response.status == 404
assert counter[("sanic.root", logging.INFO)] == 11 assert counter[("sanic.root", logging.INFO)] == 9
assert counter[("sanic.root", logging.ERROR)] == 0 assert counter[("sanic.root", logging.ERROR)] == 0
assert counter[("sanic.error", logging.ERROR)] == 0 assert counter[("sanic.error", logging.ERROR)] == 0
assert counter[("sanic.server", logging.INFO)] == 2
assert response.text == "No file: /static/non_existing_file.file" assert response.text == "No file: /static/non_existing_file.file"

View File

@@ -2,8 +2,10 @@ import logging
import os import os
import ssl import ssl
import subprocess import subprocess
import sys
from contextlib import contextmanager from contextlib import contextmanager
from multiprocessing import Event
from pathlib import Path from pathlib import Path
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -16,6 +18,7 @@ import sanic.http.tls.creators
from sanic import Sanic from sanic import Sanic
from sanic.application.constants import Mode from sanic.application.constants import Mode
from sanic.compat import use_context
from sanic.constants import LocalCertCreator from sanic.constants import LocalCertCreator
from sanic.exceptions import SanicException from sanic.exceptions import SanicException
from sanic.helpers import _default from sanic.helpers import _default
@@ -264,6 +267,7 @@ def test_cert_sni_list(app):
assert response.text == "sanic.example" assert response.text == "sanic.example"
@pytest.mark.xfail
def test_missing_sni(app): def test_missing_sni(app):
"""The sanic cert does not list 127.0.0.1 and httpx does not send """The sanic cert does not list 127.0.0.1 and httpx does not send
IP as SNI anyway.""" IP as SNI anyway."""
@@ -282,6 +286,7 @@ def test_missing_sni(app):
assert "Request and response object expected" in str(exc.value) assert "Request and response object expected" in str(exc.value)
@pytest.mark.xfail
def test_no_matching_cert(app): def test_no_matching_cert(app):
"""The sanic cert does not list 127.0.0.1 and httpx does not send """The sanic cert does not list 127.0.0.1 and httpx does not send
IP as SNI anyway.""" IP as SNI anyway."""
@@ -301,6 +306,7 @@ def test_no_matching_cert(app):
assert "Request and response object expected" in str(exc.value) assert "Request and response object expected" in str(exc.value)
@pytest.mark.xfail
def test_wildcards(app): def test_wildcards(app):
ssl_list = [None, localhost_dir, sanic_dir] ssl_list = [None, localhost_dir, sanic_dir]
@@ -422,7 +428,12 @@ def test_logger_vhosts(caplog):
app.stop() app.stop()
with caplog.at_level(logging.INFO): with caplog.at_level(logging.INFO):
app.run(host="127.0.0.1", port=42102, ssl=[localhost_dir, sanic_dir]) app.run(
host="127.0.0.1",
port=42102,
ssl=[localhost_dir, sanic_dir],
single_process=True,
)
logmsg = [ logmsg = [
m for s, l, m in caplog.record_tuples if m.startswith("Certificate") m for s, l, m in caplog.record_tuples if m.startswith("Certificate")
@@ -636,3 +647,34 @@ def test_sanic_ssl_context_create():
assert sanic_context is context assert sanic_context is context
assert isinstance(sanic_context, SanicSSLContext) assert isinstance(sanic_context, SanicSSLContext)
@pytest.mark.skipif(
sys.platform not in ("linux", "darwin"),
reason="This test requires fork context",
)
def test_ssl_in_multiprocess_mode(app: Sanic, caplog):
ssl_dict = {"cert": localhost_cert, "key": localhost_key}
event = Event()
@app.main_process_start
async def main_start(app: Sanic):
app.shared_ctx.event = event
@app.after_server_start
async def shutdown(app):
app.shared_ctx.event.set()
app.stop()
assert not event.is_set()
with use_context("fork"):
with caplog.at_level(logging.INFO):
app.run(ssl=ssl_dict)
assert event.is_set()
assert (
"sanic.root",
logging.INFO,
"Goin' Fast @ https://127.0.0.1:8000",
) in caplog.record_tuples

View File

@@ -1,8 +1,9 @@
# import asyncio # import asyncio
import logging import logging
import os import os
import sys
from asyncio import AbstractEventLoop from asyncio import AbstractEventLoop, sleep
from string import ascii_lowercase from string import ascii_lowercase
import httpcore import httpcore
@@ -12,6 +13,7 @@ import pytest
from pytest import LogCaptureFixture from pytest import LogCaptureFixture
from sanic import Sanic from sanic import Sanic
from sanic.compat import use_context
from sanic.request import Request from sanic.request import Request
from sanic.response import text from sanic.response import text
@@ -174,19 +176,27 @@ def handler(request: Request):
async def client(app: Sanic, loop: AbstractEventLoop): async def client(app: Sanic, loop: AbstractEventLoop):
try: try:
async with httpx.AsyncClient(uds=SOCKPATH) as client:
transport = httpx.AsyncHTTPTransport(uds=SOCKPATH)
async with httpx.AsyncClient(transport=transport) as client:
r = await client.get("http://myhost.invalid/") r = await client.get("http://myhost.invalid/")
assert r.status_code == 200 assert r.status_code == 200
assert r.text == os.path.abspath(SOCKPATH) assert r.text == os.path.abspath(SOCKPATH)
finally: finally:
await sleep(0.2)
app.stop() app.stop()
@pytest.mark.skipif(
sys.platform not in ("linux", "darwin"),
reason="This test requires fork context",
)
def test_unix_connection_multiple_workers(): def test_unix_connection_multiple_workers():
app_multi = Sanic(name="test") with use_context("fork"):
app_multi.get("/")(handler) app_multi = Sanic(name="test")
app_multi.listener("after_server_start")(client) app_multi.get("/")(handler)
app_multi.run(host="myhost.invalid", unix=SOCKPATH, workers=2) app_multi.listener("after_server_start")(client)
app_multi.run(host="myhost.invalid", unix=SOCKPATH, workers=2)
# @pytest.mark.xfail( # @pytest.mark.xfail(

56
tests/test_ws_handlers.py Normal file
View File

@@ -0,0 +1,56 @@
from typing import Any, Callable, Coroutine
import pytest
from websockets.client import WebSocketClientProtocol
from sanic import Request, Sanic, Websocket
MimicClientType = Callable[
[WebSocketClientProtocol], Coroutine[None, None, Any]
]
@pytest.fixture
def simple_ws_mimic_client():
async def client_mimic(ws: WebSocketClientProtocol):
await ws.send("test 1")
await ws.recv()
await ws.send("test 2")
await ws.recv()
return client_mimic
def test_ws_handler(
app: Sanic,
simple_ws_mimic_client: MimicClientType,
):
@app.websocket("/ws")
async def ws_echo_handler(request: Request, ws: Websocket):
while True:
msg = await ws.recv()
await ws.send(msg)
_, ws_proxy = app.test_client.websocket(
"/ws", mimic=simple_ws_mimic_client
)
assert ws_proxy.client_sent == ["test 1", "test 2", ""]
assert ws_proxy.client_received == ["test 1", "test 2"]
def test_ws_handler_async_for(
app: Sanic,
simple_ws_mimic_client: MimicClientType,
):
@app.websocket("/ws")
async def ws_echo_handler(request: Request, ws: Websocket):
async for msg in ws:
await ws.send(msg)
_, ws_proxy = app.test_client.websocket(
"/ws", mimic=simple_ws_mimic_client
)
assert ws_proxy.client_sent == ["test 1", "test 2", ""]
assert ws_proxy.client_received == ["test 1", "test 2"]

View File

@@ -1,14 +1,20 @@
import json try: # no cov
from ujson import dumps
except ModuleNotFoundError: # no cov
from json import dumps # type: ignore
from datetime import datetime from datetime import datetime
from logging import ERROR, INFO
from socket import AF_INET, SOCK_STREAM, timeout
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
from urllib.error import URLError
import pytest import pytest
from sanic_testing import TestManager
from sanic.cli.inspector_client import InspectorClient
from sanic.helpers import Default
from sanic.log import Colors from sanic.log import Colors
from sanic.worker.inspector import Inspector, inspect from sanic.worker.inspector import Inspector
DATA = { DATA = {
@@ -20,121 +26,90 @@ DATA = {
}, },
"workers": {"Worker-Name": {"some": "state"}}, "workers": {"Worker-Name": {"some": "state"}},
} }
SERIALIZED = json.dumps(DATA) FULL_SERIALIZED = dumps({"result": DATA})
OUT_SERIALIZED = dumps(DATA)
def test_inspector_stop(): class FooInspector(Inspector):
inspector = Inspector(Mock(), {}, {}, "", 1) async def foo(self, bar):
assert inspector.run is True return f"bar is {bar}"
inspector.stop()
assert inspector.run is False
@patch("sanic.worker.inspector.sys.stdout.write") @pytest.fixture
@patch("sanic.worker.inspector.socket") def publisher():
@pytest.mark.parametrize("command", ("foo", "raw", "pretty")) publisher = Mock()
def test_send_inspect(socket: Mock, write: Mock, command: str): return publisher
socket.return_value = socket
socket.__enter__.return_value = socket
socket.recv.return_value = SERIALIZED.encode()
inspect("localhost", 9999, command)
socket.sendall.assert_called_once_with(command.encode())
socket.recv.assert_called_once_with(4096)
socket.connect.assert_called_once_with(("localhost", 9999))
socket.assert_called_once_with(AF_INET, SOCK_STREAM)
if command == "raw":
write.assert_called_once_with(SERIALIZED)
elif command == "pretty":
write.assert_called()
else:
write.assert_not_called()
@patch("sanic.worker.inspector.sys") @pytest.fixture
@patch("sanic.worker.inspector.socket") def inspector(publisher):
def test_send_inspect_conn_refused(socket: Mock, sys: Mock, caplog): inspector = FooInspector(
with caplog.at_level(INFO): publisher, {}, {}, "localhost", 9999, "", Default(), Default()
socket.return_value = socket )
socket.__enter__.return_value = socket inspector(False)
socket.connect.side_effect = ConnectionRefusedError() return inspector
inspect("localhost", 9999, "foo")
socket.close.assert_called_once()
sys.exit.assert_called_once_with(1) @pytest.fixture
def http_client(inspector):
manager = TestManager(inspector.app)
return manager.test_client
@pytest.mark.parametrize("command", ("info",))
@patch("sanic.cli.inspector_client.sys.stdout.write")
def test_send_inspect(write, urlopen, command: str):
urlopen.read.return_value = FULL_SERIALIZED.encode()
InspectorClient("localhost", 9999, False, False, None).do(command)
write.assert_called()
write.reset_mock()
InspectorClient("localhost", 9999, False, True, None).do(command)
write.assert_called_with(OUT_SERIALIZED + "\n")
@patch("sanic.cli.inspector_client.sys")
def test_send_inspect_conn_refused(sys: Mock, urlopen):
urlopen.side_effect = URLError("")
InspectorClient("localhost", 9999, False, False, None).do("info")
message = ( message = (
f"{Colors.RED}Could not connect to inspector at: " f"{Colors.RED}Could not connect to inspector at: "
f"{Colors.YELLOW}('localhost', 9999){Colors.END}\n" f"{Colors.YELLOW}http://localhost:9999{Colors.END}\n"
"Either the application is not running, or it did not start " "Either the application is not running, or it did not start "
"an inspector instance." "an inspector instance.\n<urlopen error >\n"
) )
assert ("sanic.error", ERROR, message) in caplog.record_tuples sys.exit.assert_called_once_with(1)
sys.stderr.write.assert_called_once_with(message)
@patch("sanic.worker.inspector.configure_socket") def test_run_inspector_reload(publisher, http_client):
@pytest.mark.parametrize("action", (b"reload", b"shutdown", b"foo")) _, response = http_client.post("/reload")
def test_run_inspector(configure_socket: Mock, action: bytes): assert response.status == 200
sock = Mock() publisher.send.assert_called_once_with("__ALL_PROCESSES__:")
conn = Mock()
conn.recv.return_value = action
configure_socket.return_value = sock
inspector = Inspector(Mock(), {}, {}, "localhost", 9999)
inspector.reload = Mock() # type: ignore
inspector.shutdown = Mock() # type: ignore
inspector.state_to_json = Mock(return_value="foo") # type: ignore
def accept():
inspector.run = False
return conn, ...
sock.accept = accept
inspector()
configure_socket.assert_called_once_with(
{"host": "localhost", "port": 9999, "unix": None, "backlog": 1}
)
conn.recv.assert_called_with(64)
if action == b"reload":
conn.send.assert_called_with(b"\n")
inspector.reload.assert_called()
inspector.shutdown.assert_not_called()
inspector.state_to_json.assert_not_called()
elif action == b"shutdown":
conn.send.assert_called_with(b"\n")
inspector.reload.assert_not_called()
inspector.shutdown.assert_called()
inspector.state_to_json.assert_not_called()
else:
conn.send.assert_called_with(b'"foo"')
inspector.reload.assert_not_called()
inspector.shutdown.assert_not_called()
inspector.state_to_json.assert_called()
@patch("sanic.worker.inspector.configure_socket") def test_run_inspector_reload_zero_downtime(publisher, http_client):
def test_accept_timeout(configure_socket: Mock): _, response = http_client.post("/reload", json={"zero_downtime": True})
sock = Mock() assert response.status == 200
configure_socket.return_value = sock publisher.send.assert_called_once_with("__ALL_PROCESSES__::STARTUP_FIRST")
inspector = Inspector(Mock(), {}, {}, "localhost", 9999)
inspector.reload = Mock() # type: ignore
inspector.shutdown = Mock() # type: ignore
inspector.state_to_json = Mock(return_value="foo") # type: ignore
def accept():
inspector.run = False
raise timeout
sock.accept = accept def test_run_inspector_shutdown(publisher, http_client):
_, response = http_client.post("/shutdown")
assert response.status == 200
publisher.send.assert_called_once_with("__TERMINATE__")
inspector()
inspector.reload.assert_not_called() def test_run_inspector_scale(publisher, http_client):
inspector.shutdown.assert_not_called() _, response = http_client.post("/scale", json={"replicas": 4})
inspector.state_to_json.assert_not_called() assert response.status == 200
publisher.send.assert_called_once_with("__SCALE__:4")
def test_run_inspector_arbitrary(http_client):
_, response = http_client.post("/foo", json={"bar": 99})
assert response.status == 200
assert response.json == {"meta": {"action": "foo"}, "result": "bar is 99"}
def test_state_to_json(): def test_state_to_json():
@@ -142,8 +117,10 @@ def test_state_to_json():
now_iso = now.isoformat() now_iso = now.isoformat()
app_info = {"app": "hello"} app_info = {"app": "hello"}
worker_state = {"Test": {"now": now, "nested": {"foo": now}}} worker_state = {"Test": {"now": now, "nested": {"foo": now}}}
inspector = Inspector(Mock(), app_info, worker_state, "", 0) inspector = Inspector(
state = inspector.state_to_json() Mock(), app_info, worker_state, "", 0, "", Default(), Default()
)
state = inspector._state_to_json()
assert state == { assert state == {
"info": app_info, "info": app_info,
@@ -151,17 +128,14 @@ def test_state_to_json():
} }
def test_reload(): def test_run_inspector_authentication():
publisher = Mock() inspector = Inspector(
inspector = Inspector(publisher, {}, {}, "", 0) Mock(), {}, {}, "", 0, "super-secret", Default(), Default()
inspector.reload() )(False)
manager = TestManager(inspector.app)
publisher.send.assert_called_once_with("__ALL_PROCESSES__:") _, response = manager.test_client.get("/")
assert response.status == 401
_, response = manager.test_client.get(
def test_shutdown(): "/", headers={"Authorization": "Bearer super-secret"}
publisher = Mock() )
inspector = Inspector(publisher, {}, {}, "", 0) assert response.status == 200
inspector.shutdown()
publisher.send.assert_called_once_with("__TERMINATE__")

View File

@@ -86,6 +86,10 @@ def test_input_is_module():
@patch("sanic.worker.loader.TrustmeCreator") @patch("sanic.worker.loader.TrustmeCreator")
@patch("sanic.worker.loader.MkcertCreator") @patch("sanic.worker.loader.MkcertCreator")
def test_cert_loader(MkcertCreator: Mock, TrustmeCreator: Mock, creator: str): def test_cert_loader(MkcertCreator: Mock, TrustmeCreator: Mock, creator: str):
CertLoader._creators = {
"mkcert": MkcertCreator,
"trustme": TrustmeCreator,
}
MkcertCreator.return_value = MkcertCreator MkcertCreator.return_value = MkcertCreator
TrustmeCreator.return_value = TrustmeCreator TrustmeCreator.return_value = TrustmeCreator
data = { data = {

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