Merge branch 'feat/charts'

This commit is contained in:
ducklet 2024-05-11 19:10:33 +02:00
commit f46ab98ac2
35 changed files with 2163 additions and 1132 deletions

View file

@ -1,4 +1,6 @@
**/__pycache__
*.local *.local
.*
/data /data
/tests /tests
/unwind-ui /unwind-ui

19
.editorconfig Normal file
View file

@ -0,0 +1,19 @@
# editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.{js,json,vue,ts,css}]
indent_size = 2
[*.py]
indent_size = 4
[*.md]
trim_trailing_whitespace = false

View file

@ -1,4 +1,6 @@
FROM docker.io/library/python:3.12-alpine ARG PYTHON_VERSION
FROM docker.io/library/python:${PYTHON_VERSION}-alpine
RUN apk update --no-cache \ RUN apk update --no-cache \
&& apk upgrade --no-cache \ && apk upgrade --no-cache \
@ -9,14 +11,16 @@ RUN addgroup -g 10001 py \
WORKDIR /var/app WORKDIR /var/app
COPY requirements.txt ./ COPY build/requirements.txt ./
RUN pip install --no-cache-dir --upgrade \ RUN pip install --no-cache-dir --upgrade \
--requirement requirements.txt --requirement requirements.txt
USER 10000:10001 USER 10000:10001
COPY . ./ COPY run ./
COPY scripts ./scripts
COPY unwind ./unwind
ENV UNWIND_DATA="/data" ENV UNWIND_DATA="/data"
VOLUME $UNWIND_DATA VOLUME $UNWIND_DATA

528
poetry.lock generated
View file

@ -1,29 +1,32 @@
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand.
[[package]] [[package]]
name = "aiosqlite" name = "aiosqlite"
version = "0.19.0" version = "0.20.0"
description = "asyncio bridge to the standard sqlite3 module" description = "asyncio bridge to the standard sqlite3 module"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
files = [ files = [
{file = "aiosqlite-0.19.0-py3-none-any.whl", hash = "sha256:edba222e03453e094a3ce605db1b970c4b3376264e56f32e2a4959f948d66a96"}, {file = "aiosqlite-0.20.0-py3-none-any.whl", hash = "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6"},
{file = "aiosqlite-0.19.0.tar.gz", hash = "sha256:95ee77b91c8d2808bd08a59fbebf66270e9090c3d92ffbf260dc0db0b979577d"}, {file = "aiosqlite-0.20.0.tar.gz", hash = "sha256:6d35c8c256637f4672f843c31021464090805bf925385ac39473fb16eaaca3d7"},
] ]
[package.dependencies]
typing_extensions = ">=4.0"
[package.extras] [package.extras]
dev = ["aiounittest (==1.4.1)", "attribution (==1.6.2)", "black (==23.3.0)", "coverage[toml] (==7.2.3)", "flake8 (==5.0.4)", "flake8-bugbear (==23.3.12)", "flit (==3.7.1)", "mypy (==1.2.0)", "ufmt (==2.1.0)", "usort (==1.0.6)"] dev = ["attribution (==1.7.0)", "black (==24.2.0)", "coverage[toml] (==7.4.1)", "flake8 (==7.0.0)", "flake8-bugbear (==24.2.6)", "flit (==3.9.0)", "mypy (==1.8.0)", "ufmt (==2.3.0)", "usort (==1.0.8.post1)"]
docs = ["sphinx (==6.1.3)", "sphinx-mdinclude (==0.5.3)"] docs = ["sphinx (==7.2.6)", "sphinx-mdinclude (==0.5.3)"]
[[package]] [[package]]
name = "anyio" name = "anyio"
version = "4.1.0" version = "4.3.0"
description = "High level compatibility layer for multiple asynchronous event loop implementations" description = "High level compatibility layer for multiple asynchronous event loop implementations"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "anyio-4.1.0-py3-none-any.whl", hash = "sha256:56a415fbc462291813a94528a779597226619c8e78af7de0507333f700011e5f"}, {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"},
{file = "anyio-4.1.0.tar.gz", hash = "sha256:5a0bec7085176715be77df87fc66d6c9d70626bd752fcc85f57cdbee5b3760da"}, {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"},
] ]
[package.dependencies] [package.dependencies]
@ -37,31 +40,34 @@ trio = ["trio (>=0.23)"]
[[package]] [[package]]
name = "beautifulsoup4" name = "beautifulsoup4"
version = "4.12.2" version = "4.12.3"
description = "Screen-scraping library" description = "Screen-scraping library"
optional = false optional = false
python-versions = ">=3.6.0" python-versions = ">=3.6.0"
files = [ files = [
{file = "beautifulsoup4-4.12.2-py3-none-any.whl", hash = "sha256:bd2520ca0d9d7d12694a53d44ac482d181b4ec1888909b035a3dbf40d0f57d4a"}, {file = "beautifulsoup4-4.12.3-py3-none-any.whl", hash = "sha256:b80878c9f40111313e55da8ba20bdba06d8fa3969fc68304167741bbf9e082ed"},
{file = "beautifulsoup4-4.12.2.tar.gz", hash = "sha256:492bbc69dca35d12daac71c4db1bfff0c876c00ef4a2ffacce226d4638eb72da"}, {file = "beautifulsoup4-4.12.3.tar.gz", hash = "sha256:74e3d1928edc070d21748185c46e3fb33490f22f52a3addee9aee0f4f7781051"},
] ]
[package.dependencies] [package.dependencies]
soupsieve = ">1.2" soupsieve = ">1.2"
[package.extras] [package.extras]
cchardet = ["cchardet"]
chardet = ["chardet"]
charset-normalizer = ["charset-normalizer"]
html5lib = ["html5lib"] html5lib = ["html5lib"]
lxml = ["lxml"] lxml = ["lxml"]
[[package]] [[package]]
name = "certifi" name = "certifi"
version = "2023.11.17" version = "2024.2.2"
description = "Python package for providing Mozilla's CA Bundle." description = "Python package for providing Mozilla's CA Bundle."
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
files = [ files = [
{file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"},
{file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"},
] ]
[[package]] [[package]]
@ -91,63 +97,63 @@ files = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.3.2" version = "7.5.1"
description = "Code coverage measurement for Python" description = "Code coverage measurement for Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "coverage-7.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d872145f3a3231a5f20fd48500274d7df222e291d90baa2026cc5152b7ce86bf"}, {file = "coverage-7.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c0884920835a033b78d1c73b6d3bbcda8161a900f38a488829a83982925f6c2e"},
{file = "coverage-7.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:310b3bb9c91ea66d59c53fa4989f57d2436e08f18fb2f421a1b0b6b8cc7fffda"}, {file = "coverage-7.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:39afcd3d4339329c5f58de48a52f6e4e50f6578dd6099961cf22228feb25f38f"},
{file = "coverage-7.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f47d39359e2c3779c5331fc740cf4bce6d9d680a7b4b4ead97056a0ae07cb49a"}, {file = "coverage-7.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a7b0ceee8147444347da6a66be737c9d78f3353b0681715b668b72e79203e4a"},
{file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa72dbaf2c2068404b9870d93436e6d23addd8bbe9295f49cbca83f6e278179c"}, {file = "coverage-7.5.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a9ca3f2fae0088c3c71d743d85404cec8df9be818a005ea065495bedc33da35"},
{file = "coverage-7.3.2-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:beaa5c1b4777f03fc63dfd2a6bd820f73f036bfb10e925fce067b00a340d0f3f"}, {file = "coverage-7.5.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5fd215c0c7d7aab005221608a3c2b46f58c0285a819565887ee0b718c052aa4e"},
{file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dbc1b46b92186cc8074fee9d9fbb97a9dd06c6cbbef391c2f59d80eabdf0faa6"}, {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4bf0655ab60d754491004a5efd7f9cccefcc1081a74c9ef2da4735d6ee4a6223"},
{file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:315a989e861031334d7bee1f9113c8770472db2ac484e5b8c3173428360a9148"}, {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:61c4bf1ba021817de12b813338c9be9f0ad5b1e781b9b340a6d29fc13e7c1b5e"},
{file = "coverage-7.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:d1bc430677773397f64a5c88cb522ea43175ff16f8bfcc89d467d974cb2274f9"}, {file = "coverage-7.5.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:db66fc317a046556a96b453a58eced5024af4582a8dbdc0c23ca4dbc0d5b3146"},
{file = "coverage-7.3.2-cp310-cp310-win32.whl", hash = "sha256:a889ae02f43aa45032afe364c8ae84ad3c54828c2faa44f3bfcafecb5c96b02f"}, {file = "coverage-7.5.1-cp310-cp310-win32.whl", hash = "sha256:b016ea6b959d3b9556cb401c55a37547135a587db0115635a443b2ce8f1c7228"},
{file = "coverage-7.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c0ba320de3fb8c6ec16e0be17ee1d3d69adcda99406c43c0409cb5c41788a611"}, {file = "coverage-7.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:df4e745a81c110e7446b1cc8131bf986157770fa405fe90e15e850aaf7619bc8"},
{file = "coverage-7.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ac8c802fa29843a72d32ec56d0ca792ad15a302b28ca6203389afe21f8fa062c"}, {file = "coverage-7.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:796a79f63eca8814ca3317a1ea443645c9ff0d18b188de470ed7ccd45ae79428"},
{file = "coverage-7.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:89a937174104339e3a3ffcf9f446c00e3a806c28b1841c63edb2b369310fd074"}, {file = "coverage-7.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fc84a37bfd98db31beae3c2748811a3fa72bf2007ff7902f68746d9757f3746"},
{file = "coverage-7.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e267e9e2b574a176ddb983399dec325a80dbe161f1a32715c780b5d14b5f583a"}, {file = "coverage-7.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6175d1a0559986c6ee3f7fccfc4a90ecd12ba0a383dcc2da30c2b9918d67d8a3"},
{file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2443cbda35df0d35dcfb9bf8f3c02c57c1d6111169e3c85fc1fcc05e0c9f39a3"}, {file = "coverage-7.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fc81d5878cd6274ce971e0a3a18a8803c3fe25457165314271cf78e3aae3aa2"},
{file = "coverage-7.3.2-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4175e10cc8dda0265653e8714b3174430b07c1dca8957f4966cbd6c2b1b8065a"}, {file = "coverage-7.5.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:556cf1a7cbc8028cb60e1ff0be806be2eded2daf8129b8811c63e2b9a6c43bca"},
{file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0cbf38419fb1a347aaf63481c00f0bdc86889d9fbf3f25109cf96c26b403fda1"}, {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:9981706d300c18d8b220995ad22627647be11a4276721c10911e0e9fa44c83e8"},
{file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:5c913b556a116b8d5f6ef834038ba983834d887d82187c8f73dec21049abd65c"}, {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:d7fed867ee50edf1a0b4a11e8e5d0895150e572af1cd6d315d557758bfa9c057"},
{file = "coverage-7.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1981f785239e4e39e6444c63a98da3a1db8e971cb9ceb50a945ba6296b43f312"}, {file = "coverage-7.5.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ef48e2707fb320c8f139424a596f5b69955a85b178f15af261bab871873bb987"},
{file = "coverage-7.3.2-cp311-cp311-win32.whl", hash = "sha256:43668cabd5ca8258f5954f27a3aaf78757e6acf13c17604d89648ecc0cc66640"}, {file = "coverage-7.5.1-cp311-cp311-win32.whl", hash = "sha256:9314d5678dcc665330df5b69c1e726a0e49b27df0461c08ca12674bcc19ef136"},
{file = "coverage-7.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10c39c0452bf6e694511c901426d6b5ac005acc0f78ff265dbe36bf81f808a2"}, {file = "coverage-7.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fa567e99765fe98f4e7d7394ce623e794d7cabb170f2ca2ac5a4174437e90dd"},
{file = "coverage-7.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:4cbae1051ab791debecc4a5dcc4a1ff45fc27b91b9aee165c8a27514dd160836"}, {file = "coverage-7.5.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b6cf3764c030e5338e7f61f95bd21147963cf6aa16e09d2f74f1fa52013c1206"},
{file = "coverage-7.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12d15ab5833a997716d76f2ac1e4b4d536814fc213c85ca72756c19e5a6b3d63"}, {file = "coverage-7.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2ec92012fefebee89a6b9c79bc39051a6cb3891d562b9270ab10ecfdadbc0c34"},
{file = "coverage-7.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c7bba973ebee5e56fe9251300c00f1579652587a9f4a5ed8404b15a0471f216"}, {file = "coverage-7.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:16db7f26000a07efcf6aea00316f6ac57e7d9a96501e990a36f40c965ec7a95d"},
{file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fe494faa90ce6381770746077243231e0b83ff3f17069d748f645617cefe19d4"}, {file = "coverage-7.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:beccf7b8a10b09c4ae543582c1319c6df47d78fd732f854ac68d518ee1fb97fa"},
{file = "coverage-7.3.2-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6e9589bd04d0461a417562649522575d8752904d35c12907d8c9dfeba588faf"}, {file = "coverage-7.5.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8748731ad392d736cc9ccac03c9845b13bb07d020a33423fa5b3a36521ac6e4e"},
{file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d51ac2a26f71da1b57f2dc81d0e108b6ab177e7d30e774db90675467c847bbdf"}, {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7352b9161b33fd0b643ccd1f21f3a3908daaddf414f1c6cb9d3a2fd618bf2572"},
{file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:99b89d9f76070237975b315b3d5f4d6956ae354a4c92ac2388a5695516e47c84"}, {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:7a588d39e0925f6a2bff87154752481273cdb1736270642aeb3635cb9b4cad07"},
{file = "coverage-7.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fa28e909776dc69efb6ed975a63691bc8172b64ff357e663a1bb06ff3c9b589a"}, {file = "coverage-7.5.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:68f962d9b72ce69ea8621f57551b2fa9c70509af757ee3b8105d4f51b92b41a7"},
{file = "coverage-7.3.2-cp312-cp312-win32.whl", hash = "sha256:289fe43bf45a575e3ab10b26d7b6f2ddb9ee2dba447499f5401cfb5ecb8196bb"}, {file = "coverage-7.5.1-cp312-cp312-win32.whl", hash = "sha256:f152cbf5b88aaeb836127d920dd0f5e7edff5a66f10c079157306c4343d86c19"},
{file = "coverage-7.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dbc3ed60e8659bc59b6b304b43ff9c3ed858da2839c78b804973f613d3e92ed"}, {file = "coverage-7.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:5a5740d1fb60ddf268a3811bcd353de34eb56dc24e8f52a7f05ee513b2d4f596"},
{file = "coverage-7.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f94b734214ea6a36fe16e96a70d941af80ff3bfd716c141300d95ebc85339738"}, {file = "coverage-7.5.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e2213def81a50519d7cc56ed643c9e93e0247f5bbe0d1247d15fa520814a7cd7"},
{file = "coverage-7.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:af3d828d2c1cbae52d34bdbb22fcd94d1ce715d95f1a012354a75e5913f1bda2"}, {file = "coverage-7.5.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5037f8fcc2a95b1f0e80585bd9d1ec31068a9bcb157d9750a172836e98bc7a90"},
{file = "coverage-7.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:630b13e3036e13c7adc480ca42fa7afc2a5d938081d28e20903cf7fd687872e2"}, {file = "coverage-7.5.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3721c2c9e4c4953a41a26c14f4cef64330392a6d2d675c8b1db3b645e31f0e"},
{file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9eacf273e885b02a0273bb3a2170f30e2d53a6d53b72dbe02d6701b5296101c"}, {file = "coverage-7.5.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca498687ca46a62ae590253fba634a1fe9836bc56f626852fb2720f334c9e4e5"},
{file = "coverage-7.3.2-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d8f17966e861ff97305e0801134e69db33b143bbfb36436efb9cfff6ec7b2fd9"}, {file = "coverage-7.5.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0cdcbc320b14c3e5877ee79e649677cb7d89ef588852e9583e6b24c2e5072661"},
{file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b4275802d16882cf9c8b3d057a0839acb07ee9379fa2749eca54efbce1535b82"}, {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:57e0204b5b745594e5bc14b9b50006da722827f0b8c776949f1135677e88d0b8"},
{file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:72c0cfa5250f483181e677ebc97133ea1ab3eb68645e494775deb6a7f6f83901"}, {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:8fe7502616b67b234482c3ce276ff26f39ffe88adca2acf0261df4b8454668b4"},
{file = "coverage-7.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cb536f0dcd14149425996821a168f6e269d7dcd2c273a8bff8201e79f5104e76"}, {file = "coverage-7.5.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:9e78295f4144f9dacfed4f92935fbe1780021247c2fabf73a819b17f0ccfff8d"},
{file = "coverage-7.3.2-cp38-cp38-win32.whl", hash = "sha256:307adb8bd3abe389a471e649038a71b4eb13bfd6b7dd9a129fa856f5c695cf92"}, {file = "coverage-7.5.1-cp38-cp38-win32.whl", hash = "sha256:1434e088b41594baa71188a17533083eabf5609e8e72f16ce8c186001e6b8c41"},
{file = "coverage-7.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:88ed2c30a49ea81ea3b7f172e0269c182a44c236eb394718f976239892c0a27a"}, {file = "coverage-7.5.1-cp38-cp38-win_amd64.whl", hash = "sha256:0646599e9b139988b63704d704af8e8df7fa4cbc4a1f33df69d97f36cb0a38de"},
{file = "coverage-7.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b631c92dfe601adf8f5ebc7fc13ced6bb6e9609b19d9a8cd59fa47c4186ad1ce"}, {file = "coverage-7.5.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4cc37def103a2725bc672f84bd939a6fe4522310503207aae4d56351644682f1"},
{file = "coverage-7.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d3d9df4051c4a7d13036524b66ecf7a7537d14c18a384043f30a303b146164e9"}, {file = "coverage-7.5.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fc0b4d8bfeabd25ea75e94632f5b6e047eef8adaed0c2161ada1e922e7f7cece"},
{file = "coverage-7.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f7363d3b6a1119ef05015959ca24a9afc0ea8a02c687fe7e2d557705375c01f"}, {file = "coverage-7.5.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d0a0f5e06881ecedfe6f3dd2f56dcb057b6dbeb3327fd32d4b12854df36bf26"},
{file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2f11cc3c967a09d3695d2a6f03fb3e6236622b93be7a4b5dc09166a861be6d25"}, {file = "coverage-7.5.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9735317685ba6ec7e3754798c8871c2f49aa5e687cc794a0b1d284b2389d1bd5"},
{file = "coverage-7.3.2-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:149de1d2401ae4655c436a3dced6dd153f4c3309f599c3d4bd97ab172eaf02d9"}, {file = "coverage-7.5.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d21918e9ef11edf36764b93101e2ae8cc82aa5efdc7c5a4e9c6c35a48496d601"},
{file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:3a4006916aa6fee7cd38db3bfc95aa9c54ebb4ffbfc47c677c8bba949ceba0a6"}, {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c3e757949f268364b96ca894b4c342b41dc6f8f8b66c37878aacef5930db61be"},
{file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9028a3871280110d6e1aa2df1afd5ef003bab5fb1ef421d6dc748ae1c8ef2ebc"}, {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:79afb6197e2f7f60c4824dd4b2d4c2ec5801ceb6ba9ce5d2c3080e5660d51a4f"},
{file = "coverage-7.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9f805d62aec8eb92bab5b61c0f07329275b6f41c97d80e847b03eb894f38d083"}, {file = "coverage-7.5.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d1d0d98d95dd18fe29dc66808e1accf59f037d5716f86a501fc0256455219668"},
{file = "coverage-7.3.2-cp39-cp39-win32.whl", hash = "sha256:d1c88ec1a7ff4ebca0219f5b1ef863451d828cccf889c173e1253aa84b1e07ce"}, {file = "coverage-7.5.1-cp39-cp39-win32.whl", hash = "sha256:1cc0fe9b0b3a8364093c53b0b4c0c2dd4bb23acbec4c9240b5f284095ccf7981"},
{file = "coverage-7.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b4767da59464bb593c07afceaddea61b154136300881844768037fd5e859353f"}, {file = "coverage-7.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:dde0070c40ea8bb3641e811c1cfbf18e265d024deff6de52c5950677a8fb1e0f"},
{file = "coverage-7.3.2-pp38.pp39.pp310-none-any.whl", hash = "sha256:ae97af89f0fbf373400970c0a21eef5aa941ffeed90aee43650b81f7d7f47637"}, {file = "coverage-7.5.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:6537e7c10cc47c595828b8a8be04c72144725c383c4702703ff4e42e44577312"},
{file = "coverage-7.3.2.tar.gz", hash = "sha256:be32ad29341b0170e795ca590e1c07e81fc061cb5b10c74ce7203491484404ef"}, {file = "coverage-7.5.1.tar.gz", hash = "sha256:54de9ef3a9da981f7af93eafde4ede199e0846cd819eb27c88e2b712aae9708c"},
] ]
[package.extras] [package.extras]
@ -155,72 +161,73 @@ toml = ["tomli"]
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.0.1" version = "3.0.3"
description = "Lightweight in-process concurrent programming" description = "Lightweight in-process concurrent programming"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "greenlet-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f89e21afe925fcfa655965ca8ea10f24773a1791400989ff32f467badfe4a064"}, {file = "greenlet-3.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a"},
{file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28e89e232c7593d33cac35425b58950789962011cc274aa43ef8865f2e11f46d"}, {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881"},
{file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8ba29306c5de7717b5761b9ea74f9c72b9e2b834e24aa984da99cbfc70157fd"}, {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b"},
{file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19bbdf1cce0346ef7341705d71e2ecf6f41a35c311137f29b8a2dc2341374565"}, {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a"},
{file = "greenlet-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:599daf06ea59bfedbec564b1692b0166a0045f32b6f0933b0dd4df59a854caf2"}, {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83"},
{file = "greenlet-3.0.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b641161c302efbb860ae6b081f406839a8b7d5573f20a455539823802c655f63"}, {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405"},
{file = "greenlet-3.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d57e20ba591727da0c230ab2c3f200ac9d6d333860d85348816e1dca4cc4792e"}, {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f"},
{file = "greenlet-3.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5805e71e5b570d490938d55552f5a9e10f477c19400c38bf1d5190d760691846"}, {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb"},
{file = "greenlet-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:52e93b28db27ae7d208748f45d2db8a7b6a380e0d703f099c949d0f0d80b70e9"}, {file = "greenlet-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9"},
{file = "greenlet-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f7bfb769f7efa0eefcd039dd19d843a4fbfbac52f1878b1da2ed5793ec9b1a65"}, {file = "greenlet-3.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61"},
{file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91e6c7db42638dc45cf2e13c73be16bf83179f7859b07cfc139518941320be96"}, {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559"},
{file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1757936efea16e3f03db20efd0cd50a1c86b06734f9f7338a90c4ba85ec2ad5a"}, {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e"},
{file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19075157a10055759066854a973b3d1325d964d498a805bb68a1f9af4aaef8ec"}, {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33"},
{file = "greenlet-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9d21aaa84557d64209af04ff48e0ad5e28c5cca67ce43444e939579d085da72"}, {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379"},
{file = "greenlet-3.0.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2847e5d7beedb8d614186962c3d774d40d3374d580d2cbdab7f184580a39d234"}, {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22"},
{file = "greenlet-3.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:97e7ac860d64e2dcba5c5944cfc8fa9ea185cd84061c623536154d5a89237884"}, {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3"},
{file = "greenlet-3.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:b2c02d2ad98116e914d4f3155ffc905fd0c025d901ead3f6ed07385e19122c94"}, {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d"},
{file = "greenlet-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:22f79120a24aeeae2b4471c711dcf4f8c736a2bb2fabad2a67ac9a55ea72523c"}, {file = "greenlet-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728"},
{file = "greenlet-3.0.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:100f78a29707ca1525ea47388cec8a049405147719f47ebf3895e7509c6446aa"}, {file = "greenlet-3.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be"},
{file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:60d5772e8195f4e9ebf74046a9121bbb90090f6550f81d8956a05387ba139353"}, {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e"},
{file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:daa7197b43c707462f06d2c693ffdbb5991cbb8b80b5b984007de431493a319c"}, {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676"},
{file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea6b8aa9e08eea388c5f7a276fabb1d4b6b9d6e4ceb12cc477c3d352001768a9"}, {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc"},
{file = "greenlet-3.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d11ebbd679e927593978aa44c10fc2092bc454b7d13fdc958d3e9d508aba7d0"}, {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230"},
{file = "greenlet-3.0.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbd4c177afb8a8d9ba348d925b0b67246147af806f0b104af4d24f144d461cd5"}, {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf"},
{file = "greenlet-3.0.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:20107edf7c2c3644c67c12205dc60b1bb11d26b2610b276f97d666110d1b511d"}, {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305"},
{file = "greenlet-3.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8bef097455dea90ffe855286926ae02d8faa335ed8e4067326257cb571fc1445"}, {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6"},
{file = "greenlet-3.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:b2d3337dcfaa99698aa2377c81c9ca72fcd89c07e7eb62ece3f23a3fe89b2ce4"}, {file = "greenlet-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2"},
{file = "greenlet-3.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:80ac992f25d10aaebe1ee15df45ca0d7571d0f70b645c08ec68733fb7a020206"}, {file = "greenlet-3.0.3-cp37-cp37m-macosx_11_0_universal2.whl", hash = "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274"},
{file = "greenlet-3.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:337322096d92808f76ad26061a8f5fccb22b0809bea39212cd6c406f6a7060d2"}, {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0"},
{file = "greenlet-3.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9934adbd0f6e476f0ecff3c94626529f344f57b38c9a541f87098710b18af0a"}, {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f"},
{file = "greenlet-3.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc4d815b794fd8868c4d67602692c21bf5293a75e4b607bb92a11e821e2b859a"}, {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414"},
{file = "greenlet-3.0.1-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41bdeeb552d814bcd7fb52172b304898a35818107cc8778b5101423c9017b3de"}, {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c"},
{file = "greenlet-3.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6e6061bf1e9565c29002e3c601cf68569c450be7fc3f7336671af7ddb4657166"}, {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41"},
{file = "greenlet-3.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:fa24255ae3c0ab67e613556375a4341af04a084bd58764731972bcbc8baeba36"}, {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7"},
{file = "greenlet-3.0.1-cp37-cp37m-win32.whl", hash = "sha256:b489c36d1327868d207002391f662a1d163bdc8daf10ab2e5f6e41b9b96de3b1"}, {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6"},
{file = "greenlet-3.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f33f3258aae89da191c6ebaa3bc517c6c4cbc9b9f689e5d8452f7aedbb913fa8"}, {file = "greenlet-3.0.3-cp37-cp37m-win32.whl", hash = "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d"},
{file = "greenlet-3.0.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:d2905ce1df400360463c772b55d8e2518d0e488a87cdea13dd2c71dcb2a1fa16"}, {file = "greenlet-3.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67"},
{file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a02d259510b3630f330c86557331a3b0e0c79dac3d166e449a39363beaae174"}, {file = "greenlet-3.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca"},
{file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55d62807f1c5a1682075c62436702aaba941daa316e9161e4b6ccebbbf38bda3"}, {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04"},
{file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fcc780ae8edbb1d050d920ab44790201f027d59fdbd21362340a85c79066a74"}, {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc"},
{file = "greenlet-3.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4eddd98afc726f8aee1948858aed9e6feeb1758889dfd869072d4465973f6bfd"}, {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506"},
{file = "greenlet-3.0.1-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eabe7090db68c981fca689299c2d116400b553f4b713266b130cfc9e2aa9c5a9"}, {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b"},
{file = "greenlet-3.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f2f6d303f3dee132b322a14cd8765287b8f86cdc10d2cb6a6fae234ea488888e"}, {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4"},
{file = "greenlet-3.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d923ff276f1c1f9680d32832f8d6c040fe9306cbfb5d161b0911e9634be9ef0a"}, {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5"},
{file = "greenlet-3.0.1-cp38-cp38-win32.whl", hash = "sha256:0b6f9f8ca7093fd4433472fd99b5650f8a26dcd8ba410e14094c1e44cd3ceddd"}, {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da"},
{file = "greenlet-3.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:990066bff27c4fcf3b69382b86f4c99b3652bab2a7e685d968cd4d0cfc6f67c6"}, {file = "greenlet-3.0.3-cp38-cp38-win32.whl", hash = "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3"},
{file = "greenlet-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ce85c43ae54845272f6f9cd8320d034d7a946e9773c693b27d620edec825e376"}, {file = "greenlet-3.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf"},
{file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89ee2e967bd7ff85d84a2de09df10e021c9b38c7d91dead95b406ed6350c6997"}, {file = "greenlet-3.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53"},
{file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:87c8ceb0cf8a5a51b8008b643844b7f4a8264a2c13fcbcd8a8316161725383fe"}, {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257"},
{file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d6a8c9d4f8692917a3dc7eb25a6fb337bff86909febe2f793ec1928cd97bedfc"}, {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac"},
{file = "greenlet-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fbc5b8f3dfe24784cee8ce0be3da2d8a79e46a276593db6868382d9c50d97b1"}, {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71"},
{file = "greenlet-3.0.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85d2b77e7c9382f004b41d9c72c85537fac834fb141b0296942d52bf03fe4a3d"}, {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61"},
{file = "greenlet-3.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:696d8e7d82398e810f2b3622b24e87906763b6ebfd90e361e88eb85b0e554dc8"}, {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b"},
{file = "greenlet-3.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:329c5a2e5a0ee942f2992c5e3ff40be03e75f745f48847f118a3cfece7a28546"}, {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6"},
{file = "greenlet-3.0.1-cp39-cp39-win32.whl", hash = "sha256:cf868e08690cb89360eebc73ba4be7fb461cfbc6168dd88e2fbbe6f31812cd57"}, {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113"},
{file = "greenlet-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:ac4a39d1abae48184d420aa8e5e63efd1b75c8444dd95daa3e03f6c6310e9619"}, {file = "greenlet-3.0.3-cp39-cp39-win32.whl", hash = "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e"},
{file = "greenlet-3.0.1.tar.gz", hash = "sha256:816bd9488a94cba78d93e1abb58000e8266fa9cc2aa9ccdd6eb0696acb24005b"}, {file = "greenlet-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067"},
{file = "greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"},
] ]
[package.extras] [package.extras]
docs = ["Sphinx"] docs = ["Sphinx", "furo"]
test = ["objgraph", "psutil"] test = ["objgraph", "psutil"]
[[package]] [[package]]
@ -274,39 +281,40 @@ lxml = ["lxml"]
[[package]] [[package]]
name = "httpcore" name = "httpcore"
version = "0.17.3" version = "1.0.5"
description = "A minimal low-level HTTP client." description = "A minimal low-level HTTP client."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
files = [ files = [
{file = "httpcore-0.17.3-py3-none-any.whl", hash = "sha256:c2789b767ddddfa2a5782e3199b2b7f6894540b17b16ec26b2c4d8e103510b87"}, {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"},
{file = "httpcore-0.17.3.tar.gz", hash = "sha256:a6f30213335e34c1ade7be6ec7c47f19f50c56db36abef1a9dfa3815b1cb3888"}, {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"},
] ]
[package.dependencies] [package.dependencies]
anyio = ">=3.0,<5.0"
certifi = "*" certifi = "*"
h11 = ">=0.13,<0.15" h11 = ">=0.13,<0.15"
sniffio = "==1.*"
[package.extras] [package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"] http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"] socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<0.26.0)"]
[[package]] [[package]]
name = "httpx" name = "httpx"
version = "0.24.1" version = "0.27.0"
description = "The next generation HTTP client." description = "The next generation HTTP client."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
files = [ files = [
{file = "httpx-0.24.1-py3-none-any.whl", hash = "sha256:06781eb9ac53cde990577af654bd990a4949de37a28bdb4a230d434f3a30b9bd"}, {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"},
{file = "httpx-0.24.1.tar.gz", hash = "sha256:5853a43053df830c20f8110c5e69fe44d035d850b2dfe795e196f00fdb774bdd"}, {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"},
] ]
[package.dependencies] [package.dependencies]
anyio = "*"
certifi = "*" certifi = "*"
httpcore = ">=0.15.0,<0.18.0" httpcore = "==1.*"
idna = "*" idna = "*"
sniffio = "*" sniffio = "*"
@ -318,13 +326,13 @@ socks = ["socksio (==1.*)"]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.6" version = "3.7"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
optional = false optional = false
python-versions = ">=3.5" python-versions = ">=3.5"
files = [ files = [
{file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, {file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
{file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, {file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
] ]
[[package]] [[package]]
@ -354,24 +362,24 @@ setuptools = "*"
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "23.2" version = "24.0"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"},
{file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"},
] ]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.3.0" version = "1.5.0"
description = "plugin and hook calling mechanisms for python" description = "plugin and hook calling mechanisms for python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"},
{file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"},
] ]
[package.extras] [package.extras]
@ -380,13 +388,13 @@ testing = ["pytest", "pytest-benchmark"]
[[package]] [[package]]
name = "pyright" name = "pyright"
version = "1.1.337" version = "1.1.362"
description = "Command line wrapper for pyright" description = "Command line wrapper for pyright"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "pyright-1.1.337-py3-none-any.whl", hash = "sha256:8cbd4ef71797258f816a8393a758c9c91213479f472082d0e3a735ef7ab5f65a"}, {file = "pyright-1.1.362-py3-none-any.whl", hash = "sha256:969957cff45154d8a45a4ab1dae5bdc8223d8bd3c64654fa608ab3194dfff319"},
{file = "pyright-1.1.337.tar.gz", hash = "sha256:81d81f839d1750385390c4c4a7b84b062ece2f9a078f87055d4d2a5914ef2a08"}, {file = "pyright-1.1.362.tar.gz", hash = "sha256:6a477e448d4a07a6a0eab58b2a15a1bbed031eb3169fa809edee79cca168d83a"},
] ]
[package.dependencies] [package.dependencies]
@ -398,51 +406,51 @@ dev = ["twine (>=3.4.1)"]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "7.4.3" version = "8.2.0"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
files = [ files = [
{file = "pytest-7.4.3-py3-none-any.whl", hash = "sha256:0d009c083ea859a71b76adf7c1d502e4bc170b80a8ef002da5806527b9591fac"}, {file = "pytest-8.2.0-py3-none-any.whl", hash = "sha256:1733f0620f6cda4095bbf0d9ff8022486e91892245bb9e7d5542c018f612f233"},
{file = "pytest-7.4.3.tar.gz", hash = "sha256:d989d136982de4e3b29dabcc838ad581c64e8ed52c11fbe86ddebd9da0818cd5"}, {file = "pytest-8.2.0.tar.gz", hash = "sha256:d507d4482197eac0ba2bae2e9babf0672eb333017bcedaa5fb1a3d42c1174b3f"},
] ]
[package.dependencies] [package.dependencies]
colorama = {version = "*", markers = "sys_platform == \"win32\""} colorama = {version = "*", markers = "sys_platform == \"win32\""}
iniconfig = "*" iniconfig = "*"
packaging = "*" packaging = "*"
pluggy = ">=0.12,<2.0" pluggy = ">=1.5,<2.0"
[package.extras] [package.extras]
testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]] [[package]]
name = "pytest-asyncio" name = "pytest-asyncio"
version = "0.21.1" version = "0.23.6"
description = "Pytest support for asyncio" description = "Pytest support for asyncio"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
files = [ files = [
{file = "pytest-asyncio-0.21.1.tar.gz", hash = "sha256:40a7eae6dded22c7b604986855ea48400ab15b069ae38116e8c01238e9eeb64d"}, {file = "pytest-asyncio-0.23.6.tar.gz", hash = "sha256:ffe523a89c1c222598c76856e76852b787504ddb72dd5d9b6617ffa8aa2cde5f"},
{file = "pytest_asyncio-0.21.1-py3-none-any.whl", hash = "sha256:8666c1c8ac02631d7c51ba282e0c69a8a452b211ffedf2599099845da5c5c37b"}, {file = "pytest_asyncio-0.23.6-py3-none-any.whl", hash = "sha256:68516fdd1018ac57b846c9846b954f0393b26f094764a28c955eabb0536a4e8a"},
] ]
[package.dependencies] [package.dependencies]
pytest = ">=7.0.0" pytest = ">=7.0.0,<9"
[package.extras] [package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"]
testing = ["coverage (>=6.2)", "flaky (>=3.5.0)", "hypothesis (>=5.7.1)", "mypy (>=0.931)", "pytest-trio (>=0.7.0)"] testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]] [[package]]
name = "pytest-cov" name = "pytest-cov"
version = "4.1.0" version = "5.0.0"
description = "Pytest plugin for measuring coverage." description = "Pytest plugin for measuring coverage."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
files = [ files = [
{file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"}, {file = "pytest-cov-5.0.0.tar.gz", hash = "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857"},
{file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"}, {file = "pytest_cov-5.0.0-py3-none-any.whl", hash = "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652"},
] ]
[package.dependencies] [package.dependencies]
@ -450,49 +458,49 @@ coverage = {version = ">=5.2.1", extras = ["toml"]}
pytest = ">=4.6" pytest = ">=4.6"
[package.extras] [package.extras]
testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"]
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.1.6" version = "0.4.3"
description = "An extremely fast Python linter and code formatter, written in Rust." description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:88b8cdf6abf98130991cbc9f6438f35f6e8d41a02622cc5ee130a02a0ed28703"}, {file = "ruff-0.4.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b70800c290f14ae6fcbb41bbe201cf62dfca024d124a1f373e76371a007454ce"},
{file = "ruff-0.1.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c549ed437680b6105a1299d2cd30e4964211606eeb48a0ff7a93ef70b902248"}, {file = "ruff-0.4.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:08a0d6a22918ab2552ace96adeaca308833873a4d7d1d587bb1d37bae8728eb3"},
{file = "ruff-0.1.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cf5f701062e294f2167e66d11b092bba7af6a057668ed618a9253e1e90cfd76"}, {file = "ruff-0.4.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba1f14df3c758dd7de5b55fbae7e1c8af238597961e5fb628f3de446c3c40c5"},
{file = "ruff-0.1.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:05991ee20d4ac4bb78385360c684e4b417edd971030ab12a4fbd075ff535050e"}, {file = "ruff-0.4.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:819fb06d535cc76dfddbfe8d3068ff602ddeb40e3eacbc90e0d1272bb8d97113"},
{file = "ruff-0.1.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87455a0c1f739b3c069e2f4c43b66479a54dea0276dd5d4d67b091265f6fd1dc"}, {file = "ruff-0.4.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0bfc9e955e6dc6359eb6f82ea150c4f4e82b660e5b58d9a20a0e42ec3bb6342b"},
{file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:683aa5bdda5a48cb8266fcde8eea2a6af4e5700a392c56ea5fb5f0d4bfdc0240"}, {file = "ruff-0.4.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:510a67d232d2ebe983fddea324dbf9d69b71c4d2dfeb8a862f4a127536dd4cfb"},
{file = "ruff-0.1.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:137852105586dcbf80c1717facb6781555c4e99f520c9c827bd414fac67ddfb6"}, {file = "ruff-0.4.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc9ff11cd9a092ee7680a56d21f302bdda14327772cd870d806610a3503d001f"},
{file = "ruff-0.1.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd98138a98d48a1c36c394fd6b84cd943ac92a08278aa8ac8c0fdefcf7138f35"}, {file = "ruff-0.4.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29efff25bf9ee685c2c8390563a5b5c006a3fee5230d28ea39f4f75f9d0b6f2f"},
{file = "ruff-0.1.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a0cd909d25f227ac5c36d4e7e681577275fb74ba3b11d288aff7ec47e3ae745"}, {file = "ruff-0.4.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18b00e0bcccf0fc8d7186ed21e311dffd19761cb632241a6e4fe4477cc80ef6e"},
{file = "ruff-0.1.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e8fd1c62a47aa88a02707b5dd20c5ff20d035d634aa74826b42a1da77861b5ff"}, {file = "ruff-0.4.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:262f5635e2c74d80b7507fbc2fac28fe0d4fef26373bbc62039526f7722bca1b"},
{file = "ruff-0.1.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fd89b45d374935829134a082617954120d7a1470a9f0ec0e7f3ead983edc48cc"}, {file = "ruff-0.4.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:7363691198719c26459e08cc17c6a3dac6f592e9ea3d2fa772f4e561b5fe82a3"},
{file = "ruff-0.1.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:491262006e92f825b145cd1e52948073c56560243b55fb3b4ecb142f6f0e9543"}, {file = "ruff-0.4.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:eeb039f8428fcb6725bb63cbae92ad67b0559e68b5d80f840f11914afd8ddf7f"},
{file = "ruff-0.1.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ea284789861b8b5ca9d5443591a92a397ac183d4351882ab52f6296b4fdd5462"}, {file = "ruff-0.4.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:927b11c1e4d0727ce1a729eace61cee88a334623ec424c0b1c8fe3e5f9d3c865"},
{file = "ruff-0.1.6-py3-none-win32.whl", hash = "sha256:1610e14750826dfc207ccbcdd7331b6bd285607d4181df9c1c6ae26646d6848a"}, {file = "ruff-0.4.3-py3-none-win32.whl", hash = "sha256:25cacda2155778beb0d064e0ec5a3944dcca9c12715f7c4634fd9d93ac33fd30"},
{file = "ruff-0.1.6-py3-none-win_amd64.whl", hash = "sha256:4558b3e178145491e9bc3b2ee3c4b42f19d19384eaa5c59d10acf6e8f8b57e33"}, {file = "ruff-0.4.3-py3-none-win_amd64.whl", hash = "sha256:7a1c3a450bc6539ef00da6c819fb1b76b6b065dec585f91456e7c0d6a0bbc725"},
{file = "ruff-0.1.6-py3-none-win_arm64.whl", hash = "sha256:03910e81df0d8db0e30050725a5802441c2022ea3ae4fe0609b76081731accbc"}, {file = "ruff-0.4.3-py3-none-win_arm64.whl", hash = "sha256:71ca5f8ccf1121b95a59649482470c5601c60a416bf189d553955b0338e34614"},
{file = "ruff-0.1.6.tar.gz", hash = "sha256:1b09f29b16c6ead5ea6b097ef2764b42372aebe363722f1605ecbcd2b9207184"}, {file = "ruff-0.4.3.tar.gz", hash = "sha256:ff0a3ef2e3c4b6d133fbedcf9586abfbe38d076041f2dc18ffb2c7e0485d5a07"},
] ]
[[package]] [[package]]
name = "setuptools" name = "setuptools"
version = "69.0.2" version = "69.5.1"
description = "Easily download, build, install, upgrade, and uninstall Python packages" description = "Easily download, build, install, upgrade, and uninstall Python packages"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "setuptools-69.0.2-py3-none-any.whl", hash = "sha256:1e8fdff6797d3865f37397be788a4e3cba233608e9b509382a2777d25ebde7f2"}, {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"},
{file = "setuptools-69.0.2.tar.gz", hash = "sha256:735896e78a4742605974de002ac60562d286fa8051a7e2299445e8e8fbb01aa6"}, {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"},
] ]
[package.extras] [package.extras]
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
[[package]] [[package]]
name = "six" name = "six"
@ -507,13 +515,13 @@ files = [
[[package]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.0" version = "1.3.1"
description = "Sniff out which async library your code is running under" description = "Sniff out which async library your code is running under"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "sniffio-1.3.0-py3-none-any.whl", hash = "sha256:eecefdce1e5bbfb7ad2eeaabf7c1eeb404d7757c379bd1f7e5cce9d8bf425384"}, {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
{file = "sniffio-1.3.0.tar.gz", hash = "sha256:e60305c5e5d314f5389259b7f22aaa33d8f7dee49763119234af3755c55b9101"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
] ]
[[package]] [[package]]
@ -529,71 +537,71 @@ files = [
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.23" version = "2.0.30"
description = "Database Abstraction Library" description = "Database Abstraction Library"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
{file = "SQLAlchemy-2.0.23-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:638c2c0b6b4661a4fd264f6fb804eccd392745c5887f9317feb64bb7cb03b3ea"}, {file = "SQLAlchemy-2.0.30-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3b48154678e76445c7ded1896715ce05319f74b1e73cf82d4f8b59b46e9c0ddc"},
{file = "SQLAlchemy-2.0.23-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e3b5036aa326dc2df50cba3c958e29b291a80f604b1afa4c8ce73e78e1c9f01d"}, {file = "SQLAlchemy-2.0.30-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2753743c2afd061bb95a61a51bbb6a1a11ac1c44292fad898f10c9839a7f75b2"},
{file = "SQLAlchemy-2.0.23-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:787af80107fb691934a01889ca8f82a44adedbf5ef3d6ad7d0f0b9ac557e0c34"}, {file = "SQLAlchemy-2.0.30-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7bfc726d167f425d4c16269a9a10fe8630ff6d14b683d588044dcef2d0f6be7"},
{file = "SQLAlchemy-2.0.23-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c14eba45983d2f48f7546bb32b47937ee2cafae353646295f0e99f35b14286ab"}, {file = "SQLAlchemy-2.0.30-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4f61ada6979223013d9ab83a3ed003ded6959eae37d0d685db2c147e9143797"},
{file = "SQLAlchemy-2.0.23-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0666031df46b9badba9bed00092a1ffa3aa063a5e68fa244acd9f08070e936d3"}, {file = "SQLAlchemy-2.0.30-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:3a365eda439b7a00732638f11072907c1bc8e351c7665e7e5da91b169af794af"},
{file = "SQLAlchemy-2.0.23-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:89a01238fcb9a8af118eaad3ffcc5dedaacbd429dc6fdc43fe430d3a941ff965"}, {file = "SQLAlchemy-2.0.30-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bba002a9447b291548e8d66fd8c96a6a7ed4f2def0bb155f4f0a1309fd2735d5"},
{file = "SQLAlchemy-2.0.23-cp310-cp310-win32.whl", hash = "sha256:cabafc7837b6cec61c0e1e5c6d14ef250b675fa9c3060ed8a7e38653bd732ff8"}, {file = "SQLAlchemy-2.0.30-cp310-cp310-win32.whl", hash = "sha256:0138c5c16be3600923fa2169532205d18891b28afa817cb49b50e08f62198bb8"},
{file = "SQLAlchemy-2.0.23-cp310-cp310-win_amd64.whl", hash = "sha256:87a3d6b53c39cd173990de2f5f4b83431d534a74f0e2f88bd16eabb5667e65c6"}, {file = "SQLAlchemy-2.0.30-cp310-cp310-win_amd64.whl", hash = "sha256:99650e9f4cf3ad0d409fed3eec4f071fadd032e9a5edc7270cd646a26446feeb"},
{file = "SQLAlchemy-2.0.23-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d5578e6863eeb998980c212a39106ea139bdc0b3f73291b96e27c929c90cd8e1"}, {file = "SQLAlchemy-2.0.30-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:955991a09f0992c68a499791a753523f50f71a6885531568404fa0f231832aa0"},
{file = "SQLAlchemy-2.0.23-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:62d9e964870ea5ade4bc870ac4004c456efe75fb50404c03c5fd61f8bc669a72"}, {file = "SQLAlchemy-2.0.30-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f69e4c756ee2686767eb80f94c0125c8b0a0b87ede03eacc5c8ae3b54b99dc46"},
{file = "SQLAlchemy-2.0.23-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c80c38bd2ea35b97cbf7c21aeb129dcbebbf344ee01a7141016ab7b851464f8e"}, {file = "SQLAlchemy-2.0.30-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69c9db1ce00e59e8dd09d7bae852a9add716efdc070a3e2068377e6ff0d6fdaa"},
{file = "SQLAlchemy-2.0.23-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75eefe09e98043cff2fb8af9796e20747ae870c903dc61d41b0c2e55128f958d"}, {file = "SQLAlchemy-2.0.30-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1429a4b0f709f19ff3b0cf13675b2b9bfa8a7e79990003207a011c0db880a13"},
{file = "SQLAlchemy-2.0.23-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bd45a5b6c68357578263d74daab6ff9439517f87da63442d244f9f23df56138d"}, {file = "SQLAlchemy-2.0.30-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:efedba7e13aa9a6c8407c48facfdfa108a5a4128e35f4c68f20c3407e4376aa9"},
{file = "SQLAlchemy-2.0.23-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a86cb7063e2c9fb8e774f77fbf8475516d270a3e989da55fa05d08089d77f8c4"}, {file = "SQLAlchemy-2.0.30-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:16863e2b132b761891d6c49f0a0f70030e0bcac4fd208117f6b7e053e68668d0"},
{file = "SQLAlchemy-2.0.23-cp311-cp311-win32.whl", hash = "sha256:b41f5d65b54cdf4934ecede2f41b9c60c9f785620416e8e6c48349ab18643855"}, {file = "SQLAlchemy-2.0.30-cp311-cp311-win32.whl", hash = "sha256:2ecabd9ccaa6e914e3dbb2aa46b76dede7eadc8cbf1b8083c94d936bcd5ffb49"},
{file = "SQLAlchemy-2.0.23-cp311-cp311-win_amd64.whl", hash = "sha256:9ca922f305d67605668e93991aaf2c12239c78207bca3b891cd51a4515c72e22"}, {file = "SQLAlchemy-2.0.30-cp311-cp311-win_amd64.whl", hash = "sha256:0b3f4c438e37d22b83e640f825ef0f37b95db9aa2d68203f2c9549375d0b2260"},
{file = "SQLAlchemy-2.0.23-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d0f7fb0c7527c41fa6fcae2be537ac137f636a41b4c5a4c58914541e2f436b45"}, {file = "SQLAlchemy-2.0.30-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5a79d65395ac5e6b0c2890935bad892eabb911c4aa8e8015067ddb37eea3d56c"},
{file = "SQLAlchemy-2.0.23-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7c424983ab447dab126c39d3ce3be5bee95700783204a72549c3dceffe0fc8f4"}, {file = "SQLAlchemy-2.0.30-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9a5baf9267b752390252889f0c802ea13b52dfee5e369527da229189b8bd592e"},
{file = "SQLAlchemy-2.0.23-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f508ba8f89e0a5ecdfd3761f82dda2a3d7b678a626967608f4273e0dba8f07ac"}, {file = "SQLAlchemy-2.0.30-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cb5a646930c5123f8461f6468901573f334c2c63c795b9af350063a736d0134"},
{file = "SQLAlchemy-2.0.23-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6463aa765cf02b9247e38b35853923edbf2f6fd1963df88706bc1d02410a5577"}, {file = "SQLAlchemy-2.0.30-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:296230899df0b77dec4eb799bcea6fbe39a43707ce7bb166519c97b583cfcab3"},
{file = "SQLAlchemy-2.0.23-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e599a51acf3cc4d31d1a0cf248d8f8d863b6386d2b6782c5074427ebb7803bda"}, {file = "SQLAlchemy-2.0.30-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c62d401223f468eb4da32627bffc0c78ed516b03bb8a34a58be54d618b74d472"},
{file = "SQLAlchemy-2.0.23-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fd54601ef9cc455a0c61e5245f690c8a3ad67ddb03d3b91c361d076def0b4c60"}, {file = "SQLAlchemy-2.0.30-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:3b69e934f0f2b677ec111b4d83f92dc1a3210a779f69bf905273192cf4ed433e"},
{file = "SQLAlchemy-2.0.23-cp312-cp312-win32.whl", hash = "sha256:42d0b0290a8fb0165ea2c2781ae66e95cca6e27a2fbe1016ff8db3112ac1e846"}, {file = "SQLAlchemy-2.0.30-cp312-cp312-win32.whl", hash = "sha256:77d2edb1f54aff37e3318f611637171e8ec71472f1fdc7348b41dcb226f93d90"},
{file = "SQLAlchemy-2.0.23-cp312-cp312-win_amd64.whl", hash = "sha256:227135ef1e48165f37590b8bfc44ed7ff4c074bf04dc8d6f8e7f1c14a94aa6ca"}, {file = "SQLAlchemy-2.0.30-cp312-cp312-win_amd64.whl", hash = "sha256:b6c7ec2b1f4969fc19b65b7059ed00497e25f54069407a8701091beb69e591a5"},
{file = "SQLAlchemy-2.0.23-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:14aebfe28b99f24f8a4c1346c48bc3d63705b1f919a24c27471136d2f219f02d"}, {file = "SQLAlchemy-2.0.30-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5a8e3b0a7e09e94be7510d1661339d6b52daf202ed2f5b1f9f48ea34ee6f2d57"},
{file = "SQLAlchemy-2.0.23-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e983fa42164577d073778d06d2cc5d020322425a509a08119bdcee70ad856bf"}, {file = "SQLAlchemy-2.0.30-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b60203c63e8f984df92035610c5fb76d941254cf5d19751faab7d33b21e5ddc0"},
{file = "SQLAlchemy-2.0.23-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e0dc9031baa46ad0dd5a269cb7a92a73284d1309228be1d5935dac8fb3cae24"}, {file = "SQLAlchemy-2.0.30-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f1dc3eabd8c0232ee8387fbe03e0a62220a6f089e278b1f0aaf5e2d6210741ad"},
{file = "SQLAlchemy-2.0.23-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:5f94aeb99f43729960638e7468d4688f6efccb837a858b34574e01143cf11f89"}, {file = "SQLAlchemy-2.0.30-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:40ad017c672c00b9b663fcfcd5f0864a0a97828e2ee7ab0c140dc84058d194cf"},
{file = "SQLAlchemy-2.0.23-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:63bfc3acc970776036f6d1d0e65faa7473be9f3135d37a463c5eba5efcdb24c8"}, {file = "SQLAlchemy-2.0.30-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e42203d8d20dc704604862977b1470a122e4892791fe3ed165f041e4bf447a1b"},
{file = "SQLAlchemy-2.0.23-cp37-cp37m-win32.whl", hash = "sha256:f48ed89dd11c3c586f45e9eec1e437b355b3b6f6884ea4a4c3111a3358fd0c18"}, {file = "SQLAlchemy-2.0.30-cp37-cp37m-win32.whl", hash = "sha256:2a4f4da89c74435f2bc61878cd08f3646b699e7d2eba97144030d1be44e27584"},
{file = "SQLAlchemy-2.0.23-cp37-cp37m-win_amd64.whl", hash = "sha256:1e018aba8363adb0599e745af245306cb8c46b9ad0a6fc0a86745b6ff7d940fc"}, {file = "SQLAlchemy-2.0.30-cp37-cp37m-win_amd64.whl", hash = "sha256:b6bf767d14b77f6a18b6982cbbf29d71bede087edae495d11ab358280f304d8e"},
{file = "SQLAlchemy-2.0.23-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:64ac935a90bc479fee77f9463f298943b0e60005fe5de2aa654d9cdef46c54df"}, {file = "SQLAlchemy-2.0.30-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:bc0c53579650a891f9b83fa3cecd4e00218e071d0ba00c4890f5be0c34887ed3"},
{file = "SQLAlchemy-2.0.23-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c4722f3bc3c1c2fcc3702dbe0016ba31148dd6efcd2a2fd33c1b4897c6a19693"}, {file = "SQLAlchemy-2.0.30-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:311710f9a2ee235f1403537b10c7687214bb1f2b9ebb52702c5aa4a77f0b3af7"},
{file = "SQLAlchemy-2.0.23-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af79c06825e2836de21439cb2a6ce22b2ca129bad74f359bddd173f39582bf5"}, {file = "SQLAlchemy-2.0.30-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:408f8b0e2c04677e9c93f40eef3ab22f550fecb3011b187f66a096395ff3d9fd"},
{file = "SQLAlchemy-2.0.23-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:683ef58ca8eea4747737a1c35c11372ffeb84578d3aab8f3e10b1d13d66f2bc4"}, {file = "SQLAlchemy-2.0.30-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37a4b4fb0dd4d2669070fb05b8b8824afd0af57587393015baee1cf9890242d9"},
{file = "SQLAlchemy-2.0.23-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:d4041ad05b35f1f4da481f6b811b4af2f29e83af253bf37c3c4582b2c68934ab"}, {file = "SQLAlchemy-2.0.30-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a943d297126c9230719c27fcbbeab57ecd5d15b0bd6bfd26e91bfcfe64220621"},
{file = "SQLAlchemy-2.0.23-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aeb397de65a0a62f14c257f36a726945a7f7bb60253462e8602d9b97b5cbe204"}, {file = "SQLAlchemy-2.0.30-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0a089e218654e740a41388893e090d2e2c22c29028c9d1353feb38638820bbeb"},
{file = "SQLAlchemy-2.0.23-cp38-cp38-win32.whl", hash = "sha256:42ede90148b73fe4ab4a089f3126b2cfae8cfefc955c8174d697bb46210c8306"}, {file = "SQLAlchemy-2.0.30-cp38-cp38-win32.whl", hash = "sha256:fa561138a64f949f3e889eb9ab8c58e1504ab351d6cf55259dc4c248eaa19da6"},
{file = "SQLAlchemy-2.0.23-cp38-cp38-win_amd64.whl", hash = "sha256:964971b52daab357d2c0875825e36584d58f536e920f2968df8d581054eada4b"}, {file = "SQLAlchemy-2.0.30-cp38-cp38-win_amd64.whl", hash = "sha256:7d74336c65705b986d12a7e337ba27ab2b9d819993851b140efdf029248e818e"},
{file = "SQLAlchemy-2.0.23-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:616fe7bcff0a05098f64b4478b78ec2dfa03225c23734d83d6c169eb41a93e55"}, {file = "SQLAlchemy-2.0.30-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ae8c62fe2480dd61c532ccafdbce9b29dacc126fe8be0d9a927ca3e699b9491a"},
{file = "SQLAlchemy-2.0.23-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0e680527245895aba86afbd5bef6c316831c02aa988d1aad83c47ffe92655e74"}, {file = "SQLAlchemy-2.0.30-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2383146973a15435e4717f94c7509982770e3e54974c71f76500a0136f22810b"},
{file = "SQLAlchemy-2.0.23-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9585b646ffb048c0250acc7dad92536591ffe35dba624bb8fd9b471e25212a35"}, {file = "SQLAlchemy-2.0.30-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8409de825f2c3b62ab15788635ccaec0c881c3f12a8af2b12ae4910a0a9aeef6"},
{file = "SQLAlchemy-2.0.23-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4895a63e2c271ffc7a81ea424b94060f7b3b03b4ea0cd58ab5bb676ed02f4221"}, {file = "SQLAlchemy-2.0.30-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0094c5dc698a5f78d3d1539853e8ecec02516b62b8223c970c86d44e7a80f6c7"},
{file = "SQLAlchemy-2.0.23-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cc1d21576f958c42d9aec68eba5c1a7d715e5fc07825a629015fe8e3b0657fb0"}, {file = "SQLAlchemy-2.0.30-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:edc16a50f5e1b7a06a2dcc1f2205b0b961074c123ed17ebda726f376a5ab0953"},
{file = "SQLAlchemy-2.0.23-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:967c0b71156f793e6662dd839da54f884631755275ed71f1539c95bbada9aaab"}, {file = "SQLAlchemy-2.0.30-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:f7703c2010355dd28f53deb644a05fc30f796bd8598b43f0ba678878780b6e4c"},
{file = "SQLAlchemy-2.0.23-cp39-cp39-win32.whl", hash = "sha256:0a8c6aa506893e25a04233bc721c6b6cf844bafd7250535abb56cb6cc1368884"}, {file = "SQLAlchemy-2.0.30-cp39-cp39-win32.whl", hash = "sha256:1f9a727312ff6ad5248a4367358e2cf7e625e98b1028b1d7ab7b806b7d757513"},
{file = "SQLAlchemy-2.0.23-cp39-cp39-win_amd64.whl", hash = "sha256:f3420d00d2cb42432c1d0e44540ae83185ccbbc67a6054dcc8ab5387add6620b"}, {file = "SQLAlchemy-2.0.30-cp39-cp39-win_amd64.whl", hash = "sha256:a0ef36b28534f2a5771191be6edb44cc2673c7b2edf6deac6562400288664221"},
{file = "SQLAlchemy-2.0.23-py3-none-any.whl", hash = "sha256:31952bbc527d633b9479f5f81e8b9dfada00b91d6baba021a869095f1a97006d"}, {file = "SQLAlchemy-2.0.30-py3-none-any.whl", hash = "sha256:7108d569d3990c71e26a42f60474b4c02c8586c4681af5fd67e51a044fdea86a"},
{file = "SQLAlchemy-2.0.23.tar.gz", hash = "sha256:c1bda93cbbe4aa2aa0aa8655c5aeda505cd219ff3e8da91d1d329e143e4aff69"}, {file = "SQLAlchemy-2.0.30.tar.gz", hash = "sha256:2b1708916730f4830bc69d6f49d37f7698b5bd7530aca7f04f785f8849e95255"},
] ]
[package.dependencies] [package.dependencies]
aiosqlite = {version = "*", optional = true, markers = "extra == \"aiosqlite\""} aiosqlite = {version = "*", optional = true, markers = "extra == \"aiosqlite\""}
greenlet = {version = "!=0.4.17", optional = true, markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\" or extra == \"aiosqlite\""} greenlet = {version = "!=0.4.17", optional = true, markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\" or extra == \"aiosqlite\""}
typing-extensions = {version = ">=4.2.0", optional = true, markers = "extra == \"aiosqlite\""} typing-extensions = {version = ">=4.6.0", optional = true, markers = "extra == \"aiosqlite\""}
[package.extras] [package.extras]
aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"] aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"]
aioodbc = ["aioodbc", "greenlet (!=0.4.17)"] aioodbc = ["aioodbc", "greenlet (!=0.4.17)"]
aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing-extensions (!=3.10.0.1)"] aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"]
asyncio = ["greenlet (!=0.4.17)"] asyncio = ["greenlet (!=0.4.17)"]
asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"]
mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"] mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"]
@ -603,7 +611,7 @@ mssql-pyodbc = ["pyodbc"]
mypy = ["mypy (>=0.910)"] mypy = ["mypy (>=0.910)"]
mysql = ["mysqlclient (>=1.4.0)"] mysql = ["mysqlclient (>=1.4.0)"]
mysql-connector = ["mysql-connector-python"] mysql-connector = ["mysql-connector-python"]
oracle = ["cx-oracle (>=8)"] oracle = ["cx_oracle (>=8)"]
oracle-oracledb = ["oracledb (>=1.0.1)"] oracle-oracledb = ["oracledb (>=1.0.1)"]
postgresql = ["psycopg2 (>=2.7)"] postgresql = ["psycopg2 (>=2.7)"]
postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"]
@ -613,34 +621,34 @@ postgresql-psycopg2binary = ["psycopg2-binary"]
postgresql-psycopg2cffi = ["psycopg2cffi"] postgresql-psycopg2cffi = ["psycopg2cffi"]
postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
pymysql = ["pymysql"] pymysql = ["pymysql"]
sqlcipher = ["sqlcipher3-binary"] sqlcipher = ["sqlcipher3_binary"]
[[package]] [[package]]
name = "starlette" name = "starlette"
version = "0.30.0" version = "0.37.2"
description = "The little ASGI library that shines." description = "The little ASGI library that shines."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "starlette-0.30.0-py3-none-any.whl", hash = "sha256:cb15a5dfbd8de70c999bd1ae4b7e1ba625d74520bc57b28cc4086c7969431f2d"}, {file = "starlette-0.37.2-py3-none-any.whl", hash = "sha256:6fe59f29268538e5d0d182f2791a479a0c64638e6935d1c6989e63fb2699c6ee"},
{file = "starlette-0.30.0.tar.gz", hash = "sha256:9cf6bd5f2fbc091c2f22701f9b7f7dfcbd304a567845cffbf89d706543fd2a03"}, {file = "starlette-0.37.2.tar.gz", hash = "sha256:9af890290133b79fc3db55474ade20f6220a364a0402e0b556e7cd5e1e093823"},
] ]
[package.dependencies] [package.dependencies]
anyio = ">=3.4.0,<5" anyio = ">=3.4.0,<5"
[package.extras] [package.extras]
full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"] full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.8.0" version = "4.11.0"
description = "Backported and Experimental Type Hints for Python 3.8+" description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"},
{file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"},
] ]
[[package]] [[package]]
@ -656,13 +664,13 @@ files = [
[[package]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.23.2" version = "0.29.0"
description = "The lightning-fast ASGI server." description = "The lightning-fast ASGI server."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
files = [ files = [
{file = "uvicorn-0.23.2-py3-none-any.whl", hash = "sha256:1f9be6558f01239d4fdf22ef8126c39cb1ad0addf76c40e760549d2c2f43ab53"}, {file = "uvicorn-0.29.0-py3-none-any.whl", hash = "sha256:2c2aac7ff4f4365c206fd773a39bf4ebd1047c238f8b8268ad996829323473de"},
{file = "uvicorn-0.23.2.tar.gz", hash = "sha256:4d3cc12d7727ba72b64d12d3cc7743124074c0a69f7b201512fc50c3e3f1569a"}, {file = "uvicorn-0.29.0.tar.gz", hash = "sha256:6a69214c0b6a087462412670b3ef21224fa48cae0e452b5883e8e8bdfdd11dd0"},
] ]
[package.dependencies] [package.dependencies]
@ -686,4 +694,4 @@ files = [
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.12" python-versions = "^3.12"
content-hash = "ba28f3acc8701a53b35b1c8ea15169e151c74c277bad095f52e19e3f65be9ed7" content-hash = "038fed338d6b75c17eb8eb88d36c2411ff936dab23887b70594e5ba1da518451"

View file

@ -1,3 +1,7 @@
[project]
name = "unwind"
requires-python = ">=3.12"
[tool.poetry] [tool.poetry]
name = "unwind" name = "unwind"
version = "0" version = "0"
@ -9,10 +13,10 @@ license = "LOL"
python = "^3.12" python = "^3.12"
beautifulsoup4 = "^4.9.3" beautifulsoup4 = "^4.9.3"
html5lib = "^1.1" html5lib = "^1.1"
starlette = "^0.30" starlette = "^0.37.2"
ulid-py = "^1.1.0" ulid-py = "^1.1.0"
uvicorn = "^0.23" uvicorn = "^0.29.0"
httpx = "^0.24" httpx = "^0.27.0"
sqlalchemy = {version = "^2.0", extras = ["aiosqlite"]} sqlalchemy = {version = "^2.0", extras = ["aiosqlite"]}
[tool.poetry.group.build.dependencies] [tool.poetry.group.build.dependencies]
@ -40,7 +44,22 @@ build-backend = "poetry.core.masonry.api"
[tool.pyright] [tool.pyright]
pythonVersion = "3.12" pythonVersion = "3.12"
[tool.ruff] [tool.ruff.lint]
target-version = "py312" select = [
ignore-init-module-imports = true # See https://docs.astral.sh/ruff/rules/ for a list of all rules.
select = ["I", "F401", "F601", "F602", "F841"] "B", # flake8-bugbear
"C4", # flake8-comprehensions
"DTZ", # flake8-datetimez
"E", # pycodestyle Error
"F", # Pyflakes unused-import
"I", # isort
"RUF", # Ruff-specific rules
"S", # flake8-bandit
"T20", # flake8-print
"W", # pycodestyle Warning
]
ignore = [
"B008", # flake8-bugbear function-call-in-default-argument
"E501", # pycodestyle line-too-long
"S101", # flake8-bandit assert
]

View file

@ -4,4 +4,4 @@ cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x [ -z "${DEBUG:-}" ] || set -x
exec python -m unwind "$@" exec poetry run python -m unwind "$@"

View file

@ -6,20 +6,41 @@ cd "$RUN_DIR"
builddir=build builddir=build
_latest_python_version() {
filter="$1"
filter_regex=$(echo "$filter" | sed 's/\./\\\\./')
curl -sfL https://api.github.com/repos/python/cpython/git/refs/tags \
| jq -r 'map(select(.ref | test("^refs/tags/v?'"$filter_regex"'($|\\.)")) | .ref | match("refs/tags/v?(.*)").captures[0].string)[]' \
| grep -xE '[.0-9]+' \
| sort --version-sort \
| tail -n 1
}
[ -z "${DEBUG:-}" ] || set -x [ -z "${DEBUG:-}" ] || set -x
mkdir -p "$builddir" mkdir -p "$builddir"
python_version_filter=$(cat .python-version)
python_version=$(_latest_python_version "$python_version_filter")
if [ -z "$python_version" ]; then
echo >&2 "No Python version found matching the filter: $python_version_filter"
exit 1
fi
githash_short=$(git rev-parse --short HEAD)
githash_long=$(git rev-parse HEAD)
version="$githash_short"
echo "$version" >"$builddir"/version
poetry export \ poetry export \
--with=build \ --with=build \
--output="$builddir"/requirements.txt --output="$builddir"/requirements.txt
githash=$(git rev-parse --short HEAD)
today=$(date -u '+%Y.%m.%d')
version="${today}+${githash}"
echo "$version" >"$builddir"/version
$DOCKER_BIN build \ $DOCKER_BIN build \
--pull \ --pull \
--tag "code.dumpr.org/ducklet/unwind":"$version" \ --build-arg="PYTHON_VERSION=$python_version" \
--label="python.version=$python_version" \
--label="git.hash=$githash_long" \
--tag="code.dumpr.org/ducklet/unwind":"$version" \
. .

View file

@ -1,6 +1,7 @@
#!/bin/sh -eu #!/bin/sh -eu
: "${DOCKER_BIN:=docker}" : "${DOCKER_BIN:=docker}"
: "${UNWIND_PORT:=8097}"
cd "$RUN_DIR" cd "$RUN_DIR"
@ -8,11 +9,18 @@ cd "$RUN_DIR"
version=$(cat build/version) version=$(cat build/version)
localhost=127.0.0.1
localport=8000
netloc="$localhost:$localport"
echo >&2 "Unwind will be available on http://$netloc/
"
$DOCKER_BIN run \ $DOCKER_BIN run \
--init \ --init \
-it --rm \ -it --rm \
--read-only \ --read-only \
--memory '500m' \ --memory '500m' \
--publish 127.0.0.1:8000:8000 \ --publish "$netloc":"$UNWIND_PORT" \
--volume "$RUN_DIR"/data:/data \ --volume "$RUN_DIR"/data:/data \
"code.dumpr.org/ducklet/unwind":"$version" "code.dumpr.org/ducklet/unwind":"$version"

10
scripts/install Executable file
View file

@ -0,0 +1,10 @@
#!/bin/sh -eu
cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x
poetry install --with=dev --sync
cd unwind-ui
npm ci

View file

@ -18,4 +18,4 @@ fi
cd unwind-ui cd unwind-ui
npm run lint ||: npm run lint ||:
npx prettier $prettier_opts 'vite.config.ts' 'src/**/*.{js,ts,vue}' npx --no -- prettier $prettier_opts 'vite.config.ts' 'src/**/*.{js,ts,vue}'

View file

@ -4,7 +4,7 @@ cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x [ -z "${DEBUG:-}" ] || set -x
ruff check --fix . ||: poetry run ruff check --fix . ||:
ruff format . poetry run ruff format .
pyright poetry run pyright

13
scripts/outdated Executable file
View file

@ -0,0 +1,13 @@
#!/bin/sh -eu
cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x
echo '# Poetry:'
poetry show --outdated --top-level --with=build,dev
echo '
# Npm:'
cd unwind-ui
npm outdated

View file

@ -10,6 +10,5 @@ trap 'rm "$dbfile" "${dbfile}-shm" "${dbfile}-wal"' EXIT TERM INT QUIT
[ -z "${DEBUG:-}" ] || set -x [ -z "${DEBUG:-}" ] || set -x
export SQLALCHEMY_WARN_20=1 # XXX remove when we switched to SQLAlchemy 2.0
UNWIND_STORAGE="$dbfile" \ UNWIND_STORAGE="$dbfile" \
python -m pytest --cov "$@" exec poetry run pytest --cov "$@"

47
scripts/update Executable file
View file

@ -0,0 +1,47 @@
#!/bin/sh -eu
echo '
WARNING:
This script aggressively updates all packages to the latest version.
Be sure to run tests & check the application after this!
'
cd "$RUN_DIR"
[ -z "${DEBUG:-}" ] || set -x
# Poetry
poetry update --with=build,dev
poetry show --outdated --top-level \
| cut -d ' ' -f 1 \
| while read -r pkg; do
poetry add "$pkg@latest"
done
poetry show --outdated --top-level --only=build \
| cut -d ' ' -f 1 \
| while read -r pkg; do
poetry add --group=build "$pkg@latest"
done
poetry show --outdated --top-level --only=dev \
| cut -d ' ' -f 1 \
| while read -r pkg; do
poetry add --group=dev "$pkg@latest"
done
# Npm
cd unwind-ui
npm update
npm install $(npm outdated --json --silent | jq -r 'keys|map("\(.)@latest")|@sh')
npm outdated --json --silent \
| jq -r 'keys|map(@sh"\(.)@latest")|join("\n")' \
| xargs npm install

BIN
tests/fixtures/bottom_100.html.bz2 vendored Normal file

Binary file not shown.

BIN
tests/fixtures/most_popular_100.html.bz2 vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
tests/fixtures/ratings-ur655321.html.bz2 vendored Normal file

Binary file not shown.

BIN
tests/fixtures/top250.gql.json.bz2 vendored Normal file

Binary file not shown.

View file

@ -1,4 +1,4 @@
from datetime import datetime from datetime import UTC, datetime
import pytest import pytest
@ -141,21 +141,21 @@ async def test_remove(conn: db.Connection):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_find_ratings(conn: db.Connection): async def test_find_ratings(conn: db.Connection):
m1 = a_movie( m1 = a_movie(
title="test movie", title="a test movie",
release_year=2013, release_year=2013,
genres={"genre-1"}, genres={"genre-1"},
) )
await db.add(conn, m1) await db.add(conn, m1)
m2 = a_movie( m2 = a_movie(
title="it's anöther Movie, Part 2", title="b it's anöther Movie, Part 2",
release_year=2015, release_year=2015,
genres={"genre-2"}, genres={"genre-2"},
) )
await db.add(conn, m2) await db.add(conn, m2)
m3 = a_movie( m3 = a_movie(
title="movie it's, Part 3", title="c movie it's, Part 3",
release_year=m2.release_year, release_year=m2.release_year,
genres=m2.genres, genres=m2.genres,
) )
@ -164,14 +164,14 @@ async def test_find_ratings(conn: db.Connection):
u1 = models.User( u1 = models.User(
imdb_id="u00001", imdb_id="u00001",
name="User1", name="User1",
secret="secret1", secret="secret1", # noqa: S106
) )
await db.add(conn, u1) await db.add(conn, u1)
u2 = models.User( u2 = models.User(
imdb_id="u00002", imdb_id="u00002",
name="User2", name="User2",
secret="secret2", secret="secret2", # noqa: S106
) )
await db.add(conn, u2) await db.add(conn, u2)
@ -181,7 +181,7 @@ async def test_find_ratings(conn: db.Connection):
user_id=u1.id, user_id=u1.id,
user=u1, user=u1,
score=66, score=66,
rating_date=datetime.now(), rating_date=datetime.now(tz=UTC),
) )
await db.add(conn, r1) await db.add(conn, r1)
@ -191,7 +191,7 @@ async def test_find_ratings(conn: db.Connection):
user_id=u2.id, user_id=u2.id,
user=u2, user=u2,
score=77, score=77,
rating_date=datetime.now(), rating_date=datetime.now(tz=UTC),
) )
await db.add(conn, r2) await db.add(conn, r2)
@ -224,35 +224,35 @@ async def test_find_ratings(conn: db.Connection):
ratings = tuple(web_models.Rating(**r) for r in rows) ratings = tuple(web_models.Rating(**r) for r in rows)
assert ( assert (
web_models.Rating.from_movie(m1), web_models.Rating.from_movie(m1),
web_models.Rating.from_movie(m3),
web_models.Rating.from_movie(m2, rating=r1), web_models.Rating.from_movie(m2, rating=r1),
web_models.Rating.from_movie(m2, rating=r2), web_models.Rating.from_movie(m2, rating=r2),
web_models.Rating.from_movie(m3),
) == ratings ) == ratings
aggr = web_models.aggregate_ratings(ratings, user_ids=[]) aggr = web_models.aggregate_ratings(ratings, user_ids=[])
assert tuple( assert tuple(
web_models.RatingAggregate.from_movie(m) for m in [m1, m2, m3] web_models.RatingAggregate.from_movie(m) for m in [m1, m3, m2]
) == tuple(aggr) ) == tuple(aggr)
aggr = web_models.aggregate_ratings(ratings, user_ids=[str(u1.id)]) aggr = web_models.aggregate_ratings(ratings, user_ids=[str(u1.id)])
assert ( assert (
web_models.RatingAggregate.from_movie(m1), web_models.RatingAggregate.from_movie(m1),
web_models.RatingAggregate.from_movie(m2, ratings=[r1]),
web_models.RatingAggregate.from_movie(m3), web_models.RatingAggregate.from_movie(m3),
web_models.RatingAggregate.from_movie(m2, ratings=[r1]),
) == tuple(aggr) ) == tuple(aggr)
aggr = web_models.aggregate_ratings(ratings, user_ids=[str(u1.id), str(u2.id)]) aggr = web_models.aggregate_ratings(ratings, user_ids=[str(u1.id), str(u2.id)])
assert ( assert (
web_models.RatingAggregate.from_movie(m1), web_models.RatingAggregate.from_movie(m1),
web_models.RatingAggregate.from_movie(m2, ratings=[r1, r2]),
web_models.RatingAggregate.from_movie(m3), web_models.RatingAggregate.from_movie(m3),
web_models.RatingAggregate.from_movie(m2, ratings=[r1, r2]),
) == tuple(aggr) ) == tuple(aggr)
rows = await db.find_ratings(conn, title="movie", include_unrated=True) rows = await db.find_ratings(conn, title="movie", include_unrated=True)
ratings = (web_models.Rating(**r) for r in rows) ratings = (web_models.Rating(**r) for r in rows)
aggr = web_models.aggregate_ratings(ratings, user_ids=[]) aggr = web_models.aggregate_ratings(ratings, user_ids=[])
assert tuple( assert tuple(
web_models.RatingAggregate.from_movie(m) for m in [m1, m2, m3] web_models.RatingAggregate.from_movie(m) for m in [m1, m3, m2]
) == tuple(aggr) ) == tuple(aggr)
rows = await db.find_ratings(conn, title="test", include_unrated=True) rows = await db.find_ratings(conn, title="test", include_unrated=True)
@ -271,14 +271,14 @@ async def test_ratings_for_movies(conn: db.Connection):
u1 = models.User( u1 = models.User(
imdb_id="u00001", imdb_id="u00001",
name="User1", name="User1",
secret="secret1", secret="secret1", # noqa: S106
) )
await db.add(conn, u1) await db.add(conn, u1)
u2 = models.User( u2 = models.User(
imdb_id="u00002", imdb_id="u00002",
name="User2", name="User2",
secret="secret2", secret="secret2", # noqa: S106
) )
await db.add(conn, u2) await db.add(conn, u2)
@ -288,7 +288,7 @@ async def test_ratings_for_movies(conn: db.Connection):
user_id=u1.id, user_id=u1.id,
user=u1, user=u1,
score=66, score=66,
rating_date=datetime.now(), rating_date=datetime.now(tz=UTC),
) )
await db.add(conn, r1) await db.add(conn, r1)
@ -296,7 +296,7 @@ async def test_ratings_for_movies(conn: db.Connection):
movie_ids = [m1.id] movie_ids = [m1.id]
user_ids = [] user_ids = []
assert tuple() == tuple( assert () == tuple(
await db.ratings_for_movies(conn, movie_ids=movie_ids, user_ids=user_ids) await db.ratings_for_movies(conn, movie_ids=movie_ids, user_ids=user_ids)
) )
@ -308,7 +308,7 @@ async def test_ratings_for_movies(conn: db.Connection):
movie_ids = [m2.id] movie_ids = [m2.id]
user_ids = [u2.id] user_ids = [u2.id]
assert tuple() == tuple( assert () == tuple(
await db.ratings_for_movies(conn, movie_ids=movie_ids, user_ids=user_ids) await db.ratings_for_movies(conn, movie_ids=movie_ids, user_ids=user_ids)
) )
@ -336,14 +336,14 @@ async def test_find_movies(conn: db.Connection):
u1 = models.User( u1 = models.User(
imdb_id="u00001", imdb_id="u00001",
name="User1", name="User1",
secret="secret1", secret="secret1", # noqa: S106
) )
await db.add(conn, u1) await db.add(conn, u1)
u2 = models.User( u2 = models.User(
imdb_id="u00002", imdb_id="u00002",
name="User2", name="User2",
secret="secret2", secret="secret2", # noqa: S106
) )
await db.add(conn, u2) await db.add(conn, u2)
@ -353,7 +353,7 @@ async def test_find_movies(conn: db.Connection):
user_id=u1.id, user_id=u1.id,
user=u1, user=u1,
score=66, score=66,
rating_date=datetime.now(), rating_date=datetime.now(tz=UTC),
) )
await db.add(conn, r1) await db.add(conn, r1)

View file

@ -1,7 +1,17 @@
import bz2
import json
from pathlib import Path
from unittest.mock import AsyncMock
import bs4
import pytest import pytest
from unwind import imdb
from unwind.imdb import imdb_rating_from_score, score_from_imdb_rating from unwind.imdb import imdb_rating_from_score, score_from_imdb_rating
testsdir = Path(__file__).parent
fixturesdir = testsdir / "fixtures"
@pytest.mark.parametrize("rating", (x / 10 for x in range(10, 101))) @pytest.mark.parametrize("rating", (x / 10 for x in range(10, 101)))
def test_rating_conversion(rating: float): def test_rating_conversion(rating: float):
@ -18,3 +28,150 @@ def test_score_conversion(score: int):
pytest.skip(f"Score cannot be mapped back correctly: {score}") pytest.skip(f"Score cannot be mapped back correctly: {score}")
assert score == score_from_imdb_rating(imdb_rating_from_score(score)) assert score == score_from_imdb_rating(imdb_rating_from_score(score))
@pytest.mark.asyncio
async def test_load_most_popular_100(monkeypatch):
with bz2.open(fixturesdir / "most_popular_100.html.bz2", "rb") as f:
html = f.read()
soup = bs4.BeautifulSoup(html, "html5lib")
monkeypatch.setattr(imdb, "asoup_from_url", AsyncMock(return_value=soup))
movie_ids = await imdb.load_most_popular_100()
assert len(movie_ids) == 100
assert all(id_.startswith("tt") for id_ in movie_ids)
@pytest.mark.asyncio
async def test_load_bottom_100(monkeypatch):
with bz2.open(fixturesdir / "bottom_100.html.bz2", "rb") as f:
html = f.read()
soup = bs4.BeautifulSoup(html, "html5lib")
monkeypatch.setattr(imdb, "asoup_from_url", AsyncMock(return_value=soup))
movie_ids = await imdb.load_bottom_100()
assert len(movie_ids) == 100
assert all(id_.startswith("tt") for id_ in movie_ids)
@pytest.mark.asyncio
async def test_load_top_250(monkeypatch):
with bz2.open(fixturesdir / "top250.gql.json.bz2", "rb") as f:
jsonstr = f.read()
monkeypatch.setattr(imdb, "adownload", AsyncMock(return_value=jsonstr))
movie_ids = await imdb.load_top_250()
assert len(movie_ids) == 250
assert all(id_.startswith("tt") for id_ in movie_ids)
@pytest.mark.asyncio
async def test_load_ratings_page(monkeypatch):
with bz2.open(fixturesdir / "ratings-ur655321.html.bz2", "rb") as f:
html = f.read()
soup = bs4.BeautifulSoup(html, "html5lib")
monkeypatch.setattr(imdb, "asoup_from_url", AsyncMock(return_value=soup))
page = await imdb._load_ratings_page("fakeurl", "ur655321")
assert len(page.ratings) == 100
assert page.imdb_user_id is not None
assert page.imdb_user_id == "ur655321"
assert page.imdb_user_name == "AlexUltra"
assert page.next_page_url is not None
assert page.next_page_url.startswith("/user/ur655321/ratings?")
def _mock_response(content: bytes):
class MockResponse:
def raise_for_status(self):
pass
def json(self):
return json.loads(content)
return MockResponse()
@pytest.mark.asyncio
async def test_load_ratings_page_20240510(monkeypatch):
with bz2.open(fixturesdir / "ratings-ur655321-20240510.html.bz2", "rb") as f:
html = f.read()
soup = bs4.BeautifulSoup(html, "html5lib")
monkeypatch.setattr(imdb, "asoup_from_url", AsyncMock(return_value=soup))
with bz2.open(fixturesdir / "ratings-ur655321-20240510.gql.json.bz2", "rb") as f:
jsonstr = f.read()
async with imdb.asession() as s:
monkeypatch.setattr(s, "post", AsyncMock(return_value=_mock_response(jsonstr)))
page = await imdb._load_ratings_page("fakeurl", "ur655321")
assert len(page.ratings) == 100
assert page.imdb_user_id is not None
assert page.imdb_user_id == "ur655321"
assert page.imdb_user_name == "AlexUltra"
assert page.next_page_url is None, "not supported for new ratings page"
def movie(item: dict):
for rating in page.ratings:
assert rating.movie
if rating.movie.imdb_id == item["imdb_id"]:
rating_dict = {key: getattr(rating.movie, key) for key in item.keys()}
return rating_dict
raise AssertionError()
a_movie = {
"title": "Kung Fu Panda 4",
"release_year": 2024,
"media_type": "Movie",
"imdb_id": "tt21692408",
"imdb_score": 59,
"imdb_votes": 36000,
"runtime": 94,
}
assert a_movie == movie(a_movie)
a_running_tvseries = {
"title": "Palm Royale",
"release_year": 2024,
"media_type": "TV Series",
"imdb_id": "tt8888540",
"imdb_score": 64,
"imdb_votes": 6000,
}
assert a_running_tvseries == movie(a_running_tvseries)
a_finished_tvseries = {
"title": "Fawlty Towers",
"release_year": 1975,
"media_type": "TV Series",
"imdb_id": "tt0072500",
"imdb_score": 87,
"imdb_votes": 100000,
}
assert a_finished_tvseries == movie(a_finished_tvseries)
a_tvepisode = {
"title": "Columbo / No Time to Die",
"original_title": None,
"release_year": 1992,
"media_type": "TV Episode",
"imdb_id": "tt0103987",
"imdb_score": 59,
"imdb_votes": 2100,
"runtime": 98,
}
assert a_tvepisode == movie(a_tvepisode)
a_videogame = {
"title": "Alan Wake",
"original_title": None,
"release_year": 2010,
"media_type": "Video Game",
"imdb_id": "tt0466662",
"imdb_score": 82,
"imdb_votes": 7300,
}
assert a_videogame == movie(a_videogame)

View file

@ -1,4 +1,4 @@
from datetime import datetime from datetime import UTC, datetime
import pytest import pytest
from starlette.testclient import TestClient from starlette.testclient import TestClient
@ -24,7 +24,7 @@ def authorized_client() -> TestClient:
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def admin_client() -> TestClient: def admin_client() -> TestClient:
client = TestClient(app) client = TestClient(app)
for token in config.api_credentials.values(): for token in config.api_credentials.values(): # noqa: B007
break break
else: else:
raise RuntimeError("No bearer tokens configured.") raise RuntimeError("No bearer tokens configured.")
@ -39,7 +39,7 @@ async def test_get_ratings_for_group(
user = models.User( user = models.User(
imdb_id="ur12345678", imdb_id="ur12345678",
name="user-1", name="user-1",
secret="secret-1", secret="secret-1", # noqa: S106
groups=[], groups=[],
) )
group = models.Group( group = models.Group(
@ -69,7 +69,7 @@ async def test_get_ratings_for_group(
await db.add(conn, movie) await db.add(conn, movie)
rating = models.Rating( rating = models.Rating(
movie_id=movie.id, user_id=user.id, score=66, rating_date=datetime.now() movie_id=movie.id, user_id=user.id, score=66, rating_date=datetime.now(tz=UTC)
) )
await db.add(conn, rating) await db.add(conn, rating)
@ -190,7 +190,7 @@ async def test_list_users(
m = models.User( m = models.User(
imdb_id="ur12345678", imdb_id="ur12345678",
name="user-1", name="user-1",
secret="secret-1", secret="secret-1", # noqa: S106
groups=[], groups=[],
) )
await db.add(conn, m) await db.add(conn, m)

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
{ {
"type": "module",
"version": "0.0.0", "version": "0.0.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@ -7,15 +8,16 @@
"serve": "vite preview" "serve": "vite preview"
}, },
"dependencies": { "dependencies": {
"bulma": "^0.9.3", "bulma": "^1.0.0",
"vue": "^3.0.5" "vue": "^3.0.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^1.2.5", "@vitejs/plugin-vue": "^5.0.4",
"@vue/compiler-sfc": "^3.0.5", "@vue/compiler-sfc": "^3.0.5",
"typescript": "^4.3.2", "prettier": "^3.2.5",
"vite": "^2.4.2", "typescript": "^5.4.5",
"vue-tsc": "^0.0.24" "vite": "^5.2.11",
"vue-tsc": "^2.0.16"
}, },
"prettier": { "prettier": {
"arrowParens": "always", "arrowParens": "always",

View file

@ -1 +1 @@
from .web import create_app from .web import create_app as create_app

View file

@ -1,9 +1,11 @@
import argparse import argparse
import asyncio import asyncio
import logging import logging
import secrets
from base64 import b64encode
from pathlib import Path from pathlib import Path
from . import config from . import config, db, models, utils
from .db import close_connection_pool, open_connection_pool from .db import close_connection_pool, open_connection_pool
from .imdb import refresh_user_ratings_from_imdb from .imdb import refresh_user_ratings_from_imdb
from .imdb_import import download_datasets, import_from_file from .imdb_import import download_datasets, import_from_file
@ -11,6 +13,38 @@ from .imdb_import import download_datasets, import_from_file
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
async def run_add_user(user_id: str, name: str, overwrite_existing: bool):
if not user_id.startswith("ur"):
raise ValueError(f"Invalid IMDb user ID: {user_id!a}")
await open_connection_pool()
async with db.new_connection() as conn:
user = await db.get(conn, models.User, imdb_id=user_id)
if user is not None:
if overwrite_existing:
log.warning("⚠️ Overwriting existing user: %a", user)
else:
log.error("❌ User already exists: %a", user)
return
secret = secrets.token_bytes()
user = models.User(name=name, imdb_id=user_id, secret=utils.phc_scrypt(secret))
async with db.transaction() as conn:
await db.add_or_update_user(conn, user)
user_data = {
"secret": b64encode(secret),
"user": models.asplain(user),
}
log.info("✨ User created: %a", user_data)
await close_connection_pool()
async def run_load_user_ratings_from_imdb(): async def run_load_user_ratings_from_imdb():
await open_connection_pool() await open_connection_pool()
@ -91,6 +125,26 @@ def getargs():
const="load-user-ratings-from-imdb", const="load-user-ratings-from-imdb",
) )
parser_add_user = commands.add_parser(
"add-user",
help="Add a new user.",
description="""
Add a new user.
""",
)
parser_add_user.add_argument(
dest="mode",
action="store_const",
const="add-user",
)
parser_add_user.add_argument("--name", required=True)
parser_add_user.add_argument("--imdb-id", required=True)
parser_add_user.add_argument(
"--overwrite-existing",
action="store_true",
help="Allow overwriting an existing user. WARNING: This will reset the user's password!",
)
try: try:
args = parser.parse_args() args = parser.parse_args()
except TypeError: except TypeError:
@ -110,11 +164,13 @@ def main():
try: try:
args = getargs() args = getargs()
except: except Exception:
return return
if args.mode == "load-user-ratings-from-imdb": if args.mode == "load-user-ratings-from-imdb":
asyncio.run(run_load_user_ratings_from_imdb()) asyncio.run(run_load_user_ratings_from_imdb())
elif args.mode == "add-user":
asyncio.run(run_add_user(args.imdb_id, args.name, args.overwrite_existing))
elif args.mode == "import-imdb-dataset": elif args.mode == "import-imdb-dataset":
asyncio.run(run_import_imdb_dataset(args.basics, args.ratings)) asyncio.run(run_import_imdb_dataset(args.basics, args.ratings))
elif args.mode == "download-imdb-dataset": elif args.mode == "download-imdb-dataset":

View file

@ -582,6 +582,10 @@ async def ratings_for_movie_ids(
) )
.outerjoin_from(movies, ratings, movies.c.id == ratings.c.movie_id) .outerjoin_from(movies, ratings, movies.c.id == ratings.c.movie_id)
.where(sa.or_(*conds)) .where(sa.or_(*conds))
.order_by(
ratings.c.rating_date.asc(),
movies.c.title.asc(),
)
) )
rows = await fetch_all(conn, query) rows = await fetch_all(conn, query)
return tuple(dict(r._mapping) for r in rows) return tuple(dict(r._mapping) for r in rows)
@ -614,7 +618,7 @@ async def find_movies(
limit_rows: int = 10, limit_rows: int = 10,
skip_rows: int = 0, skip_rows: int = 0,
include_unrated: bool = False, include_unrated: bool = False,
user_ids: list[ULID] = [], user_ids: list[ULID] | None = None,
) -> Iterable[tuple[Movie, list[Rating]]]: ) -> Iterable[tuple[Movie, list[Rating]]]:
conditions = [] conditions = []

View file

@ -1,17 +1,25 @@
import json
import logging import logging
import re import re
from collections import namedtuple from collections import namedtuple
from dataclasses import dataclass, field
from datetime import datetime from datetime import datetime
from typing import AsyncIterable, NewType
from urllib.parse import urljoin from urllib.parse import urljoin
import bs4 import bs4
from . import db from . import db
from .models import Movie, Rating, User from .models import Movie, Rating, User
from .request import asession, asoup_from_url, cache_path from .request import adownload, asession, asoup_from_url, cache_path
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
ImdbRating = NewType("ImdbRating", float) # Value range: [1.0, 10.0]
UnwindScore = NewType("UnwindScore", int) # Value range: [0, 100]
MovieId = NewType("MovieId", str) # Pattern: ttXXXXXXXX
UserId = NewType("UserId", str) # Pattern: urXXXXXXXX
# div#ratings-container # div#ratings-container
# div.lister-item.mode-detail # div.lister-item.mode-detail
# div.lister-item-content # div.lister-item-content
@ -46,7 +54,7 @@ async def refresh_user_ratings_from_imdb(stop_on_dupe: bool = True):
log.info("⚡️ Loading data for %s ...", user.name) log.info("⚡️ Loading data for %s ...", user.name)
try: try:
async for rating, is_updated in load_ratings(user.imdb_id): async for rating, is_updated in load_and_store_ratings(user.imdb_id):
assert rating.user is not None and rating.user.id == user.id assert rating.user is not None and rating.user.id == user.id
if stop_on_dupe and not is_updated: if stop_on_dupe and not is_updated:
@ -66,15 +74,15 @@ def movie_url(imdb_id: str):
return f"https://www.imdb.com/title/{imdb_id}/" return f"https://www.imdb.com/title/{imdb_id}/"
def imdb_rating_from_score(score: int) -> float: def imdb_rating_from_score(score: UnwindScore) -> ImdbRating:
"""Return the IMDb rating from an Unwind Movie score.""" """Return the IMDb rating from an Unwind Movie score."""
assert 0 <= score <= 100 assert 0 <= score <= 100
rating = round(score * 9 / 100 + 1, 1) rating = round(score * 9 / 100 + 1, 1)
assert 1.0 <= rating <= 10.0 assert 1.0 <= rating <= 10.0
return rating return ImdbRating(rating)
def score_from_imdb_rating(rating: float) -> int: def score_from_imdb_rating(rating: ImdbRating | int) -> UnwindScore:
"""Return the Unwind Movie score for an IMDb rating.""" """Return the Unwind Movie score for an IMDb rating."""
# Scale IMDb's 10 point rating to our score of [0, 100]. # Scale IMDb's 10 point rating to our score of [0, 100].
# There's a pitfall here! # There's a pitfall here!
@ -83,26 +91,45 @@ def score_from_imdb_rating(rating: float) -> int:
assert 1.0 <= rating <= 10.0 assert 1.0 <= rating <= 10.0
score = round(100 * (rating - 1) / 9) score = round(100 * (rating - 1) / 9)
assert 0 <= score <= 100 assert 0 <= score <= 100
return score return UnwindScore(score)
# find_name: e.g. "Your Mom's Ratings"
find_name = re.compile(r"(?P<name>.*)'s Ratings").fullmatch find_name = re.compile(r"(?P<name>.*)'s Ratings").fullmatch
# find_rating_date: e.g. "Rated on 06 May 2021"
find_rating_date = re.compile(r"Rated on (?P<date>\d{2} \w+ \d{4})").fullmatch find_rating_date = re.compile(r"Rated on (?P<date>\d{2} \w+ \d{4})").fullmatch
# find_rating_date_2: e.g. "Rated on May 01, 2024"
find_rating_date_2 = re.compile(r"Rated on (?P<date>\w+ \d{2}, \d{4})").fullmatch
find_runtime = re.compile(r"((?P<h>\d+) hr)? ?((?P<m>\d+) min)?").fullmatch find_runtime = re.compile(r"((?P<h>\d+) hr)? ?((?P<m>\d+) min)?").fullmatch
# find_year = re.compile( # find_runtime_2: e.g. "1h 38m"
# r"(\([IVX]+\) )?\((?P<year>\d{4})(( |\d{4})| TV (Special|Movie)| Video)?\)" find_runtime_2 = re.compile(r"((?P<h>\d+)h )?((?P<m>\d+)m)?").fullmatch
# ).fullmatch # find_year: e.g. "(1992)"
find_year = re.compile( find_year = re.compile(
r"(\([IVX]+\) )?\((?P<year>\d{4})(( |\d{4})| (?P<type>[^)]+))?\)" r"(\([IVX]+\) )?\((?P<year>\d{4})(( |\d{4})| (?P<type>[^)]+))?\)" # noqa: RUF001
).fullmatch ).fullmatch
# find_year_2: e.g. "2024", "19712003", "2024" # noqa: RUF003
find_year_2 = re.compile(r"(?P<year>\d{4})((?P<end_year>\d{4})?)?").fullmatch # noqa: RUF001
find_movie_id = re.compile(r"/title/(?P<id>tt\d+)/").search find_movie_id = re.compile(r"/title/(?P<id>tt\d+)/").search
find_movie_name = re.compile(r"\d+\. (?P<name>.+)").fullmatch
# find_vote_count: e.g. "(5.9K)", "(1K)", "(8)"
find_vote_count = re.compile(r"\((?P<count>\d+(\.\d+)?K?)\)").fullmatch
def movie_and_rating_from_item(item: bs4.Tag) -> tuple[Movie, Rating]: def _first_string(tag: bs4.Tag) -> str | None:
for child in tag.children:
if isinstance(child, str):
return child
def _tv_episode_title(series_name: str, episode_name: str) -> str:
return f"{series_name.strip()} / {episode_name.strip()}"
def _movie_and_rating_from_item_legacy(item: bs4.Tag) -> tuple[Movie, Rating]:
genres = (genre := item.find("span", "genre")) and genre.string or "" genres = (genre := item.find("span", "genre")) and genre.string or ""
movie = Movie( movie = Movie(
title=item.h3.a.string.strip(), title=item.h3.a.string.strip(),
genres=set(s.strip() for s in genres.split(",")), genres={s.strip() for s in genres.split(",")},
) )
episode_br = item.h3.br episode_br = item.h3.br
@ -112,7 +139,7 @@ def movie_and_rating_from_item(item: bs4.Tag) -> tuple[Movie, Rating]:
raise ValueError("Unknown document structure.") raise ValueError("Unknown document structure.")
movie.media_type = "TV Episode" movie.media_type = "TV Episode"
movie.title += " / " + episode_a.string.strip() movie.title = _tv_episode_title(movie.title, episode_a.string)
if match := find_year(episode_br.find_next("span", "lister-item-year").string): if match := find_year(episode_br.find_next("span", "lister-item-year").string):
movie.release_year = int(match["year"]) movie.release_year = int(match["year"])
if match := find_movie_id(episode_a["href"]): if match := find_movie_id(episode_a["href"]):
@ -150,81 +177,353 @@ def movie_and_rating_from_item(item: bs4.Tag) -> tuple[Movie, Rating]:
return movie, rating return movie, rating
ForgedRequest = namedtuple("ForgedRequest", "url headers") def _movie_and_rating_from_item_2024(item: bs4.Tag) -> Movie:
movie = Movie()
# Data for `original_title` and `genres` is not available from the ratings page.
if match := find_movie_name(item.h3.string.strip()):
movie.title = match["name"]
if (match := item.find("a", "ipc-lockup-overlay")) and (
match := find_movie_id(match["href"])
):
movie.imdb_id = match["id"]
if match := item.find("span", "ratingGroup--imdb-rating"):
movie.imdb_score = score_from_imdb_rating(float(_first_string(match)))
for metadata in item.find_all("span", "dli-title-metadata-item"):
# Other known metadata types, with some example values:
# - Episode count: "10 eps"
# - Age rating: "TV-PG", "TV-MA", "R"
if match := find_runtime_2(metadata.string.strip()):
movie.runtime = int(match["h"] or 0) * 60 + int(match["m"] or 0)
if match := find_year_2(metadata.string.strip()):
movie.release_year = int(match["year"])
if match := item.find("span", "dli-title-type-data"):
movie.media_type = match.string.strip()
if not movie.media_type:
movie.media_type = "Movie"
# TODO `imdb_votes` is available as exact value from the pages' JSON template.
if (match := item.find("span", "ipc-rating-star--voteCount")) and (
match := find_vote_count("".join(match.stripped_strings))
):
count, k, _ = match["count"].partition("K")
votes = float(count)
if k:
votes *= 1_000
movie.imdb_votes = int(votes)
if movie.media_type == "TV Episode":
titles = item.find_all("h3")
if len(titles) != 2:
raise ValueError("Unknown document structure.")
movie.title = _tv_episode_title(movie.title, titles[1].string)
if match := find_year(item.find("span", "dli-ep-year").get_text()):
movie.release_year = int(match["year"])
return movie
async def parse_page(url: str) -> tuple[list[Rating], str | None]: _ForgedRequest = namedtuple("_ForgedRequest", "url headers")
ratings = []
@dataclass
class _RatingsPage:
ratings: list[Rating] = field(default_factory=list)
next_page_url: str | None = None
imdb_user_id: UserId | None = None
imdb_user_name: str | None = None
async def _load_ratings_page(url: str, user_id: UserId) -> _RatingsPage:
"""Dispatch to handlers for different ratings page versions."""
soup = await asoup_from_url(url) soup = await asoup_from_url(url)
if (meta := soup.find("meta", property="pageId")) is None: if soup.find("meta", property="imdb:pageConst") is not None:
return await _load_ratings_page_2024(user_id, url, soup)
elif soup.find("meta", property="pageId") is not None:
return await _load_ratings_page_legacy(url, soup)
raise RuntimeError("Unknown ratings page version.")
async def _load_ratings_page_2024(
user_id: UserId, url: str, soup: bs4.BeautifulSoup
) -> _RatingsPage:
"""Handle the ratings page from 2024."""
page = _RatingsPage()
if (meta := soup.find("meta", property="imdb:pageConst")) is None:
raise RuntimeError("No pageId found.") raise RuntimeError("No pageId found.")
assert isinstance(meta, bs4.Tag) assert isinstance(meta, bs4.Tag)
imdb_id = meta["content"] if isinstance(page_id := meta["content"], list):
assert isinstance(imdb_id, str) page_id = page_id[0]
async with db.new_connection() as conn: page.imdb_user_id = page_id
user = await db.get(conn, User, imdb_id=imdb_id) or User(
imdb_id=imdb_id, name="", secret=""
)
if (headline := soup.h1) is None: if (headline := soup.title) is None:
raise RuntimeError("No headline found.") raise RuntimeError("No user link found.")
assert isinstance(headline.string, str) assert isinstance(headline.string, str)
if match := find_name(headline.string): if match := find_name(headline.string):
user.name = match["name"] page.imdb_user_name = match["name"]
items = soup.find_all("div", "lister-item-content") items = soup.find_all("li", "ipc-metadata-list-summary-item")
movies: list[Movie] = []
for i, item in enumerate(items): for i, item in enumerate(items):
try: try:
movie, rating = movie_and_rating_from_item(item) movie = _movie_and_rating_from_item_2024(item)
except Exception as err: except Exception as err:
log.error( log.error(
"Error in %s item #%s (%s): %s: %s", "Error in %s item #%s (%s): %a: %s",
url, url,
i, i,
cache_path(ForgedRequest(url, headers={})), cache_path(_ForgedRequest(url, headers={})),
" ".join(item.h3.stripped_strings),
err,
)
continue
movies.append(movie)
movies_dict = {m.imdb_id: m for m in movies}
async for rating in _load_user_movie_ratings(user_id, list(movies_dict.keys())):
movie = movies_dict[rating.movie_id]
rating = Rating(
movie=movie,
score=score_from_imdb_rating(rating.imdb_rating),
rating_date=rating.rating_date,
)
page.ratings.append(rating)
# TODO: next page requires querying IMDb's Graph API
return page
async def _load_ratings_page_legacy(url: str, soup: bs4.BeautifulSoup) -> _RatingsPage:
"""Handle the ratings page as it was before 2024."""
page = _RatingsPage()
if (meta := soup.find("meta", property="pageId")) is None:
raise RuntimeError("No pageId found.")
assert isinstance(meta, bs4.Tag)
if isinstance(page_id := meta["content"], list):
page_id = page_id[0]
page.imdb_user_id = page_id
if (headline := soup.h1) is None:
raise RuntimeError("No headline found.")
assert isinstance(headline.string, str)
if match := find_name(headline.string):
page.imdb_user_name = match["name"]
items = soup.find_all("div", "lister-item-content")
for i, item in enumerate(items):
try:
movie, rating = _movie_and_rating_from_item_legacy(item)
except Exception as err:
log.error(
"Error in %s item #%s (%s): %a: %s",
url,
i,
cache_path(_ForgedRequest(url, headers={})),
" ".join(item.h3.stripped_strings), " ".join(item.h3.stripped_strings),
err, err,
) )
continue continue
rating.user = user
rating.movie = movie rating.movie = movie
ratings.append(rating) page.ratings.append(rating)
next_url = None
if (footer := soup.find("div", "footer")) is None: if (footer := soup.find("div", "footer")) is None:
raise RuntimeError("No footer found.") raise RuntimeError("No footer found.")
assert isinstance(footer, bs4.Tag) assert isinstance(footer, bs4.Tag)
if (next_link := footer.find("a", string="Next")) is not None: if (next_link := footer.find("a", string=re.compile("Next"))) is not None:
assert isinstance(next_link, bs4.Tag) assert isinstance(next_link, bs4.Tag)
next_href = next_link["href"] next_href = next_link["href"]
assert isinstance(next_href, str) assert isinstance(next_href, str)
next_url = urljoin(url, next_href) page.next_page_url = urljoin(url, next_href)
return (ratings, next_url if url != next_url else None) return page
async def load_ratings(user_id: str): async def load_and_store_ratings(
user_id: UserId,
) -> AsyncIterable[tuple[Rating, bool]]:
async with db.new_connection() as conn:
user = await db.get(conn, User, imdb_id=user_id) or User(
imdb_id=user_id, name="", secret=""
)
is_first = True
async for rating in load_ratings(user_id):
assert rating.movie
rating.user = user
async with db.transaction() as conn:
if is_first:
is_first = False
# All rating objects share the same user.
await db.add_or_update_user(conn, rating.user)
rating.user_id = rating.user.id
await db.add_or_update_movie(conn, rating.movie)
rating.movie_id = rating.movie.id
is_updated = await db.add_or_update_rating(conn, rating)
yield rating, is_updated
async def load_ratings(user_id: UserId) -> AsyncIterable[Rating]:
next_url = user_ratings_url(user_id) next_url = user_ratings_url(user_id)
while next_url: while next_url:
ratings, next_url = await parse_page(next_url) ratings_page = await _load_ratings_page(next_url, user_id)
next_url = ratings_page.next_page_url
for rating in ratings_page.ratings:
yield rating
for i, rating in enumerate(ratings):
assert rating.user and rating.movie
async with db.transaction() as conn: async def _ids_from_list_html(url: str) -> AsyncIterable[MovieId]:
if i == 0: """Return all IMDb movie IDs (`tt*`) from the given URL."""
# All rating objects share the same user. # document.querySelectorAll('li.ipc-metadata-list-summary-item a.ipc-title-link-wrapper')
await db.add_or_update_user(conn, rating.user) # .href: '/title/tt1213644/?ref_=chtbtm_t_1'
rating.user_id = rating.user.id # .text(): '1. Disaster Movie'
soup = await asoup_from_url(url)
for item in soup.find_all("li", "ipc-metadata-list-summary-item"):
if (link := item.find("a", "ipc-title-link-wrapper")) is not None:
if (href := link.get("href")) is not None:
if match_ := find_movie_id(href):
yield match_["id"]
await db.add_or_update_movie(conn, rating.movie)
rating.movie_id = rating.movie.id
is_updated = await db.add_or_update_rating(conn, rating) async def load_most_popular_100() -> list[MovieId]:
"""Return the IMDb's top 100 most popular movies.
yield rating, is_updated IMDb Charts: Most Popular Movies
As determined by IMDb users
"""
url = "https://www.imdb.com/chart/moviemeter/"
ids = [tid async for tid in _ids_from_list_html(url)]
if len(ids) != 100:
raise RuntimeError(f"Expected exactly 100 items, got {len(ids)}")
return ids
async def load_bottom_100() -> list[MovieId]:
"""Return the IMDb's bottom 100 lowest rated movies.
IMDb Charts: Lowest Rated Movies
Bottom 100 as voted by IMDb users
"""
url = "https://www.imdb.com/chart/bottom/"
ids = [tid async for tid in _ids_from_list_html(url)]
if len(ids) != 100:
raise RuntimeError(f"Expected exactly 100 items, got {len(ids)}")
return ids
async def load_top_250() -> list[MovieId]:
"""Return the IMDb's top 250 highest rated movies.
IMDb Charts: IMDb Top 250 Movies
As rated by regular IMDb voters.
"""
# Called from page https://www.imdb.com/chart/top/
qgl_api_url = "https://caching.graphql.imdb.com/"
query = {
"operationName": "Top250MoviesPagination",
"variables": {"first": 250, "locale": "en-US"},
"extensions": {
"persistedQuery": {
"sha256Hash": "26114ee01d97e04f65d6c8c7212ae8b7888fa57ceed105450d1fce09df749b2d",
"version": 1,
}
},
}
headers = {
"accept": "application/graphql+json, application/json",
"content-type": "application/json",
"origin": "https://www.imdb.com",
}
jsonstr = await adownload(qgl_api_url, query=query, headers=headers)
data = json.loads(jsonstr)
try:
imdb_title_ids = [
edge["node"]["id"] for edge in data["data"]["chartTitles"]["edges"]
]
has_next_page = data["data"]["chartTitles"]["pageInfo"]["hasNextPage"]
has_previous_page = data["data"]["chartTitles"]["pageInfo"]["hasPreviousPage"]
except KeyError as err:
log.error("Unexpected data structure.", exc_info=err)
raise
if len(imdb_title_ids) != 250 or has_next_page or has_previous_page:
raise RuntimeError(f"Expected exactly 250 items, got {len(imdb_title_ids)}")
return imdb_title_ids
@dataclass
class _UserMovieRating:
movie_id: MovieId
rating_date: datetime
imdb_rating: ImdbRating
async def _load_user_movie_ratings(
user_id: UserId, movie_ids: list[MovieId]
) -> AsyncIterable[_UserMovieRating]:
qgl_api_url = "https://api.graphql.imdb.com/"
headers = {
"accept": "application/graphql+json, application/json",
"content-type": "application/json",
"origin": "https://www.imdb.com",
}
query = {
"operationName": "UserRatingsAndWatchOptions",
"variables": {
"locale": "en-US",
"idArray": movie_ids,
"includeUserRating": False,
"location": {"latLong": {"lat": "65.03", "long": "-18.82"}},
"otherUserId": user_id,
"fetchOtherUserRating": True,
},
"extensions": {
"persistedQuery": {
"version": 1,
"sha256Hash": "9672397d6bf156302f8f61e7ede2750222bd2689e65e21cfedc5abd5ca0f4aea",
}
},
}
async with asession() as s:
r = await s.post(qgl_api_url, headers=headers, json=query, timeout=10)
r.raise_for_status()
data = r.json()
try:
titles = data["data"]["titles"]
if len(titles) != len(movie_ids):
log.warning("Expected %s items, got %s.", len(movie_ids), len(titles))
for item in titles:
yield _UserMovieRating(
movie_id=item["id"],
rating_date=datetime.fromisoformat(item["otherUserRating"]["date"]),
imdb_rating=item["otherUserRating"]["value"],
)
except KeyError as err:
log.error("Unexpected data structure.", exc_info=err)
raise

View file

@ -17,7 +17,7 @@ log = logging.getLogger(__name__)
T = TypeVar("T") T = TypeVar("T")
# See # See
# - https://www.imdb.com/interfaces/ # - https://developer.imdb.com/non-commercial-datasets/
# - https://datasets.imdbws.com/ # - https://datasets.imdbws.com/
@ -101,12 +101,10 @@ title_types = {
} }
def gz_mtime(path: Path) -> datetime: def _mtime(path: Path) -> datetime:
"""Return the timestamp of the compressed file.""" """Return the timestamp of the file."""
g = gzip.GzipFile(path, "rb") mtime = path.stat().st_mtime
g.peek(1) # start reading the file to fill the timestamp field return datetime.fromtimestamp(mtime, tz=timezone.utc)
assert g.mtime is not None
return datetime.fromtimestamp(g.mtime).replace(tzinfo=timezone.utc)
def count_lines(path: Path) -> int: def count_lines(path: Path) -> int:
@ -125,15 +123,13 @@ def count_lines(path: Path) -> int:
@overload @overload
def read_imdb_tsv( def read_imdb_tsv(
path: Path, row_type, *, unpack: Literal[False] path: Path, row_type, *, unpack: Literal[False]
) -> Generator[list[str], None, None]: ) -> Generator[list[str], None, None]: ...
...
@overload @overload
def read_imdb_tsv( def read_imdb_tsv(
path: Path, row_type: Type[T], *, unpack: Literal[True] = True path: Path, row_type: Type[T], *, unpack: Literal[True] = True
) -> Generator[T, None, None]: ) -> Generator[T, None, None]: ...
...
def read_imdb_tsv(path: Path, row_type, *, unpack=True): def read_imdb_tsv(path: Path, row_type, *, unpack=True):
@ -162,7 +158,7 @@ def read_imdb_tsv(path: Path, row_type, *, unpack=True):
def read_ratings(path: Path): def read_ratings(path: Path):
mtime = gz_mtime(path) mtime = _mtime(path)
rows = read_imdb_tsv(path, RatingRow) rows = read_imdb_tsv(path, RatingRow)
for row in rows: for row in rows:
@ -178,7 +174,7 @@ def read_ratings_as_mapping(path: Path):
def read_basics(path: Path) -> Generator[Movie | None, None, None]: def read_basics(path: Path) -> Generator[Movie | None, None, None]:
mtime = gz_mtime(path) mtime = _mtime(path)
rows = read_imdb_tsv(path, BasicRow) rows = read_imdb_tsv(path, BasicRow)
for row in rows: for row in rows:
@ -200,7 +196,7 @@ async def import_from_file(*, basics_path: Path, ratings_path: Path):
total = count_lines(basics_path) total = count_lines(basics_path)
log.debug("Found %i movies.", total) log.debug("Found %i movies.", total)
if total == 0: if total == 0:
raise RuntimeError(f"No movies found.") raise RuntimeError("No movies found.")
perc_next_report = 0.0 perc_next_report = 0.0
perc_step = 0.1 perc_step = 0.1
@ -254,13 +250,22 @@ async def download_datasets(*, basics_path: Path, ratings_path: Path) -> None:
See https://www.imdb.com/interfaces/ and https://datasets.imdbws.com/ for See https://www.imdb.com/interfaces/ and https://datasets.imdbws.com/ for
more information on the IMDb database dumps. more information on the IMDb database dumps.
""" """
basics_url = "https://datasets.imdbws.com/title.basics.tsv.gz" # name_basics_url = "https://datasets.imdbws.com/name.basics.tsv.gz"
ratings_url = "https://datasets.imdbws.com/title.ratings.tsv.gz" # title_akas_url = "https://datasets.imdbws.com/title.akas.tsv.gz"
title_basics_url = "https://datasets.imdbws.com/title.basics.tsv.gz"
# title_crew_url = "https://datasets.imdbws.com/title.crew.tsv.gz"
# title_episode_url = "https://datasets.imdbws.com/title.episode.tsv.gz"
# title_principals_url = "https://datasets.imdbws.com/title.principals.tsv.gz"
title_ratings_url = "https://datasets.imdbws.com/title.ratings.tsv.gz"
async with request.asession(): async with request.asession():
await asyncio.gather( await asyncio.gather(
request.adownload(ratings_url, to_path=ratings_path, only_if_newer=True), request.adownload(
request.adownload(basics_url, to_path=basics_path, only_if_newer=True), title_ratings_url, to_path=ratings_path, only_if_newer=True
),
request.adownload(
title_basics_url, to_path=basics_path, only_if_newer=True
),
) )

View file

@ -143,7 +143,7 @@ def asplain(
d[f.name] = v.isoformat() d[f.name] = v.isoformat()
elif target in {set}: elif target in {set}:
assert isinstance(v, set) assert isinstance(v, set)
d[f.name] = dump(list(sorted(v))) d[f.name] = dump(sorted(v))
elif target in {list}: elif target in {list}:
assert isinstance(v, list) assert isinstance(v, list)
d[f.name] = dump(list(v)) d[f.name] = dump(list(v))
@ -197,16 +197,30 @@ def fromplain(cls: Type[T], d: Mapping, *, serialized: bool = False) -> T:
def validate(o: object) -> None: def validate(o: object) -> None:
for f in fields(o): for f in fields(o):
vtype = type(getattr(o, f.name)) vtype = type(getattr(o, f.name))
if vtype is not f.type: if vtype is f.type:
if get_origin(f.type) is vtype or ( continue
(isinstance(f.type, UnionType) or get_origin(f.type) is Union)
and vtype in get_args(f.type) origin = get_origin(f.type)
): if origin is vtype:
continue
is_union = isinstance(f.type, UnionType) or origin is Union
if is_union:
# Support unioned types.
utypes = get_args(f.type)
if vtype in utypes:
continue continue
raise ValueError(f"Invalid value type: {f.name}: {vtype}")
# Support generic types (set[str], list[int], etc.)
gtypes = [g for u in utypes if (g := get_origin(u)) is not None]
if any(vtype is gtype for gtype in gtypes):
continue
raise ValueError(f"Invalid value type: {f.name}: {vtype}")
def utcnow(): def utcnow() -> datetime:
"""Return the current time as timezone aware datetime."""
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
@ -293,7 +307,7 @@ class Movie:
Column("imdb_score", Integer), Column("imdb_score", Integer),
Column("imdb_votes", Integer), Column("imdb_votes", Integer),
Column("runtime", Integer), Column("runtime", Integer),
Column("genres", String, nullable=False), Column("genres", String),
Column("created", String, nullable=False), # datetime Column("created", String, nullable=False), # datetime
Column("updated", String, nullable=False), # datetime Column("updated", String, nullable=False), # datetime
) )
@ -309,7 +323,7 @@ class Movie:
imdb_score: int | None = None # range: [0,100] imdb_score: int | None = None # range: [0,100]
imdb_votes: int | None = None imdb_votes: int | None = None
runtime: int | None = None # minutes runtime: int | None = None # minutes
genres: set[str] = None genres: set[str] | None = None
created: datetime = field(default_factory=utcnow) created: datetime = field(default_factory=utcnow)
updated: datetime = field(default_factory=utcnow) updated: datetime = field(default_factory=utcnow)
@ -334,9 +348,9 @@ class Movie:
if not self._is_lazy: if not self._is_lazy:
return return
for field in fields(Movie): for f in fields(Movie):
if getattr(self, field.name) is None and callable(field.default_factory): if getattr(self, f.name) is None and callable(f.default_factory):
setattr(self, field.name, field.default_factory()) setattr(self, f.name, f.default_factory())
self._is_lazy = False self._is_lazy = False

View file

@ -11,7 +11,7 @@ from hashlib import md5
from pathlib import Path from pathlib import Path
from random import random from random import random
from time import sleep, time from time import sleep, time
from typing import Any, Callable, ParamSpec, TypeVar, cast from typing import Any, Callable, ParamSpec, TypeVar, cast, overload
import bs4 import bs4
import httpx import httpx
@ -49,9 +49,9 @@ async def asession():
return return
_shared_asession = _ASession_T() _shared_asession = _ASession_T()
_shared_asession.headers[ _shared_asession.headers["user-agent"] = (
"user-agent" "Mozilla/5.0 Gecko/20100101 unwind/20230203"
] = "Mozilla/5.0 Gecko/20100101 unwind/20230203" )
try: try:
async with _shared_asession: async with _shared_asession:
yield _shared_asession yield _shared_asession
@ -65,7 +65,7 @@ def _throttle(
calls: deque[float] = deque(maxlen=times) calls: deque[float] = deque(maxlen=times)
if jitter is None: if jitter is None:
jitter = lambda: 0.0 jitter = lambda: 0.0 # noqa: E731
def decorator(func: Callable[_P, _T]) -> Callable[_P, _T]: def decorator(func: Callable[_P, _T]) -> Callable[_P, _T]:
@wraps(func) @wraps(func)
@ -125,12 +125,12 @@ def cache_path(req) -> Path | None:
if not config.cachedir: if not config.cachedir:
return return
sig = repr(req.url) # + repr(sorted(req.headers.items())) sig = repr(req.url) # + repr(sorted(req.headers.items()))
return config.cachedir / md5(sig.encode()).hexdigest() return config.cachedir / md5(sig.encode()).hexdigest() # noqa: S324
@_throttle(1, 1, random) @_throttle(1, 1, random)
async def _ahttp_get(s: _ASession_T, url: str, *args, **kwds) -> _Response_T: async def _ahttp_get(s: _ASession_T, url: str, *args, **kwds) -> _Response_T:
req = s.build_request(method="GET", url=url, *args, **kwds) req = s.build_request(*args, method="GET", url=url, **kwds)
cachefile = cache_path(req) if config.debug else None cachefile = cache_path(req) if config.debug else None
@ -201,10 +201,42 @@ def _last_modified_from_file(path: Path) -> float:
return path.stat().st_mtime return path.stat().st_mtime
@overload
async def adownload(
url: str,
*,
to_path: Path | str,
query: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
replace_existing: bool | None = None,
only_if_newer: bool = False,
timeout: float | None = None,
chunk_callback: Callable[[bytes], Any] | None = None,
response_callback: Callable[[_Response_T], Any] | None = None,
) -> None: ...
@overload
async def adownload(
url: str,
*,
to_path: None = None,
query: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
replace_existing: bool | None = None,
only_if_newer: bool = False,
timeout: float | None = None,
chunk_callback: Callable[[bytes], Any] | None = None,
response_callback: Callable[[_Response_T], Any] | None = None,
) -> bytes: ...
async def adownload( async def adownload(
url: str, url: str,
*, *,
to_path: Path | str | None = None, to_path: Path | str | None = None,
query: dict[str, str] | None = None,
headers: dict[str, str] | None = None,
replace_existing: bool | None = None, replace_existing: bool | None = None,
only_if_newer: bool = False, only_if_newer: bool = False,
timeout: float | None = None, timeout: float | None = None,
@ -231,7 +263,8 @@ async def adownload(
raise FileExistsError(23, "Would replace existing file", str(to_path)) raise FileExistsError(23, "Would replace existing file", str(to_path))
async with asession() as s: async with asession() as s:
headers = {} if headers is None:
headers = {}
if file_exists and only_if_newer: if file_exists and only_if_newer:
assert to_path assert to_path
file_lastmod = _last_modified_from_file(to_path) file_lastmod = _last_modified_from_file(to_path)
@ -239,7 +272,9 @@ async def adownload(
file_lastmod, usegmt=True file_lastmod, usegmt=True
) )
req = s.build_request(method="GET", url=url, headers=headers, timeout=timeout) req = s.build_request(
method="GET", url=url, params=query, headers=headers, timeout=timeout
)
log.debug("⚡️ Loading %s (%a) ...", req.url, dict(req.headers)) log.debug("⚡️ Loading %s (%a) ...", req.url, dict(req.headers))
resp = await s.send(req, follow_redirects=True, stream=True) resp = await s.send(req, follow_redirects=True, stream=True)

View file

@ -0,0 +1,38 @@
-- remove NOTNULL constraint from movies.genres
CREATE TABLE _migrate_movies (
id TEXT PRIMARY KEY NOT NULL,
title TEXT NOT NULL,
original_title TEXT,
release_year INTEGER NOT NULL,
media_type TEXT NOT NULL,
imdb_id TEXT NOT NULL UNIQUE,
imdb_score INTEGER,
imdb_votes INTEGER,
runtime INTEGER,
genres TEXT,
created TEXT NOT NULL,
updated TEXT NOT NULL
);;
INSERT INTO _migrate_movies
SELECT
id,
title,
original_title,
release_year,
media_type,
imdb_id,
imdb_score,
imdb_votes,
runtime,
genres,
created,
updated
FROM movies
WHERE true;;
DROP TABLE movies;;
ALTER TABLE _migrate_movies
RENAME TO movies;;

View file

@ -1,7 +1,7 @@
import base64 import base64
import hashlib import hashlib
import secrets import secrets
from typing import Literal from typing import Any, TypedDict
def b64encode(b: bytes) -> str: def b64encode(b: bytes) -> str:
@ -16,11 +16,21 @@ def b64padded(s: str) -> str:
return s + "=" * (4 - len(s) % 4) return s + "=" * (4 - len(s) % 4)
def _encode_params(params: dict[str, Any]) -> str:
return ",".join(f"{k}={v}" for k, v in params.items())
class _PhcScryptParams(TypedDict, total=False):
n: int
r: int
p: int
def phc_scrypt( def phc_scrypt(
secret: bytes, secret: bytes,
*, *,
salt: bytes | None = None, salt: bytes | None = None,
params: dict[Literal["n", "r", "p"], int] = {}, params: _PhcScryptParams = {}, # noqa: B006
) -> str: ) -> str:
"""Return the scrypt expanded secret in PHC string format. """Return the scrypt expanded secret in PHC string format.
@ -39,10 +49,14 @@ def phc_scrypt(
# maxmem = 2 * 128 * n * r * p # maxmem = 2 * 128 * n * r * p
hashed_secret = hashlib.scrypt(secret, salt=salt, n=n, r=r, p=p) hashed_secret = hashlib.scrypt(secret, salt=salt, n=n, r=r, p=p)
encoded_params = ",".join(f"{k}={v}" for k, v in {"n": n, "r": r, "p": p}.items())
phc = "".join( phc = "".join(
f"${x}" f"${x}"
for x in ["scrypt", encoded_params, b64encode(salt), b64encode(hashed_secret)] for x in [
"scrypt",
_encode_params({"n": n, "r": r, "p": p}),
b64encode(salt),
b64encode(hashed_secret),
]
) )
return phc return phc
@ -54,19 +68,27 @@ def phc_compare(*, secret: str, phc_string: str) -> bool:
if args["id"] != "scrypt": if args["id"] != "scrypt":
raise ValueError(f"Algorithm not supported: {args['id']}") raise ValueError(f"Algorithm not supported: {args['id']}")
assert type(args["params"]) is dict
encoded = phc_scrypt(b64decode(secret), salt=args["salt"], params=args["params"]) encoded = phc_scrypt(b64decode(secret), salt=args["salt"], params=args["params"])
return secrets.compare_digest(encoded, phc_string) return secrets.compare_digest(encoded, phc_string)
def parse_phc(s: str): class _PhcParts(TypedDict):
parts = dict.fromkeys(["id", "version", "params", "salt", "hash"]) # $<id>[$v=<version>][$<param>=<value>(,<param>=<value>)*][$<salt>[$<hash>]]
id: str # the symbolic name for the function
version: int | None # the algorithm version
params: dict[str, int]
salt: bytes | None
hash: bytes | None
def parse_phc(s: str) -> _PhcParts:
parts = _PhcParts(id="", version=None, params={}, salt=None, hash=None)
_, parts["id"], *rest = s.split("$") _, parts["id"], *rest = s.split("$")
if rest and rest[0].startswith("v="): if rest and rest[0].startswith("v="):
parts["version"] = rest.pop(0) parts["version"] = int(rest.pop(0))
if rest and "=" in rest[0]: if rest and "=" in rest[0]:
parts["params"] = { parts["params"] = {
kv[0]: int(kv[1]) kv[0]: int(kv[1])

View file

@ -20,6 +20,7 @@ from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from starlette.middleware.gzip import GZipMiddleware from starlette.middleware.gzip import GZipMiddleware
from starlette.requests import HTTPConnection
from starlette.responses import JSONResponse from starlette.responses import JSONResponse
from starlette.routing import Mount, Route from starlette.routing import Mount, Route
@ -47,17 +48,17 @@ class BearerAuthBackend(AuthenticationBackend):
def __init__(self, credentials: dict[str, str]): def __init__(self, credentials: dict[str, str]):
self.admin_tokens = {v: k for k, v in credentials.items()} self.admin_tokens = {v: k for k, v in credentials.items()}
async def authenticate(self, request): async def authenticate(self, conn: HTTPConnection):
if "Authorization" not in request.headers: if "Authorization" not in conn.headers:
return return
# XXX should we remove the auth header after reading, for security reasons? # XXX should we remove the auth header after reading, for security reasons?
auth = request.headers["Authorization"] auth = conn.headers["Authorization"]
try: try:
scheme, credentials = auth.split() scheme, credentials = auth.split()
except ValueError: except ValueError as err:
raise AuthenticationError("Invalid auth credentials") raise AuthenticationError("Invalid auth credentials") from err
roles = [] roles = []
@ -72,8 +73,8 @@ class BearerAuthBackend(AuthenticationBackend):
elif scheme.lower() == "basic": elif scheme.lower() == "basic":
try: try:
name, secret = b64decode(credentials).decode().split(":") name, secret = b64decode(credentials).decode().split(":")
except: except Exception as err:
raise AuthenticationError("Invalid auth credentials") raise AuthenticationError("Invalid auth credentials") from err
user = AuthedUser(name, secret) user = AuthedUser(name, secret)
else: else:
@ -113,7 +114,7 @@ def as_int(
return max return max
return x return x
except: except Exception:
if default is None: if default is None:
raise raise
@ -127,18 +128,16 @@ def as_ulid(s: str) -> ULID:
return ULID(s) return ULID(s)
except ValueError: except ValueError as err:
raise HTTPException(422, "Not a valid ULID.") raise HTTPException(422, "Not a valid ULID.") from err
@overload @overload
async def json_from_body(request) -> dict: async def json_from_body(request) -> dict: ...
...
@overload @overload
async def json_from_body(request, keys: list[str]) -> list: async def json_from_body(request, keys: list[str]) -> list: ...
...
async def json_from_body(request, keys: list[str] | None = None): async def json_from_body(request, keys: list[str] | None = None):
@ -148,8 +147,8 @@ async def json_from_body(request, keys: list[str] | None = None):
else: else:
try: try:
data = await request.json() data = await request.json()
except JSONDecodeError: except JSONDecodeError as err:
raise HTTPException(422, "Invalid JSON content.") raise HTTPException(422, "Invalid JSON content.") from err
if not keys: if not keys:
return data return data
@ -157,7 +156,7 @@ async def json_from_body(request, keys: list[str] | None = None):
try: try:
return [data[k] for k in keys] return [data[k] for k in keys]
except KeyError as err: except KeyError as err:
raise HTTPException(422, f"Missing data for key: {err.args[0]}") raise HTTPException(422, f"Missing data for key: {err.args[0]}") from err
def is_admin(request): def is_admin(request):
@ -510,8 +509,8 @@ async def modify_user(request):
if "secret" in data: if "secret" in data:
try: try:
secret = b64decode(data["secret"]) secret = b64decode(data["secret"])
except: except Exception as err:
raise HTTPException(422, f"Invalid secret.") raise HTTPException(422, "Invalid secret.") from err
user.secret = phc_scrypt(secret) user.secret = phc_scrypt(secret)
@ -539,7 +538,7 @@ async def add_group_to_user(request):
return not_found("Group not found") return not_found("Group not found")
if access not in set("riw"): if access not in set("riw"):
raise HTTPException(422, f"Invalid access level.") raise HTTPException(422, "Invalid access level.")
user.set_access(group_id, access) user.set_access(group_id, access)
async with db.transaction() as conn: async with db.transaction() as conn: