Compare commits

..

1 Commits

Author SHA1 Message Date
Adam Hopkins
fa864f0bab Start and restart arbitrary processes 2023-07-09 13:53:14 +03:00
63 changed files with 1020 additions and 1240 deletions

View File

@@ -21,14 +21,7 @@ body:
id: code id: code
attributes: attributes:
label: Code snippet label: Code snippet
description: | description: Relevant source code, make sure to remove what is not necessary.
Relevant source code, make sure to remove what is not necessary. Please try and format your code so that it is easier to read. For example:
```python
from sanic import Sanic
app = Sanic("Example")
```
validations: validations:
required: false required: false
- type: textarea - type: textarea
@@ -49,16 +42,11 @@ body:
- ASGI - ASGI
validations: validations:
required: true required: true
- type: dropdown - type: input
id: os id: os
attributes: attributes:
label: Operating System label: Operating System
description: What OS? description: What OS?
options:
- Linux
- MacOS
- Windows
- Other (tell us in the description)
validations: validations:
required: true required: true
- type: input - type: input

View File

@@ -12,23 +12,29 @@ on:
- main - main
- current-release - current-release
- "*LTS" - "*LTS"
jobs: jobs:
coverage: test:
name: Check coverage runs-on: ${{ matrix.os }}
runs-on: ubuntu-latest
strategy: strategy:
matrix:
python-version: [3.9]
os: [ubuntu-latest]
fail-fast: false fail-fast: false
steps: steps:
- name: Run coverage - uses: actions/checkout@v2
uses: sanic-org/simple-tox-action@v1 - uses: actions/setup-python@v1
with: with:
python-version: "3.11" python-version: ${{ matrix.python-version }}
tox-env: coverage
ignore-errors: true - name: Install dependencies 🔨
- name: Run Codecov run: |
uses: codecov/codecov-action@v3 python -m pip install --upgrade pip
pip install tox
- name: Run coverage
run: tox -e coverage
continue-on-error: true
- uses: codecov/codecov-action@v2
with: with:
files: ./coverage.xml files: ./coverage.xml
fail_ci_if_error: false fail_ci_if_error: false

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"

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

@@ -0,0 +1,38 @@
name: Security Analysis
on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]
jobs:
bandit:
if: github.event.pull_request.draft == false
name: type-check-${{ matrix.config.python-version }}
runs-on: ${{ matrix.os }}
strategy:
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}
- { python-version: "3.10", tox-env: security}
- { python-version: "3.11", 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 }}"

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

@@ -0,0 +1,33 @@
name: Document Linter
on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]
jobs:
docsLinter:
if: github.event.pull_request.draft == false
name: Lint Documentation
runs-on: ubuntu-latest
strategy:
matrix:
config:
- {python-version: "3.10", 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 }}"

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

@@ -0,0 +1,34 @@
name: Linter Checks
on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]
jobs:
linter:
if: github.event.pull_request.draft == false
name: lint
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
config:
- { python-version: "3.10", 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"

48
.github/workflows/pr-python310.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: Python 3.10 Tests
on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]
jobs:
testPy310:
if: github.event.pull_request.draft == false
name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest]
config:
- {
python-version: "3.10",
tox-env: py310,
ignore-error-flake: "false",
command-timeout: "0",
}
- {
python-version: "3.10",
tox-env: py310-no-ext,
ignore-error-flake: "true",
command-timeout: "600000",
}
steps:
- name: Checkout the Repository
uses: actions/checkout@v2
id: checkout-branch
- name: Run Unit Tests
uses: harshanarayana/custom-actions@main
with:
python-version: ${{ matrix.config.python-version }}
test-infra-tool: tox
test-infra-version: latest
action: tests
test-additional-args: "-e=${{ matrix.config.tox-env }},-vv=''"
experimental-ignore-error: "${{ matrix.config.ignore-error-flake }}"
command-timeout: "${{ matrix.config.command-timeout }}"
test-failure-retry: "3"

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

@@ -0,0 +1,48 @@
name: Python 3.11 Tests
on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]
jobs:
testPy311:
if: github.event.pull_request.draft == false
name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest]
config:
- {
python-version: "3.11",
tox-env: py311,
ignore-error-flake: "false",
command-timeout: "0",
}
- {
python-version: "3.11",
tox-env: py311-no-ext,
ignore-error-flake: "true",
command-timeout: "600000",
}
steps:
- name: Checkout the Repository
uses: actions/checkout@v2
id: checkout-branch
- name: Run Unit Tests
uses: harshanarayana/custom-actions@main
with:
python-version: ${{ matrix.config.python-version }}
test-infra-tool: tox
test-infra-version: latest
action: tests
test-additional-args: "-e=${{ matrix.config.tox-env }},-vv=''"
experimental-ignore-error: "${{ matrix.config.ignore-error-flake }}"
command-timeout: "${{ matrix.config.command-timeout }}"
test-failure-retry: "3"

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

@@ -0,0 +1,36 @@
name: Python 3.7 Tests
on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]
jobs:
testPy37:
if: github.event.pull_request.draft == false
name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
matrix:
# os: [ubuntu-latest, macos-latest]
os: [ubuntu-latest]
config:
- { python-version: 3.7, tox-env: py37 }
- { python-version: 3.7, tox-env: py37-no-ext }
steps:
- name: Checkout the Repository
uses: actions/checkout@v2
id: checkout-branch
- name: Run Unit Tests
uses: harshanarayana/custom-actions@main
with:
python-version: ${{ matrix.config.python-version }}
test-infra-tool: tox
test-infra-version: latest
action: tests
test-additional-args: "-e=${{ matrix.config.tox-env }}"
test-failure-retry: "3"

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

@@ -0,0 +1,36 @@
name: Python 3.8 Tests
on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]
jobs:
testPy38:
if: github.event.pull_request.draft == false
name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
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"

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

@@ -0,0 +1,48 @@
name: Python 3.9 Tests
on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]
jobs:
testPy39:
if: github.event.pull_request.draft == false
name: ut-${{ matrix.config.tox-env }}-${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
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"

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

@@ -0,0 +1,38 @@
name: Typing Checks
on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]
jobs:
typeChecking:
if: github.event.pull_request.draft == false
name: type-check-${{ matrix.config.python-version }}
runs-on: ${{ matrix.os }}
strategy:
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}
- { python-version: "3.10", tox-env: type-checking}
- { python-version: "3.11", tox-env: type-checking}
steps:
- name: Checkout the repository
uses: actions/checkout@v2
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 }}"

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

@@ -0,0 +1,40 @@
name: Run Unit Tests on Windows
on:
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]
jobs:
testsOnWindows:
if: github.event.pull_request.draft == false
name: ut-${{ matrix.config.tox-env }}
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
config:
- { python-version: 3.7, tox-env: py37-no-ext }
- { python-version: 3.8, tox-env: py38-no-ext }
- { python-version: 3.9, tox-env: py39-no-ext }
- { python-version: "3.10", tox-env: py310-no-ext }
- { python-version: "3.11", tox-env: py310-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", "3.10", "3.11"]
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.10"]
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"

View File

@@ -1,174 +0,0 @@
name: Publish release
on:
release:
types: [created]
env:
IS_TEST: false
DOCKER_ORG_NAME: sanicframework
DOCKER_IMAGE_NAME: sanic
DOCKER_BASE_IMAGE_NAME: sanic-build
DOCKER_IMAGE_DOCKERFILE: ./docker/Dockerfile
DOCKER_BASE_IMAGE_DOCKERFILE: ./docker/Dockerfile-base
jobs:
generate_info:
name: Generate info
runs-on: ubuntu-latest
outputs:
docker-tags: ${{ steps.generate_docker_info.outputs.tags }}
pypi-version: ${{ steps.parse_version_tag.outputs.pypi-version }}
steps:
- name: Parse version tag
id: parse_version_tag
env:
TAG_NAME: ${{ github.event.release.tag_name }}
run: |
tag_name="${{ env.TAG_NAME }}"
if [[ ! "${tag_name}" =~ ^v([0-9]{2})\.([0-9]{1,2})\.([0-9]+)$ ]]; then
echo "::error::Tag name must be in the format vYY.MM.MICRO"
exit 1
fi
year_output="year=${BASH_REMATCH[1]}"
month_output="month=${BASH_REMATCH[2]}"
pypi_output="pypi-version=${tag_name#v}"
echo "${year_output}"
echo "${month_output}"
echo "${pypi_output}"
echo "${year_output}" >> $GITHUB_OUTPUT
echo "${month_output}" >> $GITHUB_OUTPUT
echo "${pypi_output}" >> $GITHUB_OUTPUT
- name: Get latest release
id: get_latest_release
run: |
latest_tag=$(
curl -L \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${{ github.token }}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${{ github.repository }}/releases/latest \
| jq -r '.tag_name'
)
echo "latest_tag=$latest_tag" >> $GITHUB_OUTPUT
- name: Generate Docker info
id: generate_docker_info
run: |
tag_year="${{ steps.parse_version_tag.outputs.year }}"
tag_month="${{ steps.parse_version_tag.outputs.month }}"
latest_tag="${{ steps.get_latest_release.outputs.latest_tag }}"
tag="${{ github.event.release.tag_name }}"
tags="${tag_year}.${tag_month}"
if [[ "${tag_month}" == "12" ]]; then
tags+=",LTS"
echo "::notice::Tag ${tag} is LTS version"
else
echo "::notice::Tag ${tag} is not LTS version"
fi
if [[ "${latest_tag}" == "${{ github.event.release.tag_name }}" ]]; then
tags+=",latest"
echo "::notice::Tag ${tag} is marked as latest"
else
echo "::notice::Tag ${tag} is not marked as latest"
fi
tags_output="tags=${tags}"
echo "${tags_output}"
echo "${tags_output}" >> $GITHUB_OUTPUT
publish_package:
name: Build and publish package
runs-on: ubuntu-latest
needs: generate_info
steps:
- name: Checkout repo
uses: actions/checkout@v3
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: "3.11"
- name: Install dependencies
run: pip install build twine
- name: Update package version
run: |
echo "__version__ = \"${{ needs.generate_info.outputs.pypi-version }}\"" > sanic/__version__.py
- name: Build a binary wheel and a source tarball
run: python -m build --sdist --wheel --outdir dist/ .
- name: Publish to PyPi 🚀
run: twine upload --non-interactive --disable-progress-bar dist/*
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ env.IS_TEST == 'true' && secrets.SANIC_TEST_PYPI_API_TOKEN || secrets.SANIC_PYPI_API_TOKEN }}
TWINE_REPOSITORY: ${{ env.IS_TEST == 'true' && 'testpypi' || 'pypi' }}
publish_docker:
name: Publish Docker / Python ${{ matrix.python-version }}
needs: [generate_info, publish_package]
runs-on: ubuntu-latest
strategy:
fail-fast: true
matrix:
python-version: ["3.10", "3.11"]
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_ACCESS_USER }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Build and push base image
uses: docker/build-push-action@v4
with:
push: ${{ env.IS_TEST == 'false' }}
file: ${{ env.DOCKER_BASE_IMAGE_DOCKERFILE }}
tags: ${{ env.DOCKER_ORG_NAME }}/${{ env.DOCKER_BASE_IMAGE_NAME }}:${{ matrix.python-version }}
build-args: |
PYTHON_VERSION=${{ matrix.python-version }}
- name: Parse tags for this Python version
id: parse_tags
run: |
IFS=',' read -ra tags <<< "${{ needs.generate_info.outputs.docker-tags }}"
tag_args=""
for tag in "${tags[@]}"; do
tag_args+=",${{ env.DOCKER_ORG_NAME }}/${{ env.DOCKER_IMAGE_NAME }}:${tag}-py${{ matrix.python-version }}"
done
tag_args_output="tag_args=${tag_args:1}"
echo "${tag_args_output}"
echo "${tag_args_output}" >> $GITHUB_OUTPUT
- name: Build and push Sanic image
uses: docker/build-push-action@v4
with:
push: ${{ env.IS_TEST == 'false' }}
file: ${{ env.DOCKER_IMAGE_DOCKERFILE }}
tags: ${{ steps.parse_tags.outputs.tag_args }}
build-args: |
BASE_IMAGE_ORG=${{ env.DOCKER_ORG_NAME }}
BASE_IMAGE_NAME=${{ env.DOCKER_BASE_IMAGE_NAME }}
BASE_IMAGE_TAG=${{ matrix.python-version }}
SANIC_PYPI_VERSION=${{ needs.generate_info.outputs.pypi-version }}

View File

@@ -1,56 +0,0 @@
name: Tests
on:
push:
branches:
- main
- current-release
- "*LTS"
tags:
- "!*"
pull_request:
branches:
- main
- current-release
- "*LTS"
types: [opened, synchronize, reopened, ready_for_review]
jobs:
run_tests:
name: "${{ matrix.config.platform == 'windows-latest' && 'Windows' || 'Linux' }} / Python ${{ matrix.config.python-version }} / tox -e ${{ matrix.config.tox-env }}"
if: github.event.pull_request.draft == false
runs-on: ${{ matrix.config.platform || 'ubuntu-latest' }}
strategy:
fail-fast: true
matrix:
config:
- { python-version: "3.8", tox-env: security }
- { python-version: "3.9", tox-env: security }
- { python-version: "3.10", tox-env: security }
- { python-version: "3.11", tox-env: security }
- { python-version: "3.10", tox-env: lint }
- { python-version: "3.10", tox-env: docs }
- { python-version: "3.8", tox-env: type-checking }
- { python-version: "3.9", tox-env: type-checking }
- { python-version: "3.10", tox-env: type-checking }
- { python-version: "3.11", tox-env: type-checking }
- { python-version: "3.8", tox-env: py38, max-attempts: 3 }
- { python-version: "3.8", tox-env: py38-no-ext, max-attempts: 3 }
- { python-version: "3.9", tox-env: py39, max-attempts: 3 }
- { python-version: "3.9", tox-env: py39-no-ext, max-attempts: 3 }
- { python-version: "3.10", tox-env: py310, max-attempts: 3 }
- { python-version: "3.10", tox-env: py310-no-ext, max-attempts: 3 }
- { python-version: "3.11", tox-env: py311, max-attempts: 3 }
- { python-version: "3.11", tox-env: py311-no-ext, max-attempts: 3 }
- { python-version: "3.8", tox-env: py38-no-ext, platform: windows-latest, ignore-errors: true }
- { python-version: "3.9", tox-env: py39-no-ext, platform: windows-latest, ignore-errors: true }
- { python-version: "3.10", tox-env: py310-no-ext, platform: windows-latest, ignore-errors: true }
- { python-version: "3.11", tox-env: py310-no-ext, platform: windows-latest, ignore-errors: true }
steps:
- name: Run tests
uses: sanic-org/simple-tox-action@v1
with:
python-version: ${{ matrix.config.python-version }}
tox-env: ${{ matrix.config.tox-env }}
max-attempts: ${{ matrix.config.max-attempts || 1 }}
ignore-errors: ${{ matrix.config.ignore-errors || false }}

View File

@@ -11,7 +11,7 @@ Sanic | Build fast. Run fast.
:stub-columns: 1 :stub-columns: 1
* - Build * - Build
- | |Tests| - | |Py310Test| |Py39Test| |Py38Test| |Py37Test|
* - Docs * - Docs
- | |UserGuide| |Documentation| - | |UserGuide| |Documentation|
* - Package * - Package
@@ -19,7 +19,7 @@ Sanic | Build fast. Run fast.
* - Support * - Support
- | |Forums| |Discord| |Awesome| - | |Forums| |Discord| |Awesome|
* - Stats * - Stats
- | |Monthly Downloads| |Weekly Downloads| |Conda downloads| - | |Downloads| |WkDownloads| |Conda downloads|
.. |UserGuide| image:: https://img.shields.io/badge/user%20guide-sanic-ff0068 .. |UserGuide| image:: https://img.shields.io/badge/user%20guide-sanic-ff0068
:target: https://sanicframework.org/ :target: https://sanicframework.org/
@@ -27,8 +27,14 @@ Sanic | Build fast. Run fast.
:target: https://community.sanicframework.org/ :target: https://community.sanicframework.org/
.. |Discord| image:: https://img.shields.io/discord/812221182594121728?logo=discord .. |Discord| image:: https://img.shields.io/discord/812221182594121728?logo=discord
:target: https://discord.gg/FARQzAEMAA :target: https://discord.gg/FARQzAEMAA
.. |Tests| image:: https://github.com/sanic-org/sanic/actions/workflows/tests.yml/badge.svg?branch=main .. |Py310Test| image:: https://github.com/sanic-org/sanic/actions/workflows/pr-python310.yml/badge.svg?branch=main
:target: https://github.com/sanic-org/sanic/actions/workflows/tests.yml :target: https://github.com/sanic-org/sanic/actions/workflows/pr-python310.yml
.. |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 .. |Documentation| image:: https://readthedocs.org/projects/sanic/badge/?version=latest
:target: http://sanic.readthedocs.io/en/latest/?badge=latest :target: http://sanic.readthedocs.io/en/latest/?badge=latest
.. |PyPI| image:: https://img.shields.io/pypi/v/sanic.svg .. |PyPI| image:: https://img.shields.io/pypi/v/sanic.svg
@@ -46,23 +52,19 @@ Sanic | Build fast. Run fast.
.. |Awesome| image:: https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg .. |Awesome| image:: https://cdn.rawgit.com/sindresorhus/awesome/d7305f38d29fed78fa85652e3a63e154dd8e8829/media/badge.svg
:alt: Awesome Sanic List :alt: Awesome Sanic List
:target: https://github.com/mekicha/awesome-sanic :target: https://github.com/mekicha/awesome-sanic
.. |Monthly Downloads| image:: https://img.shields.io/pypi/dm/sanic.svg .. |Downloads| image:: https://pepy.tech/badge/sanic/month
:alt: Downloads :alt: Downloads
:target: https://pepy.tech/project/sanic :target: https://pepy.tech/project/sanic
.. |Weekly Downloads| image:: https://img.shields.io/pypi/dw/sanic.svg .. |WkDownloads| image:: https://pepy.tech/badge/sanic/week
:alt: Downloads :alt: Downloads
:target: https://pepy.tech/project/sanic :target: https://pepy.tech/project/sanic
.. |Conda downloads| image:: https://img.shields.io/conda/dn/conda-forge/sanic.svg .. |Conda downloads| image:: https://img.shields.io/conda/dn/conda-forge/sanic.svg
:alt: Downloads :alt: Downloads
:target: https://anaconda.org/conda-forge/sanic :target: https://anaconda.org/conda-forge/sanic
.. |Linode| image:: https://www.linode.com/wp-content/uploads/2021/01/Linode-Logo-Black.svg
:alt: Linode
:target: https://www.linode.com
:width: 200px
.. end-badges .. end-badges
Sanic is a **Python 3.8+** web server and web framework that's written to go fast. It allows the usage of the ``async/await`` syntax added in Python 3.5, which makes your code non-blocking and speedy. Sanic is a **Python 3.7+** web server and web framework that's written to go fast. It allows the usage of the ``async/await`` syntax added in Python 3.5, which makes your code non-blocking and speedy.
Sanic is also ASGI compliant, so you can deploy it with an `alternative ASGI webserver <https://sanicframework.org/en/guide/deployment/running.html#asgi>`_. Sanic is also ASGI compliant, so you can deploy it with an `alternative ASGI webserver <https://sanicframework.org/en/guide/deployment/running.html#asgi>`_.
@@ -139,17 +141,17 @@ And, we can verify it is working: ``curl localhost:8000 -i``
**Now, let's go build something fast!** **Now, let's go build something fast!**
Minimum Python version is 3.8. If you need Python 3.7 support, please use v22.12LTS. Minimum Python version is 3.7. If you need Python 3.6 support, please use v20.12LTS.
Documentation Documentation
------------- -------------
`User Guide <https://sanic.dev>`__ and `API Documentation <http://sanic.readthedocs.io/>`__. `User Guide <https://sanicframework.org>`__ and `API Documentation <http://sanic.readthedocs.io/>`__.
Changelog Changelog
--------- ---------
`Release Changelogs <https://sanic.readthedocs.io/en/stable/sanic/changelog.html>`__. `Release Changelogs <https://github.com/sanic-org/sanic/blob/master/CHANGELOG.rst>`__.
Questions and Discussion Questions and Discussion
@@ -161,3 +163,8 @@ Contribution
------------ ------------
We are always happy to have new contributions. We have `marked issues good for anyone looking to get started <https://github.com/sanic-org/sanic/issues?q=is%3Aopen+is%3Aissue+label%3Abeginner>`_, and welcome `questions on the forums <https://community.sanicframework.org/>`_. Please take a look at our `Contribution guidelines <https://github.com/sanic-org/sanic/blob/master/CONTRIBUTING.rst>`_. We are always happy to have new contributions. We have `marked issues good for anyone looking to get started <https://github.com/sanic-org/sanic/issues?q=is%3Aopen+is%3Aissue+label%3Abeginner>`_, and welcome `questions on the forums <https://community.sanicframework.org/>`_. Please take a look at our `Contribution guidelines <https://github.com/sanic-org/sanic/blob/master/CONTRIBUTING.rst>`_.
.. |Linode| image:: https://www.linode.com/wp-content/uploads/2021/01/Linode-Logo-Black.svg
:alt: Linode
:target: https://www.linode.com
:width: 200px

View File

@@ -1,13 +1,9 @@
ARG BASE_IMAGE_ORG
ARG BASE_IMAGE_NAME
ARG BASE_IMAGE_TAG ARG BASE_IMAGE_TAG
FROM ${BASE_IMAGE_ORG}/${BASE_IMAGE_NAME}:${BASE_IMAGE_TAG} FROM sanicframework/sanic-build:${BASE_IMAGE_TAG}
RUN apk update RUN apk update
RUN update-ca-certificates RUN update-ca-certificates
ARG SANIC_PYPI_VERSION RUN pip install sanic
RUN pip install -U pip && pip install sanic==${SANIC_PYPI_VERSION}
RUN apk del build-base RUN apk del build-base

View File

@@ -5,7 +5,6 @@
| 🔷 In support release | 🔷 In support release
| |
.. mdinclude:: ./releases/23/23.6.md
.. mdinclude:: ./releases/23/23.3.md .. mdinclude:: ./releases/23/23.3.md
.. mdinclude:: ./releases/22/22.12.md .. mdinclude:: ./releases/22/22.12.md
.. mdinclude:: ./releases/22/22.9.md .. mdinclude:: ./releases/22/22.9.md

View File

@@ -1,4 +1,4 @@
## Version 23.3.0 ## Version 23.3.0 🔶
### Features ### Features
- [#2545](https://github.com/sanic-org/sanic/pull/2545) Standardize init of exceptions for more consistent control of HTTP responses using exceptions - [#2545](https://github.com/sanic-org/sanic/pull/2545) Standardize init of exceptions for more consistent control of HTTP responses using exceptions

View File

@@ -1,33 +0,0 @@
## Version 23.6.0 🔶
### Features
- [#2670](https://github.com/sanic-org/sanic/pull/2670) Increase `KEEP_ALIVE_TIMEOUT` default to 120 seconds
- [#2716](https://github.com/sanic-org/sanic/pull/2716) Adding allow route overwrite option in blueprint
- [#2724](https://github.com/sanic-org/sanic/pull/2724) and [#2792](https://github.com/sanic-org/sanic/pull/2792) Add a new exception signal for ALL exceptions raised anywhere in application
- [#2727](https://github.com/sanic-org/sanic/pull/2727) Add name prefixing to BP groups
- [#2754](https://github.com/sanic-org/sanic/pull/2754) Update request type on middleware types
- [#2770](https://github.com/sanic-org/sanic/pull/2770) Better exception message on startup time application induced import error
- [#2776](https://github.com/sanic-org/sanic/pull/2776) Set multiprocessing start method early
- [#2785](https://github.com/sanic-org/sanic/pull/2785) Add custom typing to config and ctx objects
- [#2790](https://github.com/sanic-org/sanic/pull/2790) Add `request.client_ip`
### Bugfixes
- [#2728](https://github.com/sanic-org/sanic/pull/2728) Fix traversals for intended results
- [#2729](https://github.com/sanic-org/sanic/pull/2729) Handle case when headers argument of ResponseStream constructor is None
- [#2737](https://github.com/sanic-org/sanic/pull/2737) Fix type annotation for `JSONREsponse` default content type
- [#2740](https://github.com/sanic-org/sanic/pull/2740) Use Sanic's serializer for JSON responses in the Inspector
- [#2760](https://github.com/sanic-org/sanic/pull/2760) Support for `Request.get_current` in ASGI mode
- [#2773](https://github.com/sanic-org/sanic/pull/2773) Alow Blueprint routes to explicitly define error_format
- [#2774](https://github.com/sanic-org/sanic/pull/2774) Resolve headers on different renderers
- [#2782](https://github.com/sanic-org/sanic/pull/2782) Resolve pypy compatibility issues
### Deprecations and Removals
- [#2777](https://github.com/sanic-org/sanic/pull/2777) Remove Python 3.7 support
### Developer infrastructure
- [#2766](https://github.com/sanic-org/sanic/pull/2766) Unpin setuptools version
- [#2779](https://github.com/sanic-org/sanic/pull/2779) Run keep alive tests in loop to get available port
### Improved Documentation
- [#2741](https://github.com/sanic-org/sanic/pull/2741) Better documentation examples about running Sanic
From that list, the items to highlight in the release notes:

View File

@@ -1,11 +1,6 @@
from types import SimpleNamespace
from typing_extensions import TypeAlias
from sanic.__version__ import __version__ from sanic.__version__ import __version__
from sanic.app import Sanic from sanic.app import Sanic
from sanic.blueprints import Blueprint from sanic.blueprints import Blueprint
from sanic.config import Config
from sanic.constants import HTTPMethod from sanic.constants import HTTPMethod
from sanic.exceptions import ( from sanic.exceptions import (
BadRequest, BadRequest,
@@ -37,29 +32,15 @@ from sanic.response import (
from sanic.server.websockets.impl import WebsocketImplProtocol as Websocket from sanic.server.websockets.impl import WebsocketImplProtocol as Websocket
DefaultSanic: TypeAlias = "Sanic[Config, SimpleNamespace]"
"""
A type alias for a Sanic app with a default config and namespace.
"""
DefaultRequest: TypeAlias = Request[DefaultSanic, SimpleNamespace]
"""
A type alias for a request with a default Sanic app and namespace.
"""
__all__ = ( __all__ = (
"__version__", "__version__",
# Common objects # Common objects
"Sanic", "Sanic",
"Config",
"Blueprint", "Blueprint",
"HTTPMethod", "HTTPMethod",
"HTTPResponse", "HTTPResponse",
"Request", "Request",
"Websocket", "Websocket",
# Common types
"DefaultSanic",
"DefaultRequest",
# Common exceptions # Common exceptions
"BadRequest", "BadRequest",
"ExpectationFailed", "ExpectationFailed",

View File

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

View File

@@ -5,7 +5,6 @@ import logging
import logging.config import logging.config
import re import re
import sys import sys
from asyncio import ( from asyncio import (
AbstractEventLoop, AbstractEventLoop,
CancelledError, CancelledError,
@@ -17,7 +16,7 @@ from asyncio import (
from asyncio.futures import Future from asyncio.futures import Future
from collections import defaultdict, deque from collections import defaultdict, deque
from contextlib import contextmanager, suppress from contextlib import contextmanager, suppress
from functools import partial, wraps from functools import partial
from inspect import isawaitable from inspect import isawaitable
from os import environ from os import environ
from socket import socket from socket import socket
@@ -29,11 +28,9 @@ from typing import (
AnyStr, AnyStr,
Awaitable, Awaitable,
Callable, Callable,
ClassVar,
Coroutine, Coroutine,
Deque, Deque,
Dict, Dict,
Generic,
Iterable, Iterable,
Iterator, Iterator,
List, List,
@@ -43,8 +40,6 @@ from typing import (
Type, Type,
TypeVar, TypeVar,
Union, Union,
cast,
overload,
) )
from urllib.parse import urlencode, urlunparse from urllib.parse import urlencode, urlunparse
@@ -59,12 +54,7 @@ from sanic.blueprint_group import BlueprintGroup
from sanic.blueprints import Blueprint from sanic.blueprints import Blueprint
from sanic.compat import OS_IS_WINDOWS, enable_windows_color_support from sanic.compat import OS_IS_WINDOWS, enable_windows_color_support
from sanic.config import SANIC_PREFIX, Config from sanic.config import SANIC_PREFIX, Config
from sanic.exceptions import ( from sanic.exceptions import BadRequest, SanicException, ServerError, URLBuildError
BadRequest,
SanicException,
ServerError,
URLBuildError,
)
from sanic.handlers import ErrorHandler from sanic.handlers import ErrorHandler
from sanic.helpers import Default, _default from sanic.helpers import Default, _default
from sanic.http import Stage from sanic.http import Stage
@@ -87,14 +77,13 @@ from sanic.request import Request
from sanic.response import BaseHTTPResponse, HTTPResponse, ResponseStream from sanic.response import BaseHTTPResponse, HTTPResponse, ResponseStream
from sanic.router import Router from sanic.router import Router
from sanic.server.websockets.impl import ConnectionClosed from sanic.server.websockets.impl import ConnectionClosed
from sanic.signals import Event, Signal, SignalRouter from sanic.signals import Signal, SignalRouter
from sanic.touchup import TouchUp, TouchUpMeta from sanic.touchup import TouchUp, TouchUpMeta
from sanic.types.shared_ctx import SharedContext from sanic.types.shared_ctx import SharedContext
from sanic.worker.inspector import Inspector from sanic.worker.inspector import Inspector
from sanic.worker.loader import CertLoader from sanic.worker.loader import CertLoader
from sanic.worker.manager import WorkerManager from sanic.worker.manager import WorkerManager
if TYPE_CHECKING: if TYPE_CHECKING:
try: try:
from sanic_ext import Extend # type: ignore from sanic_ext import Extend # type: ignore
@@ -106,17 +95,8 @@ if TYPE_CHECKING:
if OS_IS_WINDOWS: # no cov if OS_IS_WINDOWS: # no cov
enable_windows_color_support() enable_windows_color_support()
ctx_type = TypeVar("ctx_type")
config_type = TypeVar("config_type", bound=Config)
class Sanic(StaticHandleMixin, BaseSanic, StartupMixin, metaclass=TouchUpMeta):
class Sanic(
Generic[config_type, ctx_type],
StaticHandleMixin,
BaseSanic,
StartupMixin,
metaclass=TouchUpMeta,
):
""" """
The main application instance The main application instance
""" """
@@ -171,102 +151,14 @@ class Sanic(
"websocket_tasks", "websocket_tasks",
) )
_app_registry: ClassVar[Dict[str, "Sanic"]] = {} _app_registry: Dict[str, "Sanic"] = {}
test_mode: ClassVar[bool] = False test_mode = False
@overload
def __init__(
self: Sanic[Config, SimpleNamespace],
name: str,
config: None = None,
ctx: None = None,
router: Optional[Router] = None,
signal_router: Optional[SignalRouter] = None,
error_handler: Optional[ErrorHandler] = None,
env_prefix: Optional[str] = SANIC_PREFIX,
request_class: Optional[Type[Request]] = None,
strict_slashes: bool = False,
log_config: Optional[Dict[str, Any]] = None,
configure_logging: bool = True,
dumps: Optional[Callable[..., AnyStr]] = None,
loads: Optional[Callable[..., Any]] = None,
inspector: bool = False,
inspector_class: Optional[Type[Inspector]] = None,
certloader_class: Optional[Type[CertLoader]] = None,
) -> None:
...
@overload
def __init__(
self: Sanic[config_type, SimpleNamespace],
name: str,
config: Optional[config_type] = None,
ctx: None = None,
router: Optional[Router] = None,
signal_router: Optional[SignalRouter] = None,
error_handler: Optional[ErrorHandler] = None,
env_prefix: Optional[str] = SANIC_PREFIX,
request_class: Optional[Type[Request]] = None,
strict_slashes: bool = False,
log_config: Optional[Dict[str, Any]] = None,
configure_logging: bool = True,
dumps: Optional[Callable[..., AnyStr]] = None,
loads: Optional[Callable[..., Any]] = None,
inspector: bool = False,
inspector_class: Optional[Type[Inspector]] = None,
certloader_class: Optional[Type[CertLoader]] = None,
) -> None:
...
@overload
def __init__(
self: Sanic[Config, ctx_type],
name: str,
config: None = None,
ctx: Optional[ctx_type] = None,
router: Optional[Router] = None,
signal_router: Optional[SignalRouter] = None,
error_handler: Optional[ErrorHandler] = None,
env_prefix: Optional[str] = SANIC_PREFIX,
request_class: Optional[Type[Request]] = None,
strict_slashes: bool = False,
log_config: Optional[Dict[str, Any]] = None,
configure_logging: bool = True,
dumps: Optional[Callable[..., AnyStr]] = None,
loads: Optional[Callable[..., Any]] = None,
inspector: bool = False,
inspector_class: Optional[Type[Inspector]] = None,
certloader_class: Optional[Type[CertLoader]] = None,
) -> None:
...
@overload
def __init__(
self: Sanic[config_type, ctx_type],
name: str,
config: Optional[config_type] = None,
ctx: Optional[ctx_type] = None,
router: Optional[Router] = None,
signal_router: Optional[SignalRouter] = None,
error_handler: Optional[ErrorHandler] = None,
env_prefix: Optional[str] = SANIC_PREFIX,
request_class: Optional[Type[Request]] = None,
strict_slashes: bool = False,
log_config: Optional[Dict[str, Any]] = None,
configure_logging: bool = True,
dumps: Optional[Callable[..., AnyStr]] = None,
loads: Optional[Callable[..., Any]] = None,
inspector: bool = False,
inspector_class: Optional[Type[Inspector]] = None,
certloader_class: Optional[Type[CertLoader]] = None,
) -> None:
...
def __init__( def __init__(
self, self,
name: str, name: Optional[str] = None,
config: Optional[config_type] = None, config: Optional[Config] = None,
ctx: Optional[ctx_type] = None, ctx: Optional[Any] = None,
router: Optional[Router] = None, router: Optional[Router] = None,
signal_router: Optional[SignalRouter] = None, signal_router: Optional[SignalRouter] = None,
error_handler: Optional[ErrorHandler] = None, error_handler: Optional[ErrorHandler] = None,
@@ -294,9 +186,7 @@ class Sanic(
) )
# First setup config # First setup config
self.config: config_type = cast( self.config: Config = config or Config(env_prefix=env_prefix)
config_type, config or Config(env_prefix=env_prefix)
)
if inspector: if inspector:
self.config.INSPECTOR = inspector self.config.INSPECTOR = inspector
@@ -320,7 +210,7 @@ class Sanic(
certloader_class or CertLoader certloader_class or CertLoader
) )
self.configure_logging: bool = configure_logging self.configure_logging: bool = configure_logging
self.ctx: ctx_type = cast(ctx_type, ctx or SimpleNamespace()) self.ctx: Any = ctx or SimpleNamespace()
self.error_handler: ErrorHandler = error_handler or ErrorHandler() self.error_handler: ErrorHandler = error_handler or ErrorHandler()
self.inspector_class: Type[Inspector] = inspector_class or Inspector self.inspector_class: Type[Inspector] = inspector_class or Inspector
self.listeners: Dict[str, List[ListenerType[Any]]] = defaultdict(list) self.listeners: Dict[str, List[ListenerType[Any]]] = defaultdict(list)
@@ -605,19 +495,6 @@ class Sanic(
raise NotFound("Could not find signal %s" % event) raise NotFound("Could not find signal %s" % event)
return await wait_for(signal.ctx.event.wait(), timeout=timeout) return await wait_for(signal.ctx.event.wait(), timeout=timeout)
def report_exception(
self, handler: Callable[[Sanic, Exception], Coroutine[Any, Any, None]]
):
@wraps(handler)
async def report(exception: Exception) -> None:
await handler(self, exception)
self.add_signal(
handler=report, event=Event.SERVER_EXCEPTION_REPORT.value
)
return report
def enable_websocket(self, enable=True): def enable_websocket(self, enable=True):
"""Enable or disable the support for websocket. """Enable or disable the support for websocket.
@@ -889,10 +766,8 @@ class Sanic(
:raises ServerError: response 500 :raises ServerError: response 500
""" """
response = None response = None
if not getattr(exception, "__dispatched__", False):
... # DO NOT REMOVE THIS LINE. IT IS NEEDED FOR TOUCHUP.
await self.dispatch( await self.dispatch(
"server.exception.report", "server.lifecycle.exception",
context={"exception": exception}, context={"exception": exception},
) )
await self.dispatch( await self.dispatch(
@@ -1325,28 +1200,13 @@ class Sanic(
app, app,
loop, loop,
): ):
async def do(task):
try:
if callable(task): if callable(task):
try: try:
task = task(app) task = task(app)
except TypeError: except TypeError:
task = task() task = task()
if isawaitable(task):
await task
except CancelledError:
error_logger.warning(
f"Task {task} was cancelled before it completed."
)
raise
except Exception as e:
await app.dispatch(
"server.exception.report",
context={"exception": e},
)
raise
return do(task) return task
@classmethod @classmethod
def _loop_add_task( def _loop_add_task(
@@ -1360,9 +1220,18 @@ class Sanic(
) -> Task: ) -> Task:
if not isinstance(task, Future): if not isinstance(task, Future):
prepped = cls._prep_task(task, app, loop) prepped = cls._prep_task(task, app, loop)
if sys.version_info < (3, 8): # no cov
task = loop.create_task(prepped)
if name:
error_logger.warning(
"Cannot set a name for a task when using Python 3.7. "
"Your task will be created without a name."
)
task.get_name = lambda: name
else:
task = loop.create_task(prepped, name=name) task = loop.create_task(prepped, name=name)
if name and register: if name and register and sys.version_info > (3, 7):
app._task_registry[name] = task app._task_registry[name] = task
return task return task
@@ -1800,7 +1669,10 @@ class Sanic(
def inspector(self): def inspector(self):
if environ.get("SANIC_WORKER_PROCESS") or not self._inspector: if environ.get("SANIC_WORKER_PROCESS") or not self._inspector:
raise SanicException( raise SanicException(
"Can only access the inspector from the main process" "Can only access the inspector from the main process "
"after main_process_start has run. For example, you most "
"likely want to use it inside the @app.main_process_ready "
"event listener."
) )
return self._inspector return self._inspector
@@ -1808,6 +1680,9 @@ class Sanic(
def manager(self): def manager(self):
if environ.get("SANIC_WORKER_PROCESS") or not self._manager: if environ.get("SANIC_WORKER_PROCESS") or not self._manager:
raise SanicException( raise SanicException(
"Can only access the manager from the main process" "Can only access the manager from the main process "
"after main_process_start has run. For example, you most "
"likely want to use it inside the @app.main_process_ready "
"event listener."
) )
return self._manager return self._manager

View File

@@ -111,7 +111,7 @@ class Blueprint(BaseSanic):
def __init__( def __init__(
self, self,
name: str, name: str = None,
url_prefix: Optional[str] = None, url_prefix: Optional[str] = None,
host: Optional[Union[List[str], str]] = None, host: Optional[Union[List[str], str]] = None,
version: Optional[Union[int, str, float]] = None, version: Optional[Union[int, str, float]] = None,
@@ -319,10 +319,6 @@ class Blueprint(BaseSanic):
# Prepend the blueprint URI prefix if available # Prepend the blueprint URI prefix if available
uri = self._setup_uri(future.uri, url_prefix) uri = self._setup_uri(future.uri, url_prefix)
route_error_format = (
future.error_format if future.error_format else error_format
)
version_prefix = self.version_prefix version_prefix = self.version_prefix
for prefix in ( for prefix in (
future.version_prefix, future.version_prefix,
@@ -362,7 +358,7 @@ class Blueprint(BaseSanic):
future.unquote, future.unquote,
future.static, future.static,
version_prefix, version_prefix,
route_error_format, error_format,
future.route_context, future.route_context,
) )

View File

@@ -43,14 +43,14 @@ DEFAULT_CONFIG = {
"DEPRECATION_FILTER": "once", "DEPRECATION_FILTER": "once",
"FORWARDED_FOR_HEADER": "X-Forwarded-For", "FORWARDED_FOR_HEADER": "X-Forwarded-For",
"FORWARDED_SECRET": None, "FORWARDED_SECRET": None,
"GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, "GRACEFUL_SHUTDOWN_TIMEOUT": 15.0, # 15 sec
"INSPECTOR": False, "INSPECTOR": False,
"INSPECTOR_HOST": "localhost", "INSPECTOR_HOST": "localhost",
"INSPECTOR_PORT": 6457, "INSPECTOR_PORT": 6457,
"INSPECTOR_TLS_KEY": _default, "INSPECTOR_TLS_KEY": _default,
"INSPECTOR_TLS_CERT": _default, "INSPECTOR_TLS_CERT": _default,
"INSPECTOR_API_KEY": "", "INSPECTOR_API_KEY": "",
"KEEP_ALIVE_TIMEOUT": 120, "KEEP_ALIVE_TIMEOUT": 5, # 5 seconds
"KEEP_ALIVE": True, "KEEP_ALIVE": True,
"LOCAL_CERT_CREATOR": LocalCertCreator.AUTO, "LOCAL_CERT_CREATOR": LocalCertCreator.AUTO,
"LOCAL_TLS_KEY": _default, "LOCAL_TLS_KEY": _default,
@@ -61,16 +61,16 @@ DEFAULT_CONFIG = {
"NOISY_EXCEPTIONS": False, "NOISY_EXCEPTIONS": False,
"PROXIES_COUNT": None, "PROXIES_COUNT": None,
"REAL_IP_HEADER": None, "REAL_IP_HEADER": None,
"REQUEST_BUFFER_SIZE": 65536, "REQUEST_BUFFER_SIZE": 65536, # 64 KiB
"REQUEST_MAX_HEADER_SIZE": 8192, # Cannot exceed 16384 "REQUEST_MAX_HEADER_SIZE": 8192, # 8 KiB, but cannot exceed 16384
"REQUEST_ID_HEADER": "X-Request-ID", "REQUEST_ID_HEADER": "X-Request-ID",
"REQUEST_MAX_SIZE": 100_000_000, "REQUEST_MAX_SIZE": 100000000, # 100 megabytes
"REQUEST_TIMEOUT": 60, "REQUEST_TIMEOUT": 60, # 60 seconds
"RESPONSE_TIMEOUT": 60, "RESPONSE_TIMEOUT": 60, # 60 seconds
"TLS_CERT_PASSWORD": "", "TLS_CERT_PASSWORD": "",
"TOUCHUP": _default, "TOUCHUP": _default,
"USE_UVLOOP": _default, "USE_UVLOOP": _default,
"WEBSOCKET_MAX_SIZE": 2**20, # 1 MiB "WEBSOCKET_MAX_SIZE": 2**20, # 1 megabyte
"WEBSOCKET_PING_INTERVAL": 20, "WEBSOCKET_PING_INTERVAL": 20,
"WEBSOCKET_PING_TIMEOUT": 20, "WEBSOCKET_PING_TIMEOUT": 20,
} }

View File

@@ -312,7 +312,7 @@ def exception_response(
debug: bool, debug: bool,
fallback: str, fallback: str,
base: t.Type[BaseRenderer], base: t.Type[BaseRenderer],
renderer: t.Optional[t.Type[BaseRenderer]] = None, renderer: t.Type[t.Optional[BaseRenderer]] = None,
) -> HTTPResponse: ) -> HTTPResponse:
""" """
Render a response for the default FALLBACK exception handler. Render a response for the default FALLBACK exception handler.

View File

@@ -90,7 +90,7 @@ class SanicException(Exception):
super().__init__(message) super().__init__(message)
self.status_code = status_code or self.status_code self.status_code = status_code
self.quiet = quiet self.quiet = quiet
self.headers = headers self.headers = headers

View File

@@ -436,7 +436,7 @@ def format_http1_response(status: int, headers: HeaderBytesIterable) -> bytes:
def parse_credentials( def parse_credentials(
header: Optional[str], header: Optional[str],
prefixes: Optional[Union[List, Tuple, Set]] = None, prefixes: Union[List, Tuple, Set] = None,
) -> Tuple[Optional[str], Optional[str]]: ) -> Tuple[Optional[str], Optional[str]]:
"""Parses any header with the aim to retrieve any credentials from it.""" """Parses any header with the aim to retrieve any credentials from it."""
if not prefixes or not isinstance(prefixes, (list, tuple, set)): if not prefixes or not isinstance(prefixes, (list, tuple, set)):

View File

@@ -38,15 +38,3 @@ class ExceptionMixin(metaclass=SanicMeta):
return handler return handler
return decorator return decorator
def all_exceptions(self, handler):
"""
This method enables the process of creating a global exception
handler for the current blueprint under question.
:param handler: A coroutine function to handle exceptions
:return a decorated method to handle global exceptions for any
route registered under this blueprint.
"""
return self.exception(Exception)(handler)

View File

@@ -4,7 +4,7 @@ from typing import Any, Callable, Dict, Optional, Set, Union
from sanic.base.meta import SanicMeta from sanic.base.meta import SanicMeta
from sanic.models.futures import FutureSignal from sanic.models.futures import FutureSignal
from sanic.models.handler_types import SignalHandler from sanic.models.handler_types import SignalHandler
from sanic.signals import Event, Signal from sanic.signals import Signal
from sanic.types import HashableDict from sanic.types import HashableDict
@@ -80,9 +80,3 @@ class SignalMixin(metaclass=SanicMeta):
def event(self, event: str): def event(self, event: str):
raise NotImplementedError raise NotImplementedError
def catch_exception(self, handler):
async def signal_handler(exception: Exception):
await handler(self, exception)
self.signal(Event.SERVER_LIFECYCLE_EXCEPTION)(signal_handler)

View File

@@ -16,13 +16,7 @@ from asyncio import (
from contextlib import suppress from contextlib import suppress
from functools import partial from functools import partial
from importlib import import_module from importlib import import_module
from multiprocessing import ( from multiprocessing import Manager, Pipe, get_context
Manager,
Pipe,
get_context,
get_start_method,
set_start_method,
)
from multiprocessing.context import BaseContext from multiprocessing.context import BaseContext
from pathlib import Path from pathlib import Path
from socket import SHUT_RDWR, socket from socket import SHUT_RDWR, socket
@@ -31,7 +25,6 @@ from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Any, Any,
Callable, Callable,
ClassVar,
Dict, Dict,
List, List,
Mapping, Mapping,
@@ -88,17 +81,13 @@ else: # no cov
class StartupMixin(metaclass=SanicMeta): class StartupMixin(metaclass=SanicMeta):
_app_registry: ClassVar[Dict[str, Sanic]] _app_registry: Dict[str, Sanic]
config: Config config: Config
listeners: Dict[str, List[ListenerType[Any]]] listeners: Dict[str, List[ListenerType[Any]]]
state: ApplicationState state: ApplicationState
websocket_enabled: bool websocket_enabled: bool
multiplexer: WorkerMultiplexer multiplexer: WorkerMultiplexer
start_method: StartMethod = _default
test_mode: ClassVar[bool]
start_method: ClassVar[StartMethod] = _default
START_METHOD_SET: ClassVar[bool] = False
def setup_loop(self): def setup_loop(self):
if not self.asgi: if not self.asgi:
@@ -657,9 +646,10 @@ class StartupMixin(metaclass=SanicMeta):
def get_server_location( def get_server_location(
server_settings: Optional[Dict[str, Any]] = None server_settings: Optional[Dict[str, Any]] = None
) -> str: ) -> str:
serve_location = ""
proto = "http" proto = "http"
if not server_settings: if not server_settings:
return "" return serve_location
host = server_settings["host"] host = server_settings["host"]
port = server_settings["port"] port = server_settings["port"]
@@ -667,33 +657,16 @@ class StartupMixin(metaclass=SanicMeta):
if server_settings.get("ssl") is not None: if server_settings.get("ssl") is not None:
proto = "https" proto = "https"
if server_settings.get("unix"): if server_settings.get("unix"):
return f'{server_settings["unix"]} {proto}://localhost' serve_location = f'{server_settings["unix"]} {proto}://...'
if server_settings.get("sock"): elif server_settings.get("sock"):
host, port, *_ = server_settings["sock"].getsockname() host, port, *_ = server_settings["sock"].getsockname()
if not host or not port:
return ""
if not serve_location and host and port:
# colon(:) is legal for a host only in an ipv6 address # colon(:) is legal for a host only in an ipv6 address
url_host = f"[{host}]" if ":" in host else host display_host = f"[{host}]" if ":" in host else host
url_port = ( serve_location = f"{proto}://{display_host}:{port}"
""
if (
(proto == "https" and port == 443)
or (proto == "http" and port == 80)
)
else f":{port}"
)
special = { return serve_location
"127.0.0.1": "IPv4",
"0.0.0.0": "IPv4 wildcard",
"::1": "IPv6",
"::": "IPv6 wildcard",
}.get(host, "")
if special:
return f"({special}) {proto}://localhost{url_port}"
return f"{proto}://{url_host}{url_port}"
@staticmethod @staticmethod
def get_address( def get_address(
@@ -718,26 +691,11 @@ class StartupMixin(metaclass=SanicMeta):
else "spawn" else "spawn"
) )
@classmethod
def _set_startup_method(cls) -> None:
if cls.START_METHOD_SET and not cls.test_mode:
return
method = cls._get_startup_method()
set_start_method(method, force=cls.test_mode)
cls.START_METHOD_SET = True
@classmethod @classmethod
def _get_context(cls) -> BaseContext: def _get_context(cls) -> BaseContext:
method = cls._get_startup_method() method = cls._get_startup_method()
logger.debug("Creating multiprocessing context using '%s'", method) logger.debug("Creating multiprocessing context using '%s'", method)
actual = get_start_method() return get_context(method)
if method != actual:
raise RuntimeError(
f"Start method '{method}' was requested, but '{actual}' "
"was actually set."
)
return get_context()
@classmethod @classmethod
def serve( def serve(
@@ -747,7 +705,6 @@ class StartupMixin(metaclass=SanicMeta):
app_loader: Optional[AppLoader] = None, app_loader: Optional[AppLoader] = None,
factory: Optional[Callable[[], Sanic]] = None, factory: Optional[Callable[[], Sanic]] = None,
) -> None: ) -> None:
cls._set_startup_method()
os.environ["SANIC_MOTD_OUTPUT"] = "true" os.environ["SANIC_MOTD_OUTPUT"] = "true"
apps = list(cls._app_registry.values()) apps = list(cls._app_registry.values())
if factory: if factory:

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import sys
from typing import Any, Awaitable, Callable, MutableMapping, Optional, Union from typing import Any, Awaitable, Callable, MutableMapping, Optional, Union
@@ -15,10 +16,20 @@ ASGIReceive = Callable[[], Awaitable[ASGIMessage]]
class MockProtocol: # no cov class MockProtocol: # no cov
def __init__(self, transport: "MockTransport", loop): def __init__(self, transport: "MockTransport", loop):
# This should be refactored when < 3.8 support is dropped
self.transport = transport self.transport = transport
# Fixup for 3.8+; Sanic still supports 3.7 where loop is required
loop = loop if sys.version_info[:2] < (3, 8) else None
# Optional in 3.9, necessary in 3.10 because the parameter "loop"
# was completely removed
if not loop:
self._not_paused = asyncio.Event() self._not_paused = asyncio.Event()
self._not_paused.set() self._not_paused.set()
self._complete = asyncio.Event() self._complete = asyncio.Event()
else:
self._not_paused = asyncio.Event(loop=loop)
self._not_paused.set()
self._complete = asyncio.Event(loop=loop)
def pause_writing(self) -> None: def pause_writing(self) -> None:
self._not_paused.clear() self._not_paused.clear()

View File

@@ -2,13 +2,11 @@ from __future__ import annotations
from contextvars import ContextVar from contextvars import ContextVar
from inspect import isawaitable from inspect import isawaitable
from types import SimpleNamespace
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
Any, Any,
DefaultDict, DefaultDict,
Dict, Dict,
Generic,
List, List,
Optional, Optional,
Tuple, Tuple,
@@ -17,7 +15,6 @@ from typing import (
) )
from sanic_routing.route import Route from sanic_routing.route import Route
from typing_extensions import TypeVar
from sanic.http.constants import HTTP # type: ignore from sanic.http.constants import HTTP # type: ignore
from sanic.http.stream import Stream from sanic.http.stream import Stream
@@ -26,13 +23,13 @@ from sanic.models.http_types import Credentials
if TYPE_CHECKING: if TYPE_CHECKING:
from sanic.app import Sanic
from sanic.config import Config
from sanic.server import ConnInfo from sanic.server import ConnInfo
from sanic.app import Sanic
import uuid import uuid
from collections import defaultdict from collections import defaultdict
from types import SimpleNamespace
from urllib.parse import parse_qs, parse_qsl, urlunparse from urllib.parse import parse_qs, parse_qsl, urlunparse
from httptools import parse_url from httptools import parse_url
@@ -71,21 +68,8 @@ try:
except ImportError: except ImportError:
from json import loads as json_loads # type: ignore from json import loads as json_loads # type: ignore
if TYPE_CHECKING:
# The default argument of TypeVar is proposed to be added in Python 3.13
# by PEP 696 (https://www.python.org/dev/peps/pep-0696/).
# Therefore, we use typing_extensions.TypeVar for compatibility.
# For more information, see:
# https://discuss.python.org/t/pep-696-type-defaults-for-typevarlikes
sanic_type = TypeVar(
"sanic_type", bound=Sanic, default=Sanic[Config, SimpleNamespace]
)
else:
sanic_type = TypeVar("sanic_type")
ctx_type = TypeVar("ctx_type")
class Request:
class Request(Generic[sanic_type, ctx_type]):
""" """
Properties of an HTTP request such as URL, headers, etc. Properties of an HTTP request such as URL, headers, etc.
""" """
@@ -96,7 +80,6 @@ class Request(Generic[sanic_type, ctx_type]):
__slots__ = ( __slots__ = (
"__weakref__", "__weakref__",
"_cookies", "_cookies",
"_ctx",
"_id", "_id",
"_ip", "_ip",
"_parsed_url", "_parsed_url",
@@ -113,6 +96,7 @@ class Request(Generic[sanic_type, ctx_type]):
"app", "app",
"body", "body",
"conn_info", "conn_info",
"ctx",
"head", "head",
"headers", "headers",
"method", "method",
@@ -141,7 +125,7 @@ class Request(Generic[sanic_type, ctx_type]):
version: str, version: str,
method: str, method: str,
transport: TransportProtocol, transport: TransportProtocol,
app: sanic_type, app: Sanic,
head: bytes = b"", head: bytes = b"",
stream_id: int = 0, stream_id: int = 0,
): ):
@@ -165,7 +149,7 @@ class Request(Generic[sanic_type, ctx_type]):
# Init but do not inhale # Init but do not inhale
self.body = b"" self.body = b""
self.conn_info: Optional[ConnInfo] = None self.conn_info: Optional[ConnInfo] = None
self._ctx: Optional[ctx_type] = None self.ctx = SimpleNamespace()
self.parsed_accept: Optional[AcceptList] = None self.parsed_accept: Optional[AcceptList] = None
self.parsed_args: DefaultDict[ self.parsed_args: DefaultDict[
Tuple[bool, bool, str, str], RequestParameters Tuple[bool, bool, str, str], RequestParameters
@@ -192,10 +176,6 @@ class Request(Generic[sanic_type, ctx_type]):
class_name = self.__class__.__name__ class_name = self.__class__.__name__
return f"<{class_name}: {self.method} {self.path}>" return f"<{class_name}: {self.method} {self.path}>"
@staticmethod
def make_context() -> ctx_type:
return cast(ctx_type, SimpleNamespace())
@classmethod @classmethod
def get_current(cls) -> Request: def get_current(cls) -> Request:
""" """
@@ -225,15 +205,6 @@ class Request(Generic[sanic_type, ctx_type]):
def generate_id(*_): def generate_id(*_):
return uuid.uuid4() return uuid.uuid4()
@property
def ctx(self) -> ctx_type:
"""
:return: The current request context
"""
if not self._ctx:
self._ctx = self.make_context()
return self._ctx
@property @property
def stream_id(self): def stream_id(self):
""" """
@@ -838,31 +809,19 @@ class Request(Generic[sanic_type, ctx_type]):
@property @property
def remote_addr(self) -> str: def remote_addr(self) -> str:
""" """
Client IP address, if available from proxy. Client IP address, if available.
1. proxied remote address `self.forwarded['for']`
2. local remote address `self.ip`
:return: IPv4, bracketed IPv6, UNIX socket name or arbitrary string :return: IPv4, bracketed IPv6, UNIX socket name or arbitrary string
:rtype: str :rtype: str
""" """
if not hasattr(self, "_remote_addr"): if not hasattr(self, "_remote_addr"):
self._remote_addr = str(self.forwarded.get("for", "")) self._remote_addr = str(
self.forwarded.get("for", "")
) # or self.ip
return self._remote_addr return self._remote_addr
@property
def client_ip(self) -> str:
"""
Client IP address.
1. proxied remote address `self.forwarded['for']`
2. local peer address `self.ip`
New in Sanic 23.6. Prefer this over `remote_addr` for determining the
client address regardless of whether the service runs behind a proxy
or not (proxy deployment needs separate configuration).
:return: IPv4, bracketed IPv6, UNIX socket name or arbitrary string
:rtype: str
"""
return self.remote_addr or self.ip
@property @property
def scheme(self) -> str: def scheme(self) -> str:
""" """

View File

@@ -75,7 +75,7 @@ class Router(BaseRouter):
strict_slashes: bool = False, strict_slashes: bool = False,
stream: bool = False, stream: bool = False,
ignore_body: bool = False, ignore_body: bool = False,
version: Optional[Union[str, float, int]] = None, version: Union[str, float, int] = None,
name: Optional[str] = None, name: Optional[str] = None,
unquote: bool = False, unquote: bool = False,
static: bool = False, static: bool = False,

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
import sys
from ssl import SSLContext from ssl import SSLContext
from typing import TYPE_CHECKING, Dict, Optional, Type, Union from typing import TYPE_CHECKING, Dict, Optional, Type, Union
@@ -249,6 +251,7 @@ def _serve_http_1(
loop.run_until_complete(asyncio.sleep(0.1)) loop.run_until_complete(asyncio.sleep(0.1))
start_shutdown = start_shutdown + 0.1 start_shutdown = start_shutdown + 0.1
if sys.version_info > (3, 7):
app.shutdown_tasks(graceful - start_shutdown) app.shutdown_tasks(graceful - start_shutdown)
# Force close non-idle connection after waiting for # Force close non-idle connection after waiting for

View File

@@ -96,7 +96,6 @@ class WebsocketFrameAssembler:
If ``timeout`` is set and elapses before a complete message is If ``timeout`` is set and elapses before a complete message is
received, :meth:`get` returns ``None``. received, :meth:`get` returns ``None``.
""" """
completed: bool
async with self.read_mutex: async with self.read_mutex:
if timeout is not None and timeout <= 0: if timeout is not None and timeout <= 0:
if not self.message_complete.is_set(): if not self.message_complete.is_set():

View File

@@ -21,7 +21,7 @@ from websockets.frames import Frame, Opcode
try: # websockets < 11.0 try: # websockets < 11.0
from websockets.connection import Event, State # type: ignore from websockets.connection import Event, State
from websockets.server import ServerConnection as ServerProtocol from websockets.server import ServerConnection as ServerProtocol
except ImportError: # websockets >= 11.0 except ImportError: # websockets >= 11.0
from websockets.protocol import Event, State # type: ignore from websockets.protocol import Event, State # type: ignore
@@ -532,11 +532,12 @@ class WebsocketImplProtocol:
raise WebsocketClosed( raise WebsocketClosed(
"Cannot receive from websocket interface after it is closed." "Cannot receive from websocket interface after it is closed."
) )
assembler_get: Optional[asyncio.Task] = None
try: try:
self.recv_cancel = asyncio.Future() self.recv_cancel = asyncio.Future()
assembler_get = asyncio.create_task(self.assembler.get(timeout)) tasks = (
tasks = (self.recv_cancel, assembler_get) self.recv_cancel,
asyncio.ensure_future(self.assembler.get(timeout)),
)
done, pending = await asyncio.wait( done, pending = await asyncio.wait(
tasks, tasks,
return_when=asyncio.FIRST_COMPLETED, return_when=asyncio.FIRST_COMPLETED,
@@ -550,11 +551,6 @@ class WebsocketImplProtocol:
else: else:
self.recv_cancel.cancel() self.recv_cancel.cancel()
return done_task.result() return done_task.result()
except asyncio.CancelledError:
# recv was cancelled
if assembler_get:
assembler_get.cancel()
raise
finally: finally:
self.recv_cancel = None self.recv_cancel = None
self.recv_lock.release() self.recv_lock.release()
@@ -588,15 +584,16 @@ class WebsocketImplProtocol:
"Cannot receive from websocket interface after it is closed." "Cannot receive from websocket interface after it is closed."
) )
messages = [] messages = []
assembler_get: Optional[asyncio.Task] = None
try: try:
# Prevent pausing the transport when we're # Prevent pausing the transport when we're
# receiving a burst of messages # receiving a burst of messages
self.can_pause = False self.can_pause = False
self.recv_cancel = asyncio.Future() self.recv_cancel = asyncio.Future()
while True: while True:
assembler_get = asyncio.create_task(self.assembler.get(0)) tasks = (
tasks = (self.recv_cancel, assembler_get) self.recv_cancel,
asyncio.ensure_future(self.assembler.get(timeout=0)),
)
done, pending = await asyncio.wait( done, pending = await asyncio.wait(
tasks, tasks,
return_when=asyncio.FIRST_COMPLETED, return_when=asyncio.FIRST_COMPLETED,
@@ -619,11 +616,6 @@ class WebsocketImplProtocol:
# next message to pass into the Assembler # next message to pass into the Assembler
await asyncio.sleep(0) await asyncio.sleep(0)
self.recv_cancel.cancel() self.recv_cancel.cancel()
except asyncio.CancelledError:
# recv_burst was cancelled
if assembler_get:
assembler_get.cancel()
raise
finally: finally:
self.recv_cancel = None self.recv_cancel = None
self.can_pause = True self.can_pause = True

View File

@@ -16,11 +16,11 @@ from sanic.models.handler_types import SignalHandler
class Event(Enum): class Event(Enum):
SERVER_EXCEPTION_REPORT = "server.exception.report"
SERVER_INIT_AFTER = "server.init.after" SERVER_INIT_AFTER = "server.init.after"
SERVER_INIT_BEFORE = "server.init.before" SERVER_INIT_BEFORE = "server.init.before"
SERVER_SHUTDOWN_AFTER = "server.shutdown.after" SERVER_SHUTDOWN_AFTER = "server.shutdown.after"
SERVER_SHUTDOWN_BEFORE = "server.shutdown.before" SERVER_SHUTDOWN_BEFORE = "server.shutdown.before"
SERVER_LIFECYCLE_EXCEPTION = "server.lifecycle.exception"
HTTP_LIFECYCLE_BEGIN = "http.lifecycle.begin" HTTP_LIFECYCLE_BEGIN = "http.lifecycle.begin"
HTTP_LIFECYCLE_COMPLETE = "http.lifecycle.complete" HTTP_LIFECYCLE_COMPLETE = "http.lifecycle.complete"
HTTP_LIFECYCLE_EXCEPTION = "http.lifecycle.exception" HTTP_LIFECYCLE_EXCEPTION = "http.lifecycle.exception"
@@ -40,11 +40,11 @@ class Event(Enum):
RESERVED_NAMESPACES = { RESERVED_NAMESPACES = {
"server": ( "server": (
Event.SERVER_EXCEPTION_REPORT.value,
Event.SERVER_INIT_AFTER.value, Event.SERVER_INIT_AFTER.value,
Event.SERVER_INIT_BEFORE.value, Event.SERVER_INIT_BEFORE.value,
Event.SERVER_SHUTDOWN_AFTER.value, Event.SERVER_SHUTDOWN_AFTER.value,
Event.SERVER_SHUTDOWN_BEFORE.value, Event.SERVER_SHUTDOWN_BEFORE.value,
Event.SERVER_LIFECYCLE_EXCEPTION.value,
), ),
"http": ( "http": (
Event.HTTP_LIFECYCLE_BEGIN.value, Event.HTTP_LIFECYCLE_BEGIN.value,
@@ -174,12 +174,11 @@ class SignalRouter(BaseRouter):
if self.ctx.app.debug and self.ctx.app.state.verbosity >= 1: if self.ctx.app.debug and self.ctx.app.state.verbosity >= 1:
error_logger.exception(e) error_logger.exception(e)
if event != Event.SERVER_EXCEPTION_REPORT.value: if event != Event.SERVER_LIFECYCLE_EXCEPTION.value:
await self.dispatch( await self.dispatch(
Event.SERVER_EXCEPTION_REPORT.value, Event.SERVER_LIFECYCLE_EXCEPTION.value,
context={"exception": e}, context={"exception": e},
) )
setattr(e, "__dispatched__", True)
raise e raise e
finally: finally:
for signal_event in events: for signal_event in events:
@@ -230,6 +229,14 @@ class SignalRouter(BaseRouter):
if not trigger: if not trigger:
event = ".".join([*parts[:2], "<__trigger__>"]) event = ".".join([*parts[:2], "<__trigger__>"])
try:
# Attaching __requirements__ and __trigger__ to the handler
# is deprecated and will be removed in v23.6.
handler.__requirements__ = condition # type: ignore
handler.__trigger__ = trigger # type: ignore
except AttributeError:
pass
signal = super().add( signal = super().add(
event, event,
handler, handler,

View File

@@ -16,3 +16,5 @@ class ProcessState(IntEnum):
ACKED = auto() ACKED = auto()
JOINED = auto() JOINED = auto()
TERMINATED = auto() TERMINATED = auto()
FAILED = auto()
COMPLETED = auto()

View File

@@ -1,11 +1,11 @@
import os import os
from contextlib import suppress from contextlib import suppress
from itertools import count from enum import IntEnum, auto
from itertools import chain, count
from random import choice from random import choice
from signal import SIGINT, SIGTERM, Signals from signal import SIGINT, SIGTERM, Signals
from signal import signal as signal_func from signal import signal as signal_func
from typing import Any, Callable, Dict, List, Optional from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
from sanic.compat import OS_IS_WINDOWS from sanic.compat import OS_IS_WINDOWS
from sanic.exceptions import ServerKilled from sanic.exceptions import ServerKilled
@@ -13,13 +13,17 @@ from sanic.log import error_logger, logger
from sanic.worker.constants import RestartOrder from sanic.worker.constants import RestartOrder
from sanic.worker.process import ProcessState, Worker, WorkerProcess from sanic.worker.process import ProcessState, Worker, WorkerProcess
if not OS_IS_WINDOWS: if not OS_IS_WINDOWS:
from signal import SIGKILL from signal import SIGKILL
else: else:
SIGKILL = SIGINT SIGKILL = SIGINT
class MonitorCycle(IntEnum):
BREAK = auto()
CONTINUE = auto()
class WorkerManager: class WorkerManager:
THRESHOLD = WorkerProcess.THRESHOLD THRESHOLD = WorkerProcess.THRESHOLD
MAIN_IDENT = "Sanic-Main" MAIN_IDENT = "Sanic-Main"
@@ -60,6 +64,8 @@ class WorkerManager:
func: Callable[..., Any], func: Callable[..., Any],
kwargs: Dict[str, Any], kwargs: Dict[str, Any],
transient: bool = False, transient: bool = False,
restartable: Optional[bool] = None,
tracked: bool = True,
workers: int = 1, workers: int = 1,
) -> Worker: ) -> Worker:
""" """
@@ -75,14 +81,35 @@ class WorkerManager:
then the Worker Manager will restart the process along then the Worker Manager will restart the process along
with any global restart (ex: auto-reload), defaults to False with any global restart (ex: auto-reload), defaults to False
:type transient: bool, optional :type transient: bool, optional
:param restartable: Whether to mark the process as restartable. If
True then the Worker Manager will be able to restart the process
if prompted. If transient=True, this property will be implied
to be True, defaults to None
:type restartable: Optional[bool], optional
:param tracked: Whether to track the process after completion,
defaults to True
:param workers: The number of worker processes to run, defaults to 1 :param workers: The number of worker processes to run, defaults to 1
:type workers: int, optional :type workers: int, optional
:return: The Worker instance :return: The Worker instance
:rtype: Worker :rtype: Worker
""" """
if ident in self.transient or ident in self.durable:
raise ValueError(f"Worker {ident} already exists")
restartable = restartable if restartable is not None else transient
if transient and not restartable:
raise ValueError(
"Cannot create a transient worker that is not restartable"
)
container = self.transient if transient else self.durable container = self.transient if transient else self.durable
worker = Worker( worker = Worker(
ident, func, kwargs, self.context, self.worker_state, workers ident,
func,
kwargs,
self.context,
self.worker_state,
workers,
restartable,
tracked,
) )
container[worker.ident] = worker container[worker.ident] = worker
return worker return worker
@@ -94,6 +121,7 @@ class WorkerManager:
self._serve, self._serve,
self._server_settings, self._server_settings,
transient=True, transient=True,
restartable=True,
) )
def shutdown_server(self, ident: Optional[str] = None) -> None: def shutdown_server(self, ident: Optional[str] = None) -> None:
@@ -153,9 +181,32 @@ class WorkerManager:
restart_order=RestartOrder.SHUTDOWN_FIRST, restart_order=RestartOrder.SHUTDOWN_FIRST,
**kwargs, **kwargs,
): ):
restarted = set()
for process in self.transient_processes: for process in self.transient_processes:
if not process_names or process.name in process_names: if process.restartable and (
not process_names or process.name in process_names
):
process.restart(restart_order=restart_order, **kwargs) process.restart(restart_order=restart_order, **kwargs)
restarted.add(process.name)
if process_names:
for process in self.durable_processes:
if process.restartable and process.name in process_names:
if process.state not in (
ProcessState.COMPLETED,
ProcessState.FAILED,
):
error_logger.error(
f"Cannot restart process {process.name} because "
"it is not in a final state. Current state is: "
f"{process.state.name}."
)
continue
process.restart(restart_order=restart_order, **kwargs)
restarted.add(process.name)
if process_names and not restarted:
error_logger.error(
f"Failed to restart processes: {', '.join(process_names)}"
)
def scale(self, num_worker: int): def scale(self, num_worker: int):
if num_worker <= 0: if num_worker <= 0:
@@ -183,45 +234,13 @@ class WorkerManager:
self.wait_for_ack() self.wait_for_ack()
while True: while True:
try: try:
if self.monitor_subscriber.poll(0.1): cycle = self._poll_monitor()
message = self.monitor_subscriber.recv() if cycle is MonitorCycle.BREAK:
logger.debug(
f"Monitor message: {message}", extra={"verbosity": 2}
)
if not message:
break break
elif message == "__TERMINATE__": elif cycle is MonitorCycle.CONTINUE:
self.shutdown()
break
logger.debug(
"Incoming monitor message: %s",
message,
extra={"verbosity": 1},
)
split_message = message.split(":", 2)
if message.startswith("__SCALE__"):
self.scale(int(split_message[-1]))
continue continue
processes = split_message[0]
reloaded_files = (
split_message[1] if len(split_message) > 1 else None
)
process_names = [
name.strip() for name in processes.split(",")
]
if "__ALL_PROCESSES__" in process_names:
process_names = None
order = (
RestartOrder.STARTUP_FIRST
if "STARTUP_FIRST" in split_message
else RestartOrder.SHUTDOWN_FIRST
)
self.restart(
process_names=process_names,
reloaded_files=reloaded_files,
restart_order=order,
)
self._sync_states() self._sync_states()
self._cleanup_non_tracked_workers()
except InterruptedError: except InterruptedError:
if not OS_IS_WINDOWS: if not OS_IS_WINDOWS:
raise raise
@@ -264,6 +283,10 @@ class WorkerManager:
def workers(self) -> List[Worker]: def workers(self) -> List[Worker]:
return list(self.transient.values()) + list(self.durable.values()) return list(self.transient.values()) + list(self.durable.values())
@property
def all_workers(self) -> Iterable[Tuple[str, Worker]]:
return chain(self.transient.items(), self.durable.items())
@property @property
def processes(self): def processes(self):
for worker in self.workers: for worker in self.workers:
@@ -276,6 +299,12 @@ class WorkerManager:
for process in worker.processes: for process in worker.processes:
yield process yield process
@property
def durable_processes(self):
for worker in self.durable.values():
for process in worker.processes:
yield process
def kill(self): def kill(self):
for process in self.processes: for process in self.processes:
logger.info("Killing %s [%s]", process.name, process.pid) logger.info("Killing %s [%s]", process.name, process.pid)
@@ -298,6 +327,25 @@ class WorkerManager:
process.terminate() process.terminate()
self._shutting_down = True self._shutting_down = True
def remove_worker(self, worker: Worker) -> None:
if worker.tracked:
error_logger.error(
f"Worker {worker.ident} is tracked and cannot be removed."
)
return
if worker.has_alive_processes():
error_logger.error(
f"Worker {worker.ident} has alive processes and cannot be "
"removed."
)
return
self.transient.pop(worker.ident, None)
self.durable.pop(worker.ident, None)
for process in worker.processes:
self.worker_state.pop(process.name, None)
logger.info("Removed worker %s", worker.ident)
del worker
@property @property
def pid(self): def pid(self):
return os.getpid() return os.getpid()
@@ -317,5 +365,97 @@ class WorkerManager:
except KeyError: except KeyError:
process.set_state(ProcessState.TERMINATED, True) process.set_state(ProcessState.TERMINATED, True)
continue continue
if not process.is_alive():
state = "FAILED" if process.exitcode else "COMPLETED"
if state and process.state.name != state: if state and process.state.name != state:
process.set_state(ProcessState[state], True) process.set_state(ProcessState[state], True)
def _cleanup_non_tracked_workers(self) -> None:
to_remove = [
worker
for worker in self.workers
if not worker.tracked and not worker.has_alive_processes()
]
for worker in to_remove:
self.remove_worker(worker)
def _poll_monitor(self) -> Optional[MonitorCycle]:
if self.monitor_subscriber.poll(0.1):
message = self.monitor_subscriber.recv()
logger.debug(f"Monitor message: {message}", extra={"verbosity": 2})
if not message:
return MonitorCycle.BREAK
elif message == "__TERMINATE__":
self._handle_terminate()
return MonitorCycle.BREAK
elif isinstance(message, tuple) and len(message) == 7:
self._handle_manage(*message)
return MonitorCycle.CONTINUE
elif not isinstance(message, str):
error_logger.error(
"Monitor received an invalid message: %s", message
)
return MonitorCycle.CONTINUE
return self._handle_message(message)
return None
def _handle_terminate(self) -> None:
self.shutdown()
def _handle_message(self, message: str) -> Optional[MonitorCycle]:
logger.debug(
"Incoming monitor message: %s",
message,
extra={"verbosity": 1},
)
split_message = message.split(":", 2)
if message.startswith("__SCALE__"):
self.scale(int(split_message[-1]))
return MonitorCycle.CONTINUE
processes = split_message[0]
reloaded_files = split_message[1] if len(split_message) > 1 else None
process_names: Optional[List[str]] = [
name.strip() for name in processes.split(",")
]
if process_names and "__ALL_PROCESSES__" in process_names:
process_names = None
order = (
RestartOrder.STARTUP_FIRST
if "STARTUP_FIRST" in split_message
else RestartOrder.SHUTDOWN_FIRST
)
self.restart(
process_names=process_names,
reloaded_files=reloaded_files,
restart_order=order,
)
return None
def _handle_manage(
self,
ident: str,
func: Callable[..., Any],
kwargs: Dict[str, Any],
transient: bool,
restartable: Optional[bool],
tracked: bool,
workers: int,
) -> None:
try:
worker = self.manage(
ident,
func,
kwargs,
transient=transient,
restartable=restartable,
tracked=tracked,
workers=workers,
)
except Exception:
error_logger.exception("Failed to manage worker %s", ident)
else:
for process in worker.processes:
process.start()

View File

@@ -1,6 +1,6 @@
from multiprocessing.connection import Connection from multiprocessing.connection import Connection
from os import environ, getpid from os import environ, getpid
from typing import Any, Dict from typing import Any, Callable, Dict, Optional
from sanic.log import Colors, logger from sanic.log import Colors, logger
from sanic.worker.process import ProcessState from sanic.worker.process import ProcessState
@@ -28,6 +28,27 @@ class WorkerMultiplexer:
"state": ProcessState.ACKED.name, "state": ProcessState.ACKED.name,
} }
def manage(
self,
ident: str,
func: Callable[..., Any],
kwargs: Dict[str, Any],
transient: bool = False,
restartable: Optional[bool] = None,
tracked: bool = False,
workers: int = 1,
) -> None:
bundle = (
ident,
func,
kwargs,
transient,
restartable,
tracked,
workers,
)
self._monitor_publisher.send(bundle)
def restart( def restart(
self, self,
name: str = "", name: str = "",

View File

@@ -1,5 +1,4 @@
import os import os
from datetime import datetime, timezone from datetime import datetime, timezone
from multiprocessing.context import BaseContext from multiprocessing.context import BaseContext
from signal import SIGINT from signal import SIGINT
@@ -20,13 +19,22 @@ class WorkerProcess:
THRESHOLD = 300 # == 30 seconds THRESHOLD = 300 # == 30 seconds
SERVER_LABEL = "Server" SERVER_LABEL = "Server"
def __init__(self, factory, name, target, kwargs, worker_state): def __init__(
self,
factory,
name,
target,
kwargs,
worker_state,
restartable: bool = False,
):
self.state = ProcessState.IDLE self.state = ProcessState.IDLE
self.factory = factory self.factory = factory
self.name = name self.name = name
self.target = target self.target = target
self.kwargs = kwargs self.kwargs = kwargs
self.worker_state = worker_state self.worker_state = worker_state
self.restartable = restartable
if self.name not in self.worker_state: if self.name not in self.worker_state:
self.worker_state[self.name] = { self.worker_state[self.name] = {
"server": self.SERVER_LABEL in self.name "server": self.SERVER_LABEL in self.name
@@ -132,6 +140,10 @@ class WorkerProcess:
def pid(self): def pid(self):
return self._current_process.pid return self._current_process.pid
@property
def exitcode(self):
return self._current_process.exitcode
def _terminate_now(self): def _terminate_now(self):
logger.debug( logger.debug(
f"{Colors.BLUE}Begin restart termination: " f"{Colors.BLUE}Begin restart termination: "
@@ -193,6 +205,8 @@ class Worker:
context: BaseContext, context: BaseContext,
worker_state: Dict[str, Any], worker_state: Dict[str, Any],
num: int = 1, num: int = 1,
restartable: bool = False,
tracked: bool = True,
): ):
self.ident = ident self.ident = ident
self.num = num self.num = num
@@ -201,6 +215,8 @@ class Worker:
self.server_settings = server_settings self.server_settings = server_settings
self.worker_state = worker_state self.worker_state = worker_state
self.processes: Set[WorkerProcess] = set() self.processes: Set[WorkerProcess] = set()
self.restartable = restartable
self.tracked = tracked
for _ in range(num): for _ in range(num):
self.create_process() self.create_process()
@@ -215,6 +231,10 @@ class Worker:
target=self.serve, target=self.serve,
kwargs={**self.server_settings}, kwargs={**self.server_settings},
worker_state=self.worker_state, worker_state=self.worker_state,
restartable=self.restartable,
) )
self.processes.add(process) self.processes.add(process)
return process return process
def has_alive_processes(self) -> bool:
return any(process.is_alive() for process in self.processes)

View File

@@ -83,11 +83,12 @@ setup_kwargs = {
"packages": find_packages(exclude=("tests", "tests.*")), "packages": find_packages(exclude=("tests", "tests.*")),
"package_data": {"sanic": ["py.typed", "pages/styles/*"]}, "package_data": {"sanic": ["py.typed", "pages/styles/*"]},
"platforms": "any", "platforms": "any",
"python_requires": ">=3.8", "python_requires": ">=3.7",
"classifiers": [ "classifiers": [
"Development Status :: 4 - Beta", "Development Status :: 4 - Beta",
"Environment :: Web Environment", "Environment :: Web Environment",
"License :: OSI Approved :: MIT License", "License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.10",
@@ -103,7 +104,7 @@ ujson = "ujson>=1.35" + env_dependency
uvloop = "uvloop>=0.15.0" + env_dependency uvloop = "uvloop>=0.15.0" + env_dependency
types_ujson = "types-ujson" + env_dependency types_ujson = "types-ujson" + env_dependency
requirements = [ requirements = [
"sanic-routing>=23.6.0", "sanic-routing>=22.8.0",
"httptools>=0.0.10", "httptools>=0.0.10",
uvloop, uvloop,
ujson, ujson,
@@ -112,11 +113,10 @@ requirements = [
"multidict>=5.0,<7.0", "multidict>=5.0,<7.0",
"html5tagger>=1.2.1", "html5tagger>=1.2.1",
"tracerite>=1.0.0", "tracerite>=1.0.0",
"typing-extensions>=4.4.0",
] ]
tests_require = [ tests_require = [
"sanic-testing>=23.6.0", "sanic-testing>=23.3.0",
"pytest==7.1.*", "pytest==7.1.*",
"coverage", "coverage",
"beautifulsoup4", "beautifulsoup4",
@@ -127,7 +127,7 @@ tests_require = [
"black", "black",
"isort>=5.0.0", "isort>=5.0.0",
"bandit", "bandit",
"mypy", "mypy>=0.901,<0.910",
"docutils", "docutils",
"pygments", "pygments",
"uvicorn<0.15.0", "uvicorn<0.15.0",
@@ -143,7 +143,6 @@ docs_require = [
"m2r2", "m2r2",
"enum-tools[sphinx]", "enum-tools[sphinx]",
"mistune<2.0.0", "mistune<2.0.0",
"autodocsumm>=0.2.11",
] ]
dev_require = tests_require + [ dev_require = tests_require + [

View File

@@ -293,7 +293,7 @@ def test_handle_request_with_nested_sanic_exception(
def test_app_name_required(): def test_app_name_required():
with pytest.raises(TypeError): with pytest.raises(SanicException):
Sanic() Sanic()

View File

@@ -2,8 +2,6 @@ import logging
import pytest import pytest
import sanic
from sanic import Sanic from sanic import Sanic
from sanic.config import Config from sanic.config import Config
from sanic.errorpages import TextRenderer, exception_response, guess_mime from sanic.errorpages import TextRenderer, exception_response, guess_mime
@@ -207,27 +205,6 @@ def test_route_error_response_from_explicit_format(app):
assert response.content_type == "text/plain; charset=utf-8" assert response.content_type == "text/plain; charset=utf-8"
def test_blueprint_error_response_from_explicit_format(app):
bp = sanic.Blueprint("MyBlueprint")
@bp.get("/text", error_format="json")
def text_response(request):
raise Exception("oops")
return text("Never gonna see this")
@bp.get("/json", error_format="text")
def json_response(request):
raise Exception("oops")
return json({"message": "Never gonna see this"})
app.blueprint(bp)
_, response = app.test_client.get("/text")
assert response.content_type == "application/json"
_, response = app.test_client.get("/json")
assert response.content_type == "text/plain; charset=utf-8"
def test_unknown_fallback_format(app): def test_unknown_fallback_format(app):
with pytest.raises(SanicException, match="Unknown format: bad"): with pytest.raises(SanicException, match="Unknown format: bad"):
app.config.FALLBACK_ERROR_FORMAT = "bad" app.config.FALLBACK_ERROR_FORMAT = "bad"

View File

@@ -17,7 +17,6 @@ from sanic.response import text
CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True} CONFIG_FOR_TESTS = {"KEEP_ALIVE_TIMEOUT": 2, "KEEP_ALIVE": True}
PORT = 42001 # test_keep_alive_timeout_reuse doesn't work with random port PORT = 42001 # test_keep_alive_timeout_reuse doesn't work with random port
MAX_LOOPS = 15
port_counter = count() port_counter = count()
@@ -70,15 +69,10 @@ def test_keep_alive_timeout_reuse():
"""If the server keep-alive timeout and client keep-alive timeout are """If the server keep-alive timeout and client keep-alive timeout are
both longer than the delay, the client _and_ server will successfully both longer than the delay, the client _and_ server will successfully
reuse the existing connection.""" reuse the existing connection."""
loops = 0
while True:
port = get_port() port = get_port()
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
client = ReusableClient( client = ReusableClient(keep_alive_timeout_app_reuse, loop=loop, port=port)
keep_alive_timeout_app_reuse, loop=loop, port=port
)
try:
with client: with client:
headers = {"Connection": "keep-alive"} headers = {"Connection": "keep-alive"}
request, response = client.get("/1", headers=headers) request, response = client.get("/1", headers=headers)
@@ -92,13 +86,6 @@ def test_keep_alive_timeout_reuse():
assert response.status == 200 assert response.status == 200
assert response.text == "OK" assert response.text == "OK"
assert request.protocol.state["requests_count"] == 2 assert request.protocol.state["requests_count"] == 2
except OSError:
loops += 1
if loops > MAX_LOOPS:
raise
continue
else:
break
@pytest.mark.skipif( @pytest.mark.skipif(
@@ -110,9 +97,6 @@ def test_keep_alive_timeout_reuse():
def test_keep_alive_client_timeout(): def test_keep_alive_client_timeout():
"""If the server keep-alive timeout is longer than the client """If the server keep-alive timeout is longer than the client
keep-alive timeout, client will try to create a new connection here.""" keep-alive timeout, client will try to create a new connection here."""
loops = 0
while True:
try:
port = get_port() port = get_port()
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
@@ -121,9 +105,7 @@ def test_keep_alive_client_timeout():
) )
with client: with client:
headers = {"Connection": "keep-alive"} headers = {"Connection": "keep-alive"}
request, response = client.get( request, response = client.get("/1", headers=headers, timeout=1)
"/1", headers=headers, timeout=1
)
assert response.status == 200 assert response.status == 200
assert response.text == "OK" assert response.text == "OK"
@@ -132,13 +114,6 @@ def test_keep_alive_client_timeout():
loop.run_until_complete(aio_sleep(2)) loop.run_until_complete(aio_sleep(2))
request, response = client.get("/1", timeout=1) request, response = client.get("/1", timeout=1)
assert request.protocol.state["requests_count"] == 1 assert request.protocol.state["requests_count"] == 1
except OSError:
loops += 1
if loops > MAX_LOOPS:
raise
continue
else:
break
@pytest.mark.skipif( @pytest.mark.skipif(
@@ -150,9 +125,6 @@ def test_keep_alive_server_timeout():
keep-alive timeout, the client will either a 'Connection reset' error keep-alive timeout, the client will either a 'Connection reset' error
_or_ a new connection. Depending on how the event-loop handles the _or_ a new connection. Depending on how the event-loop handles the
broken server connection.""" broken server connection."""
loops = 0
while True:
try:
port = get_port() port = get_port()
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
@@ -161,9 +133,7 @@ def test_keep_alive_server_timeout():
) )
with client: with client:
headers = {"Connection": "keep-alive"} headers = {"Connection": "keep-alive"}
request, response = client.get( request, response = client.get("/1", headers=headers, timeout=60)
"/1", headers=headers, timeout=60
)
assert response.status == 200 assert response.status == 200
assert response.text == "OK" assert response.text == "OK"
@@ -173,13 +143,6 @@ def test_keep_alive_server_timeout():
request, response = client.get("/1", timeout=60) request, response = client.get("/1", timeout=60)
assert request.protocol.state["requests_count"] == 1 assert request.protocol.state["requests_count"] == 1
except OSError:
loops += 1
if loops > MAX_LOOPS:
raise
continue
else:
break
@pytest.mark.skipif( @pytest.mark.skipif(
@@ -187,15 +150,10 @@ def test_keep_alive_server_timeout():
reason="Not testable with current client", reason="Not testable with current client",
) )
def test_keep_alive_connection_context(): def test_keep_alive_connection_context():
loops = 0
while True:
try:
port = get_port() port = get_port()
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
client = ReusableClient( client = ReusableClient(keep_alive_app_context, loop=loop, port=port)
keep_alive_app_context, loop=loop, port=port
)
with client: with client:
headers = {"Connection": "keep-alive"} headers = {"Connection": "keep-alive"}
request1, _ = client.post("/ctx", headers=headers) request1, _ = client.post("/ctx", headers=headers)
@@ -206,15 +164,6 @@ def test_keep_alive_connection_context():
assert response.text == "hello" assert response.text == "hello"
assert id(request1.conn_info.ctx) == id(request2.conn_info.ctx) assert id(request1.conn_info.ctx) == id(request2.conn_info.ctx)
assert ( assert (
request1.conn_info.ctx.foo request1.conn_info.ctx.foo == request2.conn_info.ctx.foo == "hello"
== request2.conn_info.ctx.foo
== "hello"
) )
assert request2.protocol.state["requests_count"] == 2 assert request2.protocol.state["requests_count"] == 2
except OSError:
loops += 1
if loops > MAX_LOOPS:
raise
continue
else:
break

View File

@@ -126,7 +126,7 @@ def test_redirect_with_params(app, test_str):
@app.route("/api/v2/test/<test>/", unquote=True) @app.route("/api/v2/test/<test>/", unquote=True)
async def target_handler(request, test): async def target_handler(request, test):
assert test == quote(test_str) assert test == test_str
return text("OK") return text("OK")
_, response = app.test_client.get(f"/api/v1/test/{use_in_uri}/") _, response = app.test_client.get(f"/api/v1/test/{use_in_uri}/")

View File

@@ -310,29 +310,3 @@ def test_request_idempotent(method, idempotent):
def test_request_cacheable(method, cacheable): def test_request_cacheable(method, cacheable):
request = Request(b"/", {}, None, method, None, None) request = Request(b"/", {}, None, method, None, None)
assert request.is_cacheable is cacheable assert request.is_cacheable is cacheable
def test_custom_ctx():
class CustomContext:
FOO = "foo"
class CustomRequest(Request[Sanic, CustomContext]):
@staticmethod
def make_context() -> CustomContext:
return CustomContext()
app = Sanic("Test", request_class=CustomRequest)
@app.get("/")
async def handler(request: CustomRequest):
return response.json(
[
isinstance(request, CustomRequest),
isinstance(request.ctx, CustomContext),
request.ctx.FOO,
]
)
_, resp = app.test_client.get("/")
assert resp.json == [True, True, "foo"]

View File

@@ -513,7 +513,6 @@ def test_standard_forwarded(app):
request, response = app.test_client.get("/", headers=headers) request, response = app.test_client.get("/", headers=headers)
assert response.json == {"for": "127.0.0.2", "proto": "ws"} assert response.json == {"for": "127.0.0.2", "proto": "ws"}
assert request.remote_addr == "127.0.0.2" assert request.remote_addr == "127.0.0.2"
assert request.client_ip == "127.0.0.2"
assert request.scheme == "ws" assert request.scheme == "ws"
assert request.server_name == "local.site" assert request.server_name == "local.site"
assert request.server_port == 80 assert request.server_port == 80
@@ -738,7 +737,6 @@ def test_remote_addr_with_two_proxies(app):
headers = {"X-Forwarded-For": "127.0.1.1"} headers = {"X-Forwarded-For": "127.0.1.1"}
request, response = app.test_client.get("/", headers=headers) request, response = app.test_client.get("/", headers=headers)
assert request.remote_addr == "" assert request.remote_addr == ""
assert request.client_ip == "127.0.0.1"
assert response.body == b"" assert response.body == b""
headers = {"X-Forwarded-For": "127.0.0.1, 127.0.1.2"} headers = {"X-Forwarded-For": "127.0.0.1, 127.0.1.2"}

View File

@@ -1,14 +1,12 @@
import asyncio import asyncio
import os import os
import signal import signal
from queue import Queue from queue import Queue
from types import SimpleNamespace from types import SimpleNamespace
from typing import Optional from typing import Optional
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest
from sanic_testing.testing import HOST, PORT from sanic_testing.testing import HOST, PORT
from sanic import Sanic from sanic import Sanic
@@ -160,7 +158,7 @@ def test_signal_server_lifecycle_exception(app: Sanic):
async def hello_route(request): async def hello_route(request):
return HTTPResponse() return HTTPResponse()
@app.signal(Event.SERVER_EXCEPTION_REPORT) @app.signal(Event.SERVER_LIFECYCLE_EXCEPTION)
async def test_signal(exception: Exception): async def test_signal(exception: Exception):
nonlocal trigger nonlocal trigger
trigger = exception trigger = exception

View File

@@ -2,7 +2,6 @@ import asyncio
from enum import Enum from enum import Enum
from inspect import isawaitable from inspect import isawaitable
from itertools import count
import pytest import pytest
@@ -10,7 +9,6 @@ from sanic_routing.exceptions import NotFound
from sanic import Blueprint, Sanic, empty from sanic import Blueprint, Sanic, empty
from sanic.exceptions import InvalidSignal, SanicException from sanic.exceptions import InvalidSignal, SanicException
from sanic.signals import Event
def test_add_signal(app): def test_add_signal(app):
@@ -429,114 +427,3 @@ def test_signal_reservation(app, event, expected):
app.signal(event)(lambda: ...) app.signal(event)(lambda: ...)
else: else:
app.signal(event)(lambda: ...) app.signal(event)(lambda: ...)
@pytest.mark.asyncio
async def test_report_exception(app: Sanic):
@app.report_exception
async def catch_any_exception(app: Sanic, exception: Exception):
...
@app.route("/")
async def handler(request):
1 / 0
app.signal_router.finalize()
registered_signal_handlers = [
handler
for handler, *_ in app.signal_router.get(
Event.SERVER_EXCEPTION_REPORT.value
)
]
assert catch_any_exception in registered_signal_handlers
def test_report_exception_runs(app: Sanic):
event = asyncio.Event()
@app.report_exception
async def catch_any_exception(app: Sanic, exception: Exception):
event.set()
@app.route("/")
async def handler(request):
1 / 0
app.test_client.get("/")
assert event.is_set()
def test_report_exception_runs_once_inline(app: Sanic):
event = asyncio.Event()
c = count()
@app.report_exception
async def catch_any_exception(app: Sanic, exception: Exception):
event.set()
next(c)
@app.route("/")
async def handler(request):
...
@app.signal(Event.HTTP_ROUTING_AFTER.value)
async def after_routing(**_):
1 / 0
app.test_client.get("/")
assert event.is_set()
assert next(c) == 1
def test_report_exception_runs_once_custom(app: Sanic):
event = asyncio.Event()
c = count()
@app.report_exception
async def catch_any_exception(app: Sanic, exception: Exception):
event.set()
next(c)
@app.route("/")
async def handler(request):
await app.dispatch("one.two.three")
return empty()
@app.signal("one.two.three")
async def one_two_three(**_):
1 / 0
app.test_client.get("/")
assert event.is_set()
assert next(c) == 1
def test_report_exception_runs_task(app: Sanic):
c = count()
async def task_1():
next(c)
async def task_2(app):
next(c)
@app.report_exception
async def catch_any_exception(app: Sanic, exception: Exception):
next(c)
@app.route("/")
async def handler(request):
app.add_task(task_1)
app.add_task(task_1())
app.add_task(task_2)
app.add_task(task_2(app))
return empty()
app.test_client.get("/")
assert next(c) == 4

View File

@@ -1,10 +0,0 @@
from sanic import Sanic
from sanic.config import Config
class CustomConfig(Config):
pass
app = Sanic("test", config=CustomConfig())
reveal_type(app)

View File

@@ -1,9 +0,0 @@
from sanic import Sanic
class Foo:
pass
app = Sanic("test", ctx=Foo())
reveal_type(app)

View File

@@ -1,5 +0,0 @@
from sanic import Sanic
app = Sanic("test")
reveal_type(app)

View File

@@ -1,14 +0,0 @@
from sanic import Sanic
from sanic.config import Config
class CustomConfig(Config):
pass
class Foo:
pass
app = Sanic("test", config=CustomConfig(), ctx=Foo())
reveal_type(app)

View File

@@ -1,17 +0,0 @@
from types import SimpleNamespace
from sanic import Request, Sanic
from sanic.config import Config
class Foo:
pass
app = Sanic("test")
@app.get("/")
async def handler(request: Request[Sanic[Config, SimpleNamespace], Foo]):
reveal_type(request.ctx)
reveal_type(request.app)

View File

@@ -1,19 +0,0 @@
from types import SimpleNamespace
from sanic import Request, Sanic
from sanic.config import Config
class CustomConfig(Config):
pass
app = Sanic("test", config=CustomConfig())
@app.get("/")
async def handler(
request: Request[Sanic[CustomConfig, SimpleNamespace], SimpleNamespace]
):
reveal_type(request.ctx)
reveal_type(request.app)

View File

@@ -1,34 +0,0 @@
from sanic import Request, Sanic
from sanic.config import Config
class CustomConfig(Config):
pass
class Foo:
pass
class RequestContext:
foo: Foo
class CustomRequest(Request[Sanic[CustomConfig, Foo], RequestContext]):
@staticmethod
def make_context() -> RequestContext:
ctx = RequestContext()
ctx.foo = Foo()
return ctx
app = Sanic(
"test", config=CustomConfig(), ctx=Foo(), request_class=CustomRequest
)
@app.get("/")
async def handler(request: CustomRequest):
reveal_type(request)
reveal_type(request.ctx)
reveal_type(request.app)

View File

@@ -1,127 +0,0 @@
# flake8: noqa: E501
import subprocess
import sys
from pathlib import Path
from typing import List, Tuple
import pytest
CURRENT_DIR = Path(__file__).parent
def run_check(path_location: str) -> str:
"""Use mypy to check the given path location and return the output."""
mypy_path = "mypy"
path = CURRENT_DIR / path_location
command = [mypy_path, path.resolve().as_posix()]
process = subprocess.run(
command,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True,
)
output = process.stdout + process.stderr
return output
@pytest.mark.parametrize(
"path_location,expected",
(
(
"app_default.py",
[
(
"sanic.app.Sanic[sanic.config.Config, types.SimpleNamespace]",
5,
)
],
),
(
"app_custom_config.py",
[
(
"sanic.app.Sanic[app_custom_config.CustomConfig, types.SimpleNamespace]",
10,
)
],
),
(
"app_custom_ctx.py",
[("sanic.app.Sanic[sanic.config.Config, app_custom_ctx.Foo]", 9)],
),
(
"app_fully_custom.py",
[
(
"sanic.app.Sanic[app_fully_custom.CustomConfig, app_fully_custom.Foo]",
14,
)
],
),
(
"request_custom_sanic.py",
[
("types.SimpleNamespace", 18),
(
"sanic.app.Sanic[request_custom_sanic.CustomConfig, types.SimpleNamespace]",
19,
),
],
),
(
"request_custom_ctx.py",
[
("request_custom_ctx.Foo", 16),
(
"sanic.app.Sanic[sanic.config.Config, types.SimpleNamespace]",
17,
),
],
),
(
"request_fully_custom.py",
[
("request_fully_custom.CustomRequest", 32),
("request_fully_custom.RequestContext", 33),
(
"sanic.app.Sanic[request_fully_custom.CustomConfig, request_fully_custom.Foo]",
34,
),
],
),
),
)
def test_check_app_default(
path_location: str, expected: List[Tuple[str, int]]
) -> None:
output = run_check(f"samples/{path_location}")
for text, number in expected:
current = CURRENT_DIR / f"samples/{path_location}"
path = current.relative_to(CURRENT_DIR.parent)
target = Path.cwd()
while True:
note = _text_from_path(current, path, target, number, text)
try:
assert note in output, output
except AssertionError:
target = target.parent
if not target.exists():
raise
else:
break
def _text_from_path(
base: Path, path: Path, target: Path, number: int, text: str
) -> str:
relative_to_cwd = base.relative_to(target)
prefix = ".".join(relative_to_cwd.parts[:-1])
text = text.replace(path.stem, f"{prefix}.{path.stem}")
return f'{path}:{number}: note: Revealed type is "{text}"'

View File

@@ -1,14 +1,14 @@
[tox] [tox]
envlist = py38, py39, py310, py311, pyNightly, pypy310, {py38,py39,py310,py311,pyNightly,pypy310}-no-ext, lint, check, security, docs, type-checking envlist = py37, py38, py39, py310, py311, pyNightly, pypy37, {py37,py38,py39,py310,py311,pyNightly,pypy37}-no-ext, lint, check, security, docs, type-checking
[testenv] [testenv]
usedevelop = true usedevelop = true
setenv = setenv =
{py38,py39,py310,py311,pyNightly}-no-ext: SANIC_NO_UJSON=1 {py37,py38,py39,py310,py311,pyNightly}-no-ext: SANIC_NO_UJSON=1
{py38,py39,py310,py311,pyNightly}-no-ext: SANIC_NO_UVLOOP=1 {py37,py38,py39,py310,py311,pyNightly}-no-ext: SANIC_NO_UVLOOP=1
extras = test, http3 extras = test, http3
deps = deps =
httpx>=0.23 httpx==0.23
allowlist_externals = allowlist_externals =
pytest pytest
coverage coverage