commit cf8c763c3031b6123d3a05491ec4be880d8a1b8b
parent c0a11ab37b52e3bf0a8f1e5981500e09abb3c232
Author: Silas Brack <silasbrack@gmail.com>
Date: Mon, 20 Apr 2026 21:46:49 +0200
refactor: migrate from Python/Starlette to Rust/Axum
- Replace Python (Starlette, Jinja2, aiosqlite) with Rust (Axum, Askama, sqlx)
- Convert Jinja2 templates to Askama syntax
- Port all database queries to sqlx
- Add configurable PORT environment variable
- Update flake.nix for Rust toolchain with crane
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
56 files changed, 4098 insertions(+), 3688 deletions(-)
diff --git a/.env.example b/.env.example
@@ -1,10 +1,3 @@
-# Uvicorn settings
-UVICORN_RELOAD=true
-UVICORN_RELOAD_DIRS=src
-UVICORN_RELOAD_INCLUDES=*.html *.css *.json *.sql
-UVICORN_LOG_CONFIG=logging.local.json
-
# Application settings
DEBUG=true
-DATABASE_PATH=db.sqlite3
-
+DATABASE_URL=sqlite:./db.sqlite3
diff --git a/.gitignore b/.gitignore
@@ -1,17 +1,5 @@
-# Python-generated files
-__pycache__/
-*.py[oc]
-build/
-dist/
-wheels/
-*.egg-info/
-
-# Virtual environments
-.venv/
-
-# Caches
-.ruff_cache/
-.pytest_cache/
+# Rust
+/target/
# Sqlite
*.sqlite3*
@@ -25,3 +13,9 @@ result
# Environment
.env
+# IDE
+*.swp
+tags
+TAGS
+.idea/
+*.iml
diff --git a/.python-version b/.python-version
@@ -1 +0,0 @@
-3.13
diff --git a/Cargo.lock b/Cargo.lock
@@ -0,0 +1,2359 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "askama"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28"
+dependencies = [
+ "askama_derive",
+ "askama_escape",
+ "humansize",
+ "num-traits",
+ "percent-encoding",
+]
+
+[[package]]
+name = "askama_axum"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a41603f7cdbf5ac4af60760f17253eb6adf6ec5b6f14a7ed830cf687d375f163"
+dependencies = [
+ "askama",
+ "axum-core 0.4.5",
+ "http",
+]
+
+[[package]]
+name = "askama_derive"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83"
+dependencies = [
+ "askama_parser",
+ "basic-toml",
+ "mime",
+ "mime_guess",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn",
+]
+
+[[package]]
+name = "askama_escape"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341"
+
+[[package]]
+name = "askama_parser"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
+dependencies = [
+ "nom",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "atoi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core 0.5.6",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "rustversion",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "basic-toml"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "cc"
+version = "1.2.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chrono"
+version = "0.4.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5"
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "etcetera"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943"
+dependencies = [
+ "cfg-if",
+ "home",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flume"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spin",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-intrusive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
+dependencies = [
+ "futures-core",
+ "lock_api",
+ "parking_lot",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51"
+
+[[package]]
+name = "hashlink"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "home"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "http"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "humansize"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
+dependencies = [
+ "libm",
+]
+
+[[package]]
+name = "hyper"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "bytes",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "tokio",
+ "tower-service",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.0",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.95"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+dependencies = [
+ "spin",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.185"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libredox"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
+dependencies = [
+ "bitflags",
+ "libc",
+ "plain",
+ "redox_syscall 0.7.4",
+]
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "md-5"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
+dependencies = [
+ "cfg-if",
+ "digest",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "mio"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-bigint-dig"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7"
+dependencies = [
+ "lazy_static",
+ "libm",
+ "num-integer",
+ "num-iter",
+ "num-traits",
+ "rand",
+ "smallvec",
+ "zeroize",
+]
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.18",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkcs1"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
+dependencies = [
+ "der",
+ "pkcs8",
+ "spki",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
+
+[[package]]
+name = "rsa"
+version = "0.9.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
+dependencies = [
+ "const-oid",
+ "digest",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core",
+]
+
+[[package]]
+name = "simple-web-app"
+version = "0.1.0"
+dependencies = [
+ "askama",
+ "askama_axum",
+ "axum",
+ "chrono",
+ "dotenvy",
+ "futures",
+ "serde",
+ "sqlx",
+ "thiserror",
+ "tokio",
+ "tower-http",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "sqlx"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
+dependencies = [
+ "sqlx-core",
+ "sqlx-macros",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+]
+
+[[package]]
+name = "sqlx-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
+dependencies = [
+ "base64",
+ "bytes",
+ "chrono",
+ "crc",
+ "crossbeam-queue",
+ "either",
+ "event-listener",
+ "futures-core",
+ "futures-intrusive",
+ "futures-io",
+ "futures-util",
+ "hashbrown 0.15.5",
+ "hashlink",
+ "indexmap",
+ "log",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec",
+ "thiserror",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "sqlx-macros"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sqlx-core",
+ "sqlx-macros-core",
+ "syn",
+]
+
+[[package]]
+name = "sqlx-macros-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
+dependencies = [
+ "dotenvy",
+ "either",
+ "heck",
+ "hex",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx-core",
+ "sqlx-mysql",
+ "sqlx-postgres",
+ "sqlx-sqlite",
+ "syn",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "sqlx-mysql"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526"
+dependencies = [
+ "atoi",
+ "base64",
+ "bitflags",
+ "byteorder",
+ "bytes",
+ "chrono",
+ "crc",
+ "digest",
+ "dotenvy",
+ "either",
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-util",
+ "generic-array",
+ "hex",
+ "hkdf",
+ "hmac",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "rand",
+ "rsa",
+ "serde",
+ "sha1",
+ "sha2",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror",
+ "tracing",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-postgres"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46"
+dependencies = [
+ "atoi",
+ "base64",
+ "bitflags",
+ "byteorder",
+ "chrono",
+ "crc",
+ "dotenvy",
+ "etcetera",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "hex",
+ "hkdf",
+ "hmac",
+ "home",
+ "itoa",
+ "log",
+ "md-5",
+ "memchr",
+ "once_cell",
+ "rand",
+ "serde",
+ "serde_json",
+ "sha2",
+ "smallvec",
+ "sqlx-core",
+ "stringprep",
+ "thiserror",
+ "tracing",
+ "whoami",
+]
+
+[[package]]
+name = "sqlx-sqlite"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
+dependencies = [
+ "atoi",
+ "chrono",
+ "flume",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-intrusive",
+ "futures-util",
+ "libsqlite3-sys",
+ "log",
+ "percent-encoding",
+ "serde",
+ "serde_urlencoded",
+ "sqlx-core",
+ "thiserror",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "stringprep"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+ "unicode-properties",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-properties"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "whoami"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
+dependencies = [
+ "libredox",
+ "wasite",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.48"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
@@ -0,0 +1,19 @@
+[package]
+name = "simple-web-app"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+axum = "0.8"
+askama = { version = "0.12", features = ["with-axum"] }
+askama_axum = "0.4"
+sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "chrono"] }
+tokio = { version = "1", features = ["full"] }
+tower-http = { version = "0.6", features = ["fs", "trace"] }
+chrono = { version = "0.4", features = ["serde"] }
+serde = { version = "1", features = ["derive"] }
+dotenvy = "0.15"
+thiserror = "2"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+futures = "0.3"
diff --git a/README.md b/README.md
@@ -1,22 +1,93 @@
# Simple Web App
-## Development Guidelines
+A news aggregation web application built with Rust, Axum, Askama, and SQLite.
+
+## Tech Stack
-We use `uv2nix` to build a python package and a virtual environment with all its dependencies.
-Everything in the `src/` folder should get included in the built Python package.
-The entire web app logic should be contained within the Python package and consequently in the `src/` folder.
+- **Axum** - Web framework
+- **Askama** - Type-safe HTML templating
+- **SQLx** - Async SQL toolkit with compile-time checked queries
+- **HTMX** - Frontend interactivity without JavaScript frameworks
+- **PicoCSS** - Minimal CSS framework
-Configuration, on the other hand, should, whenever possible, be kept outside of the Python package.
-Keep the application itself configuration-agnostic whenever possible and choose to instead maintain the `.env.example` file containing a suggested default configuration for developers.
-Configuration should thus always be performed via environment variables.
-In this way and some others, we follow the principles of the [12-factor app](https://12factor.net/).
+## Development Guidelines
+
+Configuration should be performed via environment variables following the [12-factor app](https://12factor.net/) principles.
## Developer Setup
-Since configuration is all performed via environment variables and everything else is stored in the package, all you need to do to get started is:
-1. Run `cp .env.example .env`: Copy the example environment file. Since we're not really using any secrets, the default configuration should suffice for now.
-2. Load environment variables from the `.env` file. I have a function `loadenv` which does this for me. Lots of people do this automatically with [direnv](https://direnv.net/), and VS Code's Python extension does this automatically too.
-3. Run `uv sync` or `nix develop .#impure` followed by `uv sync` or `nix develop .#uv2nix`: Create a virtual environment and install the required dependencies.
-4. Run either `uv run simple-web-app` (if you used `uv sync` above) or `simple-web-app` (if you used the `.#uv2nix` approach): Run the server locally. The application should automatically create a database file (based on the value of `DATABASE_PATH` from your `.env` file) and apply the necessary migrations to it. By default, `uvicorn` will reload the web server whenever files in the `src/` folder change.
-5. Go to [localhost:8000](http://localhost:8000/) (assuming you didn't override the `UVICORN_PORT` variable in the `.env` file) to see the page.
+### With Nix (recommended)
+
+1. Copy the example environment file:
+ ```bash
+ cp .env.example .env
+ ```
+
+2. Enter the development shell:
+ ```bash
+ nix develop
+ ```
+
+3. Run the server:
+ ```bash
+ cargo run
+ ```
+
+4. For auto-reload during development:
+ ```bash
+ cargo watch -x run
+ ```
+
+### Without Nix
+
+1. Install Rust via [rustup](https://rustup.rs/)
+
+2. Copy the example environment file:
+ ```bash
+ cp .env.example .env
+ ```
+
+3. Run the server:
+ ```bash
+ cargo run
+ ```
+
+4. Go to [localhost:8000](http://localhost:8000/) to see the page.
+
+## Building for Production
+
+```bash
+nix build
+```
+
+Or without Nix:
+
+```bash
+cargo build --release
+```
+
+## Project Structure
+```
+simple-web-app/
+├── Cargo.toml # Rust dependencies
+├── flake.nix # Nix flake configuration
+├── migrations/ # SQLite migrations
+├── static/ # Static assets (CSS)
+├── templates/ # Askama HTML templates
+└── src/
+ ├── main.rs # Entry point
+ ├── config.rs # Environment configuration
+ ├── state.rs # Application state (DB pool)
+ ├── error.rs # Error types
+ ├── routes.rs # Route definitions
+ ├── extractors.rs # Custom Axum extractors
+ ├── templates.rs # Askama template structs
+ ├── handlers/ # Route handlers
+ │ ├── home.rs
+ │ ├── search.rs
+ │ └── settings.rs
+ └── db/ # Database layer
+ ├── models.rs
+ └── queries.rs
+```
diff --git a/flake.lock b/flake.lock
@@ -1,95 +1,60 @@
{
"nodes": {
- "nixpkgs": {
+ "crane": {
"locked": {
- "lastModified": 1757347588,
- "narHash": "sha256-tLdkkC6XnsY9EOZW9TlpesTclELy8W7lL2ClL+nma8o=",
- "owner": "NixOS",
- "repo": "nixpkgs",
- "rev": "b599843bad24621dcaa5ab60dac98f9b0eb1cabe",
+ "lastModified": 1776635034,
+ "narHash": "sha256-OEOJrT3ZfwbChzODfIH4GzlNTtOFuZFWPtW7jIeR8xU=",
+ "owner": "ipetkov",
+ "repo": "crane",
+ "rev": "dc7496d8ea6e526b1254b55d09b966e94673750f",
"type": "github"
},
"original": {
- "owner": "NixOS",
- "ref": "nixos-unstable",
- "repo": "nixpkgs",
+ "owner": "ipetkov",
+ "repo": "crane",
"type": "github"
}
},
- "pyproject-build-systems": {
- "inputs": {
- "nixpkgs": [
- "nixpkgs"
- ],
- "pyproject-nix": [
- "pyproject-nix"
- ],
- "uv2nix": [
- "uv2nix"
- ]
- },
- "locked": {
- "lastModified": 1757296493,
- "narHash": "sha256-6nzSZl28IwH2Vx8YSmd3t6TREHpDbKlDPK+dq1LKIZQ=",
- "owner": "pyproject-nix",
- "repo": "build-system-pkgs",
- "rev": "5b8e37fe0077db5c1df3a5ee90a651345f085d38",
- "type": "github"
- },
- "original": {
- "owner": "pyproject-nix",
- "repo": "build-system-pkgs",
- "type": "github"
- }
- },
- "pyproject-nix": {
- "inputs": {
- "nixpkgs": [
- "nixpkgs"
- ]
- },
+ "nixpkgs": {
"locked": {
- "lastModified": 1757246327,
- "narHash": "sha256-6pNlGhwOIMfhe/RLjHdpXveKS4FyLHvlGe+KtjDild4=",
- "owner": "pyproject-nix",
- "repo": "pyproject.nix",
- "rev": "8d77f342d66ad1601cdb9d97e9388b69f64d4c8e",
+ "lastModified": 1776548001,
+ "narHash": "sha256-ZSK0NL4a1BwVbbTBoSnWgbJy9HeZFXLYQizjb2DPF24=",
+ "owner": "NixOS",
+ "repo": "nixpkgs",
+ "rev": "b12141ef619e0a9c1c84dc8c684040326f27cdcc",
"type": "github"
},
"original": {
- "owner": "pyproject-nix",
- "repo": "pyproject.nix",
+ "owner": "NixOS",
+ "ref": "nixos-unstable",
+ "repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
+ "crane": "crane",
"nixpkgs": "nixpkgs",
- "pyproject-build-systems": "pyproject-build-systems",
- "pyproject-nix": "pyproject-nix",
- "uv2nix": "uv2nix"
+ "rust-overlay": "rust-overlay"
}
},
- "uv2nix": {
+ "rust-overlay": {
"inputs": {
"nixpkgs": [
"nixpkgs"
- ],
- "pyproject-nix": [
- "pyproject-nix"
]
},
"locked": {
- "lastModified": 1756973152,
- "narHash": "sha256-9JcKAA7T9J98LWdcxbXvmf+amQG3ZErxqQnBjEJI04I=",
- "owner": "pyproject-nix",
- "repo": "uv2nix",
- "rev": "64298e806f4a5f63a51c625edc100348138491aa",
+ "lastModified": 1776654897,
+ "narHash": "sha256-Vqi4AiJVCcBGn/RmBtRCgyH5rCxqm/w0xV9diJWF1Ic=",
+ "owner": "oxalica",
+ "repo": "rust-overlay",
+ "rev": "25d75be8139815a53560745fa060909777495105",
"type": "github"
},
"original": {
- "owner": "pyproject-nix",
- "repo": "uv2nix",
+ "owner": "oxalica",
+ "repo": "rust-overlay",
"type": "github"
}
}
diff --git a/flake.nix b/flake.nix
@@ -1,288 +1,185 @@
{
- description = "A Nix-flake-based Python development environment";
+ description = "A Nix-flake-based Rust web application";
+
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
-
- pyproject-nix = {
- url = "github:pyproject-nix/pyproject.nix";
- inputs.nixpkgs.follows = "nixpkgs";
- };
-
- uv2nix = {
- url = "github:pyproject-nix/uv2nix";
- inputs.pyproject-nix.follows = "pyproject-nix";
- inputs.nixpkgs.follows = "nixpkgs";
- };
-
- pyproject-build-systems = {
- url = "github:pyproject-nix/build-system-pkgs";
- inputs.pyproject-nix.follows = "pyproject-nix";
- inputs.uv2nix.follows = "uv2nix";
+ rust-overlay = {
+ url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
+ crane.url = "github:ipetkov/crane";
};
+
outputs =
{
self,
nixpkgs,
- uv2nix,
- pyproject-nix,
- pyproject-build-systems,
+ rust-overlay,
+ crane,
...
}:
let
inherit (nixpkgs) lib;
forAllSystems = lib.genAttrs lib.systems.flakeExposed;
- # Load a uv workspace from a workspace root.
- # Uv2nix treats all uv projects as workspace projects.
- workspace = uv2nix.lib.workspace.loadWorkspace { workspaceRoot = ./.; };
-
- # Create package overlay from workspace.
- overlay = workspace.mkPyprojectOverlay {
- sourcePreference = "wheel";
- };
-
- # Construct package set
- pythonSets = forAllSystems (
+ mkPkgs =
+ system:
+ import nixpkgs {
+ inherit system;
+ overlays = [ (import rust-overlay) ];
+ };
+ in
+ {
+ packages = forAllSystems (
system:
let
- pkgs = nixpkgs.legacyPackages.${system};
- inherit (pkgs) stdenv;
- baseSet = pkgs.callPackage pyproject-nix.build.packages {
- python = pkgs.python313;
- };
+ pkgs = mkPkgs system;
+ rustToolchain = pkgs.rust-bin.stable.latest.default;
+ craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
+
+ # Common arguments for crane builds
+ commonArgs = {
+ src = ./.;
+ pname = "simple-web-app";
+ version = "0.1.0";
+ strictDeps = true;
+
+ buildInputs = with pkgs; [
+ openssl
+ ] ++ lib.optionals pkgs.stdenv.isDarwin [
+ pkgs.darwin.apple_sdk.frameworks.Security
+ pkgs.darwin.apple_sdk.frameworks.SystemConfiguration
+ ];
- includeLoggingConfigOverride = _final: prev: {
- simple-web-app = prev.simple-web-app.overrideAttrs (old: {
- postInstall = ''
- mkdir -p $out/share
- cp ${./logging.json} $out/share/logging.json
- '';
- });
+ nativeBuildInputs = with pkgs; [
+ pkg-config
+ ];
};
- # Extend generated overlay with build fixups
- #
- # Uv2nix can only work with what it has, and uv.lock is missing essential metadata to perform some builds.
- # This is an additional overlay implementing build fixups.
- # See:
- # - https://pyproject-nix.github.io/uv2nix/FAQ.html
- pyprojectOverrides = final: prev: {
- # Implement build fixups here.
- # Note that uv2nix is _not_ using Nixpkgs buildPythonPackage.
- # It's using https://pyproject-nix.github.io/pyproject.nix/build.html
- simple-web-app = prev.simple-web-app.overrideAttrs (old: {
- passthru = old.passthru // {
- # Put all tests in the passthru.tests attribute set.
- # Nixpkgs also uses the passthru.tests mechanism for ofborg test discovery.
- #
- # For usage with Flakes we will refer to the passthru.tests attributes to construct the flake checks attribute set.
- tests =
- let
- # Construct a virtual environment with only the test dependency-group enabled.
- virtualenv = final.mkVirtualEnv "simple-web-app-pytest-env" {
- simple-web-app = [ "test" ];
- };
-
- in
- (old.tests or { })
- // {
- pytest = stdenv.mkDerivation {
- name = "${final.simple-web-app.name}-pytest";
- inherit (final.simple-web-app) src;
- nativeBuildInputs = [
- virtualenv
- ];
- dontConfigure = true;
-
- # Because this package is running tests, and not actually building the main package
- # the build phase is running the tests.
- #
- # In this particular example we also output a HTML coverage report, which is used as the build output.
- buildPhase = ''
- runHook preBuild
- pytest --cov tests --cov-report html
- runHook postBuild
- '';
+ # Build just the cargo dependencies
+ cargoArtifacts = craneLib.buildDepsOnly commonArgs;
- # Install the HTML coverage report into the build output.
- #
- # If you wanted to install multiple test output formats such as TAP outputs
- # you could make this derivation a multiple-output derivation.
- #
- # See https://nixos.org/manual/nixpkgs/stable/#chap-multiple-output for more information on multiple outputs.
- installPhase = ''
- runHook preInstall
- mv htmlcov $out
- runHook postInstall
- '';
- };
-
- };
- };
- });
- };
+ # Build the actual crate
+ simple-web-app = craneLib.buildPackage (
+ commonArgs
+ // {
+ inherit cargoArtifacts;
+ }
+ );
in
- # Use base package set from pyproject.nix builders
- baseSet.overrideScope (
- lib.composeManyExtensions [
- pyproject-build-systems.overlays.default
- overlay
- pyprojectOverrides
- includeLoggingConfigOverride
- ]
- )
+ {
+ default = simple-web-app;
+ inherit simple-web-app;
+ }
);
- # inherit (pkgs.callPackages pyproject-nix.build.util { }) mkApplication;
- in
- {
- # Package a virtual environment as our main application.
- #
- # Enable no optional dependencies for production build.
- packages = forAllSystems (system: {
- default = pythonSets.${system}.mkVirtualEnv "simple-web-app-env" workspace.deps.default;
- });
-
- # default = mkApplication {
- # venv = pythonSet.mkVirtualEnv "simple-web-app-env" workspace.deps.default;
- # package = pythonSet.hello-world;
- # };
-
# Make simple-web-app runnable with `nix run`
apps = forAllSystems (
system:
let
- appRoot = self.packages.${system}.default;
- appProgram = "${appRoot}/bin/simple-web-app";
+ pkgs = mkPkgs system;
in
{
default = {
type = "app";
- program = appProgram;
+ program = "${self.packages.${system}.default}/bin/simple-web-app";
};
}
);
- # Run the checks by running `nix flake check`
+ # Run checks with `nix flake check`
checks = forAllSystems (
system:
let
- pythonSet = pythonSets.${system};
+ pkgs = mkPkgs system;
+ rustToolchain = pkgs.rust-bin.stable.latest.default;
+ craneLib = (crane.mkLib pkgs).overrideToolchain rustToolchain;
+
+ commonArgs = {
+ src = ./.;
+ pname = "simple-web-app";
+ version = "0.1.0";
+ strictDeps = true;
+
+ buildInputs = with pkgs; [
+ openssl
+ ] ++ lib.optionals pkgs.stdenv.isDarwin [
+ pkgs.darwin.apple_sdk.frameworks.Security
+ pkgs.darwin.apple_sdk.frameworks.SystemConfiguration
+ ];
+
+ nativeBuildInputs = with pkgs; [
+ pkg-config
+ ];
+ };
+
+ cargoArtifacts = craneLib.buildDepsOnly commonArgs;
in
{
- inherit (pythonSet.simple-web-app.passthru.tests) pytest;
+ # Build the crate as part of checks
+ simple-web-app = self.packages.${system}.default;
+
+ # Run clippy
+ clippy = craneLib.cargoClippy (
+ commonArgs
+ // {
+ inherit cargoArtifacts;
+ cargoClippyExtraArgs = "--all-targets -- --deny warnings";
+ }
+ );
+
+ # Check formatting
+ fmt = craneLib.cargoFmt { src = ./.; };
+
+ # Run tests
+ test = craneLib.cargoTest (
+ commonArgs
+ // {
+ inherit cargoArtifacts;
+ }
+ );
}
);
- # This example provides two different modes of development:
- # - Impurely using uv to manage virtual environments
- # - Pure development using uv2nix to manage virtual environments
devShells = forAllSystems (
system:
let
- pkgs = nixpkgs.legacyPackages.${system};
- python = pkgs.python313;
+ pkgs = mkPkgs system;
+ rustToolchain = pkgs.rust-bin.stable.latest.default.override {
+ extensions = [
+ "rust-src"
+ "rust-analyzer"
+ ];
+ };
in
{
- # It is of course perfectly OK to keep using an impure virtualenv workflow and only use uv2nix to build packages.
- # This devShell simply adds Python and undoes the dependency leakage done by Nixpkgs Python infrastructure.
- impure = pkgs.mkShell {
- packages = [
- python
- pkgs.uv
- pkgs.ruff
- pkgs.ty
+ default = pkgs.mkShell {
+ packages = with pkgs; [
+ rustToolchain
+ pkg-config
+ openssl
+ sqlite
+
+ # Development tools
+ cargo-watch
+ cargo-edit
+ rust-analyzer
+ ] ++ lib.optionals pkgs.stdenv.isDarwin [
+ pkgs.darwin.apple_sdk.frameworks.Security
+ pkgs.darwin.apple_sdk.frameworks.SystemConfiguration
];
- env = {
- # Prevent uv from managing Python downloads
- UV_PYTHON_DOWNLOADS = "never";
- # Force uv to use nixpkgs Python interpreter
- UV_PYTHON = python.interpreter;
-
- LD_LIBRARY_PATH = "${pkgs.stdenv.cc.cc.lib}/lib/:/run/opengl-driver/lib/";
+ env = {
+ RUST_BACKTRACE = "1";
+ RUST_LOG = "debug";
};
+
shellHook = ''
- unset PYTHONPATH
+ echo "Rust development environment loaded"
+ echo "Run 'cargo run' to start the server"
+ echo "Run 'cargo watch -x run' for auto-reload"
'';
};
-
- # This devShell uses uv2nix to construct a virtual environment purely from Nix, using the same dependency specification as the application.
- # The notable difference is that we also apply another overlay here enabling editable mode ( https://setuptools.pypa.io/en/latest/userguide/development_mode.html ).
- #
- # This means that any changes done to your local files do not require a rebuild.
- #
- # Note: Editable package support is still unstable and subject to change.
- uv2nix =
- let
- # Create an overlay enabling editable mode for all local dependencies.
- editableOverlay = workspace.mkEditablePyprojectOverlay {
- # Use environment variable
- root = "$REPO_ROOT";
- # Optional: Only enable editable for these packages
- # members = [ "hello-world" ];
- };
-
- pythonSet = pythonSets.${system}.overrideScope editableOverlay;
-
- # Override previous set with our overrideable overlay.
- editablePythonSet = pythonSet.overrideScope (
- lib.composeManyExtensions [
- editableOverlay
-
- # Apply fixups for building an editable package of your workspace packages
- (final: prev: {
- simple-web-app = prev.simple-web-app.overrideAttrs (old: {
- # It's a good idea to filter the sources going into an editable build
- # so the editable package doesn't have to be rebuilt on every change.
- src = lib.fileset.toSource {
- root = old.src;
- fileset = lib.fileset.unions [
- (old.src + "/pyproject.toml")
- (old.src + "/README.md")
- (old.src + "/src/simple_web_app/__init__.py")
- ];
- };
- });
- })
- ]
- );
-
- # Build virtual environment, with local packages being editable.
- #
- # Enable all optional dependencies for development.
- virtualenv = editablePythonSet.mkVirtualEnv "simple-web-app-dev-env" workspace.deps.all;
-
- in
- pkgs.mkShell {
- packages = [
- virtualenv
- pkgs.uv
- pkgs.ruff
- pkgs.ty
- ];
-
- env = {
- # Don't create venv using uv
- UV_NO_SYNC = "1";
-
- # Force uv to use nixpkgs Python interpreter
- UV_PYTHON = python.interpreter;
-
- # Prevent uv from downloading managed Python's
- UV_PYTHON_DOWNLOADS = "never";
- };
-
- shellHook = ''
- # Undo dependency propagation by nixpkgs.
- unset PYTHONPATH
-
- # Get repository root using git. This is expanded at runtime by the editable `.pth` machinery.
- export REPO_ROOT=$(git rev-parse --show-toplevel)
- '';
- };
}
);
};
diff --git a/logging.json b/logging.json
@@ -1,59 +0,0 @@
-{
- "version": 1,
- "formatters": {
- "default": {
- "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
- },
- "extra": {
- "format": "%(asctime)-16s %(name)-8s %(filename)-12s %(lineno)-6s %(funcName)-30s %(levelname)-8s %(message)s",
- "datefmt": "%m-%d %H:%M:%S"
- },
- "json": {
- "()": "simple_web_app.log.JsonFormatter",
- "fmt_dict": {
- "level": "levelname",
- "message": "message",
- "loggerName": "name",
- "processName": "processName",
- "processID": "process",
- "threadName": "threadName",
- "threadID": "thread",
- "timestamp": "asctime"
- }
- }
- },
- "handlers": {
- "root": {
- "class": "logging.StreamHandler",
- "level": "WARN",
- "formatter": "json",
- "stream": "ext://sys.stdout"
- },
- "simple_web_app": {
- "class": "logging.StreamHandler",
- "level": "INFO",
- "formatter": "json",
- "stream": "ext://sys.stdout"
- },
- "uvicorn": {
- "class": "logging.StreamHandler",
- "level": "INFO",
- "formatter": "json",
- "stream": "ext://sys.stdout"
- }
- },
- "loggers": {
- "simple_web_app": {
- "level": "INFO",
- "handlers": ["simple_web_app"]
- },
- "uvicorn": {
- "level": "INFO",
- "handlers": ["uvicorn"]
- }
- },
- "root": {
- "level": "WARN",
- "handlers": ["root"]
- }
-}
diff --git a/logging.local.json b/logging.local.json
@@ -1,35 +0,0 @@
-{
- "version": 1,
- "formatters": {
- "default": {
- "format": "%(asctime)s - %(levelname)-5s - %(name)-20.20s - %(filename)s:%(lineno)s - %(funcName)s - %(message)s",
- "datefmt": "%m-%d %H:%M:%S"
- }
- },
- "handlers": {
- "default": {
- "class": "logging.StreamHandler",
- "level": "DEBUG",
- "formatter": "default",
- "stream": "ext://sys.stdout"
- }
- },
- "loggers": {
- "simple_web_app": {
- "level": "INFO",
- "handlers": ["default"]
- },
- "uvicorn": {
- "level": "INFO",
- "handlers": ["default"]
- },
- "aiosqlite": {
- "level": "DEBUG",
- "handlers": ["default"]
- }
- },
- "root": {
- "level": "WARN",
- "handlers": ["default"]
- }
-}
diff --git a/src/simple_web_app/migrations/20251001193400_add_news_table.sql b/migrations/20251001193400_add_news_table.sql
diff --git a/pyproject.toml b/pyproject.toml
@@ -1,56 +0,0 @@
-[project]
-name = "simple-web-app"
-version = "0.1.0"
-description = "Add your description here"
-readme = "README.md"
-authors = [
- { name = "Silas Brack", email = "silasbrack@gmail.com" }
-]
-requires-python = "==3.13.*"
-dependencies = [
- # HTTP server
- "uvicorn",
- "uvloop",
- "httptools",
- # Web framework
- "starlette",
- "jinja2",
- "python-multipart",
- "aiosql", # Loading SQL queries as Python functions
- "aiosqlite", # Async sqlite driver
- "argon2-cffi", # Password hashing
- "orjson", # Serializing logs as JSON
-]
-
-[dependency-groups]
-dev = [
- { include-group = "test" },
- "ruff",
- "watchfiles", # Watch non-*.py files with uvicorn
-]
-test = [
- "pytest",
- "pytest-cov",
- "pytest-asyncio",
- "httpx", # Starlette TestClient support
- "locust", # Stress testing
- "beautifulsoup4", # HTML parsing
-]
-
-[project.scripts]
-simple-web-app = "simple_web_app.__main__:run_uvicorn"
-create-migration = "simple_web_app.migration:run_create_migration"
-
-[build-system]
-requires = ["setuptools", "wheel"]
-build-backend = "setuptools.build_meta"
-
-[tool.setuptools.packages.find]
-where = ["src"]
-
-[tool.setuptools.package-data]
-simple_web_app = ["queries/**", "migrations/**", "templates/**", "static/**"]
-
-[tool.pytest.ini_options]
-asyncio_mode = "auto"
-
diff --git a/src/config.rs b/src/config.rs
@@ -0,0 +1,27 @@
+use std::env;
+
+pub struct Config {
+ pub database_url: String,
+ pub debug: bool,
+ pub port: u16,
+}
+
+impl Config {
+ pub fn from_env() -> Self {
+ dotenvy::dotenv().ok();
+
+ let database_url =
+ env::var("DATABASE_URL").unwrap_or_else(|_| "sqlite:./db.sqlite3".to_string());
+
+ let debug = env::var("DEBUG")
+ .map(|v| v.to_lowercase() == "true" || v == "1")
+ .unwrap_or(true);
+
+ let port = env::var("PORT")
+ .ok()
+ .and_then(|v| v.parse().ok())
+ .unwrap_or(8000);
+
+ Self { database_url, debug, port }
+ }
+}
diff --git a/src/db/mod.rs b/src/db/mod.rs
@@ -0,0 +1,5 @@
+pub mod models;
+pub mod queries;
+
+pub use models::*;
+pub use queries::*;
diff --git a/src/db/models.rs b/src/db/models.rs
@@ -0,0 +1,48 @@
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
+pub struct NewsItem {
+ pub id: i64,
+ pub title: String,
+ pub text: String,
+ pub published: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
+pub struct SearchResult {
+ pub title: String,
+ pub text: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
+pub struct Category {
+ pub id: i64,
+ pub name: Option<String>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct NewsWithCategories {
+ pub id: i64,
+ pub title: String,
+ pub text: String,
+ pub published: String,
+ pub categories: Vec<Category>,
+ pub time_since_published: String,
+}
+
+impl NewsWithCategories {
+ pub fn from_news_item(
+ item: NewsItem,
+ categories: Vec<Category>,
+ time_since_published: String,
+ ) -> Self {
+ Self {
+ id: item.id,
+ title: item.title,
+ text: item.text,
+ published: item.published,
+ categories,
+ time_since_published,
+ }
+ }
+}
diff --git a/src/db/queries.rs b/src/db/queries.rs
@@ -0,0 +1,106 @@
+use sqlx::SqlitePool;
+
+use super::models::{Category, NewsItem, SearchResult};
+
+pub async fn get_news(
+ pool: &SqlitePool,
+ limit: i64,
+ offset: i64,
+ max_published_time: &str,
+) -> Result<Vec<NewsItem>, sqlx::Error> {
+ sqlx::query_as::<_, NewsItem>(
+ r#"
+ SELECT id, title, text, published
+ FROM news_item
+ WHERE language = 'english'
+ AND (published < ?)
+ ORDER BY published DESC, id ASC
+ LIMIT ?
+ OFFSET ?
+ "#,
+ )
+ .bind(max_published_time)
+ .bind(limit)
+ .bind(offset)
+ .fetch_all(pool)
+ .await
+}
+
+pub async fn get_news_by_category(
+ pool: &SqlitePool,
+ category_id: i64,
+ limit: i64,
+ offset: i64,
+ max_published_time: &str,
+) -> Result<Vec<NewsItem>, sqlx::Error> {
+ sqlx::query_as::<_, NewsItem>(
+ r#"
+ SELECT ni.id, ni.title, ni.text, ni.published
+ FROM news_item AS ni
+ INNER JOIN news_item_category AS nic
+ ON nic.news_item_id = ni.id
+ WHERE ni.language = 'english'
+ AND (ni.published < ?)
+ AND nic.category_id = ?
+ ORDER BY ni.published DESC, ni.id ASC
+ LIMIT ?
+ OFFSET ?
+ "#,
+ )
+ .bind(max_published_time)
+ .bind(category_id)
+ .bind(limit)
+ .bind(offset)
+ .fetch_all(pool)
+ .await
+}
+
+pub async fn search_news(
+ pool: &SqlitePool,
+ query: &str,
+ limit: i64,
+) -> Result<Vec<SearchResult>, sqlx::Error> {
+ sqlx::query_as::<_, SearchResult>(
+ r#"
+ SELECT title, text
+ FROM news_item_fts(?)
+ LIMIT ?
+ "#,
+ )
+ .bind(query)
+ .bind(limit)
+ .fetch_all(pool)
+ .await
+}
+
+pub async fn get_categories_for_news(
+ pool: &SqlitePool,
+ news_item_id: i64,
+) -> Result<Vec<Category>, sqlx::Error> {
+ sqlx::query_as::<_, Category>(
+ r#"
+ SELECT c.id, c.name
+ FROM news_item_category AS nic
+ INNER JOIN category AS c
+ ON c.id = nic.category_id
+ WHERE nic.news_item_id = ?
+ "#,
+ )
+ .bind(news_item_id)
+ .fetch_all(pool)
+ .await
+}
+
+pub async fn get_categories(pool: &SqlitePool, limit: i64) -> Result<Vec<Category>, sqlx::Error> {
+ sqlx::query_as::<_, Category>(
+ r#"
+ SELECT id, name
+ FROM category
+ ORDER BY id
+ LIMIT ?
+ "#,
+ )
+ .bind(limit)
+ .fetch_all(pool)
+ .await
+}
diff --git a/src/error.rs b/src/error.rs
@@ -0,0 +1,33 @@
+use axum::http::StatusCode;
+use axum::response::{IntoResponse, Response};
+use thiserror::Error;
+
+#[derive(Error, Debug)]
+pub enum AppError {
+ #[error("Database error: {0}")]
+ Database(#[from] sqlx::Error),
+
+ #[error("Template error: {0}")]
+ Template(#[from] askama::Error),
+
+ #[error("Not found")]
+ NotFound,
+}
+
+impl IntoResponse for AppError {
+ fn into_response(self) -> Response {
+ let (status, message) = match &self {
+ AppError::Database(e) => {
+ tracing::error!("Database error: {}", e);
+ (StatusCode::INTERNAL_SERVER_ERROR, "Database error")
+ }
+ AppError::Template(e) => {
+ tracing::error!("Template error: {}", e);
+ (StatusCode::INTERNAL_SERVER_ERROR, "Template error")
+ }
+ AppError::NotFound => (StatusCode::NOT_FOUND, "Not found"),
+ };
+
+ (status, message).into_response()
+ }
+}
diff --git a/src/extractors.rs b/src/extractors.rs
@@ -0,0 +1,22 @@
+use axum::{
+ extract::FromRequestParts,
+ http::{request::Parts, StatusCode},
+};
+
+/// Extractor that checks if the request is an HTMX request.
+/// Returns true if HX-Request header is present OR HX-History-Restore-Request is present.
+pub struct HtmxRequest(pub bool);
+
+impl<S> FromRequestParts<S> for HtmxRequest
+where
+ S: Send + Sync,
+{
+ type Rejection = (StatusCode, &'static str);
+
+ async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
+ let is_hx_request = parts.headers.get("HX-Request").is_some();
+ let is_hx_history_restore = parts.headers.get("HX-History-Restore-Request").is_some();
+
+ Ok(HtmxRequest(is_hx_request || is_hx_history_restore))
+ }
+}
diff --git a/src/handlers/home.rs b/src/handlers/home.rs
@@ -0,0 +1,112 @@
+use askama::Template;
+use axum::{
+ extract::{Query, State},
+ response::Html,
+};
+use chrono::{DateTime, Utc};
+use serde::Deserialize;
+
+use crate::db::{self, NewsWithCategories};
+use crate::error::AppError;
+use crate::extractors::HtmxRequest;
+use crate::state::AppState;
+use crate::templates::{ApplicationTemplate, IndexTemplate, OobSwapTemplate};
+
+#[derive(Debug, Deserialize)]
+pub struct HomeQuery {
+ #[serde(default)]
+ pub page: i64,
+ pub current_time: Option<String>,
+ pub category_id: Option<i64>,
+}
+
+fn format_time_since_published(published: &str, current_time: DateTime<Utc>) -> String {
+ // Parse the published timestamp
+ let published_time = DateTime::parse_from_str(published, "%Y-%m-%dT%H:%M:%S%.f%z")
+ .or_else(|_| DateTime::parse_from_rfc3339(published))
+ .map(|dt| dt.with_timezone(&Utc))
+ .unwrap_or(current_time);
+
+ let duration = current_time.signed_duration_since(published_time);
+
+ let days = duration.num_days();
+ let hours = duration.num_hours() % 24;
+
+ format!("{} days, {} hours ago", days, hours)
+}
+
+pub async fn show_home_page(
+ State(state): State<AppState>,
+ HtmxRequest(is_htmx): HtmxRequest,
+ Query(query): Query<HomeQuery>,
+) -> Result<Html<String>, AppError> {
+ let page = query.page;
+ let current_time_str = query
+ .current_time
+ .unwrap_or_else(|| Utc::now().to_rfc3339());
+ let category_id = query.category_id;
+
+ let limit: i64 = 5;
+ let offset = page * limit;
+
+ // Fetch categories and news concurrently
+ let (all_categories, rows) = tokio::try_join!(
+ db::get_categories(&state.pool, 20),
+ async {
+ if let Some(cat_id) = category_id {
+ db::get_news_by_category(&state.pool, cat_id, limit + 1, offset, ¤t_time_str)
+ .await
+ } else {
+ db::get_news(&state.pool, limit + 1, offset, ¤t_time_str).await
+ }
+ }
+ )?;
+
+ let reached_end = rows.len() as i64 <= limit;
+ let rows: Vec<_> = rows.into_iter().take(limit as usize).collect();
+
+ // Fetch categories for each news item concurrently
+ let categories_futures: Vec<_> = rows
+ .iter()
+ .map(|row| db::get_categories_for_news(&state.pool, row.id))
+ .collect();
+
+ let categories_results = futures::future::try_join_all(categories_futures).await?;
+
+ let current_time_utc = Utc::now();
+
+ let news: Vec<NewsWithCategories> = rows
+ .into_iter()
+ .zip(categories_results)
+ .map(|(row, cats)| {
+ let time_since = format_time_since_published(&row.published, current_time_utc);
+ NewsWithCategories::from_news_item(row, cats, time_since)
+ })
+ .collect();
+
+ if is_htmx {
+ let template = OobSwapTemplate {
+ news,
+ page,
+ current_time: current_time_str,
+ category_id,
+ reached_end,
+ oob: true,
+ };
+ Ok(Html(template.render()?))
+ } else {
+ let index_template = IndexTemplate {
+ news,
+ categories: all_categories,
+ page,
+ current_time: current_time_str,
+ category_id,
+ reached_end,
+ oob: false,
+ };
+ let content = index_template.render()?;
+
+ let app_template = ApplicationTemplate { content };
+ Ok(Html(app_template.render()?))
+ }
+}
diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs
@@ -0,0 +1,7 @@
+pub mod home;
+pub mod search;
+pub mod settings;
+
+pub use home::*;
+pub use search::*;
+pub use settings::*;
diff --git a/src/handlers/search.rs b/src/handlers/search.rs
@@ -0,0 +1,32 @@
+use askama::Template;
+use axum::{extract::State, response::Html, Form};
+use serde::Deserialize;
+
+use crate::db;
+use crate::error::AppError;
+use crate::state::AppState;
+use crate::templates::{SearchResultsTemplate, SearchTemplate};
+
+pub async fn open_search() -> Result<Html<String>, AppError> {
+ let template = SearchTemplate;
+ Ok(Html(template.render()?))
+}
+
+#[derive(Debug, Deserialize)]
+pub struct SearchForm {
+ pub search: String,
+}
+
+pub async fn search(
+ State(state): State<AppState>,
+ Form(form): Form<SearchForm>,
+) -> Result<Html<String>, AppError> {
+ let news = if form.search.is_empty() {
+ Vec::new()
+ } else {
+ db::search_news(&state.pool, &form.search, 10).await?
+ };
+
+ let template = SearchResultsTemplate { news };
+ Ok(Html(template.render()?))
+}
diff --git a/src/handlers/settings.rs b/src/handlers/settings.rs
@@ -0,0 +1,28 @@
+use askama::Template;
+use axum::{extract::Query, response::Html};
+use serde::Deserialize;
+
+use crate::error::AppError;
+use crate::templates::{SettingsTabTemplate, SettingsTemplate};
+
+#[derive(Debug, Deserialize)]
+pub struct SettingsQuery {
+ #[serde(default = "default_tab")]
+ pub tab: String,
+}
+
+fn default_tab() -> String {
+ "general".to_string()
+}
+
+pub async fn open_settings(Query(query): Query<SettingsQuery>) -> Result<Html<String>, AppError> {
+ let template = SettingsTemplate { tab: query.tab };
+ Ok(Html(template.render()?))
+}
+
+pub async fn open_settings_tab(
+ Query(query): Query<SettingsQuery>,
+) -> Result<Html<String>, AppError> {
+ let template = SettingsTabTemplate { tab: query.tab };
+ Ok(Html(template.render()?))
+}
diff --git a/src/main.rs b/src/main.rs
@@ -0,0 +1,75 @@
+use axum::{
+ body::Body,
+ http::{Request, Response},
+ middleware::{self, Next},
+};
+use tower_http::trace::TraceLayer;
+use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
+
+mod config;
+mod db;
+mod error;
+mod extractors;
+mod handlers;
+mod routes;
+mod state;
+mod templates;
+
+use config::Config;
+use state::AppState;
+
+/// Middleware that sets Cache-Control for preloaded HTMX requests
+async fn cache_control_middleware(request: Request<Body>, next: Next) -> Response<Body> {
+ let is_preloaded = request
+ .headers()
+ .get("HX-Preloaded")
+ .map(|v| v == "true")
+ .unwrap_or(false);
+
+ let mut response = next.run(request).await;
+
+ if is_preloaded {
+ response.headers_mut().insert(
+ "Cache-Control",
+ "private, max-age=60".parse().unwrap(),
+ );
+ }
+
+ response
+}
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error>> {
+ // Initialize tracing
+ tracing_subscriber::registry()
+ .with(tracing_subscriber::fmt::layer())
+ .with(
+ tracing_subscriber::EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| "simple_web_app=debug,tower_http=debug".into()),
+ )
+ .init();
+
+ // Load configuration
+ let config = Config::from_env();
+
+ tracing::info!("Starting server with DEBUG={}", config.debug);
+
+ // Create application state
+ let state = AppState::new(&config).await?;
+
+ tracing::info!("Database connected and migrations applied");
+
+ // Build router with middleware
+ let app = routes::create_router(state)
+ .layer(middleware::from_fn(cache_control_middleware))
+ .layer(TraceLayer::new_for_http());
+
+ // Serve on configured port
+ let addr = format!("0.0.0.0:{}", config.port);
+ let listener = tokio::net::TcpListener::bind(&addr).await?;
+ tracing::info!("Server listening on http://{}", addr);
+
+ axum::serve(listener, app).await?;
+
+ Ok(())
+}
diff --git a/src/routes.rs b/src/routes.rs
@@ -0,0 +1,19 @@
+use axum::{
+ Router,
+ routing::{get, post},
+};
+use tower_http::services::ServeDir;
+
+use crate::handlers;
+use crate::state::AppState;
+
+pub fn create_router(state: AppState) -> Router {
+ Router::new()
+ .route("/", get(handlers::show_home_page))
+ .route("/search", get(handlers::open_search))
+ .route("/search", post(handlers::search))
+ .route("/settings", get(handlers::open_settings))
+ .route("/settings/tab", get(handlers::open_settings_tab))
+ .nest_service("/static", ServeDir::new("static"))
+ .with_state(state)
+}
diff --git a/src/simple_web_app/__init__.py b/src/simple_web_app/__init__.py
@@ -1 +0,0 @@
-
diff --git a/src/simple_web_app/__main__.py b/src/simple_web_app/__main__.py
@@ -1,11 +0,0 @@
-import subprocess
-import sys
-
-
-def run_uvicorn():
- subprocess.run([sys.executable, "-m", "uvicorn", "simple_web_app.app:app"])
-
-
-if __name__ == "__main__":
- run_uvicorn()
-
diff --git a/src/simple_web_app/app.py b/src/simple_web_app/app.py
@@ -1,238 +0,0 @@
-import asyncio
-import contextlib
-import importlib.resources
-import logging
-import datetime
-from pathlib import Path
-
-import aiosql
-import aiosqlite
-from starlette.applications import Starlette
-from starlette.config import Config
-from starlette.middleware import Middleware
-from starlette.middleware.base import BaseHTTPMiddleware
-
-# from starlette.middleware.sessions import SessionMiddleware
-from starlette.requests import Request
-from starlette.routing import Mount, Route
-from starlette.staticfiles import StaticFiles
-from starlette.templating import Jinja2Templates
-
-from simple_web_app.migration import (
- apply_migrations,
- create_migrations_table_if_not_exists,
-)
-
-logger = logging.getLogger(__name__)
-
-SQLITE_PRAGMAS = [
- "PRAGMA journal_mode = WAL;",
- "PRAGMA foreign_keys = ON;",
- "PRAGMA synchronous = NORMAL;",
- "PRAGMA cache_size = 1000000000;",
- "PRAGMA temp_store = MEMORY;",
- "PRAGMA busy_timeout = 5000;",
- "PRAGMA foreign_keys = ON;",
- "PRAGMA synchronous = NORMAL;",
-]
-
-config = Config()
-DEBUG = config("DEBUG", cast=bool, default=True)
-DATABASE_PATH = config("DATABASE_PATH", default="./db.sqlite3")
-# SECRET_KEY = config("SECRET_KEY", default="asdf")
-
-DATABASE_PATH = Path(DATABASE_PATH)
-MIGRATION_DIR = importlib.resources.files("simple_web_app").joinpath("migrations")
-TEMPLATE_DIR = importlib.resources.files("simple_web_app").joinpath("templates")
-QUERY_DIR = importlib.resources.files("simple_web_app").joinpath("queries")
-STATIC_DIR = importlib.resources.files("simple_web_app").joinpath("static")
-
-templates = Jinja2Templates(directory=TEMPLATE_DIR)
-queries_basic = aiosql.from_path(QUERY_DIR / "basic.sql", "aiosqlite")
-
-
-def is_htmx_request(request: Request) -> bool:
- is_hx_request = request.headers.get("HX-Request") is not None
- is_hx_history_restore = (
- request.headers.get("HX-History-Restore-Request") is not None
- )
- return is_hx_request or is_hx_history_restore
-
-
-def render(
- request: Request, partial_template: str, include_oob: str | None = None, **kwargs
-):
- ctx = kwargs.get("context", {})
- is_authenticated = False # request.user.is_authenticated if request.user else False
- ctx = ctx | {"is_authenticated": is_authenticated}
- headers = kwargs.get("headers", {})
- if is_htmx_request(request):
- if include_oob:
- ctx = ctx | {
- "context_partial": partial_template,
- "oob_template": include_oob,
- }
- return templates.TemplateResponse(
- request, partial_template, ctx, headers=headers
- )
- return templates.TemplateResponse(
- request,
- "application.html",
- ctx | {"content": partial_template},
- headers=headers,
- )
-
-
-def format_timedelta(tdelta, fmt):
- d = {"days": tdelta.days}
- d["hours"], rem = divmod(tdelta.seconds, 3600)
- d["minutes"], d["seconds"] = divmod(rem, 60)
- return fmt.format(**d)
-
-
-async def show_home_page(request: Request):
- page = request.query_params.get("page", default=0)
- page = int(page) if page is not None else page
- current_time = request.query_params.get(
- "current_time", default=datetime.datetime.now(tz=datetime.UTC)
- )
- category_id = request.query_params.get("category_id")
- category_id = int(category_id) if category_id is not None else None
-
- limit = 5
- offset = page * limit
-
- all_categories = await queries_basic.get_categories(request.state.conn, limit=20)
-
- rows = (
- await queries_basic.get_news_by_category(
- request.state.conn,
- category_id=category_id,
- limit=limit + 1,
- offset=offset,
- max_published_time=current_time,
- )
- if category_id
- else await queries_basic.get_news(
- request.state.conn,
- limit=limit + 1,
- offset=offset,
- max_published_time=current_time,
- )
- )
- reached_end = len(rows) <= limit
- rows = rows[:limit]
- categories = await asyncio.gather(
- *[
- queries_basic.get_categories_for_news(
- request.state.conn, news_item_id=row["id"]
- )
- for row in rows
- ]
- )
-
- current_time_utc = datetime.datetime.now(tz=datetime.UTC)
-
- def _get_time_since_published(published: str, current_time: datetime):
- published = datetime.datetime.strptime(published, "%Y-%m-%dT%H:%M:%S.%f%z")
- return format_timedelta(
- current_time - published, "{days} days, {hours} hours ago"
- )
-
- times_since_published = [
- _get_time_since_published(row["published"], current_time_utc) for row in rows
- ]
-
- news = [
- {**row, "categories": c, "time_since_published": tsp}
- for row, c, tsp in zip(rows, categories, times_since_published, strict=True)
- ]
- context = {
- "news": news,
- "categories": all_categories,
- "page": page,
- "current_time": current_time,
- "category_id": category_id,
- "reached_end": reached_end,
- }
-
- if is_htmx_request(request):
- context = context | {"oob": True}
- template_name = "oob_swap.html" if is_htmx_request(request) else "index.html"
- return render(request, template_name, context=context)
-
-
-async def open_search(request: Request):
- return render(request, "search.html")
-
-
-async def search(request: Request):
- async with request.form() as form:
- query = form["search"]
- rows = (
- await queries_basic.search_news(request.state.conn, query=query, limit=10)
- if query
- else []
- )
- return render(request, "search_results.html", context={"news": rows})
-
-
-async def open_settings(request: Request):
- tab = request.query_params.get("tab", default="general")
- return render(request, "settings.html", context={"tab": tab})
-
-
-async def open_settings_tab(request: Request):
- tab = request.query_params.get("tab", default="general")
- return render(request, "settings_tab.html", context={"tab": tab})
-
-
-class CacheControlMiddleware(BaseHTTPMiddleware):
- async def dispatch(self, request, call_next):
- response = await call_next(request)
- if request.headers.get("HX-Preloaded") == "true":
- response.headers["cache-control"] = "private, max-age=60"
- return response
-
-
-@contextlib.asynccontextmanager
-async def lifespan(app: Starlette):
- logger.debug("START LIFESPAN")
- async with aiosqlite.connect(DATABASE_PATH, autocommit=True) as conn:
- conn.row_factory = aiosqlite.Row
-
- await create_migrations_table_if_not_exists(conn)
- migration_files = sorted(MIGRATION_DIR.glob("*.sql"))
- migration_queries = [p.read_text() for p in migration_files]
- await apply_migrations(conn, migration_queries)
-
- cursors = [conn.execute(query) for query in SQLITE_PRAGMAS]
- [await c for c in cursors]
- logger.debug("FINISH LIFESPAN")
- yield {"conn": conn}
- logger.debug("KILL LIFESPAN")
-
-
-routes = [
- Route("/", methods=["GET"], endpoint=show_home_page, name="news_page"),
- Route("/search", methods=["GET"], endpoint=open_search, name="search_page"),
- Route("/search", methods=["POST"], endpoint=search, name="search"),
- Route("/settings", methods=["GET"], endpoint=open_settings, name="settings_page"),
- Route(
- "/settings/tab",
- methods=["GET"],
- endpoint=open_settings_tab,
- name="settings_tab",
- ),
- Mount("/static", StaticFiles(directory=STATIC_DIR), name="static"),
-]
-middleware = [
- Middleware(CacheControlMiddleware),
- # Middleware(SessionMiddleware, secret_key=SECRET_KEY),
-]
-app = Starlette(
- debug=DEBUG,
- routes=routes,
- middleware=middleware,
- lifespan=lifespan,
-)
diff --git a/src/simple_web_app/log.py b/src/simple_web_app/log.py
@@ -1,59 +0,0 @@
-import logging
-import json
-
-import orjson
-
-
-class JsonFormatter(logging.Formatter):
- """
- Formatter that outputs JSON strings after parsing the LogRecord.
-
- @param dict fmt_dict: Key: logging format attribute pairs. Defaults to {"message": "message"}.
- @param str time_format: time.strftime() format string. Default: "%Y-%m-%dT%H:%M:%S"
- @param str msec_format: Microsecond formatting. Appended at the end. Default: "%s.%03dZ"
- """
- def __init__(self, fmt_dict: dict = None, time_format: str = "%Y-%m-%dT%H:%M:%S", msec_format: str = "%s.%03dZ"):
- self.fmt_dict = fmt_dict if fmt_dict is not None else {"message": "message"}
- self.default_time_format = time_format
- self.default_msec_format = msec_format
- self.datefmt = None
-
- def usesTime(self) -> bool:
- """
- Overwritten to look for the attribute in the format dict values instead of the fmt string.
- """
- return "asctime" in self.fmt_dict.values()
-
- def formatMessage(self, record) -> dict:
- """
- Overwritten to return a dictionary of the relevant LogRecord attributes instead of a string.
- KeyError is raised if an unknown attribute is provided in the fmt_dict.
- """
- return {fmt_key: record.__dict__[fmt_val] for fmt_key, fmt_val in self.fmt_dict.items()}
-
- def format(self, record) -> str:
- """
- Mostly the same as the parent's class method, the difference being that a dict is manipulated and dumped as JSON
- instead of a string.
- """
- record.message = record.getMessage()
-
- if self.usesTime():
- record.asctime = self.formatTime(record, self.datefmt)
-
- message_dict = self.formatMessage(record)
-
- if record.exc_info:
- # Cache the traceback text to avoid converting it multiple times
- # (it's constant anyway)
- if not record.exc_text:
- record.exc_text = self.formatException(record.exc_info)
-
- if record.exc_text:
- message_dict["exc_info"] = record.exc_text
-
- if record.stack_info:
- message_dict["stack_info"] = self.formatStack(record.stack_info)
-
- return json.dumps(message_dict, default=str)
-
diff --git a/src/simple_web_app/migration.py b/src/simple_web_app/migration.py
@@ -1,63 +0,0 @@
-import datetime
-import logging
-import aiosqlite
-from pathlib import Path
-
-logger = logging.getLogger(__name__)
-
-
-async def create_migrations_table_if_not_exists(conn: aiosqlite.Connection) -> None:
- result = await conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='migration_version';")
- row = await result.fetchone()
- migrations_table_exists = row is not None
- if not migrations_table_exists:
- logger.info("Creating migrations table migration_version.")
- await conn.execute("CREATE TABLE migration_version (version INTEGER)")
- await conn.execute("INSERT INTO migration_version (version) VALUES (0)")
- await conn.commit()
-
-
-async def apply_migrations(conn: aiosqlite.Connection, migration_queries: list[str]) -> None:
- result = await conn.execute("SELECT version FROM migration_version")
- (db_version,) = await result.fetchone()
- num_migrations = len(migration_queries)
- for i in range(db_version, num_migrations):
- query = migration_queries[i]
- print(f"Running migration {i}: {query}")
- await conn.execute("BEGIN TRANSACTION")
- try:
- _ = await conn.executescript(query)
- _ = await conn.execute("UPDATE migration_version SET version = ?", [i + 1])
- await conn.execute("COMMIT TRANSACTION")
- except Exception as e:
- await conn.execute("ROLLBACK")
- logger.exception(f"Encountered the following error while running the migration: {e}")
- raise
-
-
-
-def create_migration(name: str, migrations_dir: Path):
- current_timestamp = datetime.datetime.now(tz=datetime.UTC)
- file_name = f"{current_timestamp.strftime('%Y%m%d%H%M%S')}_{name}.sql"
- file_path = migrations_dir / file_name
- file_path.touch()
- return file_path
-
-
-def run_create_migration():
- import argparse
- import importlib.resources
-
- default_migrations_dir = importlib.resources.files("simple_web_app").joinpath("migrations")
- parser = argparse.ArgumentParser()
- parser.add_argument("name")
- parser.add_argument("-d", "--dir", default=default_migrations_dir)
- args = parser.parse_args()
-
- path = create_migration(args.name, Path(args.dir))
- print(str(path))
-
-
-if __name__ == "__main__":
- run_create_migration()
-
diff --git a/src/simple_web_app/queries/basic.sql b/src/simple_web_app/queries/basic.sql
@@ -1,39 +0,0 @@
--- name: get_news(limit, offset, max_published_time)
-SELECT id, title, text, published
-FROM news_item
-WHERE language = 'english'
- AND (published < :max_published_time)
-ORDER BY published DESC, id ASC
-LIMIT :limit
-OFFSET :offset;
-
--- name: search_news(query, limit)
-SELECT title, text
-FROM news_item_fts(:query)
-LIMIT :limit;
-
--- name: get_news_by_category(category_id, limit, offset, max_published_time)
-SELECT ni.id, ni.title, ni.text, ni.published
-FROM news_item AS ni
-INNER JOIN news_item_category AS nic
- ON nic.news_item_id = ni.id
-WHERE ni.language = 'english'
- AND (ni.published < :max_published_time)
- AND nic.category_id = :category_id
-ORDER BY ni.published DESC, ni.id ASC
-LIMIT :limit
-OFFSET :offset;
-
--- name: get_categories_for_news(news_item_id)
-SELECT c.id, c.name
-FROM news_item_category AS nic
-INNER JOIN category AS c
- ON c.id = nic.category_id
-WHERE nic.news_item_id = :news_item_id;
-
--- name: get_categories(limit)
-SELECT id, name
-FROM category
-ORDER BY id
-LIMIT :limit;
-
diff --git a/src/simple_web_app/templates/application.html b/src/simple_web_app/templates/application.html
@@ -1,21 +0,0 @@
-<!doctype html>
-<html lang="en">
- <head>
- <meta charset="utf-8" />
- <title>My Chat</title>
- <meta name="viewport" content="width=device-width, initial-scale=1" />
- <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
- <script src="https://cdn.jsdelivr.net/npm/htmx-ext-preload@2.1.0"></script>
- <script src="https://unpkg.com/hyperscript.org@0.9.14"></script>
- <link rel="stylesheet" href="{{ url_for('static', path='style.css') }}" />
- <link
- rel="stylesheet"
- href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.amber.min.css"
- />
- </head>
- <body hx-ext="preload">
- <main class="container">
- <div id="content">{% include content %}</div>
- </main>
- </body>
-</html>
diff --git a/src/simple_web_app/templates/chat.html b/src/simple_web_app/templates/chat.html
diff --git a/src/simple_web_app/templates/index.html b/src/simple_web_app/templates/index.html
@@ -1,176 +0,0 @@
-<div id="login">
- <!-- <article> -->
- <nav>
- <ul>
- <li>
- <a href="/" class="secondary"
- ><img src="https://kite.kagi.com/svg/kagi_news_compact_dark.svg"
- /></a>
- </li>
- </ul>
- <ul>
- <li>
- <strong
- style="cursor: pointer"
- data-tooltip="Choose another date"
- data-placement="bottom"
- >Tuesday, September 30</strong
- >
- </li>
- </ul>
- <ul>
- <li>
- <a
- id="search-btn"
- style="cursor: pointer"
- data-tooltip="Search"
- hx-trigger="click, keyup[ctrlKey&&shiftKey&&key=='O']"
- hx-get="{{ url_for('search_page') }}"
- hx-target="#modal-placeholder"
- hx-swap="innerHTML"
- preload
- class="secondary"
- ><svg
- height="20pt"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 640 640"
- >
- <!--!Font Awesome Free v7.0.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
- <path
- d="M480 272C480 317.9 465.1 360.3 440 394.7L566.6 521.4C579.1 533.9 579.1 554.2 566.6 566.7C554.1 579.2 533.8 579.2 521.3 566.7L394.7 440C360.3 465.1 317.9 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272zM272 416C351.5 416 416 351.5 416 272C416 192.5 351.5 128 272 128C192.5 128 128 192.5 128 272C128 351.5 192.5 416 272 416z"
- /></svg
- ></a>
- </li>
- <li>
- <a id="font-size-btn" data-tooltip="Change font size" class="secondary"
- ><svg
- height="20pt"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 640 640"
- >
- <!--!Font Awesome Free v7.0.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
- <path
- d="M349.1 114.7C343.9 103.3 332.5 96 320 96C307.5 96 296.1 103.3 290.9 114.7L123.5 480L112 480C94.3 480 80 494.3 80 512C80 529.7 94.3 544 112 544L200 544C217.7 544 232 529.7 232 512C232 494.3 217.7 480 200 480L193.9 480L215.9 432L424.2 432L446.2 480L440.1 480C422.4 480 408.1 494.3 408.1 512C408.1 529.7 422.4 544 440.1 544L528.1 544C545.8 544 560.1 529.7 560.1 512C560.1 494.3 545.8 480 528.1 480L516.6 480L349.2 114.7zM394.8 368L245.2 368L320 204.8L394.8 368z"
- /></svg
- ></a>
- </li>
- <li>
- <a
- id="settings-btn"
- style="cursor: pointer"
- data-tooltip="Settings"
- hx-get="{{ url_for('settings_page') }}"
- hx-target="#modal-placeholder"
- hx-swap="innerHTML"
- preload
- class="secondary"
- ><svg
- height="20pt"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 640 640"
- >
- <!--!Font Awesome Free v7.0.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
- <path
- d="M259.1 73.5C262.1 58.7 275.2 48 290.4 48L350.2 48C365.4 48 378.5 58.7 381.5 73.5L396 143.5C410.1 149.5 423.3 157.2 435.3 166.3L503.1 143.8C517.5 139 533.3 145 540.9 158.2L570.8 210C578.4 223.2 575.7 239.8 564.3 249.9L511 297.3C511.9 304.7 512.3 312.3 512.3 320C512.3 327.7 511.8 335.3 511 342.7L564.4 390.2C575.8 400.3 578.4 417 570.9 430.1L541 481.9C533.4 495 517.6 501.1 503.2 496.3L435.4 473.8C423.3 482.9 410.1 490.5 396.1 496.6L381.7 566.5C378.6 581.4 365.5 592 350.4 592L290.6 592C275.4 592 262.3 581.3 259.3 566.5L244.9 496.6C230.8 490.6 217.7 482.9 205.6 473.8L137.5 496.3C123.1 501.1 107.3 495.1 99.7 481.9L69.8 430.1C62.2 416.9 64.9 400.3 76.3 390.2L129.7 342.7C128.8 335.3 128.4 327.7 128.4 320C128.4 312.3 128.9 304.7 129.7 297.3L76.3 249.8C64.9 239.7 62.3 223 69.8 209.9L99.7 158.1C107.3 144.9 123.1 138.9 137.5 143.7L205.3 166.2C217.4 157.1 230.6 149.5 244.6 143.4L259.1 73.5zM320.3 400C364.5 399.8 400.2 363.9 400 319.7C399.8 275.5 363.9 239.8 319.7 240C275.5 240.2 239.8 276.1 240 320.3C240.2 364.5 276.1 400.2 320.3 400z"
- /></svg
- ></a>
- </li>
- </ul>
- </nav>
-
- <div class="overflow-auto" style="text-wrap: nowrap">
- <table>
- <tr>
- {% for category in categories %}
- <th>
- <div
- hx-get="{{ url_for('news_page').include_query_params(category_id=category.id) }}"
- hx-target="#news-cards"
- preload
- style="cursor: pointer"
- >
- {{ category.name }}
- </div>
- </th>
- {% endfor %}
- </tr>
- </table>
- </div>
-
- <div id="modal-placeholder"></div>
- <div id="news-cards-with-buttons">
- <div id="news-cards">{% include 'news_cards.html' %}</div>
- <nav>
- <ul></ul>
- <ul>
- <li>{% include 'load_more.html' %}</li>
- <li><button>Mark all as read</button></li>
- </ul>
- <ul></ul>
- </nav>
- </div>
- <!-- </article> -->
- <footer>
- <nav>
- <ul></ul>
- <ul>
- <li>
- <a href="https://github.com/silasbrack/simple-web-app"
- ><svg
- height="24pt"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 640 640"
- >
- <!--!Font Awesome Free v7.0.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
- <path
- d="M237.9 461.4C237.9 463.4 235.6 465 232.7 465C229.4 465.3 227.1 463.7 227.1 461.4C227.1 459.4 229.4 457.8 232.3 457.8C235.3 457.5 237.9 459.1 237.9 461.4zM206.8 456.9C206.1 458.9 208.1 461.2 211.1 461.8C213.7 462.8 216.7 461.8 217.3 459.8C217.9 457.8 216 455.5 213 454.6C210.4 453.9 207.5 454.9 206.8 456.9zM251 455.2C248.1 455.9 246.1 457.8 246.4 460.1C246.7 462.1 249.3 463.4 252.3 462.7C255.2 462 257.2 460.1 256.9 458.1C256.6 456.2 253.9 454.9 251 455.2zM316.8 72C178.1 72 72 177.3 72 316C72 426.9 141.8 521.8 241.5 555.2C254.3 557.5 258.8 549.6 258.8 543.1C258.8 536.9 258.5 502.7 258.5 481.7C258.5 481.7 188.5 496.7 173.8 451.9C173.8 451.9 162.4 422.8 146 415.3C146 415.3 123.1 399.6 147.6 399.9C147.6 399.9 172.5 401.9 186.2 425.7C208.1 464.3 244.8 453.2 259.1 446.6C261.4 430.6 267.9 419.5 275.1 412.9C219.2 406.7 162.8 398.6 162.8 302.4C162.8 274.9 170.4 261.1 186.4 243.5C183.8 237 175.3 210.2 189 175.6C209.9 169.1 258 202.6 258 202.6C278 197 299.5 194.1 320.8 194.1C342.1 194.1 363.6 197 383.6 202.6C383.6 202.6 431.7 169 452.6 175.6C466.3 210.3 457.8 237 455.2 243.5C471.2 261.2 481 275 481 302.4C481 398.9 422.1 406.6 366.2 412.9C375.4 420.8 383.2 435.8 383.2 459.3C383.2 493 382.9 534.7 382.9 542.9C382.9 549.4 387.5 557.3 400.2 555C500.2 521.8 568 426.9 568 316C568 177.3 455.5 72 316.8 72zM169.2 416.9C167.9 417.9 168.2 420.2 169.9 422.1C171.5 423.7 173.8 424.4 175.1 423.1C176.4 422.1 176.1 419.8 174.4 417.9C172.8 416.3 170.5 415.6 169.2 416.9zM158.4 408.8C157.7 410.1 158.7 411.7 160.7 412.7C162.3 413.7 164.3 413.4 165 412C165.7 410.7 164.7 409.1 162.7 408.1C160.7 407.5 159.1 407.8 158.4 408.8zM190.8 444.4C189.2 445.7 189.8 448.7 192.1 450.6C194.4 452.9 197.3 453.2 198.6 451.6C199.9 450.3 199.3 447.3 197.3 445.4C195.1 443.1 192.1 442.8 190.8 444.4zM179.4 429.7C177.8 430.7 177.8 433.3 179.4 435.6C181 437.9 183.7 438.9 185 437.9C186.6 436.6 186.6 434 185 431.7C183.6 429.4 181 428.4 179.4 429.7z"
- />
- </svg>
- Contribute</a
- >
- </li>
-
- <li>
- <a href=""
- ><svg
- height="20pt"
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="1.5"
- stroke="currentColor"
- class="size-6"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"
- />
- </svg>
- About</a
- >
- </li>
-
- <li>
- <a href=""
- ><svg
- height="20pt"
- xmlns="http://www.w3.org/2000/svg"
- viewBox="0 0 24 24"
- fill="currentColor"
- class="size-6"
- >
- <path
- fill-rule="evenodd"
- d="M3.75 4.5a.75.75 0 0 1 .75-.75h.75c8.284 0 15 6.716 15 15v.75a.75.75 0 0 1-.75.75h-.75a.75.75 0 0 1-.75-.75v-.75C18 11.708 12.292 6 5.25 6H4.5a.75.75 0 0 1-.75-.75V4.5Zm0 6.75a.75.75 0 0 1 .75-.75h.75a8.25 8.25 0 0 1 8.25 8.25v.75a.75.75 0 0 1-.75.75H12a.75.75 0 0 1-.75-.75v-.75a6 6 0 0 0-6-6H4.5a.75.75 0 0 1-.75-.75v-.75Zm0 7.5a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0Z"
- clip-rule="evenodd"
- />
- </svg>
- RSS Feed</a
- >
- </li>
- </ul>
- <ul></ul>
- </nav>
- </footer>
-</div>
diff --git a/src/simple_web_app/templates/load_more.html b/src/simple_web_app/templates/load_more.html
@@ -1,24 +0,0 @@
-<button
- id="load-more-btn"
- {%-
- if
- reached_end
- %}
- disabled{%
- endif
- %}
- {%
- if
- oob
- %}hx-swap-oob="true"
- {%
- endif
- %}
- hx-target="#news-cards"
- hx-swap="beforeend"
- hx-get="{{ url_for('news_page') }}?page={{ page + 1 }}¤t_time={{ current_time }}{% if category_id %}&category_id={{ category_id }}{% endif %}"
- preload
- class="secondary"
->
- Load more
-</button>
diff --git a/src/simple_web_app/templates/news_card.html b/src/simple_web_app/templates/news_card.html
@@ -1,30 +0,0 @@
-<header>
- <nav>
- <ul>
- {% for category in new.categories %}
- <li>
- <div
- style="cursor: pointer"
- hx-get="{{ url_for('news_page').include_query_params(category_id=category.id) }}"
- hx-target="#news-cards"
- preload
- >
- <small>{{ category.name }}</small>
- </div>
- </li>
- {% endfor %}
- </ul>
- </nav>
-</header>
-<details>
- <summary>
- <strong>{{ new.title }}</strong>
- </summary>
- <p><small>{{ new.text }}</small></p>
-</details>
-<footer>
- <small>
- <em> {{ new.time_since_published }} </em>
- </small>
-</footer>
-<hr />
diff --git a/src/simple_web_app/templates/news_cards.html b/src/simple_web_app/templates/news_cards.html
@@ -1 +0,0 @@
-{% for new in news %} {% include 'news_card.html' %} {% endfor %}
diff --git a/src/simple_web_app/templates/oob_swap.html b/src/simple_web_app/templates/oob_swap.html
@@ -1 +0,0 @@
-{% include 'news_cards.html' %} {% include 'load_more.html' %}
diff --git a/src/simple_web_app/templates/settings.html b/src/simple_web_app/templates/settings.html
@@ -1,96 +0,0 @@
-<div
- id="settings-modal"
- class="modal-is-open modal-is-opening"
- _="on closeModal remove .modal-is-opening then wait 10 then add .modal-is-closing then wait for animationend then remove me"
->
- <dialog _="on click if target is me then trigger closeModal" open>
- <article>
- <div class="overflow-auto" style="text-wrap: nowrap">
- <nav>
- <ul>
- <li>
- <div
- id="general-tab"
- hx-get="{{ url_for('settings_tab').include_query_params(tab='general') }}"
- hx-target="#settings-tab"
- hx-swap="innerHTML"
- preload
- style="cursor: pointer"
- >
- General
- </div>
- </li>
- <li>
- <div
- id="categories-tab"
- hx-get="{{ url_for('settings_tab').include_query_params(tab='categories') }}"
- hx-target="#settings-tab"
- hx-swap="innerHTML"
- preload
- style="cursor: pointer"
- >
- Categories
- </div>
- </li>
- <li>
- <div
- id="sections-tab"
- hx-get="{{ url_for('settings_tab').include_query_params(tab='sections') }}"
- hx-target="#settings-tab"
- hx-swap="innerHTML"
- preload
- style="cursor: pointer"
- >
- Sections
- </div>
- </li>
- <li>
- <div
- id="content-filter-tab"
- hx-get="{{ url_for('settings_tab').include_query_params(tab='content-filter') }}"
- hx-target="#settings-tab"
- hx-swap="innerHTML"
- preload
- style="cursor: pointer"
- >
- Content Filter
- </div>
- </li>
- <li>
- <div
- id="syncing-tab"
- hx-get="{{ url_for('settings_tab').include_query_params(tab='syncing') }}"
- hx-target="#settings-tab"
- hx-swap="innerHTML"
- preload
- style="cursor: pointer"
- >
- Syncing
- </div>
- </li>
- <li>
- <div
- id="experimental-tab"
- hx-get="{{ url_for('settings_tab').include_query_params(tab='experimental') }}"
- hx-target="#settings-tab"
- hx-swap="innerHTML"
- preload
- style="cursor: pointer"
- >
- Experimental
- </div>
- </li>
- </ul>
- </nav>
- </div>
- <hr />
- <div id="settings-tab">{% include 'settings_tab.html' %}</div>
- <footer>
- <button _="on click trigger closeModal" class="secondary">
- Cancel
- </button>
- <button>Save</button>
- </footer>
- </article>
- </dialog>
-</div>
diff --git a/src/simple_web_app/templates/settings_tab.html b/src/simple_web_app/templates/settings_tab.html
@@ -1,501 +0,0 @@
-<section>
- {% if tab == "general" %}
- <h4>Appearance</h4>
-
- <label>
- Theme
- <details class="dropdown">
- <summary>System</summary>
- <ul>
- <li><a>System</a></li>
- <li><a>Light</a></li>
- <li><a>Dark</a></li>
- </ul>
- </details>
- </label>
-
- <label>
- Font Size
- <details class="dropdown">
- <summary>Normal</summary>
- <ul>
- <li><a href="#">Small</a></li>
- <li><a href="#">Normal</a></li>
- <li><a href="#">Large</a></li>
- </ul>
- </details>
- </label>
-
- <h4>Language & Region</h4>
-
- <label>
- Interface Language
- <details class="dropdown">
- <summary>English</summary>
- <ul>
- <li>English</li>
- <li>Spanish</li>
- <li>Danish</li>
- <li>Chinese</li>
- </ul>
- </details>
- </label>
-
- <label>
- Content Language
- <details class="dropdown">
- <summary>English</summary>
- <ul>
- <li>English</li>
- <li>Spanish</li>
- <li>Danish</li>
- <li>Chinese</li>
- </ul>
- </details>
- </label>
-
- <h4>Reading Experience</h4>
-
- <label>
- Number of Stories Shown: <output>10</output>
- <input
- type="range"
- value="10"
- min="1"
- max="30"
- oninput="this.previousElementSibling.value = this.value"
- />
- </label>
-
- <label>
- Story Open Mode
- <details class="dropdown">
- <summary>Multiple stories</summary>
- <ul>
- <li>Multiple stories</li>
- <li>One story at a time</li>
- </ul>
- </details>
- </label>
-
- <label>
- Story Expand Mode
- <details class="dropdown">
- <summary>Double-click to expand all</summary>
- <ul>
- <li>Always expand all</li>
- <li>Double-click to expand all</li>
- <li>Never expand all</li>
- </ul>
- </details>
- </label>
-
- <h4>Navigation</h4>
-
- <label>
- Use Latest URLs
- <details class="dropdown">
- <summary>Disabled</summary>
- <ul>
- <li>Disabled</li>
- <li>Enabled</li>
- </ul>
- </details>
- </label>
-
- <h4>About</h4>
-
- <label>
- Maps Provider
- <details class="dropdown">
- <summary>Auto</summary>
- <ul>
- <li>Auto</li>
- <li>Kagi Maps</li>
- <li>Google Maps</li>
- <li>OpenStreetMap</li>
- <li>Apple Maps</li>
- </ul>
- </details>
- </label>
- {% elif tab == "sections" %}
- <hgroup>
- <h4>Article Sections</h4>
- <h6>Drag to reorder sections. Use the switch to enable/disable.</h6>
- </hgroup>
- <article>
- <nav>
- <ul>
- <li>Summary</li>
- </ul>
- <ul>
- <li>
- <label><input name="summary" type="checkbox" role="switch" /></label>
- </li>
- </ul>
- </nav>
- </article>
- <article>
- <nav>
- <ul>
- <li>Primary Image</li>
- </ul>
- <ul>
- <li>
- <label><input name="summary" type="checkbox" role="switch" /></label>
- </li>
- </ul>
- </nav>
- </article>
- <article>
- <nav>
- <ul>
- <li>Sources</li>
- </ul>
- <ul>
- <li>
- <label><input name="summary" type="checkbox" role="switch" /></label>
- </li>
- </ul>
- </nav>
- </article>
- <article>
- <nav>
- <ul>
- <li>Highlights</li>
- </ul>
- <ul>
- <li>
- <label><input name="summary" type="checkbox" role="switch" /></label>
- </li>
- </ul>
- </nav>
- </article>
- <article>
- <nav>
- <ul>
- <li>Quotes</li>
- </ul>
- <ul>
- <li>
- <label><input name="summary" type="checkbox" role="switch" /></label>
- </li>
- </ul>
- </nav>
- </article>
- <article>
- <nav>
- <ul>
- <li>Secondary Image</li>
- </ul>
- <ul>
- <li>
- <label><input name="summary" type="checkbox" role="switch" /></label>
- </li>
- </ul>
- </nav>
- </article>
- <article>
- <nav>
- <ul>
- <li>Perspectives</li>
- </ul>
- <ul>
- <li>
- <label><input name="summary" type="checkbox" role="switch" /></label>
- </li>
- </ul>
- </nav>
- </article>
- <article>
- <nav>
- <ul>
- <li>Historical Background</li>
- </ul>
- <ul>
- <li>
- <label><input name="summary" type="checkbox" role="switch" /></label>
- </li>
- </ul>
- </nav>
- </article>
- <article>
- <nav>
- <ul>
- <li>Humanitarian Impact</li>
- </ul>
- <ul>
- <li>
- <label><input name="summary" type="checkbox" role="switch" /></label>
- </li>
- </ul>
- </nav>
- </article>
- <article>
- <nav>
- <ul>
- <li>Technical Details</li>
- </ul>
- <ul>
- <li>
- <label><input name="summary" type="checkbox" role="switch" /></label>
- </li>
- </ul>
- </nav>
- </article>
- <article>
- <nav>
- <ul>
- <li>Business Angle</li>
- </ul>
- <ul>
- <li>
- <label><input name="summary" type="checkbox" role="switch" /></label>
- </li>
- </ul>
- </nav>
- </article>
- <article>
- <nav>
- <ul>
- <li>Scientific Significance</li>
- </ul>
- <ul>
- <li>
- <label><input name="summary" type="checkbox" role="switch" /></label>
- </li>
- </ul>
- </nav>
- </article>
- <article>
- <nav>
- <ul>
- <li>Travel Advisory</li>
- </ul>
- <ul>
- <li>
- <label><input name="summary" type="checkbox" role="switch" /></label>
- </li>
- </ul>
- </nav>
- </article>
- <nav>
- <ul></ul>
- <ul>
- <li>
- <button class="secondary">Reset Order</button>
- <button>Toggle All</button>
- </li>
- </ul>
- <ul></ul>
- </nav>
- {% elif tab == "content-filter" %}
- <hgroup>
- <h4>Filter Presets</h4>
- <p>Select one or more preset filters to quickly hide common topics</p>
- </hgroup>
- <select aria-label="Topics" multiple size="6">
- <option>Cheese</option>
- <option selected>Fruits</option>
- <option selected>Nuts</option>
- <option>Chocolate</option>
- <option>Crackers</option>
- </select>
- <hgroup>
- <h4>Custom Keywords</h4>
- <p>Add your own keywords to filter, separated by commas</p>
- </hgroup>
- <input
- type="text"
- name="custom-keywords"
- placeholder="e.g., celebrity name, topic"
- aria-label="Custom Keywords"
- />
- <hgroup>
- <h4>Filter Mode</h4>
- <p>Choose how filtered content is handled</p>
- </hgroup>
- <fieldset>
- <label>
- <input type="radio" name="language" checked />
- Hide completely
- </label>
- <label>
- <input type="radio" name="language" />
- Blur with warning
- </label>
- </fieldset>
- <hgroup>
- <h4>Filter Scope</h4>
- <p>Choose which parts of stories to check for keywords</p>
- </hgroup>
- <select name="filter-scope" required>
- <option>Title only</option>
- <option>Title and summary</option>
- <option selected>All content</option>
- </select>
- <nav>
- <ul>
- <li>
- <hgroup>
- <h4>Show Filtered Count</h4>
- <p>Display number of filtered stories in each category</p>
- </hgroup>
- </li>
- </ul>
- <ul>
- <li>
- <input name="terms" type="checkbox" role="switch" />
- </li>
- </ul>
- </nav>
- <hr />
- <hgroup>
- <h4>Backup & Restore</h4>
- <p>
- Export your filter settings to a file or import from a previous backup
- </p>
- </hgroup>
- <button class="secondary">Export Settings</button>
- <button>Import Settings</button>
- <hr />
- <button class="outline">Reset Content Filter Settings to Defaults</button>
- {% elif tab == "syncing" %}
- <p>
- Kagi News can sync your settings and read history across all your devices.
- This data is stored securely on Kagi servers and associated with your Kagi
- Search account. Your synced data is not used for any other purpose, not
- shared with anyone, and is solely stored to provide the sync service to you.
- You have full control over what gets synced and can delete your data at any
- time.
- </p>
- <h4>Sync Preferences</h4>
- <label>
- <input
- name="sync-settings"
- type="checkbox"
- role="switch"
- aria-describedby="sync-settings-description"
- />
- Sync Settings
- </label>
- <label>
- <input
- name="sync-read-history"
- type="checkbox"
- role="switch"
- aria-describedby="email-helper"
- />
- Sync Read History
- </label>
- <br />
- <h4>Data Management</h4>
- <p>
- You are in control of your data. Export or delete your cloud data at any
- time. Deletion does not affect local data.
- </p>
- <button class="secondary">Export Data</button>
- <button>Clear All Synced Data</button>
- {% elif tab == "experimental" %}
- <p>
- ⚠️ These are experimental features that may be changed or removed at any
- time.
- </p>
- <article>
- <nav>
- <ul>
- <li>
- <hgroup>
- <h4>Show icons next to articles</h4>
- <p>
- When enabled, small icons will be displayed next to articles to
- provide visual context.
- </p>
- </hgroup>
- </li>
- </ul>
- <ul>
- <li>
- <input
- name="show-icons-next-to-articles"
- type="checkbox"
- role="switch"
- />
- </li>
- </ul>
- </nav>
- </article>
- <article>
- <nav>
- <ul>
- <li>
- <hgroup>
- <h4>Show icons in category labels</h4>
- <p>
- When enabled, small icons will be displayed next to category
- labels to provide visual context.
- </p>
- </hgroup>
- </li>
- </ul>
- <ul>
- <li>
- <input
- name="show-icons-next-to-category-labels"
- type="checkbox"
- role="switch"
- />
- </li>
- </ul>
- </nav>
- </article>
- <article>
- <nav>
- <ul>
- <li>
- <hgroup>
- <h4>Disable horizontal category swiping</h4>
- <p>
- When enabled, horizontal swiping to change categories on mobile
- devices will be disabled.
- </p>
- </hgroup>
- </li>
- </ul>
- <ul>
- <li>
- <input
- name="disable-horizontal-category-swiping"
- type="checkbox"
- role="switch"
- />
- </li>
- </ul>
- </nav>
- </article>
- <article>
- <nav>
- <ul>
- <li>
- <hgroup>
- <h4>Show World Tension Index</h4>
- <p>
- Display a global temperature reading of world stability based on
- current events.
- </p>
- </hgroup>
- </li>
- </ul>
- <ul>
- <li>
- <input
- name="show-icons-next-to-articles"
- type="checkbox"
- role="switch"
- />
- </li>
- </ul>
- </nav>
- </article>
- {% endif %}
-</section>
diff --git a/src/state.rs b/src/state.rs
@@ -0,0 +1,39 @@
+use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
+use sqlx::SqlitePool;
+use std::str::FromStr;
+
+use crate::config::Config;
+
+#[derive(Clone)]
+pub struct AppState {
+ pub pool: SqlitePool,
+}
+
+impl AppState {
+ pub async fn new(config: &Config) -> Result<Self, sqlx::Error> {
+ let options = SqliteConnectOptions::from_str(&config.database_url)?
+ .create_if_missing(true)
+ .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
+ .foreign_keys(true)
+ .synchronous(sqlx::sqlite::SqliteSynchronous::Normal)
+ .busy_timeout(std::time::Duration::from_secs(5));
+
+ let pool = SqlitePoolOptions::new()
+ .max_connections(5)
+ .connect_with(options)
+ .await?;
+
+ // Run additional pragmas
+ sqlx::query("PRAGMA cache_size = 1000000000")
+ .execute(&pool)
+ .await?;
+ sqlx::query("PRAGMA temp_store = MEMORY")
+ .execute(&pool)
+ .await?;
+
+ // Run migrations
+ sqlx::migrate!("./migrations").run(&pool).await?;
+
+ Ok(Self { pool })
+ }
+}
diff --git a/src/templates.rs b/src/templates.rs
@@ -0,0 +1,76 @@
+use askama::Template;
+
+use crate::db::{Category, NewsWithCategories, SearchResult};
+
+#[derive(Template)]
+#[template(path = "application.html")]
+pub struct ApplicationTemplate {
+ pub content: String,
+}
+
+#[derive(Template)]
+#[template(path = "index.html")]
+pub struct IndexTemplate {
+ pub news: Vec<NewsWithCategories>,
+ pub categories: Vec<Category>,
+ pub page: i64,
+ pub current_time: String,
+ pub category_id: Option<i64>,
+ pub reached_end: bool,
+ pub oob: bool,
+}
+
+#[derive(Template)]
+#[template(path = "oob_swap.html")]
+pub struct OobSwapTemplate {
+ pub news: Vec<NewsWithCategories>,
+ pub page: i64,
+ pub current_time: String,
+ pub category_id: Option<i64>,
+ pub reached_end: bool,
+ pub oob: bool,
+}
+
+#[derive(Template)]
+#[template(path = "news_cards.html")]
+pub struct NewsCardsTemplate<'a> {
+ pub news: &'a [NewsWithCategories],
+}
+
+#[derive(Template)]
+#[template(path = "news_card.html")]
+pub struct NewsCardTemplate<'a> {
+ pub new: &'a NewsWithCategories,
+}
+
+#[derive(Template)]
+#[template(path = "load_more.html")]
+pub struct LoadMoreTemplate {
+ pub page: i64,
+ pub current_time: String,
+ pub category_id: Option<i64>,
+ pub reached_end: bool,
+ pub oob: bool,
+}
+
+#[derive(Template)]
+#[template(path = "search.html")]
+pub struct SearchTemplate;
+
+#[derive(Template)]
+#[template(path = "search_results.html")]
+pub struct SearchResultsTemplate {
+ pub news: Vec<SearchResult>,
+}
+
+#[derive(Template)]
+#[template(path = "settings.html")]
+pub struct SettingsTemplate {
+ pub tab: String,
+}
+
+#[derive(Template)]
+#[template(path = "settings_tab.html")]
+pub struct SettingsTabTemplate {
+ pub tab: String,
+}
diff --git a/src/simple_web_app/static/style.css b/static/style.css
diff --git a/templates/application.html b/templates/application.html
@@ -0,0 +1,21 @@
+<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <title>My Chat</title>
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.7/dist/htmx.min.js"></script>
+ <script src="https://cdn.jsdelivr.net/npm/htmx-ext-preload@2.1.0"></script>
+ <script src="https://unpkg.com/hyperscript.org@0.9.14"></script>
+ <link rel="stylesheet" href="/static/style.css" />
+ <link
+ rel="stylesheet"
+ href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.amber.min.css"
+ />
+ </head>
+ <body hx-ext="preload">
+ <main class="container">
+ <div id="content">{{ content|safe }}</div>
+ </main>
+ </body>
+</html>
diff --git a/templates/index.html b/templates/index.html
@@ -0,0 +1,180 @@
+<div id="login">
+ <!-- <article> -->
+ <nav>
+ <ul>
+ <li>
+ <a href="/" class="secondary"
+ ><img src="https://kite.kagi.com/svg/kagi_news_compact_dark.svg"
+ /></a>
+ </li>
+ </ul>
+ <ul>
+ <li>
+ <strong
+ style="cursor: pointer"
+ data-tooltip="Choose another date"
+ data-placement="bottom"
+ >Tuesday, September 30</strong
+ >
+ </li>
+ </ul>
+ <ul>
+ <li>
+ <a
+ id="search-btn"
+ style="cursor: pointer"
+ data-tooltip="Search"
+ hx-trigger="click, keyup[ctrlKey&&shiftKey&&key=='O']"
+ hx-get="/search"
+ hx-target="#modal-placeholder"
+ hx-swap="innerHTML"
+ preload
+ class="secondary"
+ ><svg
+ height="20pt"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 640 640"
+ >
+ <!--!Font Awesome Free v7.0.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
+ <path
+ d="M480 272C480 317.9 465.1 360.3 440 394.7L566.6 521.4C579.1 533.9 579.1 554.2 566.6 566.7C554.1 579.2 533.8 579.2 521.3 566.7L394.7 440C360.3 465.1 317.9 480 272 480C157.1 480 64 386.9 64 272C64 157.1 157.1 64 272 64C386.9 64 480 157.1 480 272zM272 416C351.5 416 416 351.5 416 272C416 192.5 351.5 128 272 128C192.5 128 128 192.5 128 272C128 351.5 192.5 416 272 416z"
+ /></svg
+ ></a>
+ </li>
+ <li>
+ <a id="font-size-btn" data-tooltip="Change font size" class="secondary"
+ ><svg
+ height="20pt"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 640 640"
+ >
+ <!--!Font Awesome Free v7.0.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
+ <path
+ d="M349.1 114.7C343.9 103.3 332.5 96 320 96C307.5 96 296.1 103.3 290.9 114.7L123.5 480L112 480C94.3 480 80 494.3 80 512C80 529.7 94.3 544 112 544L200 544C217.7 544 232 529.7 232 512C232 494.3 217.7 480 200 480L193.9 480L215.9 432L424.2 432L446.2 480L440.1 480C422.4 480 408.1 494.3 408.1 512C408.1 529.7 422.4 544 440.1 544L528.1 544C545.8 544 560.1 529.7 560.1 512C560.1 494.3 545.8 480 528.1 480L516.6 480L349.2 114.7zM394.8 368L245.2 368L320 204.8L394.8 368z"
+ /></svg
+ ></a>
+ </li>
+ <li>
+ <a
+ id="settings-btn"
+ style="cursor: pointer"
+ data-tooltip="Settings"
+ hx-get="/settings"
+ hx-target="#modal-placeholder"
+ hx-swap="innerHTML"
+ preload
+ class="secondary"
+ ><svg
+ height="20pt"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 640 640"
+ >
+ <!--!Font Awesome Free v7.0.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
+ <path
+ d="M259.1 73.5C262.1 58.7 275.2 48 290.4 48L350.2 48C365.4 48 378.5 58.7 381.5 73.5L396 143.5C410.1 149.5 423.3 157.2 435.3 166.3L503.1 143.8C517.5 139 533.3 145 540.9 158.2L570.8 210C578.4 223.2 575.7 239.8 564.3 249.9L511 297.3C511.9 304.7 512.3 312.3 512.3 320C512.3 327.7 511.8 335.3 511 342.7L564.4 390.2C575.8 400.3 578.4 417 570.9 430.1L541 481.9C533.4 495 517.6 501.1 503.2 496.3L435.4 473.8C423.3 482.9 410.1 490.5 396.1 496.6L381.7 566.5C378.6 581.4 365.5 592 350.4 592L290.6 592C275.4 592 262.3 581.3 259.3 566.5L244.9 496.6C230.8 490.6 217.7 482.9 205.6 473.8L137.5 496.3C123.1 501.1 107.3 495.1 99.7 481.9L69.8 430.1C62.2 416.9 64.9 400.3 76.3 390.2L129.7 342.7C128.8 335.3 128.4 327.7 128.4 320C128.4 312.3 128.9 304.7 129.7 297.3L76.3 249.8C64.9 239.7 62.3 223 69.8 209.9L99.7 158.1C107.3 144.9 123.1 138.9 137.5 143.7L205.3 166.2C217.4 157.1 230.6 149.5 244.6 143.4L259.1 73.5zM320.3 400C364.5 399.8 400.2 363.9 400 319.7C399.8 275.5 363.9 239.8 319.7 240C275.5 240.2 239.8 276.1 240 320.3C240.2 364.5 276.1 400.2 320.3 400z"
+ /></svg
+ ></a>
+ </li>
+ </ul>
+ </nav>
+
+ <div class="overflow-auto" style="text-wrap: nowrap">
+ <table>
+ <tr>
+ {% for category in categories %}
+ <th>
+ <div
+ hx-get="/?category_id={{ category.id }}"
+ hx-target="#news-cards"
+ preload
+ style="cursor: pointer"
+ >
+ {% match category.name %}
+ {% when Some with (name) %}
+ {{ name }}
+ {% when None %}
+ {% endmatch %}
+ </div>
+ </th>
+ {% endfor %}
+ </tr>
+ </table>
+ </div>
+
+ <div id="modal-placeholder"></div>
+ <div id="news-cards-with-buttons">
+ <div id="news-cards">{% include "news_cards.html" %}</div>
+ <nav>
+ <ul></ul>
+ <ul>
+ <li>{% include "load_more.html" %}</li>
+ <li><button>Mark all as read</button></li>
+ </ul>
+ <ul></ul>
+ </nav>
+ </div>
+ <!-- </article> -->
+ <footer>
+ <nav>
+ <ul></ul>
+ <ul>
+ <li>
+ <a href="https://github.com/silasbrack/simple-web-app"
+ ><svg
+ height="24pt"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 640 640"
+ >
+ <!--!Font Awesome Free v7.0.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.-->
+ <path
+ d="M237.9 461.4C237.9 463.4 235.6 465 232.7 465C229.4 465.3 227.1 463.7 227.1 461.4C227.1 459.4 229.4 457.8 232.3 457.8C235.3 457.5 237.9 459.1 237.9 461.4zM206.8 456.9C206.1 458.9 208.1 461.2 211.1 461.8C213.7 462.8 216.7 461.8 217.3 459.8C217.9 457.8 216 455.5 213 454.6C210.4 453.9 207.5 454.9 206.8 456.9zM251 455.2C248.1 455.9 246.1 457.8 246.4 460.1C246.7 462.1 249.3 463.4 252.3 462.7C255.2 462 257.2 460.1 256.9 458.1C256.6 456.2 253.9 454.9 251 455.2zM316.8 72C178.1 72 72 177.3 72 316C72 426.9 141.8 521.8 241.5 555.2C254.3 557.5 258.8 549.6 258.8 543.1C258.8 536.9 258.5 502.7 258.5 481.7C258.5 481.7 188.5 496.7 173.8 451.9C173.8 451.9 162.4 422.8 146 415.3C146 415.3 123.1 399.6 147.6 399.9C147.6 399.9 172.5 401.9 186.2 425.7C208.1 464.3 244.8 453.2 259.1 446.6C261.4 430.6 267.9 419.5 275.1 412.9C219.2 406.7 162.8 398.6 162.8 302.4C162.8 274.9 170.4 261.1 186.4 243.5C183.8 237 175.3 210.2 189 175.6C209.9 169.1 258 202.6 258 202.6C278 197 299.5 194.1 320.8 194.1C342.1 194.1 363.6 197 383.6 202.6C383.6 202.6 431.7 169 452.6 175.6C466.3 210.3 457.8 237 455.2 243.5C471.2 261.2 481 275 481 302.4C481 398.9 422.1 406.6 366.2 412.9C375.4 420.8 383.2 435.8 383.2 459.3C383.2 493 382.9 534.7 382.9 542.9C382.9 549.4 387.5 557.3 400.2 555C500.2 521.8 568 426.9 568 316C568 177.3 455.5 72 316.8 72zM169.2 416.9C167.9 417.9 168.2 420.2 169.9 422.1C171.5 423.7 173.8 424.4 175.1 423.1C176.4 422.1 176.1 419.8 174.4 417.9C172.8 416.3 170.5 415.6 169.2 416.9zM158.4 408.8C157.7 410.1 158.7 411.7 160.7 412.7C162.3 413.7 164.3 413.4 165 412C165.7 410.7 164.7 409.1 162.7 408.1C160.7 407.5 159.1 407.8 158.4 408.8zM190.8 444.4C189.2 445.7 189.8 448.7 192.1 450.6C194.4 452.9 197.3 453.2 198.6 451.6C199.9 450.3 199.3 447.3 197.3 445.4C195.1 443.1 192.1 442.8 190.8 444.4zM179.4 429.7C177.8 430.7 177.8 433.3 179.4 435.6C181 437.9 183.7 438.9 185 437.9C186.6 436.6 186.6 434 185 431.7C183.6 429.4 181 428.4 179.4 429.7z"
+ />
+ </svg>
+ Contribute</a
+ >
+ </li>
+
+ <li>
+ <a href=""
+ ><svg
+ height="20pt"
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
+ class="size-6"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"
+ />
+ </svg>
+ About</a
+ >
+ </li>
+
+ <li>
+ <a href=""
+ ><svg
+ height="20pt"
+ xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 24 24"
+ fill="currentColor"
+ class="size-6"
+ >
+ <path
+ fill-rule="evenodd"
+ d="M3.75 4.5a.75.75 0 0 1 .75-.75h.75c8.284 0 15 6.716 15 15v.75a.75.75 0 0 1-.75.75h-.75a.75.75 0 0 1-.75-.75v-.75C18 11.708 12.292 6 5.25 6H4.5a.75.75 0 0 1-.75-.75V4.5Zm0 6.75a.75.75 0 0 1 .75-.75h.75a8.25 8.25 0 0 1 8.25 8.25v.75a.75.75 0 0 1-.75.75H12a.75.75 0 0 1-.75-.75v-.75a6 6 0 0 0-6-6H4.5a.75.75 0 0 1-.75-.75v-.75Zm0 7.5a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0Z"
+ clip-rule="evenodd"
+ />
+ </svg>
+ RSS Feed</a
+ >
+ </li>
+ </ul>
+ <ul></ul>
+ </nav>
+ </footer>
+</div>
diff --git a/templates/load_more.html b/templates/load_more.html
@@ -0,0 +1,12 @@
+<button
+ id="load-more-btn"
+ {% if reached_end %}disabled{% endif %}
+ {% if oob %}hx-swap-oob="true"{% endif %}
+ hx-target="#news-cards"
+ hx-swap="beforeend"
+ hx-get="/?page={{ page + 1 }}&current_time={{ current_time }}{% if let Some(cat_id) = category_id %}&category_id={{ cat_id }}{% endif %}"
+ preload
+ class="secondary"
+>
+ Load more
+</button>
diff --git a/templates/news_card.html b/templates/news_card.html
@@ -0,0 +1,30 @@
+<header>
+ <nav>
+ <ul>
+ {% for category in new.categories %}
+ <li>
+ <div
+ style="cursor: pointer"
+ hx-get="/?category_id={{ category.id }}"
+ hx-target="#news-cards"
+ preload
+ >
+ <small>{% match category.name %}{% when Some with (name) %}{{ name }}{% when None %}{% endmatch %}</small>
+ </div>
+ </li>
+ {% endfor %}
+ </ul>
+ </nav>
+</header>
+<details>
+ <summary>
+ <strong>{{ new.title }}</strong>
+ </summary>
+ <p><small>{{ new.text }}</small></p>
+</details>
+<footer>
+ <small>
+ <em> {{ new.time_since_published }} </em>
+ </small>
+</footer>
+<hr />
diff --git a/templates/news_cards.html b/templates/news_cards.html
@@ -0,0 +1 @@
+{% for new in news %}{% include "news_card.html" %}{% endfor %}
diff --git a/templates/oob_swap.html b/templates/oob_swap.html
@@ -0,0 +1 @@
+{% include "news_cards.html" %}{% include "load_more.html" %}
diff --git a/src/simple_web_app/templates/search.html b/templates/search.html
diff --git a/src/simple_web_app/templates/search_results.html b/templates/search_results.html
diff --git a/templates/settings.html b/templates/settings.html
@@ -0,0 +1,96 @@
+<div
+ id="settings-modal"
+ class="modal-is-open modal-is-opening"
+ _="on closeModal remove .modal-is-opening then wait 10 then add .modal-is-closing then wait for animationend then remove me"
+>
+ <dialog _="on click if target is me then trigger closeModal" open>
+ <article>
+ <div class="overflow-auto" style="text-wrap: nowrap">
+ <nav>
+ <ul>
+ <li>
+ <div
+ id="general-tab"
+ hx-get="/settings/tab?tab=general"
+ hx-target="#settings-tab"
+ hx-swap="innerHTML"
+ preload
+ style="cursor: pointer"
+ >
+ General
+ </div>
+ </li>
+ <li>
+ <div
+ id="categories-tab"
+ hx-get="/settings/tab?tab=categories"
+ hx-target="#settings-tab"
+ hx-swap="innerHTML"
+ preload
+ style="cursor: pointer"
+ >
+ Categories
+ </div>
+ </li>
+ <li>
+ <div
+ id="sections-tab"
+ hx-get="/settings/tab?tab=sections"
+ hx-target="#settings-tab"
+ hx-swap="innerHTML"
+ preload
+ style="cursor: pointer"
+ >
+ Sections
+ </div>
+ </li>
+ <li>
+ <div
+ id="content-filter-tab"
+ hx-get="/settings/tab?tab=content-filter"
+ hx-target="#settings-tab"
+ hx-swap="innerHTML"
+ preload
+ style="cursor: pointer"
+ >
+ Content Filter
+ </div>
+ </li>
+ <li>
+ <div
+ id="syncing-tab"
+ hx-get="/settings/tab?tab=syncing"
+ hx-target="#settings-tab"
+ hx-swap="innerHTML"
+ preload
+ style="cursor: pointer"
+ >
+ Syncing
+ </div>
+ </li>
+ <li>
+ <div
+ id="experimental-tab"
+ hx-get="/settings/tab?tab=experimental"
+ hx-target="#settings-tab"
+ hx-swap="innerHTML"
+ preload
+ style="cursor: pointer"
+ >
+ Experimental
+ </div>
+ </li>
+ </ul>
+ </nav>
+ </div>
+ <hr />
+ <div id="settings-tab">{% include "settings_tab.html" %}</div>
+ <footer>
+ <button _="on click trigger closeModal" class="secondary">
+ Cancel
+ </button>
+ <button>Save</button>
+ </footer>
+ </article>
+ </dialog>
+</div>
diff --git a/templates/settings_tab.html b/templates/settings_tab.html
@@ -0,0 +1,503 @@
+<section>
+ {% if tab == "general" %}
+ <h4>Appearance</h4>
+
+ <label>
+ Theme
+ <details class="dropdown">
+ <summary>System</summary>
+ <ul>
+ <li><a>System</a></li>
+ <li><a>Light</a></li>
+ <li><a>Dark</a></li>
+ </ul>
+ </details>
+ </label>
+
+ <label>
+ Font Size
+ <details class="dropdown">
+ <summary>Normal</summary>
+ <ul>
+ <li><a href="#">Small</a></li>
+ <li><a href="#">Normal</a></li>
+ <li><a href="#">Large</a></li>
+ </ul>
+ </details>
+ </label>
+
+ <h4>Language & Region</h4>
+
+ <label>
+ Interface Language
+ <details class="dropdown">
+ <summary>English</summary>
+ <ul>
+ <li>English</li>
+ <li>Spanish</li>
+ <li>Danish</li>
+ <li>Chinese</li>
+ </ul>
+ </details>
+ </label>
+
+ <label>
+ Content Language
+ <details class="dropdown">
+ <summary>English</summary>
+ <ul>
+ <li>English</li>
+ <li>Spanish</li>
+ <li>Danish</li>
+ <li>Chinese</li>
+ </ul>
+ </details>
+ </label>
+
+ <h4>Reading Experience</h4>
+
+ <label>
+ Number of Stories Shown: <output>10</output>
+ <input
+ type="range"
+ value="10"
+ min="1"
+ max="30"
+ oninput="this.previousElementSibling.value = this.value"
+ />
+ </label>
+
+ <label>
+ Story Open Mode
+ <details class="dropdown">
+ <summary>Multiple stories</summary>
+ <ul>
+ <li>Multiple stories</li>
+ <li>One story at a time</li>
+ </ul>
+ </details>
+ </label>
+
+ <label>
+ Story Expand Mode
+ <details class="dropdown">
+ <summary>Double-click to expand all</summary>
+ <ul>
+ <li>Always expand all</li>
+ <li>Double-click to expand all</li>
+ <li>Never expand all</li>
+ </ul>
+ </details>
+ </label>
+
+ <h4>Navigation</h4>
+
+ <label>
+ Use Latest URLs
+ <details class="dropdown">
+ <summary>Disabled</summary>
+ <ul>
+ <li>Disabled</li>
+ <li>Enabled</li>
+ </ul>
+ </details>
+ </label>
+
+ <h4>About</h4>
+
+ <label>
+ Maps Provider
+ <details class="dropdown">
+ <summary>Auto</summary>
+ <ul>
+ <li>Auto</li>
+ <li>Kagi Maps</li>
+ <li>Google Maps</li>
+ <li>OpenStreetMap</li>
+ <li>Apple Maps</li>
+ </ul>
+ </details>
+ </label>
+ {% else if tab == "sections" %}
+ <hgroup>
+ <h4>Article Sections</h4>
+ <h6>Drag to reorder sections. Use the switch to enable/disable.</h6>
+ </hgroup>
+ <article>
+ <nav>
+ <ul>
+ <li>Summary</li>
+ </ul>
+ <ul>
+ <li>
+ <label><input name="summary" type="checkbox" role="switch" /></label>
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <article>
+ <nav>
+ <ul>
+ <li>Primary Image</li>
+ </ul>
+ <ul>
+ <li>
+ <label><input name="summary" type="checkbox" role="switch" /></label>
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <article>
+ <nav>
+ <ul>
+ <li>Sources</li>
+ </ul>
+ <ul>
+ <li>
+ <label><input name="summary" type="checkbox" role="switch" /></label>
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <article>
+ <nav>
+ <ul>
+ <li>Highlights</li>
+ </ul>
+ <ul>
+ <li>
+ <label><input name="summary" type="checkbox" role="switch" /></label>
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <article>
+ <nav>
+ <ul>
+ <li>Quotes</li>
+ </ul>
+ <ul>
+ <li>
+ <label><input name="summary" type="checkbox" role="switch" /></label>
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <article>
+ <nav>
+ <ul>
+ <li>Secondary Image</li>
+ </ul>
+ <ul>
+ <li>
+ <label><input name="summary" type="checkbox" role="switch" /></label>
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <article>
+ <nav>
+ <ul>
+ <li>Perspectives</li>
+ </ul>
+ <ul>
+ <li>
+ <label><input name="summary" type="checkbox" role="switch" /></label>
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <article>
+ <nav>
+ <ul>
+ <li>Historical Background</li>
+ </ul>
+ <ul>
+ <li>
+ <label><input name="summary" type="checkbox" role="switch" /></label>
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <article>
+ <nav>
+ <ul>
+ <li>Humanitarian Impact</li>
+ </ul>
+ <ul>
+ <li>
+ <label><input name="summary" type="checkbox" role="switch" /></label>
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <article>
+ <nav>
+ <ul>
+ <li>Technical Details</li>
+ </ul>
+ <ul>
+ <li>
+ <label><input name="summary" type="checkbox" role="switch" /></label>
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <article>
+ <nav>
+ <ul>
+ <li>Business Angle</li>
+ </ul>
+ <ul>
+ <li>
+ <label><input name="summary" type="checkbox" role="switch" /></label>
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <article>
+ <nav>
+ <ul>
+ <li>Scientific Significance</li>
+ </ul>
+ <ul>
+ <li>
+ <label><input name="summary" type="checkbox" role="switch" /></label>
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <article>
+ <nav>
+ <ul>
+ <li>Travel Advisory</li>
+ </ul>
+ <ul>
+ <li>
+ <label><input name="summary" type="checkbox" role="switch" /></label>
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <nav>
+ <ul></ul>
+ <ul>
+ <li>
+ <button class="secondary">Reset Order</button>
+ <button>Toggle All</button>
+ </li>
+ </ul>
+ <ul></ul>
+ </nav>
+ {% else if tab == "content-filter" %}
+ <hgroup>
+ <h4>Filter Presets</h4>
+ <p>Select one or more preset filters to quickly hide common topics</p>
+ </hgroup>
+ <select aria-label="Topics" multiple size="6">
+ <option>Cheese</option>
+ <option selected>Fruits</option>
+ <option selected>Nuts</option>
+ <option>Chocolate</option>
+ <option>Crackers</option>
+ </select>
+ <hgroup>
+ <h4>Custom Keywords</h4>
+ <p>Add your own keywords to filter, separated by commas</p>
+ </hgroup>
+ <input
+ type="text"
+ name="custom-keywords"
+ placeholder="e.g., celebrity name, topic"
+ aria-label="Custom Keywords"
+ />
+ <hgroup>
+ <h4>Filter Mode</h4>
+ <p>Choose how filtered content is handled</p>
+ </hgroup>
+ <fieldset>
+ <label>
+ <input type="radio" name="language" checked />
+ Hide completely
+ </label>
+ <label>
+ <input type="radio" name="language" />
+ Blur with warning
+ </label>
+ </fieldset>
+ <hgroup>
+ <h4>Filter Scope</h4>
+ <p>Choose which parts of stories to check for keywords</p>
+ </hgroup>
+ <select name="filter-scope" required>
+ <option>Title only</option>
+ <option>Title and summary</option>
+ <option selected>All content</option>
+ </select>
+ <nav>
+ <ul>
+ <li>
+ <hgroup>
+ <h4>Show Filtered Count</h4>
+ <p>Display number of filtered stories in each category</p>
+ </hgroup>
+ </li>
+ </ul>
+ <ul>
+ <li>
+ <input name="terms" type="checkbox" role="switch" />
+ </li>
+ </ul>
+ </nav>
+ <hr />
+ <hgroup>
+ <h4>Backup & Restore</h4>
+ <p>
+ Export your filter settings to a file or import from a previous backup
+ </p>
+ </hgroup>
+ <button class="secondary">Export Settings</button>
+ <button>Import Settings</button>
+ <hr />
+ <button class="outline">Reset Content Filter Settings to Defaults</button>
+ {% else if tab == "syncing" %}
+ <p>
+ Kagi News can sync your settings and read history across all your devices.
+ This data is stored securely on Kagi servers and associated with your Kagi
+ Search account. Your synced data is not used for any other purpose, not
+ shared with anyone, and is solely stored to provide the sync service to you.
+ You have full control over what gets synced and can delete your data at any
+ time.
+ </p>
+ <h4>Sync Preferences</h4>
+ <label>
+ <input
+ name="sync-settings"
+ type="checkbox"
+ role="switch"
+ aria-describedby="sync-settings-description"
+ />
+ Sync Settings
+ </label>
+ <label>
+ <input
+ name="sync-read-history"
+ type="checkbox"
+ role="switch"
+ aria-describedby="email-helper"
+ />
+ Sync Read History
+ </label>
+ <br />
+ <h4>Data Management</h4>
+ <p>
+ You are in control of your data. Export or delete your cloud data at any
+ time. Deletion does not affect local data.
+ </p>
+ <button class="secondary">Export Data</button>
+ <button>Clear All Synced Data</button>
+ {% else if tab == "experimental" %}
+ <p>
+ ⚠️ These are experimental features that may be changed or removed at any
+ time.
+ </p>
+ <article>
+ <nav>
+ <ul>
+ <li>
+ <hgroup>
+ <h4>Show icons next to articles</h4>
+ <p>
+ When enabled, small icons will be displayed next to articles to
+ provide visual context.
+ </p>
+ </hgroup>
+ </li>
+ </ul>
+ <ul>
+ <li>
+ <input
+ name="show-icons-next-to-articles"
+ type="checkbox"
+ role="switch"
+ />
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <article>
+ <nav>
+ <ul>
+ <li>
+ <hgroup>
+ <h4>Show icons in category labels</h4>
+ <p>
+ When enabled, small icons will be displayed next to category
+ labels to provide visual context.
+ </p>
+ </hgroup>
+ </li>
+ </ul>
+ <ul>
+ <li>
+ <input
+ name="show-icons-next-to-category-labels"
+ type="checkbox"
+ role="switch"
+ />
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <article>
+ <nav>
+ <ul>
+ <li>
+ <hgroup>
+ <h4>Disable horizontal category swiping</h4>
+ <p>
+ When enabled, horizontal swiping to change categories on mobile
+ devices will be disabled.
+ </p>
+ </hgroup>
+ </li>
+ </ul>
+ <ul>
+ <li>
+ <input
+ name="disable-horizontal-category-swiping"
+ type="checkbox"
+ role="switch"
+ />
+ </li>
+ </ul>
+ </nav>
+ </article>
+ <article>
+ <nav>
+ <ul>
+ <li>
+ <hgroup>
+ <h4>Show World Tension Index</h4>
+ <p>
+ Display a global temperature reading of world stability based on
+ current events.
+ </p>
+ </hgroup>
+ </li>
+ </ul>
+ <ul>
+ <li>
+ <input
+ name="show-icons-next-to-articles"
+ type="checkbox"
+ role="switch"
+ />
+ </li>
+ </ul>
+ </nav>
+ </article>
+ {% else %}
+ <p>Select a tab to view settings.</p>
+ {% endif %}
+</section>
diff --git a/tests/locustfile.py b/tests/locustfile.py
@@ -1,7 +0,0 @@
-from locust import HttpUser, task
-
-class QuickstartUser(HttpUser):
- @task
- def home_page(self):
- self.client.get("/")
-
diff --git a/tests/test_simple_web_app/conftest.py b/tests/test_simple_web_app/conftest.py
@@ -1,729 +0,0 @@
-import os
-import unittest.mock
-
-import pytest
-
-
-@pytest.fixture()
-def test_env() -> dict[str, str]:
- return {
- "DEBUG": "true",
- "DATABASE_PATH": ":memory:",
- }
-
-
-import typing
-import anyio
-import httpx
-import math
-import io
-import contextlib
-from urllib.parse import unquote, urljoin
-from anyio.streams.stapled import StapledObjectStream
-from starlette.types import ASGIApp, Message, Receive, Scope, Send
-from starlette.testclient import ASGI3App, _RequestData, _is_asgi3
-
-
-class AsyncWebSocketTestSession:
- def __init__(
- self,
- app: ASGI3App,
- scope: Scope,
- ) -> None:
- self.app = app
- self.scope = scope
- self.accepted_subprotocol = None
- self.extra_headers = None
-
- async def __aenter__(self) -> "AsyncWebSocketTestSession":
- async with contextlib.AsyncExitStack() as stack:
- task_group = await stack.enter_async_context(anyio.create_task_group())
- self.done = anyio.Event()
-
- async def run(
- *, task_status: anyio.abc.TaskStatus[anyio.CancelScope]
- ) -> None:
- await self._run(task_status=task_status)
- self.done.set()
-
- await task_group.start(run)
- stack.push_async_callback(self.done.wait)
- stack.callback(task_group.cancel_scope.cancel)
- await self.send({"type": "websocket.connect"})
- message = await self.receive()
- await self._raise_on_close(message)
- self.accepted_subprotocol = message.get("subprotocol", None)
- self.extra_headers = message.get("headers", None)
- stack.push_async_callback(self.aclose, 1000)
- self.exit_stack = stack.pop_all()
- return self
-
- async def __aexit__(self, *args: typing.Any) -> bool | None:
- return await self.exit_stack.__aexit__(*args)
-
- async def _run(
- self, *, task_status: anyio.abc.TaskStatus[anyio.CancelScope]
- ) -> None:
- send: anyio.create_memory_object_stream[Message] = (
- anyio.create_memory_object_stream(math.inf)
- )
- send_tx, send_rx = send
- receive: anyio.create_memory_object_stream[Message] = (
- anyio.create_memory_object_stream(math.inf)
- )
- receive_tx, receive_rx = receive
- with send_tx, send_rx, receive_tx, receive_rx, anyio.CancelScope() as cs:
- self._receive_tx = receive_tx
- self._send_rx = send_rx
- task_status.started(cs)
- await self.app(self.scope, receive_rx.receive, send_tx.send)
-
- # wait for cs.cancel to be called before closing streams
- await anyio.sleep_forever()
-
- async def _raise_on_close(self, message: Message) -> None:
- if message["type"] == "websocket.close":
- raise WebSocketDisconnect(
- code=message.get("code", 1000), reason=message.get("reason", "")
- )
- elif message["type"] == "websocket.http.response.start":
- status_code: int = message["status"]
- headers: list[tuple[bytes, bytes]] = message["headers"]
- body: list[bytes] = []
- while True:
- message = await self.receive()
- assert message["type"] == "websocket.http.response.body"
- body.append(message["body"])
- if not message.get("more_body", False):
- break
- raise WebSocketDenialResponse(
- status_code=status_code, headers=headers, content=b"".join(body)
- )
-
- async def send(self, message: Message) -> None:
- await self._receive_tx.send(message)
-
- async def send_text(self, data: str) -> None:
- await self.send({"type": "websocket.receive", "text": data})
-
- async def send_bytes(self, data: bytes) -> None:
- await self.send({"type": "websocket.receive", "bytes": data})
-
- async def send_json(
- self, data: typing.Any, mode: typing.Literal["text", "binary"] = "text"
- ) -> None:
- text = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
- if mode == "text":
- await self.send({"type": "websocket.receive", "text": text})
- else:
- await self.send(
- {"type": "websocket.receive", "bytes": text.encode("utf-8")}
- )
-
- async def aclose(self, code: int = 1000, reason: str | None = None) -> None:
- await self.send(
- {"type": "websocket.disconnect", "code": code, "reason": reason}
- )
-
- async def receive(self) -> Message:
- return await self._send_rx.receive()
-
- async def receive_text(self) -> str:
- message = await self.receive()
- await self._raise_on_close(message)
- return typing.cast(str, message["text"])
-
- async def receive_bytes(self) -> bytes:
- message = await self.receive()
- await self._raise_on_close(message)
- return typing.cast(bytes, message["bytes"])
-
- async def receive_json(
- self, mode: typing.Literal["text", "binary"] = "text"
- ) -> typing.Any:
- message = await self.receive()
- await self._raise_on_close(message)
- if mode == "text":
- text = message["text"]
- else:
- text = message["bytes"].decode("utf-8")
- return json.loads(text)
-
-
-class _AsyncTestClientTransport(httpx.AsyncBaseTransport):
- def __init__(
- self,
- app: ASGI3App,
- raise_server_exceptions: bool = True,
- root_path: str = "",
- *,
- client: tuple[str, int],
- app_state: dict[str, typing.Any],
- ) -> None:
- self.app = app
- self.raise_server_exceptions = raise_server_exceptions
- self.root_path = root_path
- self.app_state = app_state
- self.client = client
-
- async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
- scheme = request.url.scheme
- netloc = request.url.netloc.decode(encoding="ascii")
- path = request.url.path
- raw_path = request.url.raw_path
- query = request.url.query.decode(encoding="ascii")
-
- default_port = {"http": 80, "ws": 80, "https": 443, "wss": 443}[scheme]
-
- if ":" in netloc:
- host, port_string = netloc.split(":", 1)
- port = int(port_string)
- else:
- host = netloc
- port = default_port
-
- # Include the 'host' header.
- if "host" in request.headers:
- headers: list[tuple[bytes, bytes]] = []
- elif port == default_port: # pragma: no cover
- headers = [(b"host", host.encode())]
- else: # pragma: no cover
- headers = [(b"host", (f"{host}:{port}").encode())]
-
- # Include other request headers.
- headers += [
- (key.lower().encode(), value.encode())
- for key, value in request.headers.multi_items()
- ]
-
- scope: dict[str, typing.Any]
-
- if scheme in {"ws", "wss"}:
- subprotocol = request.headers.get("sec-websocket-protocol", None)
- if subprotocol is None:
- subprotocols: typing.Sequence[str] = []
- else:
- subprotocols = [value.strip() for value in subprotocol.split(",")]
- scope = {
- "type": "websocket",
- "path": unquote(path),
- "raw_path": raw_path.split(b"?", 1)[0],
- "root_path": self.root_path,
- "scheme": scheme,
- "query_string": query.encode(),
- "headers": headers,
- "client": self.client,
- "server": [host, port],
- "subprotocols": subprotocols,
- "state": self.app_state.copy(),
- "extensions": {"websocket.http.response": {}},
- }
- session = AsyncWebSocketTestSession(self.app, scope)
- raise _AsyncUpgrade(session)
-
- scope = {
- "type": "http",
- "http_version": "1.1",
- "method": request.method,
- "path": unquote(path),
- "raw_path": raw_path.split(b"?", 1)[0],
- "root_path": self.root_path,
- "scheme": scheme,
- "query_string": query.encode(),
- "headers": headers,
- "client": self.client,
- "server": [host, port],
- "extensions": {"http.response.debug": {}},
- "state": self.app_state.copy(),
- }
-
- request_complete = False
- response_started = False
- response_complete: anyio.Event
- raw_kwargs: dict[str, typing.Any] = {"stream": io.BytesIO()}
- template = None
- context = None
-
- async def receive() -> Message:
- nonlocal request_complete
-
- if request_complete:
- if not response_complete.is_set():
- await response_complete.wait()
- return {"type": "http.disconnect"}
-
- body = request.read()
- if isinstance(body, str):
- body_bytes: bytes = body.encode("utf-8") # pragma: no cover
- elif body is None:
- body_bytes = b"" # pragma: no cover
- elif isinstance(body, GeneratorType):
- try: # pragma: no cover
- chunk = body.send(None)
- if isinstance(chunk, str):
- chunk = chunk.encode("utf-8")
- return {"type": "http.request", "body": chunk, "more_body": True}
- except StopIteration: # pragma: no cover
- request_complete = True
- return {"type": "http.request", "body": b""}
- else:
- body_bytes = body
-
- request_complete = True
- return {"type": "http.request", "body": body_bytes}
-
- async def send(message: Message) -> None:
- nonlocal raw_kwargs, response_started, template, context
-
- if message["type"] == "http.response.start":
- assert not response_started, (
- 'Received multiple "http.response.start" messages.'
- )
- raw_kwargs["status_code"] = message["status"]
- raw_kwargs["headers"] = [
- (key.decode(), value.decode())
- for key, value in message.get("headers", [])
- ]
- response_started = True
- elif message["type"] == "http.response.body":
- assert response_started, (
- 'Received "http.response.body" without "http.response.start".'
- )
- assert not response_complete.is_set(), (
- 'Received "http.response.body" after response completed.'
- )
- body = message.get("body", b"")
- more_body = message.get("more_body", False)
- if request.method != "HEAD":
- raw_kwargs["stream"].write(body)
- if not more_body:
- raw_kwargs["stream"].seek(0)
- response_complete.set()
- elif message["type"] == "http.response.debug":
- template = message["info"]["template"]
- context = message["info"]["context"]
-
- try:
- response_complete = anyio.Event()
- await self.app(scope, receive, send)
- except BaseException as exc:
- if self.raise_server_exceptions:
- raise exc
-
- if self.raise_server_exceptions:
- assert response_started, "TestClient did not receive any response."
- elif not response_started:
- raw_kwargs = {
- "status_code": 500,
- "headers": [],
- "stream": io.BytesIO(),
- }
-
- raw_kwargs["stream"] = httpx.ByteStream(raw_kwargs["stream"].read())
-
- response = httpx.Response(**raw_kwargs, request=request)
- if template is not None:
- response.template = template # type: ignore[attr-defined]
- response.context = context # type: ignore[attr-defined]
- return response
-
-
-class _AsyncUpgrade(Exception):
- def __init__(self, session: AsyncWebSocketTestSession) -> None:
- self.session = session
-
-
-class AsyncTestClient(httpx.AsyncClient):
- __test__ = False
-
- def __init__(
- self,
- app: ASGIApp,
- base_url: str = "http://testserver",
- raise_server_exceptions: bool = True,
- root_path: str = "",
- cookies: httpx._types.CookieTypes | None = None,
- headers: dict[str, str] | None = None,
- follow_redirects: bool = True,
- client: tuple[str, int] = ("testclient", 50000),
- ) -> None:
- if _is_asgi3(app):
- asgi_app = app
- else:
- app = typing.cast(ASGI2App, app) # type: ignore[assignment]
- asgi_app = _WrapASGI2(app) # type: ignore[arg-type]
- self.app = asgi_app
- self.app_state: dict[str, typing.Any] = {}
- transport = _AsyncTestClientTransport(
- self.app,
- raise_server_exceptions=raise_server_exceptions,
- root_path=root_path,
- app_state=self.app_state,
- client=client,
- )
- if headers is None:
- headers = {}
- headers.setdefault("user-agent", "testclient")
- super().__init__(
- base_url=base_url,
- headers=headers,
- transport=transport,
- follow_redirects=follow_redirects,
- cookies=cookies,
- )
-
- async def request( # type: ignore[override]
- self,
- method: str,
- url: httpx._types.URLTypes,
- *,
- content: httpx._types.RequestContent | None = None,
- data: _RequestData | None = None,
- files: httpx._types.RequestFiles | None = None,
- json: typing.Any = None,
- params: httpx._types.QueryParamTypes | None = None,
- headers: httpx._types.HeaderTypes | None = None,
- cookies: httpx._types.CookieTypes | None = None,
- auth: httpx._types.AuthTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- follow_redirects: bool
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- timeout: httpx._types.TimeoutTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- extensions: dict[str, typing.Any] | None = None,
- ) -> httpx.Response:
- if timeout is not httpx.USE_CLIENT_DEFAULT:
- warnings.warn(
- "You should not use the 'timeout' argument with the TestClient. "
- "See https://github.com/encode/starlette/issues/1108 for more information.",
- DeprecationWarning,
- )
- url = self._merge_url(url)
- return await super().request(
- method,
- url,
- content=content,
- data=data,
- files=files,
- json=json,
- params=params,
- headers=headers,
- cookies=cookies,
- auth=auth,
- follow_redirects=follow_redirects,
- timeout=timeout,
- extensions=extensions,
- )
-
- async def get( # type: ignore[override]
- self,
- url: httpx._types.URLTypes,
- *,
- params: httpx._types.QueryParamTypes | None = None,
- headers: httpx._types.HeaderTypes | None = None,
- cookies: httpx._types.CookieTypes | None = None,
- auth: httpx._types.AuthTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- follow_redirects: bool
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- timeout: httpx._types.TimeoutTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- extensions: dict[str, typing.Any] | None = None,
- ) -> httpx.Response:
- return await super().get(
- url,
- params=params,
- headers=headers,
- cookies=cookies,
- auth=auth,
- follow_redirects=follow_redirects,
- timeout=timeout,
- extensions=extensions,
- )
-
- async def options( # type: ignore[override]
- self,
- url: httpx._types.URLTypes,
- *,
- params: httpx._types.QueryParamTypes | None = None,
- headers: httpx._types.HeaderTypes | None = None,
- cookies: httpx._types.CookieTypes | None = None,
- auth: httpx._types.AuthTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- follow_redirects: bool
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- timeout: httpx._types.TimeoutTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- extensions: dict[str, typing.Any] | None = None,
- ) -> httpx.Response:
- return await super().options(
- url,
- params=params,
- headers=headers,
- cookies=cookies,
- auth=auth,
- follow_redirects=follow_redirects,
- timeout=timeout,
- extensions=extensions,
- )
-
- async def head( # type: ignore[override]
- self,
- url: httpx._types.URLTypes,
- *,
- params: httpx._types.QueryParamTypes | None = None,
- headers: httpx._types.HeaderTypes | None = None,
- cookies: httpx._types.CookieTypes | None = None,
- auth: httpx._types.AuthTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- follow_redirects: bool
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- timeout: httpx._types.TimeoutTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- extensions: dict[str, typing.Any] | None = None,
- ) -> httpx.Response:
- return await super().head(
- url,
- params=params,
- headers=headers,
- cookies=cookies,
- auth=auth,
- follow_redirects=follow_redirects,
- timeout=timeout,
- extensions=extensions,
- )
-
- async def post( # type: ignore[override]
- self,
- url: httpx._types.URLTypes,
- *,
- content: httpx._types.RequestContent | None = None,
- data: _RequestData | None = None,
- files: httpx._types.RequestFiles | None = None,
- json: typing.Any = None,
- params: httpx._types.QueryParamTypes | None = None,
- headers: httpx._types.HeaderTypes | None = None,
- cookies: httpx._types.CookieTypes | None = None,
- auth: httpx._types.AuthTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- follow_redirects: bool
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- timeout: httpx._types.TimeoutTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- extensions: dict[str, typing.Any] | None = None,
- ) -> httpx.Response:
- return await super().post(
- url,
- content=content,
- data=data,
- files=files,
- json=json,
- params=params,
- headers=headers,
- cookies=cookies,
- auth=auth,
- follow_redirects=follow_redirects,
- timeout=timeout,
- extensions=extensions,
- )
-
- async def put( # type: ignore[override]
- self,
- url: httpx._types.URLTypes,
- *,
- content: httpx._types.RequestContent | None = None,
- data: _RequestData | None = None,
- files: httpx._types.RequestFiles | None = None,
- json: typing.Any = None,
- params: httpx._types.QueryParamTypes | None = None,
- headers: httpx._types.HeaderTypes | None = None,
- cookies: httpx._types.CookieTypes | None = None,
- auth: httpx._types.AuthTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- follow_redirects: bool
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- timeout: httpx._types.TimeoutTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- extensions: dict[str, typing.Any] | None = None,
- ) -> httpx.Response:
- return await super().put(
- url,
- content=content,
- data=data,
- files=files,
- json=json,
- params=params,
- headers=headers,
- cookies=cookies,
- auth=auth,
- follow_redirects=follow_redirects,
- timeout=timeout,
- extensions=extensions,
- )
-
- async def patch( # type: ignore[override]
- self,
- url: httpx._types.URLTypes,
- *,
- content: httpx._types.RequestContent | None = None,
- data: _RequestData | None = None,
- files: httpx._types.RequestFiles | None = None,
- json: typing.Any = None,
- params: httpx._types.QueryParamTypes | None = None,
- headers: httpx._types.HeaderTypes | None = None,
- cookies: httpx._types.CookieTypes | None = None,
- auth: httpx._types.AuthTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- follow_redirects: bool
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- timeout: httpx._types.TimeoutTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- extensions: dict[str, typing.Any] | None = None,
- ) -> httpx.Response:
- return await super().patch(
- url,
- content=content,
- data=data,
- files=files,
- json=json,
- params=params,
- headers=headers,
- cookies=cookies,
- auth=auth,
- follow_redirects=follow_redirects,
- timeout=timeout,
- extensions=extensions,
- )
-
- async def delete( # type: ignore[override]
- self,
- url: httpx._types.URLTypes,
- *,
- params: httpx._types.QueryParamTypes | None = None,
- headers: httpx._types.HeaderTypes | None = None,
- cookies: httpx._types.CookieTypes | None = None,
- auth: httpx._types.AuthTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- follow_redirects: bool
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- timeout: httpx._types.TimeoutTypes
- | httpx._client.UseClientDefault = httpx._client.USE_CLIENT_DEFAULT,
- extensions: dict[str, typing.Any] | None = None,
- ) -> httpx.Response:
- return await super().delete(
- url,
- params=params,
- headers=headers,
- cookies=cookies,
- auth=auth,
- follow_redirects=follow_redirects,
- timeout=timeout,
- extensions=extensions,
- )
-
- async def websocket_connect(
- self,
- url: str,
- subprotocols: typing.Sequence[str] | None = None,
- **kwargs: typing.Any,
- ) -> AsyncWebSocketTestSession:
- url = urljoin("ws://testserver", url)
- headers = kwargs.get("headers", {})
- headers.setdefault("connection", "upgrade")
- headers.setdefault("sec-websocket-key", "testserver==")
- headers.setdefault("sec-websocket-version", "13")
- if subprotocols is not None:
- headers.setdefault("sec-websocket-protocol", ", ".join(subprotocols))
- kwargs["headers"] = headers
- try:
- await super().request("GET", url, **kwargs)
- except _AsyncUpgrade as exc:
- session = exc.session
- else:
- raise RuntimeError("Expected WebSocket upgrade") # pragma: no cover
-
- return session
-
- async def __aenter__(self) -> "AsyncTestClient":
- async with contextlib.AsyncExitStack() as stack:
- task_group = await stack.enter_async_context(anyio.create_task_group())
- send: anyio.create_memory_object_stream[
- typing.MutableMapping[str, typing.Any] | None
- ] = anyio.create_memory_object_stream(math.inf)
- receive: anyio.create_memory_object_stream[
- typing.MutableMapping[str, typing.Any]
- ] = anyio.create_memory_object_stream(math.inf)
- for channel in (*send, *receive):
- stack.push_async_callback(channel.aclose)
- self.stream_send = StapledObjectStream(*send)
- self.stream_receive = StapledObjectStream(*receive)
- self.task_done = anyio.Event()
-
- async def lifespan() -> None:
- await self.lifespan()
- self.task_done.set()
-
- task_group.start_soon(lifespan)
- await self.wait_startup()
-
- @stack.push_async_callback
- async def wait_shutdown() -> None:
- await self.wait_shutdown()
-
- self.exit_stack = stack.pop_all()
-
- return self
-
- async def __aexit__(self, *args: typing.Any) -> None:
- await self.exit_stack.aclose()
-
- async def lifespan(self) -> None:
- scope = {"type": "lifespan", "state": self.app_state}
- try:
- await self.app(scope, self.stream_receive.receive, self.stream_send.send)
- finally:
- try:
- await self.stream_send.send(None)
- except anyio.ClosedResourceError:
- pass
-
- async def wait_startup(self) -> None:
- await self.stream_receive.send({"type": "lifespan.startup"})
-
- async def receive() -> typing.Any:
- message = await self.stream_send.receive()
- if message is None:
- await self.task_done.wait()
- return message
-
- message = await receive()
- assert message["type"] in (
- "lifespan.startup.complete",
- "lifespan.startup.failed",
- )
- if message["type"] == "lifespan.startup.failed":
- await receive()
-
- async def wait_shutdown(self) -> None:
- async def receive() -> typing.Any:
- message = await self.stream_send.receive()
- if message is None:
- await self.task_done.wait()
- return message
-
- await self.stream_receive.send({"type": "lifespan.shutdown"})
- message = await receive()
- assert message["type"] in (
- "lifespan.shutdown.complete",
- "lifespan.shutdown.failed",
- )
- if message["type"] == "lifespan.shutdown.failed":
- await receive()
-
-
-@pytest.fixture()
-async def async_test_client(test_env):
- with unittest.mock.patch.dict(os.environ, test_env, clear=True):
- from simple_web_app.app import app
-
- return AsyncTestClient(app)
diff --git a/tests/test_simple_web_app/test_app.py b/tests/test_simple_web_app/test_app.py
@@ -1,170 +0,0 @@
-import datetime
-import collections
-
-import aiosqlite
-import pytest
-from bs4 import BeautifulSoup
-
-
-DummyData = collections.namedtuple("DummyData", ["table", "cols", "rows"])
-
-
-async def insert_dummy_data(conn: aiosqlite.Connection, data: DummyData):
- await conn.executemany(f"INSERT INTO {data.table} ({', '.join(data.cols)}) VALUES ({', '.join(['?'] * len(data.cols))});", data.rows)
-
-
-@pytest.fixture()
-def test_news_item() -> DummyData:
- return DummyData(
- table="news_item",
- cols=["id", "url", "title", "text", "published", "author", "language"],
- rows=[
- (1, "https://example.com", "Test Title", "Asdf.", datetime.datetime(2025, 8, 1, 20, 42, 35, 123, tzinfo=datetime.UTC).isoformat(), "Silas", "english"),
- (2, "https://example.com", "Test Title", "Asdf.", datetime.datetime(2025, 8, 1, 20, 42, 35, 123, tzinfo=datetime.UTC).isoformat(), "Silas", "english"),
- (3, "https://example.com", "Test Title", "Asdf.", datetime.datetime(2025, 8, 1, 20, 42, 35, 123, tzinfo=datetime.UTC).isoformat(), "Silas", "english"),
- (4, "https://example.com", "Test Title", "Asdf.", datetime.datetime(2025, 8, 1, 20, 42, 35, 123, tzinfo=datetime.UTC).isoformat(), "Silas", "english"),
- (5, "https://example.com", "Test Title", "Asdf.", datetime.datetime(2025, 8, 1, 20, 42, 35, 123, tzinfo=datetime.UTC).isoformat(), "Silas", "english"),
- (6, "https://example.com", "Test Title", "Asdf.", datetime.datetime(2025, 8, 1, 20, 42, 35, 123, tzinfo=datetime.UTC).isoformat(), "Silas", "english"),
- ]
- )
-
-
-@pytest.fixture()
-def test_category() -> DummyData:
- return DummyData(
- table="category",
- cols=["id", "name"],
- rows=[(1, "Category 1")]
- )
-
-
-@pytest.fixture()
-def test_news_item_category() -> DummyData:
- return DummyData(
- table="news_item_category",
- cols=["id", "news_item_id", "category_id"],
- rows=[
- (1, 1, 1),
- (2, 2, 1),
- (3, 3, 1),
- (4, 4, 1),
- (5, 5, 1),
- (6, 6, 1),
- ]
- )
-
-
-@pytest.fixture()
-def test_data(test_news_item, test_category, test_news_item_category) -> tuple[DummyData, ...]:
- return (test_news_item, test_category, test_news_item_category)
-
-
-async def test_home_page(async_test_client, test_data):
- """Full home page render."""
- async with async_test_client as client:
- conn = client.app_state["conn"]
- for d in test_data:
- await insert_dummy_data(conn, d)
- response = await client.get("/")
-
- assert response.status_code == 200
- assert response.template.name == "application.html"
- assert len(response.context["news"]) > 0
-
-
-async def test_home_htmx(async_test_client, test_data):
- """Partial home template render for HTMX."""
- async with async_test_client as client:
- conn = client.app_state["conn"]
- for d in test_data:
- await insert_dummy_data(conn, d)
- response = await client.get("/", headers={"HX-Request": "true"})
-
- assert response.status_code == 200
- assert response.template.name == "oob_swap.html"
-
-
-async def test_select_chain(async_test_client, test_data):
- """"""
- async with async_test_client as client:
- conn = client.app_state["conn"]
- for d in test_data:
- await insert_dummy_data(conn, d)
-
- # Open the home page
- response = await client.get("/")
- assert response.status_code == 200
- assert response.template.name == "application.html"
- assert len(response.context["news"]) > 0
- soup = BeautifulSoup(response.text, features="html.parser")
- load_more_button = soup.find("button", {"id": "load-more-btn"})
- assert "page=1" in load_more_button.attrs["hx-get"]
- assert "category_id" not in load_more_button.attrs["hx-get"]
-
- # Load more data
- path = load_more_button["hx-get"]
- response = await client.get(path, headers={"HX-Request": "true"})
- assert response.status_code == 200
- assert len(response.context["news"]) > 0
-
- # Filter for category 2, which doesn't exist
- # TODO: should this instead throw an error?
- response = await client.get("/?category_id=2", headers={"HX-Request": "true"})
- assert response.template.name == "oob_swap.html"
- assert len(response.context["news"]) == 0
- assert response.context["category_id"] == 2
- soup = BeautifulSoup(response.text, features="html.parser")
- load_more_button = soup.find("button", {"id": "load-more-btn"})
- assert "page=1" in load_more_button.attrs["hx-get"]
- assert "category_id=2" in load_more_button.attrs["hx-get"]
-
- # Filter for category 1, show only news with that category
- response = await client.get("/?category_id=1", headers={"HX-Request": "true"})
- assert response.template.name == "oob_swap.html"
- assert len(response.context["news"]) > 0
- assert response.context["category_id"] == 1
- assert all(any(c["id"] == 1 for c in n["categories"]) for n in response.context["news"]) # Every row contains the category we filtered by
- soup = BeautifulSoup(response.text, features="html.parser")
- load_more_button = soup.find("button", {"id": "load-more-btn"})
- assert "disabled" not in load_more_button.attrs
- assert "page=1" in load_more_button.attrs["hx-get"]
- assert "category_id=1" in load_more_button.attrs["hx-get"]
-
- # Load more news, should all still be category one; we only have 1 left so the load more button should be disabled
- path = load_more_button["hx-get"]
- response = await client.get(path, headers={"HX-Request": "true"})
- assert response.template.name == "oob_swap.html"
- assert len(response.context["news"]) > 0
- assert response.context["category_id"] == 1
- assert all(any(c["id"] == 1 for c in n["categories"]) for n in response.context["news"]) # Every row contains the category we filtered by
- soup = BeautifulSoup(response.text, features="html.parser")
- load_more_button = soup.find("button", {"id": "load-more-btn"})
- assert "disabled" in load_more_button.attrs
- assert "page=2" in load_more_button.attrs["hx-get"]
- assert "category_id=1" in load_more_button.attrs["hx-get"]
-
-
-async def test_settings(async_test_client, test_data):
- async with async_test_client as client:
- # Open the home page
- response = await client.get("/")
- soup = BeautifulSoup(response.text, features="html.parser")
- modal_placeholder = soup.find("div", {"id": "modal-placeholder"})
- assert len(list(modal_placeholder.children)) == 0
-
- # Open the settings modal
- settings_button = soup.find("a", {"id": "settings-btn"})
- path = settings_button["hx-get"]
- response = await client.get(path, headers={"HX-Request": "true"})
- assert response.template.name == "settings.html"
- soup = BeautifulSoup(response.text, features="html.parser")
- assert soup.find("h4").text == "Appearance"
- # TODO: check that the modal_placeholder has an element
-
- # Open the sync tab within the settings
- sync_tab = soup.find("div", {"id": "syncing-tab"})
- path = sync_tab["hx-get"]
- response = await client.get(path, headers={"HX-Request": "true"})
- assert response.template.name == "settings_tab.html"
- soup = BeautifulSoup(response.text, features="html.parser")
- assert soup.find("h4").text == "Sync Preferences"
diff --git a/uv.lock b/uv.lock
@@ -1,1043 +0,0 @@
-version = 1
-revision = 3
-requires-python = "==3.13.*"
-
-[[package]]
-name = "aiosql"
-version = "13.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/98/3a/105cdf480d444ee059f3fbea65616fba006fba29d32e382fb1a4d947f4b7/aiosql-13.4.tar.gz", hash = "sha256:da6ebb4d5e735753853007a7d574e60761338fc6ca8d95d6d95b5d85d7b1354d", size = 75730, upload-time = "2025-04-09T08:27:36.593Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b4/cf/219d8fdae6b6ea9192111b68b611550852f0b082ace9f23e391dde639f28/aiosql-13.4-py3-none-any.whl", hash = "sha256:f5dc4a93484a96ee9008f8488cc1be100e1ebf08cd2135ccade89975332bf49f", size = 24783, upload-time = "2025-04-09T08:27:34.753Z" },
-]
-
-[[package]]
-name = "aiosqlite"
-version = "0.21.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/13/7d/8bca2bf9a247c2c5dfeec1d7a5f40db6518f88d314b8bca9da29670d2671/aiosqlite-0.21.0.tar.gz", hash = "sha256:131bb8056daa3bc875608c631c678cda73922a2d4ba8aec373b19f18c17e7aa3", size = 13454, upload-time = "2025-02-03T07:30:16.235Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" },
-]
-
-[[package]]
-name = "anyio"
-version = "4.11.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "idna" },
- { name = "sniffio" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
-]
-
-[[package]]
-name = "argon2-cffi"
-version = "25.1.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "argon2-cffi-bindings" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" },
-]
-
-[[package]]
-name = "argon2-cffi-bindings"
-version = "25.1.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cffi" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" },
- { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" },
- { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" },
- { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" },
- { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" },
- { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" },
- { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" },
- { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" },
- { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" },
- { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" },
-]
-
-[[package]]
-name = "beautifulsoup4"
-version = "4.14.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "soupsieve" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/77/e9/df2358efd7659577435e2177bfa69cba6c33216681af51a707193dec162a/beautifulsoup4-4.14.2.tar.gz", hash = "sha256:2a98ab9f944a11acee9cc848508ec28d9228abfd522ef0fad6a02a72e0ded69e", size = 625822, upload-time = "2025-09-29T10:05:42.613Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/94/fe/3aed5d0be4d404d12d36ab97e2f1791424d9ca39c2f754a6285d59a3b01d/beautifulsoup4-4.14.2-py3-none-any.whl", hash = "sha256:5ef6fa3a8cbece8488d66985560f97ed091e22bbc4e9c2338508a9d5de6d4515", size = 106392, upload-time = "2025-09-29T10:05:43.771Z" },
-]
-
-[[package]]
-name = "bidict"
-version = "0.23.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9a/6e/026678aa5a830e07cd9498a05d3e7e650a4f56a42f267a53d22bcda1bdc9/bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71", size = 29093, upload-time = "2024-02-18T19:09:05.748Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/99/37/e8730c3587a65eb5645d4aba2d27aae48e8003614d6aaf15dda67f702f1f/bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5", size = 32764, upload-time = "2024-02-18T19:09:04.156Z" },
-]
-
-[[package]]
-name = "blinker"
-version = "1.9.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
-]
-
-[[package]]
-name = "brotli"
-version = "1.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/2f/c2/f9e977608bdf958650638c3f1e28f85a1b075f075ebbe77db8555463787b/Brotli-1.1.0.tar.gz", hash = "sha256:81de08ac11bcb85841e440c13611c00b67d3bf82698314928d0b676362546724", size = 7372270, upload-time = "2023-09-07T14:05:41.643Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0a/9f/fb37bb8ffc52a8da37b1c03c459a8cd55df7a57bdccd8831d500e994a0ca/Brotli-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8bf32b98b75c13ec7cf774164172683d6e7891088f6316e54425fde1efc276d5", size = 815681, upload-time = "2024-10-18T12:32:34.942Z" },
- { url = "https://files.pythonhosted.org/packages/06/b3/dbd332a988586fefb0aa49c779f59f47cae76855c2d00f450364bb574cac/Brotli-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7bc37c4d6b87fb1017ea28c9508b36bbcb0c3d18b4260fcdf08b200c74a6aee8", size = 422475, upload-time = "2024-10-18T12:32:36.485Z" },
- { url = "https://files.pythonhosted.org/packages/bb/80/6aaddc2f63dbcf2d93c2d204e49c11a9ec93a8c7c63261e2b4bd35198283/Brotli-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c0ef38c7a7014ffac184db9e04debe495d317cc9c6fb10071f7fefd93100a4f", size = 2906173, upload-time = "2024-10-18T12:32:37.978Z" },
- { url = "https://files.pythonhosted.org/packages/ea/1d/e6ca79c96ff5b641df6097d299347507d39a9604bde8915e76bf026d6c77/Brotli-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91d7cc2a76b5567591d12c01f019dd7afce6ba8cba6571187e21e2fc418ae648", size = 2943803, upload-time = "2024-10-18T12:32:39.606Z" },
- { url = "https://files.pythonhosted.org/packages/ac/a3/d98d2472e0130b7dd3acdbb7f390d478123dbf62b7d32bda5c830a96116d/Brotli-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a93dde851926f4f2678e704fadeb39e16c35d8baebd5252c9fd94ce8ce68c4a0", size = 2918946, upload-time = "2024-10-18T12:32:41.679Z" },
- { url = "https://files.pythonhosted.org/packages/c4/a5/c69e6d272aee3e1423ed005d8915a7eaa0384c7de503da987f2d224d0721/Brotli-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f0db75f47be8b8abc8d9e31bc7aad0547ca26f24a54e6fd10231d623f183d089", size = 2845707, upload-time = "2024-10-18T12:32:43.478Z" },
- { url = "https://files.pythonhosted.org/packages/58/9f/4149d38b52725afa39067350696c09526de0125ebfbaab5acc5af28b42ea/Brotli-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6967ced6730aed543b8673008b5a391c3b1076d834ca438bbd70635c73775368", size = 2936231, upload-time = "2024-10-18T12:32:45.224Z" },
- { url = "https://files.pythonhosted.org/packages/5a/5a/145de884285611838a16bebfdb060c231c52b8f84dfbe52b852a15780386/Brotli-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7eedaa5d036d9336c95915035fb57422054014ebdeb6f3b42eac809928e40d0c", size = 2848157, upload-time = "2024-10-18T12:32:46.894Z" },
- { url = "https://files.pythonhosted.org/packages/50/ae/408b6bfb8525dadebd3b3dd5b19d631da4f7d46420321db44cd99dcf2f2c/Brotli-1.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d487f5432bf35b60ed625d7e1b448e2dc855422e87469e3f450aa5552b0eb284", size = 3035122, upload-time = "2024-10-18T12:32:48.844Z" },
- { url = "https://files.pythonhosted.org/packages/af/85/a94e5cfaa0ca449d8f91c3d6f78313ebf919a0dbd55a100c711c6e9655bc/Brotli-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:832436e59afb93e1836081a20f324cb185836c617659b07b129141a8426973c7", size = 2930206, upload-time = "2024-10-18T12:32:51.198Z" },
- { url = "https://files.pythonhosted.org/packages/c2/f0/a61d9262cd01351df22e57ad7c34f66794709acab13f34be2675f45bf89d/Brotli-1.1.0-cp313-cp313-win32.whl", hash = "sha256:43395e90523f9c23a3d5bdf004733246fba087f2948f87ab28015f12359ca6a0", size = 333804, upload-time = "2024-10-18T12:32:52.661Z" },
- { url = "https://files.pythonhosted.org/packages/7e/c1/ec214e9c94000d1c1974ec67ced1c970c148aa6b8d8373066123fc3dbf06/Brotli-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:9011560a466d2eb3f5a6e4929cf4a09be405c64154e12df0dd72713f6500e32b", size = 358517, upload-time = "2024-10-18T12:32:54.066Z" },
-]
-
-[[package]]
-name = "certifi"
-version = "2025.8.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
-]
-
-[[package]]
-name = "cffi"
-version = "2.0.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pycparser", marker = "implementation_name != 'PyPy'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
- { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
- { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
- { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
- { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
- { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
- { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
- { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
- { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
- { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
- { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
- { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
-]
-
-[[package]]
-name = "charset-normalizer"
-version = "3.4.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
- { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
- { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
- { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
- { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
- { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
- { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
- { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
- { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
- { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
- { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
- { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
-]
-
-[[package]]
-name = "click"
-version = "8.3.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
-]
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
-]
-
-[[package]]
-name = "configargparse"
-version = "1.7.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/85/4d/6c9ef746dfcc2a32e26f3860bb4a011c008c392b83eabdfb598d1a8bbe5d/configargparse-1.7.1.tar.gz", hash = "sha256:79c2ddae836a1e5914b71d58e4b9adbd9f7779d4e6351a637b7d2d9b6c46d3d9", size = 43958, upload-time = "2025-05-23T14:26:17.369Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/31/28/d28211d29bcc3620b1fece85a65ce5bb22f18670a03cd28ea4b75ede270c/configargparse-1.7.1-py3-none-any.whl", hash = "sha256:8b586a31f9d873abd1ca527ffbe58863c99f36d896e2829779803125e83be4b6", size = 25607, upload-time = "2025-05-23T14:26:15.923Z" },
-]
-
-[[package]]
-name = "coverage"
-version = "7.10.7"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/9a/94/b765c1abcb613d103b64fcf10395f54d69b0ef8be6a0dd9c524384892cc7/coverage-7.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:981a651f543f2854abd3b5fcb3263aac581b18209be49863ba575de6edf4c14d", size = 218320, upload-time = "2025-09-21T20:01:56.629Z" },
- { url = "https://files.pythonhosted.org/packages/72/4f/732fff31c119bb73b35236dd333030f32c4bfe909f445b423e6c7594f9a2/coverage-7.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73ab1601f84dc804f7812dc297e93cd99381162da39c47040a827d4e8dafe63b", size = 218575, upload-time = "2025-09-21T20:01:58.203Z" },
- { url = "https://files.pythonhosted.org/packages/87/02/ae7e0af4b674be47566707777db1aa375474f02a1d64b9323e5813a6cdd5/coverage-7.10.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a8b6f03672aa6734e700bbcd65ff050fd19cddfec4b031cc8cf1c6967de5a68e", size = 249568, upload-time = "2025-09-21T20:01:59.748Z" },
- { url = "https://files.pythonhosted.org/packages/a2/77/8c6d22bf61921a59bce5471c2f1f7ac30cd4ac50aadde72b8c48d5727902/coverage-7.10.7-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10b6ba00ab1132a0ce4428ff68cf50a25efd6840a42cdf4239c9b99aad83be8b", size = 252174, upload-time = "2025-09-21T20:02:01.192Z" },
- { url = "https://files.pythonhosted.org/packages/b1/20/b6ea4f69bbb52dac0aebd62157ba6a9dddbfe664f5af8122dac296c3ee15/coverage-7.10.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c79124f70465a150e89340de5963f936ee97097d2ef76c869708c4248c63ca49", size = 253447, upload-time = "2025-09-21T20:02:02.701Z" },
- { url = "https://files.pythonhosted.org/packages/f9/28/4831523ba483a7f90f7b259d2018fef02cb4d5b90bc7c1505d6e5a84883c/coverage-7.10.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:69212fbccdbd5b0e39eac4067e20a4a5256609e209547d86f740d68ad4f04911", size = 249779, upload-time = "2025-09-21T20:02:04.185Z" },
- { url = "https://files.pythonhosted.org/packages/a7/9f/4331142bc98c10ca6436d2d620c3e165f31e6c58d43479985afce6f3191c/coverage-7.10.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7ea7c6c9d0d286d04ed3541747e6597cbe4971f22648b68248f7ddcd329207f0", size = 251604, upload-time = "2025-09-21T20:02:06.034Z" },
- { url = "https://files.pythonhosted.org/packages/ce/60/bda83b96602036b77ecf34e6393a3836365481b69f7ed7079ab85048202b/coverage-7.10.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b9be91986841a75042b3e3243d0b3cb0b2434252b977baaf0cd56e960fe1e46f", size = 249497, upload-time = "2025-09-21T20:02:07.619Z" },
- { url = "https://files.pythonhosted.org/packages/5f/af/152633ff35b2af63977edd835d8e6430f0caef27d171edf2fc76c270ef31/coverage-7.10.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b281d5eca50189325cfe1f365fafade89b14b4a78d9b40b05ddd1fc7d2a10a9c", size = 249350, upload-time = "2025-09-21T20:02:10.34Z" },
- { url = "https://files.pythonhosted.org/packages/9d/71/d92105d122bd21cebba877228990e1646d862e34a98bb3374d3fece5a794/coverage-7.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:99e4aa63097ab1118e75a848a28e40d68b08a5e19ce587891ab7fd04475e780f", size = 251111, upload-time = "2025-09-21T20:02:12.122Z" },
- { url = "https://files.pythonhosted.org/packages/a2/9e/9fdb08f4bf476c912f0c3ca292e019aab6712c93c9344a1653986c3fd305/coverage-7.10.7-cp313-cp313-win32.whl", hash = "sha256:dc7c389dce432500273eaf48f410b37886be9208b2dd5710aaf7c57fd442c698", size = 220746, upload-time = "2025-09-21T20:02:13.919Z" },
- { url = "https://files.pythonhosted.org/packages/b1/b1/a75fd25df44eab52d1931e89980d1ada46824c7a3210be0d3c88a44aaa99/coverage-7.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:cac0fdca17b036af3881a9d2729a850b76553f3f716ccb0360ad4dbc06b3b843", size = 221541, upload-time = "2025-09-21T20:02:15.57Z" },
- { url = "https://files.pythonhosted.org/packages/14/3a/d720d7c989562a6e9a14b2c9f5f2876bdb38e9367126d118495b89c99c37/coverage-7.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:4b6f236edf6e2f9ae8fcd1332da4e791c1b6ba0dc16a2dc94590ceccb482e546", size = 220170, upload-time = "2025-09-21T20:02:17.395Z" },
- { url = "https://files.pythonhosted.org/packages/bb/22/e04514bf2a735d8b0add31d2b4ab636fc02370730787c576bb995390d2d5/coverage-7.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a0ec07fd264d0745ee396b666d47cef20875f4ff2375d7c4f58235886cc1ef0c", size = 219029, upload-time = "2025-09-21T20:02:18.936Z" },
- { url = "https://files.pythonhosted.org/packages/11/0b/91128e099035ece15da3445d9015e4b4153a6059403452d324cbb0a575fa/coverage-7.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd5e856ebb7bfb7672b0086846db5afb4567a7b9714b8a0ebafd211ec7ce6a15", size = 219259, upload-time = "2025-09-21T20:02:20.44Z" },
- { url = "https://files.pythonhosted.org/packages/8b/51/66420081e72801536a091a0c8f8c1f88a5c4bf7b9b1bdc6222c7afe6dc9b/coverage-7.10.7-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f57b2a3c8353d3e04acf75b3fed57ba41f5c0646bbf1d10c7c282291c97936b4", size = 260592, upload-time = "2025-09-21T20:02:22.313Z" },
- { url = "https://files.pythonhosted.org/packages/5d/22/9b8d458c2881b22df3db5bb3e7369e63d527d986decb6c11a591ba2364f7/coverage-7.10.7-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ef2319dd15a0b009667301a3f84452a4dc6fddfd06b0c5c53ea472d3989fbf0", size = 262768, upload-time = "2025-09-21T20:02:24.287Z" },
- { url = "https://files.pythonhosted.org/packages/f7/08/16bee2c433e60913c610ea200b276e8eeef084b0d200bdcff69920bd5828/coverage-7.10.7-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83082a57783239717ceb0ad584de3c69cf581b2a95ed6bf81ea66034f00401c0", size = 264995, upload-time = "2025-09-21T20:02:26.133Z" },
- { url = "https://files.pythonhosted.org/packages/20/9d/e53eb9771d154859b084b90201e5221bca7674ba449a17c101a5031d4054/coverage-7.10.7-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50aa94fb1fb9a397eaa19c0d5ec15a5edd03a47bf1a3a6111a16b36e190cff65", size = 259546, upload-time = "2025-09-21T20:02:27.716Z" },
- { url = "https://files.pythonhosted.org/packages/ad/b0/69bc7050f8d4e56a89fb550a1577d5d0d1db2278106f6f626464067b3817/coverage-7.10.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2120043f147bebb41c85b97ac45dd173595ff14f2a584f2963891cbcc3091541", size = 262544, upload-time = "2025-09-21T20:02:29.216Z" },
- { url = "https://files.pythonhosted.org/packages/ef/4b/2514b060dbd1bc0aaf23b852c14bb5818f244c664cb16517feff6bb3a5ab/coverage-7.10.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2fafd773231dd0378fdba66d339f84904a8e57a262f583530f4f156ab83863e6", size = 260308, upload-time = "2025-09-21T20:02:31.226Z" },
- { url = "https://files.pythonhosted.org/packages/54/78/7ba2175007c246d75e496f64c06e94122bdb914790a1285d627a918bd271/coverage-7.10.7-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:0b944ee8459f515f28b851728ad224fa2d068f1513ef6b7ff1efafeb2185f999", size = 258920, upload-time = "2025-09-21T20:02:32.823Z" },
- { url = "https://files.pythonhosted.org/packages/c0/b3/fac9f7abbc841409b9a410309d73bfa6cfb2e51c3fada738cb607ce174f8/coverage-7.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4b583b97ab2e3efe1b3e75248a9b333bd3f8b0b1b8e5b45578e05e5850dfb2c2", size = 261434, upload-time = "2025-09-21T20:02:34.86Z" },
- { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" },
- { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" },
- { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" },
- { url = "https://files.pythonhosted.org/packages/ec/16/114df1c291c22cac3b0c127a73e0af5c12ed7bbb6558d310429a0ae24023/coverage-7.10.7-py3-none-any.whl", hash = "sha256:f7941f6f2fe6dd6807a1208737b8a0cbcf1cc6d7b07d24998ad2d63590868260", size = 209952, upload-time = "2025-09-21T20:03:53.918Z" },
-]
-
-[[package]]
-name = "flask"
-version = "3.1.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "blinker" },
- { name = "click" },
- { name = "itsdangerous" },
- { name = "jinja2" },
- { name = "markupsafe" },
- { name = "werkzeug" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
-]
-
-[[package]]
-name = "flask-cors"
-version = "6.0.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "flask" },
- { name = "werkzeug" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/76/37/bcfa6c7d5eec777c4c7cf45ce6b27631cebe5230caf88d85eadd63edd37a/flask_cors-6.0.1.tar.gz", hash = "sha256:d81bcb31f07b0985be7f48406247e9243aced229b7747219160a0559edd678db", size = 13463, upload-time = "2025-06-11T01:32:08.518Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/17/f8/01bf35a3afd734345528f98d0353f2a978a476528ad4d7e78b70c4d149dd/flask_cors-6.0.1-py3-none-any.whl", hash = "sha256:c7b2cbfb1a31aa0d2e5341eea03a6805349f7a61647daee1a15c46bbe981494c", size = 13244, upload-time = "2025-06-11T01:32:07.352Z" },
-]
-
-[[package]]
-name = "flask-login"
-version = "0.6.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "flask" },
- { name = "werkzeug" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834, upload-time = "2023-10-30T14:53:21.151Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303, upload-time = "2023-10-30T14:53:19.636Z" },
-]
-
-[[package]]
-name = "gevent"
-version = "25.5.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" },
- { name = "greenlet", marker = "platform_python_implementation == 'CPython'" },
- { name = "zope-event" },
- { name = "zope-interface" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f1/58/267e8160aea00ab00acd2de97197eecfe307064a376fb5c892870a8a6159/gevent-25.5.1.tar.gz", hash = "sha256:582c948fa9a23188b890d0bc130734a506d039a2e5ad87dae276a456cc683e61", size = 6388207, upload-time = "2025-05-12T12:57:59.833Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/10/25/2162b38d7b48e08865db6772d632bd1648136ce2bb50e340565e45607cad/gevent-25.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a022a9de9275ce0b390b7315595454258c525dc8287a03f1a6cacc5878ab7cbc", size = 2928044, upload-time = "2025-05-12T11:11:36.33Z" },
- { url = "https://files.pythonhosted.org/packages/1b/e0/dbd597a964ed00176da122ea759bf2a6c1504f1e9f08e185379f92dc355f/gevent-25.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fae8533f9d0ef3348a1f503edcfb531ef7a0236b57da1e24339aceb0ce52922", size = 1788751, upload-time = "2025-05-12T11:52:32.643Z" },
- { url = "https://files.pythonhosted.org/packages/f1/74/960cc4cf4c9c90eafbe0efc238cdf588862e8e278d0b8c0d15a0da4ed480/gevent-25.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c7b32d9c3b5294b39ea9060e20c582e49e1ec81edbfeae6cf05f8ad0829cb13d", size = 1869766, upload-time = "2025-05-12T11:54:23.903Z" },
- { url = "https://files.pythonhosted.org/packages/56/78/fa84b1c7db79b156929685db09a7c18c3127361dca18a09e998e98118506/gevent-25.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b95815fe44f318ebbfd733b6428b4cb18cc5e68f1c40e8501dd69cc1f42a83d", size = 1835358, upload-time = "2025-05-12T12:00:06.794Z" },
- { url = "https://files.pythonhosted.org/packages/00/5c/bfefe3822bbca5b83bfad256c82251b3f5be13d52d14e17a786847b9b625/gevent-25.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d316529b70d325b183b2f3f5cde958911ff7be12eb2b532b5c301f915dbbf1e", size = 2073071, upload-time = "2025-05-12T11:33:04.2Z" },
- { url = "https://files.pythonhosted.org/packages/20/e4/08a77a3839a37db96393dea952e992d5846a881b887986dde62ead6b48a1/gevent-25.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f6ba33c13db91ffdbb489a4f3d177a261ea1843923e1d68a5636c53fe98fa5ce", size = 1809805, upload-time = "2025-05-12T12:00:00.537Z" },
- { url = "https://files.pythonhosted.org/packages/2b/ac/28848348f790c1283df74b0fc0a554271d0606676470f848eccf84eae42a/gevent-25.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ee34b77c7553777c0b8379915f75934c3f9c8cd32f7cd098ea43c9323c2276", size = 2138305, upload-time = "2025-05-12T11:40:56.566Z" },
- { url = "https://files.pythonhosted.org/packages/52/9e/0e9e40facd2d714bfb00f71fc6dacaacc82c24c1c2e097bf6461e00dec9f/gevent-25.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fa6aa0da224ed807d3b76cdb4ee8b54d4d4d5e018aed2478098e685baae7896", size = 1637444, upload-time = "2025-05-12T12:17:45.995Z" },
-]
-
-[[package]]
-name = "geventhttpclient"
-version = "2.3.4"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "brotli" },
- { name = "certifi" },
- { name = "gevent" },
- { name = "urllib3" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/89/19/1ca8de73dcc0596d3df01be299e940d7fc3bccbeb6f62bb8dd2d427a3a50/geventhttpclient-2.3.4.tar.gz", hash = "sha256:1749f75810435a001fc6d4d7526c92cf02b39b30ab6217a886102f941c874222", size = 83545, upload-time = "2025-06-11T13:18:14.144Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ff/ad/132fddde6e2dca46d6a86316962437acd2bfaeb264db4e0fae83c529eb04/geventhttpclient-2.3.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:be64c5583884c407fc748dedbcb083475d5b138afb23c6bc0836cbad228402cc", size = 71967, upload-time = "2025-06-11T13:17:22.121Z" },
- { url = "https://files.pythonhosted.org/packages/f4/34/5e77d9a31d93409a8519cf573843288565272ae5a016be9c9293f56c50a1/geventhttpclient-2.3.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:15b2567137734183efda18e4d6245b18772e648b6a25adea0eba8b3a8b0d17e8", size = 52632, upload-time = "2025-06-11T13:17:23.016Z" },
- { url = "https://files.pythonhosted.org/packages/47/d2/cf0dbc333304700e68cee9347f654b56e8b0f93a341b8b0d027ee96800d6/geventhttpclient-2.3.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a4bca1151b8cd207eef6d5cb3c720c562b2aa7293cf113a68874e235cfa19c31", size = 51980, upload-time = "2025-06-11T13:17:23.933Z" },
- { url = "https://files.pythonhosted.org/packages/27/6e/049e685fc43e2e966c83f24b3187f6a6736103f0fc51118140f4ca1793d4/geventhttpclient-2.3.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8a681433e2f3d4b326d8b36b3e05b787b2c6dd2a5660a4a12527622278bf02ed", size = 114998, upload-time = "2025-08-24T12:16:54.72Z" },
- { url = "https://files.pythonhosted.org/packages/24/13/1d08cf0400bf0fe0bb21e70f3f5fab2130aecef962b4362b7a1eba3cd738/geventhttpclient-2.3.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:736aa8e9609e4da40aeff0dbc02fea69021a034f4ed1e99bf93fc2ca83027b64", size = 115690, upload-time = "2025-08-24T12:16:56.328Z" },
- { url = "https://files.pythonhosted.org/packages/fd/bc/15d22882983cac573859d274783c5b0a95881e553fc312e7b646be432668/geventhttpclient-2.3.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d477ae1f5d42e1ee6abbe520a2e9c7f369781c3b8ca111d1f5283c1453bc825", size = 121681, upload-time = "2025-08-24T12:16:58.344Z" },
- { url = "https://files.pythonhosted.org/packages/ec/5b/c0c30ccd9d06c603add3f2d6abd68bd98430ee9730dc5478815759cf07f7/geventhttpclient-2.3.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b50d9daded5d36193d67e2fc30e59752262fcbbdc86e8222c7df6b93af0346a", size = 118987, upload-time = "2025-06-11T13:17:24.97Z" },
- { url = "https://files.pythonhosted.org/packages/4f/56/095a46af86476372064128162eccbd2ba4a7721503759890d32ea701d5fd/geventhttpclient-2.3.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fe705e7656bc6982a463a4ed7f9b1db8c78c08323f1d45d0d1d77063efa0ce96", size = 124519, upload-time = "2025-06-11T13:17:25.933Z" },
- { url = "https://files.pythonhosted.org/packages/ae/12/7c9ba94b58f7954a83d33183152ce6bf5bda10c08ebe47d79a314cd33e29/geventhttpclient-2.3.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:69668589359db4cbb9efa327dda5735d1e74145e6f0a9ffa50236d15cf904053", size = 115574, upload-time = "2025-06-11T13:17:27.331Z" },
- { url = "https://files.pythonhosted.org/packages/73/77/c4e7c5bce0199428fdb811d6adf6e347180d89eaa1b9b723f711f6bbc830/geventhttpclient-2.3.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9ba526e07ccaf4f1c2cd3395dda221139f01468b6eee1190d4a616f187a0378", size = 114222, upload-time = "2025-06-11T13:17:28.289Z" },
- { url = "https://files.pythonhosted.org/packages/a3/79/58802d300950dbd7d4e31eb24afd7c270fc7900ff3923fd266cc915bb086/geventhttpclient-2.3.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:525bd192705b5cb41a7cc3fe41fca194bfd6b5b59997ab9fe68fe0a82dab6140", size = 111682, upload-time = "2025-06-11T13:17:29.291Z" },
- { url = "https://files.pythonhosted.org/packages/d3/9c/ae04e4033459b8142788dad80d8d0b42d460bc6db9150e0815c2d0a02cb4/geventhttpclient-2.3.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:42b6f6afb0d3aab6a013c9cdb97e19bf4fe08695975670d0a018113d24cb344c", size = 113252, upload-time = "2025-06-11T13:17:30.357Z" },
- { url = "https://files.pythonhosted.org/packages/d3/67/5ae5d5878b06397a7b54334d1d31bb78cefc950ae890c2b8f5c917eb271e/geventhttpclient-2.3.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:227579b703085c4e5c6d5217ad6565b19ac8d1164404133e5874efaae1905114", size = 118426, upload-time = "2025-06-11T13:17:31.363Z" },
- { url = "https://files.pythonhosted.org/packages/ca/36/9065bb51f261950c42eddf8718e01a9ff344d8082e31317a8b6677be9bd6/geventhttpclient-2.3.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d1d0db89c1c8f3282eac9a22fda2b4082e1ed62a2107f70e3f1de1872c7919f", size = 112245, upload-time = "2025-06-11T13:17:32.331Z" },
- { url = "https://files.pythonhosted.org/packages/21/7e/08a615bec095c288f997951e42e48b262d43c6081bef33cfbfad96ab9658/geventhttpclient-2.3.4-cp313-cp313-win32.whl", hash = "sha256:4e492b9ab880f98f8a9cc143b96ea72e860946eae8ad5fb2837cede2a8f45154", size = 48360, upload-time = "2025-06-11T13:17:33.349Z" },
- { url = "https://files.pythonhosted.org/packages/ec/19/ef3cb21e7e95b14cfcd21e3ba7fe3d696e171682dfa43ab8c0a727cac601/geventhttpclient-2.3.4-cp313-cp313-win_amd64.whl", hash = "sha256:72575c5b502bf26ececccb905e4e028bb922f542946be701923e726acf305eb6", size = 48956, upload-time = "2025-06-11T13:17:34.956Z" },
-]
-
-[[package]]
-name = "greenlet"
-version = "3.2.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" },
- { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" },
- { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" },
- { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" },
- { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" },
- { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
- { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
- { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
- { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
-]
-
-[[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 = "httpcore"
-version = "1.0.9"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "certifi" },
- { name = "h11" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
-]
-
-[[package]]
-name = "httptools"
-version = "0.6.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" },
- { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" },
- { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" },
- { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" },
- { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" },
- { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" },
- { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" },
-]
-
-[[package]]
-name = "httpx"
-version = "0.28.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
- { name = "certifi" },
- { name = "httpcore" },
- { name = "idna" },
-]
-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 = "idna"
-version = "3.10"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
-]
-
-[[package]]
-name = "iniconfig"
-version = "2.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
-]
-
-[[package]]
-name = "itsdangerous"
-version = "2.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
-]
-
-[[package]]
-name = "jinja2"
-version = "3.1.6"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "markupsafe" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
-]
-
-[[package]]
-name = "locust"
-version = "2.41.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "configargparse" },
- { name = "flask" },
- { name = "flask-cors" },
- { name = "flask-login" },
- { name = "gevent" },
- { name = "geventhttpclient" },
- { name = "locust-cloud" },
- { name = "msgpack" },
- { name = "psutil" },
- { name = "pytest" },
- { name = "python-engineio" },
- { name = "python-socketio", extra = ["client"] },
- { name = "pywin32", marker = "sys_platform == 'win32'" },
- { name = "pyzmq" },
- { name = "requests" },
- { name = "setuptools" },
- { name = "werkzeug" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/f6/97/9e176b560152f97454aeb908ef61619ae2d8df8ad8cf2ec3829b9a29c8ca/locust-2.41.3.tar.gz", hash = "sha256:531804b6900472bad7f1123c73b81720826d502254b754f70907240d99663491", size = 1415538, upload-time = "2025-10-01T13:33:52.446Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/35/55/bfc3df125d1f4b798ae9abe33b5e2917f7c4711ef2f40940c2c71eaedacf/locust-2.41.3-py3-none-any.whl", hash = "sha256:dd55ac0eee5cd5f7f478eea20adfc83b7cdbead71532a746faa355dff525e5ae", size = 1434071, upload-time = "2025-10-01T13:33:51.176Z" },
-]
-
-[[package]]
-name = "locust-cloud"
-version = "1.27.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "configargparse" },
- { name = "gevent" },
- { name = "platformdirs" },
- { name = "python-engineio" },
- { name = "python-socketio", extra = ["client"] },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/df/63/330c4a7a1d114c0687d1ad58326ad27fa128208f0166eb000331a5e9b72f/locust_cloud-1.27.2.tar.gz", hash = "sha256:0d80d63ba722a45c39dcdce66ca5a389944db2123a5fff59d1438e4ee3701258", size = 454487, upload-time = "2025-10-01T12:26:45.752Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/38/ef/49ecd09f0717494f1303b2889a4cdb4b2984465cf6c738812e9f23170b93/locust_cloud-1.27.2-py3-none-any.whl", hash = "sha256:cd21ee33e5ea721c0e4f3c0e691e9c637fc88783d234b060ae1693c26010604e", size = 409313, upload-time = "2025-10-01T12:26:43.682Z" },
-]
-
-[[package]]
-name = "markupsafe"
-version = "3.0.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
- { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
- { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
- { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
- { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
- { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
- { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
- { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
- { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
- { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
- { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
- { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
- { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
- { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
- { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
- { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
- { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
- { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
- { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
- { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
- { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
- { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
-]
-
-[[package]]
-name = "msgpack"
-version = "1.1.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/45/b1/ea4f68038a18c77c9467400d166d74c4ffa536f34761f7983a104357e614/msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd", size = 173555, upload-time = "2025-06-13T06:52:51.324Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a1/38/561f01cf3577430b59b340b51329803d3a5bf6a45864a55f4ef308ac11e3/msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0", size = 81677, upload-time = "2025-06-13T06:52:16.64Z" },
- { url = "https://files.pythonhosted.org/packages/09/48/54a89579ea36b6ae0ee001cba8c61f776451fad3c9306cd80f5b5c55be87/msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9", size = 78603, upload-time = "2025-06-13T06:52:17.843Z" },
- { url = "https://files.pythonhosted.org/packages/a0/60/daba2699b308e95ae792cdc2ef092a38eb5ee422f9d2fbd4101526d8a210/msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8", size = 420504, upload-time = "2025-06-13T06:52:18.982Z" },
- { url = "https://files.pythonhosted.org/packages/20/22/2ebae7ae43cd8f2debc35c631172ddf14e2a87ffcc04cf43ff9df9fff0d3/msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a", size = 423749, upload-time = "2025-06-13T06:52:20.211Z" },
- { url = "https://files.pythonhosted.org/packages/40/1b/54c08dd5452427e1179a40b4b607e37e2664bca1c790c60c442c8e972e47/msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac", size = 404458, upload-time = "2025-06-13T06:52:21.429Z" },
- { url = "https://files.pythonhosted.org/packages/2e/60/6bb17e9ffb080616a51f09928fdd5cac1353c9becc6c4a8abd4e57269a16/msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b", size = 405976, upload-time = "2025-06-13T06:52:22.995Z" },
- { url = "https://files.pythonhosted.org/packages/ee/97/88983e266572e8707c1f4b99c8fd04f9eb97b43f2db40e3172d87d8642db/msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7", size = 408607, upload-time = "2025-06-13T06:52:24.152Z" },
- { url = "https://files.pythonhosted.org/packages/bc/66/36c78af2efaffcc15a5a61ae0df53a1d025f2680122e2a9eb8442fed3ae4/msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5", size = 424172, upload-time = "2025-06-13T06:52:25.704Z" },
- { url = "https://files.pythonhosted.org/packages/8c/87/a75eb622b555708fe0427fab96056d39d4c9892b0c784b3a721088c7ee37/msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323", size = 65347, upload-time = "2025-06-13T06:52:26.846Z" },
- { url = "https://files.pythonhosted.org/packages/ca/91/7dc28d5e2a11a5ad804cf2b7f7a5fcb1eb5a4966d66a5d2b41aee6376543/msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69", size = 72341, upload-time = "2025-06-13T06:52:27.835Z" },
-]
-
-[[package]]
-name = "orjson"
-version = "3.11.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/be/4d/8df5f83256a809c22c4d6792ce8d43bb503be0fb7a8e4da9025754b09658/orjson-3.11.3.tar.gz", hash = "sha256:1c0603b1d2ffcd43a411d64797a19556ef76958aef1c182f22dc30860152a98a", size = 5482394, upload-time = "2025-08-26T17:46:43.171Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fc/79/8932b27293ad35919571f77cb3693b5906cf14f206ef17546052a241fdf6/orjson-3.11.3-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:af40c6612fd2a4b00de648aa26d18186cd1322330bd3a3cc52f87c699e995810", size = 238127, upload-time = "2025-08-26T17:45:38.146Z" },
- { url = "https://files.pythonhosted.org/packages/1c/82/cb93cd8cf132cd7643b30b6c5a56a26c4e780c7a145db6f83de977b540ce/orjson-3.11.3-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:9f1587f26c235894c09e8b5b7636a38091a9e6e7fe4531937534749c04face43", size = 127494, upload-time = "2025-08-26T17:45:39.57Z" },
- { url = "https://files.pythonhosted.org/packages/a4/b8/2d9eb181a9b6bb71463a78882bcac1027fd29cf62c38a40cc02fc11d3495/orjson-3.11.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:61dcdad16da5bb486d7227a37a2e789c429397793a6955227cedbd7252eb5a27", size = 123017, upload-time = "2025-08-26T17:45:40.876Z" },
- { url = "https://files.pythonhosted.org/packages/b4/14/a0e971e72d03b509190232356d54c0f34507a05050bd026b8db2bf2c192c/orjson-3.11.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11c6d71478e2cbea0a709e8a06365fa63da81da6498a53e4c4f065881d21ae8f", size = 127898, upload-time = "2025-08-26T17:45:42.188Z" },
- { url = "https://files.pythonhosted.org/packages/8e/af/dc74536722b03d65e17042cc30ae586161093e5b1f29bccda24765a6ae47/orjson-3.11.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff94112e0098470b665cb0ed06efb187154b63649403b8d5e9aedeb482b4548c", size = 130742, upload-time = "2025-08-26T17:45:43.511Z" },
- { url = "https://files.pythonhosted.org/packages/62/e6/7a3b63b6677bce089fe939353cda24a7679825c43a24e49f757805fc0d8a/orjson-3.11.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae8b756575aaa2a855a75192f356bbda11a89169830e1439cfb1a3e1a6dde7be", size = 132377, upload-time = "2025-08-26T17:45:45.525Z" },
- { url = "https://files.pythonhosted.org/packages/fc/cd/ce2ab93e2e7eaf518f0fd15e3068b8c43216c8a44ed82ac2b79ce5cef72d/orjson-3.11.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c9416cc19a349c167ef76135b2fe40d03cea93680428efee8771f3e9fb66079d", size = 135313, upload-time = "2025-08-26T17:45:46.821Z" },
- { url = "https://files.pythonhosted.org/packages/d0/b4/f98355eff0bd1a38454209bbc73372ce351ba29933cb3e2eba16c04b9448/orjson-3.11.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b822caf5b9752bc6f246eb08124c3d12bf2175b66ab74bac2ef3bbf9221ce1b2", size = 132908, upload-time = "2025-08-26T17:45:48.126Z" },
- { url = "https://files.pythonhosted.org/packages/eb/92/8f5182d7bc2a1bed46ed960b61a39af8389f0ad476120cd99e67182bfb6d/orjson-3.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:414f71e3bdd5573893bf5ecdf35c32b213ed20aa15536fe2f588f946c318824f", size = 130905, upload-time = "2025-08-26T17:45:49.414Z" },
- { url = "https://files.pythonhosted.org/packages/1a/60/c41ca753ce9ffe3d0f67b9b4c093bdd6e5fdb1bc53064f992f66bb99954d/orjson-3.11.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:828e3149ad8815dc14468f36ab2a4b819237c155ee1370341b91ea4c8672d2ee", size = 403812, upload-time = "2025-08-26T17:45:51.085Z" },
- { url = "https://files.pythonhosted.org/packages/dd/13/e4a4f16d71ce1868860db59092e78782c67082a8f1dc06a3788aef2b41bc/orjson-3.11.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ac9e05f25627ffc714c21f8dfe3a579445a5c392a9c8ae7ba1d0e9fb5333f56e", size = 146277, upload-time = "2025-08-26T17:45:52.851Z" },
- { url = "https://files.pythonhosted.org/packages/8d/8b/bafb7f0afef9344754a3a0597a12442f1b85a048b82108ef2c956f53babd/orjson-3.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e44fbe4000bd321d9f3b648ae46e0196d21577cf66ae684a96ff90b1f7c93633", size = 135418, upload-time = "2025-08-26T17:45:54.806Z" },
- { url = "https://files.pythonhosted.org/packages/60/d4/bae8e4f26afb2c23bea69d2f6d566132584d1c3a5fe89ee8c17b718cab67/orjson-3.11.3-cp313-cp313-win32.whl", hash = "sha256:2039b7847ba3eec1f5886e75e6763a16e18c68a63efc4b029ddf994821e2e66b", size = 136216, upload-time = "2025-08-26T17:45:57.182Z" },
- { url = "https://files.pythonhosted.org/packages/88/76/224985d9f127e121c8cad882cea55f0ebe39f97925de040b75ccd4b33999/orjson-3.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:29be5ac4164aa8bdcba5fa0700a3c9c316b411d8ed9d39ef8a882541bd452fae", size = 131362, upload-time = "2025-08-26T17:45:58.56Z" },
- { url = "https://files.pythonhosted.org/packages/e2/cf/0dce7a0be94bd36d1346be5067ed65ded6adb795fdbe3abd234c8d576d01/orjson-3.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:18bd1435cb1f2857ceb59cfb7de6f92593ef7b831ccd1b9bfb28ca530e539dce", size = 125989, upload-time = "2025-08-26T17:45:59.95Z" },
-]
-
-[[package]]
-name = "packaging"
-version = "25.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
-]
-
-[[package]]
-name = "platformdirs"
-version = "4.4.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" },
-]
-
-[[package]]
-name = "pluggy"
-version = "1.6.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
-]
-
-[[package]]
-name = "psutil"
-version = "7.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b3/31/4723d756b59344b643542936e37a31d1d3204bcdc42a7daa8ee9eb06fb50/psutil-7.1.0.tar.gz", hash = "sha256:655708b3c069387c8b77b072fc429a57d0e214221d01c0a772df7dfedcb3bcd2", size = 497660, upload-time = "2025-09-17T20:14:52.902Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/46/62/ce4051019ee20ce0ed74432dd73a5bb087a6704284a470bb8adff69a0932/psutil-7.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:76168cef4397494250e9f4e73eb3752b146de1dd950040b29186d0cce1d5ca13", size = 245242, upload-time = "2025-09-17T20:14:56.126Z" },
- { url = "https://files.pythonhosted.org/packages/38/61/f76959fba841bf5b61123fbf4b650886dc4094c6858008b5bf73d9057216/psutil-7.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:5d007560c8c372efdff9e4579c2846d71de737e4605f611437255e81efcca2c5", size = 246682, upload-time = "2025-09-17T20:14:58.25Z" },
- { url = "https://files.pythonhosted.org/packages/88/7a/37c99d2e77ec30d63398ffa6a660450b8a62517cabe44b3e9bae97696e8d/psutil-7.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22e4454970b32472ce7deaa45d045b34d3648ce478e26a04c7e858a0a6e75ff3", size = 287994, upload-time = "2025-09-17T20:14:59.901Z" },
- { url = "https://files.pythonhosted.org/packages/9d/de/04c8c61232f7244aa0a4b9a9fbd63a89d5aeaf94b2fc9d1d16e2faa5cbb0/psutil-7.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c70e113920d51e89f212dd7be06219a9b88014e63a4cec69b684c327bc474e3", size = 291163, upload-time = "2025-09-17T20:15:01.481Z" },
- { url = "https://files.pythonhosted.org/packages/f4/58/c4f976234bf6d4737bc8c02a81192f045c307b72cf39c9e5c5a2d78927f6/psutil-7.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d4a113425c037300de3ac8b331637293da9be9713855c4fc9d2d97436d7259d", size = 293625, upload-time = "2025-09-17T20:15:04.492Z" },
- { url = "https://files.pythonhosted.org/packages/79/87/157c8e7959ec39ced1b11cc93c730c4fb7f9d408569a6c59dbd92ceb35db/psutil-7.1.0-cp37-abi3-win32.whl", hash = "sha256:09ad740870c8d219ed8daae0ad3b726d3bf9a028a198e7f3080f6a1888b99bca", size = 244812, upload-time = "2025-09-17T20:15:07.462Z" },
- { url = "https://files.pythonhosted.org/packages/bf/e9/b44c4f697276a7a95b8e94d0e320a7bf7f3318521b23de69035540b39838/psutil-7.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:57f5e987c36d3146c0dd2528cd42151cf96cd359b9d67cfff836995cc5df9a3d", size = 247965, upload-time = "2025-09-17T20:15:09.673Z" },
- { url = "https://files.pythonhosted.org/packages/26/65/1070a6e3c036f39142c2820c4b52e9243246fcfc3f96239ac84472ba361e/psutil-7.1.0-cp37-abi3-win_arm64.whl", hash = "sha256:6937cb68133e7c97b6cc9649a570c9a18ba0efebed46d8c5dae4c07fa1b67a07", size = 244971, upload-time = "2025-09-17T20:15:12.262Z" },
-]
-
-[[package]]
-name = "pycparser"
-version = "2.23"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" },
-]
-
-[[package]]
-name = "pygments"
-version = "2.19.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
-]
-
-[[package]]
-name = "pytest"
-version = "8.4.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "iniconfig" },
- { name = "packaging" },
- { name = "pluggy" },
- { name = "pygments" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
-]
-
-[[package]]
-name = "pytest-asyncio"
-version = "1.2.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pytest" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" },
-]
-
-[[package]]
-name = "pytest-cov"
-version = "7.0.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "coverage" },
- { name = "pluggy" },
- { name = "pytest" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
-]
-
-[[package]]
-name = "python-engineio"
-version = "4.12.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "simple-websocket" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c9/d8/63e5535ab21dc4998ba1cfe13690ccf122883a38f025dca24d6e56c05eba/python_engineio-4.12.3.tar.gz", hash = "sha256:35633e55ec30915e7fc8f7e34ca8d73ee0c080cec8a8cd04faf2d7396f0a7a7a", size = 91910, upload-time = "2025-09-28T06:31:36.765Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d8/f0/c5aa0a69fd9326f013110653543f36ece4913c17921f3e1dbd78e1b423ee/python_engineio-4.12.3-py3-none-any.whl", hash = "sha256:7c099abb2a27ea7ab429c04da86ab2d82698cdd6c52406cb73766fe454feb7e1", size = 59637, upload-time = "2025-09-28T06:31:35.354Z" },
-]
-
-[[package]]
-name = "python-multipart"
-version = "0.0.20"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" },
-]
-
-[[package]]
-name = "python-socketio"
-version = "5.13.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "bidict" },
- { name = "python-engineio" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/21/1a/396d50ccf06ee539fa758ce5623b59a9cb27637fc4b2dc07ed08bf495e77/python_socketio-5.13.0.tar.gz", hash = "sha256:ac4e19a0302ae812e23b712ec8b6427ca0521f7c582d6abb096e36e24a263029", size = 121125, upload-time = "2025-04-12T15:46:59.933Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/3c/32/b4fb8585d1be0f68bde7e110dffbcf354915f77ad8c778563f0ad9655c02/python_socketio-5.13.0-py3-none-any.whl", hash = "sha256:51f68d6499f2df8524668c24bcec13ba1414117cfb3a90115c559b601ab10caf", size = 77800, upload-time = "2025-04-12T15:46:58.412Z" },
-]
-
-[package.optional-dependencies]
-client = [
- { name = "requests" },
- { name = "websocket-client" },
-]
-
-[[package]]
-name = "pywin32"
-version = "311"
-source = { registry = "https://pypi.org/simple" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" },
- { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" },
- { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" },
-]
-
-[[package]]
-name = "pyzmq"
-version = "27.1.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cffi", marker = "implementation_name == 'pypy'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" },
- { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" },
- { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" },
- { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" },
- { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" },
- { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" },
- { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" },
- { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" },
- { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" },
- { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" },
- { url = "https://files.pythonhosted.org/packages/60/cb/84a13459c51da6cec1b7b1dc1a47e6db6da50b77ad7fd9c145842750a011/pyzmq-27.1.0-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:93ad4b0855a664229559e45c8d23797ceac03183c7b6f5b4428152a6b06684a5", size = 1122436, upload-time = "2025-09-08T23:08:20.801Z" },
- { url = "https://files.pythonhosted.org/packages/dc/b6/94414759a69a26c3dd674570a81813c46a078767d931a6c70ad29fc585cb/pyzmq-27.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fbb4f2400bfda24f12f009cba62ad5734148569ff4949b1b6ec3b519444342e6", size = 1156301, upload-time = "2025-09-08T23:08:22.47Z" },
- { url = "https://files.pythonhosted.org/packages/a5/ad/15906493fd40c316377fd8a8f6b1f93104f97a752667763c9b9c1b71d42d/pyzmq-27.1.0-cp313-cp313t-macosx_10_15_universal2.whl", hash = "sha256:e343d067f7b151cfe4eb3bb796a7752c9d369eed007b91231e817071d2c2fec7", size = 1341197, upload-time = "2025-09-08T23:08:24.286Z" },
- { url = "https://files.pythonhosted.org/packages/14/1d/d343f3ce13db53a54cb8946594e567410b2125394dafcc0268d8dda027e0/pyzmq-27.1.0-cp313-cp313t-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:08363b2011dec81c354d694bdecaef4770e0ae96b9afea70b3f47b973655cc05", size = 897275, upload-time = "2025-09-08T23:08:26.063Z" },
- { url = "https://files.pythonhosted.org/packages/69/2d/d83dd6d7ca929a2fc67d2c3005415cdf322af7751d773524809f9e585129/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d54530c8c8b5b8ddb3318f481297441af102517602b569146185fa10b63f4fa9", size = 660469, upload-time = "2025-09-08T23:08:27.623Z" },
- { url = "https://files.pythonhosted.org/packages/3e/cd/9822a7af117f4bc0f1952dbe9ef8358eb50a24928efd5edf54210b850259/pyzmq-27.1.0-cp313-cp313t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3afa12c392f0a44a2414056d730eebc33ec0926aae92b5ad5cf26ebb6cc128", size = 847961, upload-time = "2025-09-08T23:08:29.672Z" },
- { url = "https://files.pythonhosted.org/packages/9a/12/f003e824a19ed73be15542f172fd0ec4ad0b60cf37436652c93b9df7c585/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c65047adafe573ff023b3187bb93faa583151627bc9c51fc4fb2c561ed689d39", size = 1650282, upload-time = "2025-09-08T23:08:31.349Z" },
- { url = "https://files.pythonhosted.org/packages/d5/4a/e82d788ed58e9a23995cee70dbc20c9aded3d13a92d30d57ec2291f1e8a3/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90e6e9441c946a8b0a667356f7078d96411391a3b8f80980315455574177ec97", size = 2024468, upload-time = "2025-09-08T23:08:33.543Z" },
- { url = "https://files.pythonhosted.org/packages/d9/94/2da0a60841f757481e402b34bf4c8bf57fa54a5466b965de791b1e6f747d/pyzmq-27.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:add071b2d25f84e8189aaf0882d39a285b42fa3853016ebab234a5e78c7a43db", size = 1885394, upload-time = "2025-09-08T23:08:35.51Z" },
- { url = "https://files.pythonhosted.org/packages/4f/6f/55c10e2e49ad52d080dc24e37adb215e5b0d64990b57598abc2e3f01725b/pyzmq-27.1.0-cp313-cp313t-win32.whl", hash = "sha256:7ccc0700cfdf7bd487bea8d850ec38f204478681ea02a582a8da8171b7f90a1c", size = 574964, upload-time = "2025-09-08T23:08:37.178Z" },
- { url = "https://files.pythonhosted.org/packages/87/4d/2534970ba63dd7c522d8ca80fb92777f362c0f321900667c615e2067cb29/pyzmq-27.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:8085a9fba668216b9b4323be338ee5437a235fe275b9d1610e422ccc279733e2", size = 641029, upload-time = "2025-09-08T23:08:40.595Z" },
- { url = "https://files.pythonhosted.org/packages/f6/fa/f8aea7a28b0641f31d40dea42d7ef003fded31e184ef47db696bc74cd610/pyzmq-27.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6bb54ca21bcfe361e445256c15eedf083f153811c37be87e0514934d6913061e", size = 561541, upload-time = "2025-09-08T23:08:42.668Z" },
-]
-
-[[package]]
-name = "requests"
-version = "2.32.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "certifi" },
- { name = "charset-normalizer" },
- { name = "idna" },
- { name = "urllib3" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
-]
-
-[[package]]
-name = "ruff"
-version = "0.13.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c7/8e/f9f9ca747fea8e3ac954e3690d4698c9737c23b51731d02df999c150b1c9/ruff-0.13.3.tar.gz", hash = "sha256:5b0ba0db740eefdfbcce4299f49e9eaefc643d4d007749d77d047c2bab19908e", size = 5438533, upload-time = "2025-10-02T19:29:31.582Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/33/8f7163553481466a92656d35dea9331095122bb84cf98210bef597dd2ecd/ruff-0.13.3-py3-none-linux_armv6l.whl", hash = "sha256:311860a4c5e19189c89d035638f500c1e191d283d0cc2f1600c8c80d6dcd430c", size = 12484040, upload-time = "2025-10-02T19:28:49.199Z" },
- { url = "https://files.pythonhosted.org/packages/b0/b5/4a21a4922e5dd6845e91896b0d9ef493574cbe061ef7d00a73c61db531af/ruff-0.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2bdad6512fb666b40fcadb65e33add2b040fc18a24997d2e47fee7d66f7fcae2", size = 13122975, upload-time = "2025-10-02T19:28:52.446Z" },
- { url = "https://files.pythonhosted.org/packages/40/90/15649af836d88c9f154e5be87e64ae7d2b1baa5a3ef317cb0c8fafcd882d/ruff-0.13.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fc6fa4637284708d6ed4e5e970d52fc3b76a557d7b4e85a53013d9d201d93286", size = 12346621, upload-time = "2025-10-02T19:28:54.712Z" },
- { url = "https://files.pythonhosted.org/packages/a5/42/bcbccb8141305f9a6d3f72549dd82d1134299177cc7eaf832599700f95a7/ruff-0.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c9e6469864f94a98f412f20ea143d547e4c652f45e44f369d7b74ee78185838", size = 12574408, upload-time = "2025-10-02T19:28:56.679Z" },
- { url = "https://files.pythonhosted.org/packages/ce/19/0f3681c941cdcfa2d110ce4515624c07a964dc315d3100d889fcad3bfc9e/ruff-0.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5bf62b705f319476c78891e0e97e965b21db468b3c999086de8ffb0d40fd2822", size = 12285330, upload-time = "2025-10-02T19:28:58.79Z" },
- { url = "https://files.pythonhosted.org/packages/10/f8/387976bf00d126b907bbd7725219257feea58650e6b055b29b224d8cb731/ruff-0.13.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cc1abed87ce40cb07ee0667ce99dbc766c9f519eabfd948ed87295d8737c60", size = 13980815, upload-time = "2025-10-02T19:29:01.577Z" },
- { url = "https://files.pythonhosted.org/packages/0c/a6/7c8ec09d62d5a406e2b17d159e4817b63c945a8b9188a771193b7e1cc0b5/ruff-0.13.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:4fb75e7c402d504f7a9a259e0442b96403fa4a7310ffe3588d11d7e170d2b1e3", size = 14987733, upload-time = "2025-10-02T19:29:04.036Z" },
- { url = "https://files.pythonhosted.org/packages/97/e5/f403a60a12258e0fd0c2195341cfa170726f254c788673495d86ab5a9a9d/ruff-0.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:17b951f9d9afb39330b2bdd2dd144ce1c1335881c277837ac1b50bfd99985ed3", size = 14439848, upload-time = "2025-10-02T19:29:06.684Z" },
- { url = "https://files.pythonhosted.org/packages/39/49/3de381343e89364c2334c9f3268b0349dc734fc18b2d99a302d0935c8345/ruff-0.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6052f8088728898e0a449f0dde8fafc7ed47e4d878168b211977e3e7e854f662", size = 13421890, upload-time = "2025-10-02T19:29:08.767Z" },
- { url = "https://files.pythonhosted.org/packages/ab/b5/c0feca27d45ae74185a6bacc399f5d8920ab82df2d732a17213fb86a2c4c/ruff-0.13.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc742c50f4ba72ce2a3be362bd359aef7d0d302bf7637a6f942eaa763bd292af", size = 13444870, upload-time = "2025-10-02T19:29:11.234Z" },
- { url = "https://files.pythonhosted.org/packages/50/a1/b655298a1f3fda4fdc7340c3f671a4b260b009068fbeb3e4e151e9e3e1bf/ruff-0.13.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:8e5640349493b378431637019366bbd73c927e515c9c1babfea3e932f5e68e1d", size = 13691599, upload-time = "2025-10-02T19:29:13.353Z" },
- { url = "https://files.pythonhosted.org/packages/32/b0/a8705065b2dafae007bcae21354e6e2e832e03eb077bb6c8e523c2becb92/ruff-0.13.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6b139f638a80eae7073c691a5dd8d581e0ba319540be97c343d60fb12949c8d0", size = 12421893, upload-time = "2025-10-02T19:29:15.668Z" },
- { url = "https://files.pythonhosted.org/packages/0d/1e/cbe7082588d025cddbb2f23e6dfef08b1a2ef6d6f8328584ad3015b5cebd/ruff-0.13.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:6b547def0a40054825de7cfa341039ebdfa51f3d4bfa6a0772940ed351d2746c", size = 12267220, upload-time = "2025-10-02T19:29:17.583Z" },
- { url = "https://files.pythonhosted.org/packages/a5/99/4086f9c43f85e0755996d09bdcb334b6fee9b1eabdf34e7d8b877fadf964/ruff-0.13.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:9cc48a3564423915c93573f1981d57d101e617839bef38504f85f3677b3a0a3e", size = 13177818, upload-time = "2025-10-02T19:29:19.943Z" },
- { url = "https://files.pythonhosted.org/packages/9b/de/7b5db7e39947d9dc1c5f9f17b838ad6e680527d45288eeb568e860467010/ruff-0.13.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:1a993b17ec03719c502881cb2d5f91771e8742f2ca6de740034433a97c561989", size = 13618715, upload-time = "2025-10-02T19:29:22.527Z" },
- { url = "https://files.pythonhosted.org/packages/28/d3/bb25ee567ce2f61ac52430cf99f446b0e6d49bdfa4188699ad005fdd16aa/ruff-0.13.3-py3-none-win32.whl", hash = "sha256:f14e0d1fe6460f07814d03c6e32e815bff411505178a1f539a38f6097d3e8ee3", size = 12334488, upload-time = "2025-10-02T19:29:24.782Z" },
- { url = "https://files.pythonhosted.org/packages/cf/49/12f5955818a1139eed288753479ba9d996f6ea0b101784bb1fe6977ec128/ruff-0.13.3-py3-none-win_amd64.whl", hash = "sha256:621e2e5812b691d4f244638d693e640f188bacbb9bc793ddd46837cea0503dd2", size = 13455262, upload-time = "2025-10-02T19:29:26.882Z" },
- { url = "https://files.pythonhosted.org/packages/fe/72/7b83242b26627a00e3af70d0394d68f8f02750d642567af12983031777fc/ruff-0.13.3-py3-none-win_arm64.whl", hash = "sha256:9e9e9d699841eaf4c2c798fa783df2fabc680b72059a02ca0ed81c460bc58330", size = 12538484, upload-time = "2025-10-02T19:29:28.951Z" },
-]
-
-[[package]]
-name = "setuptools"
-version = "80.9.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
-]
-
-[[package]]
-name = "simple-web-app"
-version = "0.1.0"
-source = { editable = "." }
-dependencies = [
- { name = "aiosql" },
- { name = "aiosqlite" },
- { name = "argon2-cffi" },
- { name = "httptools" },
- { name = "jinja2" },
- { name = "orjson" },
- { name = "python-multipart" },
- { name = "starlette" },
- { name = "uvicorn" },
- { name = "uvloop" },
-]
-
-[package.dev-dependencies]
-dev = [
- { name = "beautifulsoup4" },
- { name = "httpx" },
- { name = "locust" },
- { name = "pytest" },
- { name = "pytest-asyncio" },
- { name = "pytest-cov" },
- { name = "ruff" },
- { name = "watchfiles" },
-]
-test = [
- { name = "beautifulsoup4" },
- { name = "httpx" },
- { name = "locust" },
- { name = "pytest" },
- { name = "pytest-asyncio" },
- { name = "pytest-cov" },
-]
-
-[package.metadata]
-requires-dist = [
- { name = "aiosql" },
- { name = "aiosqlite" },
- { name = "argon2-cffi" },
- { name = "httptools" },
- { name = "jinja2" },
- { name = "orjson" },
- { name = "python-multipart" },
- { name = "starlette" },
- { name = "uvicorn" },
- { name = "uvloop" },
-]
-
-[package.metadata.requires-dev]
-dev = [
- { name = "beautifulsoup4" },
- { name = "httpx" },
- { name = "locust" },
- { name = "pytest" },
- { name = "pytest-asyncio" },
- { name = "pytest-cov" },
- { name = "ruff" },
- { name = "watchfiles" },
-]
-test = [
- { name = "beautifulsoup4" },
- { name = "httpx" },
- { name = "locust" },
- { name = "pytest" },
- { name = "pytest-asyncio" },
- { name = "pytest-cov" },
-]
-
-[[package]]
-name = "simple-websocket"
-version = "1.1.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "wsproto" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/b0/d4/bfa032f961103eba93de583b161f0e6a5b63cebb8f2c7d0c6e6efe1e3d2e/simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4", size = 17300, upload-time = "2024-10-10T22:39:31.412Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c", size = 13842, upload-time = "2024-10-10T22:39:29.645Z" },
-]
-
-[[package]]
-name = "sniffio"
-version = "1.3.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
-]
-
-[[package]]
-name = "soupsieve"
-version = "2.8"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
-]
-
-[[package]]
-name = "starlette"
-version = "0.48.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a7/a5/d6f429d43394057b67a6b5bbe6eae2f77a6bf7459d961fdb224bf206eee6/starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46", size = 2652949, upload-time = "2025-09-13T08:41:05.699Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/be/72/2db2f49247d0a18b4f1bb9a5a39a0162869acf235f3a96418363947b3d46/starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659", size = 73736, upload-time = "2025-09-13T08:41:03.869Z" },
-]
-
-[[package]]
-name = "typing-extensions"
-version = "4.15.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
-]
-
-[[package]]
-name = "urllib3"
-version = "2.5.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
-]
-
-[[package]]
-name = "uvicorn"
-version = "0.37.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "click" },
- { name = "h11" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" },
-]
-
-[[package]]
-name = "uvloop"
-version = "0.21.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" },
- { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" },
- { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" },
- { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" },
- { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" },
- { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" },
-]
-
-[[package]]
-name = "watchfiles"
-version = "1.1.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "anyio" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d3/42/fae874df96595556a9089ade83be34a2e04f0f11eb53a8dbf8a8a5e562b4/watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30", size = 402004, upload-time = "2025-06-15T19:05:38.499Z" },
- { url = "https://files.pythonhosted.org/packages/fa/55/a77e533e59c3003d9803c09c44c3651224067cbe7fb5d574ddbaa31e11ca/watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a", size = 393671, upload-time = "2025-06-15T19:05:39.52Z" },
- { url = "https://files.pythonhosted.org/packages/05/68/b0afb3f79c8e832e6571022611adbdc36e35a44e14f129ba09709aa4bb7a/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc", size = 449772, upload-time = "2025-06-15T19:05:40.897Z" },
- { url = "https://files.pythonhosted.org/packages/ff/05/46dd1f6879bc40e1e74c6c39a1b9ab9e790bf1f5a2fe6c08b463d9a807f4/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b", size = 456789, upload-time = "2025-06-15T19:05:42.045Z" },
- { url = "https://files.pythonhosted.org/packages/8b/ca/0eeb2c06227ca7f12e50a47a3679df0cd1ba487ea19cf844a905920f8e95/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895", size = 482551, upload-time = "2025-06-15T19:05:43.781Z" },
- { url = "https://files.pythonhosted.org/packages/31/47/2cecbd8694095647406645f822781008cc524320466ea393f55fe70eed3b/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a", size = 597420, upload-time = "2025-06-15T19:05:45.244Z" },
- { url = "https://files.pythonhosted.org/packages/d9/7e/82abc4240e0806846548559d70f0b1a6dfdca75c1b4f9fa62b504ae9b083/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b", size = 477950, upload-time = "2025-06-15T19:05:46.332Z" },
- { url = "https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c", size = 451706, upload-time = "2025-06-15T19:05:47.459Z" },
- { url = "https://files.pythonhosted.org/packages/81/b5/5516cf46b033192d544102ea07c65b6f770f10ed1d0a6d388f5d3874f6e4/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b", size = 625814, upload-time = "2025-06-15T19:05:48.654Z" },
- { url = "https://files.pythonhosted.org/packages/0c/dd/7c1331f902f30669ac3e754680b6edb9a0dd06dea5438e61128111fadd2c/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb", size = 622820, upload-time = "2025-06-15T19:05:50.088Z" },
- { url = "https://files.pythonhosted.org/packages/1b/14/36d7a8e27cd128d7b1009e7715a7c02f6c131be9d4ce1e5c3b73d0e342d8/watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9", size = 279194, upload-time = "2025-06-15T19:05:51.186Z" },
- { url = "https://files.pythonhosted.org/packages/25/41/2dd88054b849aa546dbeef5696019c58f8e0774f4d1c42123273304cdb2e/watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7", size = 292349, upload-time = "2025-06-15T19:05:52.201Z" },
- { url = "https://files.pythonhosted.org/packages/c8/cf/421d659de88285eb13941cf11a81f875c176f76a6d99342599be88e08d03/watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5", size = 283836, upload-time = "2025-06-15T19:05:53.265Z" },
- { url = "https://files.pythonhosted.org/packages/45/10/6faf6858d527e3599cc50ec9fcae73590fbddc1420bd4fdccfebffeedbc6/watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1", size = 400343, upload-time = "2025-06-15T19:05:54.252Z" },
- { url = "https://files.pythonhosted.org/packages/03/20/5cb7d3966f5e8c718006d0e97dfe379a82f16fecd3caa7810f634412047a/watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339", size = 392916, upload-time = "2025-06-15T19:05:55.264Z" },
- { url = "https://files.pythonhosted.org/packages/8c/07/d8f1176328fa9e9581b6f120b017e286d2a2d22ae3f554efd9515c8e1b49/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633", size = 449582, upload-time = "2025-06-15T19:05:56.317Z" },
- { url = "https://files.pythonhosted.org/packages/66/e8/80a14a453cf6038e81d072a86c05276692a1826471fef91df7537dba8b46/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011", size = 456752, upload-time = "2025-06-15T19:05:57.359Z" },
- { url = "https://files.pythonhosted.org/packages/5a/25/0853b3fe0e3c2f5af9ea60eb2e781eade939760239a72c2d38fc4cc335f6/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670", size = 481436, upload-time = "2025-06-15T19:05:58.447Z" },
- { url = "https://files.pythonhosted.org/packages/fe/9e/4af0056c258b861fbb29dcb36258de1e2b857be4a9509e6298abcf31e5c9/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf", size = 596016, upload-time = "2025-06-15T19:05:59.59Z" },
- { url = "https://files.pythonhosted.org/packages/c5/fa/95d604b58aa375e781daf350897aaaa089cff59d84147e9ccff2447c8294/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4", size = 476727, upload-time = "2025-06-15T19:06:01.086Z" },
- { url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864, upload-time = "2025-06-15T19:06:02.144Z" },
- { url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626, upload-time = "2025-06-15T19:06:03.578Z" },
- { url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744, upload-time = "2025-06-15T19:06:05.066Z" },
-]
-
-[[package]]
-name = "websocket-client"
-version = "1.8.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648, upload-time = "2024-04-23T22:16:16.976Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826, upload-time = "2024-04-23T22:16:14.422Z" },
-]
-
-[[package]]
-name = "werkzeug"
-version = "3.1.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "markupsafe" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" },
-]
-
-[[package]]
-name = "wsproto"
-version = "1.2.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "h11" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c9/4a/44d3c295350d776427904d73c189e10aeae66d7f555bb2feee16d1e4ba5a/wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065", size = 53425, upload-time = "2022-08-23T19:58:21.447Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/78/58/e860788190eba3bcce367f74d29c4675466ce8dddfba85f7827588416f01/wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736", size = 24226, upload-time = "2022-08-23T19:58:19.96Z" },
-]
-
-[[package]]
-name = "zope-event"
-version = "6.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "setuptools" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c2/d8/9c8b0c6bb1db09725395618f68d3b8a08089fca0aed28437500caaf713ee/zope_event-6.0.tar.gz", hash = "sha256:0ebac894fa7c5f8b7a89141c272133d8c1de6ddc75ea4b1f327f00d1f890df92", size = 18731, upload-time = "2025-09-12T07:10:13.551Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/b5/1abb5a8b443314c978617bf46d5d9ad648bdf21058074e817d7efbb257db/zope_event-6.0-py3-none-any.whl", hash = "sha256:6f0922593407cc673e7d8766b492c519f91bdc99f3080fe43dcec0a800d682a3", size = 6409, upload-time = "2025-09-12T07:10:12.316Z" },
-]
-
-[[package]]
-name = "zope-interface"
-version = "8.0.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/88/3a/7fcf02178b8fad0a51e67e32765cd039ae505d054d744d76b8c2bbcba5ba/zope_interface-8.0.1.tar.gz", hash = "sha256:eba5610d042c3704a48222f7f7c6ab5b243ed26f917e2bc69379456b115e02d1", size = 253746, upload-time = "2025-09-25T05:55:51.285Z" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5f/dc/3c12fca01c910c793d636ffe9c0984e0646abaf804e44552070228ed0ede/zope_interface-8.0.1-cp313-cp313-macosx_10_9_x86_64.whl", hash = "sha256:c7cc027fc5c61c5d69e5080c30b66382f454f43dc379c463a38e78a9c6bab71a", size = 208992, upload-time = "2025-09-25T05:58:40.712Z" },
- { url = "https://files.pythonhosted.org/packages/46/71/6127b7282a3e380ca927ab2b40778a9c97935a4a57a2656dadc312db5f30/zope_interface-8.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fcf9097ff3003b7662299f1c25145e15260ec2a27f9a9e69461a585d79ca8552", size = 209051, upload-time = "2025-09-25T05:58:42.182Z" },
- { url = "https://files.pythonhosted.org/packages/56/86/4387a9f951ee18b0e41fda77da77d59c33e59f04660578e2bad688703e64/zope_interface-8.0.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6d965347dd1fb9e9a53aa852d4ded46b41ca670d517fd54e733a6b6a4d0561c2", size = 259223, upload-time = "2025-09-25T05:58:23.191Z" },
- { url = "https://files.pythonhosted.org/packages/61/08/ce60a114466abc067c68ed41e2550c655f551468ae17b4b17ea360090146/zope_interface-8.0.1-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9a3b8bb77a4b89427a87d1e9eb969ab05e38e6b4a338a9de10f6df23c33ec3c2", size = 264690, upload-time = "2025-09-25T05:58:15.052Z" },
- { url = "https://files.pythonhosted.org/packages/36/9a/62a9ba3a919594605a07c34eee3068659bbd648e2fa0c4a86d876810b674/zope_interface-8.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:87e6b089002c43231fb9afec89268391bcc7a3b66e76e269ffde19a8112fb8d5", size = 264201, upload-time = "2025-09-25T06:26:27.797Z" },
- { url = "https://files.pythonhosted.org/packages/da/06/8fe88bd7edef60566d21ef5caca1034e10f6b87441ea85de4bbf9ea74768/zope_interface-8.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:64a43f5280aa770cbafd0307cb3d1ff430e2a1001774e8ceb40787abe4bb6658", size = 212273, upload-time = "2025-09-25T06:00:25.398Z" },
-]