Compare commits

...

43 Commits

Author SHA1 Message Date
Adam Hopkins
8df80e276b sanic-routing==0.7.0 2021-06-27 23:01:47 +03:00
Adam Hopkins
30572c972d 21.6 Changelog, release version, and deprecations (#2172)
* Changelog and version

* Rearrange API docs for easier navigation

* Version 21.6 docs

* Change release workflow

* Disable Windows tests
2021-06-27 22:52:56 +03:00
Adam Hopkins
53da4dd091 Allow blueprints and groups to be infinitely reusable (#2150)
* Allow blueprints and groups to be infinitely reusable
2021-06-21 18:41:04 +03:00
Adam Hopkins
108a4a99c7 v2 AST router (#2133)
* Update some tests

* Update some tests

* Resolve #2122 route decorator returning tuple

* Use rc sanic-routing version

* Update unit tests to <:str>
2021-06-21 15:10:26 +03:00
Adam Hopkins
7c180376d6 Add Simple Server and Coverage action (#2168)
* Add Simple Server and CodeCov action

* Remove token

* Codecov to tox.ini

* fix tox

* Set coverage location

* Add ignore to codecov

* Try glob ignore

* Setup CodeClimate

* Allow coverage check to run

* Change coverage check

* Add codeclimate exclusions
2021-06-21 14:53:09 +03:00
Adam Hopkins
f39b8b32f7 Make sure ASGI ws subprotocols is a list (#2127)
* Ensure protocols is a list for ASGI

* Subprotocol updates
2021-06-21 14:39:06 +03:00
Adam Hopkins
c543d19f8a CBV alternate attach; CompositionView deprecate (#2170)
* Deprecate composition view and add alternate methods to attach CBV

* Add args to CBV attaching
2021-06-21 14:26:42 +03:00
Adam Hopkins
80fca9aef7 Better exception handling (#2128)
* WIP for better exception handling

* Note about removal

* resolve conditional to reduce lookups

* Cleanup logic
2021-06-21 14:14:07 +03:00
Adam Hopkins
5bb9aa0c2c Add reloading on addtional directories (#2167) 2021-06-18 11:39:09 +03:00
Stephen Sadowski
83c746ee57 Added new client_ip accessor (#2114)
* Added new client_ip accessor for ConnInfo class, updated request to use client_ip instead of client to be more representative of what will be returned (actual ipv6 ip instead of bracket wrapped ip)

* Fix ConnInfo init

* add ipv6 test - maybe will work?

* fixed silly indentation error

* Bump testing client

* Extend testing

* Fix text

Co-authored-by: Adam Hopkins <adam@amhopkins.com>
Co-authored-by: Adam Hopkins <admhpkns@gmail.com>
2021-06-16 22:34:52 +03:00
Aymeric Augustin
aff6604636 Upgrade websockets dependency. (#2154)
* Upgrade websockets dependency.

Fix #2142.

* Bumpt sanic-testing version

Co-authored-by: Adam Hopkins <adam@amhopkins.com>
Co-authored-by: Adam Hopkins <admhpkns@gmail.com>
2021-06-16 21:49:50 +03:00
sanjeevanahilan
2c80571a8a Update listeners.py (#2164)
fix typo

Co-authored-by: Adam Hopkins <admhpkns@gmail.com>
2021-06-16 16:00:29 +03:00
Adam Hopkins
d964b552af HTTPMethod enum (#2165)
* Add HTTP enum constants

* Tests

* Add some more string compat
2021-06-16 15:13:55 +03:00
Thomas Grainger
48f8b37b74 support app factory patten in CLI (#2157)
* support app factory patten in CLI

* Update sanic/__main__.py

* fix mypy errors

* Update mypy further

* Update sanic/utils.py

* Update sanic/utils.py

* support hypercorn/gunicorn style 'asgi.app:create_app()'

* add test for app factory
2021-06-09 12:05:56 +03:00
Adam Hopkins
141be0028d Allow 8192 header max to be breached (#2155)
* Allow 8192 header max to be breached

* Add REQUEST_MAX_HEADER_SIZE as config value

* remove queue size
2021-06-04 13:56:29 +03:00
L. Kärkkäinen
a140c47195 Remove config.REQUEST_BUFFER_QUEUE_SIZE which was not being used since 21.03. (#2156)
Co-authored-by: L. Kärkkäinen <tronic@users.noreply.github.com>
2021-06-03 11:26:32 +03:00
Adam Hopkins
0c3a8392f2 Event autoregister (#2140)
* Add event autoregistration

* Setup tests

* Cleanup IDE added imports

* Remove unused imports
2021-06-01 10:44:07 +03:00
Adam Hopkins
16875b1f41 Disable MacOS Tests (#2151)
* Update pr-python37.yml

* Update workflows
2021-06-01 10:23:52 +03:00
Adam Hopkins
b1f31f2eeb Alternatate classes on instantiation for Config and Sanic.ctx (#2119) 2021-06-01 00:21:31 +03:00
Adam Hopkins
d16b9e5a02 Cleanup conftest and fix warning message (#2147) 2021-05-31 22:41:41 +03:00
Adam Hopkins
680484bdc8 Update README.rst 2021-05-31 22:10:15 +03:00
Adam Hopkins
05cd44b5dd Remove travis from repo (#2149)
* Remove travis from repo

* Use PR branch for Windows tests to add --user to pip args
2021-05-31 21:56:51 +03:00
Adam Hopkins
ba374139f4 Require stricter object names (#2146) 2021-05-30 15:37:44 +03:00
Adam Hopkins
72a745bfd5 Small improvements to CLI experience (#2136)
* Small improvements to CLI experience

* Add tests

* Add test server for cli testing

* Add LOGO logging to reloader and some additional context to logging debug

* Cleanup tests
2021-05-20 15:35:19 +03:00
Adam Hopkins
3a6fac7d59 Version prefix (#2137)
* Add version prefixing

* Versioning tests

* Testing BP group properties
2021-05-19 13:32:40 +03:00
Adam Hopkins
28ba8e53df Implement 0.6 routing and some cleanup (#2117)
* Implement 0.6 routing and some cleanup

* Additional tests and annotation cleanup

* Resolve sorting

* cleanup test with encoding
2021-04-20 00:53:42 +03:00
Ajay Gupta
9b26358e63 add eof method to close stream (#2094)
* add eof method to close stream

* Add eof test

Co-authored-by: Ajay Gupta <ajay.gupta@1mg.com>
Co-authored-by: Adam Hopkins <admhpkns@gmail.com>
2021-04-18 02:46:34 +03:00
Johnathan Raymond
e21521f45c Fix type hinting for load_env (#2107)
* Deprecate `load_env` in favor of `env_prefix`

`load_env` both enabled/disabled environment variable parsing, while
also letting the user modify the env prefix. Deprecate the ability to
disable environment parsing, and add a new config variable to track the
user's desired prefix for environment-based configuration.

Resolves: #2102

* Add a few common .gitignore patterns
2021-04-12 21:31:35 +03:00
Stephen Sadowski
30479765cb Fix request.args.pop removes parameters inconsistently (#2110)
* Fix https://github.com/sanic-org/sanic/issues/2106

* style

* also apply fix to request.query_args

* add test

Co-authored-by: Arthur Goldberg <arthur.c.goldberg@gmail.com>
Co-authored-by: artcg <arthurgoldbergfwd@gmail.com>
2021-04-11 01:04:33 +03:00
ENT8R
53a571ec6c Consistent use of error loggers (#2109)
* Consistent use of error loggers

* Fix tests
2021-04-10 21:35:53 +03:00
ENT8R
ad97cac313 Explicit usage of CIMultiDict getters (#2104) 2021-04-08 13:30:12 +03:00
Harsha Narayana
1a352ddf55 GIT-2023: Enable GitHub Actions support (#2050)
* GIT-2023: Enable GitHub Actions support

* GIT-2023: fix tox runtime trigger

* GIT-2023: add top level action name

* GIT-2023: rename tox step name

* GIT-2023: rename build task names

* GIT-2023: remove macos and windows + nightly versions

* GIT-2023: add macos and windows back to os matrix

* GIT-2023: expermiental flag to conditionally skip failure

* GIT-2023: enable using custom actions

* GIT-2023: fix matrix config rendering type

* GIT-2023: fix naming issue with os label

* GIT-2023: enable type-checking env for tox

* GIT-2023: enable pypy3.7 support

* GIT-2023: enable pypy experimental flag

* GIT-2023: add pypy tox env config

* add max timeout of 5 min for pypy tests

* GIT-2023: add timeout for each actions

* GIT-2023: fix codeQL workflow actions

* GIT-2023: limit test matrix to ubuntu and support on demand

* GIT-2023: enable docker image publish on release

* GIT-2023: fix on-demand pypy action

* GIT-2023: enable pypi publish workflow

* GIT-2023: enable verbose logs for py3.9

* GIT-2023: reduce py3.9 verbosity

* GIT-2023: enable docs linter

* GIT-2023: extend test matrix to include macos + windows

* GIT-2023: move windows based workflow to standalone task

* GIT-2023: fix windows test matrix

* GIT-2023: mark py39-no-ext as flaky test

* GIT-2023: mark flaky test

* GIT-2023: make timeout internal to steps for ease of management

* GIT-2023: rename image publish step name

* GIT-2023: mark keep alive client timeout for linux only

* GIT-2023: enable retries on test failure

Co-authored-by: Adam Hopkins <admhpkns@gmail.com>
2021-04-06 23:32:01 +03:00
Lu
5ba43decf2 FIX UserWarning when using ASGI mode (#2091)
Co-authored-by: Adam Hopkins <admhpkns@gmail.com>
2021-04-06 22:36:50 +03:00
Frederik Gelder
8f06d035cb fixing static request handler logging exception on 404 (#2099)
* fixing static request handler logging exception when not necessary, adding test to verify exception is gone on 404

* Fixup tests

* Fix tests

* resolve test failure

Co-authored-by: Adam Hopkins <admhpkns@gmail.com>
2021-04-06 22:20:25 +03:00
Kyle Verhoog
b716f48c84 Update outdated handle_request docstring (#2100) 2021-04-06 13:41:18 +03:00
Arthur Goldberg
42b1e7143e deprecate abort (#2077) 2021-04-05 18:01:48 +03:00
ENT8R
eba7821a6d Allow case-insensitive HTTP Upgrade header (#2097)
* Allow case-insensitive HTTP Upgrade header

* Allow case-insensitive Upgrade header when checking the scheme

* Fix reference to headers

* Add None check

* Simplify HTTP Upgrade checks

* Fix newlines at end of file

* Run make pretty
2021-04-05 14:15:45 +03:00
Adam Hopkins
93a0246c03 Bump version: 2021-03-23 02:31:17 +02:00
Adam Hopkins
dfd1787a49 Make sure that blueprints with no slash is maintained when applied (#2085)
* Make sure that blueprints with no slash is maintained when applied

* Remove unneeded import
2021-03-23 02:28:42 +02:00
Adam Hopkins
4998fd54c0 Disable response timeout on websocket connections (#2081)
* Disable response timeout on websocket connections

* Add response timeout ignore test to websockets

* add logging assertion

* Move test items inside test context
2021-03-23 01:20:17 +02:00
Adam Hopkins
7be5f0ed3d CHANGELOG for 21.3.1 2021-03-21 15:04:27 +02:00
Adam Hopkins
938d2b5923 Static dir 2075 (#2076)
* Add support for nested static directories

* Add support for nested static directories

* Bump version 21.3.1
2021-03-21 15:03:54 +02:00
Adam Hopkins
13630a79ad Update sanic-org URL on setup.py 2021-03-21 12:00:32 +02:00
105 changed files with 2866 additions and 791 deletions

12
.codeclimate.yml Normal file
View File

@@ -0,0 +1,12 @@
exclude_patterns:
- "sanic/__main__.py"
- "sanic/reloader_helpers.py"
- "sanic/simple.py"
- "sanic/utils.py"
- ".github/"
- "changelogs/"
- "docker/"
- "docs/"
- "examples/"
- "hack/"
- "scripts/"

View File

@@ -1,7 +1,12 @@
[run]
branch = True
source = sanic
omit = site-packages, sanic/utils.py, sanic/__main__.py
omit =
site-packages
sanic/__main__.py
sanic/reloader_helpers.py
sanic/simple.py
sanic/utils.py
[html]
directory = coverage

View File

@@ -1,22 +1,10 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master ]
branches: [ main ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
branches: [ main ]
schedule:
- cron: '25 16 * * 0'
@@ -29,39 +17,18 @@ jobs:
fail-fast: false
matrix:
language: [ 'python' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
# Learn more:
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v1
# Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

40
.github/workflows/coverage.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Coverage check
# on:
# push:
# branches:
# - main
# tags:
# - "!*" # Do not execute on tags
# paths:
# - sanic/*
# - tests/*
# pull_request:
# paths:
# - "!*.MD"
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: [3.9]
os: [ubuntu-latest]
fail-fast: false
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies 🔨
run: |
python -m pip install --upgrade pip
pip install tox
- uses: paambaati/codeclimate-action@v2.5.3
if: always()
env:
CC_TEST_REPORTER_ID: ${{ secrets.CODECLIMATE }}
with:
coverageCommand: tox -e coverage

39
.github/workflows/on-demand.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: On Demand Task
on:
workflow_dispatch:
inputs:
python-version:
description: 'Version of Python to use for running Test'
required: false
default: "3.8"
tox-env:
description: 'Test Environment to Run'
required: true
default: ''
os:
description: 'Operating System to Run Test on'
required: false
default: ubuntu-latest
jobs:
onDemand:
name: tox-${{ matrix.config.tox-env }}-on-${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: ["${{ github.event.inputs.os}}"]
config:
- { tox-env: "${{ github.event.inputs.tox-env }}", py-version: "${{ github.event.inputs.python-version }}"}
steps:
- name: Checkout Repository
uses: actions/checkout@v2
- name: Run tests
uses: harshanarayana/custom-actions@main
with:
python-version: ${{ matrix.config.py-version }}
test-infra-tool: tox
test-infra-version: latest
action: tests
test-additional-args: "-e=${{ matrix.config.tox-env }}"
experimental-ignore-error: "yes"

32
.github/workflows/pr-bandit.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Security Analysis
on:
pull_request:
branches:
- main
jobs:
bandit:
name: type-check-${{ matrix.config.python-version }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
config:
- { python-version: 3.7, tox-env: security}
- { python-version: 3.8, tox-env: security}
- { python-version: 3.9, tox-env: security}
steps:
- name: Checkout the repository
uses: actions/checkout@v2
id: checkout-branch
- name: Run Linter Checks
id: linter-check
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 }}"

29
.github/workflows/pr-docs.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Document Linter
on:
pull_request:
branches:
- main
jobs:
docsLinter:
name: Lint Documentation
runs-on: ubuntu-latest
strategy:
matrix:
config:
- {python-version: "3.8", tox-env: "docs"}
fail-fast: false
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Run Document Linter
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 }}"

30
.github/workflows/pr-linter.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Linter Checks
on:
pull_request:
branches:
- main
jobs:
linter:
name: lint
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
config:
- { python-version: 3.8, tox-env: lint}
steps:
- name: Checkout the repository
uses: actions/checkout@v2
id: checkout-branch
- name: Run Linter Checks
id: linter-check
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 }}"

41
.github/workflows/pr-python-pypy.yml vendored Normal file
View File

@@ -0,0 +1,41 @@
name: Python PyPy Tests
on:
workflow_dispatch:
inputs:
tox-env:
description: "Tox Env to run on the PyPy Infra"
required: false
default: "pypy37"
pypy-version:
description: "Version of PyPy to use"
required: false
default: "pypy-3.7"
jobs:
testPyPy:
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: "${{ github.event.inputs.pypy-version }}",
tox-env: "${{ github.event.inputs.tox-env }}",
}
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 }}"
experimental-ignore-error: "true"
command-timeout: "600000"

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

@@ -0,0 +1,38 @@
name: Python 3.7 Tests
on:
pull_request:
branches:
- main
push:
branches:
- main
paths:
- sanic/*
- tests/*
jobs:
testPy37:
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.7, tox-env: py37 }
- { python-version: 3.7, tox-env: py37-no-ext }
steps:
- name: Checkout the Repository
uses: actions/checkout@v2
id: checkout-branch
- name: Run Unit Tests
uses: harshanarayana/custom-actions@main
with:
python-version: ${{ matrix.config.python-version }}
test-infra-tool: tox
test-infra-version: latest
action: tests
test-additional-args: "-e=${{ matrix.config.tox-env }}"
test-failure-retry: "3"

38
.github/workflows/pr-python38.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Python 3.8 Tests
on:
pull_request:
branches:
- main
push:
branches:
- main
paths:
- sanic/*
- tests/*
jobs:
testPy38:
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.8, tox-env: py38 }
- { python-version: 3.8, tox-env: py38-no-ext }
steps:
- name: Checkout the Repository
uses: actions/checkout@v2
id: checkout-branch
- name: Run Unit Tests
uses: harshanarayana/custom-actions@main
with:
python-version: ${{ matrix.config.python-version }}
test-infra-tool: tox
test-infra-version: latest
action: tests
test-additional-args: "-e=${{ matrix.config.tox-env }}"
test-failure-retry: "3"

50
.github/workflows/pr-python39.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: Python 3.9 Tests
on:
pull_request:
branches:
- main
push:
branches:
- main
paths:
- sanic/*
- tests/*
jobs:
testPy39:
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.9,
tox-env: py39,
ignore-error-flake: "false",
command-timeout: "0",
}
- {
python-version: 3.9,
tox-env: py39-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"

32
.github/workflows/pr-type-check.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: Typing Checks
on:
pull_request:
branches:
- main
jobs:
typeChecking:
name: type-check-${{ matrix.config.python-version }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
config:
- { python-version: 3.7, tox-env: type-checking}
- { python-version: 3.8, tox-env: type-checking}
- { python-version: 3.9, tox-env: type-checking}
steps:
- name: Checkout the repository
uses: actions/checkout@v2
id: checkout-branch
- name: Run Linter Checks
id: linter-check
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 }}"

34
.github/workflows/pr-windows.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
# name: Run Unit Tests on Windows
# on:
# pull_request:
# branches:
# - main
# jobs:
# testsOnWindows:
# name: ut-${{ matrix.config.tox-env }}
# runs-on: windows-latest
# strategy:
# fail-fast: false
# matrix:
# config:
# - { python-version: 3.7, tox-env: py37-no-ext }
# - { python-version: 3.8, tox-env: py38-no-ext }
# - { python-version: 3.9, tox-env: py39-no-ext }
# - { python-version: pypy-3.7, tox-env: pypy37-no-ext }
# steps:
# - name: Checkout Repository
# uses: actions/checkout@v2
# - name: Run Unit Tests
# uses: ahopkins/custom-actions@pip-extra-args
# with:
# python-version: ${{ matrix.config.python-version }}
# test-infra-tool: tox
# test-infra-version: latest
# action: tests
# test-additional-args: "-e=${{ matrix.config.tox-env }}"
# experimental-ignore-error: "true"
# command-timeout: "600000"
# pip-extra-args: "--user"

48
.github/workflows/publish-images.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Publish Docker Images
on:
workflow_run:
workflows:
- 'Publish Artifacts'
types:
- completed
jobs:
publishDockerImages:
name: Docker Image Build [${{ matrix.python-version }}]
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
python-version: ["3.7", "3.8", "3.9"]
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Build Latest Base images for ${{ matrix.python-version }}
uses: harshanarayana/custom-actions@main
with:
docker-image-base-name: sanicframework/sanic-build
ignore-python-setup: 'true'
dockerfile-base-dir: './docker'
action: 'image-publish'
docker-image-tag: "${{ matrix.python-version }}"
docker-file-suffix: "base"
docker-build-args: "PYTHON_VERSION=${{ matrix.python-version }}"
registry-auth-user: ${{ secrets.DOCKER_ACCESS_USER }}
registry-auth-password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
push-images: 'true'
- name: Publish Sanic Docker Image for ${{ matrix.python-version }}
uses: harshanarayana/custom-actions@main
with:
docker-image-base-name: sanicframework/sanic
ignore-python-setup: 'true'
dockerfile-base-dir: './docker'
action: 'image-publish'
docker-build-args: "BASE_IMAGE_TAG=${{ matrix.python-version }}"
docker-image-prefix: "${{ matrix.python-version }}"
registry-auth-user: ${{ secrets.DOCKER_ACCESS_USER }}
registry-auth-password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
push-images: 'true'

28
.github/workflows/publish-package.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Publish Artifacts
on:
release:
types: [created]
jobs:
publishPythonPackage:
name: Publishing Sanic Release Artifacts
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
python-version: ["3.8"]
steps:
- name: Checkout Repository
uses: actions/checkout@v2
- name: Publish Python Package
uses: harshanarayana/custom-actions@main
with:
python-version: ${{ matrix.python-version }}
package-infra-name: "twine"
pypi-user: __token__
pypi-access-token: ${{ secrets.PYPI_ACCESS_TOKEN }}
action: "package-publish"
pypi-verify-metadata: "true"

4
.gitignore vendored
View File

@@ -6,6 +6,7 @@
.coverage
.coverage.*
coverage
coverage.xml
.tox
settings.py
.idea/*
@@ -18,3 +19,6 @@ build/*
.DS_Store
dist/*
pip-wheel-metadata/
.pytest_cache/*
.venv/*
.vscode/*

View File

@@ -1,94 +0,0 @@
sudo: false
language: python
cache:
directories:
- $HOME/.cache/pip
matrix:
include:
- env: TOX_ENV=py37
python: 3.7
dist: xenial
sudo: true
name: "Python 3.7 with Extensions"
- env: TOX_ENV=py37-no-ext
python: 3.7
dist: xenial
sudo: true
name: "Python 3.7 without Extensions"
- env: TOX_ENV=py38
python: 3.8
dist: xenial
sudo: true
name: "Python 3.8 with Extensions"
- env: TOX_ENV=py38-no-ext
python: 3.8
dist: xenial
sudo: true
name: "Python 3.8 without Extensions"
- env: TOX_ENV=py39
python: 3.9
dist: bionic
sudo: true
name: "Python 3.9 with Extensions"
- env: TOX_ENV=py39-no-ext
python: 3.9
dist: bionic
sudo: true
name: "Python 3.9 without Extensions"
- env: TOX_ENV=type-checking
python: 3.7
name: "Python 3.7 Type checks"
- env: TOX_ENV=type-checking
python: 3.8
name: "Python 3.8 Type checks"
- env: TOX_ENV=type-checking
python: 3.9
dist: bionic
name: "Python 3.9 Type checks"
- env: TOX_ENV=security
python: 3.7
dist: xenial
sudo: true
name: "Python 3.7 Bandit security scan"
- env: TOX_ENV=security
python: 3.8
dist: xenial
sudo: true
name: "Python 3.8 Bandit security scan"
- env: TOX_ENV=security
python: 3.9
dist: bionic
sudo: true
name: "Python 3.9 Bandit security scan"
- env: TOX_ENV=docs
python: 3.7
dist: xenial
sudo: true
name: "Python 3.7 Documentation tests"
- env: TOX_ENV=pyNightly
python: "nightly"
name: "Python nightly with Extensions"
- env: TOX_ENV=pyNightly-no-ext
python: "nightly"
name: "Python nightly without Extensions"
allow_failures:
- env: TOX_ENV=pyNightly
python: "nightly"
name: "Python nightly with Extensions"
- env: TOX_ENV=pyNightly-no-ext
python: "nightly"
name: "Python nightly without Extensions"
install:
- pip install -U tox
- pip install codecov
script: travis_retry tox -e $TOX_ENV
after_success:
- codecov
deploy:
provider: pypi
user: brewmaster
password:
secure: "GoawLwmbtJOgKB6AJ0ZSYUUnNwIoonseHBxaAUH3zu79TS/Afrq+yB3lsVaMSG0CbyDgN4FrfD1phT1NzbvZ1VcLIOTDtCrmpQ1kLDw+zwgF40ab8sp8fPkKVHHHfCCs1mjltHIpxQa5lZTJcAs6Bpi/lbUWWwYxFzSV8pHw4W4hY09EHUd2o+evLTSVxaploetSt725DJUYKICUr2eAtCC11IDnIW4CzBJEx6krVV3uhzfTJW0Ls17x0c6sdZ9icMnV/G9xO/eQH6RIHe4xcrWJ6cmLDNKoGAkJp+BKr1CeVVg7Jw/MzPjvZKL2/ki6Beue1y6GUIy7lOS7jPVaOEhJ23b0zQwFcLMZw+Tt+E3v6QfHk+B/WBBBnM3zUZed9UI+QyW8+lqLLt39sQX0FO0P3eaDh8qTXtUuon2jTyFMMAMTFRTNpJmpAzuBH9yeMmDeALPTh0HphI+BkoUl5q1QbWFYjjnZMH2CatApxpLybt9A7rwm//PbOG0TSI93GEKNQ4w5DYryKTfwHzRBptNSephJSuxZYEfJsmUtas5es1D7Fe0PkyjxNNSU+eO+8wsTlitLUsJO4k0jAgy+cEKdU7YJ3J0GZVXocSkrNnUfd2hQPcJ3UtEJx3hLqqr8EM7EZBAasc1yGHh36NFetclzFY24YPih0G1+XurhTys="
on:
tags: true
distributions: "sdist bdist_wheel"

View File

@@ -1,3 +1,116 @@
Version 21.6.0
--------------
Features
********
* `#2094 <https://github.com/sanic-org/sanic/pull/2094>`_
Add ``response.eof()`` method for closing a stream in a handler
* `#2097 <https://github.com/sanic-org/sanic/pull/2097>`_
Allow case-insensitive HTTP Upgrade header
* `#2104 <https://github.com/sanic-org/sanic/pull/2104>`_
Explicit usage of CIMultiDict getters
* `#2109 <https://github.com/sanic-org/sanic/pull/2109>`_
Consistent use of error loggers
* `#2114 <https://github.com/sanic-org/sanic/pull/2114>`_
New ``client_ip`` access of connection info instance
* `#2119 <https://github.com/sanic-org/sanic/pull/2119>`_
Alternatate classes on instantiation for ``Config`` and ``Sanic.ctx``
* `#2133 <https://github.com/sanic-org/sanic/pull/2133>`_
Implement new version of AST router
* Proper differentiation between ``alpha`` and ``string`` param types
* Adds a ``slug`` param type, example: ``<foo:slug>``
* Deprecates ``<foo:string>`` in favor of ``<foo:str>``
* Deprecates ``<foo:number>`` in favor of ``<foo:float>``
* Adds a ``route.uri`` accessor
* `#2136 <https://github.com/sanic-org/sanic/pull/2136>`_
CLI improvements with new optional params
* `#2137 <https://github.com/sanic-org/sanic/pull/2137>`_
Add ``version_prefix`` to URL builders
* `#2140 <https://github.com/sanic-org/sanic/pull/2140>`_
Event autoregistration with ``EVENT_AUTOREGISTER``
* `#2146 <https://github.com/sanic-org/sanic/pull/2146>`_, `#2147 <https://github.com/sanic-org/sanic/pull/2147>`_
Require stricter names on ``Sanic()`` and ``Blueprint()``
* `#2150 <https://github.com/sanic-org/sanic/pull/2150>`_
Infinitely reusable and nestable ``Blueprint`` and ``BlueprintGroup``
* `#2154 <https://github.com/sanic-org/sanic/pull/2154>`_
Upgrade ``websockets`` dependency to min version
* `#2155 <https://github.com/sanic-org/sanic/pull/2155>`_
Allow for maximum header sizes to be increased: ``REQUEST_MAX_HEADER_SIZE``
* `#2157 <https://github.com/sanic-org/sanic/pull/2157>`_
Allow app factory pattern in CLI
* `#2165 <https://github.com/sanic-org/sanic/pull/2165>`_
Change HTTP methods to enums
* `#2167 <https://github.com/sanic-org/sanic/pull/2167>`_
Allow auto-reloading on additional directories
* `#2168 <https://github.com/sanic-org/sanic/pull/2168>`_
Add simple HTTP server to CLI
* `#2170 <https://github.com/sanic-org/sanic/pull/2170>`_
Additional methods for attaching ``HTTPMethodView``
Bugfixes
********
* `#2091 <https://github.com/sanic-org/sanic/pull/2091>`_
Fix ``UserWarning`` in ASGI mode for missing ``__slots__``
* `#2099 <https://github.com/sanic-org/sanic/pull/2099>`_
Fix static request handler logging exception on 404
* `#2110 <https://github.com/sanic-org/sanic/pull/2110>`_
Fix request.args.pop removes parameters inconsistently
* `#2107 <https://github.com/sanic-org/sanic/pull/2107>`_
Fix type hinting for load_env
* `#2127 <https://github.com/sanic-org/sanic/pull/2127>`_
Make sure ASGI ws subprotocols is a list
* `#2128 <https://github.com/sanic-org/sanic/pull/2128>`_
Fix issue where Blueprint exception handlers do not consistently route to proper handler
Deprecations and Removals
*************************
* `#2156 <https://github.com/sanic-org/sanic/pull/2156>`_
Remove config value ``REQUEST_BUFFER_QUEUE_SIZE``
* `#2170 <https://github.com/sanic-org/sanic/pull/2170>`_
``CompositionView`` deprecated and marked for removal in 21.12
* `#2172 <https://github.com/sanic-org/sanic/pull/2170>`_
Deprecate StreamingHTTPResponse
Developer infrastructure
************************
* `#2149 <https://github.com/sanic-org/sanic/pull/2149>`_
Remove Travis CI in favor of GitHub Actions
Improved Documentation
**********************
* `#2164 <https://github.com/sanic-org/sanic/pull/2164>`_
Fix typo in documentation
* `#2100 <https://github.com/sanic-org/sanic/pull/2100>`_
Remove documentation for non-existent arguments
Version 21.3.2
--------------
Bugfixes
********
* `#2081 <https://github.com/sanic-org/sanic/pull/2081>`_
Disable response timeout on websocket connections
* `#2085 <https://github.com/sanic-org/sanic/pull/2085>`_
Make sure that blueprints with no slash is maintained when applied
Version 21.3.1
--------------
Bugfixes
********
* `#2076 <https://github.com/sanic-org/sanic/pull/2076>`_
Static files inside subfolders are not accessible (404)
Version 21.3.0
--------------

View File

@@ -87,7 +87,7 @@ Permform ``flake8``\ , ``black`` and ``isort`` checks.
tox -e lint
Run type annotation checks
---------------
--------------------------
``tox`` environment -> ``[testenv:type-checking]``

View File

@@ -49,6 +49,9 @@ test: clean
test-coverage: clean
python setup.py test --pytest-args="--cov sanic --cov-report term --cov-append "
view-coverage:
sanic ./coverage --simple
install:
python setup.py install
@@ -85,8 +88,7 @@ docs-test: docs-clean
cd docs && make dummy
docs-serve:
# python -m http.server --directory=./docs/_build/html 9999
sphinx-autobuild docs docs/_build/html --port 9999 --watch ./sanic
sphinx-autobuild docs docs/_build/html --port 9999 --watch ./
changelog:
python scripts/changelog.py

View File

@@ -11,7 +11,7 @@ Sanic | Build fast. Run fast.
:stub-columns: 1
* - Build
- | |Build Status| |AppVeyor Build Status| |Codecov|
- | |Py39Test| |Py38Test| |Py37Test| |Codecov|
* - Docs
- | |UserGuide| |Documentation|
* - Package
@@ -29,10 +29,12 @@ Sanic | Build fast. Run fast.
:target: https://discord.gg/FARQzAEMAA
.. |Codecov| image:: https://codecov.io/gh/sanic-org/sanic/branch/master/graph/badge.svg
:target: https://codecov.io/gh/sanic-org/sanic
.. |Build Status| image:: https://travis-ci.com/sanic-org/sanic.svg?branch=master
:target: https://travis-ci.com/sanic-org/sanic
.. |AppVeyor Build Status| image:: https://ci.appveyor.com/api/projects/status/d8pt3ids0ynexi8c/branch/master?svg=true
:target: https://ci.appveyor.com/project/sanic-org/sanic
.. |Py39Test| image:: https://github.com/sanic-org/sanic/actions/workflows/pr-python39.yml/badge.svg?branch=main
:target: https://github.com/sanic-org/sanic/actions/workflows/pr-python39.yml
.. |Py38Test| image:: https://github.com/sanic-org/sanic/actions/workflows/pr-python38.yml/badge.svg?branch=main
:target: https://github.com/sanic-org/sanic/actions/workflows/pr-python38.yml
.. |Py37Test| image:: https://github.com/sanic-org/sanic/actions/workflows/pr-python37.yml/badge.svg?branch=main
:target: https://github.com/sanic-org/sanic/actions/workflows/pr-python37.yml
.. |Documentation| image:: https://readthedocs.org/projects/sanic/badge/?version=latest
:target: http://sanic.readthedocs.io/en/latest/?badge=latest
.. |PyPI| image:: https://img.shields.io/pypi/v/sanic.svg

View File

@@ -1,14 +0,0 @@
codecov:
require_ci_to_pass: no
coverage:
precision: 3
round: nearest
status:
project:
default:
target: auto
threshold: 0.5%
patch:
default:
target: auto
threshold: 0.75%

View File

@@ -1,28 +1,9 @@
FROM alpine:3.7
ARG BASE_IMAGE_TAG
RUN apk add --no-cache --update \
curl \
bash \
build-base \
ca-certificates \
git \
bzip2-dev \
linux-headers \
ncurses-dev \
openssl \
openssl-dev \
readline-dev \
sqlite-dev
FROM sanicframework/sanic-build:${BASE_IMAGE_TAG}
RUN apk update
RUN update-ca-certificates
RUN rm -rf /var/cache/apk/*
ENV PYENV_ROOT="/root/.pyenv"
ENV PATH="$PYENV_ROOT/bin:$PATH"
ADD . /app
WORKDIR /app
RUN /app/docker/bin/install_python.sh 3.5.4 3.6.4
ENTRYPOINT ["./docker/bin/entrypoint.sh"]
RUN pip install sanic
RUN apk del build-base

9
docker/Dockerfile-base Normal file
View File

@@ -0,0 +1,9 @@
ARG PYTHON_VERSION
FROM python:${PYTHON_VERSION}-alpine
RUN apk update
RUN apk add --no-cache --update build-base \
ca-certificates \
openssl
RUN update-ca-certificates
RUN rm -rf /var/cache/apk/*

View File

@@ -1,11 +0,0 @@
#!/bin/bash
set -e
eval "$(pyenv init -)"
eval "$(pyenv virtualenv-init -)"
source /root/.pyenv/completions/pyenv.bash
pip install tox
exec $@

View File

@@ -1,17 +0,0 @@
#!/bin/bash
set -e
export CFLAGS='-O2'
export EXTRA_CFLAGS="-DTHREAD_STACK_SIZE=0x100000"
curl -L https://raw.githubusercontent.com/pyenv/pyenv-installer/master/bin/pyenv-installer | bash
eval "$(pyenv init -)"
for ver in $@
do
pyenv install $ver
done
pyenv global $@
pip install --upgrade pip
pyenv rehash

17
docs/sanic/api/app.rst Normal file
View File

@@ -0,0 +1,17 @@
Application
===========
sanic.app
---------
.. automodule:: sanic.app
:members:
:show-inheritance:
:inherited-members:
sanic.config
------------
.. automodule:: sanic.config
:members:
:show-inheritance:

View File

@@ -0,0 +1,17 @@
Blueprints
==========
sanic.blueprints
----------------
.. automodule:: sanic.blueprints
:members:
:show-inheritance:
:inherited-members:
sanic.blueprint_group
---------------------
.. automodule:: sanic.blueprint_group
:members:
:special-members:

47
docs/sanic/api/core.rst Normal file
View File

@@ -0,0 +1,47 @@
Core
====
sanic.cookies
-------------
.. automodule:: sanic.cookies
:members:
:show-inheritance:
sanic.handlers
--------------
.. automodule:: sanic.handlers
:members:
:show-inheritance:
sanic.request
-------------
.. automodule:: sanic.request
:members:
:show-inheritance:
sanic.response
--------------
.. automodule:: sanic.response
:members:
:show-inheritance:
sanic.views
-----------
.. automodule:: sanic.views
:members:
:show-inheritance:
sanic.websocket
---------------
.. automodule:: sanic.websocket
:members:
:show-inheritance:

View File

@@ -0,0 +1,16 @@
Exceptions
==========
sanic.errorpages
----------------
.. automodule:: sanic.errorpages
:members:
:show-inheritance:
sanic.exceptions
----------------
.. automodule:: sanic.exceptions
:members:
:show-inheritance:

18
docs/sanic/api/router.rst Normal file
View File

@@ -0,0 +1,18 @@
Routing
=======
sanic_routing models
--------------------
.. autoclass:: sanic_routing.route::Route
:members:
.. autoclass:: sanic_routing.group::RouteGroup
:members:
sanic.router
------------
.. automodule:: sanic.router
:members:
:show-inheritance:

25
docs/sanic/api/server.rst Normal file
View File

@@ -0,0 +1,25 @@
Sanic Server
============
sanic.http
----------
.. automodule:: sanic.http
:members:
:show-inheritance:
sanic.server
------------
.. automodule:: sanic.server
:members:
:show-inheritance:
sanic.worker
------------
.. automodule:: sanic.worker
:members:
:show-inheritance:

View File

@@ -0,0 +1,16 @@
Utility
=======
sanic.compat
------------
.. automodule:: sanic.compat
:members:
:show-inheritance:
sanic.log
---------
.. automodule:: sanic.log
:members:
:show-inheritance:

View File

@@ -1,132 +1,13 @@
📑 API Reference
================
sanic.app
---------
.. toctree::
:maxdepth: 2
.. automodule:: sanic.app
:members:
:show-inheritance:
:inherited-members:
sanic.blueprints
----------------
.. automodule:: sanic.blueprints
:members:
:show-inheritance:
:inherited-members:
sanic.blueprint_group
---------------------
.. automodule:: sanic.blueprint_group
:members:
:special-members:
sanic.compat
------------
.. automodule:: sanic.compat
:members:
:show-inheritance:
sanic.config
------------
.. automodule:: sanic.config
:members:
:show-inheritance:
sanic.cookies
-------------
.. automodule:: sanic.cookies
:members:
:show-inheritance:
sanic.errorpages
----------------
.. automodule:: sanic.errorpages
:members:
:show-inheritance:
sanic.exceptions
----------------
.. automodule:: sanic.exceptions
:members:
:show-inheritance:
sanic.handlers
--------------
.. automodule:: sanic.handlers
:members:
:show-inheritance:
sanic.http
----------
.. automodule:: sanic.http
:members:
:show-inheritance:
sanic.log
---------
.. automodule:: sanic.log
:members:
:show-inheritance:
sanic.request
-------------
.. automodule:: sanic.request
:members:
:show-inheritance:
sanic.response
--------------
.. automodule:: sanic.response
:members:
:show-inheritance:
sanic.router
------------
.. automodule:: sanic.router
:members:
:show-inheritance:
sanic.server
------------
.. automodule:: sanic.server
:members:
:show-inheritance:
sanic.views
-----------
.. automodule:: sanic.views
:members:
:show-inheritance:
sanic.websocket
---------------
.. automodule:: sanic.websocket
:members:
:show-inheritance:
sanic.worker
------------
.. automodule:: sanic.worker
:members:
:show-inheritance:
api/app
api/blueprints
api/core
api/exceptions
api/router
api/server
api/utility

View File

@@ -1,4 +1,4 @@
♥️ Contributing
===============
==============
.. include:: ../../CONTRIBUTING.rst

BIN
examples/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -0,0 +1,6 @@
from sanic import Sanic
app = Sanic(__name__)
app.static("/", "./static")

6
hack/Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM catthehacker/ubuntu:act-latest
SHELL [ "/bin/bash", "-c" ]
ENTRYPOINT []
RUN apt-get update
RUN apt-get install gcc -y
RUN apt-get install -y --no-install-recommends g++

View File

@@ -1,6 +1,7 @@
from sanic.__version__ import __version__
from sanic.app import Sanic
from sanic.blueprints import Blueprint
from sanic.constants import HTTPMethod
from sanic.request import Request
from sanic.response import HTTPResponse, html, json, text
@@ -9,6 +10,7 @@ __all__ = (
"__version__",
"Sanic",
"Blueprint",
"HTTPMethod",
"HTTPResponse",
"Request",
"html",

View File

@@ -1,21 +1,25 @@
import os
import sys
from argparse import ArgumentParser, RawDescriptionHelpFormatter
from argparse import ArgumentParser, RawTextHelpFormatter
from importlib import import_module
from pathlib import Path
from typing import Any, Dict, Optional
from sanic_routing import __version__ as __routing_version__ # type: ignore
from sanic import __version__
from sanic.app import Sanic
from sanic.config import BASE_LOGO
from sanic.log import logger
from sanic.log import error_logger
from sanic.simple import create_simple_server
class SanicArgumentParser(ArgumentParser):
def add_bool_arguments(self, *args, **kwargs):
group = self.add_mutually_exclusive_group()
group.add_argument(*args, action="store_true", **kwargs)
kwargs["help"] = "no " + kwargs["help"]
kwargs["help"] = f"no {kwargs['help']}\n "
group.add_argument(
"--no-" + args[0][2:], *args[1:], action="store_false", **kwargs
)
@@ -25,7 +29,30 @@ def main():
parser = SanicArgumentParser(
prog="sanic",
description=BASE_LOGO,
formatter_class=RawDescriptionHelpFormatter,
formatter_class=lambda prog: RawTextHelpFormatter(
prog, max_help_position=33
),
)
parser.add_argument(
"-v",
"--version",
action="version",
version=f"Sanic {__version__}; Routing {__routing_version__}",
)
parser.add_argument(
"--factory",
action="store_true",
help=(
"Treat app as an application factory, "
"i.e. a () -> <Sanic app> callable"
),
)
parser.add_argument(
"-s",
"--simple",
dest="simple",
action="store_true",
help="Run Sanic as a Simple Server (module arg should be a path)\n ",
)
parser.add_argument(
"-H",
@@ -33,7 +60,7 @@ def main():
dest="host",
type=str,
default="127.0.0.1",
help="host address [default 127.0.0.1]",
help="Host address [default 127.0.0.1]",
)
parser.add_argument(
"-p",
@@ -41,7 +68,7 @@ def main():
dest="port",
type=int,
default=8000,
help="port to serve on [default 8000]",
help="Port to serve on [default 8000]",
)
parser.add_argument(
"-u",
@@ -49,13 +76,16 @@ def main():
dest="unix",
type=str,
default="",
help="location of unix socket",
help="location of unix socket\n ",
)
parser.add_argument(
"--cert", dest="cert", type=str, help="location of certificate for SSL"
"--cert", dest="cert", type=str, help="Location of certificate for SSL"
)
parser.add_argument(
"--key", dest="key", type=str, help="location of keyfile for SSL."
"--key", dest="key", type=str, help="location of keyfile for SSL\n "
)
parser.add_bool_arguments(
"--access-logs", dest="access_log", help="display access logs"
)
parser.add_argument(
"-w",
@@ -63,20 +93,31 @@ def main():
dest="workers",
type=int,
default=1,
help="number of worker processes [default 1]",
help="number of worker processes [default 1]\n ",
)
parser.add_argument("--debug", dest="debug", action="store_true")
parser.add_bool_arguments(
"--access-logs", dest="access_log", help="display access logs"
parser.add_argument("-d", "--debug", dest="debug", action="store_true")
parser.add_argument(
"-r",
"--reload",
"--auto-reload",
dest="auto_reload",
action="store_true",
help="Watch source directory for file changes and reload on changes",
)
parser.add_argument(
"-v",
"--version",
action="version",
version=f"Sanic {__version__}",
"-R",
"--reload-dir",
dest="path",
action="append",
help="Extra directories to watch and reload on changes\n ",
)
parser.add_argument(
"module", help="path to your Sanic app. Example: path.to.server:app"
"module",
help=(
"Path to your Sanic app. Example: path.to.server:app\n"
"If running a Simple Server, path to directory to serve. "
"Example: ./\n"
),
)
args = parser.parse_args()
@@ -85,47 +126,71 @@ def main():
if module_path not in sys.path:
sys.path.append(module_path)
if ":" in args.module:
module_name, app_name = args.module.rsplit(":", 1)
if args.simple:
path = Path(args.module)
app = create_simple_server(path)
else:
module_parts = args.module.split(".")
module_name = ".".join(module_parts[:-1])
app_name = module_parts[-1]
delimiter = ":" if ":" in args.module else "."
module_name, app_name = args.module.rsplit(delimiter, 1)
module = import_module(module_name)
app = getattr(module, app_name, None)
app_name = type(app).__name__
if app_name.endswith("()"):
args.factory = True
app_name = app_name[:-2]
if not isinstance(app, Sanic):
raise ValueError(
f"Module is not a Sanic app, it is a {app_name}. "
f"Perhaps you meant {args.module}.app?"
)
module = import_module(module_name)
app = getattr(module, app_name, None)
if args.factory:
app = app()
app_type_name = type(app).__name__
if not isinstance(app, Sanic):
raise ValueError(
f"Module is not a Sanic app, it is a {app_type_name}. "
f"Perhaps you meant {args.module}.app?"
)
if args.cert is not None or args.key is not None:
ssl = {
ssl: Optional[Dict[str, Any]] = {
"cert": args.cert,
"key": args.key,
} # type: Optional[Dict[str, Any]]
}
else:
ssl = None
app.run(
host=args.host,
port=args.port,
unix=args.unix,
workers=args.workers,
debug=args.debug,
access_log=args.access_log,
ssl=ssl,
)
kwargs = {
"host": args.host,
"port": args.port,
"unix": args.unix,
"workers": args.workers,
"debug": args.debug,
"access_log": args.access_log,
"ssl": ssl,
}
if args.auto_reload:
kwargs["auto_reload"] = True
if args.path:
if args.auto_reload or args.debug:
kwargs["reload_dir"] = args.path
else:
error_logger.warning(
"Ignoring '--reload-dir' since auto reloading was not "
"enabled. If you would like to watch directories for "
"changes, consider using --debug or --auto-reload."
)
app.run(**kwargs)
except ImportError as e:
logger.error(
f"No module named {e.name} found.\n"
f" Example File: project/sanic_server.py -> app\n"
f" Example Module: project.sanic_server.app"
)
if module_name.startswith(e.name):
error_logger.error(
f"No module named {e.name} found.\n"
" Example File: project/sanic_server.py -> app\n"
" Example Module: project.sanic_server.app"
)
else:
raise e
except ValueError:
logger.exception("Failed to run app")
error_logger.exception("Failed to run app")
if __name__ == "__main__":

View File

@@ -1 +1 @@
__version__ = "21.3.0"
__version__ = "21.6.0"

View File

@@ -14,6 +14,7 @@ from asyncio.futures import Future
from collections import defaultdict, deque
from functools import partial
from inspect import isawaitable
from pathlib import Path
from socket import socket
from ssl import Purpose, SSLContext, create_default_context
from traceback import format_exc
@@ -43,7 +44,7 @@ from sanic.asgi import ASGIApp
from sanic.base import BaseSanic
from sanic.blueprint_group import BlueprintGroup
from sanic.blueprints import Blueprint
from sanic.config import BASE_LOGO, Config
from sanic.config import BASE_LOGO, SANIC_PREFIX, Config
from sanic.exceptions import (
InvalidUsage,
SanicException,
@@ -78,6 +79,7 @@ class Sanic(BaseSanic):
"""
__fake_slots__ = (
"_asgi_app",
"_app_registry",
"_asgi_client",
"_blueprint_order",
@@ -89,6 +91,7 @@ class Sanic(BaseSanic):
"_future_signals",
"_test_client",
"_test_manager",
"auto_reload",
"asgi",
"blueprints",
"config",
@@ -103,6 +106,7 @@ class Sanic(BaseSanic):
"name",
"named_request_middleware",
"named_response_middleware",
"reload_dirs",
"request_class",
"request_middleware",
"response_middleware",
@@ -121,10 +125,13 @@ class Sanic(BaseSanic):
def __init__(
self,
name: str = None,
config: Optional[Config] = None,
ctx: Optional[Any] = None,
router: Optional[Router] = None,
signal_router: Optional[SignalRouter] = None,
error_handler: Optional[ErrorHandler] = None,
load_env: bool = True,
load_env: Union[bool, str] = True,
env_prefix: Optional[str] = SANIC_PREFIX,
request_class: Optional[Type[Request]] = None,
strict_slashes: bool = False,
log_config: Optional[Dict[str, Any]] = None,
@@ -132,34 +139,38 @@ class Sanic(BaseSanic):
register: Optional[bool] = None,
dumps: Optional[Callable[..., str]] = None,
) -> None:
super().__init__()
super().__init__(name=name)
if name is None:
raise SanicException(
"Sanic instance cannot be unnamed. "
"Please use Sanic(name='your_application_name') instead.",
)
# logging
if configure_logging:
logging.config.dictConfig(log_config or LOGGING_CONFIG_DEFAULTS)
if config and (load_env is not True or env_prefix != SANIC_PREFIX):
raise SanicException(
"When instantiating Sanic with config, you cannot also pass "
"load_env or env_prefix"
)
self._asgi_client = None
self._blueprint_order: List[Blueprint] = []
self._test_client = None
self._test_manager = None
self.asgi = False
self.auto_reload = False
self.blueprints: Dict[str, Blueprint] = {}
self.config = Config(load_env=load_env)
self.config = config or Config(
load_env=load_env, env_prefix=env_prefix
)
self.configure_logging = configure_logging
self.ctx = SimpleNamespace()
self.ctx = ctx or SimpleNamespace()
self.debug = None
self.error_handler = error_handler or ErrorHandler()
self.is_running = False
self.is_stopping = False
self.listeners: Dict[str, List[ListenerType]] = defaultdict(list)
self.name = name
self.named_request_middleware: Dict[str, Deque[MiddlewareType]] = {}
self.named_response_middleware: Dict[str, Deque[MiddlewareType]] = {}
self.reload_dirs: Set[Path] = set()
self.request_class = request_class
self.request_middleware: Deque[MiddlewareType] = deque()
self.response_middleware: Deque[MiddlewareType] = deque()
@@ -175,7 +186,6 @@ class Sanic(BaseSanic):
if register is not None:
self.config.REGISTER = register
if self.config.REGISTER:
self.__class__.register_app(self)
@@ -374,11 +384,19 @@ class Sanic(BaseSanic):
condition=condition,
)
def event(self, event: str, timeout: Optional[Union[int, float]] = None):
async def event(
self, event: str, timeout: Optional[Union[int, float]] = None
):
signal = self.signal_router.name_index.get(event)
if not signal:
raise NotFound("Could not find signal %s" % event)
return wait_for(signal.ctx.event.wait(), timeout=timeout)
if self.config.EVENT_AUTOREGISTER:
self.signal_router.reset()
self.add_signal(None, event)
signal = self.signal_router.name_index[event]
self.signal_router.finalize()
else:
raise NotFound("Could not find signal %s" % event)
return await wait_for(signal.ctx.event.wait(), timeout=timeout)
def enable_websocket(self, enable=True):
"""Enable or disable the support for websocket.
@@ -402,7 +420,33 @@ class Sanic(BaseSanic):
"""
if isinstance(blueprint, (list, tuple, BlueprintGroup)):
for item in blueprint:
self.blueprint(item, **options)
params = {**options}
if isinstance(blueprint, BlueprintGroup):
if blueprint.url_prefix:
merge_from = [
options.get("url_prefix", ""),
blueprint.url_prefix,
]
if not isinstance(item, BlueprintGroup):
merge_from.append(item.url_prefix or "")
merged_prefix = "/".join(
u.strip("/") for u in merge_from
).rstrip("/")
params["url_prefix"] = f"/{merged_prefix}"
for _attr in ["version", "strict_slashes"]:
if getattr(item, _attr) is None:
params[_attr] = getattr(
blueprint, _attr
) or options.get(_attr)
if item.version_prefix == "/v":
if blueprint.version_prefix == "/v":
params["version_prefix"] = options.get(
"version_prefix"
)
else:
params["version_prefix"] = blueprint.version_prefix
self.blueprint(item, **params)
return
if blueprint.name in self.blueprints:
assert self.blueprints[blueprint.name] is blueprint, (
@@ -567,7 +611,12 @@ class Sanic(BaseSanic):
# determine if the parameter supplied by the caller
# passes the test in the URL
if param_info.pattern:
passes_pattern = param_info.pattern.match(supplied_param)
pattern = (
param_info.pattern[1]
if isinstance(param_info.pattern, tuple)
else param_info.pattern
)
passes_pattern = pattern.match(supplied_param)
if not passes_pattern:
if param_info.cast != str:
msg = (
@@ -575,13 +624,13 @@ class Sanic(BaseSanic):
f"for parameter `{param_info.name}` does "
"not match pattern for type "
f"`{param_info.cast.__name__}`: "
f"{param_info.pattern.pattern}"
f"{pattern.pattern}"
)
else:
msg = (
f'Value "{supplied_param}" for parameter '
f"`{param_info.name}` does not satisfy "
f"pattern {param_info.pattern.pattern}"
f"pattern {pattern.pattern}"
)
raise URLBuildError(msg)
@@ -664,11 +713,6 @@ class Sanic(BaseSanic):
exception handling must be done here
:param request: HTTP Request object
:param write_callback: Synchronous response function to be
called with the response as the only argument
:param stream_callback: Coroutine that handles streaming a
StreamingHTTPResponse if produced by the handler.
:return: Nothing
"""
# Define `response` var here to remove warnings about
@@ -677,7 +721,9 @@ class Sanic(BaseSanic):
try:
# Fetch handler from router
route, handler, kwargs = self.router.get(
request.path, request.method, request.headers.get("host")
request.path,
request.method,
request.headers.getone("host", None),
)
request._match_info = kwargs
@@ -725,17 +771,14 @@ class Sanic(BaseSanic):
if response:
response = await request.respond(response)
else:
elif not hasattr(handler, "is_websocket"):
response = request.stream.response # type: ignore
# Make sure that response is finished / run StreamingHTTP callback
# Make sure that response is finished / run StreamingHTTP callback
if isinstance(response, BaseHTTPResponse):
await response.send(end_stream=True)
else:
try:
# Fastest method for checking if the property exists
handler.is_websocket # type: ignore
except AttributeError:
if not hasattr(handler, "is_websocket"):
raise ServerError(
f"Invalid response type {response!r} "
"(need HTTPResponse)"
@@ -762,6 +805,7 @@ class Sanic(BaseSanic):
if self.asgi:
ws = request.transport.get_websocket_connection()
await ws.accept(subprotocols)
else:
protocol = request.transport.get_protocol()
protocol.app = self
@@ -834,6 +878,7 @@ class Sanic(BaseSanic):
access_log: Optional[bool] = None,
unix: Optional[str] = None,
loop: None = None,
reload_dir: Optional[Union[List[str], str]] = None,
) -> None:
"""
Run the HTTP Server and listen until keyboard interrupt or term
@@ -868,6 +913,18 @@ class Sanic(BaseSanic):
:type unix: str
:return: Nothing
"""
if reload_dir:
if isinstance(reload_dir, str):
reload_dir = [reload_dir]
for directory in reload_dir:
direc = Path(directory)
if not direc.is_dir():
logger.warning(
f"Directory {directory} could not be located"
)
self.reload_dirs.add(Path(directory))
if loop is not None:
raise TypeError(
"loop is not a valid argument. To use an existing loop, "
@@ -877,8 +934,9 @@ class Sanic(BaseSanic):
)
if auto_reload or auto_reload is None and debug:
self.auto_reload = True
if os.environ.get("SANIC_SERVER_RUNNING") != "true":
return reloader_helpers.watchdog(1.0)
return reloader_helpers.watchdog(1.0, self)
if sock is None:
host, port = host or "127.0.0.1", port or 8000
@@ -1177,6 +1235,10 @@ class Sanic(BaseSanic):
else:
logger.info(f"Goin' Fast @ {proto}://{host}:{port}")
debug_mode = "enabled" if self.debug else "disabled"
logger.debug("Sanic auto-reload: enabled")
logger.debug(f"Sanic debug mode: {debug_mode}")
return server_settings
def _build_endpoint_name(self, *parts):

View File

@@ -140,7 +140,6 @@ class ASGIApp:
instance.ws = instance.transport.create_websocket_connection(
send, receive
)
await instance.ws.accept()
else:
raise ServerError("Received unknown ASGI scope")
@@ -164,10 +163,12 @@ class ASGIApp:
Read and stream the body in chunks from an incoming ASGI message.
"""
message = await self.transport.receive()
body = message.get("body", b"")
if not message.get("more_body", False):
self.request_body = False
return None
return message.get("body", b"")
if not body:
return None
return body
async def __aiter__(self):
while self.request_body:

View File

@@ -1,6 +1,9 @@
import re
from typing import Any, Tuple
from warnings import warn
from sanic.exceptions import SanicException
from sanic.mixins.exceptions import ExceptionMixin
from sanic.mixins.listeners import ListenerMixin
from sanic.mixins.middleware import MiddlewareMixin
@@ -8,6 +11,9 @@ from sanic.mixins.routes import RouteMixin
from sanic.mixins.signals import SignalMixin
VALID_NAME = re.compile(r"^[a-zA-Z][a-zA-Z0-9_\-]*$")
class BaseSanic(
RouteMixin,
MiddlewareMixin,
@@ -17,7 +23,25 @@ class BaseSanic(
):
__fake_slots__: Tuple[str, ...]
def __init__(self, *args, **kwargs) -> None:
def __init__(self, name: str = None, *args, **kwargs) -> None:
class_name = self.__class__.__name__
if name is None:
raise SanicException(
f"{class_name} instance cannot be unnamed. "
"Please use Sanic(name='your_application_name') instead.",
)
if not VALID_NAME.match(name):
warn(
f"{class_name} instance named '{name}' uses a format that is"
f"deprecated. Starting in version 21.12, {class_name} objects "
"must be named only using alphanumeric characters, _, or -.",
DeprecationWarning,
)
self.name = name
for base in BaseSanic.__bases__:
base.__init__(self, *args, **kwargs) # type: ignore
@@ -36,6 +60,7 @@ class BaseSanic(
f"Setting variables on {self.__class__.__name__} instances is "
"deprecated and will be removed in version 21.9. You should "
f"change your {self.__class__.__name__} instance to use "
f"instance.ctx.{name} instead."
f"instance.ctx.{name} instead.",
DeprecationWarning,
)
super().__setattr__(name, value)

View File

@@ -1,7 +1,11 @@
from collections.abc import MutableSequence
from typing import List, Optional, Union
from __future__ import annotations
import sanic
from collections.abc import MutableSequence
from typing import TYPE_CHECKING, List, Optional, Union
if TYPE_CHECKING:
from sanic.blueprints import Blueprint
class BlueprintGroup(MutableSequence):
@@ -54,9 +58,21 @@ class BlueprintGroup(MutableSequence):
app.blueprint(bpg)
"""
__slots__ = ("_blueprints", "_url_prefix", "_version", "_strict_slashes")
__slots__ = (
"_blueprints",
"_url_prefix",
"_version",
"_strict_slashes",
"_version_prefix",
)
def __init__(self, url_prefix=None, version=None, strict_slashes=None):
def __init__(
self,
url_prefix: Optional[str] = None,
version: Optional[Union[int, str, float]] = None,
strict_slashes: Optional[bool] = None,
version_prefix: str = "/v",
):
"""
Create a new Blueprint Group
@@ -65,13 +81,14 @@ class BlueprintGroup(MutableSequence):
inherited by each of the Blueprint
:param strict_slashes: URL Strict slash behavior indicator
"""
self._blueprints = []
self._blueprints: List[Blueprint] = []
self._url_prefix = url_prefix
self._version = version
self._version_prefix = version_prefix
self._strict_slashes = strict_slashes
@property
def url_prefix(self) -> str:
def url_prefix(self) -> Optional[Union[int, str, float]]:
"""
Retrieve the URL prefix being used for the Current Blueprint Group
@@ -80,7 +97,7 @@ class BlueprintGroup(MutableSequence):
return self._url_prefix
@property
def blueprints(self) -> List["sanic.Blueprint"]:
def blueprints(self) -> List[Blueprint]:
"""
Retrieve a list of all the available blueprints under this group.
@@ -107,6 +124,15 @@ class BlueprintGroup(MutableSequence):
"""
return self._strict_slashes
@property
def version_prefix(self) -> str:
"""
Version prefix; defaults to ``/v``
:return: str
"""
return self._version_prefix
def __iter__(self):
"""
Tun the class Blueprint Group into an Iterable item
@@ -161,34 +187,16 @@ class BlueprintGroup(MutableSequence):
"""
return len(self._blueprints)
def _sanitize_blueprint(self, bp: "sanic.Blueprint") -> "sanic.Blueprint":
"""
Sanitize the Blueprint Entity to override the Version and strict slash
behaviors as required.
:param bp: Sanic Blueprint entity Object
:return: Modified Blueprint
"""
if self._url_prefix:
merged_prefix = "/".join(
u.strip("/") for u in [self._url_prefix, bp.url_prefix or ""]
).rstrip("/")
bp.url_prefix = f"/{merged_prefix}"
for _attr in ["version", "strict_slashes"]:
if getattr(bp, _attr) is None:
setattr(bp, _attr, getattr(self, _attr))
return bp
def append(self, value: "sanic.Blueprint") -> None:
def append(self, value: Blueprint) -> None:
"""
The Abstract class `MutableSequence` leverages this append method to
perform the `BlueprintGroup.append` operation.
:param value: New `Blueprint` object.
:return: None
"""
self._blueprints.append(self._sanitize_blueprint(bp=value))
self._blueprints.append(value)
def insert(self, index: int, item: "sanic.Blueprint") -> None:
def insert(self, index: int, item: Blueprint) -> None:
"""
The Abstract class `MutableSequence` leverages this insert method to
perform the `BlueprintGroup.append` operation.
@@ -197,7 +205,7 @@ class BlueprintGroup(MutableSequence):
:param item: New `Blueprint` object.
:return: None
"""
self._blueprints.insert(index, self._sanitize_blueprint(item))
self._blueprints.insert(index, item)
def middleware(self, *args, **kwargs):
"""

View File

@@ -62,18 +62,20 @@ class Blueprint(BaseSanic):
"strict_slashes",
"url_prefix",
"version",
"version_prefix",
"websocket_routes",
)
def __init__(
self,
name: str,
name: str = None,
url_prefix: Optional[str] = None,
host: Optional[str] = None,
version: Optional[int] = None,
version: Optional[Union[int, str, float]] = None,
strict_slashes: Optional[bool] = None,
version_prefix: str = "/v",
):
super().__init__()
super().__init__(name=name)
self._apps: Set[Sanic] = set()
self.ctx = SimpleNamespace()
@@ -81,12 +83,16 @@ class Blueprint(BaseSanic):
self.host = host
self.listeners: Dict[str, List[ListenerType]] = {}
self.middlewares: List[MiddlewareType] = []
self.name = name
self.routes: List[Route] = []
self.statics: List[RouteHandler] = []
self.strict_slashes = strict_slashes
self.url_prefix = url_prefix
self.url_prefix = (
url_prefix[:-1]
if url_prefix and url_prefix.endswith("/")
else url_prefix
)
self.version = version
self.version_prefix = version_prefix
self.websocket_routes: List[Route] = []
def __repr__(self) -> str:
@@ -139,7 +145,13 @@ class Blueprint(BaseSanic):
return super().signal(event, *args, **kwargs)
@staticmethod
def group(*blueprints, url_prefix="", version=None, strict_slashes=None):
def group(
*blueprints,
url_prefix="",
version=None,
strict_slashes=None,
version_prefix: str = "/v",
):
"""
Create a list of blueprints, optionally grouping them under a
general URL prefix.
@@ -156,8 +168,6 @@ class Blueprint(BaseSanic):
for i in nested:
if isinstance(i, (list, tuple)):
yield from chain(i)
elif isinstance(i, BlueprintGroup):
yield from i.blueprints
else:
yield i
@@ -165,6 +175,7 @@ class Blueprint(BaseSanic):
url_prefix=url_prefix,
version=version,
strict_slashes=strict_slashes,
version_prefix=version_prefix,
)
for bp in chain(blueprints):
bps.append(bp)
@@ -182,6 +193,9 @@ class Blueprint(BaseSanic):
self._apps.add(app)
url_prefix = options.get("url_prefix", self.url_prefix)
opt_version = options.get("version", None)
opt_strict_slashes = options.get("strict_slashes", None)
opt_version_prefix = options.get("version_prefix", self.version_prefix)
routes = []
middleware = []
@@ -196,12 +210,22 @@ class Blueprint(BaseSanic):
# Prepend the blueprint URI prefix if available
uri = url_prefix + future.uri if url_prefix else future.uri
strict_slashes = (
self.strict_slashes
if future.strict_slashes is None
and self.strict_slashes is not None
else future.strict_slashes
version_prefix = self.version_prefix
for prefix in (
future.version_prefix,
opt_version_prefix,
):
if prefix and prefix != "/v":
version_prefix = prefix
break
version = self._extract_value(
future.version, opt_version, self.version
)
strict_slashes = self._extract_value(
future.strict_slashes, opt_strict_slashes, self.strict_slashes
)
name = app._generate_name(future.name)
apply_route = FutureRoute(
@@ -211,13 +235,14 @@ class Blueprint(BaseSanic):
future.host or self.host,
strict_slashes,
future.stream,
future.version or self.version,
version,
name,
future.ignore_body,
future.websocket,
future.subprotocols,
future.unquote,
future.static,
version_prefix,
)
route = app._apply_route(apply_route)
@@ -254,8 +279,6 @@ class Blueprint(BaseSanic):
app._apply_signal(signal)
self.routes = [route for route in routes if isinstance(route, Route)]
# Deprecate these in 21.6
self.websocket_routes = [
route for route in self.routes if route.ctx.websocket
]
@@ -284,3 +307,12 @@ class Blueprint(BaseSanic):
return_when=asyncio.FIRST_COMPLETED,
timeout=timeout,
)
@staticmethod
def _extract_value(*values):
value = values[-1]
for v in values:
if v is not None:
value = v
break
return value

View File

@@ -1,7 +1,10 @@
from inspect import isclass
from os import environ
from pathlib import Path
from typing import Any, Union
from typing import Any, Dict, Optional, Union
from warnings import warn
from sanic.http import Http
from .utils import load_module_from_file_location, str_to_bool
@@ -15,33 +18,64 @@ BASE_LOGO = """
"""
DEFAULT_CONFIG = {
"REQUEST_MAX_SIZE": 100000000, # 100 megabytes
"REQUEST_BUFFER_QUEUE_SIZE": 100,
"ACCESS_LOG": True,
"EVENT_AUTOREGISTER": False,
"FALLBACK_ERROR_FORMAT": "html",
"FORWARDED_FOR_HEADER": "X-Forwarded-For",
"FORWARDED_SECRET": None,
"GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec
"KEEP_ALIVE_TIMEOUT": 5, # 5 seconds
"KEEP_ALIVE": True,
"PROXIES_COUNT": None,
"REAL_IP_HEADER": None,
"REGISTER": True,
"REQUEST_BUFFER_SIZE": 65536, # 64 KiB
"REQUEST_MAX_HEADER_SIZE": 8192, # 8 KiB, but cannot exceed 16384
"REQUEST_ID_HEADER": "X-Request-ID",
"REQUEST_MAX_SIZE": 100000000, # 100 megabytes
"REQUEST_TIMEOUT": 60, # 60 seconds
"RESPONSE_TIMEOUT": 60, # 60 seconds
"KEEP_ALIVE": True,
"KEEP_ALIVE_TIMEOUT": 5, # 5 seconds
"WEBSOCKET_MAX_SIZE": 2 ** 20, # 1 megabyte
"WEBSOCKET_MAX_QUEUE": 32,
"WEBSOCKET_MAX_SIZE": 2 ** 20, # 1 megabyte
"WEBSOCKET_PING_INTERVAL": 20,
"WEBSOCKET_PING_TIMEOUT": 20,
"WEBSOCKET_READ_LIMIT": 2 ** 16,
"WEBSOCKET_WRITE_LIMIT": 2 ** 16,
"WEBSOCKET_PING_TIMEOUT": 20,
"WEBSOCKET_PING_INTERVAL": 20,
"GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec
"ACCESS_LOG": True,
"FORWARDED_SECRET": None,
"REAL_IP_HEADER": None,
"PROXIES_COUNT": None,
"FORWARDED_FOR_HEADER": "X-Forwarded-For",
"REQUEST_ID_HEADER": "X-Request-ID",
"FALLBACK_ERROR_FORMAT": "html",
"REGISTER": True,
}
class Config(dict):
def __init__(self, defaults=None, load_env=True, keep_alive=None):
ACCESS_LOG: bool
EVENT_AUTOREGISTER: bool
FALLBACK_ERROR_FORMAT: str
FORWARDED_FOR_HEADER: str
FORWARDED_SECRET: Optional[str]
GRACEFUL_SHUTDOWN_TIMEOUT: float
KEEP_ALIVE_TIMEOUT: int
KEEP_ALIVE: bool
PROXIES_COUNT: Optional[int]
REAL_IP_HEADER: Optional[str]
REGISTER: bool
REQUEST_BUFFER_SIZE: int
REQUEST_MAX_HEADER_SIZE: int
REQUEST_ID_HEADER: str
REQUEST_MAX_SIZE: int
REQUEST_TIMEOUT: int
RESPONSE_TIMEOUT: int
WEBSOCKET_MAX_QUEUE: int
WEBSOCKET_MAX_SIZE: int
WEBSOCKET_PING_INTERVAL: int
WEBSOCKET_PING_TIMEOUT: int
WEBSOCKET_READ_LIMIT: int
WEBSOCKET_WRITE_LIMIT: int
def __init__(
self,
defaults: Dict[str, Union[str, bool, int, float, None]] = None,
load_env: Optional[Union[bool, str]] = True,
env_prefix: Optional[str] = SANIC_PREFIX,
keep_alive: Optional[bool] = None,
):
defaults = defaults or {}
super().__init__({**DEFAULT_CONFIG, **defaults})
@@ -50,9 +84,22 @@ class Config(dict):
if keep_alive is not None:
self.KEEP_ALIVE = keep_alive
if load_env:
prefix = SANIC_PREFIX if load_env is True else load_env
self.load_environment_vars(prefix=prefix)
if env_prefix != SANIC_PREFIX:
if env_prefix:
self.load_environment_vars(env_prefix)
elif load_env is not True:
if load_env:
self.load_environment_vars(prefix=load_env)
warn(
"Use of load_env is deprecated and will be removed in "
"21.12. Modify the configuration prefix by passing "
"env_prefix instead.",
DeprecationWarning,
)
else:
self.load_environment_vars(SANIC_PREFIX)
self._configure_header_size()
def __getattr__(self, attr):
try:
@@ -62,6 +109,19 @@ class Config(dict):
def __setattr__(self, attr, value):
self[attr] = value
if attr in (
"REQUEST_MAX_HEADER_SIZE",
"REQUEST_BUFFER_SIZE",
"REQUEST_MAX_SIZE",
):
self._configure_header_size()
def _configure_header_size(self):
Http.set_header_max_size(
self.REQUEST_MAX_HEADER_SIZE,
self.REQUEST_BUFFER_SIZE - 4096,
self.REQUEST_MAX_SIZE,
)
def load_environment_vars(self, prefix=SANIC_PREFIX):
"""

View File

@@ -1,2 +1,28 @@
HTTP_METHODS = ("GET", "POST", "PUT", "HEAD", "OPTIONS", "PATCH", "DELETE")
from enum import Enum, auto
class HTTPMethod(str, Enum):
def _generate_next_value_(name, start, count, last_values):
return name.upper()
def __eq__(self, value: object) -> bool:
value = str(value).upper()
return super().__eq__(value)
def __hash__(self) -> int:
return hash(self.value)
def __str__(self) -> str:
return self.value
GET = auto()
POST = auto()
PUT = auto()
HEAD = auto()
OPTIONS = auto()
PATCH = auto()
DELETE = auto()
HTTP_METHODS = tuple(HTTPMethod.__members__.values())
DEFAULT_HTTP_CONTENT_TYPE = "application/octet-stream"

View File

@@ -366,7 +366,7 @@ def exception_response(
except InvalidUsage:
renderer = HTMLRenderer
content_type, *_ = request.headers.get(
content_type, *_ = request.headers.getone(
"content-type", ""
).split(";")
renderer = RENDERERS_BY_CONTENT_TYPE.get(

View File

@@ -3,26 +3,18 @@ from typing import Optional, Union
from sanic.helpers import STATUS_CODES
_sanic_exceptions = {}
def add_status_code(code, quiet=None):
"""
Decorator used for adding exceptions to :class:`SanicException`.
"""
def class_decorator(cls):
cls.status_code = code
if quiet or quiet is None and code != 500:
cls.quiet = True
_sanic_exceptions[code] = cls
return cls
return class_decorator
class SanicException(Exception):
def __init__(self, message, status_code=None, quiet=None):
def __init__(
self,
message: Optional[Union[str, bytes]] = None,
status_code: Optional[int] = None,
quiet: Optional[bool] = None,
) -> None:
if message is None and status_code is not None:
msg: bytes = STATUS_CODES.get(status_code, b"")
message = msg.decode("utf8")
super().__init__(message)
if status_code is not None:
@@ -33,45 +25,42 @@ class SanicException(Exception):
self.quiet = True
@add_status_code(404)
class NotFound(SanicException):
"""
**Status**: 404 Not Found
"""
pass
status_code = 404
@add_status_code(400)
class InvalidUsage(SanicException):
"""
**Status**: 400 Bad Request
"""
pass
status_code = 400
@add_status_code(405)
class MethodNotSupported(SanicException):
"""
**Status**: 405 Method Not Allowed
"""
status_code = 405
def __init__(self, message, method, allowed_methods):
super().__init__(message)
self.headers = {"Allow": ", ".join(allowed_methods)}
@add_status_code(500)
class ServerError(SanicException):
"""
**Status**: 500 Internal Server Error
"""
pass
status_code = 500
@add_status_code(503)
class ServiceUnavailable(SanicException):
"""
**Status**: 503 Service Unavailable
@@ -80,7 +69,7 @@ class ServiceUnavailable(SanicException):
down for maintenance). Generally, this is a temporary state.
"""
pass
status_code = 503
class URLBuildError(ServerError):
@@ -88,7 +77,7 @@ class URLBuildError(ServerError):
**Status**: 500 Internal Server Error
"""
pass
status_code = 500
class FileNotFound(NotFound):
@@ -102,7 +91,6 @@ class FileNotFound(NotFound):
self.relative_url = relative_url
@add_status_code(408)
class RequestTimeout(SanicException):
"""The Web server (running the Web site) thinks that there has been too
long an interval of time between 1) the establishment of an IP
@@ -112,16 +100,15 @@ class RequestTimeout(SanicException):
server has 'timed out' on that particular socket connection.
"""
pass
status_code = 408
@add_status_code(413)
class PayloadTooLarge(SanicException):
"""
**Status**: 413 Payload Too Large
"""
pass
status_code = 413
class HeaderNotFound(InvalidUsage):
@@ -129,36 +116,35 @@ class HeaderNotFound(InvalidUsage):
**Status**: 400 Bad Request
"""
pass
status_code = 400
@add_status_code(416)
class ContentRangeError(SanicException):
"""
**Status**: 416 Range Not Satisfiable
"""
status_code = 416
def __init__(self, message, content_range):
super().__init__(message)
self.headers = {"Content-Range": f"bytes */{content_range.total}"}
@add_status_code(417)
class HeaderExpectationFailed(SanicException):
"""
**Status**: 417 Expectation Failed
"""
pass
status_code = 417
@add_status_code(403)
class Forbidden(SanicException):
"""
**Status**: 403 Forbidden
"""
pass
status_code = 403
class InvalidRangeType(ContentRangeError):
@@ -166,7 +152,7 @@ class InvalidRangeType(ContentRangeError):
**Status**: 416 Range Not Satisfiable
"""
pass
status_code = 416
class PyFileError(Exception):
@@ -174,7 +160,6 @@ class PyFileError(Exception):
super().__init__("could not execute config file %s", file)
@add_status_code(401)
class Unauthorized(SanicException):
"""
**Status**: 401 Unauthorized
@@ -210,6 +195,8 @@ class Unauthorized(SanicException):
realm="Restricted Area")
"""
status_code = 401
def __init__(self, message, status_code=None, scheme=None, **kwargs):
super().__init__(message, status_code)
@@ -241,9 +228,13 @@ def abort(status_code: int, message: Optional[Union[str, bytes]] = None):
:param status_code: The HTTP status code to return.
:param message: The HTTP response body. Defaults to the messages in
"""
if message is None:
msg: bytes = STATUS_CODES[status_code]
# These are stored as bytes in the STATUS_CODES dict
message = msg.decode("utf8")
sanic_exception = _sanic_exceptions.get(status_code, SanicException)
raise sanic_exception(message=message, status_code=status_code)
import warnings
warnings.warn(
"sanic.exceptions.abort has been marked as deprecated, and will be "
"removed in release 21.12.\n To migrate your code, simply replace "
"abort(status_code, msg) with raise SanicException(msg, status_code), "
"or even better, raise an appropriate SanicException subclass."
)
raise SanicException(message=message, status_code=status_code)

View File

@@ -6,7 +6,7 @@ from sanic.exceptions import (
HeaderNotFound,
InvalidRangeType,
)
from sanic.log import logger
from sanic.log import error_logger
from sanic.response import text
@@ -25,7 +25,6 @@ class ErrorHandler:
handlers = None
cached_handlers = None
_missing = object()
def __init__(self):
self.handlers = []
@@ -45,7 +44,9 @@ class ErrorHandler:
:return: None
"""
# self.handlers to be deprecated and removed in version 21.12
self.handlers.append((exception, handler))
self.cached_handlers[exception] = handler
def lookup(self, exception):
"""
@@ -61,14 +62,19 @@ class ErrorHandler:
:return: Registered function if found ``None`` otherwise
"""
handler = self.cached_handlers.get(type(exception), self._missing)
if handler is self._missing:
for exception_class, handler in self.handlers:
if isinstance(exception, exception_class):
self.cached_handlers[type(exception)] = handler
return handler
self.cached_handlers[type(exception)] = None
handler = None
exception_class = type(exception)
if exception_class in self.cached_handlers:
return self.cached_handlers[exception_class]
for ancestor in type.mro(exception_class):
if ancestor in self.cached_handlers:
handler = self.cached_handlers[ancestor]
self.cached_handlers[exception_class] = handler
return handler
if ancestor is BaseException:
break
self.cached_handlers[exception_class] = None
handler = None
return handler
def response(self, request, exception):
@@ -101,7 +107,7 @@ class ErrorHandler:
response_message = (
"Exception raised in exception handler " '"%s" for uri: %s'
)
logger.exception(response_message, handler.__name__, url)
error_logger.exception(response_message, handler.__name__, url)
if self.debug:
return text(response_message % (handler.__name__, url), 500)
@@ -137,7 +143,9 @@ class ErrorHandler:
url = "unknown"
self.log(format_exc())
logger.exception("Exception occurred while handling uri: %s", url)
error_logger.exception(
"Exception occurred while handling uri: %s", url
)
return exception_response(request, exception, self.debug)
@@ -165,7 +173,7 @@ class ContentRangeHandler:
def __init__(self, request, stats):
self.total = stats.st_size
_range = request.headers.get("Range")
_range = request.headers.getone("range", None)
if _range is None:
raise HeaderNotFound("Range Header Not Found")
unit, _, value = tuple(map(str.strip, _range.partition("=")))

View File

@@ -102,7 +102,7 @@ def parse_xforwarded(headers, config) -> Optional[Options]:
"""Parse traditional proxy headers."""
real_ip_header = config.REAL_IP_HEADER
proxies_count = config.PROXIES_COUNT
addr = real_ip_header and headers.get(real_ip_header)
addr = real_ip_header and headers.getone(real_ip_header, None)
if not addr and proxies_count:
assert proxies_count > 0
try:
@@ -131,7 +131,7 @@ def parse_xforwarded(headers, config) -> Optional[Options]:
("port", "x-forwarded-port"),
("path", "x-forwarded-path"),
):
yield key, headers.get(header)
yield key, headers.getone(header, None)
return fwd_normalize(options())

View File

@@ -20,7 +20,7 @@ from sanic.exceptions import (
)
from sanic.headers import format_http1_response
from sanic.helpers import has_message_body
from sanic.log import access_logger, logger
from sanic.log import access_logger, error_logger, logger
class Stage(Enum):
@@ -64,6 +64,9 @@ class Http:
:raises RuntimeError:
"""
HEADER_CEILING = 16_384
HEADER_MAX_SIZE = 0
__slots__ = [
"_send",
"_receive_more",
@@ -82,6 +85,7 @@ class Http:
"request_max_size",
"response",
"response_func",
"response_size",
"response_bytes_left",
"upgrade_websocket",
]
@@ -143,7 +147,7 @@ class Http:
# Try to consume any remaining request body
if self.request_body:
if self.response and 200 <= self.response.status < 300:
logger.error(f"{self.request} body not consumed.")
error_logger.error(f"{self.request} body not consumed.")
try:
async for _ in self:
@@ -168,7 +172,6 @@ class Http:
"""
Receive and parse request header into self.request.
"""
HEADER_MAX_SIZE = min(8192, self.request_max_size)
# Receive until full header is in buffer
buf = self.recv_buffer
pos = 0
@@ -179,12 +182,12 @@ class Http:
break
pos = max(0, len(buf) - 3)
if pos >= HEADER_MAX_SIZE:
if pos >= self.HEADER_MAX_SIZE:
break
await self._receive_more()
if pos >= HEADER_MAX_SIZE:
if pos >= self.HEADER_MAX_SIZE:
raise PayloadTooLarge("Request header exceeds the size limit")
# Parse header content
@@ -218,7 +221,9 @@ class Http:
raise InvalidUsage("Bad Request")
headers_instance = Header(headers)
self.upgrade_websocket = headers_instance.get("upgrade") == "websocket"
self.upgrade_websocket = (
headers_instance.getone("upgrade", "").lower() == "websocket"
)
# Prepare a Request object
request = self.protocol.request_class(
@@ -235,7 +240,7 @@ class Http:
self.request_bytes_left = self.request_bytes = 0
if request_body:
headers = request.headers
expect = headers.get("expect")
expect = headers.getone("expect", None)
if expect is not None:
if expect.lower() == "100-continue":
@@ -243,7 +248,7 @@ class Http:
else:
raise HeaderExpectationFailed(f"Unknown Expect: {expect}")
if headers.get("transfer-encoding") == "chunked":
if headers.getone("transfer-encoding", None) == "chunked":
self.request_body = "chunked"
pos -= 2 # One CRLF stays in buffer
else:
@@ -270,6 +275,7 @@ class Http:
size = len(data)
headers = res.headers
status = res.status
self.response_size = size
if not isinstance(status, int) or status < 200:
raise RuntimeError(f"Invalid response status {status!r}")
@@ -424,7 +430,9 @@ class Http:
req, res = self.request, self.response
extra = {
"status": getattr(res, "status", 0),
"byte": getattr(self, "response_bytes_left", -1),
"byte": getattr(
self, "response_bytes_left", getattr(self, "response_size", -1)
),
"host": "UNKNOWN",
"request": "nil",
}
@@ -535,3 +543,10 @@ class Http:
@property
def send(self):
return self.response_func
@classmethod
def set_header_max_size(cls, *sizes: int):
cls.HEADER_MAX_SIZE = min(
*sizes,
cls.HEADER_CEILING,
)

View File

@@ -35,7 +35,7 @@ class ListenerMixin:
"""
Create a listener from a decorated function.
To be used as a deocrator:
To be used as a decorator:
.. code-block:: python

View File

@@ -26,10 +26,11 @@ from sanic.views import CompositionView
class RouteMixin:
name: str
def __init__(self, *args, **kwargs) -> None:
self._future_routes: Set[FutureRoute] = set()
self._future_statics: Set[FutureStatic] = set()
self.name = ""
self.strict_slashes: Optional[bool] = False
def _apply_route(self, route: FutureRoute) -> List[Route]:
@@ -45,7 +46,7 @@ class RouteMixin:
host: Optional[str] = None,
strict_slashes: Optional[bool] = None,
stream: bool = False,
version: Optional[int] = None,
version: Optional[Union[int, str, float]] = None,
name: Optional[str] = None,
ignore_body: bool = False,
apply: bool = True,
@@ -53,6 +54,7 @@ class RouteMixin:
websocket: bool = False,
unquote: bool = False,
static: bool = False,
version_prefix: str = "/v",
):
"""
Decorate a function to be registered as a route
@@ -66,12 +68,14 @@ class RouteMixin:
:param name: user defined route name for url_for
:param ignore_body: whether the handler should ignore request
body (eg. GET requests)
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: tuple of routes, decorated function
"""
# Fix case where the user did not prefix the URL with a /
# and will probably get confused as to why it's not working
if not uri.startswith("/"):
if not uri.startswith("/") and (uri or hasattr(self, "router")):
uri = "/" + uri
if strict_slashes is None:
@@ -92,6 +96,7 @@ class RouteMixin:
nonlocal subprotocols
nonlocal websocket
nonlocal static
nonlocal version_prefix
if isinstance(handler, tuple):
# if a handler fn is already wrapped in a route, the handler
@@ -128,6 +133,7 @@ class RouteMixin:
subprotocols,
unquote,
static,
version_prefix,
)
self._future_routes.add(route)
@@ -154,7 +160,9 @@ class RouteMixin:
if apply:
self._apply_route(route)
return route, handler
if static:
return route, handler
return handler
return decorator
@@ -168,6 +176,7 @@ class RouteMixin:
version: Optional[int] = None,
name: Optional[str] = None,
stream: bool = False,
version_prefix: str = "/v",
):
"""A helper method to register class instance or
functions as a handler to the application url
@@ -182,6 +191,8 @@ class RouteMixin:
:param version:
:param name: user defined route name for url_for
:param stream: boolean specifying if the handler is a stream handler
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: function or class instance
"""
# Handle HTTPMethodView differently
@@ -214,6 +225,7 @@ class RouteMixin:
stream=stream,
version=version,
name=name,
version_prefix=version_prefix,
)(handler)
return handler
@@ -226,6 +238,7 @@ class RouteMixin:
version: Optional[int] = None,
name: Optional[str] = None,
ignore_body: bool = True,
version_prefix: str = "/v",
):
"""
Add an API URL under the **GET** *HTTP* method
@@ -236,6 +249,8 @@ class RouteMixin:
URLs need to terminate with a */*
:param version: API Version
:param name: Unique name that can be used to identify the Route
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Object decorated with :func:`route` method
"""
return self.route(
@@ -246,6 +261,7 @@ class RouteMixin:
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
)
def post(
@@ -256,6 +272,7 @@ class RouteMixin:
stream: bool = False,
version: Optional[int] = None,
name: Optional[str] = None,
version_prefix: str = "/v",
):
"""
Add an API URL under the **POST** *HTTP* method
@@ -266,6 +283,8 @@ class RouteMixin:
URLs need to terminate with a */*
:param version: API Version
:param name: Unique name that can be used to identify the Route
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Object decorated with :func:`route` method
"""
return self.route(
@@ -276,6 +295,7 @@ class RouteMixin:
stream=stream,
version=version,
name=name,
version_prefix=version_prefix,
)
def put(
@@ -286,6 +306,7 @@ class RouteMixin:
stream: bool = False,
version: Optional[int] = None,
name: Optional[str] = None,
version_prefix: str = "/v",
):
"""
Add an API URL under the **PUT** *HTTP* method
@@ -296,6 +317,8 @@ class RouteMixin:
URLs need to terminate with a */*
:param version: API Version
:param name: Unique name that can be used to identify the Route
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Object decorated with :func:`route` method
"""
return self.route(
@@ -306,6 +329,7 @@ class RouteMixin:
stream=stream,
version=version,
name=name,
version_prefix=version_prefix,
)
def head(
@@ -316,6 +340,7 @@ class RouteMixin:
version: Optional[int] = None,
name: Optional[str] = None,
ignore_body: bool = True,
version_prefix: str = "/v",
):
"""
Add an API URL under the **HEAD** *HTTP* method
@@ -334,6 +359,8 @@ class RouteMixin:
:param ignore_body: whether the handler should ignore request
body (eg. GET requests), defaults to True
:type ignore_body: bool, optional
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Object decorated with :func:`route` method
"""
return self.route(
@@ -344,6 +371,7 @@ class RouteMixin:
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
)
def options(
@@ -354,6 +382,7 @@ class RouteMixin:
version: Optional[int] = None,
name: Optional[str] = None,
ignore_body: bool = True,
version_prefix: str = "/v",
):
"""
Add an API URL under the **OPTIONS** *HTTP* method
@@ -372,6 +401,8 @@ class RouteMixin:
:param ignore_body: whether the handler should ignore request
body (eg. GET requests), defaults to True
:type ignore_body: bool, optional
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Object decorated with :func:`route` method
"""
return self.route(
@@ -382,6 +413,7 @@ class RouteMixin:
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
)
def patch(
@@ -392,6 +424,7 @@ class RouteMixin:
stream=False,
version: Optional[int] = None,
name: Optional[str] = None,
version_prefix: str = "/v",
):
"""
Add an API URL under the **PATCH** *HTTP* method
@@ -412,6 +445,8 @@ class RouteMixin:
:param ignore_body: whether the handler should ignore request
body (eg. GET requests), defaults to True
:type ignore_body: bool, optional
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Object decorated with :func:`route` method
"""
return self.route(
@@ -422,6 +457,7 @@ class RouteMixin:
stream=stream,
version=version,
name=name,
version_prefix=version_prefix,
)
def delete(
@@ -432,6 +468,7 @@ class RouteMixin:
version: Optional[int] = None,
name: Optional[str] = None,
ignore_body: bool = True,
version_prefix: str = "/v",
):
"""
Add an API URL under the **DELETE** *HTTP* method
@@ -442,6 +479,8 @@ class RouteMixin:
URLs need to terminate with a */*
:param version: API Version
:param name: Unique name that can be used to identify the Route
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Object decorated with :func:`route` method
"""
return self.route(
@@ -452,6 +491,7 @@ class RouteMixin:
version=version,
name=name,
ignore_body=ignore_body,
version_prefix=version_prefix,
)
def websocket(
@@ -463,6 +503,7 @@ class RouteMixin:
version: Optional[int] = None,
name: Optional[str] = None,
apply: bool = True,
version_prefix: str = "/v",
):
"""
Decorate a function to be registered as a websocket route
@@ -474,6 +515,8 @@ class RouteMixin:
:param subprotocols: optional list of str with supported subprotocols
:param name: A unique name assigned to the URL so that it can
be used with :func:`url_for`
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: tuple of routes, decorated function
"""
return self.route(
@@ -486,6 +529,7 @@ class RouteMixin:
apply=apply,
subprotocols=subprotocols,
websocket=True,
version_prefix=version_prefix,
)
def add_websocket_route(
@@ -497,6 +541,7 @@ class RouteMixin:
subprotocols=None,
version: Optional[int] = None,
name: Optional[str] = None,
version_prefix: str = "/v",
):
"""
A helper method to register a function as a websocket route.
@@ -513,6 +558,8 @@ class RouteMixin:
handshake
:param name: A unique name assigned to the URL so that it can
be used with :func:`url_for`
:param version_prefix: URL path that should be before the version
value; default: ``/v``
:return: Objected decorated by :func:`websocket`
"""
return self.websocket(
@@ -522,6 +569,7 @@ class RouteMixin:
subprotocols=subprotocols,
version=version,
name=name,
version_prefix=version_prefix,
)(handler)
def static(
@@ -665,7 +713,10 @@ class RouteMixin:
modified_since = strftime(
"%a, %d %b %Y %H:%M:%S GMT", gmtime(stats.st_mtime)
)
if request.headers.get("If-Modified-Since") == modified_since:
if (
request.headers.getone("if-modified-since", None)
== modified_since
):
return HTTPResponse(status=304)
headers["Last-Modified"] = modified_since
_range = None
@@ -718,16 +769,18 @@ class RouteMixin:
return await file(file_path, headers=headers, _range=_range)
except ContentRangeError:
raise
except Exception:
error_logger.exception(
f"File not found: path={file_or_directory}, "
f"relative_url={__file_uri__}"
)
except FileNotFoundError:
raise FileNotFound(
"File not found",
path=file_or_directory,
relative_url=__file_uri__,
)
except Exception:
error_logger.exception(
f"Exception in static request handler:\
path={file_or_directory}, "
f"relative_url={__file_uri__}"
)
def _register_static(
self,
@@ -776,7 +829,7 @@ class RouteMixin:
# If we're not trying to match a file directly,
# serve from the folder
if not path.isfile(file_or_directory):
uri += "/<__file_uri__>"
uri += "/<__file_uri__:path>"
# special prefix for static files
# if not static.name.startswith("_static_"):

View File

@@ -1,4 +1,4 @@
from typing import Any, Callable, Dict, Set
from typing import Any, Callable, Dict, Optional, Set
from sanic.models.futures import FutureSignal
from sanic.models.handler_types import SignalHandler
@@ -60,10 +60,16 @@ class SignalMixin:
def add_signal(
self,
handler,
handler: Optional[Callable[..., Any]],
event: str,
condition: Dict[str, Any] = None,
):
if not handler:
async def noop():
...
handler = noop
self.signal(event=event, condition=condition)(handler)
return handler

View File

@@ -23,6 +23,7 @@ class FutureRoute(NamedTuple):
subprotocols: Optional[List[str]]
unquote: bool
static: bool
version_prefix: str
class FutureListener(NamedTuple):

View File

@@ -1,3 +1,4 @@
import itertools
import os
import signal
import subprocess
@@ -5,6 +6,9 @@ import sys
from time import sleep
from sanic.config import BASE_LOGO
from sanic.log import logger
def _iter_module_files():
"""This iterates over all relevant Python files.
@@ -56,7 +60,21 @@ def restart_with_reloader():
)
def watchdog(sleep_interval):
def _check_file(filename, mtimes):
need_reload = False
mtime = os.stat(filename).st_mtime
old_time = mtimes.get(filename)
if old_time is None:
mtimes[filename] = mtime
elif mtime > old_time:
mtimes[filename] = mtime
need_reload = True
return need_reload
def watchdog(sleep_interval, app):
"""Watch project files, restart worker process if a change happened.
:param sleep_interval: interval in second.
@@ -73,21 +91,25 @@ def watchdog(sleep_interval):
worker_process = restart_with_reloader()
if app.config.LOGO:
logger.debug(
app.config.LOGO if isinstance(app.config.LOGO, str) else BASE_LOGO
)
try:
while True:
need_reload = False
for filename in _iter_module_files():
for filename in itertools.chain(
_iter_module_files(),
*(d.glob("**/*") for d in app.reload_dirs),
):
try:
mtime = os.stat(filename).st_mtime
check = _check_file(filename, mtimes)
except OSError:
continue
old_time = mtimes.get(filename)
if old_time is None:
mtimes[filename] = mtime
elif mtime > old_time:
mtimes[filename] = mtime
if check:
need_reload = True
if need_reload:

View File

@@ -125,7 +125,7 @@ class Request:
self._name: Optional[str] = None
self.app = app
self.headers = headers
self.headers = Header(headers)
self.version = version
self.method = method
self.transport = transport
@@ -262,7 +262,7 @@ class Request:
app = Sanic("MyApp", request_class=IntRequest)
"""
if not self._id:
self._id = self.headers.get(
self._id = self.headers.getone(
self.app.config.REQUEST_ID_HEADER,
self.__class__.generate_id(self), # type: ignore
)
@@ -303,7 +303,7 @@ class Request:
:return: token related to request
"""
prefixes = ("Bearer", "Token")
auth_header = self.headers.get("Authorization")
auth_header = self.headers.getone("authorization", None)
if auth_header is not None:
for prefix in prefixes:
@@ -317,8 +317,8 @@ class Request:
if self.parsed_form is None:
self.parsed_form = RequestParameters()
self.parsed_files = RequestParameters()
content_type = self.headers.get(
"Content-Type", DEFAULT_HTTP_CONTENT_TYPE
content_type = self.headers.getone(
"content-type", DEFAULT_HTTP_CONTENT_TYPE
)
content_type, parameters = parse_content_header(content_type)
try:
@@ -378,9 +378,12 @@ class Request:
:type errors: str
:return: RequestParameters
"""
if not self.parsed_args[
(keep_blank_values, strict_parsing, encoding, errors)
]:
if (
keep_blank_values,
strict_parsing,
encoding,
errors,
) not in self.parsed_args:
if self.query_string:
self.parsed_args[
(keep_blank_values, strict_parsing, encoding, errors)
@@ -434,9 +437,12 @@ class Request:
:type errors: str
:return: list
"""
if not self.parsed_not_grouped_args[
(keep_blank_values, strict_parsing, encoding, errors)
]:
if (
keep_blank_values,
strict_parsing,
encoding,
errors,
) not in self.parsed_not_grouped_args:
if self.query_string:
self.parsed_not_grouped_args[
(keep_blank_values, strict_parsing, encoding, errors)
@@ -465,7 +471,7 @@ class Request:
"""
if self._cookies is None:
cookie = self.headers.get("Cookie")
cookie = self.headers.getone("cookie", None)
if cookie is not None:
cookies: SimpleCookie = SimpleCookie()
cookies.load(cookie)
@@ -482,7 +488,7 @@ class Request:
:return: Content-Type header form the request
:rtype: str
"""
return self.headers.get("Content-Type", DEFAULT_HTTP_CONTENT_TYPE)
return self.headers.getone("content-type", DEFAULT_HTTP_CONTENT_TYPE)
@property
def match_info(self):
@@ -499,7 +505,7 @@ class Request:
:return: peer ip of the socket
:rtype: str
"""
return self.conn_info.client if self.conn_info else ""
return self.conn_info.client_ip if self.conn_info else ""
@property
def port(self) -> int:
@@ -581,7 +587,7 @@ class Request:
if (
self.app.websocket_enabled
and self.headers.get("upgrade") == "websocket"
and self.headers.getone("upgrade", "").lower() == "websocket"
):
scheme = "ws"
else:
@@ -608,7 +614,9 @@ class Request:
server_name = self.app.config.get("SERVER_NAME")
if server_name:
return server_name.split("//", 1)[-1].split("/", 1)[0]
return str(self.forwarded.get("host") or self.headers.get("host", ""))
return str(
self.forwarded.get("host") or self.headers.getone("host", "")
)
@property
def server_name(self) -> str:

View File

@@ -143,7 +143,7 @@ class StreamingHTTPResponse(BaseHTTPResponse):
.. warning::
**Deprecated** and set for removal in v21.6. You can now achieve the
**Deprecated** and set for removal in v21.12. You can now achieve the
same functionality without a callback.
.. code-block:: python
@@ -174,12 +174,16 @@ class StreamingHTTPResponse(BaseHTTPResponse):
status: int = 200,
headers: Optional[Union[Header, Dict[str, str]]] = None,
content_type: str = "text/plain; charset=utf-8",
chunked="deprecated",
ignore_deprecation_notice: bool = False,
):
if chunked != "deprecated":
if not ignore_deprecation_notice:
warn(
"The chunked argument has been deprecated and will be "
"removed in v21.6"
"Use of the StreamingHTTPResponse is deprecated in v21.6, and "
"will be removed in v21.12. Please upgrade your streaming "
"response implementation. You can learn more here: "
"https://sanicframework.org/en/guide/advanced/streaming.html"
"#response-streaming. If you use the builtin stream() or "
"file_stream() methods, this upgrade will be be done for you."
)
super().__init__()
@@ -203,6 +207,9 @@ class StreamingHTTPResponse(BaseHTTPResponse):
self.streaming_fn = None
await super().send(*args, **kwargs)
async def eof(self):
raise NotImplementedError
class HTTPResponse(BaseHTTPResponse):
"""
@@ -235,6 +242,15 @@ class HTTPResponse(BaseHTTPResponse):
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(
status=204, headers: Optional[Dict[str, str]] = None
@@ -396,7 +412,6 @@ async def file_stream(
mime_type: Optional[str] = None,
headers: Optional[Dict[str, str]] = None,
filename: Optional[str] = None,
chunked="deprecated",
_range: Optional[Range] = None,
) -> StreamingHTTPResponse:
"""Return a streaming response object with file data.
@@ -409,12 +424,6 @@ async def file_stream(
:param chunked: Deprecated
:param _range:
"""
if chunked != "deprecated":
warn(
"The chunked argument has been deprecated and will be "
"removed in v21.6"
)
headers = headers or {}
if filename:
headers.setdefault(
@@ -453,6 +462,7 @@ async def file_stream(
status=status,
headers=headers,
content_type=mime_type,
ignore_deprecation_notice=True,
)
@@ -461,7 +471,6 @@ def stream(
status: int = 200,
headers: Optional[Dict[str, str]] = None,
content_type: str = "text/plain; charset=utf-8",
chunked="deprecated",
):
"""Accepts an coroutine `streaming_fn` which can be used to
write chunks to a streaming response. Returns a `StreamingHTTPResponse`.
@@ -482,17 +491,12 @@ def stream(
:param headers: Custom Headers.
:param chunked: Deprecated
"""
if chunked != "deprecated":
warn(
"The chunked argument has been deprecated and will be "
"removed in v21.6"
)
return StreamingHTTPResponse(
streaming_fn,
headers=headers,
content_type=content_type,
status=status,
ignore_deprecation_notice=True,
)

View File

@@ -33,7 +33,7 @@ class Router(BaseRouter):
return self.resolve(
path=path,
method=method,
extra={"host": host},
extra={"host": host} if host else None,
)
except RoutingNotFound as e:
raise NotFound("Requested URL {} not found".format(e.path))
@@ -73,6 +73,7 @@ class Router(BaseRouter):
name: Optional[str] = None,
unquote: bool = False,
static: bool = False,
version_prefix: str = "/v",
) -> Union[Route, List[Route]]:
"""
Add a handler to the router
@@ -103,12 +104,12 @@ class Router(BaseRouter):
"""
if version is not None:
version = str(version).strip("/").lstrip("v")
uri = "/".join([f"/v{version}", uri.lstrip("/")])
uri = "/".join([f"{version_prefix}{version}", uri.lstrip("/")])
params = dict(
path=uri,
handler=handler,
methods=methods,
methods=frozenset(map(str, methods)) if methods else None,
name=name,
strict=strict_slashes,
unquote=unquote,
@@ -161,7 +162,7 @@ class Router(BaseRouter):
@property
def routes_all(self):
return self.routes
return {route.parts: route for route in self.routes}
@property
def routes_static(self):

View File

@@ -39,7 +39,7 @@ from sanic.compat import OS_IS_WINDOWS, ctrlc_workaround_for_windows
from sanic.config import Config
from sanic.exceptions import RequestTimeout, ServiceUnavailable
from sanic.http import Http, Stage
from sanic.log import logger
from sanic.log import error_logger, logger
from sanic.models.protocol_types import TransportProtocol
from sanic.request import Request
@@ -65,6 +65,7 @@ class ConnInfo:
__slots__ = (
"client_port",
"client",
"client_ip",
"ctx",
"peername",
"server_port",
@@ -78,6 +79,7 @@ class ConnInfo:
self.peername = None
self.server = self.client = ""
self.server_port = self.client_port = 0
self.client_ip = ""
self.sockname = addr = transport.get_extra_info("sockname")
self.ssl: bool = bool(transport.get_extra_info("sslcontext"))
@@ -96,6 +98,7 @@ class ConnInfo:
if isinstance(addr, tuple):
self.client = addr[0] if len(addr) == 2 else f"[{addr[0]}]"
self.client_ip = addr[0]
self.client_port = addr[1]
@@ -122,7 +125,6 @@ class HttpProtocol(asyncio.Protocol):
"response_timeout",
"keep_alive_timeout",
"request_max_size",
"request_buffer_queue_size",
"request_class",
"error_handler",
# enable or disable access log purpose
@@ -165,9 +167,6 @@ class HttpProtocol(asyncio.Protocol):
self.request_handler = self.app.handle_request
self.error_handler = self.app.error_handler
self.request_timeout = self.app.config.REQUEST_TIMEOUT
self.request_buffer_queue_size = (
self.app.config.REQUEST_BUFFER_QUEUE_SIZE
)
self.response_timeout = self.app.config.RESPONSE_TIMEOUT
self.keep_alive_timeout = self.app.config.KEEP_ALIVE_TIMEOUT
self.request_max_size = self.app.config.REQUEST_MAX_SIZE
@@ -199,11 +198,11 @@ class HttpProtocol(asyncio.Protocol):
except CancelledError:
pass
except Exception:
logger.exception("protocol.connection_task uncaught")
error_logger.exception("protocol.connection_task uncaught")
finally:
if self.app.debug and self._http:
ip = self.transport.get_extra_info("peername")
logger.error(
error_logger.error(
"Connection lost before response written"
f" @ {ip} {self._http.request}"
)
@@ -212,7 +211,7 @@ class HttpProtocol(asyncio.Protocol):
try:
self.close()
except BaseException:
logger.exception("Closing failed")
error_logger.exception("Closing failed")
async def receive_more(self):
"""
@@ -234,11 +233,16 @@ class HttpProtocol(asyncio.Protocol):
if stage is Stage.IDLE and duration > self.keep_alive_timeout:
logger.debug("KeepAlive Timeout. Closing connection.")
elif stage is Stage.REQUEST and duration > self.request_timeout:
logger.debug("Request Timeout. Closing connection.")
self._http.exception = RequestTimeout("Request Timeout")
elif stage is Stage.HANDLER and self._http.upgrade_websocket:
logger.debug("Handling websocket. Timeouts disabled.")
return
elif (
stage in (Stage.HANDLER, Stage.RESPONSE, Stage.FAILED)
and duration > self.response_timeout
):
logger.debug("Response Timeout. Closing connection.")
self._http.exception = ServiceUnavailable("Response Timeout")
else:
interval = (
@@ -253,7 +257,7 @@ class HttpProtocol(asyncio.Protocol):
return
self._task.cancel()
except Exception:
logger.exception("protocol.check_timeouts")
error_logger.exception("protocol.check_timeouts")
async def send(self, data):
"""
@@ -299,7 +303,7 @@ class HttpProtocol(asyncio.Protocol):
self.recv_buffer = bytearray()
self.conn_info = ConnInfo(self.transport, unix=self._unix)
except Exception:
logger.exception("protocol.connect_made")
error_logger.exception("protocol.connect_made")
def connection_lost(self, exc):
try:
@@ -308,7 +312,7 @@ class HttpProtocol(asyncio.Protocol):
if self._task:
self._task.cancel()
except Exception:
logger.exception("protocol.connection_lost")
error_logger.exception("protocol.connection_lost")
def pause_writing(self):
self._can_write.clear()
@@ -332,7 +336,7 @@ class HttpProtocol(asyncio.Protocol):
if self._data_received:
self._data_received.set()
except Exception:
logger.exception("protocol.data_received")
error_logger.exception("protocol.data_received")
def trigger_events(events: Optional[Iterable[Callable[..., Any]]], loop):
@@ -551,7 +555,7 @@ def serve(
try:
http_server = loop.run_until_complete(server_coroutine)
except BaseException:
logger.exception("Unable to start server")
error_logger.exception("Unable to start server")
return
trigger_events(after_start, loop)

View File

@@ -5,7 +5,7 @@ import asyncio
from inspect import isawaitable
from typing import Any, Dict, List, Optional, Tuple, Union
from sanic_routing import BaseRouter, Route # type: ignore
from sanic_routing import BaseRouter, Route, RouteGroup # type: ignore
from sanic_routing.exceptions import NotFound # type: ignore
from sanic_routing.utils import path_to_parts # type: ignore
@@ -20,17 +20,11 @@ RESERVED_NAMESPACES = (
class Signal(Route):
def get_handler(self, raw_path, method, _):
method = method or self.router.DEFAULT_METHOD
raw_path = raw_path.lstrip(self.router.delimiter)
try:
return self.handlers[raw_path][method]
except (IndexError, KeyError):
raise self.router.method_handler_exception(
f"Method '{method}' not found on {self}",
method=method,
allowed_methods=set(self.methods[raw_path]),
)
...
class SignalGroup(RouteGroup):
...
class SignalRouter(BaseRouter):
@@ -38,6 +32,7 @@ class SignalRouter(BaseRouter):
super().__init__(
delimiter=".",
route_class=Signal,
group_class=SignalGroup,
stacking=True,
)
self.ctx.loop = None
@@ -49,7 +44,13 @@ class SignalRouter(BaseRouter):
):
extra = condition or {}
try:
return self.resolve(f".{event}", extra=extra)
group, param_basket = self.find_route(
f".{event}",
self.DEFAULT_METHOD,
self,
{"__params__": {}, "__matches__": {}},
extra=extra,
)
except NotFound:
message = "Could not find signal %s"
terms: List[Union[str, Optional[Dict[str, str]]]] = [event]
@@ -58,16 +59,26 @@ class SignalRouter(BaseRouter):
terms.append(extra)
raise NotFound(message % tuple(terms))
params = param_basket["__params__"]
if not params:
params = {
param.name: param_basket["__matches__"][idx]
for idx, param in group.params.items()
}
return group, [route.handler for route in group], params
async def _dispatch(
self,
event: str,
context: Optional[Dict[str, Any]] = None,
condition: Optional[Dict[str, str]] = None,
) -> None:
signal, handlers, params = self.get(event, condition=condition)
group, handlers, params = self.get(event, condition=condition)
signal_event = signal.ctx.event
signal_event.set()
events = [signal.ctx.event for signal in group]
for signal_event in events:
signal_event.set()
if context:
params.update(context)
@@ -78,7 +89,8 @@ class SignalRouter(BaseRouter):
if isawaitable(maybe_coroutine):
await maybe_coroutine
finally:
signal_event.clear()
for signal_event in events:
signal_event.clear()
async def dispatch(
self,
@@ -116,7 +128,7 @@ class SignalRouter(BaseRouter):
handler,
requirements=condition,
name=name,
overwrite=True,
append=True,
) # type: ignore
def finalize(self, do_compile: bool = True):
@@ -125,7 +137,7 @@ class SignalRouter(BaseRouter):
except RuntimeError:
raise RuntimeError("Cannot finalize signals outside of event loop")
for signal in self.routes.values():
for signal in self.routes:
signal.ctx.event = asyncio.Event()
return super().finalize(do_compile=do_compile)

21
sanic/simple.py Normal file
View File

@@ -0,0 +1,21 @@
from pathlib import Path
from sanic import Sanic
from sanic.exceptions import SanicException
from sanic.response import redirect
def create_simple_server(directory: Path):
if not directory.is_dir():
raise SanicException(
"Cannot setup Sanic Simple Server without a path to a directory"
)
app = Sanic("SimpleServer")
app.static("/", directory, name="main")
@app.get("/")
def index(_):
return redirect(app.url_for("main", filename="index.html"))
return app

View File

@@ -105,6 +105,7 @@ def load_module_from_file_location(
_mod_spec = spec_from_file_location(
name, location, *args, **kwargs
)
assert _mod_spec is not None # type assertion for mypy
module = module_from_spec(_mod_spec)
_mod_spec.loader.exec_module(module) # type: ignore

View File

@@ -1,9 +1,25 @@
from typing import Any, Callable, List
from __future__ import annotations
from typing import (
TYPE_CHECKING,
Any,
Callable,
Iterable,
List,
Optional,
Union,
)
from warnings import warn
from sanic.constants import HTTP_METHODS
from sanic.exceptions import InvalidUsage
if TYPE_CHECKING:
from sanic import Sanic
from sanic.blueprints import Blueprint
class HTTPMethodView:
"""Simple class based implementation of view for the sanic.
You should implement methods (get, post, put, patch, delete) for the class
@@ -40,6 +56,31 @@ class HTTPMethodView:
decorators: List[Callable[[Callable[..., Any]], Callable[..., Any]]] = []
def __init_subclass__(
cls,
attach: Optional[Union[Sanic, Blueprint]] = None,
uri: str = "",
methods: Iterable[str] = frozenset({"GET"}),
host: Optional[str] = None,
strict_slashes: Optional[bool] = None,
version: Optional[int] = None,
name: Optional[str] = None,
stream: bool = False,
version_prefix: str = "/v",
) -> None:
if attach:
cls.attach(
attach,
uri=uri,
methods=methods,
host=host,
strict_slashes=strict_slashes,
version=version,
name=name,
stream=stream,
version_prefix=version_prefix,
)
def dispatch_request(self, request, *args, **kwargs):
handler = getattr(self, request.method.lower(), None)
return handler(request, *args, **kwargs)
@@ -65,6 +106,31 @@ class HTTPMethodView:
view.__name__ = cls.__name__
return view
@classmethod
def attach(
cls,
to: Union[Sanic, Blueprint],
uri: str,
methods: Iterable[str] = frozenset({"GET"}),
host: Optional[str] = None,
strict_slashes: Optional[bool] = None,
version: Optional[int] = None,
name: Optional[str] = None,
stream: bool = False,
version_prefix: str = "/v",
) -> None:
to.add_route(
cls.as_view(),
uri=uri,
methods=methods,
host=host,
strict_slashes=strict_slashes,
version=version,
name=name,
stream=stream,
version_prefix=version_prefix,
)
def stream(func):
func.is_stream = True
@@ -91,6 +157,11 @@ class CompositionView:
def __init__(self):
self.handlers = {}
self.name = self.__class__.__name__
warn(
"CompositionView has been deprecated and will be removed in "
"v21.12. Please update your view to HTTPMethodView.",
DeprecationWarning,
)
def __name__(self):
return self.name

View File

@@ -14,9 +14,13 @@ from websockets import ( # type: ignore
ConnectionClosed,
InvalidHandshake,
WebSocketCommonProtocol,
handshake,
)
# Despite the "legacy" namespace, the primary maintainer of websockets
# committed to maintaining backwards-compatibility until 2026 and will
# consider extending it if sanic continues depending on this module.
from websockets.legacy import handshake
from sanic.exceptions import InvalidUsage
from sanic.server import HttpProtocol
@@ -37,7 +41,7 @@ class WebSocketProtocol(HttpProtocol):
websocket_write_limit=2 ** 16,
websocket_ping_interval=20,
websocket_ping_timeout=20,
**kwargs
**kwargs,
):
super().__init__(*args, **kwargs)
self.websocket = None
@@ -126,7 +130,9 @@ class WebSocketProtocol(HttpProtocol):
ping_interval=self.websocket_ping_interval,
ping_timeout=self.websocket_ping_timeout,
)
# Following two lines are required for websockets 8.x
# we use WebSocketCommonProtocol because we don't want the handshake
# logic from WebSocketServerProtocol; however, we must tell it that
# we're running on the server side
self.websocket.is_client = False
self.websocket.side = "server"
self.websocket.subprotocol = subprotocol
@@ -148,7 +154,7 @@ class WebSocketConnection:
) -> None:
self._send = send
self._receive = receive
self.subprotocols = subprotocols or []
self._subprotocols = subprotocols or []
async def send(self, data: Union[str, bytes], *args, **kwargs) -> None:
message: Dict[str, Union[str, bytes]] = {"type": "websocket.send"}
@@ -172,13 +178,28 @@ class WebSocketConnection:
receive = recv
async def accept(self) -> None:
async def accept(self, subprotocols: Optional[List[str]] = None) -> None:
subprotocol = None
if subprotocols:
for subp in subprotocols:
if subp in self.subprotocols:
subprotocol = subp
break
await self._send(
{
"type": "websocket.accept",
"subprotocol": ",".join(list(self.subprotocols)),
"subprotocol": subprotocol,
}
)
async def close(self) -> None:
pass
@property
def subprotocols(self):
return self._subprotocols
@subprotocols.setter
def subprotocols(self, subprotocols: Optional[List[str]] = None):
self._subprotocols = subprotocols or []

View File

@@ -8,7 +8,7 @@ import sys
from distutils.util import strtobool
from setuptools import setup, find_packages
from setuptools import find_packages, setup
from setuptools.command.test import test as TestCommand
@@ -52,7 +52,7 @@ with open_local(["README.rst"]) as rm:
setup_kwargs = {
"name": "sanic",
"version": version,
"url": "http://github.com/huge-success/sanic/",
"url": "http://github.com/sanic-org/sanic/",
"license": "MIT",
"author": "Sanic Community",
"author_email": "admhpkns@gmail.com",
@@ -83,17 +83,17 @@ ujson = "ujson>=1.35" + env_dependency
uvloop = "uvloop>=0.5.3" + env_dependency
requirements = [
"sanic-routing",
"sanic-routing==0.7.0",
"httptools>=0.0.10",
uvloop,
ujson,
"aiofiles>=0.6.0",
"websockets>=8.1,<9.0",
"websockets>=9.0",
"multidict>=5.0,<6.0",
]
tests_require = [
"sanic-testing",
"sanic-testing>=0.6.0",
"pytest==5.2.1",
"multidict>=5.0,<6.0",
"gunicorn==20.0.4",

View File

@@ -15,6 +15,7 @@ from sanic.constants import HTTP_METHODS
from sanic.router import Router
slugify = re.compile(r"[^a-zA-Z0-9_\-]")
random.seed("Pack my box with five dozen liquor jugs.")
Sanic.test_mode = True
@@ -140,5 +141,5 @@ def url_param_generator():
@pytest.fixture(scope="function")
def app(request):
app = Sanic(request.node.name)
app = Sanic(slugify.sub("-", request.node.name))
return app

36
tests/fake/server.py Normal file
View File

@@ -0,0 +1,36 @@
import json
import logging
from sanic import Sanic, text
from sanic.log import LOGGING_CONFIG_DEFAULTS, logger
LOGGING_CONFIG = {**LOGGING_CONFIG_DEFAULTS}
LOGGING_CONFIG["formatters"]["generic"]["format"] = "%(message)s"
LOGGING_CONFIG["loggers"]["sanic.root"]["level"] = "DEBUG"
app = Sanic(__name__, log_config=LOGGING_CONFIG)
@app.get("/")
async def handler(request):
return text(request.ip)
@app.before_server_start
async def app_info_dump(app: Sanic, _):
app_data = {
"access_log": app.config.ACCESS_LOG,
"auto_reload": app.auto_reload,
"debug": app.debug,
}
logger.info(json.dumps(app_data))
@app.after_server_start
async def shutdown(app: Sanic, _):
app.stop()
def create_app():
return app

View File

@@ -0,0 +1 @@
foo

View File

@@ -9,6 +9,7 @@ from unittest.mock import Mock, patch
import pytest
from sanic import Sanic
from sanic.config import Config
from sanic.exceptions import SanicException
from sanic.response import text
@@ -276,7 +277,7 @@ def test_handle_request_with_nested_sanic_exception(app, monkeypatch, caplog):
assert response.status == 500
assert "Mock SanicException" in response.text
assert (
"sanic.root",
"sanic.error",
logging.ERROR,
f"Exception occurred while handling uri: 'http://127.0.0.1:{port}/'",
) in caplog.record_tuples
@@ -389,7 +390,7 @@ def test_app_no_registry_env():
def test_app_set_attribute_warning(app):
with pytest.warns(UserWarning) as record:
with pytest.warns(DeprecationWarning) as record:
app.foo = 1
assert len(record) == 1
@@ -412,3 +413,42 @@ def test_subclass_initialisation():
pass
CustomSanic("test_subclass_initialisation")
def test_bad_custom_config():
with pytest.raises(
SanicException,
match=(
"When instantiating Sanic with config, you cannot also pass "
"load_env or env_prefix"
),
):
Sanic("test", config=1, load_env=1)
with pytest.raises(
SanicException,
match=(
"When instantiating Sanic with config, you cannot also pass "
"load_env or env_prefix"
),
):
Sanic("test", config=1, env_prefix=1)
def test_custom_config():
class CustomConfig(Config):
...
config = CustomConfig()
app = Sanic("custom", config=config)
assert app.config == config
def test_custom_context():
class CustomContext:
...
ctx = CustomContext()
app = Sanic("custom", ctx=ctx)
assert app.ctx == ctx

View File

@@ -1,5 +1,4 @@
import asyncio
import sys
from collections import deque, namedtuple
@@ -219,7 +218,7 @@ async def test_websocket_accept_with_no_subprotocols(
message = message_stack.popleft()
assert message["type"] == "websocket.accept"
assert message["subprotocol"] == ""
assert message["subprotocol"] is None
assert "bytes" not in message
@@ -228,7 +227,7 @@ async def test_websocket_accept_with_subprotocol(send, receive, message_stack):
subprotocols = ["graphql-ws"]
ws = WebSocketConnection(send, receive, subprotocols)
await ws.accept()
await ws.accept(subprotocols)
assert len(message_stack) == 1
@@ -245,13 +244,13 @@ async def test_websocket_accept_with_multiple_subprotocols(
subprotocols = ["graphql-ws", "hello", "world"]
ws = WebSocketConnection(send, receive, subprotocols)
await ws.accept()
await ws.accept(["hello", "world"])
assert len(message_stack) == 1
message = message_stack.popleft()
assert message["type"] == "websocket.accept"
assert message["subprotocol"] == "graphql-ws,hello,world"
assert message["subprotocol"] == "hello"
assert "bytes" not in message

View File

@@ -41,3 +41,62 @@ def test_bp_repr_with_values(bp):
'Blueprint(name="my_bp", url_prefix="/foo", host="example.com", '
"version=3, strict_slashes=True)"
)
@pytest.mark.parametrize(
"name",
(
"something",
"some-thing",
"some_thing",
"Something",
"SomeThing",
"Some-Thing",
"Some_Thing",
"SomeThing123",
"something123",
"some-thing123",
"some_thing123",
"some-Thing123",
"some_Thing123",
),
)
def test_names_okay(name):
app = Sanic(name)
bp = Blueprint(name)
assert app.name == name
assert bp.name == name
@pytest.mark.parametrize(
"name",
(
"123something",
"some thing",
"something!",
),
)
def test_names_not_okay(name):
app_message = (
f"Sanic instance named '{name}' uses a format that isdeprecated. "
"Starting in version 21.12, Sanic objects must be named only using "
"alphanumeric characters, _, or -."
)
bp_message = (
f"Blueprint instance named '{name}' uses a format that isdeprecated. "
"Starting in version 21.12, Blueprint objects must be named only using "
"alphanumeric characters, _, or -."
)
with pytest.warns(DeprecationWarning) as app_e:
app = Sanic(name)
with pytest.warns(DeprecationWarning) as bp_e:
bp = Blueprint(name)
assert app.name == name
assert bp.name == name
assert app_e[0].message.args[0] == app_message
assert bp_e[0].message.args[0] == bp_message

View File

@@ -200,7 +200,7 @@ def test_bp_group_as_nested_group():
blueprint_group_1 = Blueprint.group(
Blueprint.group(blueprint_1, blueprint_2)
)
assert len(blueprint_group_1) == 2
assert len(blueprint_group_1) == 1
def test_blueprint_group_insert():
@@ -215,6 +215,61 @@ def test_blueprint_group_insert():
group.insert(0, blueprint_1)
group.insert(0, blueprint_2)
group.insert(0, blueprint_3)
assert group.blueprints[1].strict_slashes is False
assert group.blueprints[2].strict_slashes is True
assert group.blueprints[0].url_prefix == "/test"
@blueprint_1.route("/")
def blueprint_1_default_route(request):
return text("BP1_OK")
@blueprint_2.route("/")
def blueprint_2_default_route(request):
return text("BP2_OK")
@blueprint_3.route("/")
def blueprint_3_default_route(request):
return text("BP3_OK")
app = Sanic("PropTest")
app.blueprint(group)
app.router.finalize()
routes = [(route.path, route.strict) for route in app.router.routes]
assert len(routes) == 3
assert ("v1/test/bp1/", True) in routes
assert ("v1.3/test/bp2", False) in routes
assert ("v1.3/test", False) in routes
def test_bp_group_properties():
blueprint_1 = Blueprint("blueprint_1", url_prefix="/bp1")
blueprint_2 = Blueprint("blueprint_2", url_prefix="/bp2")
group = Blueprint.group(
blueprint_1,
blueprint_2,
version=1,
version_prefix="/api/v",
url_prefix="/grouped",
strict_slashes=True,
)
primary = Blueprint.group(group, url_prefix="/primary")
@blueprint_1.route("/")
def blueprint_1_default_route(request):
return text("BP1_OK")
@blueprint_2.route("/")
def blueprint_2_default_route(request):
return text("BP2_OK")
app = Sanic("PropTest")
app.blueprint(group)
app.blueprint(primary)
app.router.finalize()
routes = [route.path for route in app.router.routes]
assert len(routes) == 4
assert "api/v1/grouped/bp1/" in routes
assert "api/v1/grouped/bp2/" in routes
assert "api/v1/primary/grouped/bp1" in routes
assert "api/v1/primary/grouped/bp2" in routes

View File

@@ -1028,7 +1028,7 @@ def test_blueprint_registered_multiple_apps():
def test_bp_set_attribute_warning():
bp = Blueprint("bp")
with pytest.warns(UserWarning) as record:
with pytest.warns(DeprecationWarning) as record:
bp.foo = 1
assert len(record) == 1

133
tests/test_cli.py Normal file
View File

@@ -0,0 +1,133 @@
import json
import subprocess
from pathlib import Path
import pytest
from sanic_routing import __version__ as __routing_version__
from sanic import __version__
from sanic.config import BASE_LOGO
def capture(command):
proc = subprocess.Popen(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
cwd=Path(__file__).parent,
)
try:
out, err = proc.communicate(timeout=0.5)
except subprocess.TimeoutExpired:
proc.kill()
out, err = proc.communicate()
return out, err, proc.returncode
@pytest.mark.parametrize(
"appname",
(
"fake.server.app",
"fake.server:app",
"fake.server:create_app()",
"fake.server.create_app()",
),
)
def test_server_run(appname):
command = ["sanic", appname]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
firstline = lines[6]
assert exitcode != 1
assert firstline == b"Goin' Fast @ http://127.0.0.1:8000"
@pytest.mark.parametrize(
"cmd",
(
("--host=localhost", "--port=9999"),
("-H", "localhost", "-p", "9999"),
),
)
def test_host_port(cmd):
command = ["sanic", "fake.server.app", *cmd]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
firstline = lines[6]
assert exitcode != 1
assert firstline == b"Goin' Fast @ http://localhost:9999"
@pytest.mark.parametrize(
"num,cmd",
(
(1, (f"--workers={1}",)),
(2, (f"--workers={2}",)),
(4, (f"--workers={4}",)),
(1, ("-w", "1")),
(2, ("-w", "2")),
(4, ("-w", "4")),
),
)
def test_num_workers(num, cmd):
command = ["sanic", "fake.server.app", *cmd]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
worker_lines = [line for line in lines if b"worker" in line]
assert exitcode != 1
assert len(worker_lines) == num * 2
@pytest.mark.parametrize("cmd", ("--debug", "-d"))
def test_debug(cmd):
command = ["sanic", "fake.server.app", cmd]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
app_info = lines[9]
info = json.loads(app_info)
assert (b"\n".join(lines[:6])).decode("utf-8") == BASE_LOGO
assert info["debug"] is True
assert info["auto_reload"] is True
@pytest.mark.parametrize("cmd", ("--auto-reload", "-r"))
def test_auto_reload(cmd):
command = ["sanic", "fake.server.app", cmd]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
app_info = lines[9]
info = json.loads(app_info)
assert info["debug"] is False
assert info["auto_reload"] is True
@pytest.mark.parametrize(
"cmd,expected", (("--access-log", True), ("--no-access-log", False))
)
def test_access_logs(cmd, expected):
command = ["sanic", "fake.server.app", cmd]
out, err, exitcode = capture(command)
lines = out.split(b"\n")
app_info = lines[9]
info = json.loads(app_info)
assert info["access_log"] is expected
@pytest.mark.parametrize("cmd", ("--version", "-v"))
def test_version(cmd):
command = ["sanic", cmd]
out, err, exitcode = capture(command)
version_string = f"Sanic {__version__}; Routing {__routing_version__}\n"
assert out == version_string.encode("utf-8")

View File

@@ -59,14 +59,14 @@ def test_load_from_object_string_exception(app):
app.config.load("test_config.Config.test")
def test_auto_load_env():
def test_auto_env_prefix():
environ["SANIC_TEST_ANSWER"] = "42"
app = Sanic(name=__name__)
assert app.config.TEST_ANSWER == 42
del environ["SANIC_TEST_ANSWER"]
def test_auto_load_bool_env():
def test_auto_bool_env_prefix():
environ["SANIC_TEST_ANSWER"] = "True"
app = Sanic(name=__name__)
assert app.config.TEST_ANSWER is True
@@ -80,6 +80,12 @@ def test_dont_load_env():
del environ["SANIC_TEST_ANSWER"]
@pytest.mark.parametrize("load_env", [None, False, "", "MYAPP_"])
def test_load_env_deprecation(load_env):
with pytest.warns(DeprecationWarning, match=r"21\.12"):
_ = Sanic(name=__name__, load_env=load_env)
def test_load_env_prefix():
environ["MYAPP_TEST_ANSWER"] = "42"
app = Sanic(name=__name__, load_env="MYAPP_")
@@ -87,6 +93,14 @@ def test_load_env_prefix():
del environ["MYAPP_TEST_ANSWER"]
@pytest.mark.parametrize("env_prefix", [None, ""])
def test_empty_load_env_prefix(env_prefix):
environ["SANIC_TEST_ANSWER"] = "42"
app = Sanic(name=__name__, env_prefix=env_prefix)
assert getattr(app.config, "TEST_ANSWER", None) is None
del environ["SANIC_TEST_ANSWER"]
def test_load_env_prefix_float_values():
environ["MYAPP_TEST_ROI"] = "2.3"
app = Sanic(name=__name__, load_env="MYAPP_")
@@ -101,6 +115,27 @@ def test_load_env_prefix_string_value():
del environ["MYAPP_TEST_TOKEN"]
def test_env_prefix():
environ["MYAPP_TEST_ANSWER"] = "42"
app = Sanic(name=__name__, env_prefix="MYAPP_")
assert app.config.TEST_ANSWER == 42
del environ["MYAPP_TEST_ANSWER"]
def test_env_prefix_float_values():
environ["MYAPP_TEST_ROI"] = "2.3"
app = Sanic(name=__name__, env_prefix="MYAPP_")
assert app.config.TEST_ROI == 2.3
del environ["MYAPP_TEST_ROI"]
def test_env_prefix_string_value():
environ["MYAPP_TEST_TOKEN"] = "somerandomtesttoken"
app = Sanic(name=__name__, env_prefix="MYAPP_")
assert app.config.TEST_TOKEN == "somerandomtesttoken"
del environ["MYAPP_TEST_TOKEN"]
def test_load_from_file(app):
config = dedent(
"""

28
tests/test_constants.py Normal file
View File

@@ -0,0 +1,28 @@
from crypt import methods
from sanic import text
from sanic.constants import HTTP_METHODS, HTTPMethod
def test_string_compat():
assert "GET" == HTTPMethod.GET
assert "GET" in HTTP_METHODS
assert "get" == HTTPMethod.GET
assert "get" in HTTP_METHODS
assert HTTPMethod.GET.lower() == "get"
assert HTTPMethod.GET.upper() == "GET"
def test_use_in_routes(app):
@app.route("/", methods=[HTTPMethod.GET, HTTPMethod.POST])
def handler(_):
return text("It works")
_, response = app.test_client.get("/")
assert response.status == 200
assert response.text == "It works"
_, response = app.test_client.post("/")
assert response.status == 200
assert response.text == "It works"

View File

@@ -1,3 +1,5 @@
import warnings
import pytest
from bs4 import BeautifulSoup
@@ -7,6 +9,7 @@ from sanic.exceptions import (
Forbidden,
InvalidUsage,
NotFound,
SanicException,
ServerError,
Unauthorized,
abort,
@@ -68,16 +71,19 @@ def exception_app():
@app.route("/abort/401")
def handler_401_error(request):
abort(401)
raise SanicException(status_code=401)
@app.route("/abort")
def handler_500_error(request):
raise SanicException(status_code=500)
@app.route("/old_abort")
def handler_old_abort_error(request):
abort(500)
return text("OK")
@app.route("/abort/message")
def handler_abort_message(request):
abort(500, message="Abort")
raise SanicException(message="Custom Message", status_code=500)
@app.route("/divide_by_zero")
def handle_unhandled_exception(request):
@@ -208,14 +214,21 @@ def test_exception_in_exception_handler_debug_on(exception_app):
assert response.body.startswith(b"Exception raised in exception ")
def test_abort(exception_app):
"""Test the abort function"""
def test_sanic_exception(exception_app):
"""Test sanic exceptions are handled"""
request, response = exception_app.test_client.get("/abort/401")
assert response.status == 401
request, response = exception_app.test_client.get("/abort")
assert response.status == 500
# check fallback message
assert "Internal Server Error" in response.text
request, response = exception_app.test_client.get("/abort/message")
assert response.status == 500
assert "Abort" in response.text
assert "Custom Message" in response.text
with warnings.catch_warnings(record=True) as w:
request, response = exception_app.test_client.get("/old_abort")
assert response.status == 500
assert len(w) == 1 and "deprecated" in w[0].message.args[0]

View File

@@ -7,6 +7,13 @@ from sanic.exceptions import PayloadTooLarge
from sanic.http import Http
@pytest.fixture
def raised_ceiling():
Http.HEADER_CEILING = 32_768
yield
Http.HEADER_CEILING = 16_384
@pytest.mark.parametrize(
"input, expected",
[
@@ -76,15 +83,75 @@ async def test_header_size_exceeded():
recv_buffer += b"123"
protocol = Mock()
Http.set_header_max_size(1)
http = Http(protocol)
http._receive_more = _receive_more
http.request_max_size = 1
http.recv_buffer = recv_buffer
with pytest.raises(PayloadTooLarge):
await http.http1_request_header()
@pytest.mark.asyncio
async def test_header_size_increased_okay():
recv_buffer = bytearray()
async def _receive_more():
nonlocal recv_buffer
recv_buffer += b"123"
protocol = Mock()
Http.set_header_max_size(12_288)
http = Http(protocol)
http._receive_more = _receive_more
http.recv_buffer = recv_buffer
with pytest.raises(PayloadTooLarge):
await http.http1_request_header()
assert len(recv_buffer) == 12_291
@pytest.mark.asyncio
async def test_header_size_exceeded_maxed_out():
recv_buffer = bytearray()
async def _receive_more():
nonlocal recv_buffer
recv_buffer += b"123"
protocol = Mock()
Http.set_header_max_size(18_432)
http = Http(protocol)
http._receive_more = _receive_more
http.recv_buffer = recv_buffer
with pytest.raises(PayloadTooLarge):
await http.http1_request_header()
assert len(recv_buffer) == 16_389
@pytest.mark.asyncio
async def test_header_size_exceeded_raised_ceiling(raised_ceiling):
recv_buffer = bytearray()
async def _receive_more():
nonlocal recv_buffer
recv_buffer += b"123"
protocol = Mock()
http = Http(protocol)
Http.set_header_max_size(65_536)
http._receive_more = _receive_more
http.recv_buffer = recv_buffer
with pytest.raises(PayloadTooLarge):
await http.http1_request_header()
assert len(recv_buffer) == 32_772
def test_raw_headers(app):
app.route("/")(lambda _: text(""))
request, _ = app.test_client.get(

View File

@@ -1,4 +1,5 @@
import asyncio
import platform
from asyncio import sleep as aio_sleep
from json import JSONDecodeError
@@ -241,7 +242,9 @@ def test_keep_alive_timeout_reuse():
@pytest.mark.skipif(
bool(environ.get("SANIC_NO_UVLOOP")) or OS_IS_WINDOWS,
bool(environ.get("SANIC_NO_UVLOOP"))
or OS_IS_WINDOWS
or platform.system() != "Linux",
reason="Not testable with current client",
)
def test_keep_alive_client_timeout():

View File

@@ -113,9 +113,9 @@ def test_logging_pass_customer_logconfig():
def test_log_connection_lost(app, debug, monkeypatch):
""" Should not log Connection lost exception on non debug """
stream = StringIO()
root = logging.getLogger("sanic.root")
root.addHandler(logging.StreamHandler(stream))
monkeypatch.setattr(sanic.server, "logger", root)
error = logging.getLogger("sanic.error")
error.addHandler(logging.StreamHandler(stream))
monkeypatch.setattr(sanic.server, "error_logger", error)
@app.route("/conn_lost")
async def conn_lost(request):

View File

@@ -3,7 +3,7 @@ import logging
from asyncio import CancelledError
from itertools import count
from sanic.exceptions import NotFound, SanicException
from sanic.exceptions import NotFound
from sanic.request import Request
from sanic.response import HTTPResponse, text
@@ -156,7 +156,7 @@ def test_middleware_response_raise_cancelled_error(app, caplog):
assert response.status == 503
assert (
"sanic.root",
"sanic.error",
logging.ERROR,
"Exception occurred while handling uri: 'http://127.0.0.1:42101/'",
) not in caplog.record_tuples
@@ -174,7 +174,7 @@ def test_middleware_response_raise_exception(app, caplog):
assert response.status == 404
# 404 errors are not logged
assert (
"sanic.root",
"sanic.error",
logging.ERROR,
"Exception occurred while handling uri: 'http://127.0.0.1:42101/'",
) not in caplog.record_tuples

View File

@@ -209,13 +209,13 @@ def test_named_static_routes():
return text("OK2")
assert app.router.routes_all[("test",)].name == "app.route_test"
assert app.router.routes_static[("test",)].name == "app.route_test"
assert app.router.routes_static[("test",)][0].name == "app.route_test"
assert app.url_for("route_test") == "/test"
with pytest.raises(URLBuildError):
app.url_for("handler1")
assert app.router.routes_all[("pizazz",)].name == "app.route_pizazz"
assert app.router.routes_static[("pizazz",)].name == "app.route_pizazz"
assert app.router.routes_static[("pizazz",)][0].name == "app.route_pizazz"
assert app.url_for("route_pizazz") == "/pizazz"
with pytest.raises(URLBuildError):
app.url_for("handler2")
@@ -234,7 +234,7 @@ def test_named_dynamic_route():
app.router.routes_all[
(
"folder",
"<name>",
"<name:str>",
)
].name
== "app.route_dynamic"
@@ -347,13 +347,13 @@ def test_static_add_named_route():
app.add_route(handler2, "/test2", name="route_test2")
assert app.router.routes_all[("test",)].name == "app.route_test"
assert app.router.routes_static[("test",)].name == "app.route_test"
assert app.router.routes_static[("test",)][0].name == "app.route_test"
assert app.url_for("route_test") == "/test"
with pytest.raises(URLBuildError):
app.url_for("handler1")
assert app.router.routes_all[("test2",)].name == "app.route_test2"
assert app.router.routes_static[("test2",)].name == "app.route_test2"
assert app.router.routes_static[("test2",)][0].name == "app.route_test2"
assert app.url_for("route_test2") == "/test2"
with pytest.raises(URLBuildError):
app.url_for("handler2")
@@ -369,7 +369,8 @@ def test_dynamic_add_named_route():
app.add_route(handler, "/folder/<name>", name="route_dynamic")
assert (
app.router.routes_all[("folder", "<name>")].name == "app.route_dynamic"
app.router.routes_all[("folder", "<name:str>")].name
== "app.route_dynamic"
)
assert app.url_for("route_dynamic", name="test") == "/folder/test"
with pytest.raises(URLBuildError):

View File

@@ -1,4 +1,4 @@
from urllib.parse import quote, unquote
from urllib.parse import quote
import pytest

View File

@@ -23,6 +23,8 @@ try:
except ImportError:
flags = 0
TIMER_DELAY = 2
def terminate(proc):
if flags:
@@ -56,6 +58,40 @@ def write_app(filename, **runargs):
return text
def write_json_config_app(filename, jsonfile, **runargs):
with open(filename, "w") as f:
f.write(
dedent(
f"""\
import os
from sanic import Sanic
import json
app = Sanic(__name__)
with open("{jsonfile}", "r") as f:
config = json.load(f)
app.config.update_config(config)
app.route("/")(lambda x: x)
@app.listener("after_server_start")
def complete(*args):
print("complete", os.getpid(), app.config.FOO)
if __name__ == "__main__":
app.run(**{runargs!r})
"""
)
)
def write_file(filename):
text = secrets.token_urlsafe()
with open(filename, "w") as f:
f.write(f"""{{"FOO": "{text}"}}""")
return text
def scanner(proc):
for line in proc.stdout:
line = line.decode().strip()
@@ -90,9 +126,10 @@ async def test_reloader_live(runargs, mode):
with TemporaryDirectory() as tmpdir:
filename = os.path.join(tmpdir, "reloader.py")
text = write_app(filename, **runargs)
proc = Popen(argv[mode], cwd=tmpdir, stdout=PIPE, creationflags=flags)
command = argv[mode]
proc = Popen(command, cwd=tmpdir, stdout=PIPE, creationflags=flags)
try:
timeout = Timer(5, terminate, [proc])
timeout = Timer(TIMER_DELAY, terminate, [proc])
timeout.start()
# Python apparently keeps using the old source sometimes if
# we don't sleep before rewrite (pycache timestamp problem?)
@@ -107,3 +144,40 @@ async def test_reloader_live(runargs, mode):
terminate(proc)
with suppress(TimeoutExpired):
proc.wait(timeout=3)
@pytest.mark.parametrize(
"runargs, mode",
[
(dict(port=42102, auto_reload=True), "script"),
(dict(port=42103, debug=True), "module"),
({}, "sanic"),
],
)
async def test_reloader_live_with_dir(runargs, mode):
with TemporaryDirectory() as tmpdir:
filename = os.path.join(tmpdir, "reloader.py")
config_file = os.path.join(tmpdir, "config.json")
runargs["reload_dir"] = tmpdir
write_json_config_app(filename, config_file, **runargs)
text = write_file(config_file)
command = argv[mode]
if mode == "sanic":
command += ["--reload-dir", tmpdir]
proc = Popen(command, cwd=tmpdir, stdout=PIPE, creationflags=flags)
try:
timeout = Timer(TIMER_DELAY, terminate, [proc])
timeout.start()
# Python apparently keeps using the old source sometimes if
# we don't sleep before rewrite (pycache timestamp problem?)
sleep(1)
line = scanner(proc)
assert text in next(line)
# Edit source code and try again
text = write_file(config_file)
assert text in next(line)
finally:
timeout.cancel()
terminate(proc)
with suppress(TimeoutExpired):
proc.wait(timeout=3)

View File

@@ -20,6 +20,7 @@ def test_request_id_generates_from_request(monkeypatch):
monkeypatch.setattr(Request, "generate_id", Mock())
Request.generate_id.return_value = 1
request = Request(b"/", {}, None, "GET", None, Mock())
request.app.config.REQUEST_ID_HEADER = "foo"
for _ in range(10):
request.id
@@ -28,6 +29,7 @@ def test_request_id_generates_from_request(monkeypatch):
def test_request_id_defaults_uuid():
request = Request(b"/", {}, None, "GET", None, Mock())
request.app.config.REQUEST_ID_HEADER = "foo"
assert isinstance(request.id, UUID)
@@ -104,7 +106,7 @@ def test_route_assigned_to_request(app):
return response.empty()
request, _ = app.test_client.get("/")
assert request.route is list(app.router.routes.values())[0]
assert request.route is list(app.router.routes)[0]
def test_protocol_attribute(app):
@@ -120,3 +122,21 @@ def test_protocol_attribute(app):
_ = app.test_client.get("/", headers=headers)
assert isinstance(retrieved, HttpProtocol)
def test_ipv6_address_is_not_wrapped(app):
@app.get("/")
async def get(request):
return response.json(
{
"client_ip": request.conn_info.client_ip,
"client": request.conn_info.client,
}
)
request, resp = app.test_client.get("/", host="::1")
assert request.route is list(app.router.routes)[0]
assert resp.json["client"] == "[::1]"
assert resp.json["client_ip"] == "::1"
assert request.ip == "::1"

View File

@@ -8,7 +8,6 @@ import pytest
from sanic import Sanic
from sanic.blueprints import Blueprint
from sanic.response import json, text
from sanic.server import HttpProtocol
from sanic.views import CompositionView, HTTPMethodView
from sanic.views import stream as stream_decorator

View File

@@ -1,21 +1,8 @@
import asyncio
from typing import cast
import httpcore
import httpx
from httpcore._async.base import (
AsyncByteStream,
AsyncHTTPTransport,
ConnectionState,
NewConnectionRequired,
)
from httpcore._async.connection import AsyncHTTPConnection
from httpcore._async.connection_pool import ResponseByteStream
from httpcore._exceptions import LocalProtocolError, UnsupportedProtocol
from httpcore._types import TimeoutDict
from httpcore._utils import url_to_origin
from sanic_testing.testing import SanicTestClient
from sanic import Sanic

View File

@@ -253,6 +253,31 @@ async def test_empty_json_asgi(app):
assert response.body == b"null"
def test_echo_json(app):
@app.post("/")
async def handler(request):
return json(request.json)
data = {"foo": "bar"}
request, response = app.test_client.post("/", json=data)
assert response.status == 200
assert response.json == data
@pytest.mark.asyncio
async def test_echo_json_asgi(app):
@app.post("/")
async def handler(request):
return json(request.json)
data = {"foo": "bar"}
request, response = await app.asgi_client.post("/", json=data)
assert response.status == 200
assert response.json == data
def test_invalid_json(app):
@app.post("/")
async def handler(request):
@@ -292,6 +317,17 @@ def test_query_string(app):
assert request.args.get("test3", default="My value") == "My value"
def test_popped_stays_popped(app):
@app.route("/")
async def handler(request):
return text("OK")
request, response = app.test_client.get("/", params=[("test1", "1")])
assert request.args.pop("test1") == ["1"]
assert "test1" not in request.args
@pytest.mark.asyncio
async def test_query_string_asgi(app):
@app.route("/")
@@ -2159,3 +2195,70 @@ def test_safe_method_with_body(app):
assert request.body == data.encode("utf-8")
assert request.json.get("test") == "OK"
assert response.body == b"OK"
def test_conflicting_body_methods_overload(app):
@app.put("/")
@app.put("/p/")
@app.put("/p/<foo>")
async def put(request, foo=None):
return json(
{"name": request.route.name, "body": str(request.body), "foo": foo}
)
@app.delete("/p/<foo>")
async def delete(request, foo):
return json(
{"name": request.route.name, "body": str(request.body), "foo": foo}
)
payload = {"test": "OK"}
data = str(json_dumps(payload).encode())
_, response = app.test_client.put("/", json=payload)
assert response.status == 200
assert response.json == {
"name": "test_conflicting_body_methods_overload.put",
"foo": None,
"body": data,
}
_, response = app.test_client.put("/p", json=payload)
assert response.status == 200
assert response.json == {
"name": "test_conflicting_body_methods_overload.put",
"foo": None,
"body": data,
}
_, response = app.test_client.put("/p/test", json=payload)
assert response.status == 200
assert response.json == {
"name": "test_conflicting_body_methods_overload.put",
"foo": "test",
"body": data,
}
_, response = app.test_client.delete("/p/test")
assert response.status == 200
assert response.json == {
"name": "test_conflicting_body_methods_overload.delete",
"foo": "test",
"body": str("".encode()),
}
def test_handler_overload(app):
@app.get("/long/sub/route/param_a/<param_a:str>/param_b/<param_b:str>")
@app.post("/long/sub/route/")
def handler(request, **kwargs):
return json(kwargs)
_, response = app.test_client.get(
"/long/sub/route/param_a/foo/param_b/bar"
)
assert response.status == 200
assert response.json == {
"param_a": "foo",
"param_b": "bar",
}
_, response = app.test_client.post("/long/sub/route")
assert response.status == 200
assert response.json == {}

View File

@@ -1,23 +1,19 @@
import asyncio
import inspect
import os
import warnings
from collections import namedtuple
from mimetypes import guess_type
from random import choice
from unittest.mock import MagicMock
from urllib.parse import unquote
import pytest
from aiofiles import os as async_os
from sanic_testing.testing import HOST, PORT
from sanic import Sanic
from sanic.response import (
HTTPResponse,
StreamingHTTPResponse,
empty,
file,
file_stream,
@@ -26,7 +22,6 @@ from sanic.response import (
stream,
text,
)
from sanic.server import HttpProtocol
JSON_DATA = {"ok": True}
@@ -65,7 +60,9 @@ def test_method_not_allowed():
}
request, response = app.test_client.post("/")
assert set(response.headers["Allow"].split(", ")) == {"GET", "HEAD"}
assert set(response.headers["Allow"].split(", ")) == {
"GET",
}
app.router.reset()
@@ -78,7 +75,6 @@ def test_method_not_allowed():
assert set(response.headers["Allow"].split(", ")) == {
"GET",
"POST",
"HEAD",
}
assert response.headers["Content-Length"] == "0"
@@ -87,7 +83,6 @@ def test_method_not_allowed():
assert set(response.headers["Allow"].split(", ")) == {
"GET",
"POST",
"HEAD",
}
assert response.headers["Content-Length"] == "0"
@@ -229,7 +224,6 @@ def non_chunked_streaming_app(app):
sample_streaming_fn,
headers={"Content-Length": "7"},
content_type="text/csv",
chunked=False,
)
return app
@@ -256,11 +250,7 @@ async def test_chunked_streaming_returns_correct_content_asgi(streaming_app):
def test_non_chunked_streaming_adds_correct_headers(non_chunked_streaming_app):
with pytest.warns(UserWarning) as record:
request, response = non_chunked_streaming_app.test_client.get("/")
assert len(record) == 1
assert "removed in v21.6" in record[0].message.args[0]
request, response = non_chunked_streaming_app.test_client.get("/")
assert "Transfer-Encoding" not in response.headers
assert response.headers["Content-Type"] == "text/csv"
@@ -534,3 +524,19 @@ def test_empty_response(app):
request, response = app.test_client.get("/test")
assert response.content_type is None
assert response.body == b""
def test_direct_response_stream(app):
@app.route("/")
async def test(request):
response = await request.respond(content_type="text/csv")
await response.send("foo,")
await response.send("bar")
await response.eof()
return response
_, response = app.test_client.get("/")
assert response.text == "foo,bar"
assert response.headers["Transfer-Encoding"] == "chunked"
assert response.headers["Content-Type"] == "text/csv"
assert "Content-Length" not in response.headers

View File

@@ -1,7 +1,11 @@
import asyncio
import logging
from time import sleep
from sanic import Sanic
from sanic.exceptions import ServiceUnavailable
from sanic.log import LOGGING_CONFIG_DEFAULTS
from sanic.response import text
@@ -13,6 +17,8 @@ response_timeout_app.config.RESPONSE_TIMEOUT = 1
response_timeout_default_app.config.RESPONSE_TIMEOUT = 1
response_handler_cancelled_app.config.RESPONSE_TIMEOUT = 1
response_handler_cancelled_app.ctx.flag = False
@response_timeout_app.route("/1")
async def handler_1(request):
@@ -25,32 +31,17 @@ def handler_exception(request, exception):
return text("Response Timeout from error_handler.", 503)
def test_server_error_response_timeout():
request, response = response_timeout_app.test_client.get("/1")
assert response.status == 503
assert response.text == "Response Timeout from error_handler."
@response_timeout_default_app.route("/1")
async def handler_2(request):
await asyncio.sleep(2)
return text("OK")
def test_default_server_error_response_timeout():
request, response = response_timeout_default_app.test_client.get("/1")
assert response.status == 503
assert "Response Timeout" in response.text
response_handler_cancelled_app.flag = False
@response_handler_cancelled_app.exception(asyncio.CancelledError)
def handler_cancelled(request, exception):
# If we get a CancelledError, it means sanic has already sent a response,
# we should not ever have to handle a CancelledError.
response_handler_cancelled_app.flag = True
response_handler_cancelled_app.ctx.flag = True
return text("App received CancelledError!", 500)
# The client will never receive this response, because the socket
# is already closed when we get a CancelledError.
@@ -62,8 +53,44 @@ async def handler_3(request):
return text("OK")
def test_server_error_response_timeout():
request, response = response_timeout_app.test_client.get("/1")
assert response.status == 503
assert response.text == "Response Timeout from error_handler."
def test_default_server_error_response_timeout():
request, response = response_timeout_default_app.test_client.get("/1")
assert response.status == 503
assert "Response Timeout" in response.text
def test_response_handler_cancelled():
request, response = response_handler_cancelled_app.test_client.get("/1")
assert response.status == 503
assert "Response Timeout" in response.text
assert response_handler_cancelled_app.flag is False
assert response_handler_cancelled_app.ctx.flag is False
def test_response_timeout_not_applied(caplog):
modified_config = LOGGING_CONFIG_DEFAULTS
modified_config["loggers"]["sanic.root"]["level"] = "DEBUG"
app = Sanic("test_logging", log_config=modified_config)
app.config.RESPONSE_TIMEOUT = 1
app.ctx.event = asyncio.Event()
@app.websocket("/ws")
async def ws_handler(request, ws):
sleep(2)
await asyncio.sleep(0)
request.app.ctx.event.set()
with caplog.at_level(logging.DEBUG):
_ = app.test_client.websocket("/ws")
assert app.ctx.event.is_set()
assert (
"sanic.root",
10,
"Handling websocket. Timeouts disabled.",
) in caplog.record_tuples

View File

@@ -258,7 +258,7 @@ def test_route_strict_slash(app):
def test_route_invalid_parameter_syntax(app):
with pytest.raises(ValueError):
@app.get("/get/<:string>", strict_slashes=True)
@app.get("/get/<:str>", strict_slashes=True)
def handler(request):
return text("OK")
@@ -478,7 +478,7 @@ def test_dynamic_route(app):
def test_dynamic_route_string(app):
results = []
@app.route("/folder/<name:string>")
@app.route("/folder/<name:str>")
async def handler(request, name):
results.append(name)
return text("OK")
@@ -513,7 +513,7 @@ def test_dynamic_route_int(app):
def test_dynamic_route_number(app):
results = []
@app.route("/weight/<weight:number>")
@app.route("/weight/<weight:float>")
async def handler(request, weight):
results.append(weight)
return text("OK")
@@ -543,9 +543,6 @@ def test_dynamic_route_regex(app):
async def handler(request, folder_id):
return text("OK")
app.router.finalize()
print(app.router.find_route_src)
request, response = app.test_client.get("/folder/test")
assert response.status == 200
@@ -587,6 +584,8 @@ def test_dynamic_route_path(app):
async def handler(request, path):
return text("OK")
app.router.finalize()
request, response = app.test_client.get("/path/1/info")
assert response.status == 200
@@ -824,7 +823,7 @@ def test_dynamic_add_route_string(app):
results.append(name)
return text("OK")
app.add_route(handler, "/folder/<name:string>")
app.add_route(handler, "/folder/<name:str>")
request, response = app.test_client.get("/folder/test123")
assert response.text == "OK"
@@ -860,7 +859,7 @@ def test_dynamic_add_route_number(app):
results.append(weight)
return text("OK")
app.add_route(handler, "/weight/<weight:number>")
app.add_route(handler, "/weight/<weight:float>")
request, response = app.test_client.get("/weight/12345")
assert response.text == "OK"
@@ -1008,14 +1007,8 @@ def test_unmergeable_overload_routes(app):
async def handler2(request):
return text("OK1")
assert (
len(
dict(list(app.router.static_routes.values())[0].handlers)[
"overload_whole"
]
)
== 3
)
assert len(app.router.static_routes) == 1
assert len(app.router.static_routes[("overload_whole",)].methods) == 3
request, response = app.test_client.get("/overload_whole")
assert response.text == "OK1"
@@ -1073,7 +1066,8 @@ def test_uri_with_different_method_and_different_params(app):
return json({"action": action})
request, response = app.test_client.get("/ads/1234")
assert response.status == 405
assert response.status == 200
assert response.json == {"ad_id": "1234"}
request, response = app.test_client.post("/ads/post")
assert response.status == 200
@@ -1175,3 +1169,59 @@ def test_route_with_bad_named_param(app):
with pytest.raises(SanicException):
app.router.finalize()
def test_routes_with_and_without_slash_definitions(app):
bar = Blueprint("bar", url_prefix="bar")
baz = Blueprint("baz", url_prefix="/baz")
fizz = Blueprint("fizz", url_prefix="fizz/")
buzz = Blueprint("buzz", url_prefix="/buzz/")
instances = (
(app, "foo"),
(bar, "bar"),
(baz, "baz"),
(fizz, "fizz"),
(buzz, "buzz"),
)
for instance, term in instances:
route = f"/{term}" if isinstance(instance, Sanic) else ""
@instance.get(route, strict_slashes=True)
def get_without(request):
return text(f"{term}_without")
@instance.get(f"{route}/", strict_slashes=True)
def get_with(request):
return text(f"{term}_with")
@instance.post(route, strict_slashes=True)
def post_without(request):
return text(f"{term}_without")
@instance.post(f"{route}/", strict_slashes=True)
def post_with(request):
return text(f"{term}_with")
app.blueprint(bar)
app.blueprint(baz)
app.blueprint(fizz)
app.blueprint(buzz)
for _, term in instances:
_, response = app.test_client.get(f"/{term}")
assert response.status == 200
assert response.text == f"{term}_without"
_, response = app.test_client.get(f"/{term}/")
assert response.status == 200
assert response.text == f"{term}_with"
_, response = app.test_client.post(f"/{term}")
assert response.status == 200
assert response.text == f"{term}_without"
_, response = app.test_client.post(f"/{term}/")
assert response.status == 200
assert response.text == f"{term}_with"

View File

@@ -28,7 +28,8 @@ def test_add_signal_decorator(app):
async def async_signal(*_):
...
assert len(app.signal_router.routes) == 1
assert len(app.signal_router.routes) == 2
assert len(app.signal_router.dynamic_routes) == 1
@pytest.mark.parametrize(
@@ -79,13 +80,13 @@ async def test_dispatch_signal_triggers_triggers_event(app):
def sync_signal(*args):
nonlocal app
nonlocal counter
signal, *_ = app.signal_router.get("foo.bar.baz")
counter += signal.ctx.event.is_set()
group, *_ = app.signal_router.get("foo.bar.baz")
for signal in group:
counter += signal.ctx.event.is_set()
app.signal_router.finalize()
await app.dispatch("foo.bar.baz")
signal, *_ = app.signal_router.get("foo.bar.baz")
assert counter == 1
@@ -224,7 +225,7 @@ async def test_dispatch_signal_triggers_event_on_bp(app):
app.blueprint(bp)
app.signal_router.finalize()
signal, *_ = app.signal_router.get(
signal_group, *_ = app.signal_router.get(
"foo.bar.baz", condition={"blueprint": "bp"}
)
@@ -233,7 +234,8 @@ async def test_dispatch_signal_triggers_event_on_bp(app):
assert isawaitable(waiter)
fut = asyncio.ensure_future(do_wait())
signal.ctx.event.set()
for signal in signal_group:
signal.ctx.event.set()
await fut
assert bp_counter == 1
@@ -255,17 +257,60 @@ def test_bad_finalize(app):
assert counter == 0
def test_event_not_exist(app):
@pytest.mark.asyncio
async def test_event_not_exist(app):
with pytest.raises(NotFound, match="Could not find signal does.not.exist"):
app.event("does.not.exist")
await app.event("does.not.exist")
def test_event_not_exist_on_bp(app):
@pytest.mark.asyncio
async def test_event_not_exist_on_bp(app):
bp = Blueprint("bp")
app.blueprint(bp)
with pytest.raises(NotFound, match="Could not find signal does.not.exist"):
bp.event("does.not.exist")
await bp.event("does.not.exist")
@pytest.mark.asyncio
async def test_event_not_exist_with_autoregister(app):
app.config.EVENT_AUTOREGISTER = True
try:
await app.event("does.not.exist", timeout=0.1)
except asyncio.TimeoutError:
...
@pytest.mark.asyncio
async def test_dispatch_signal_triggers_non_exist_event_with_autoregister(app):
@app.signal("some.stand.in")
async def signal_handler():
...
app.config.EVENT_AUTOREGISTER = True
app_counter = 0
app.signal_router.finalize()
async def do_wait():
nonlocal app_counter
await app.event("foo.bar.baz")
app_counter += 1
fut = asyncio.ensure_future(do_wait())
await app.dispatch("foo.bar.baz")
await fut
assert app_counter == 1
@pytest.mark.asyncio
async def test_dispatch_not_exist(app):
@app.signal("do.something.start")
async def signal_handler():
...
app.signal_router.finalize()
await app.dispatch("does.not.exist")
def test_event_on_bp_not_registered():

View File

@@ -1,11 +1,16 @@
import inspect
import logging
import os
from collections import Counter
from pathlib import Path
from time import gmtime, strftime
import pytest
from sanic import text
from sanic.exceptions import FileNotFound
@pytest.fixture(scope="module")
def static_file_directory():
@@ -445,3 +450,60 @@ def test_static_name(app, static_file_directory, static_name, file_name):
request, response = app.test_client.get(f"/static/{file_name}")
assert response.status == 200
def test_nested_dir(app, static_file_directory):
app.static("/static", static_file_directory)
request, response = app.test_client.get("/static/nested/dir/foo.txt")
assert response.status == 200
assert response.text == "foo\n"
def test_stack_trace_on_not_found(app, static_file_directory, caplog):
app.static("/static", static_file_directory)
with caplog.at_level(logging.INFO):
_, response = app.test_client.get("/static/non_existing_file.file")
counter = Counter([r[1] for r in caplog.record_tuples])
assert response.status == 404
assert counter[logging.INFO] == 5
assert counter[logging.ERROR] == 1
def test_no_stack_trace_on_not_found(app, static_file_directory, caplog):
app.static("/static", static_file_directory)
@app.exception(FileNotFound)
async def file_not_found(request, exception):
return text(f"No file: {request.path}", status=404)
with caplog.at_level(logging.INFO):
_, response = app.test_client.get("/static/non_existing_file.file")
counter = Counter([r[1] for r in caplog.record_tuples])
assert response.status == 404
assert counter[logging.INFO] == 5
assert logging.ERROR not in counter
assert response.text == "No file: /static/non_existing_file.file"
def test_multiple_statics(app, static_file_directory):
app.static("/file", get_file_path(static_file_directory, "test.file"))
app.static("/png", get_file_path(static_file_directory, "python.png"))
_, response = app.test_client.get("/file")
assert response.status == 200
assert response.body == get_file_content(
static_file_directory, "test.file"
)
_, response = app.test_client.get("/png")
assert response.status == 200
assert response.body == get_file_content(
static_file_directory, "python.png"
)

View File

@@ -1,6 +1,5 @@
import asyncio
from time import monotonic as current_time
from unittest.mock import Mock
import pytest

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