From 4b605dc951f0900d5dc4bff41e3805cfdb0aecbd Mon Sep 17 00:00:00 2001 From: Aly Raffauf Date: Mon, 06 Apr 2026 14:20:55 +0000 Subject: [PATCH] flask -> quart --- Dockerfile | 2 +- README.md | 4 ++-- justfile | 2 +- main.py | 121 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------- pyproject.toml | 3 ++- uv.lock | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------ 6 file(s) changed, 163 insertion(s)(+), 82 deletion(s)(-) diff --git a/Dockerfile b/Dockerfile --- a/Dockerfile +++ b/Dockerfile @@ -31,4 +31,4 @@ EXPOSE 8000 -CMD ["uv", "run", "gunicorn", "main:app", "--bind", "0.0.0.0:8000", "--workers", "3", "--threads", "4", "--preload", "--timeout", "120"] +CMD ["uv", "run", "hypercorn", "main:app", "--bind", "0.0.0.0:8000", "--workers", "3"] diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ git clone https://github.com/alyraffauf/morsels.git cd morsel uv sync -uv run flask --app main run --debug +just dev ``` ## Architecture @@ -62,7 +62,7 @@ All configuration is automatic. On first run, morsels generates: -- `secrets.json` — Flask secret key and OAuth client signing key +- `secrets.json` — app secret key and OAuth client signing key - `morsel.db` — SQLite database for OAuth sessions Set `MORSEL_DATA_DIR` to control where these files are stored (default: current directory, `/data` in Docker). diff --git a/justfile b/justfile --- a/justfile +++ b/justfile @@ -3,7 +3,7 @@ # Run dev server with CSS watching dev: tailwindcss -i static/input.css -o static/tailwind.css --watch & - uv run flask --app main run --debug + uv run quart --app main run --reload # Build CSS for production build-css: diff --git a/main.py b/main.py --- a/main.py +++ b/main.py @@ -17,8 +17,12 @@ from atproto_client.exceptions import BadRequestError, NetworkError from atproto_identity.resolver import IdResolver from authlib.jose import JsonWebKey -from flask import ( - Flask, +from markupsafe import Markup +from pygments import highlight +from pygments.formatters import HtmlFormatter +from pygments.lexers import TextLexer, guess_lexer +from quart import ( + Quart, Response, abort, flash, @@ -30,10 +34,6 @@ session, url_for, ) -from markupsafe import Markup -from pygments import highlight -from pygments.formatters import HtmlFormatter -from pygments.lexers import TextLexer, guess_lexer from werkzeug.exceptions import HTTPException from werkzeug.wrappers import Response as WerkzeugResponse @@ -57,7 +57,7 @@ resolve_identity, ) -app = Flask(__name__) +app = Quart(__name__) DATA_DIR = os.environ.get("MORSEL_DATA_DIR", ".") load_config(app, data_dir=DATA_DIR) @@ -101,11 +101,14 @@ return (rv[0] if rv else None) if one else rv -with app.app_context(): - db = get_db() - with app.open_resource("schema.sql", mode="r") as f: - db.cursor().executescript(f.read()) - db.commit() +@app.before_serving +async def init_db() -> None: + async with app.app_context(): + db = get_db() + schema_path = os.path.join(os.path.dirname(__file__), "schema.sql") + with open(schema_path, "r") as f: + db.cursor().executescript(f.read()) + db.commit() # ============================================================================= @@ -114,7 +117,7 @@ @app.route("/avatar/") -def avatar_proxy(did: str) -> WerkzeugResponse: +async def avatar_proxy(did: str) -> WerkzeugResponse: now = time.time() if did in _avatar_cache: @@ -285,9 +288,9 @@ return authed_pds_request("POST", "com.atproto.repo.deleteRecord", body=body) -def check_csrf() -> None: +async def check_csrf() -> None: token = session.get("csrf_token") - submitted = request.form.get("csrf_token") + submitted = (await request.form).get("csrf_token") if not token or token != submitted: abort(400, "Invalid or missing security token. Please try again.") @@ -298,20 +301,20 @@ @app.teardown_appcontext -def close_db(exception: BaseException | None) -> None: +async def close_db(exception: BaseException | None) -> None: db = g.pop("db", None) if db is not None: db.close() @app.before_request -def ensure_csrf_token() -> None: +async def ensure_csrf_token() -> None: if "csrf_token" not in session: session["csrf_token"] = secrets.token_hex(32) @app.before_request -def load_logged_in_user() -> None: +async def load_logged_in_user() -> None: user_did = session.get("user_did") if user_did is None: g.user = None @@ -339,52 +342,52 @@ @app.errorhandler(403) -def forbidden(e: HTTPException) -> tuple[str, int]: +async def forbidden(e: HTTPException) -> tuple[str, int]: message = ( e.description if e.description != "Forbidden" else "You don't have permission to do that." ) - return render_template("error.html", code=403, message=message), 403 + return await render_template("error.html", code=403, message=message), 403 @app.errorhandler(400) -def bad_request(e: HTTPException) -> tuple[str, int]: +async def bad_request(e: HTTPException) -> tuple[str, int]: message = e.description if e.description != "Bad Request" else "Bad request." - return render_template("error.html", code=400, message=message), 400 + return await render_template("error.html", code=400, message=message), 400 @app.errorhandler(401) -def unauthorized(e: HTTPException) -> tuple[str, int]: +async def unauthorized(e: HTTPException) -> tuple[str, int]: message = ( e.description if e.description != "Unauthorized" else "You need to log in." ) - return render_template("error.html", code=401, message=message), 401 + return await render_template("error.html", code=401, message=message), 401 @app.errorhandler(404) -def not_found(e: HTTPException) -> tuple[str, int]: +async def not_found(e: HTTPException) -> tuple[str, int]: message = ( e.description if e.description != "Not Found" else "That page doesn't exist." ) - return render_template("error.html", code=404, message=message), 404 + return await render_template("error.html", code=404, message=message), 404 @app.errorhandler(500) -def internal_error(e: HTTPException) -> tuple[str, int]: - return render_template( +async def internal_error(e: HTTPException) -> tuple[str, int]: + return await render_template( "error.html", code=500, message="Something went wrong on our end." ), 500 @app.errorhandler(502) -def bad_gateway(e: HTTPException) -> tuple[str, int]: +async def bad_gateway(e: HTTPException) -> tuple[str, int]: message = ( e.description if e.description != "Bad Gateway" else "Couldn't reach the upstream server." ) - return render_template("error.html", code=502, message=message), 502 + return await render_template("error.html", code=502, message=message), 502 # ============================================================================= @@ -393,7 +396,7 @@ @app.route("/oauth-client-metadata.json") -def oauth_client_metadata() -> WerkzeugResponse: +async def oauth_client_metadata() -> WerkzeugResponse: app_url = request.url_root.replace("http://", "https://") client_id = f"{app_url}oauth-client-metadata.json" return jsonify( @@ -415,12 +418,12 @@ @app.route("/oauth/jwks.json") -def oauth_jwks() -> WerkzeugResponse: +async def oauth_jwks() -> WerkzeugResponse: return jsonify({"keys": [CLIENT_PUB_JWK]}) @app.route("/pygments.css") -def pygments_css() -> WerkzeugResponse: +async def pygments_css() -> WerkzeugResponse: css = HtmlFormatter(style="default").get_style_defs() resp = Response(css, mimetype="text/css") resp.headers["Cache-Control"] = "public, max-age=86400" @@ -433,12 +436,12 @@ @app.route("/oauth/login", methods=("GET", "POST")) -def oauth_login() -> WerkzeugResponse | str: +async def oauth_login() -> WerkzeugResponse | str: if request.method != "POST": return redirect(url_for("index")) - check_csrf() - username = request.form["username"] + await check_csrf() + username = (await request.form)["username"] username = regex.sub(r"[\p{C}]", "", username) if username.startswith("@"): username = username[1:] @@ -513,7 +516,7 @@ @app.route("/oauth/callback") -def oauth_callback() -> WerkzeugResponse: +async def oauth_callback() -> WerkzeugResponse: if request.args.get("error"): flash("Login was denied or failed. Please try again.", "error") return redirect(url_for("index")) @@ -588,7 +591,7 @@ @app.route("/oauth/logout") -def oauth_logout() -> WerkzeugResponse: +async def oauth_logout() -> WerkzeugResponse: if g.user: client_id, _ = compute_client_id(request.url_root) try: @@ -607,7 +610,7 @@ @app.route("/") -def index() -> str: +async def index() -> str: recent = fetch_recent_bites(limit=5) # Pre-populate bite cache from feed data @@ -623,21 +626,21 @@ }, now) if g.user: - return render_template( + return await render_template( "create.html", recent=recent, did=g.user["did"], handle=g.user["handle"] ) - return render_template("index.html", recent=recent) + return await render_template("index.html", recent=recent) @app.route("/b/new", methods=["POST"]) -def create_bite() -> WerkzeugResponse | str: +async def create_bite() -> WerkzeugResponse | str: if not g.user: return redirect(url_for("oauth_login")) - check_csrf() + await check_csrf() - content: str = request.form["content"] - title: str = request.form.get("title", "").strip() or "Untitled" + content: str = (await request.form)["content"] + title: str = (await request.form).get("title", "").strip() or "Untitled" did: str = g.user["did"] body = { @@ -662,7 +665,7 @@ @app.route("/u/") -def list_bites(identifier: str) -> WerkzeugResponse | str: +async def list_bites(identifier: str) -> WerkzeugResponse | str: result = require_identity(identifier, "list_bites") if not isinstance(result, tuple): return result @@ -670,7 +673,7 @@ pastes = fetch_bites(pds_url, did) - return render_template( + return await render_template( "list.html", pastes=pastes, did=did, @@ -680,7 +683,7 @@ @app.route("/b//") -def view_bite(identifier: str, rkey: str) -> WerkzeugResponse | str: +async def view_bite(identifier: str, rkey: str) -> WerkzeugResponse | str: result = require_identity(identifier, "view_bite", rkey=rkey) if not isinstance(result, tuple): return result @@ -737,7 +740,7 @@ if not already_indexed: replies.insert(0, pending) - return render_template( + return await render_template( "view.html", title=title, paste_html=highlight_code(content), @@ -754,17 +757,17 @@ @app.route("/b///reply", methods=["POST"]) -def create_reply(identifier: str, rkey: str) -> WerkzeugResponse: +async def create_reply(identifier: str, rkey: str) -> WerkzeugResponse: if not g.user: return redirect(url_for("oauth_login")) - check_csrf() + await check_csrf() - text: str = request.form["text"].strip() + text: str = (await request.form)["text"].strip() if not text: return redirect(url_for("view_bite", identifier=identifier, rkey=rkey)) - at_uri: str = request.form["at_uri"] - cid: str = request.form["cid"] + at_uri: str = (await request.form)["at_uri"] + cid: str = (await request.form)["cid"] body = { "repo": g.user["did"], @@ -800,10 +803,10 @@ @app.route("/b///delete", methods=["POST"]) -def delete_bite(identifier: str, rkey: str) -> WerkzeugResponse: +async def delete_bite(identifier: str, rkey: str) -> WerkzeugResponse: if not g.user: return redirect(url_for("oauth_login")) - check_csrf() + await check_csrf() if resolve_did(identifier) != g.user["did"]: abort(403, "You can only delete your own bites.") @@ -818,12 +821,12 @@ @app.route("/b///delete-reply", methods=["POST"]) -def delete_reply(identifier: str, rkey: str) -> WerkzeugResponse: +async def delete_reply(identifier: str, rkey: str) -> WerkzeugResponse: if not g.user: return redirect(url_for("oauth_login")) - check_csrf() + await check_csrf() - reply_rkey: str | None = request.form.get("reply_rkey") + reply_rkey: str | None = (await request.form).get("reply_rkey") if not reply_rkey: abort(400) diff --git a/pyproject.toml b/pyproject.toml --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,9 @@ "atproto>=0.0.65", "authlib>=1.6.9", "flask>=3.1.3", - "gunicorn>=25.3.0", + "hypercorn>=0.18.0", "pygments>=2.20.0", + "quart>=0.20.0", "regex>=2026.3.32", "requests-hardened>=1.2.0", ] diff --git a/uv.lock b/uv.lock --- a/uv.lock +++ b/uv.lock @@ -3,6 +3,15 @@ requires-python = ">=3.14" [[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] name = "annotated-types" version = "0.7.0" source = { registry = "https://pypi.org/simple" } @@ -247,24 +256,34 @@ ] [[package]] -name = "gunicorn" -version = "25.3.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c4/f4/e78fa054248fab913e2eab0332c6c2cb07421fca1ce56d8fe43b6aef57a4/gunicorn-25.3.0.tar.gz", hash = "sha256:f74e1b2f9f76f6cd1ca01198968bd2dd65830edc24b6e8e4d78de8320e2fe889", size = 634883, upload-time = "2026-03-27T00:00:26.092Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/c8/8aaf447698c4d59aa853fd318eed300b5c9e44459f242ab8ead6c9c09792/gunicorn-25.3.0-py3-none-any.whl", hash = "sha256:cacea387dab08cd6776501621c295a904fe8e3b7aae9a1a3cbb26f4e7ed54660", size = 208403, upload-time = "2026-03-27T00:00:27.386Z" }, -] - -[[package]] name = "h11" version = "0.16.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, ] [[package]] @@ -293,6 +312,30 @@ sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "hypercorn" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420, upload-time = "2025-11-08T13:54:04.78Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640, upload-time = "2025-11-08T13:54:03.202Z" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, ] [[package]] @@ -384,8 +427,9 @@ { name = "atproto" }, { name = "authlib" }, { name = "flask" }, - { name = "gunicorn" }, + { name = "hypercorn" }, { name = "pygments" }, + { name = "quart" }, { name = "regex" }, { name = "requests-hardened" }, ] @@ -395,19 +439,20 @@ { name = "atproto", specifier = ">=0.0.65" }, { name = "authlib", specifier = ">=1.6.9" }, { name = "flask", specifier = ">=3.1.3" }, - { name = "gunicorn", specifier = ">=25.3.0" }, + { name = "hypercorn", specifier = ">=0.18.0" }, { name = "pygments", specifier = ">=2.20.0" }, + { name = "quart", specifier = ">=0.20.0" }, { name = "regex", specifier = ">=2026.3.32" }, { name = "requests-hardened", specifier = ">=1.2.0" }, ] [[package]] -name = "packaging" -version = "26.0" +name = "priority" +version = "2.0.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792, upload-time = "2021-06-27T10:15:05.487Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946, upload-time = "2021-06-27T10:15:03.856Z" }, ] [[package]] @@ -480,6 +525,26 @@ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "quart" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiofiles" }, + { name = "blinker" }, + { name = "click" }, + { name = "flask" }, + { name = "hypercorn" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/9d/12e1143a5bd2ccc05c293a6f5ae1df8fd94a8fc1440ecc6c344b2b30ce13/quart-0.20.0.tar.gz", hash = "sha256:08793c206ff832483586f5ae47018c7e40bdd75d886fee3fabbdaa70c2cf505d", size = 63874, upload-time = "2024-12-23T13:53:05.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/e9/cc28f21f52913adf333f653b9e0a3bf9cb223f5083a26422968ba73edd8d/quart-0.20.0-py3-none-any.whl", hash = "sha256:003c08f551746710acb757de49d9b768986fd431517d0eb127380b656b98b8f1", size = 77960, upload-time = "2024-12-23T13:53:02.842Z" }, ] [[package]] @@ -598,4 +663,16 @@ sdist = { url = "https://files.pythonhosted.org/packages/b5/43/76ded108b296a49f52de6bac5192ca1c4be84e886f9b5c9ba8427d9694fd/werkzeug-3.1.7.tar.gz", hash = "sha256:fb8c01fe6ab13b9b7cdb46892b99b1d66754e1d7ab8e542e865ec13f526b5351", size = 875700, upload-time = "2026-03-24T01:08:07.687Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7f/b2/0bba9bbb4596d2d2f285a16c2ab04118f6b957d8441566e1abb892e6a6b2/werkzeug-3.1.7-py3-none-any.whl", hash = "sha256:4b314d81163a3e1a169b6a0be2a000a0e204e8873c5de6586f453c55688d422f", size = 226295, upload-time = "2026-03-24T01:08:06.133Z" }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116, upload-time = "2025-11-20T18:18:01.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, ] -- tangled.sh