Compare commits
1 commit
7443d8d886
...
b3da2351dd
Author | SHA1 | Date | |
---|---|---|---|
b3da2351dd |
22 changed files with 4118 additions and 1336 deletions
|
@ -11,7 +11,7 @@ jobs:
|
|||
container:
|
||||
image: alpine:latest
|
||||
env:
|
||||
downstream: https://forge.ilot.io/api/packages/ilot/alpine/v3.21/ilot
|
||||
downstream: https://forge.ilot.io/api/packages/ilot/alpine/v3.20/ilot
|
||||
FORGEJO_TOKEN: ${{ secrets.forgejo_token }}
|
||||
LABEL_NUMBER: 8
|
||||
steps:
|
||||
|
|
47
backports/forgejo-runner/APKBUILD
Normal file
47
backports/forgejo-runner/APKBUILD
Normal file
|
@ -0,0 +1,47 @@
|
|||
# Contributor: Patrycja Rosa <alpine@ptrcnull.me>
|
||||
# Maintainer: Patrycja Rosa <alpine@ptrcnull.me>
|
||||
pkgname=forgejo-runner
|
||||
pkgver=3.5.0
|
||||
pkgrel=2
|
||||
pkgdesc="CI/CD job runner for Forgejo"
|
||||
url="https://code.forgejo.org/forgejo/runner"
|
||||
arch="all"
|
||||
license="MIT"
|
||||
makedepends="go"
|
||||
install="$pkgname.pre-install $pkgname.pre-upgrade"
|
||||
subpackages="$pkgname-openrc"
|
||||
source="$pkgname-$pkgver.tar.gz::https://code.forgejo.org/forgejo/runner/archive/v$pkgver.tar.gz
|
||||
|
||||
forgejo-runner.logrotate
|
||||
forgejo-runner.initd
|
||||
forgejo-runner.confd
|
||||
"
|
||||
builddir="$srcdir/runner"
|
||||
options="!check" # tests require running forgejo
|
||||
|
||||
build() {
|
||||
go build \
|
||||
-o forgejo-runner \
|
||||
-ldflags "-X gitea.com/gitea/act_runner/internal/pkg/ver.version=$pkgver"
|
||||
./forgejo-runner generate-config > config.example.yaml
|
||||
}
|
||||
|
||||
check() {
|
||||
go test ./...
|
||||
}
|
||||
|
||||
package() {
|
||||
install -Dm755 forgejo-runner -t "$pkgdir"/usr/bin/
|
||||
install -Dm644 config.example.yaml -t "$pkgdir"/etc/forgejo-runner/
|
||||
|
||||
install -Dm755 "$srcdir"/forgejo-runner.initd "$pkgdir"/etc/init.d/forgejo-runner
|
||||
install -Dm644 "$srcdir"/forgejo-runner.confd "$pkgdir"/etc/conf.d/forgejo-runner
|
||||
install -Dm644 "$srcdir"/forgejo-runner.logrotate "$pkgdir"/etc/logrotate.d/forgejo-runner
|
||||
}
|
||||
|
||||
sha512sums="
|
||||
e78968a5f9b6e797fb759a5c8cbf46a5c2fef2083dabc88599c9017729faface963576c63a948b0add424cb267902e864fb1a1b619202660296976d93e670713 forgejo-runner-3.5.0.tar.gz
|
||||
a3c7238b0c63053325d31e09277edd88690ef5260854517f82d9042d6173fb5d24ebfe36e1d7363673dd8801972638a6e69b6af8ad43debb6057515c73655236 forgejo-runner.logrotate
|
||||
bb0c6fbe90109c77f9ef9cb0d35d20b8033be0e4b7a60839b596aa5528dfa24309ec894d8c04066bf8fb30143e63a5fd8cc6fc89aac364422b583e0f840e2da6 forgejo-runner.initd
|
||||
e11eab27f88f1181112389befa7de3aa0bac7c26841861918707ede53335535425c805e6682e25704e9c8a6aecba3dc13e20900a99df1183762b012b62f26d5f forgejo-runner.confd
|
||||
"
|
17
backports/forgejo-runner/forgejo-runner.confd
Normal file
17
backports/forgejo-runner/forgejo-runner.confd
Normal file
|
@ -0,0 +1,17 @@
|
|||
# Configuration for /etc/init.d/forgejo-runner
|
||||
|
||||
# Path to the config file (--config).
|
||||
#cfgfile="/etc/forgejo-runner/config.yaml"
|
||||
|
||||
# Path to the working directory (--working-directory).
|
||||
#datadir="/var/lib/forgejo-runner"
|
||||
|
||||
# Path to the log file where stdout/stderr will be redirected.
|
||||
# Leave empty/commented out to use syslog instead.
|
||||
#output_log="/var/log/forgejo-runner.log"
|
||||
|
||||
# You may change this to root, e.g. to run jobs in LXC
|
||||
#command_user="forgejo-runner"
|
||||
|
||||
# Comment out to run without process supervisor.
|
||||
supervisor=supervise-daemon
|
38
backports/forgejo-runner/forgejo-runner.initd
Normal file
38
backports/forgejo-runner/forgejo-runner.initd
Normal file
|
@ -0,0 +1,38 @@
|
|||
#!/sbin/openrc-run
|
||||
|
||||
description="Forgejo CI Runner"
|
||||
name="Forgejo Runner"
|
||||
|
||||
: ${cfgfile:="/etc/forgejo-runner/config.yaml"}
|
||||
: ${datadir:="/var/lib/forgejo-runner"}
|
||||
: ${command_user:="forgejo-runner"}
|
||||
|
||||
command="/usr/bin/forgejo-runner"
|
||||
command_args="daemon --config $cfgfile"
|
||||
command_background="yes"
|
||||
directory="$datadir"
|
||||
pidfile="/run/$RC_SVCNAME.pid"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
use dns logger
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
checkpath -d -o "$command_user" /etc/forgejo-runner
|
||||
checkpath -d -o "$command_user" "$datadir"
|
||||
|
||||
if ! [ -e "$cfgfile" ]; then
|
||||
eerror "Config file $cfgfile doesn't exist."
|
||||
eerror "You can generate it with: forgejo-runner generate-config,"
|
||||
eerror "or use the auto-generated one in /etc/forgejo-runner/config.example.yaml"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [ "$error_log" ]; then
|
||||
output_log="$error_log"
|
||||
else
|
||||
output_logger="logger -t '${RC_SVCNAME}' -p daemon.info"
|
||||
error_logger="logger -t '${RC_SVCNAME}' -p daemon.error"
|
||||
fi
|
||||
}
|
5
backports/forgejo-runner/forgejo-runner.logrotate
Normal file
5
backports/forgejo-runner/forgejo-runner.logrotate
Normal file
|
@ -0,0 +1,5 @@
|
|||
/var/log/forgejo-runner.log {
|
||||
copytruncate
|
||||
missingok
|
||||
notifempty
|
||||
}
|
14
backports/forgejo-runner/forgejo-runner.pre-install
Normal file
14
backports/forgejo-runner/forgejo-runner.pre-install
Normal file
|
@ -0,0 +1,14 @@
|
|||
#!/bin/sh
|
||||
|
||||
addgroup -S forgejo-runner 2>/dev/null
|
||||
adduser -S -D -H -h /var/lib/forgejo-runner -s /sbin/nologin -G forgejo-runner -g forgejo-runner forgejo-runner 2>/dev/null
|
||||
|
||||
cat >&2 <<EOF
|
||||
* In order to setup the runner, create a config file
|
||||
* in /etc/forgejo-runner/config.yaml (either from .example.yaml,
|
||||
* or generating your own with 'forgejo-runner generate-config'),
|
||||
* then register it with 'doas -u forgejo-runner forgejo-runner register'
|
||||
* ran in the /var/lib/forgejo-runner directory.
|
||||
EOF
|
||||
|
||||
exit 0
|
1
backports/forgejo-runner/forgejo-runner.pre-upgrade
Symbolic link
1
backports/forgejo-runner/forgejo-runner.pre-upgrade
Symbolic link
|
@ -0,0 +1 @@
|
|||
forgejo-runner.pre-install
|
|
@ -1,48 +0,0 @@
|
|||
From 7b88a0ba662a05076ee209f4b21caf9bed972b6a Mon Sep 17 00:00:00 2001
|
||||
From: Jens Langhammer <jens@goauthentik.io>
|
||||
Date: Mon, 25 Nov 2024 16:48:40 +0100
|
||||
Subject: [PATCH] web/admin: fix impersonate API call
|
||||
|
||||
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
|
||||
---
|
||||
web/src/admin/groups/RelatedUserList.ts | 1 +
|
||||
web/src/admin/users/UserListPage.ts | 1 +
|
||||
web/src/admin/users/UserViewPage.ts | 1 +
|
||||
3 files changed, 3 insertions(+)
|
||||
|
||||
diff --git a/web/src/admin/groups/RelatedUserList.ts b/web/src/admin/groups/RelatedUserList.ts
|
||||
index 72a9b62a1856..80474f5938e8 100644
|
||||
--- a/web/src/admin/groups/RelatedUserList.ts
|
||||
+++ b/web/src/admin/groups/RelatedUserList.ts
|
||||
@@ -219,6 +219,7 @@ export class RelatedUserList extends WithBrandConfig(WithCapabilitiesConfig(Tabl
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersImpersonateCreate({
|
||||
id: item.pk,
|
||||
+ impersonationRequest: { reason: "" },
|
||||
})
|
||||
.then(() => {
|
||||
window.location.href = "/";
|
||||
diff --git a/web/src/admin/users/UserListPage.ts b/web/src/admin/users/UserListPage.ts
|
||||
index 1264c00814fc..6b0f19a42e88 100644
|
||||
--- a/web/src/admin/users/UserListPage.ts
|
||||
+++ b/web/src/admin/users/UserListPage.ts
|
||||
@@ -272,6 +272,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersImpersonateCreate({
|
||||
id: item.pk,
|
||||
+ impersonationRequest: { reason: "" },
|
||||
})
|
||||
.then(() => {
|
||||
window.location.href = "/";
|
||||
diff --git a/web/src/admin/users/UserViewPage.ts b/web/src/admin/users/UserViewPage.ts
|
||||
index 119ffdb3716a..83e2661e6294 100644
|
||||
--- a/web/src/admin/users/UserViewPage.ts
|
||||
+++ b/web/src/admin/users/UserViewPage.ts
|
||||
@@ -215,6 +215,7 @@ export class UserViewPage extends WithCapabilitiesConfig(AKElement) {
|
||||
return new CoreApi(DEFAULT_CONFIG)
|
||||
.coreUsersImpersonateCreate({
|
||||
id: user.pk,
|
||||
+ impersonationRequest: { reason: "" },
|
||||
})
|
||||
.then(() => {
|
||||
window.location.href = "/";
|
|
@ -1,15 +1,14 @@
|
|||
# Contributor: Antoine Martin (ayakael) <dev@ayakael.net>
|
||||
# Maintainer: Antoine Martin (ayakael) <dev@ayakael.net>
|
||||
pkgname=authentik
|
||||
pkgver=2024.8.6
|
||||
pkgrel=1
|
||||
pkgver=2024.8.4
|
||||
pkgrel=0
|
||||
pkgdesc="An open-source Identity Provider focused on flexibility and versatility"
|
||||
url="https://github.com/goauthentik/authentik"
|
||||
# s390x: missing py3-celery py3-flower and py3-kombu
|
||||
# armhf/armv7/x86: out of memory error when building goauthentik
|
||||
# ppc64le: not supported by Rollup build
|
||||
# missing uvicorn
|
||||
# arch="aarch64 x86_64"
|
||||
arch="aarch64 x86_64"
|
||||
license="MIT"
|
||||
depends="
|
||||
bash
|
||||
|
@ -65,7 +64,7 @@ depends="
|
|||
py3-django-prometheus
|
||||
py3-django-pglock
|
||||
py3-django-redis
|
||||
py3-django-rest-framework~3.14.0
|
||||
py3-django-rest-framework~=3.14.0
|
||||
py3-django-rest-framework-guardian
|
||||
py3-django-storages
|
||||
py3-django-tenants
|
||||
|
@ -181,7 +180,6 @@ source="
|
|||
fix-ak-bash.patch
|
||||
root-settings-csrf_trusted_origins.patch
|
||||
go-downgrade-1.22.patch
|
||||
12184_fix-impersonnate-api.patch
|
||||
"
|
||||
builddir="$srcdir/"authentik-version-$pkgver
|
||||
subpackages="$pkgname-openrc $pkgname-doc $pkgname-pyc"
|
||||
|
@ -335,7 +333,7 @@ pyc() {
|
|||
}
|
||||
|
||||
sha512sums="
|
||||
ede869ff73e83707819d1cdc0c73bc4dc445fbf4a8ac27140245a3fe77949fc6b9cfa4ebb5de935956a5a1d9faf340720e8259287805fccdb0f141294f54e3cc authentik-2024.8.6.tar.gz
|
||||
63548adc1ff93f603d133f1a23357ac1fedd975e790b81e1ad1ce17c7b32a58197c2fe49e6199362d3e90f873cd010b14b2e83b254f81b0198663657a2532e91 authentik-2024.8.4.tar.gz
|
||||
4defb4fe3a4230f4aa517fbecd5e5b8bcef2a64e1b40615660ae9eec33597310a09df5e126f4d39ce7764bd1716c0a7040637699135c103cbc1879593c6c06f1 authentik.openrc
|
||||
6cb03b9b69df39bb4539fe05c966536314d766b2e9307a92d87070ba5f5b7e7ab70f1b5ee1ab3c0c50c23454f9c5a4caec29e63fdf411bbb7a124ad687569b89 authentik-worker.openrc
|
||||
351e6920d987861f8bf0d7ab2f942db716a8dbdad1f690ac662a6ef29ac0fd46cf817cf557de08f1c024703503d36bc8b46f0d9eb1ecaeb399dce4c3bb527d17 authentik-ldap.openrc
|
||||
|
@ -344,5 +342,4 @@ f1a3cb215b6210fa7d857a452a9f2bc4dc0520e49b9fa7027547cff093d740a7e2548f1bf1f8831f
|
|||
3e47db684a3f353dcecdb7bab8836b9d5198766735d77f676a51d952141a0cf9903fcb92e6306c48d2522d7a1f3028b37247fdc1dc74d4d6e043da7eb4f36d49 fix-ak-bash.patch
|
||||
5c60e54b6a7829d611af66f5cb8184a002b5ae927efbd024c054a7c176fcb9efcfbe5685279ffcf0390b0f0abb3bb03e02782c6867c2b38d1ad2d508aae83fa0 root-settings-csrf_trusted_origins.patch
|
||||
badff70b19aad79cf16046bd46cb62db25c2a8b85b2673ce7c44c42eb60d42f6fcb1b9a7a7236c00f24803b25d3c66a4d64423f7ce14a59763b8415db292a5b9 go-downgrade-1.22.patch
|
||||
5d409cb41d4a506df30618bdaf325188d304d879e2d23fe49277db6ae8fe9341803d7eee54c9a5dbdfa87267146ea1647995b2adb503fbf8f8c4d7f861ce9de6 12184_fix-impersonnate-api.patch
|
||||
"
|
||||
|
|
|
@ -4,14 +4,14 @@
|
|||
# Contributor: Patrycja Rosa <alpine@ptrcnull.me>
|
||||
# Maintainer: Antoine Martin (ayakael) <dev@ayakael.net>
|
||||
pkgname=forgejo-aneksajo
|
||||
pkgver=9.0.3_git0
|
||||
pkgver=9.0.1_git0
|
||||
_gittag=v${pkgver/_git/-git-annex}
|
||||
pkgrel=0
|
||||
pkgdesc="Self-hosted Git service written in Go with git-annex support"
|
||||
url="https://forgejo.org"
|
||||
# riscv64: builds fail https://codeberg.org/forgejo/forgejo/issues/3025
|
||||
arch="all !riscv64"
|
||||
license="GPL-3.0-or-later"
|
||||
license="MIT"
|
||||
depends="git git-lfs gnupg"
|
||||
makedepends="go nodejs npm"
|
||||
checkdepends="bash openssh openssh-keygen sqlite tzdata"
|
||||
|
@ -106,7 +106,7 @@ package() {
|
|||
}
|
||||
|
||||
sha512sums="
|
||||
2c2493c0011e83994c12c11859c2153d855a2265d234a671d2ce855e4f45b8e1b7d7f257e9c7ffa6284b844e0068a6184ef39b88800a1d79f399ce11c7cb23b7 forgejo-aneksajo-v9.0.3-git-annex0.tar.gz
|
||||
3617f4adc4d0be977074944d9a032ca3c6f3e5c8d12a14140052c1e732d9f5c557cab8304837562960616ac3d3194f4c81632f309f05c30b4566e14b15784c7f forgejo-aneksajo-v9.0.1-git-annex0.tar.gz
|
||||
eb93a9f6c8f204de5c813f58727015f53f9feaab546589e016c60743131559f04fc1518f487b6d2a0e7fa8fab6d4a67cd0cd9713a7ccd9dec767a8c1ddebe129 forgejo-aneksajo.initd
|
||||
b537b41b6b3a945274a6028800f39787b48c318425a37cf5d40ace0d1b305444fd07f17b4acafcd31a629bedd7d008b0bb3e30f82ffeb3d7e7e947bdbe0ff4f3 forgejo-aneksajo.ini
|
||||
"
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
# Contributor: Antoine Martin (ayakael) <dev@ayakael.net>
|
||||
# Maintainer: Antoine Martin (ayakael) <dev@ayakael.net>
|
||||
pkgname=listmonk
|
||||
pkgver=4.1.0
|
||||
pkgver=4.0.1
|
||||
pkgrel=0
|
||||
pkgdesc='Self-hosted newsletter and mailing list manager with a modern dashboard'
|
||||
arch="all"
|
||||
|
@ -67,7 +67,7 @@ package() {
|
|||
ln -s /etc/listmonk/config.toml "$pkgdir"/usr/share/webapps/listmonk/config.toml
|
||||
}
|
||||
sha512sums="
|
||||
936b33d6de1d69ee4e7f768810116ac997c516754aace0371089bc8106bebee944197864afc11b7bc5725afa9a4f195d6629957bfcdd37c847e3780aa34558ec listmonk-4.1.0.tar.gz
|
||||
ae5c338b756bb9d84739ab8b04b591e33ee7f6a579725083ec95e2609dbff55adbd1f2a11c0487b971aa030a3a35347cf54966820e1320b4144351935b2497d8 listmonk-4.0.1.tar.gz
|
||||
939450af4b23708e3d23a5a88fad4c24b957090bdd21351a6dd520959e52e45e5fcac117a3eafa280d9506616dae39ad3943589571f008cac5abe1ffd8062424 listmonk.sh
|
||||
8e9c0b1f335c295fb741418246eb17c7566e5e4200a284c6483433e8ddbf5250aa692435211cf062ad1dfcdce3fae9148def28f03f2492d33fe5e66cbeebd4bd listmonk.openrc
|
||||
"
|
||||
|
|
3823
ilot/py3-django-tenants/997_update-from-pgclone-schema.patch
Normal file
3823
ilot/py3-django-tenants/997_update-from-pgclone-schema.patch
Normal file
File diff suppressed because it is too large
Load diff
43
ilot/py3-django-tenants/APKBUILD
Normal file
43
ilot/py3-django-tenants/APKBUILD
Normal file
|
@ -0,0 +1,43 @@
|
|||
# Contributor: Antoine Martin (ayakael) <dev@ayakael.net>
|
||||
# Maintainer: Antoine Martin (ayakael) <dev@ayakael.net>
|
||||
pkgname=py3-django-tenants
|
||||
#_pkgreal is used by apkbuild-pypi to find modules at PyPI
|
||||
_pkgreal=django-tenants
|
||||
pkgver=3.6.1
|
||||
pkgrel=5
|
||||
pkgdesc="Tenant support for Django using PostgreSQL schemas."
|
||||
url="https://pypi.python.org/project/django-tenants"
|
||||
arch="noarch"
|
||||
license="MIT"
|
||||
depends="py3-django py3-psycopg py3-gunicorn py3-coverage"
|
||||
checkdepends="python3-dev py3-pytest"
|
||||
makedepends="py3-setuptools py3-gpep517 py3-wheel"
|
||||
source="
|
||||
$pkgname-$pkgver.tar.gz::https://codeload.github.com/django-tenants/django-tenants/tar.gz/refs/tags/v$pkgver
|
||||
997_update-from-pgclone-schema.patch
|
||||
"
|
||||
builddir="$srcdir/$_pkgreal-$pkgver"
|
||||
options="!check" # Requires setting up test database
|
||||
subpackages="$pkgname-pyc"
|
||||
|
||||
build() {
|
||||
gpep517 build-wheel \
|
||||
--wheel-dir .dist \
|
||||
--output-fd 3 3>&1 >&2
|
||||
}
|
||||
|
||||
check() {
|
||||
python3 -m venv --clear --without-pip --system-site-packages .testenv
|
||||
.testenv/bin/python3 -m installer .dist/*.whl
|
||||
DJANGO_SETTINGS_MODULE=tests.settings .testenv/bin/python3 -m pytest -v
|
||||
}
|
||||
|
||||
package() {
|
||||
python3 -m installer -d "$pkgdir" \
|
||||
.dist/*.whl
|
||||
}
|
||||
|
||||
sha512sums="
|
||||
b18afce81ccc89e49fcc4ebe85d90be602415ca898c1660a4e71e2bef6a3ed2e8c724e94b61d8c6f48f3fb19eb2a87d6a6f5bbf449b3e2f661f87e4b5638eafb py3-django-tenants-3.6.1.tar.gz
|
||||
f2424bb188db2e3c7d13c15e5bdf0959c6f794e68dbc677c8b876d4faa321f78aded5565539f1bfd97583c6df0fcc19ec05abe203b08407e4446dd7194756825 997_update-from-pgclone-schema.patch
|
||||
"
|
39
ilot/py3-drf-orjson-renderer/APKBUILD
Normal file
39
ilot/py3-drf-orjson-renderer/APKBUILD
Normal file
|
@ -0,0 +1,39 @@
|
|||
# Contributor: Antoine Martin (ayakael) <dev@ayakael.net>
|
||||
# Maintainer: Antoine Martin (ayakael) <dev@ayakael.net>
|
||||
pkgname=py3-drf-orjson-renderer
|
||||
#_pkgreal is used by apkbuild-pypi to find modules at PyPI
|
||||
_pkgreal=drf_orjson_renderer
|
||||
pkgver=1.7.3
|
||||
_gittag=9a59352f82e262bd78ccc0228361bcb321a33623
|
||||
pkgrel=0
|
||||
pkgdesc="Django RestFramework JSON Renderer Backed by orjson"
|
||||
url="https://pypi.python.org/project/drf-orjson-renderer"
|
||||
arch="noarch"
|
||||
license="MIT"
|
||||
depends="py3-django-rest-framework py3-orjson"
|
||||
checkdepends="py3-pytest-django py3-numpy"
|
||||
makedepends="py3-setuptools py3-gpep517 py3-wheel"
|
||||
source="$pkgname-$pkgver.tar.gz::https://github.com/brianjbuck/drf_orjson_renderer/archive/$_gittag.tar.gz"
|
||||
builddir="$srcdir/$_pkgreal-$_gittag"
|
||||
subpackages="$pkgname-pyc"
|
||||
|
||||
build() {
|
||||
gpep517 build-wheel \
|
||||
--wheel-dir .dist \
|
||||
--output-fd 3 3>&1 >&2
|
||||
}
|
||||
|
||||
check() {
|
||||
python3 -m venv --clear --without-pip --system-site-packages .testenv
|
||||
.testenv/bin/python3 -m installer .dist/*.whl
|
||||
.testenv/bin/python3 -m pytest -v
|
||||
}
|
||||
|
||||
package() {
|
||||
python3 -m installer -d "$pkgdir" \
|
||||
.dist/*.whl
|
||||
}
|
||||
|
||||
sha512sums="
|
||||
7870aebf6bcc249228b1620f4b50124eef54e251dcac236e23be4287284461617d630b073d2e9122f66779a908dfd69c5e16b486b23de0114b06b3df6b468e95 py3-drf-orjson-renderer-1.7.3.tar.gz
|
||||
"
|
39
ilot/py3-pyrad/APKBUILD
Normal file
39
ilot/py3-pyrad/APKBUILD
Normal file
|
@ -0,0 +1,39 @@
|
|||
# Contributor: Antoine Martin (ayakael) <dev@ayakael.net>
|
||||
# Maintainer: Antoine Martin (ayakael) <dev@ayakael.net>
|
||||
pkgname=py3-pyrad
|
||||
#_pkgreal is used by apkbuild-pypi to find modules at PyPI
|
||||
_pkgreal=pyrad
|
||||
pkgver=2.4
|
||||
pkgrel=0
|
||||
pkgdesc="Python RADIUS Implementation"
|
||||
url="https://pypi.python.org/project/pyrad"
|
||||
arch="noarch"
|
||||
license="BSD-3-Clause"
|
||||
depends="py3-netaddr"
|
||||
checkdepends="py3-pytest"
|
||||
makedepends="py3-setuptools py3-gpep517 py3-wheel poetry"
|
||||
source="$pkgname-$pkgver.tar.gz::https://github.com/pyradius/pyrad/archive/refs/tags/$pkgver.tar.gz"
|
||||
options="!check" # TODO
|
||||
builddir="$srcdir/$_pkgreal-$pkgver"
|
||||
subpackages="$pkgname-pyc"
|
||||
|
||||
build() {
|
||||
gpep517 build-wheel \
|
||||
--wheel-dir .dist \
|
||||
--output-fd 3 3>&1 >&2
|
||||
}
|
||||
|
||||
check() {
|
||||
python3 -m venv --clear --without-pip --system-site-packages .testenv
|
||||
.testenv/bin/python3 -m installer .dist/*.whl
|
||||
.testenv/bin/python3 -m pytest -v
|
||||
}
|
||||
|
||||
package() {
|
||||
python3 -m installer -d "$pkgdir" \
|
||||
.dist/*.whl
|
||||
}
|
||||
|
||||
sha512sums="
|
||||
e4f4c687596bd226cf2cdb409a8d940c7b665fb7f722d09113dd9a1b05ab176ce8f920b235918ec01695f262930d13b4057b199cf6aac72afa54950c1fb59166 py3-pyrad-2.4.tar.gz
|
||||
"
|
41
ilot/py3-tenant-schemas-celery/APKBUILD
Normal file
41
ilot/py3-tenant-schemas-celery/APKBUILD
Normal file
|
@ -0,0 +1,41 @@
|
|||
# Contributor: Antoine Martin (ayakael) <dev@ayakael.net>
|
||||
# Maintainer: Antoine Martin (ayakael) <dev@ayakael.net>
|
||||
pkgname=py3-tenant-schemas-celery
|
||||
#_pkgreal is used by apkbuild-pypi to find modules at PyPI
|
||||
_pkgreal=tenant-schemas-celery
|
||||
pkgver=2.2.0
|
||||
pkgrel=2
|
||||
pkgdesc="Celery integration for django-tenant-schemas and django-tenants"
|
||||
url="https://pypi.python.org/project/tenant-schemas-celery"
|
||||
arch="noarch"
|
||||
license="MIT"
|
||||
depends="py3-django-tenants py3-celery"
|
||||
checkdepends="python3-dev py3-pytest"
|
||||
makedepends="py3-setuptools py3-gpep517 py3-wheel"
|
||||
source="
|
||||
$pkgname-$pkgver.tar.gz::https://codeload.github.com/maciej-gol/tenant-schemas-celery/tar.gz/refs/tags/$pkgver
|
||||
"
|
||||
options="!check" # Test suite wants docker
|
||||
builddir="$srcdir/$_pkgreal-$pkgver"
|
||||
subpackages="$pkgname-pyc"
|
||||
|
||||
build() {
|
||||
gpep517 build-wheel \
|
||||
--wheel-dir .dist \
|
||||
--output-fd 3 3>&1 >&2
|
||||
}
|
||||
|
||||
check() {
|
||||
python3 -m venv --clear --without-pip --system-site-packages .testenv
|
||||
.testenv/bin/python3 -m installer .dist/*.whl
|
||||
DJANGO_SETTINGS_MODULE=tests.settings .testenv/bin/python3 -m pytest -v
|
||||
}
|
||||
|
||||
package() {
|
||||
python3 -m installer -d "$pkgdir" \
|
||||
.dist/*.whl
|
||||
}
|
||||
|
||||
sha512sums="
|
||||
dad71011306936dc84d966797b113008780750e9e973513092bec892be0d1468e0a0e7e8e2fcca9765309a27767e1c72bdaad7c8aca16353ae1eef783c239148 py3-tenant-schemas-celery-2.2.0.tar.gz
|
||||
"
|
|
@ -1,618 +0,0 @@
|
|||
diff --git a/docs/deployment.md b/docs/deployment.md
|
||||
index d69fcf8..99dfbf3 100644
|
||||
--- a/docs/deployment.md
|
||||
+++ b/docs/deployment.md
|
||||
@@ -60,7 +60,7 @@ Options:
|
||||
--loop [auto|asyncio|uvloop] Event loop implementation. [default: auto]
|
||||
--http [auto|h11|httptools] HTTP protocol implementation. [default:
|
||||
auto]
|
||||
- --ws [auto|none|websockets|wsproto]
|
||||
+ --ws [auto|none|websockets|websockets-sansio|wsproto]
|
||||
WebSocket protocol implementation.
|
||||
[default: auto]
|
||||
--ws-max-size INTEGER WebSocket max size message in bytes
|
||||
diff --git a/docs/index.md b/docs/index.md
|
||||
index bb6fc32..50e2ab9 100644
|
||||
--- a/docs/index.md
|
||||
+++ b/docs/index.md
|
||||
@@ -130,7 +130,7 @@ Options:
|
||||
--loop [auto|asyncio|uvloop] Event loop implementation. [default: auto]
|
||||
--http [auto|h11|httptools] HTTP protocol implementation. [default:
|
||||
auto]
|
||||
- --ws [auto|none|websockets|wsproto]
|
||||
+ --ws [auto|none|websockets|websockets-sansio|wsproto]
|
||||
WebSocket protocol implementation.
|
||||
[default: auto]
|
||||
--ws-max-size INTEGER WebSocket max size message in bytes
|
||||
diff --git a/pyproject.toml b/pyproject.toml
|
||||
index 0a89966..8771bfb 100644
|
||||
--- a/pyproject.toml
|
||||
+++ b/pyproject.toml
|
||||
@@ -92,6 +92,10 @@ filterwarnings = [
|
||||
"ignore:Uvicorn's native WSGI implementation is deprecated.*:DeprecationWarning",
|
||||
"ignore: 'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning",
|
||||
"ignore: remove second argument of ws_handler:DeprecationWarning:websockets",
|
||||
+ "ignore: websockets.legacy is deprecated.*:DeprecationWarning",
|
||||
+ "ignore: websockets.server.WebSocketServerProtocol is deprecated.*:DeprecationWarning",
|
||||
+ "ignore: websockets.client.connect is deprecated.*:DeprecationWarning",
|
||||
+ "ignore: websockets.exceptions.InvalidStatusCode is deprecated",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
diff --git a/tests/conftest.py b/tests/conftest.py
|
||||
index 1b0c0e8..7061a14 100644
|
||||
--- a/tests/conftest.py
|
||||
+++ b/tests/conftest.py
|
||||
@@ -233,9 +233,9 @@ def unused_tcp_port() -> int:
|
||||
marks=pytest.mark.skipif(not importlib.util.find_spec("wsproto"), reason="wsproto not installed."),
|
||||
id="wsproto",
|
||||
),
|
||||
+ pytest.param("uvicorn.protocols.websockets.websockets_impl:WebSocketProtocol", id="websockets"),
|
||||
pytest.param(
|
||||
- "uvicorn.protocols.websockets.websockets_impl:WebSocketProtocol",
|
||||
- id="websockets",
|
||||
+ "uvicorn.protocols.websockets.websockets_sansio_impl:WebSocketsSansIOProtocol", id="websockets-sansio"
|
||||
),
|
||||
]
|
||||
)
|
||||
diff --git a/tests/middleware/test_logging.py b/tests/middleware/test_logging.py
|
||||
index f27633a..63d7daf 100644
|
||||
--- a/tests/middleware/test_logging.py
|
||||
+++ b/tests/middleware/test_logging.py
|
||||
@@ -49,7 +49,9 @@ async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
|
||||
await send({"type": "http.response.body", "body": b"", "more_body": False})
|
||||
|
||||
|
||||
-async def test_trace_logging(caplog: pytest.LogCaptureFixture, logging_config, unused_tcp_port: int):
|
||||
+async def test_trace_logging(
|
||||
+ caplog: pytest.LogCaptureFixture, logging_config: dict[str, typing.Any], unused_tcp_port: int
|
||||
+):
|
||||
config = Config(
|
||||
app=app,
|
||||
log_level="trace",
|
||||
@@ -91,8 +93,8 @@ async def test_trace_logging_on_http_protocol(http_protocol_cls, caplog, logging
|
||||
|
||||
async def test_trace_logging_on_ws_protocol(
|
||||
ws_protocol_cls: WSProtocol,
|
||||
- caplog,
|
||||
- logging_config,
|
||||
+ caplog: pytest.LogCaptureFixture,
|
||||
+ logging_config: dict[str, typing.Any],
|
||||
unused_tcp_port: int,
|
||||
):
|
||||
async def websocket_app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
|
||||
@@ -104,7 +106,7 @@ async def test_trace_logging_on_ws_protocol(
|
||||
elif message["type"] == "websocket.disconnect":
|
||||
break
|
||||
|
||||
- async def open_connection(url):
|
||||
+ async def open_connection(url: str):
|
||||
async with websockets.client.connect(url) as websocket:
|
||||
return websocket.open
|
||||
|
||||
diff --git a/tests/middleware/test_proxy_headers.py b/tests/middleware/test_proxy_headers.py
|
||||
index 0ade974..d300c45 100644
|
||||
--- a/tests/middleware/test_proxy_headers.py
|
||||
+++ b/tests/middleware/test_proxy_headers.py
|
||||
@@ -465,6 +465,7 @@ async def test_proxy_headers_websocket_x_forwarded_proto(
|
||||
host, port = scope["client"]
|
||||
await send({"type": "websocket.accept"})
|
||||
await send({"type": "websocket.send", "text": f"{scheme}://{host}:{port}"})
|
||||
+ await send({"type": "websocket.close"})
|
||||
|
||||
app_with_middleware = ProxyHeadersMiddleware(websocket_app, trusted_hosts="*")
|
||||
config = Config(
|
||||
diff --git a/tests/protocols/test_websocket.py b/tests/protocols/test_websocket.py
|
||||
index 15ccfdd..e728544 100644
|
||||
--- a/tests/protocols/test_websocket.py
|
||||
+++ b/tests/protocols/test_websocket.py
|
||||
@@ -7,6 +7,8 @@ from copy import deepcopy
|
||||
import httpx
|
||||
import pytest
|
||||
import websockets
|
||||
+import websockets.asyncio
|
||||
+import websockets.asyncio.client
|
||||
import websockets.client
|
||||
import websockets.exceptions
|
||||
from typing_extensions import TypedDict
|
||||
@@ -601,12 +603,9 @@ async def test_connection_lost_before_handshake_complete(
|
||||
await send_accept_task.wait()
|
||||
disconnect_message = await receive() # type: ignore
|
||||
|
||||
- response: httpx.Response | None = None
|
||||
-
|
||||
async def websocket_session(uri: str):
|
||||
- nonlocal response
|
||||
async with httpx.AsyncClient() as client:
|
||||
- response = await client.get(
|
||||
+ await client.get(
|
||||
f"http://127.0.0.1:{unused_tcp_port}",
|
||||
headers={
|
||||
"upgrade": "websocket",
|
||||
@@ -623,9 +622,6 @@ async def test_connection_lost_before_handshake_complete(
|
||||
send_accept_task.set()
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
- assert response is not None
|
||||
- assert response.status_code == 500, response.text
|
||||
- assert response.text == "Internal Server Error"
|
||||
assert disconnect_message == {"type": "websocket.disconnect", "code": 1006}
|
||||
await task
|
||||
|
||||
@@ -920,6 +916,9 @@ async def test_server_reject_connection_with_body_nolength(
|
||||
async def test_server_reject_connection_with_invalid_msg(
|
||||
ws_protocol_cls: WSProtocol, http_protocol_cls: HTTPProtocol, unused_tcp_port: int
|
||||
):
|
||||
+ if ws_protocol_cls.__name__ == "WebSocketsSansIOProtocol":
|
||||
+ pytest.skip("WebSocketsSansIOProtocol sends both start and body messages in one message.")
|
||||
+
|
||||
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
|
||||
assert scope["type"] == "websocket"
|
||||
assert "extensions" in scope and "websocket.http.response" in scope["extensions"]
|
||||
@@ -951,6 +950,9 @@ async def test_server_reject_connection_with_invalid_msg(
|
||||
async def test_server_reject_connection_with_missing_body(
|
||||
ws_protocol_cls: WSProtocol, http_protocol_cls: HTTPProtocol, unused_tcp_port: int
|
||||
):
|
||||
+ if ws_protocol_cls.__name__ == "WebSocketsSansIOProtocol":
|
||||
+ pytest.skip("WebSocketsSansIOProtocol sends both start and body messages in one message.")
|
||||
+
|
||||
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
|
||||
assert scope["type"] == "websocket"
|
||||
assert "extensions" in scope and "websocket.http.response" in scope["extensions"]
|
||||
@@ -986,6 +988,8 @@ async def test_server_multiple_websocket_http_response_start_events(
|
||||
The server should raise an exception if it sends multiple
|
||||
websocket.http.response.start events.
|
||||
"""
|
||||
+ if ws_protocol_cls.__name__ == "WebSocketsSansIOProtocol":
|
||||
+ pytest.skip("WebSocketsSansIOProtocol sends both start and body messages in one message.")
|
||||
exception_message: str | None = None
|
||||
|
||||
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
|
||||
diff --git a/uvicorn/config.py b/uvicorn/config.py
|
||||
index 664d191..cbfeea6 100644
|
||||
--- a/uvicorn/config.py
|
||||
+++ b/uvicorn/config.py
|
||||
@@ -25,7 +25,7 @@ from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
|
||||
from uvicorn.middleware.wsgi import WSGIMiddleware
|
||||
|
||||
HTTPProtocolType = Literal["auto", "h11", "httptools"]
|
||||
-WSProtocolType = Literal["auto", "none", "websockets", "wsproto"]
|
||||
+WSProtocolType = Literal["auto", "none", "websockets", "websockets-sansio", "wsproto"]
|
||||
LifespanType = Literal["auto", "on", "off"]
|
||||
LoopSetupType = Literal["none", "auto", "asyncio", "uvloop"]
|
||||
InterfaceType = Literal["auto", "asgi3", "asgi2", "wsgi"]
|
||||
@@ -47,6 +47,7 @@ WS_PROTOCOLS: dict[WSProtocolType, str | None] = {
|
||||
"auto": "uvicorn.protocols.websockets.auto:AutoWebSocketsProtocol",
|
||||
"none": None,
|
||||
"websockets": "uvicorn.protocols.websockets.websockets_impl:WebSocketProtocol",
|
||||
+ "websockets-sansio": "uvicorn.protocols.websockets.websockets_sansio_impl:WebSocketsSansIOProtocol",
|
||||
"wsproto": "uvicorn.protocols.websockets.wsproto_impl:WSProtocol",
|
||||
}
|
||||
LIFESPAN: dict[LifespanType, str] = {
|
||||
diff --git a/uvicorn/protocols/websockets/websockets_sansio_impl.py b/uvicorn/protocols/websockets/websockets_sansio_impl.py
|
||||
new file mode 100644
|
||||
index 0000000..994af07
|
||||
--- /dev/null
|
||||
+++ b/uvicorn/protocols/websockets/websockets_sansio_impl.py
|
||||
@@ -0,0 +1,405 @@
|
||||
+from __future__ import annotations
|
||||
+
|
||||
+import asyncio
|
||||
+import logging
|
||||
+from asyncio.transports import BaseTransport, Transport
|
||||
+from http import HTTPStatus
|
||||
+from typing import Any, Literal, cast
|
||||
+from urllib.parse import unquote
|
||||
+
|
||||
+from websockets import InvalidState
|
||||
+from websockets.extensions.permessage_deflate import ServerPerMessageDeflateFactory
|
||||
+from websockets.frames import Frame, Opcode
|
||||
+from websockets.http11 import Request
|
||||
+from websockets.server import ServerProtocol
|
||||
+
|
||||
+from uvicorn._types import (
|
||||
+ ASGIReceiveEvent,
|
||||
+ ASGISendEvent,
|
||||
+ WebSocketAcceptEvent,
|
||||
+ WebSocketCloseEvent,
|
||||
+ WebSocketDisconnectEvent,
|
||||
+ WebSocketReceiveEvent,
|
||||
+ WebSocketResponseBodyEvent,
|
||||
+ WebSocketResponseStartEvent,
|
||||
+ WebSocketScope,
|
||||
+ WebSocketSendEvent,
|
||||
+)
|
||||
+from uvicorn.config import Config
|
||||
+from uvicorn.logging import TRACE_LOG_LEVEL
|
||||
+from uvicorn.protocols.utils import (
|
||||
+ ClientDisconnected,
|
||||
+ get_local_addr,
|
||||
+ get_path_with_query_string,
|
||||
+ get_remote_addr,
|
||||
+ is_ssl,
|
||||
+)
|
||||
+from uvicorn.server import ServerState
|
||||
+
|
||||
+
|
||||
+class WebSocketsSansIOProtocol(asyncio.Protocol):
|
||||
+ def __init__(
|
||||
+ self,
|
||||
+ config: Config,
|
||||
+ server_state: ServerState,
|
||||
+ app_state: dict[str, Any],
|
||||
+ _loop: asyncio.AbstractEventLoop | None = None,
|
||||
+ ) -> None:
|
||||
+ if not config.loaded:
|
||||
+ config.load() # pragma: no cover
|
||||
+
|
||||
+ self.config = config
|
||||
+ self.app = config.loaded_app
|
||||
+ self.loop = _loop or asyncio.get_event_loop()
|
||||
+ self.logger = logging.getLogger("uvicorn.error")
|
||||
+ self.root_path = config.root_path
|
||||
+ self.app_state = app_state
|
||||
+
|
||||
+ # Shared server state
|
||||
+ self.connections = server_state.connections
|
||||
+ self.tasks = server_state.tasks
|
||||
+ self.default_headers = server_state.default_headers
|
||||
+
|
||||
+ # Connection state
|
||||
+ self.transport: asyncio.Transport = None # type: ignore[assignment]
|
||||
+ self.server: tuple[str, int] | None = None
|
||||
+ self.client: tuple[str, int] | None = None
|
||||
+ self.scheme: Literal["wss", "ws"] = None # type: ignore[assignment]
|
||||
+
|
||||
+ # WebSocket state
|
||||
+ self.queue: asyncio.Queue[ASGIReceiveEvent] = asyncio.Queue()
|
||||
+ self.handshake_initiated = False
|
||||
+ self.handshake_complete = False
|
||||
+ self.close_sent = False
|
||||
+ self.initial_response: tuple[int, list[tuple[str, str]], bytes] | None = None
|
||||
+
|
||||
+ extensions = []
|
||||
+ if self.config.ws_per_message_deflate:
|
||||
+ extensions = [ServerPerMessageDeflateFactory()]
|
||||
+ self.conn = ServerProtocol(
|
||||
+ extensions=extensions,
|
||||
+ max_size=self.config.ws_max_size,
|
||||
+ logger=logging.getLogger("uvicorn.error"),
|
||||
+ )
|
||||
+
|
||||
+ self.read_paused = False
|
||||
+ self.writable = asyncio.Event()
|
||||
+ self.writable.set()
|
||||
+
|
||||
+ # Buffers
|
||||
+ self.bytes = b""
|
||||
+
|
||||
+ def connection_made(self, transport: BaseTransport) -> None:
|
||||
+ """Called when a connection is made."""
|
||||
+ transport = cast(Transport, transport)
|
||||
+ self.connections.add(self)
|
||||
+ self.transport = transport
|
||||
+ self.server = get_local_addr(transport)
|
||||
+ self.client = get_remote_addr(transport)
|
||||
+ self.scheme = "wss" if is_ssl(transport) else "ws"
|
||||
+
|
||||
+ if self.logger.level <= TRACE_LOG_LEVEL:
|
||||
+ prefix = "%s:%d - " % self.client if self.client else ""
|
||||
+ self.logger.log(TRACE_LOG_LEVEL, "%sWebSocket connection made", prefix)
|
||||
+
|
||||
+ def connection_lost(self, exc: Exception | None) -> None:
|
||||
+ code = 1005 if self.handshake_complete else 1006
|
||||
+ self.queue.put_nowait({"type": "websocket.disconnect", "code": code})
|
||||
+ self.connections.remove(self)
|
||||
+
|
||||
+ if self.logger.level <= TRACE_LOG_LEVEL:
|
||||
+ prefix = "%s:%d - " % self.client if self.client else ""
|
||||
+ self.logger.log(TRACE_LOG_LEVEL, "%sWebSocket connection lost", prefix)
|
||||
+
|
||||
+ self.handshake_complete = True
|
||||
+ if exc is None:
|
||||
+ self.transport.close()
|
||||
+
|
||||
+ def eof_received(self) -> None:
|
||||
+ pass
|
||||
+
|
||||
+ def shutdown(self) -> None:
|
||||
+ if self.handshake_complete:
|
||||
+ self.queue.put_nowait({"type": "websocket.disconnect", "code": 1012})
|
||||
+ self.conn.send_close(1012)
|
||||
+ output = self.conn.data_to_send()
|
||||
+ self.transport.write(b"".join(output))
|
||||
+ else:
|
||||
+ self.send_500_response()
|
||||
+ self.transport.close()
|
||||
+
|
||||
+ def data_received(self, data: bytes) -> None:
|
||||
+ self.conn.receive_data(data)
|
||||
+ parser_exc = self.conn.parser_exc
|
||||
+ if parser_exc is not None:
|
||||
+ self.handle_parser_exception()
|
||||
+ return
|
||||
+ self.handle_events()
|
||||
+
|
||||
+ def handle_events(self) -> None:
|
||||
+ for event in self.conn.events_received():
|
||||
+ if isinstance(event, Request):
|
||||
+ self.handle_connect(event)
|
||||
+ if isinstance(event, Frame):
|
||||
+ if event.opcode == Opcode.CONT:
|
||||
+ self.handle_cont(event)
|
||||
+ elif event.opcode == Opcode.TEXT:
|
||||
+ self.handle_text(event)
|
||||
+ elif event.opcode == Opcode.BINARY:
|
||||
+ self.handle_bytes(event)
|
||||
+ elif event.opcode == Opcode.PING:
|
||||
+ self.handle_ping(event)
|
||||
+ elif event.opcode == Opcode.CLOSE:
|
||||
+ self.handle_close(event)
|
||||
+
|
||||
+ # Event handlers
|
||||
+
|
||||
+ def handle_connect(self, event: Request) -> None:
|
||||
+ self.request = event
|
||||
+ self.response = self.conn.accept(event)
|
||||
+ self.handshake_initiated = True
|
||||
+ if self.response.status_code != 101:
|
||||
+ self.handshake_complete = True
|
||||
+ self.close_sent = True
|
||||
+ self.conn.send_response(self.response)
|
||||
+ output = self.conn.data_to_send()
|
||||
+ self.transport.write(b"".join(output))
|
||||
+ self.transport.close()
|
||||
+ return
|
||||
+
|
||||
+ headers = [
|
||||
+ (key.encode("ascii"), value.encode("ascii", errors="surrogateescape"))
|
||||
+ for key, value in event.headers.raw_items()
|
||||
+ ]
|
||||
+ raw_path, _, query_string = event.path.partition("?")
|
||||
+ self.scope: WebSocketScope = {
|
||||
+ "type": "websocket",
|
||||
+ "asgi": {"version": self.config.asgi_version, "spec_version": "2.3"},
|
||||
+ "http_version": "1.1",
|
||||
+ "scheme": self.scheme,
|
||||
+ "server": self.server,
|
||||
+ "client": self.client,
|
||||
+ "root_path": self.root_path,
|
||||
+ "path": unquote(raw_path),
|
||||
+ "raw_path": raw_path.encode("ascii"),
|
||||
+ "query_string": query_string.encode("ascii"),
|
||||
+ "headers": headers,
|
||||
+ "subprotocols": event.headers.get_all("Sec-WebSocket-Protocol"),
|
||||
+ "state": self.app_state.copy(),
|
||||
+ "extensions": {"websocket.http.response": {}},
|
||||
+ }
|
||||
+ self.queue.put_nowait({"type": "websocket.connect"})
|
||||
+ task = self.loop.create_task(self.run_asgi())
|
||||
+ task.add_done_callback(self.on_task_complete)
|
||||
+ self.tasks.add(task)
|
||||
+
|
||||
+ def handle_cont(self, event: Frame) -> None:
|
||||
+ self.bytes += event.data
|
||||
+ if event.fin:
|
||||
+ self.send_receive_event_to_app()
|
||||
+
|
||||
+ def handle_text(self, event: Frame) -> None:
|
||||
+ self.bytes = event.data
|
||||
+ self.curr_msg_data_type: Literal["text", "bytes"] = "text"
|
||||
+ if event.fin:
|
||||
+ self.send_receive_event_to_app()
|
||||
+
|
||||
+ def handle_bytes(self, event: Frame) -> None:
|
||||
+ self.bytes = event.data
|
||||
+ self.curr_msg_data_type = "bytes"
|
||||
+ if event.fin:
|
||||
+ self.send_receive_event_to_app()
|
||||
+
|
||||
+ def send_receive_event_to_app(self) -> None:
|
||||
+ data_type = self.curr_msg_data_type
|
||||
+ msg: WebSocketReceiveEvent
|
||||
+ if data_type == "text":
|
||||
+ msg = {"type": "websocket.receive", data_type: self.bytes.decode()}
|
||||
+ else:
|
||||
+ msg = {"type": "websocket.receive", data_type: self.bytes}
|
||||
+ self.queue.put_nowait(msg)
|
||||
+ if not self.read_paused:
|
||||
+ self.read_paused = True
|
||||
+ self.transport.pause_reading()
|
||||
+
|
||||
+ def handle_ping(self, event: Frame) -> None:
|
||||
+ output = self.conn.data_to_send()
|
||||
+ self.transport.write(b"".join(output))
|
||||
+
|
||||
+ def handle_close(self, event: Frame) -> None:
|
||||
+ if not self.close_sent and not self.transport.is_closing():
|
||||
+ disconnect_event: WebSocketDisconnectEvent = {
|
||||
+ "type": "websocket.disconnect",
|
||||
+ "code": self.conn.close_rcvd.code, # type: ignore[union-attr]
|
||||
+ "reason": self.conn.close_rcvd.reason, # type: ignore[union-attr]
|
||||
+ }
|
||||
+ self.queue.put_nowait(disconnect_event)
|
||||
+ output = self.conn.data_to_send()
|
||||
+ self.transport.write(b"".join(output))
|
||||
+ self.transport.close()
|
||||
+
|
||||
+ def handle_parser_exception(self) -> None:
|
||||
+ disconnect_event: WebSocketDisconnectEvent = {
|
||||
+ "type": "websocket.disconnect",
|
||||
+ "code": self.conn.close_sent.code, # type: ignore[union-attr]
|
||||
+ "reason": self.conn.close_sent.reason, # type: ignore[union-attr]
|
||||
+ }
|
||||
+ self.queue.put_nowait(disconnect_event)
|
||||
+ output = self.conn.data_to_send()
|
||||
+ self.transport.write(b"".join(output))
|
||||
+ self.close_sent = True
|
||||
+ self.transport.close()
|
||||
+
|
||||
+ def on_task_complete(self, task: asyncio.Task[None]) -> None:
|
||||
+ self.tasks.discard(task)
|
||||
+
|
||||
+ async def run_asgi(self) -> None:
|
||||
+ try:
|
||||
+ result = await self.app(self.scope, self.receive, self.send)
|
||||
+ except ClientDisconnected:
|
||||
+ self.transport.close()
|
||||
+ except BaseException:
|
||||
+ self.logger.exception("Exception in ASGI application\n")
|
||||
+ self.send_500_response()
|
||||
+ self.transport.close()
|
||||
+ else:
|
||||
+ if not self.handshake_complete:
|
||||
+ msg = "ASGI callable returned without completing handshake."
|
||||
+ self.logger.error(msg)
|
||||
+ self.send_500_response()
|
||||
+ self.transport.close()
|
||||
+ elif result is not None:
|
||||
+ msg = "ASGI callable should return None, but returned '%s'."
|
||||
+ self.logger.error(msg, result)
|
||||
+ self.transport.close()
|
||||
+
|
||||
+ def send_500_response(self) -> None:
|
||||
+ if self.initial_response or self.handshake_complete:
|
||||
+ return
|
||||
+ response = self.conn.reject(500, "Internal Server Error")
|
||||
+ self.conn.send_response(response)
|
||||
+ output = self.conn.data_to_send()
|
||||
+ self.transport.write(b"".join(output))
|
||||
+
|
||||
+ async def send(self, message: ASGISendEvent) -> None:
|
||||
+ await self.writable.wait()
|
||||
+
|
||||
+ message_type = message["type"]
|
||||
+
|
||||
+ if not self.handshake_complete and self.initial_response is None:
|
||||
+ if message_type == "websocket.accept":
|
||||
+ message = cast(WebSocketAcceptEvent, message)
|
||||
+ self.logger.info(
|
||||
+ '%s - "WebSocket %s" [accepted]',
|
||||
+ self.scope["client"],
|
||||
+ get_path_with_query_string(self.scope),
|
||||
+ )
|
||||
+ headers = [
|
||||
+ (name.decode("latin-1").lower(), value.decode("latin-1").lower())
|
||||
+ for name, value in (self.default_headers + list(message.get("headers", [])))
|
||||
+ ]
|
||||
+ accepted_subprotocol = message.get("subprotocol")
|
||||
+ if accepted_subprotocol:
|
||||
+ headers.append(("Sec-WebSocket-Protocol", accepted_subprotocol))
|
||||
+ self.response.headers.update(headers)
|
||||
+
|
||||
+ if not self.transport.is_closing():
|
||||
+ self.handshake_complete = True
|
||||
+ self.conn.send_response(self.response)
|
||||
+ output = self.conn.data_to_send()
|
||||
+ self.transport.write(b"".join(output))
|
||||
+
|
||||
+ elif message_type == "websocket.close":
|
||||
+ message = cast(WebSocketCloseEvent, message)
|
||||
+ self.queue.put_nowait({"type": "websocket.disconnect", "code": 1006})
|
||||
+ self.logger.info(
|
||||
+ '%s - "WebSocket %s" 403',
|
||||
+ self.scope["client"],
|
||||
+ get_path_with_query_string(self.scope),
|
||||
+ )
|
||||
+ response = self.conn.reject(HTTPStatus.FORBIDDEN, "")
|
||||
+ self.conn.send_response(response)
|
||||
+ output = self.conn.data_to_send()
|
||||
+ self.close_sent = True
|
||||
+ self.handshake_complete = True
|
||||
+ self.transport.write(b"".join(output))
|
||||
+ self.transport.close()
|
||||
+ elif message_type == "websocket.http.response.start" and self.initial_response is None:
|
||||
+ message = cast(WebSocketResponseStartEvent, message)
|
||||
+ if not (100 <= message["status"] < 600):
|
||||
+ raise RuntimeError("Invalid HTTP status code '%d' in response." % message["status"])
|
||||
+ self.logger.info(
|
||||
+ '%s - "WebSocket %s" %d',
|
||||
+ self.scope["client"],
|
||||
+ get_path_with_query_string(self.scope),
|
||||
+ message["status"],
|
||||
+ )
|
||||
+ headers = [
|
||||
+ (name.decode("latin-1"), value.decode("latin-1"))
|
||||
+ for name, value in list(message.get("headers", []))
|
||||
+ ]
|
||||
+ self.initial_response = (message["status"], headers, b"")
|
||||
+ else:
|
||||
+ msg = (
|
||||
+ "Expected ASGI message 'websocket.accept', 'websocket.close' "
|
||||
+ "or 'websocket.http.response.start' "
|
||||
+ "but got '%s'."
|
||||
+ )
|
||||
+ raise RuntimeError(msg % message_type)
|
||||
+
|
||||
+ elif not self.close_sent and self.initial_response is None:
|
||||
+ try:
|
||||
+ if message_type == "websocket.send":
|
||||
+ message = cast(WebSocketSendEvent, message)
|
||||
+ bytes_data = message.get("bytes")
|
||||
+ text_data = message.get("text")
|
||||
+ if text_data:
|
||||
+ self.conn.send_text(text_data.encode())
|
||||
+ elif bytes_data:
|
||||
+ self.conn.send_binary(bytes_data)
|
||||
+ output = self.conn.data_to_send()
|
||||
+ self.transport.write(b"".join(output))
|
||||
+
|
||||
+ elif message_type == "websocket.close" and not self.transport.is_closing():
|
||||
+ message = cast(WebSocketCloseEvent, message)
|
||||
+ code = message.get("code", 1000)
|
||||
+ reason = message.get("reason", "") or ""
|
||||
+ self.queue.put_nowait({"type": "websocket.disconnect", "code": code})
|
||||
+ self.conn.send_close(code, reason)
|
||||
+ output = self.conn.data_to_send()
|
||||
+ self.transport.write(b"".join(output))
|
||||
+ self.close_sent = True
|
||||
+ self.transport.close()
|
||||
+ else:
|
||||
+ msg = "Expected ASGI message 'websocket.send' or 'websocket.close'," " but got '%s'."
|
||||
+ raise RuntimeError(msg % message_type)
|
||||
+ except InvalidState:
|
||||
+ raise ClientDisconnected()
|
||||
+ elif self.initial_response is not None:
|
||||
+ if message_type == "websocket.http.response.body":
|
||||
+ message = cast(WebSocketResponseBodyEvent, message)
|
||||
+ body = self.initial_response[2] + message["body"]
|
||||
+ self.initial_response = self.initial_response[:2] + (body,)
|
||||
+ if not message.get("more_body", False):
|
||||
+ response = self.conn.reject(self.initial_response[0], body.decode())
|
||||
+ response.headers.update(self.initial_response[1])
|
||||
+ self.queue.put_nowait({"type": "websocket.disconnect", "code": 1006})
|
||||
+ self.conn.send_response(response)
|
||||
+ output = self.conn.data_to_send()
|
||||
+ self.close_sent = True
|
||||
+ self.transport.write(b"".join(output))
|
||||
+ self.transport.close()
|
||||
+ else:
|
||||
+ msg = "Expected ASGI message 'websocket.http.response.body' " "but got '%s'."
|
||||
+ raise RuntimeError(msg % message_type)
|
||||
+
|
||||
+ else:
|
||||
+ msg = "Unexpected ASGI message '%s', after sending 'websocket.close'."
|
||||
+ raise RuntimeError(msg % message_type)
|
||||
+
|
||||
+ async def receive(self) -> ASGIReceiveEvent:
|
||||
+ message = await self.queue.get()
|
||||
+ if self.read_paused and self.queue.empty():
|
||||
+ self.read_paused = False
|
||||
+ self.transport.resume_reading()
|
||||
+ return message
|
||||
diff --git a/uvicorn/server.py b/uvicorn/server.py
|
||||
index cca2e85..50c5ed2 100644
|
||||
--- a/uvicorn/server.py
|
||||
+++ b/uvicorn/server.py
|
||||
@@ -23,9 +23,10 @@ if TYPE_CHECKING:
|
||||
from uvicorn.protocols.http.h11_impl import H11Protocol
|
||||
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
|
||||
from uvicorn.protocols.websockets.websockets_impl import WebSocketProtocol
|
||||
+ from uvicorn.protocols.websockets.websockets_sansio_impl import WebSocketsSansIOProtocol
|
||||
from uvicorn.protocols.websockets.wsproto_impl import WSProtocol
|
||||
|
||||
- Protocols = Union[H11Protocol, HttpToolsProtocol, WSProtocol, WebSocketProtocol]
|
||||
+ Protocols = Union[H11Protocol, HttpToolsProtocol, WSProtocol, WebSocketProtocol, WebSocketsSansIOProtocol]
|
||||
|
||||
HANDLED_SIGNALS = (
|
||||
signal.SIGINT, # Unix signal 2. Sent by Ctrl+C.
|
|
@ -1,567 +0,0 @@
|
|||
diff --git a/requirements.txt b/requirements.txt
|
||||
index e26e6b3..b16569f 100644
|
||||
--- a/requirements.txt
|
||||
+++ b/requirements.txt
|
||||
@@ -7,7 +7,7 @@ h11 @ git+https://github.com/python-hyper/h11.git@master
|
||||
# Explicit optionals
|
||||
a2wsgi==1.10.7
|
||||
wsproto==1.2.0
|
||||
-websockets==13.1
|
||||
+websockets==14.1
|
||||
|
||||
# Packaging
|
||||
build==1.2.2.post1
|
||||
diff --git a/tests/middleware/test_logging.py b/tests/middleware/test_logging.py
|
||||
index 63d7daf..5aef174 100644
|
||||
--- a/tests/middleware/test_logging.py
|
||||
+++ b/tests/middleware/test_logging.py
|
||||
@@ -8,8 +8,7 @@ import typing
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
-import websockets
|
||||
-import websockets.client
|
||||
+from websockets.asyncio.client import connect
|
||||
|
||||
from tests.utils import run_server
|
||||
from uvicorn import Config
|
||||
@@ -107,8 +106,8 @@ async def test_trace_logging_on_ws_protocol(
|
||||
break
|
||||
|
||||
async def open_connection(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
- return websocket.open
|
||||
+ async with connect(url):
|
||||
+ return True
|
||||
|
||||
config = Config(
|
||||
app=websocket_app,
|
||||
diff --git a/tests/middleware/test_proxy_headers.py b/tests/middleware/test_proxy_headers.py
|
||||
index d300c45..4b5f195 100644
|
||||
--- a/tests/middleware/test_proxy_headers.py
|
||||
+++ b/tests/middleware/test_proxy_headers.py
|
||||
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING
|
||||
import httpx
|
||||
import httpx._transports.asgi
|
||||
import pytest
|
||||
-import websockets.client
|
||||
+from websockets.asyncio.client import connect
|
||||
|
||||
from tests.response import Response
|
||||
from tests.utils import run_server
|
||||
@@ -479,7 +479,7 @@ async def test_proxy_headers_websocket_x_forwarded_proto(
|
||||
async with run_server(config):
|
||||
url = f"ws://127.0.0.1:{unused_tcp_port}"
|
||||
headers = {X_FORWARDED_FOR: "1.2.3.4", X_FORWARDED_PROTO: forwarded_proto}
|
||||
- async with websockets.client.connect(url, extra_headers=headers) as websocket:
|
||||
+ async with connect(url, additional_headers=headers) as websocket:
|
||||
data = await websocket.recv()
|
||||
assert data == expected
|
||||
|
||||
diff --git a/tests/protocols/test_websocket.py b/tests/protocols/test_websocket.py
|
||||
index e728544..b9035ec 100644
|
||||
--- a/tests/protocols/test_websocket.py
|
||||
+++ b/tests/protocols/test_websocket.py
|
||||
@@ -12,6 +12,8 @@ import websockets.asyncio.client
|
||||
import websockets.client
|
||||
import websockets.exceptions
|
||||
from typing_extensions import TypedDict
|
||||
+from websockets.asyncio.client import ClientConnection, connect
|
||||
+from websockets.exceptions import ConnectionClosed, ConnectionClosedError, InvalidHandshake, InvalidStatus
|
||||
from websockets.extensions.permessage_deflate import ClientPerMessageDeflateFactory
|
||||
from websockets.typing import Subprotocol
|
||||
|
||||
@@ -130,8 +132,8 @@ async def test_accept_connection(ws_protocol_cls: WSProtocol, http_protocol_cls:
|
||||
await self.send({"type": "websocket.accept"})
|
||||
|
||||
async def open_connection(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
- return websocket.open
|
||||
+ async with connect(url):
|
||||
+ return True
|
||||
|
||||
config = Config(app=App, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
@@ -146,7 +148,7 @@ async def test_shutdown(ws_protocol_cls: WSProtocol, http_protocol_cls: HTTPProt
|
||||
|
||||
config = Config(app=App, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config) as server:
|
||||
- async with websockets.client.connect(f"ws://127.0.0.1:{unused_tcp_port}"):
|
||||
+ async with connect(f"ws://127.0.0.1:{unused_tcp_port}"):
|
||||
# Attempt shutdown while connection is still open
|
||||
await server.shutdown()
|
||||
|
||||
@@ -160,8 +162,8 @@ async def test_supports_permessage_deflate_extension(
|
||||
|
||||
async def open_connection(url: str):
|
||||
extension_factories = [ClientPerMessageDeflateFactory()]
|
||||
- async with websockets.client.connect(url, extensions=extension_factories) as websocket:
|
||||
- return [extension.name for extension in websocket.extensions]
|
||||
+ async with connect(url, extensions=extension_factories) as websocket:
|
||||
+ return [extension.name for extension in websocket.protocol.extensions]
|
||||
|
||||
config = Config(app=App, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
@@ -180,8 +182,8 @@ async def test_can_disable_permessage_deflate_extension(
|
||||
# enable per-message deflate on the client, so that we can check the server
|
||||
# won't support it when it's disabled.
|
||||
extension_factories = [ClientPerMessageDeflateFactory()]
|
||||
- async with websockets.client.connect(url, extensions=extension_factories) as websocket:
|
||||
- return [extension.name for extension in websocket.extensions]
|
||||
+ async with connect(url, extensions=extension_factories) as websocket:
|
||||
+ return [extension.name for extension in websocket.protocol.extensions]
|
||||
|
||||
config = Config(
|
||||
app=App,
|
||||
@@ -203,8 +205,8 @@ async def test_close_connection(ws_protocol_cls: WSProtocol, http_protocol_cls:
|
||||
|
||||
async def open_connection(url: str):
|
||||
try:
|
||||
- await websockets.client.connect(url)
|
||||
- except websockets.exceptions.InvalidHandshake:
|
||||
+ await connect(url)
|
||||
+ except InvalidHandshake:
|
||||
return False
|
||||
return True # pragma: no cover
|
||||
|
||||
@@ -224,8 +226,8 @@ async def test_headers(ws_protocol_cls: WSProtocol, http_protocol_cls: HTTPProto
|
||||
await self.send({"type": "websocket.accept"})
|
||||
|
||||
async def open_connection(url: str):
|
||||
- async with websockets.client.connect(url, extra_headers=[("username", "abraão")]) as websocket:
|
||||
- return websocket.open
|
||||
+ async with connect(url, additional_headers=[("username", "abraão")]):
|
||||
+ return True
|
||||
|
||||
config = Config(app=App, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
@@ -239,8 +241,9 @@ async def test_extra_headers(ws_protocol_cls: WSProtocol, http_protocol_cls: HTT
|
||||
await self.send({"type": "websocket.accept", "headers": [(b"extra", b"header")]})
|
||||
|
||||
async def open_connection(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
- return websocket.response_headers
|
||||
+ async with connect(url) as websocket:
|
||||
+ assert websocket.response
|
||||
+ return websocket.response.headers
|
||||
|
||||
config = Config(app=App, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
@@ -258,8 +261,8 @@ async def test_path_and_raw_path(ws_protocol_cls: WSProtocol, http_protocol_cls:
|
||||
await self.send({"type": "websocket.accept"})
|
||||
|
||||
async def open_connection(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
- return websocket.open
|
||||
+ async with connect(url):
|
||||
+ return True
|
||||
|
||||
config = Config(app=App, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
@@ -276,7 +279,7 @@ async def test_send_text_data_to_client(
|
||||
await self.send({"type": "websocket.send", "text": "123"})
|
||||
|
||||
async def get_data(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
+ async with connect(url) as websocket:
|
||||
return await websocket.recv()
|
||||
|
||||
config = Config(app=App, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
@@ -294,7 +297,7 @@ async def test_send_binary_data_to_client(
|
||||
await self.send({"type": "websocket.send", "bytes": b"123"})
|
||||
|
||||
async def get_data(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
+ async with connect(url) as websocket:
|
||||
return await websocket.recv()
|
||||
|
||||
config = Config(app=App, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
@@ -313,7 +316,7 @@ async def test_send_and_close_connection(
|
||||
await self.send({"type": "websocket.close"})
|
||||
|
||||
async def get_data(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
+ async with connect(url) as websocket:
|
||||
data = await websocket.recv()
|
||||
is_open = True
|
||||
try:
|
||||
@@ -342,7 +345,7 @@ async def test_send_text_data_to_server(
|
||||
await self.send({"type": "websocket.send", "text": _text})
|
||||
|
||||
async def send_text(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
+ async with connect(url) as websocket:
|
||||
await websocket.send("abc")
|
||||
return await websocket.recv()
|
||||
|
||||
@@ -365,7 +368,7 @@ async def test_send_binary_data_to_server(
|
||||
await self.send({"type": "websocket.send", "bytes": _bytes})
|
||||
|
||||
async def send_text(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
+ async with connect(url) as websocket:
|
||||
await websocket.send(b"abc")
|
||||
return await websocket.recv()
|
||||
|
||||
@@ -387,7 +390,7 @@ async def test_send_after_protocol_close(
|
||||
await self.send({"type": "websocket.send", "text": "123"})
|
||||
|
||||
async def get_data(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
+ async with connect(url) as websocket:
|
||||
data = await websocket.recv()
|
||||
is_open = True
|
||||
try:
|
||||
@@ -407,14 +410,14 @@ async def test_missing_handshake(ws_protocol_cls: WSProtocol, http_protocol_cls:
|
||||
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
|
||||
pass
|
||||
|
||||
- async def connect(url: str):
|
||||
- await websockets.client.connect(url)
|
||||
+ async def open_connection(url: str):
|
||||
+ await connect(url)
|
||||
|
||||
config = Config(app=app, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
- with pytest.raises(websockets.exceptions.InvalidStatusCode) as exc_info:
|
||||
- await connect(f"ws://127.0.0.1:{unused_tcp_port}")
|
||||
- assert exc_info.value.status_code == 500
|
||||
+ with pytest.raises(InvalidStatus) as exc_info:
|
||||
+ await open_connection(f"ws://127.0.0.1:{unused_tcp_port}")
|
||||
+ assert exc_info.value.response.status_code == 500
|
||||
|
||||
|
||||
async def test_send_before_handshake(
|
||||
@@ -423,14 +426,14 @@ async def test_send_before_handshake(
|
||||
async def app(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
|
||||
await send({"type": "websocket.send", "text": "123"})
|
||||
|
||||
- async def connect(url: str):
|
||||
- await websockets.client.connect(url)
|
||||
+ async def open_connection(url: str):
|
||||
+ await connect(url)
|
||||
|
||||
config = Config(app=app, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
- with pytest.raises(websockets.exceptions.InvalidStatusCode) as exc_info:
|
||||
- await connect(f"ws://127.0.0.1:{unused_tcp_port}")
|
||||
- assert exc_info.value.status_code == 500
|
||||
+ with pytest.raises(InvalidStatus) as exc_info:
|
||||
+ await open_connection(f"ws://127.0.0.1:{unused_tcp_port}")
|
||||
+ assert exc_info.value.response.status_code == 500
|
||||
|
||||
|
||||
async def test_duplicate_handshake(ws_protocol_cls: WSProtocol, http_protocol_cls: HTTPProtocol, unused_tcp_port: int):
|
||||
@@ -440,10 +443,10 @@ async def test_duplicate_handshake(ws_protocol_cls: WSProtocol, http_protocol_cl
|
||||
|
||||
config = Config(app=app, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
- async with websockets.client.connect(f"ws://127.0.0.1:{unused_tcp_port}") as websocket:
|
||||
- with pytest.raises(websockets.exceptions.ConnectionClosed):
|
||||
+ async with connect(f"ws://127.0.0.1:{unused_tcp_port}") as websocket:
|
||||
+ with pytest.raises(ConnectionClosed):
|
||||
_ = await websocket.recv()
|
||||
- assert websocket.close_code == 1006
|
||||
+ assert websocket.protocol.close_code == 1006
|
||||
|
||||
|
||||
async def test_asgi_return_value(ws_protocol_cls: WSProtocol, http_protocol_cls: HTTPProtocol, unused_tcp_port: int):
|
||||
@@ -458,10 +461,10 @@ async def test_asgi_return_value(ws_protocol_cls: WSProtocol, http_protocol_cls:
|
||||
|
||||
config = Config(app=app, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
- async with websockets.client.connect(f"ws://127.0.0.1:{unused_tcp_port}") as websocket:
|
||||
- with pytest.raises(websockets.exceptions.ConnectionClosed):
|
||||
+ async with connect(f"ws://127.0.0.1:{unused_tcp_port}") as websocket:
|
||||
+ with pytest.raises(ConnectionClosed):
|
||||
_ = await websocket.recv()
|
||||
- assert websocket.close_code == 1006
|
||||
+ assert websocket.protocol.close_code == 1006
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [None, 1000, 1001])
|
||||
@@ -493,13 +496,13 @@ async def test_app_close(
|
||||
|
||||
config = Config(app=app, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
- async with websockets.client.connect(f"ws://127.0.0.1:{unused_tcp_port}") as websocket:
|
||||
+ async with connect(f"ws://127.0.0.1:{unused_tcp_port}") as websocket:
|
||||
await websocket.ping()
|
||||
await websocket.send("abc")
|
||||
- with pytest.raises(websockets.exceptions.ConnectionClosed):
|
||||
+ with pytest.raises(ConnectionClosed):
|
||||
await websocket.recv()
|
||||
- assert websocket.close_code == (code or 1000)
|
||||
- assert websocket.close_reason == (reason or "")
|
||||
+ assert websocket.protocol.close_code == (code or 1000)
|
||||
+ assert websocket.protocol.close_reason == (reason or "")
|
||||
|
||||
|
||||
async def test_client_close(ws_protocol_cls: WSProtocol, http_protocol_cls: HTTPProtocol, unused_tcp_port: int):
|
||||
@@ -518,7 +521,7 @@ async def test_client_close(ws_protocol_cls: WSProtocol, http_protocol_cls: HTTP
|
||||
break
|
||||
|
||||
async def websocket_session(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
+ async with connect(url) as websocket:
|
||||
await websocket.ping()
|
||||
await websocket.send("abc")
|
||||
await websocket.close(code=1001, reason="custom reason")
|
||||
@@ -555,7 +558,7 @@ async def test_client_connection_lost(
|
||||
port=unused_tcp_port,
|
||||
)
|
||||
async with run_server(config):
|
||||
- async with websockets.client.connect(f"ws://127.0.0.1:{unused_tcp_port}") as websocket:
|
||||
+ async with connect(f"ws://127.0.0.1:{unused_tcp_port}") as websocket:
|
||||
websocket.transport.close()
|
||||
await asyncio.sleep(0.1)
|
||||
got_disconnect_event_before_shutdown = got_disconnect_event
|
||||
@@ -583,7 +586,7 @@ async def test_client_connection_lost_on_send(
|
||||
config = Config(app=app, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
url = f"ws://127.0.0.1:{unused_tcp_port}"
|
||||
- async with websockets.client.connect(url):
|
||||
+ async with connect(url):
|
||||
await asyncio.sleep(0.1)
|
||||
disconnect.set()
|
||||
|
||||
@@ -642,11 +645,11 @@ async def test_send_close_on_server_shutdown(
|
||||
disconnect_message = message
|
||||
break
|
||||
|
||||
- websocket: websockets.client.WebSocketClientProtocol | None = None
|
||||
+ websocket: ClientConnection | None = None
|
||||
|
||||
async def websocket_session(uri: str):
|
||||
nonlocal websocket
|
||||
- async with websockets.client.connect(uri) as ws_connection:
|
||||
+ async with connect(uri) as ws_connection:
|
||||
websocket = ws_connection
|
||||
await server_shutdown_event.wait()
|
||||
|
||||
@@ -676,9 +679,7 @@ async def test_subprotocols(
|
||||
await self.send({"type": "websocket.accept", "subprotocol": subprotocol})
|
||||
|
||||
async def get_subprotocol(url: str):
|
||||
- async with websockets.client.connect(
|
||||
- url, subprotocols=[Subprotocol("proto1"), Subprotocol("proto2")]
|
||||
- ) as websocket:
|
||||
+ async with connect(url, subprotocols=[Subprotocol("proto1"), Subprotocol("proto2")]) as websocket:
|
||||
return websocket.subprotocol
|
||||
|
||||
config = Config(app=App, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
@@ -688,7 +689,7 @@ async def test_subprotocols(
|
||||
|
||||
|
||||
MAX_WS_BYTES = 1024 * 1024 * 16
|
||||
-MAX_WS_BYTES_PLUS1 = MAX_WS_BYTES + 1
|
||||
+MAX_WS_BYTES_PLUS1 = MAX_WS_BYTES + 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -731,15 +732,15 @@ async def test_send_binary_data_to_server_bigger_than_default_on_websockets(
|
||||
port=unused_tcp_port,
|
||||
)
|
||||
async with run_server(config):
|
||||
- async with websockets.client.connect(f"ws://127.0.0.1:{unused_tcp_port}", max_size=client_size_sent) as ws:
|
||||
+ async with connect(f"ws://127.0.0.1:{unused_tcp_port}", max_size=client_size_sent) as ws:
|
||||
await ws.send(b"\x01" * client_size_sent)
|
||||
if expected_result == 0:
|
||||
data = await ws.recv()
|
||||
assert data == b"\x01" * client_size_sent
|
||||
else:
|
||||
- with pytest.raises(websockets.exceptions.ConnectionClosedError):
|
||||
+ with pytest.raises(ConnectionClosedError):
|
||||
await ws.recv()
|
||||
- assert ws.close_code == expected_result
|
||||
+ assert ws.protocol.close_code == expected_result
|
||||
|
||||
|
||||
async def test_server_reject_connection(
|
||||
@@ -764,10 +765,10 @@ async def test_server_reject_connection(
|
||||
disconnected_message = await receive()
|
||||
|
||||
async def websocket_session(url: str):
|
||||
- with pytest.raises(websockets.exceptions.InvalidStatusCode) as exc_info:
|
||||
- async with websockets.client.connect(url):
|
||||
+ with pytest.raises(InvalidStatus) as exc_info:
|
||||
+ async with connect(url):
|
||||
pass # pragma: no cover
|
||||
- assert exc_info.value.status_code == 403
|
||||
+ assert exc_info.value.response.status_code == 403
|
||||
|
||||
config = Config(app=app, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
@@ -937,10 +938,10 @@ async def test_server_reject_connection_with_invalid_msg(
|
||||
await send(message)
|
||||
|
||||
async def websocket_session(url: str):
|
||||
- with pytest.raises(websockets.exceptions.InvalidStatusCode) as exc_info:
|
||||
- async with websockets.client.connect(url):
|
||||
+ with pytest.raises(InvalidStatus) as exc_info:
|
||||
+ async with connect(url):
|
||||
pass # pragma: no cover
|
||||
- assert exc_info.value.status_code == 404
|
||||
+ assert exc_info.value.response.status_code == 404
|
||||
|
||||
config = Config(app=app, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
@@ -971,10 +972,10 @@ async def test_server_reject_connection_with_missing_body(
|
||||
# no further message
|
||||
|
||||
async def websocket_session(url: str):
|
||||
- with pytest.raises(websockets.exceptions.InvalidStatusCode) as exc_info:
|
||||
- async with websockets.client.connect(url):
|
||||
+ with pytest.raises(InvalidStatus) as exc_info:
|
||||
+ async with connect(url):
|
||||
pass # pragma: no cover
|
||||
- assert exc_info.value.status_code == 404
|
||||
+ assert exc_info.value.response.status_code == 404
|
||||
|
||||
config = Config(app=app, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
@@ -1014,17 +1015,17 @@ async def test_server_multiple_websocket_http_response_start_events(
|
||||
exception_message = str(exc)
|
||||
|
||||
async def websocket_session(url: str):
|
||||
- with pytest.raises(websockets.exceptions.InvalidStatusCode) as exc_info:
|
||||
- async with websockets.client.connect(url):
|
||||
+ with pytest.raises(InvalidStatus) as exc_info:
|
||||
+ async with connect(url):
|
||||
pass # pragma: no cover
|
||||
- assert exc_info.value.status_code == 404
|
||||
+ assert exc_info.value.response.status_code == 404
|
||||
|
||||
config = Config(app=app, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
await websocket_session(f"ws://127.0.0.1:{unused_tcp_port}")
|
||||
|
||||
assert exception_message == (
|
||||
- "Expected ASGI message 'websocket.http.response.body' but got " "'websocket.http.response.start'."
|
||||
+ "Expected ASGI message 'websocket.http.response.body' but got 'websocket.http.response.start'."
|
||||
)
|
||||
|
||||
|
||||
@@ -1053,7 +1054,7 @@ async def test_server_can_read_messages_in_buffer_after_close(
|
||||
|
||||
config = Config(app=App, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
- async with websockets.client.connect(f"ws://127.0.0.1:{unused_tcp_port}") as websocket:
|
||||
+ async with connect(f"ws://127.0.0.1:{unused_tcp_port}") as websocket:
|
||||
await websocket.send(b"abc")
|
||||
await websocket.send(b"abc")
|
||||
await websocket.send(b"abc")
|
||||
@@ -1070,8 +1071,9 @@ async def test_default_server_headers(
|
||||
await self.send({"type": "websocket.accept"})
|
||||
|
||||
async def open_connection(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
- return websocket.response_headers
|
||||
+ async with connect(url) as websocket:
|
||||
+ assert websocket.response
|
||||
+ return websocket.response.headers
|
||||
|
||||
config = Config(app=App, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
@@ -1085,8 +1087,9 @@ async def test_no_server_headers(ws_protocol_cls: WSProtocol, http_protocol_cls:
|
||||
await self.send({"type": "websocket.accept"})
|
||||
|
||||
async def open_connection(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
- return websocket.response_headers
|
||||
+ async with connect(url) as websocket:
|
||||
+ assert websocket.response
|
||||
+ return websocket.response.headers
|
||||
|
||||
config = Config(
|
||||
app=App,
|
||||
@@ -1108,8 +1111,9 @@ async def test_no_date_header_on_wsproto(http_protocol_cls: HTTPProtocol, unused
|
||||
await self.send({"type": "websocket.accept"})
|
||||
|
||||
async def open_connection(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
- return websocket.response_headers
|
||||
+ async with connect(url) as websocket:
|
||||
+ assert websocket.response
|
||||
+ return websocket.response.headers
|
||||
|
||||
config = Config(
|
||||
app=App,
|
||||
@@ -1140,8 +1144,9 @@ async def test_multiple_server_header(
|
||||
)
|
||||
|
||||
async def open_connection(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
- return websocket.response_headers
|
||||
+ async with connect(url) as websocket:
|
||||
+ assert websocket.response
|
||||
+ return websocket.response.headers
|
||||
|
||||
config = Config(app=App, ws=ws_protocol_cls, http=http_protocol_cls, lifespan="off", port=unused_tcp_port)
|
||||
async with run_server(config):
|
||||
@@ -1176,8 +1181,8 @@ async def test_lifespan_state(ws_protocol_cls: WSProtocol, http_protocol_cls: HT
|
||||
await self.send({"type": "websocket.accept"})
|
||||
|
||||
async def open_connection(url: str):
|
||||
- async with websockets.client.connect(url) as websocket:
|
||||
- return websocket.open
|
||||
+ async with connect(url):
|
||||
+ return True
|
||||
|
||||
async def app_wrapper(scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable):
|
||||
if scope["type"] == "lifespan":
|
||||
diff --git a/uvicorn/protocols/websockets/websockets_impl.py b/uvicorn/protocols/websockets/websockets_impl.py
|
||||
index cd6c54f..685d6b6 100644
|
||||
--- a/uvicorn/protocols/websockets/websockets_impl.py
|
||||
+++ b/uvicorn/protocols/websockets/websockets_impl.py
|
||||
@@ -13,8 +13,7 @@ from websockets.datastructures import Headers
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
from websockets.extensions.base import ServerExtensionFactory
|
||||
from websockets.extensions.permessage_deflate import ServerPerMessageDeflateFactory
|
||||
-from websockets.legacy.server import HTTPResponse
|
||||
-from websockets.server import WebSocketServerProtocol
|
||||
+from websockets.legacy.server import HTTPResponse, WebSocketServerProtocol
|
||||
from websockets.typing import Subprotocol
|
||||
|
||||
from uvicorn._types import (
|
||||
diff --git a/uvicorn/protocols/websockets/wsproto_impl.py b/uvicorn/protocols/websockets/wsproto_impl.py
|
||||
index 828afe5..5d84bff 100644
|
||||
--- a/uvicorn/protocols/websockets/wsproto_impl.py
|
||||
+++ b/uvicorn/protocols/websockets/wsproto_impl.py
|
||||
@@ -149,12 +149,13 @@ class WSProtocol(asyncio.Protocol):
|
||||
self.writable.set() # pragma: full coverage
|
||||
|
||||
def shutdown(self) -> None:
|
||||
- if self.handshake_complete:
|
||||
- self.queue.put_nowait({"type": "websocket.disconnect", "code": 1012})
|
||||
- output = self.conn.send(wsproto.events.CloseConnection(code=1012))
|
||||
- self.transport.write(output)
|
||||
- else:
|
||||
- self.send_500_response()
|
||||
+ if not self.response_started:
|
||||
+ if self.handshake_complete:
|
||||
+ self.queue.put_nowait({"type": "websocket.disconnect", "code": 1012})
|
||||
+ output = self.conn.send(wsproto.events.CloseConnection(code=1012))
|
||||
+ self.transport.write(output)
|
||||
+ else:
|
||||
+ self.send_500_response()
|
||||
self.transport.close()
|
||||
|
||||
def on_task_complete(self, task: asyncio.Task[None]) -> None:
|
||||
@@ -221,13 +222,15 @@ class WSProtocol(asyncio.Protocol):
|
||||
def send_500_response(self) -> None:
|
||||
if self.response_started or self.handshake_complete:
|
||||
return # we cannot send responses anymore
|
||||
+ reject_data = b"Internal Server Error"
|
||||
headers: list[tuple[bytes, bytes]] = [
|
||||
(b"content-type", b"text/plain; charset=utf-8"),
|
||||
+ (b"content-length", str(len(reject_data)).encode()),
|
||||
(b"connection", b"close"),
|
||||
(b"content-length", b"21"),
|
||||
]
|
||||
output = self.conn.send(wsproto.events.RejectConnection(status_code=500, headers=headers, has_body=True))
|
||||
- output += self.conn.send(wsproto.events.RejectData(data=b"Internal Server Error"))
|
||||
+ output += self.conn.send(wsproto.events.RejectData(data=reject_data))
|
||||
self.transport.write(output)
|
||||
|
||||
async def run_asgi(self) -> None:
|
|
@ -1,59 +0,0 @@
|
|||
maintainer="Michał Polański <michal@polanski.me>"
|
||||
pkgname=uvicorn
|
||||
pkgver=0.34.0
|
||||
pkgrel=0
|
||||
pkgdesc="Lightning-fast ASGI server"
|
||||
url="https://www.uvicorn.org/"
|
||||
license="BSD-3-Clause"
|
||||
# disable due to lack of support for websockets 14
|
||||
# https://gitlab.alpinelinux.org/alpine/aports/-/issues/16646
|
||||
arch="noarch"
|
||||
depends="py3-click py3-h11"
|
||||
makedepends="py3-gpep517 py3-hatchling"
|
||||
checkdepends="
|
||||
py3-a2wsgi
|
||||
py3-dotenv
|
||||
py3-httptools
|
||||
py3-httpx
|
||||
py3-pytest
|
||||
py3-pytest-mock
|
||||
py3-trustme
|
||||
py3-typing-extensions
|
||||
py3-watchfiles
|
||||
py3-websockets
|
||||
py3-wsproto
|
||||
py3-yaml
|
||||
"
|
||||
subpackages="$pkgname-pyc"
|
||||
source="https://github.com/encode/uvicorn/archive/$pkgver/uvicorn-$pkgver.tar.gz
|
||||
test_multiprocess.patch
|
||||
2540_add-websocketssansioprotocol.patch
|
||||
2541_bump-wesockets-on-requirements.patch
|
||||
fix-test-wsgi.patch
|
||||
"
|
||||
|
||||
build() {
|
||||
gpep517 build-wheel \
|
||||
--wheel-dir .dist \
|
||||
--output-fd 3 3>&1 >&2
|
||||
}
|
||||
|
||||
check() {
|
||||
python3 -m venv --clear --without-pip --system-site-packages .testenv
|
||||
.testenv/bin/python3 -m installer .dist/*.whl
|
||||
.testenv/bin/python3 -m pytest \
|
||||
-k "not test_close_connection_with_multiple_requests" # a known issue
|
||||
}
|
||||
|
||||
package() {
|
||||
python3 -m installer -d "$pkgdir" \
|
||||
.dist/uvicorn-$pkgver-py3-none-any.whl
|
||||
}
|
||||
|
||||
sha512sums="
|
||||
260782e385a2934049da8c474750958826afe1bfe23b38fe2f6420f355af7a537563f8fe6ac3830814c7469203703d10f4f9f3d6e53e79113bfd2fd34f7a7c72 uvicorn-0.34.0.tar.gz
|
||||
cfad91dd84f8974362f52d754d7a29f09d07927a46acaa0eb490b6115a5729d84d6df94fead10ccd4cce7f5ea376f1348b0f59daede661dd8373a3851c313c46 test_multiprocess.patch
|
||||
858e9a7baaf1c12e076aecd81aaaf622b35a59dcaabea4ee1bfc4cda704c9fe271b1cc616a5910d845393717e4989cecb3b04be249cb5d0df1001ec5224c293f 2540_add-websocketssansioprotocol.patch
|
||||
f8a8c190981b9070232ea985880685bc801947cc7f673d59abf73d3e68bc2e13515ad200232a1de2af0808bc85da48a341f57d47caf87bcc190bfdc3c45718e0 2541_bump-wesockets-on-requirements.patch
|
||||
379963f9ccbda013e4a0bc3441eee70a581c91f60206aedc15df6a8737950824b7cb8d867774fc415763449bb3e0bba66601e8551101bfc1741098acd035f0cc fix-test-wsgi.patch
|
||||
"
|
|
@ -1,13 +0,0 @@
|
|||
diff --git a/tests/middleware/test_wsgi.py.orig b/tests/middleware/test_wsgi.py
|
||||
index 6003f27..2750487 100644
|
||||
--- a/tests/middleware/test_wsgi.py.orig
|
||||
+++ b/tests/middleware/test_wsgi.py
|
||||
@@ -73,7 +73,7 @@ async def test_wsgi_post(wsgi_middleware: Callable) -> None:
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
|
||||
response = await client.post("/", json={"example": 123})
|
||||
assert response.status_code == 200
|
||||
- assert response.text == '{"example":123}'
|
||||
+ assert response.text == '{"example": 123}'
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
|
@ -1,14 +0,0 @@
|
|||
Wait a bit longer, otherwise the workers might
|
||||
not have time to finish restarting.
|
||||
|
||||
--- a/tests/supervisors/test_multiprocess.py
|
||||
+++ b/tests/supervisors/test_multiprocess.py
|
||||
@@ -132,7 +132,7 @@ def test_multiprocess_sighup() -> None:
|
||||
time.sleep(1)
|
||||
pids = [p.pid for p in supervisor.processes]
|
||||
supervisor.signal_queue.append(signal.SIGHUP)
|
||||
- time.sleep(1)
|
||||
+ time.sleep(3)
|
||||
assert pids != [p.pid for p in supervisor.processes]
|
||||
supervisor.signal_queue.append(signal.SIGINT)
|
||||
supervisor.join_all()
|
|
@ -49,9 +49,6 @@ package() {
|
|||
install -Dm644 "$builddir"/package.json -t "$pkgdir"/usr/lib/bundles/wikijs
|
||||
cp -aR "$builddir"/assets "$builddir"/server "$builddir"/node_modules "$pkgdir"/usr/lib/bundles/wikijs
|
||||
|
||||
# remove prebuilts
|
||||
rm -Rf "$pkgdir"/usr/lib/bundles/wikijs/node_modules/*/prebuilds
|
||||
|
||||
mkdir -p "$pkgdir"/var/lib/wikijs
|
||||
chown 5494:5494 "$pkgdir"/var/lib/wikijs
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue