Author SHA1 Message Date
MH.Dmitrii 5dde797a1b remove boilerplate target url fix 2026-08-19 11:28:29 +03:00
MH.Dmitrii cfd3078216 removing boilerplate target url 2026-08-19 11:19:23 +03:00
MH.Dmitrii 73e983c6a5 all the remain tests 2026-08-19 11:08:33 +03:00
MH.Dmitrii 8bc3ff7b54 unit & interga tests 2026-08-19 00:22:59 +03:00
MH.Dmitrii b3083b0e82 e2e crud 0.2 2026-08-18 19:33:41 +03:00
MH.Dmitrii 1eb9935a15 conftest e2e auth/test user create 2026-08-17 19:33:23 +03:00
MH.Dmitrii 0a4a21f2e7 e2e tests 0.1 2026-08-17 11:42:12 +03:00
MH.Dmitrii 4486f62e17 uploads dir mount fix 2026-08-15 11:35:03 +03:00
MH.Dmitrii 33aa3cb7a4 create, delete, patch user 2026-08-14 13:24:55 +03:00
MH.Dmitrii d3244666af makefile fix 2026-08-13 19:40:20 +03:00
MH.Dmitrii 6c5b85223e Merge pull request 'feature/docker' (#2) from feature/docker into dev
Reviewed-on: MH.Dmitrii/The_Great_Excel_Project#2
2026-08-13 16:32:55 +00:00
MH.Dmitrii 0f8d816e7f feature gunicorn startup 2026-08-13 19:30:55 +03:00
MH.Dmitrii e23a6fc569 docker 2026-08-12 18:36:56 +03:00
MH.Dmitrii 50201542da imports fix 2026-08-10 17:56:41 +03:00
MH.Dmitrii 5ec033a21d asyncio style refactoring 2026-08-05 14:46:10 +03:00
MH.Dmitrii 2da58c7483 fix problems 2026-08-05 12:22:45 +03:00
MH.Dmitrii 4e14972cf6 fixes 2026-08-04 17:11:33 +03:00
MH.Dmitrii 8fa72daa6b makefile and http errors 2026-07-30 20:58:53 +03:00
MH.Dmitrii 3705ac4f0c conftest fix 2026-07-30 15:54:43 +03:00
MH.Dmitrii 439d57554c integration tests 2026-07-30 15:49:08 +03:00
MH.Dmitrii f87f54de55 tests logout 2026-07-29 18:32:22 +03:00
MH.Dmitrii 00403191f5 integrated tests 2026-07-29 00:44:14 +03:00
MH.Dmitrii d24b99b8b0 integrated tests + omit unnecessary 2026-07-28 15:16:52 +03:00
MH.Dmitrii ef1e39d506 fix gitignore 2026-07-28 13:35:38 +03:00
MH.Dmitrii 2781317797 rm coverage 2026-07-28 13:34:14 +03:00
MH.Dmitrii f81ba19da4 hash tests 2026-07-28 13:31:58 +03:00
MH.Dmitrii 7a4df2933d ruff fix 2026-07-28 01:43:35 +03:00
MH.Dmitrii 5522447b07 auth unit tests & ruff visual fix 2026-07-28 01:42:29 +03:00
MH.Dmitrii 1d25b0edc3 refactoring some code by removing unnecessary checks and moving tests block to root / 2026-07-24 17:13:37 +03:00
42 changed files with 2199 additions and 360 deletions
+18
View File
@@ -0,0 +1,18 @@
# виртуальное окружение
venv/
.venv/
# кэш питона
__pycache__/
*.pyc
*.pyo
*.pyd
*.pytest_cache
# IDE и редакторы
.vscode/
.idea/
# OS мусор
.DS_Store
Thumbs.db
+2
View File
@@ -25,3 +25,5 @@ Thumbs.db
#Примеры документов #Примеры документов
input/ input/
output/ output/
allure-results/
.coverage
+4
View File
@@ -0,0 +1,4 @@
HOST="change_me"
PORT="change_me"
TEST_USERNAME="change_me"
TEST_PASSWORD="change_me"
+64
View File
@@ -0,0 +1,64 @@
name: excel-project
services:
backend-dev:
profiles: ["dev"]
image: "${DOCKER_REGISTRY:-local}/excel-dev:${IMAGE_TAG:-local}"
container_name: backend-dev
build:
dockerfile: ./docker/dockerfile
context: ../
target: dev
init: true #Manage processes and reap zombies
ipc: private #Inter-Process Communication (IPC) namespace for high-performance applications
volumes:
- type: bind
source: ../src
target: /home/excel-project/src
- type: bind
source: ../main.py
target: /home/excel-project/main.py
- type: bind
source: ../configs
target: /home/excel-project/configs
- type: bind
source: ../DB
target: /home/excel-project/DB
- type: bind
source: ../uploads
target: /home/excel-project/uploads
networks:
- backend
ports:
- "80:8000"
entrypoint: ["./entrypoint.sh", "--dev"]
backend-prod:
profiles: ["prod"]
image: "${DOCKER_REGISTRY:-local}/excel-prod:${IMAGE_TAG:-local}"
container_name: backend-prod
build:
dockerfile: ./docker/dockerfile
context: ../
target: prod
init: true #Manage processes and reap zombies
ipc: private #Inter-Process Communication (IPC) namespace for high-performance applications
volumes:
- type: bind
source: ../configs
target: /home/excel-project/configs
- type: bind
source: ../DB
target: /home/excel-project/DB
- type: bind
source: ../uploads
target: /home/excel-project/uploads
networks:
- backend
restart: unless-stopped
ports:
- "80:8000"
networks:
backend:
name: "${BACKEND_NETWORK:-backend_network}"
+41
View File
@@ -0,0 +1,41 @@
# --- Stage 1: Python Backend dev ---
FROM python:3.14-slim AS dev
LABEL org.opencontainers.image.title="the-great-excel-project-dev"
LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_Great_Excel_Project"
WORKDIR /home/excel-project
COPY pyproject.toml poetry.lock docker/entrypoint.sh ./
RUN chmod +x ./entrypoint.sh
RUN pip install --no-cache-dir --break-system-packages poetry \
&& poetry config virtualenvs.create false \
&& poetry install --no-root --no-interaction
# --- Stage 2: Python Backend prod ---
FROM python:3.14-slim AS prod
LABEL org.opencontainers.image.title="the-great-excel-project-prod"
LABEL org.opencontainers.image.source="https://git.homyk.space/MH.Dmitrii/The_Great_Excel_Project"
WORKDIR /home/excel-project
COPY pyproject.toml poetry.lock main.py docker/entrypoint.sh ./
COPY src/ ./src/
RUN chmod +x ./entrypoint.sh
RUN pip install --no-cache-dir --break-system-packages poetry \
&& poetry config virtualenvs.create false \
&& poetry install --no-root --no-interaction --only main
RUN groupadd --gid 1000 appuser \
&& useradd --uid 1000 --gid appuser --shell /bin/bash --create-home appuser \
&& chown -R appuser:appuser /home/excel-project
USER appuser
ENTRYPOINT ["./entrypoint.sh", "--prod"]
+41
View File
@@ -0,0 +1,41 @@
#!/bin/sh
MODE=""
WORKERS=""
while [ -n "$1" ]; do
case "$1" in
--dev) MODE="dev" ;;
--prod) MODE="prod" ;;
--workers)
shift
WORKERS="$1"
;;
*) echo "$1 is not an option" ;;
esac
shift
done
if [ -z "$MODE" ]; then
echo "Usage: entrypoint.sh --dev|--prod [--workers N]"
exit 1
fi
if [ "$MODE" = "dev" ]; then
WORKERS="${WORKERS:-1}"
exec gunicorn \
--workers "$WORKERS" \
--worker-class uvicorn.workers.UvicornWorker \
--worker-connections 1000 \
--reload \
--bind 0.0.0.0:8000 \
main:app
else
WORKERS="${WORKERS:-4}"
exec gunicorn \
--workers "$WORKERS" \
--worker-class uvicorn.workers.UvicornWorker \
--worker-connections 1000 \
--bind 0.0.0.0:8000 \
main:app
fi
+31 -9
View File
@@ -1,16 +1,38 @@
from fastapi import FastAPI from contextlib import asynccontextmanager
from src.web.protected_routes.routes import router as protected_router from pathlib import Path
import uvicorn
app=FastAPI(root_path="/") # import uvicorn
from fastapi import FastAPI
from src.web.protected_routes.auth_routes import router as protected_router
from src.web.protected_routes.protected_user_action_routes import (
router as protected_user_action_routes,
)
@asynccontextmanager
async def lifespan(app: FastAPI):
create_dirs()
yield
print("shutting down")
app=FastAPI(root_path="/", lifespan=lifespan)
app.include_router(router=protected_router) app.include_router(router=protected_router)
app.include_router(router=protected_user_action_routes)
@app.get("") @app.get("")
def root()->dict: async def root()->dict:
return {"root":"hello, this is root"} return {"root":"hello, this is root"}
def main():
uvicorn.run("main:app", reload=True)
if __name__=="__main__": def create_dirs():
main()
dirs_to_create=("./DB",
"./uploads/upload",
"./uploads/upload_bad",
"./uploads/upload_finished")
for x in dirs_to_create:
Path(x).mkdir(parents=True, exist_ok=True)
+78 -7
View File
@@ -1,11 +1,82 @@
VENV=source .venv/bin/activate; VENV:=source .venv/bin/activate;
ALLURE:=.venv/allure-2.44.0/bin/allure #linux&macos
#ALLURE=.venv\allure-2.44.0\bin\allure #Windows
.PHONY: NUM_DOWN ?= 1
run, m_gen, m_up
run: .DEFAULT_GOAL := help
${VENV} python3 main.py
m_gen: .PHONY: help
help:
@grep -E '(^[a-zA-Z0-9_-]+:.*?##.*$$)|(^##)' Makefile | awk 'BEGIN {FS = ":.*?## "}{printf "[32m%-30s\033[0m %s\n", $$1, $$2}' | sed -e 's/\[32m## /[33m/' | sed -e 's/\[32m/ /' | sed -e 's/\[33m//'
##
## Init section
##
.PHONY: run
run: ## Run dev local application
${VENV} uvicorn main:app --host 0.0.0.0 --port 8000 --reload
.PHONY: run-dev
run-dev: ## Run dev application
docker compose -f docker/compose-dev.yaml --profile dev up -d
.PHONY: run-prod
run-prod: ## Run prod application
docker compose -f docker/compose-dev.yaml --profile prod up -d
.PHONY: down-dev
down-dev: ## Down dev application
docker compose -f docker/compose-dev.yaml --profile dev down
.PHONY: down-prod
down-prod: ## Down prod application
docker compose -f docker/compose-dev.yaml --profile prod down
##
## Migration section
##
.PHONY: m_gen
m_gen: ## Generate alembic new revision
${VENV} alembic revision --autogenerate ${VENV} alembic revision --autogenerate
m_up:
.PHONY: m_up
m_up: ## Set new alembic revision
${VENV} alembic upgrade head ${VENV} alembic upgrade head
.PHONY: m_down
m_down: ## Downgrade alembic revision
${VENV} alembic downgrade -${NUM_DOWN}
.PHONY: m_history
m_history: ## List history of migrations
${VENV} alembic history
.PHONY: m_current
m_current: ## Current migration
${VENV} alembic current
.PHONY: m_stamp
m_stamp: ## Stamp head
${VENV} alembic stamp head
##
## Test section
##
.PHONY: test
test: ## Run tests
${VENV} pytest
.PHONY: allure
allure: ## Generate allure report
${VENV} ${ALLURE} generate tests/allure-results/reports --single-file -o tests/allure-results/html --clean
.PHONY: coverage
coverage: ## Run pytest coverage
${VENV} pytest --cov=src tests/ --cov-report=term-missing
.PHONY: clear
clear: ## Delete old test results
rm -rf ./tests/allure-results/reports
Generated
+420 -122
View File
@@ -1,5 +1,21 @@
# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. # This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand.
[[package]]
name = "aiosqlite"
version = "0.22.1"
description = "asyncio bridge to the standard sqlite3 module"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"},
{file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"},
]
[package.extras]
dev = ["attribution (==1.8.0)", "black (==25.11.0)", "build (>=1.2)", "coverage[toml] (==7.10.7)", "flake8 (==7.3.0)", "flake8-bugbear (==24.12.12)", "flit (==3.12.0)", "mypy (==1.19.0)", "ufmt (==2.8.0)", "usort (==1.0.8.post1)"]
docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.2)"]
[[package]] [[package]]
name = "alembic" name = "alembic"
version = "1.18.5" version = "1.18.5"
@@ -20,6 +36,38 @@ typing-extensions = ">=4.12"
[package.extras] [package.extras]
tz = ["tzdata"] tz = ["tzdata"]
[[package]]
name = "allure-pytest"
version = "2.16.0"
description = "Allure pytest integration"
optional = false
python-versions = "*"
groups = ["dev"]
files = [
{file = "allure_pytest-2.16.0-py3-none-any.whl", hash = "sha256:e5035a3b1f541b0c2ee566822df6fec20e0628b8536780e5bdfe39060ac0c71b"},
{file = "allure_pytest-2.16.0.tar.gz", hash = "sha256:3cc883595b1ce4280b0b9a5fdaa0ce3bb5cdbaa1b43c7269d8b82e825e6e107c"},
]
[package.dependencies]
allure-python-commons = "2.16.0"
pytest = ">=4.5.0"
[[package]]
name = "allure-python-commons"
version = "2.16.0"
description = "Contains the API for end users as well as helper functions and classes to build Allure adapters for Python test frameworks"
optional = false
python-versions = ">=3.6"
groups = ["dev"]
files = [
{file = "allure_python_commons-2.16.0-py3-none-any.whl", hash = "sha256:6d42a500078aca8a2e68823075c1ffc2396987bb268d62b19af82390b205ce88"},
{file = "allure_python_commons-2.16.0.tar.gz", hash = "sha256:ecdc92bafea074bab96b5f2c4eb3100825340188f5aece608ae80eced709b36f"},
]
[package.dependencies]
attrs = ">=16.0.0"
pluggy = ">=0.4.0"
[[package]] [[package]]
name = "annotated-doc" name = "annotated-doc"
version = "0.0.4" version = "0.0.4"
@@ -50,7 +98,7 @@ version = "4.14.2"
description = "High-level concurrency and networking framework on top of asyncio or Trio" description = "High-level concurrency and networking framework on top of asyncio or Trio"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494"}, {file = "anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494"},
{file = "anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f"}, {file = "anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f"},
@@ -68,7 +116,7 @@ version = "3.0.2"
description = "Annotate AST trees with source code positions" description = "Annotate AST trees with source code positions"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933"}, {file = "asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933"},
{file = "asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2"}, {file = "asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2"},
@@ -78,6 +126,18 @@ files = [
astroid = ["astroid (>=2,<5)"] astroid = ["astroid (>=2,<5)"]
test = ["astroid (>=2,<5)", "pytest (<9.0)", "pytest-cov", "pytest-xdist"] test = ["astroid (>=2,<5)", "pytest (<9.0)", "pytest-cov", "pytest-xdist"]
[[package]]
name = "attrs"
version = "26.1.0"
description = "Classes Without Boilerplate"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"},
{file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"},
]
[[package]] [[package]]
name = "bcrypt" name = "bcrypt"
version = "5.0.0" version = "5.0.0"
@@ -161,7 +221,7 @@ version = "2026.7.22"
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.7" python-versions = ">=3.7"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"}, {file = "certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775"},
{file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"}, {file = "certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55"},
@@ -173,7 +233,7 @@ version = "3.4.9"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a"}, {file = "charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a"},
{file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616"}, {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616"},
@@ -291,12 +351,116 @@ version = "0.4.6"
description = "Cross-platform colored terminal text." description = "Cross-platform colored terminal text."
optional = false optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main"] groups = ["main", "dev"]
markers = "sys_platform == \"win32\" or platform_system == \"Windows\""
files = [ files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
markers = {main = "platform_system == \"Windows\"", dev = "sys_platform == \"win32\""}
[[package]]
name = "coverage"
version = "7.15.2"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "coverage-7.15.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d"},
{file = "coverage-7.15.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846"},
{file = "coverage-7.15.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf"},
{file = "coverage-7.15.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376"},
{file = "coverage-7.15.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd"},
{file = "coverage-7.15.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb"},
{file = "coverage-7.15.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1"},
{file = "coverage-7.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9"},
{file = "coverage-7.15.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a"},
{file = "coverage-7.15.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287"},
{file = "coverage-7.15.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89"},
{file = "coverage-7.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88"},
{file = "coverage-7.15.2-cp310-cp310-win32.whl", hash = "sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443"},
{file = "coverage-7.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629"},
{file = "coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036"},
{file = "coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660"},
{file = "coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589"},
{file = "coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee"},
{file = "coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0"},
{file = "coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487"},
{file = "coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f"},
{file = "coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1"},
{file = "coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5"},
{file = "coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0"},
{file = "coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad"},
{file = "coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db"},
{file = "coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9"},
{file = "coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688"},
{file = "coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934"},
{file = "coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9"},
{file = "coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73"},
{file = "coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d"},
{file = "coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b"},
{file = "coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296"},
{file = "coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6"},
{file = "coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098"},
{file = "coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a"},
{file = "coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b"},
{file = "coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2"},
{file = "coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440"},
{file = "coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e"},
{file = "coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd"},
{file = "coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40"},
{file = "coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3"},
{file = "coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8"},
{file = "coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1"},
{file = "coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578"},
{file = "coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1"},
{file = "coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6"},
{file = "coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7"},
{file = "coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d"},
{file = "coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026"},
{file = "coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa"},
{file = "coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d"},
{file = "coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b"},
{file = "coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188"},
{file = "coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050"},
{file = "coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c"},
{file = "coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b"},
{file = "coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a"},
{file = "coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2"},
{file = "coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138"},
{file = "coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f"},
{file = "coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984"},
{file = "coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7"},
{file = "coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3"},
{file = "coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee"},
{file = "coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1"},
{file = "coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a"},
{file = "coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145"},
{file = "coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446"},
{file = "coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243"},
{file = "coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc"},
{file = "coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635"},
{file = "coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be"},
{file = "coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072"},
{file = "coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328"},
{file = "coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a"},
{file = "coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0"},
{file = "coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5"},
{file = "coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743"},
{file = "coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c"},
{file = "coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071"},
{file = "coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a"},
{file = "coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d"},
{file = "coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c"},
{file = "coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688"},
{file = "coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199"},
{file = "coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658"},
{file = "coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c"},
{file = "coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d"},
]
[package.extras]
toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]] [[package]]
name = "decorator" name = "decorator"
@@ -304,7 +468,7 @@ version = "5.3.1"
description = "Decorators for Humans" description = "Decorators for Humans"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c"}, {file = "decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c"},
{file = "decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82"}, {file = "decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82"},
@@ -316,7 +480,7 @@ version = "0.7.1"
description = "XML bomb protection for Python stdlib modules" description = "XML bomb protection for Python stdlib modules"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"},
{file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
@@ -384,7 +548,7 @@ version = "2.2.1"
description = "Get the currently executing AST node of a frame, and other information" description = "Get the currently executing AST node of a frame, and other information"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"}, {file = "executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017"},
{file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"}, {file = "executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4"},
@@ -419,92 +583,91 @@ standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[stand
[[package]] [[package]]
name = "greenlet" name = "greenlet"
version = "3.5.3" version = "3.5.4"
description = "Lightweight in-process concurrent programming" description = "Lightweight in-process concurrent programming"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["main"]
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\""
files = [ files = [
{file = "greenlet-3.5.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c180d22d325fb613956b443c3c6f4406eb70e6defc70d3974da2a7b59e06f48c"}, {file = "greenlet-3.5.4-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:ac5bf81d79d2c8eeb2ef6359b2e1687a1e9ebf46c2b1f970da9a9255df51d190"},
{file = "greenlet-3.5.3-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:483d08c11181c83a6ce1a7a61df0f624a208ec40817a3bb2302714592eee4f04"}, {file = "greenlet-3.5.4-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89f3738167bab8c1084b94e23023d41d247117ac149fa0fbcb5bd4cf6262b353"},
{file = "greenlet-3.5.3-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1dae6e0091eae084317e411f047f0b7cb241c6db570f7c45fd6b900a274914ce"}, {file = "greenlet-3.5.4-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e9a5e3406e3ed8125ae1a3b37c12f3434e2b1f0fa053197c5557895b4fb09606"},
{file = "greenlet-3.5.3-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0f6ff50ff8dbd51fae9b37f4101648b04ea0df19b3f50ab2beb5061e7716a5c8"}, {file = "greenlet-3.5.4-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a2d614cb2372c7101a12ea8b96dd56f81c986d247c5a73db67063f3ed1ca4a52"},
{file = "greenlet-3.5.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bcd2d72ccd70a1ec68ba6ef93e7fbb4420ef9997dabc7010d893bd4015e0bec"}, {file = "greenlet-3.5.4-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ab9f0704bccf6d3b38e0d2130b7b33271cff11453690da074fa280c3aa8e8e7"},
{file = "greenlet-3.5.3-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:37bf9c538f5ae6e63d643f88dec37c0c83bdf0e2ebc62961dedcf458822f7b71"}, {file = "greenlet-3.5.4-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:188e4d142f243051d92a1f5c244a741da02dddc070a0620c842804d7b56d008c"},
{file = "greenlet-3.5.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:73f152c895e09907e0dbe24f6c2db37beb085cd63db91c3825a0fcd0064124a8"}, {file = "greenlet-3.5.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2cdaadc3d31445a8f782bde3cd37e49a2c2a9c6da6daf76a3e34c683b271a3c7"},
{file = "greenlet-3.5.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8bdb43e1a1d1873721acab2be99c5befd4d2044ddfd52e4d610801019880a702"}, {file = "greenlet-3.5.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:70bdfacdc183dac838b2a0aaff2dd6134a457c52fe68a9c6bbab435483d2b9df"},
{file = "greenlet-3.5.3-cp310-cp310-win_amd64.whl", hash = "sha256:0909f9355a9f24845d3299f3112e266a06afb68302041989fd26bd68894933db"}, {file = "greenlet-3.5.4-cp310-cp310-win_amd64.whl", hash = "sha256:69173331fbc5d64bfac0065d7e22c39cfcd089e9b18d125bdcd5079363b09616"},
{file = "greenlet-3.5.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:aca9b4ce85b152b5524ef7d88170efdff80dc0032aa8b75f9aaf7f3479ea95b4"}, {file = "greenlet-3.5.4-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e883de250e299654b1f1680f72a1a9f9ba62c9bd1bce84099c90657349a8dfbb"},
{file = "greenlet-3.5.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f71be4920368fe1fabeeaa53d1e3548337e2b223d9565f8ad5e392a75ba23fc"}, {file = "greenlet-3.5.4-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32802705c2c1ff25e8237b3bdacf2594fa02be80af8a66703eb7853ea7e68686"},
{file = "greenlet-3.5.3-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d77e67f65f98449e3fb83f795b5d0a8437aead2f874ca89c96576caf4be3af6"}, {file = "greenlet-3.5.4-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57aa201b351f7c7c75627c60d29e4d5b97a07d37efeb62b903466fca42c097d7"},
{file = "greenlet-3.5.3-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e18619ba655ac05d78d80fc83cac4ba892bd6927b99e3b8237aee861aaacc8bb"}, {file = "greenlet-3.5.4-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9667862a2e38ad379f11b845daeda22c8989186def44f06962c9c4c05e556da7"},
{file = "greenlet-3.5.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8540f1e6205bd13ca0ce685581037219ca54a1b41a0a15d228c6c9b8ad5903d7"}, {file = "greenlet-3.5.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c3fe76c2cac86b4f7a1e92865ac0a54384deb05c92986287c1a7110d9bd53071"},
{file = "greenlet-3.5.3-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:d27c0c653a60d9535f690226474a5cc1036a8b0d7b57504d1c4f89c44a07a80c"}, {file = "greenlet-3.5.4-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:ae53534b5dec0f4c2ec26f898f538dc8ea1ca3ef2927d597a9439e40a09da937"},
{file = "greenlet-3.5.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ef56fe650f50575bf843acde967b9c567687f3c22340941a899b7bc56e956a8"}, {file = "greenlet-3.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1e1a4a684b16c45ba324e60b32a4386a87722bcb815d2a149d2182f9b401ca72"},
{file = "greenlet-3.5.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5121af01cf911e70056c00d4b46d5e9b5d1415550038573d744138bacb59e6b8"}, {file = "greenlet-3.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e849e6e139b9671adeac505f72fc05f4af7fd1921faef40295e214fc3b361b59"},
{file = "greenlet-3.5.3-cp311-cp311-win_amd64.whl", hash = "sha256:0f41e4a05a3c0cb31b17023eff28dd111e1d16bf7d7d00406cd7df23f31398a7"}, {file = "greenlet-3.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:dc418cf4c873357964d6624445ed09472e50def990c65dd4e76fc3ba8cd9cef6"},
{file = "greenlet-3.5.3-cp311-cp311-win_arm64.whl", hash = "sha256:ec6f1af59f6b5f3fc9678e2ea062d8377d22ac644f7844cb7a292910cf12ff44"}, {file = "greenlet-3.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:c38c902a0986eba1f6e7ba1ab39ad5195926abde90f3fe080e08212db62176da"},
{file = "greenlet-3.5.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:719757059f5a53fd0dde23f78cffeafcdd97b21c850ddb7ca684a3c1a1f122e2"}, {file = "greenlet-3.5.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3d66250e8b09f182ede05490998c818b5961f7a3640332d44c4927caec7bbfe4"},
{file = "greenlet-3.5.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:efa9f765dd09f9d0cdac651ffdf631ee59ec5dc6ee7a73e0c012ba9c52fbdf5b"}, {file = "greenlet-3.5.4-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90e930c9c192e5b3ee9fb8bcd920ea3926155e2e3ded39fc697323addecee17"},
{file = "greenlet-3.5.3-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7faba15ac005376e02a0384504e0243be3370ce010296a44a820feb342b505ab"}, {file = "greenlet-3.5.4-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:791fdfeeb9c6e0c7b10fa151bf110d2a6974866f13dcb5b1c7efae698245893a"},
{file = "greenlet-3.5.3-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5795cd1101371140551c645f2d408b8d3c01a5a29cf8a9bce6e759c983682d23"}, {file = "greenlet-3.5.4-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b7c895310363f310361e0fe2072af85269d2a2a285cd04c0c59e79a5e3670dcf"},
{file = "greenlet-3.5.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:87142215824be6ac05e2e8e2786eec307ccbc27c36723c3881959df654af6861"}, {file = "greenlet-3.5.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f88193799d43dbf8c8a806d6405c9c52fe2af40bf75072a606357b33cc336c7f"},
{file = "greenlet-3.5.3-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:af4923b3096e26a36d7e9cf24ab88083a20f97d191e3b97f253731ce9b41b28c"}, {file = "greenlet-3.5.4-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:13b980043cb1b3134e81ea469da1250ddcc6bfe6d245bbaa59168d9cdc8f228f"},
{file = "greenlet-3.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215275b1b49320987352e6c1b054acca0064f965a2c66992bed9a6f7d913f149"}, {file = "greenlet-3.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7a5f095767c4493afcd06067f2bb3b8716e3f3f9e92b99c88e7e99f885b3d4d"},
{file = "greenlet-3.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6b1b0eed82364b0e32c4ea0f221452d33e6bb17ae094d9f72aed9851812747ea"}, {file = "greenlet-3.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:42afdc1ab5f66da8c586c32af9224a74a706b4f0ea0dc3a4188a0860a09c65c9"},
{file = "greenlet-3.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:cde8adafa2365676f74a979744629589999093bc86e2484214f58e61df08902c"}, {file = "greenlet-3.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:60149df8f462d1b230038e6590c23c3b4768bb5d6c022b3b6e82532b34b0b8a3"},
{file = "greenlet-3.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4e7b79d83805475f0102008843f6eb45fd3bb0b2e88c774adab5fbaab27117d"}, {file = "greenlet-3.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:77d6ce04fed0d9aeed42e0f37923cc43eba9b027bdd9c34546bb4ccd143d0fe0"},
{file = "greenlet-3.5.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:c8d87c2134d871df96ecdea9cec7cbaab286dadab0f56476e57aaf9e8ac11550"}, {file = "greenlet-3.5.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:24e61b88cb7e1b1d794b32a10cc346ac779681d6d74ff137a3e0a444d2bf1f02"},
{file = "greenlet-3.5.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2d185dd1621757e70c3861cceffd5317ab4e7ed7eb09c82994828468527ade5"}, {file = "greenlet-3.5.4-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:870d730fec833f5a06906a32596cc099b9161594642a92a520b7a88911c95356"},
{file = "greenlet-3.5.3-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c514a468149bf8fbbab874188a3535cd8a48a3e353eb53a3d424296f8dbacd3"}, {file = "greenlet-3.5.4-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec5ff0d1878df6af3bf9b638a5a92a7d5693291de77c91bff10fa48519c604ef"},
{file = "greenlet-3.5.3-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ad04dd75458c6300b047c61b8639092433d205a25a14e310d6582a480efcca1"}, {file = "greenlet-3.5.4-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:07bd44616608d873d06735b63ef1a88191d6ca57c8d291d6559c71bc14c0893c"},
{file = "greenlet-3.5.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:915f887cf2682b66419b879423a2e072634aa7b7dce6f3ada4957cfced3f1e9a"}, {file = "greenlet-3.5.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d84d993f6e575c950d91a23c1345d18fe1a4310d447bf630849d7809196b52f0"},
{file = "greenlet-3.5.3-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:afaabdd554cd7ae9bbb3ca070b0d7fdfd207dbf1d16865f7233837709d354bda"}, {file = "greenlet-3.5.4-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:3529a8a933582ad19e224792cac7372489526576b75b4c124e8e4f29948f4861"},
{file = "greenlet-3.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:766cfd421c13e450feb340cd472a3ed9957d438727b7b4593ad7c76c5d2b0deb"}, {file = "greenlet-3.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:58023945f421093de5e6fa108c0985a8659d43f49e0216da25099369a121bcbd"},
{file = "greenlet-3.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2ecda9ec22edf38fa389369eaed8c3d37c05f3c54e69f69438dbb2cc1de1458b"}, {file = "greenlet-3.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bae2728e1897aa8df8cb1af38cd48b3a743aefe29372de7b8b7a9f532501e69f"},
{file = "greenlet-3.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:c82304750f057167ff60d188df1d0cc1764ce9567eadf03e6a7443bcedd0b30b"}, {file = "greenlet-3.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:ca5726c0b08ca35ae873557266a78b2c3f3b2b7d7401aa5ff886c2045dd0111c"},
{file = "greenlet-3.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:dc133a1569ee667b2a6ef56ce551084aeefd87a5acbc4736d336d1e2edc6cfc4"}, {file = "greenlet-3.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:7c1303791d603080cac6fc3b34df51c3b75b723739c282c8029e48a0d241672f"},
{file = "greenlet-3.5.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:fd2e02fa07485778536a036222d616ab957b1d533f36b3ed98ce725d9c9d3117"}, {file = "greenlet-3.5.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:7e8afa5eac028f8140ceafe5ceec66e6aa127ddcb21452d2a564dcd2900b5f22"},
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df0a0628d1597eb0897b62f55d1343f772405fd25f3b2a796c76874b0c2e22e8"}, {file = "greenlet-3.5.4-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:73b37afe369021423ea53dd3123e04bffa7e93ac64429b9f50835b2e4fcae7cf"},
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ebd933a6adabc298bab47731a130fe6bfb888bd934eee37810f151159544540d"}, {file = "greenlet-3.5.4-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3ef964f56dfcb6f9bbef2a190d9126795eac408716aeae47b5e7c73c32aafca9"},
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8d19fe6c39ebff9259f07bcc685d3290f8fa4ea2278e51dd0008e4d6b0f2d814"}, {file = "greenlet-3.5.4-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cef589bc65fae02d10bca2ac341191c5b33acc2967892ebf4fcbd10eabb7a74c"},
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b9d501b40e80b70e32323c799dd9b420a5577a9601469d362ae1ffb690f3a7c"}, {file = "greenlet-3.5.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c53ff01a5c53a40f2c16820ebc56d7c61a77f5fbe009dadd96292d5682f80f8"},
{file = "greenlet-3.5.3-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:962c5df2db8cb446da51edf1ca5296c389d93b99c9d8aa2ee4c7d0d8f1218260"}, {file = "greenlet-3.5.4-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:dfc41ae893d9ceaf22c824f2153a88b30651b20e8758c2cd9ac143f23640563c"},
{file = "greenlet-3.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a1fad1d11e7d6aab184107baa8e4ece11ccba3ec9599cd7efa5ff4d70d43256a"}, {file = "greenlet-3.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecca4d80d55a01ad6b23b33262662956149fbb7b2c6be2910f1705921958cbf3"},
{file = "greenlet-3.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fad5aec764399f1b5cc347ad250a59660f20c8f8888ea6bae1f93b769cce1154"}, {file = "greenlet-3.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffbc533e0eaf8e80d8471411646ab88fe58f641d508c0b02b24494479f4d9ec"},
{file = "greenlet-3.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:7669aa24cf2a1041d6f7899575b494a3ab4cf68bfcc8609b1dc0be7272db835e"}, {file = "greenlet-3.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:305f69e6c4523d7f6979ed001cff4e5853c063e5da04880296603aa0227e544c"},
{file = "greenlet-3.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:5b4807c4082c9d1b6d9eed56fcd041863e37f2228106eef24c30ca096e238605"}, {file = "greenlet-3.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:f260930bbbbcf9caee661211235a5111c86dfe5832fdf6ae4570da1e0995320f"},
{file = "greenlet-3.5.3-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:271a8ea7c1024e8a0d7dd2be66dd66dda8a07193f41a17b9e924f7600f5b62be"}, {file = "greenlet-3.5.4-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:41ddab54e4b238f4a6c323f39b4e59e176affd5a94d461a9fb7583dac74240a3"},
{file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19131729ae0ddc3c2e1ef85e650169b5e37ee32e400f215f78b94d7b0d567310"}, {file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3dabe3e2809013052c68bdf0b7fa5f5f2859c43a80803131ad61af9cabd7867"},
{file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1540dd8e5fc2a5aec40fbb98ef8e149fa47c89a4b4a1cf2575a14d3d1869d7a8"}, {file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:27d3f00718634d4520a3a150154ac5da36f257869d41321953375b90bfbbc72c"},
{file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b897d97759425953f69a9c0fac67f8fe333ec0ce7377ef186fb2b0c3ad5e354d"}, {file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39169a11d87a6a263afda3e9a27d1df16d0f919d40a4837cc73986c9884c0dd8"},
{file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e81fa194a1d20967877bdf9c7794db2bc99063e5be36aee710c08f04c5bb087f"}, {file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbd60b5763c6543c1827e48faaf14ea9bfbad245f52b1a4d76a2a2d8884c6c66"},
{file = "greenlet-3.5.3-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:3236754d423955ea08e9bb5f6c04a7895f9e22c290b66aa7653fcb922d839eb0"}, {file = "greenlet-3.5.4-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:bd3d1145f603b2db19feb9078c2e6855eb7c67e15580c010ed815cee519b86fd"},
{file = "greenlet-3.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55cf4d777485d43110e47133cbba6d74a8885a87ec1227ef0267f9ee80c5aa21"}, {file = "greenlet-3.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00f910f0e7b35416c63b23ad78b769aeccfc1775f712b43c4ee525624a2eef7"},
{file = "greenlet-3.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:12a248ba75f6a9a236375f52296c498c89ff1d8badf32deb9eca7abd5853f7da"}, {file = "greenlet-3.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:91c26423753b92caf41ab3f98fd547d7374d4d9fc2d85be041886c1579d9255e"},
{file = "greenlet-3.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:efc6bd60ea02e085862c74a3ef64b147ffc6f1a5ea7d9f26e7a939943f68c1e3"}, {file = "greenlet-3.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:ee032b91fd8ec29ec6c4cea2b8c561b178435134bd0752c7334b94e9c736c132"},
{file = "greenlet-3.5.3-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:ea03f2f04367845d6b58eeed276e1e56e51f0b97d8ad5a88a7d20a91dc9056cc"}, {file = "greenlet-3.5.4-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:178111881dd7a6c946471fda85485ec796e1043c2b939f694b096e2ecf986809"},
{file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78dbef602fda6d97d957eb7937f70c9ce9e9527330347f8f6b6f9e554a9e7a47"}, {file = "greenlet-3.5.4-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d92df08dd65fede97fc37aad36c2e9dcda3b31c467f8e0c2c096456cb818e927"},
{file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f73857adb8fee13fa56c172bd11262f888c0c648f9fea113e777bb2c7904a81"}, {file = "greenlet-3.5.4-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99e8f8c4ebc4fd80aa26c1280ae9ad43a0976e786349703a181cf0bae60413e5"},
{file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cefa9cef4b371f9844c6053db71f1138bc6807bab1578b0dae5149c1f1141357"}, {file = "greenlet-3.5.4-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1f17e362d78e37559e0506c5a7d066bdd45073c36a0127a543e8a0df27242ff3"},
{file = "greenlet-3.5.3-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:232fec92e823addaf02d9472cf7381e24a1d046a6ced1103c5caa4c21b9dfc1d"}, {file = "greenlet-3.5.4-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:394de08dad5ffcb1f50c2159d93e398d9d2da3ed437645eaa54771fa720db9f0"},
{file = "greenlet-3.5.3-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:6219b6d04dbf6ba6084d77dc609e8473060dc55f759cbf626d512122781fa128"}, {file = "greenlet-3.5.4-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:cd320d998cbaa032932830448e39abf3c6a12901295e386e8114db926e10cffb"},
{file = "greenlet-3.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:2421c3564da9429d5586d46ca31ebb26516b5498a802cf65c041a8e8a8980d34"}, {file = "greenlet-3.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:c883d61f2282d72c767a14936641b3efcbde9d82f1080712aaea0b1d3126cb88"},
{file = "greenlet-3.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e0f0d160f0b2e558e6c75f7930967183255dc9735e5f5b8cae58ee09c9576d8b"}, {file = "greenlet-3.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2a924f15d17957e252a810acefcb5942f5ca712298e8b6fcaed9a307d357522c"},
{file = "greenlet-3.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:dd99329bbc15ca78dcc583dba05d0b1b0bae01ab6c2174989f5aaee3e41ac930"}, {file = "greenlet-3.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ed17e5f3420360d5b459de8462efb52060399a5326a613d4cde31cef63ef95da"},
{file = "greenlet-3.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:499fef2acede88c1864a57bb586b4bf533c81e1b82df7ab93451cdb47dfec227"}, {file = "greenlet-3.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:f908898d6fa484ce4b6f447ce70ea99b52c503fee419e53cf74d60a16bc9e667"},
{file = "greenlet-3.5.3-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:176bc16a721fa5fc294d70b87b4dfa5fbdd251b3da5d5372735ecef9bd7d6d0c"}, {file = "greenlet-3.5.4-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:1833637f17d5e7472548a48575c394fe39f1b1890d676d162d86593610f44d8c"},
{file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:629b614d2b786e89c50440e246f33eea78f58a962d0bdbbcc809e6d13605903f"}, {file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12cda9122e03341f1cb6b8207a19d7a9d375e52f1b4e9243918375f40fd7b4b9"},
{file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b2e857ae16f5f72142edf75f9f176fe7526ba19a2841df1420516f83831c9f2"}, {file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d83ae0e32d14957ab7170785a20f582635c8474deab1bfbb552b17e769a6ce25"},
{file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d192579ed281051396dddd7f7754dac6259e6b1fb26378c87b66622f8e3f91"}, {file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:123aa379c962ed5fe90a880327e0c3066124ac64ec99e12a238be9fd8eb3db3d"},
{file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e515757e2e36bcbf1fad09a46e1557e8b1ae1797d4b44d09da7deed88ad28608"}, {file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f1467de1bb767f75db0aa34c195e3a496d8d1278c796e70c24ce205d3e99cde"},
{file = "greenlet-3.5.3-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:4399eb8d041f20b68d943918bc55502a93d6fdc0a37c14da7881c04139acee9d"}, {file = "greenlet-3.5.4-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:adf2244d7f69409925a8f22ed22cc5f93cdfe5c9dc87ff3476be2c2aaae61a05"},
{file = "greenlet-3.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b363d46ed1ea431825fdb01471bb024fc08399bad1572a616e853c7684415adb"}, {file = "greenlet-3.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0fa53040b78b578120eecdc0265e3f1051487cc425d11a2b7c761daadf4feaa8"},
{file = "greenlet-3.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e44da2f5bbdaabaf7d80b73dbb430c7035771e9f244e3c8b769715c9d8fa0a16"}, {file = "greenlet-3.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:60e0bc961d367df506660e9ac0177a76bc6d81305300704b0977d1634f76efe2"},
{file = "greenlet-3.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:8ff8bed3e3baa20a3ea261ce00526f1898ad4801d4886fd2220580ee0ad8fadf"}, {file = "greenlet-3.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:f680e549edb3eaf21eea4e7fe101e15ec180c74b7879ab46adc080f22d4015d2"},
{file = "greenlet-3.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:b7068bd09f761f3f5b4d214c2bed063186b2a86148c740b3873e3f56d79bac31"}, {file = "greenlet-3.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:08fc36de8442d5c3e95b044550dbea9bf144d31ec0cc58e36fb241cb6ef6a994"},
{file = "greenlet-3.5.3.tar.gz", hash = "sha256:a61efc018fd3eb317eeca31aba90ee9e7f26f22884a79b6c6ec715bf71bb62f1"}, {file = "greenlet-3.5.4.tar.gz", hash = "sha256:0232ae1de90a8e07867bb127d7a6ba2301e859145489f25cda8a6096dabe1d20"},
] ]
[package.extras] [package.extras]
@@ -540,19 +703,41 @@ version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
] ]
[[package]]
name = "httpcore"
version = "1.0.9"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.16"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<1.0)"]
[[package]] [[package]]
name = "httpie" name = "httpie"
version = "3.2.4" version = "3.2.4"
description = "HTTPie: modern, user-friendly command-line HTTP client for the API era." description = "HTTPie: modern, user-friendly command-line HTTP client for the API era."
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "httpie-3.2.4-py3-none-any.whl", hash = "sha256:4bd0435cc4b9bca59501bc65089de96f3e93b393803f32a81951db62050ebf0b"}, {file = "httpie-3.2.4-py3-none-any.whl", hash = "sha256:4bd0435cc4b9bca59501bc65089de96f3e93b393803f32a81951db62050ebf0b"},
{file = "httpie-3.2.4.tar.gz", hash = "sha256:302ad436c3dc14fd0d1b19d4572ef8d62b146bcd94b505f3c2521f701e2e7a2a"}, {file = "httpie-3.2.4.tar.gz", hash = "sha256:302ad436c3dc14fd0d1b19d4572ef8d62b146bcd94b505f3c2521f701e2e7a2a"},
@@ -574,13 +759,39 @@ setuptools = "*"
dev = ["Jinja2", "flake8", "flake8-comprehensions", "flake8-deprecated", "flake8-mutable", "flake8-tuple", "pyopenssl", "pytest", "pytest-cov", "pytest-httpbin (>=0.0.6)", "pytest-mock", "pyyaml", "responses", "twine", "werkzeug (<2.1.0)", "wheel"] dev = ["Jinja2", "flake8", "flake8-comprehensions", "flake8-deprecated", "flake8-mutable", "flake8-tuple", "pyopenssl", "pytest", "pytest-cov", "pytest-httpbin (>=0.0.6)", "pytest-mock", "pyyaml", "responses", "twine", "werkzeug (<2.1.0)", "wheel"]
test = ["pytest", "pytest-httpbin (>=0.0.6)", "pytest-mock", "responses", "werkzeug (<2.1.0)"] test = ["pytest", "pytest-httpbin (>=0.0.6)", "pytest-mock", "responses", "werkzeug (<2.1.0)"]
[[package]]
name = "httpx"
version = "0.28.1"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
httpcore = "==1.*"
idna = "*"
socksio = {version = "==1.*", optional = true, markers = "extra == \"socks\""}
[package.extras]
brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.18" version = "3.18"
description = "Internationalized Domain Names in Applications (IDNA)" description = "Internationalized Domain Names in Applications (IDNA)"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"},
{file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"},
@@ -595,7 +806,7 @@ version = "2.3.0"
description = "brain-dead simple config-ini parsing" description = "brain-dead simple config-ini parsing"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
@@ -607,7 +818,7 @@ version = "9.15.0"
description = "IPython: Productive Interactive Computing" description = "IPython: Productive Interactive Computing"
optional = false optional = false
python-versions = ">=3.11" python-versions = ">=3.11"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e"}, {file = "ipython-9.15.0-py3-none-any.whl", hash = "sha256:515ad9c3cdf0c932a5a9f6245419e8aba706b7bd03c3e1d3a1c83d9351d6aa6e"},
{file = "ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756"}, {file = "ipython-9.15.0.tar.gz", hash = "sha256:da2819ce2aa83135257df830660b1176d986c3d2876db24df01974fa955b2756"},
@@ -640,7 +851,7 @@ version = "1.1.1"
description = "Defines a variety of Pygments lexers for highlighting IPython code." description = "Defines a variety of Pygments lexers for highlighting IPython code."
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c"}, {file = "ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c"},
{file = "ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81"}, {file = "ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81"},
@@ -655,7 +866,7 @@ version = "0.20.0"
description = "An autocompletion tool for Python that can be used for text editors." description = "An autocompletion tool for Python that can be used for text editors."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67"}, {file = "jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67"},
{file = "jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011"}, {file = "jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011"},
@@ -694,7 +905,7 @@ version = "4.2.0"
description = "Python port of markdown-it. Markdown parsing, done right!" description = "Python port of markdown-it. Markdown parsing, done right!"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"}, {file = "markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a"},
{file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"}, {file = "markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49"},
@@ -817,7 +1028,7 @@ version = "0.2.2"
description = "Inline Matplotlib backend for Jupyter" description = "Inline Matplotlib backend for Jupyter"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6"}, {file = "matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6"},
{file = "matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79"}, {file = "matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79"},
@@ -835,7 +1046,7 @@ version = "0.1.2"
description = "Markdown URL utilities" description = "Markdown URL utilities"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
{file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
@@ -847,7 +1058,7 @@ version = "6.7.1"
description = "multidict implementation" description = "multidict implementation"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"}, {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5"},
{file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"}, {file = "multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8"},
@@ -1057,7 +1268,7 @@ version = "26.2"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["main", "dev"]
files = [ files = [
{file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"}, {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
{file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"}, {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
@@ -1161,7 +1372,7 @@ version = "0.8.7"
description = "A Python Parser" description = "A Python Parser"
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c"}, {file = "parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c"},
{file = "parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1"}, {file = "parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1"},
@@ -1177,7 +1388,7 @@ version = "4.9.0"
description = "Pexpect allows easy control of interactive console applications." description = "Pexpect allows easy control of interactive console applications."
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"] groups = ["dev"]
markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""
files = [ files = [
{file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"}, {file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"},
@@ -1193,7 +1404,7 @@ version = "26.1.2"
description = "The PyPA recommended tool for installing Python packages." description = "The PyPA recommended tool for installing Python packages."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab"}, {file = "pip-26.1.2-py3-none-any.whl", hash = "sha256:382ff9f685ee3bc25864f820aa50505825f10f5458ffff07e30a6d96e5715cab"},
{file = "pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605"}, {file = "pip-26.1.2.tar.gz", hash = "sha256:f49cd134c61cf2fd75e0ce2676db03e4054504a5a4986d00f8299ae632dc4605"},
@@ -1205,7 +1416,7 @@ version = "1.6.0"
description = "plugin and hook calling mechanisms for python" description = "plugin and hook calling mechanisms for python"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
@@ -1221,7 +1432,7 @@ version = "3.0.52"
description = "Library for building powerful interactive command lines in Python" description = "Library for building powerful interactive command lines in Python"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"}, {file = "prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955"},
{file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"}, {file = "prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855"},
@@ -1236,7 +1447,7 @@ version = "7.2.2"
description = "Cross-platform lib for process and system monitoring." description = "Cross-platform lib for process and system monitoring."
optional = false optional = false
python-versions = ">=3.6" python-versions = ">=3.6"
groups = ["main"] groups = ["dev"]
markers = "sys_platform != \"emscripten\" and sys_platform != \"cygwin\"" markers = "sys_platform != \"emscripten\" and sys_platform != \"cygwin\""
files = [ files = [
{file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"}, {file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"},
@@ -1272,7 +1483,7 @@ version = "0.7.0"
description = "Run a subprocess in a pseudo terminal" description = "Run a subprocess in a pseudo terminal"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"] groups = ["dev"]
markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\"" markers = "sys_platform != \"win32\" and sys_platform != \"emscripten\""
files = [ files = [
{file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"}, {file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
@@ -1285,7 +1496,7 @@ version = "0.2.3"
description = "Safely evaluate AST nodes without side effects" description = "Safely evaluate AST nodes without side effects"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"}, {file = "pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0"},
{file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"},
@@ -1492,7 +1703,7 @@ version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python." description = "Pygments is a syntax highlighting package written in Python."
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"}, {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
{file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"}, {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
@@ -1507,7 +1718,7 @@ version = "1.7.1"
description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information."
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"},
{file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"},
@@ -1520,7 +1731,7 @@ version = "9.1.1"
description = "pytest: simple powerful testing with Python" description = "pytest: simple powerful testing with Python"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"}, {file = "pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c"},
{file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"}, {file = "pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313"},
@@ -1536,6 +1747,63 @@ pygments = ">=2.7.2"
[package.extras] [package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "1.4.0"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.10"
groups = ["dev"]
files = [
{file = "pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1"},
{file = "pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42"},
]
[package.dependencies]
pytest = ">=8.4,<10"
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1)", "sphinx-tabs (>=3.5)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-cov"
version = "7.1.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678"},
{file = "pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2"},
]
[package.dependencies]
coverage = {version = ">=7.10.6", extras = ["toml"]}
pluggy = ">=1.2"
pytest = ">=7"
[package.extras]
testing = ["process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "pytest-mock"
version = "3.15.1"
description = "Thin-wrapper around the mock package for easier use with pytest"
optional = false
python-versions = ">=3.9"
groups = ["dev"]
files = [
{file = "pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d"},
{file = "pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f"},
]
[package.dependencies]
pytest = ">=6.2.5"
[package.extras]
dev = ["pre-commit", "pytest-asyncio", "tox"]
[[package]] [[package]]
name = "python-dateutil" name = "python-dateutil"
version = "2.9.0.post0" version = "2.9.0.post0"
@@ -1607,7 +1875,7 @@ version = "2.34.2"
description = "Python HTTP for Humans." description = "Python HTTP for Humans."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
{file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
@@ -1624,13 +1892,31 @@ urllib3 = ">=1.26,<3"
socks = ["PySocks (>=1.5.6,!=1.5.7)"] socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
[[package]]
name = "requests-async"
version = "0.2.4"
description = "Simple async HTTP client with requests-like interface, powered by httpx"
optional = false
python-versions = ">=3.7"
groups = ["dev"]
files = [
{file = "requests_async-0.2.4-py3-none-any.whl", hash = "sha256:a967efb84eb03a2053a847b1bfbac5917df8100d101368d883e20ae9ab15f7a2"},
{file = "requests_async-0.2.4.tar.gz", hash = "sha256:7eaa42cbfe4d0f1a5f1ef78625c625248cac5c5323afcbd76e7a2a26b85b56b9"},
]
[package.dependencies]
httpx = {version = ">=0.23.0", extras = ["socks"]}
[package.extras]
dev = ["pytest (>=7.0.0)", "pytest-asyncio (>=0.21.0)", "pytest-cov (>=4.0.0)"]
[[package]] [[package]]
name = "requests-toolbelt" name = "requests-toolbelt"
version = "1.0.0" version = "1.0.0"
description = "A utility belt for advanced users of python-requests" description = "A utility belt for advanced users of python-requests"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"},
{file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"},
@@ -1645,7 +1931,7 @@ version = "15.0.0"
description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
optional = false optional = false
python-versions = ">=3.9.0" python-versions = ">=3.9.0"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"}, {file = "rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb"},
{file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"}, {file = "rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36"},
@@ -1695,7 +1981,7 @@ version = "83.0.0"
description = "Most extensible Python build backend with support for C/C++ extension modules" description = "Most extensible Python build backend with support for C/C++ extension modules"
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3"}, {file = "setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3"},
{file = "setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef"}, {file = "setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef"},
@@ -1722,6 +2008,18 @@ files = [
{file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"},
] ]
[[package]]
name = "socksio"
version = "1.0.0"
description = "Sans-I/O implementation of SOCKS4, SOCKS4A, and SOCKS5."
optional = false
python-versions = ">=3.6"
groups = ["dev"]
files = [
{file = "socksio-1.0.0-py3-none-any.whl", hash = "sha256:95dc1f15f9b34e8d7b16f06d74b8ccf48f609af32ab33c608d08761c5dcbb1f3"},
{file = "socksio-1.0.0.tar.gz", hash = "sha256:f88beb3da5b5c38b9890469de67d0cb0f9d494b78b106ca1845f96c10b91c4ac"},
]
[[package]] [[package]]
name = "sqlalchemy" name = "sqlalchemy"
version = "2.0.51" version = "2.0.51"
@@ -1825,7 +2123,7 @@ version = "0.6.3"
description = "Extract data from python stack frames and tracebacks for informative displays" description = "Extract data from python stack frames and tracebacks for informative displays"
optional = false optional = false
python-versions = "*" python-versions = "*"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"}, {file = "stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695"},
{file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"}, {file = "stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9"},
@@ -1863,7 +2161,7 @@ version = "5.15.1"
description = "Traitlets Python configuration system" description = "Traitlets Python configuration system"
optional = false optional = false
python-versions = ">=3.9" python-versions = ">=3.9"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92"}, {file = "traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92"},
{file = "traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722"}, {file = "traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722"},
@@ -1919,7 +2217,7 @@ version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more." description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false optional = false
python-versions = ">=3.10" python-versions = ">=3.10"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
@@ -1956,7 +2254,7 @@ version = "0.8.2"
description = "Measures the displayed width of unicode strings in a terminal" description = "Measures the displayed width of unicode strings in a terminal"
optional = false optional = false
python-versions = ">=3.8" python-versions = ">=3.8"
groups = ["main"] groups = ["dev"]
files = [ files = [
{file = "wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85"}, {file = "wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85"},
{file = "wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda"}, {file = "wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda"},
@@ -1965,4 +2263,4 @@ files = [
[metadata] [metadata]
lock-version = "2.1" lock-version = "2.1"
python-versions = ">=3.13" python-versions = ">=3.13"
content-hash = "22aa39f5ef7ad5d2415b44f3908af10f00430ba0b9be5071910d691fa9a9cb34" content-hash = "e1edf156eafb0e85fb3c090f3f17595ef56216f6bb9a2881f553619a19d83f3f"
+27 -3
View File
@@ -8,9 +8,9 @@ authors = [
license = "MH.Dmitrii's project" license = "MH.Dmitrii's project"
readme = "README.md" readme = "README.md"
requires-python = ">=3.13" requires-python = ">=3.13"
dependencies = [ dependencies = [
"alembic (>=1.18.5,<2.0.0)", "alembic (>=1.18.5,<2.0.0)",
"pytest (>=9.1.1,<10.0.0)",
"uvicorn (>=0.51.0,<0.52.0)", "uvicorn (>=0.51.0,<0.52.0)",
"gunicorn (>=26.0.0,<27.0.0)", "gunicorn (>=26.0.0,<27.0.0)",
"fastapi (>=0.139.1,<0.140.0)", "fastapi (>=0.139.1,<0.140.0)",
@@ -21,11 +21,35 @@ dependencies = [
"bcrypt (>=5.0.0,<6.0.0)", "bcrypt (>=5.0.0,<6.0.0)",
"python-jose (>=3.5.0,<4.0.0)", "python-jose (>=3.5.0,<4.0.0)",
"python-multipart (>=0.0.32,<0.0.33)", "python-multipart (>=0.0.32,<0.0.33)",
"ipython (>=9.15.0,<10.0.0)", "aiosqlite (>=0.22.1,<0.23.0)",
"httpie (>=3.2.4,<4.0.0)" "greenlet (>=3.5.4,<4.0.0)",
] ]
[tool.poetry.group.dev.dependencies]
pytest = ">=9.1.1,<10.0.0"
pytest-cov = ">=7.1.0,<8.0.0"
pytest-mock = ">=3.15.1,<4.0.0"
allure-pytest = ">=2.16.0,<3.0.0"
ipython = ">=9.15.0,<10.0.0"
httpie = ">=3.2.4,<4.0.0"
pytest-asyncio = ">=1.4.0,<2.0.0"
requests-async = ">=0.2.4,<0.3.0"
[build-system] [build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"] requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.coverage.run]
omit = [
# "*/models/*",
"*/migrations/*",
"*/database/*",
"*/errors/*",
"__init__.py",
"*/docker/*"
]
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
]
+16
View File
@@ -0,0 +1,16 @@
[pytest]
addopts =
-l
-v
-s
--alluredir=tests/allure-results/reports/
testpaths =
tests
markers=
unit: unit tests
integra: integrations test
e2e: e2e tests
smoke: smoke tests
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
+42 -37
View File
@@ -1,62 +1,67 @@
from uuid import UUID from uuid import UUID
from src.models.database_models.model import engine, RefreshTokens
from sqlalchemy import and_, not_, select from sqlalchemy import and_, not_, select, update
from sqlalchemy.orm import sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker
from src.models.database_models.model import RefreshTokens, engine
from src.models.pydantic_models.model import RefreshTokensOut from src.models.pydantic_models.model import RefreshTokensOut
class JwtCrudActions: class JwtCrudActions:
def __init__(self) -> None: def __init__(self) -> None:
self.Session=sessionmaker(bind=engine) self.Session=async_sessionmaker(bind=engine)
def get_token_by_user_id(self, user_id:UUID)->RefreshTokensOut|None: async def get_token_by_user_id(self, user_id:UUID)->RefreshTokensOut|None:
with self.Session() as session: async with self.Session() as session, session.begin():
with session.begin():
query=select(RefreshTokens).where(and_(RefreshTokens.user_id==user_id, not_(RefreshTokens.is_revoked))) query=select(RefreshTokens).where(and_(RefreshTokens.user_id==user_id, not_(RefreshTokens.is_revoked)))
response=session.scalars(query).one_or_none() response= (await session.scalars(query)).first()
if response is None: if response is None:
return None return None
return RefreshTokensOut.model_validate(response) return RefreshTokensOut.model_validate(response)
def get_token_by_id(self, id:UUID)->RefreshTokensOut|None: async def get_token_by_id(self, token_id:UUID)->RefreshTokensOut|None:
with self.Session() as session: async with self.Session() as session, session.begin():
with session.begin(): query=select(RefreshTokens).where(RefreshTokens.id==token_id)
query=select(RefreshTokens).where(RefreshTokens.id==id) response= (await session.scalars(query)).one_or_none()
response=session.scalars(query).one_or_none()
if response is None: if response is None:
return None return None
return RefreshTokensOut.model_validate(response) return RefreshTokensOut.model_validate(response)
def create_token(self, data:dict)->None: async def create_token(self, data:dict)->None:
with self.Session() as session: async with self.Session() as session, session.begin():
with session.begin():
new_token=RefreshTokens(**data) new_token=RefreshTokens(**data)
response=session.add(new_token) session.add(new_token)
return response
def update_token(self, old_jti:UUID, new_jti:UUID)->bool:
with self.Session() as session: async def create_and_update_token(self, data: dict, old_jti: UUID, new_jti: UUID) -> bool:
with session.begin(): async with self.Session() as session, session.begin():
query=select(RefreshTokens).where(RefreshTokens.id==old_jti) new_token = RefreshTokens(**data)
response=session.scalars(query).one()
response.is_revoked=True query = (
response.replaced_by=new_jti update(RefreshTokens)
.where(RefreshTokens.id == old_jti, RefreshTokens.is_revoked.is_(False))
.values(is_revoked=True, replaced_by=new_jti)
.returning(RefreshTokens.id)
)
updated_id = (await session.execute(query)).scalar_one_or_none()
if updated_id is None:
return False
session.add(new_token)
return True return True
def revoke_all(self, user_id:UUID)->bool:
with self.Session() as session: async def revoke_all(self, user_id:UUID)->bool:
with session.begin(): async with self.Session() as session, session.begin():
query=select(RefreshTokens).where(RefreshTokens.user_id==user_id) await session.execute(update(RefreshTokens).where(RefreshTokens.user_id==user_id).values(is_revoked=True)) #bulk update
response=session.scalars(query).all()
for record in response:
record.is_revoked=True
return True return True
def logout(self,id:UUID)->bool: async def logout(self,token_id:UUID)->bool:
with self.Session() as session: async with self.Session() as session, session.begin():
with session.begin(): query=select(RefreshTokens).where(RefreshTokens.id == token_id)
query=select(RefreshTokens).where(RefreshTokens.id == id) response= (await session.scalars(query)).one_or_none()
response=session.scalars(query).one_or_none()
if response is None: if response is None:
return False return False
else: else:
+111 -13
View File
@@ -1,26 +1,124 @@
from sqlalchemy import select
from src.models.database_models.model import User, engine
from src.models.pydantic_models.model import UserOutDB
from sqlalchemy.orm import sessionmaker
from uuid import UUID from uuid import UUID
from sqlalchemy import delete, select, update
from sqlalchemy.ext.asyncio import async_sessionmaker
from src.models.database_models.model import (
Permissions,
PermissionsGroups,
User,
engine,
)
from src.models.pydantic_models.model import UserOutDB
class UsersCrudActions: class UsersCrudActions:
def __init__(self) -> None: def __init__(self) -> None:
self.Session=sessionmaker(bind=engine) self.Session=async_sessionmaker(bind=engine)
async def get_user_by_email(self, email:str)->UserOutDB|None:
async with self.Session() as session, session.begin():
def get_user_by_email(self, email:str)->UserOutDB|None:
with self.Session() as session:
with session.begin():
query=select(User).where(User.email==email) query=select(User).where(User.email==email)
response=session.scalars(query).one_or_none() response=(await session.scalars(query)).one_or_none()
if response is None: if response is None:
return None return None
return UserOutDB.model_validate(response) return UserOutDB.model_validate(response)
def get_user_by_id(self, id:UUID)->UserOutDB|None: async def get_user_by_id(self, id:UUID)->UserOutDB|None:
with self.Session() as session:
with session.begin(): async with self.Session() as session, session.begin():
query=select(User).where(User.id==id) query=select(User).where(User.id==id)
response=session.scalars(query).one_or_none() response=(await session.scalars(query)).one_or_none()
if response is None: if response is None:
return None return None
return UserOutDB.model_validate(response) return UserOutDB.model_validate(response)
async def create_user(self, data:dict)->UserOutDB|None:
async with self.Session() as session, session.begin():
groups_name=data.pop("group", None)
permissions_name=data.pop("direct_permissions", None)
new_user=User(**data)
if groups_name:
query=select(PermissionsGroups).where(PermissionsGroups.group.in_(groups_name))
response=(await session.scalars(query)).all()
if response is None:
new_user.group=[]
else:
new_user.group=list(response)
else:
new_user.group=[]
if permissions_name:
query=select(Permissions).where(Permissions.permission.in_(permissions_name))
response=(await session.scalars(query)).all()
if response is None:
new_user.direct_permissions=[]
else:
new_user.direct_permissions=list(response)
else:
new_user.direct_permissions=[]
session.add(new_user)
await session.flush()
return UserOutDB.model_validate(new_user)
async def delete_user_soft(self, user_email:str)->bool|None:
async with self.Session() as session, session.begin():
query=select(User).where(User.email == user_email)
response=(await session.scalars(query)).one_or_none()
if response is None:
return None
response.status=False
return True
async def delete_user_hard(self, user_email:str)->bool|None:
async with self.Session() as session, session.begin():
query=delete(User).where(User.email == user_email).returning(User)
response=(await session.scalars(query)).one_or_none()
if response is None:
return None
return True
async def update_user_partially(self, user_email:str, data:dict)->UserOutDB|None:
async with self.Session() as session, session.begin():
groups_name=data.pop("group", None)
permissions_name=data.pop("direct_permissions", None)
query = update(User).where(User.email == user_email).values(**data).returning(User)
user_edit=(await session.scalars(query)).one_or_none()
if user_edit is None:
return None
if groups_name is not None:
query=select(PermissionsGroups).where(PermissionsGroups.group.in_(permissions_name))
groups=(await session.scalars(query)).all()
user_edit.group=list(groups)
if permissions_name is not None:
query=select(Permissions).where(Permissions.permission.in_(permissions_name))
groups=(await session.scalars(query)).all()
user_edit.direct_permissions=list(groups)
await session.flush()
return UserOutDB.model_validate(user_edit)
+26
View File
@@ -12,3 +12,29 @@ class Errors:
def not_found_error(self, detail:str)->HTTPException: def not_found_error(self, detail:str)->HTTPException:
raise HTTPException(status_code=404, detail=detail, headers={"Cache-Control": "no-store, max-age=0"}) raise HTTPException(status_code=404, detail=detail, headers={"Cache-Control": "no-store, max-age=0"})
def conflict_error(self, detail:str)->HTTPException:
raise HTTPException(status_code=409, detail=detail)
def bad_request_error(self, detail:str) -> HTTPException:
raise HTTPException(status_code=400, detail=detail)
def validation_error(self, detail:str) -> HTTPException:
raise HTTPException(status_code=422, detail=detail)
def rate_limit_error(self, detail:str, retry_after: int = 60) -> HTTPException:
raise HTTPException(
status_code=429,
detail=detail,
headers={"Retry-After":str(retry_after)}
)
def internal_server_error(self, detail:str = "Internal server error") -> HTTPException:
raise HTTPException(status_code=500, detail=detail)
def service_unavailable_error(self, detail:str, retry_after: int = 30) -> HTTPException:
raise HTTPException(
status_code=503,
detail=detail,
headers={"Retry-After":str(retry_after)}
)
+2 -1
View File
@@ -1,5 +1,6 @@
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
class Base(BaseSettings): class Base(BaseSettings):
pass pass
@@ -11,4 +12,4 @@ class Env(Base):
model_config=SettingsConfigDict(env_file="configs/.env", extra=None) model_config=SettingsConfigDict(env_file="configs/.env", extra=None)
env_settings=Env() env_settings=Env() # type: ignore[call-arg]
+5 -2
View File
@@ -1,7 +1,10 @@
from src.models.database_models.model import Model from uuid import UUID, uuid1
from sqlalchemy import String from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from uuid import UUID, uuid1
from src.models.database_models.model import Model
class AccountantSettings(Model): class AccountantSettings(Model):
__tablename__="accountant_settings" __tablename__="accountant_settings"
+5 -3
View File
@@ -1,9 +1,11 @@
from datetime import datetime from datetime import datetime
from sqlalchemy import TIMESTAMP, String, func, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.database_models.model import Model
from uuid import UUID, uuid1 from uuid import UUID, uuid1
from sqlalchemy import TIMESTAMP, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.database_models.model import Model
class Stored(Model): class Stored(Model):
__tablename__="reports" __tablename__="reports"
+4 -1
View File
@@ -1,8 +1,11 @@
from decimal import Decimal from decimal import Decimal
from src.models.database_models.model import Model
from sqlalchemy import Boolean, ForeignKey, Integer, Numeric, String from sqlalchemy import Boolean, ForeignKey, Integer, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.database_models.model import Model
class Nomenclature(Model): class Nomenclature(Model):
__tablename__="goods" __tablename__="goods"
+17 -4
View File
@@ -1,8 +1,21 @@
from sqlalchemy import TIMESTAMP, Table, create_engine, String, Boolean, MetaData, Column, ForeignKey, func, Uuid
from sqlalchemy.orm import Mapped, mapped_column, DeclarativeBase, relationship
from uuid import UUID, uuid4
from datetime import datetime from datetime import datetime
engine = create_engine("sqlite:///DB/database.db", echo=True) from uuid import UUID, uuid4
from sqlalchemy import (
TIMESTAMP,
Boolean,
Column,
ForeignKey,
MetaData,
String,
Table,
Uuid,
func,
)
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
engine = create_async_engine("sqlite+aiosqlite:///DB/database.db", echo=True)
'''remember as a boilerplate, or just cp/pst''' '''remember as a boilerplate, or just cp/pst'''
class Model(DeclarativeBase): class Model(DeclarativeBase):
+6 -2
View File
@@ -1,7 +1,11 @@
from typing import Annotated from typing import Annotated
from src.models.pydantic_models.model import Base
from pydantic import Field
from uuid import UUID from uuid import UUID
from pydantic import Field
from src.models.pydantic_models.model import Base
class AccountantCreate(Base): class AccountantCreate(Base):
name:Annotated[str, Field(...,max_length=64,description="name of the accountant setting")] name:Annotated[str, Field(...,max_length=64,description="name of the accountant setting")]
+4 -1
View File
@@ -1,9 +1,12 @@
from datetime import datetime from datetime import datetime
from typing import Annotated from typing import Annotated
from uuid import UUID from uuid import UUID
from src.models.pydantic_models.model import Base
from pydantic import Field from pydantic import Field
from src.models.pydantic_models.model import Base
class ReportCreate(Base): class ReportCreate(Base):
filename:Annotated[str,Field(..., min_length=2, max_length=255, description="name of the report")] filename:Annotated[str,Field(..., min_length=2, max_length=255, description="name of the report")]
doc_date:Annotated[datetime, Field(..., description="ts of the report")] doc_date:Annotated[datetime, Field(..., description="ts of the report")]
+5 -2
View File
@@ -1,8 +1,11 @@
from decimal import Decimal from decimal import Decimal
from src.models.pydantic_models.model import Base
from pydantic import Field
from typing import Annotated from typing import Annotated
from pydantic import Field
from src.models.pydantic_models.model import Base
class NomenclatureCreate(Base): class NomenclatureCreate(Base):
article:Annotated[str, Field(...,min_length=5,max_length=16, description="name of the article")] article:Annotated[str, Field(...,min_length=5,max_length=16, description="name of the article")]
+19 -4
View File
@@ -1,14 +1,29 @@
from datetime import datetime from datetime import datetime
from typing import Annotated from typing import Annotated
from pydantic import BaseModel, EmailStr, Field
from uuid import UUID from uuid import UUID
from pydantic import AfterValidator, BaseModel, EmailStr, Field
def validate_password(password: str) -> str:
PUNCTUATION: set[str] = {"$", "@", "#", "%", "!", "^", "&", "*", "(", ")", "-", "_", "+", "=", "{", "}", "[", "]"}
if len(password) < 8 or len(password) > 72:
raise ValueError("Password must be 8-72 characters")
if (
not any(c.isupper() for c in password)
or not any(c.islower() for c in password)
or not any(c.isdigit() for c in password)
or not any(c in PUNCTUATION for c in password)
):
raise ValueError("Password must contain uppercase, lowercase, digit and special char")
return password
PasswordStr = Annotated[str, AfterValidator(validate_password)]
class Base(BaseModel): class Base(BaseModel):
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
class PermissionsCreate(Base): class PermissionsCreate(Base):
permission:Annotated[str, Field(..., max_length=30, description="permission name")] permission:Annotated[str, Field(..., max_length=30, description="permission name")]
@@ -34,8 +49,7 @@ class UserCreate(Base):
last_name:Annotated[str, Field(...,max_length=64, description="last name of the user")] last_name:Annotated[str, Field(...,max_length=64, description="last name of the user")]
middle_name:Annotated[str, Field(...,max_length=64, description="middle name of the user")] middle_name:Annotated[str, Field(...,max_length=64, description="middle name of the user")]
email:Annotated[EmailStr, Field(...,min_length=5, max_length=255, description="email of the user")] email:Annotated[EmailStr, Field(...,min_length=5, max_length=255, description="email of the user")]
plain_password:Annotated[str, Field(...,min_length=8,max_length=72, description="plain password of the user")] plain_password:Annotated[PasswordStr, Field(..., description="plain password of the user")]
status:Annotated[bool, Field(..., description="status of the user")]
direct_permissions:Annotated[list[str], Field(..., description="permissions of the user")] direct_permissions:Annotated[list[str], Field(..., description="permissions of the user")]
group:Annotated[list[str], Field(..., description="permissions groups of the user")] group:Annotated[list[str], Field(..., description="permissions groups of the user")]
@@ -62,6 +76,7 @@ class UserUpdate(Base):
last_name:Annotated[str|None, Field(None, max_length=64,description="last name of the user")] last_name:Annotated[str|None, Field(None, max_length=64,description="last name of the user")]
middle_name:Annotated[str|None, Field(None, max_length=64,description="middle name of the user")] middle_name:Annotated[str|None, Field(None, max_length=64,description="middle name of the user")]
email:Annotated[EmailStr|None, Field(None, min_length=5, max_length=255, description="email of the user")] email:Annotated[EmailStr|None, Field(None, min_length=5, max_length=255, description="email of the user")]
plain_password:Annotated[PasswordStr|None, Field(None, description="plain password of the user")]
status:Annotated[bool|None, Field(None, description="status of the user")] status:Annotated[bool|None, Field(None, description="status of the user")]
direct_permissions:Annotated[list[str]|None, Field(None, description="permissions of the user")] direct_permissions:Annotated[list[str]|None, Field(None, description="permissions of the user")]
group:Annotated[list[str]|None, Field(None, description="permissions groups of the user")] group:Annotated[list[str]|None, Field(None, description="permissions groups of the user")]
+108 -92
View File
@@ -1,31 +1,35 @@
from datetime import datetime, timedelta, timezone import asyncio
from datetime import UTC, datetime, timedelta
from uuid import UUID from uuid import UUID
from fastapi import Request from fastapi import Request
from .jwt import Jwt, Hashes
from src.database.users.crud import UsersCrudActions
from src.database.auth.refresh_tokens import JwtCrudActions from src.database.auth.refresh_tokens import JwtCrudActions
from src.database.users.crud import UsersCrudActions
from src.errors.http_errors.errors import Errors from src.errors.http_errors.errors import Errors
from src.models.pydantic_models.model import RefreshTokensCreate, UserOut
from src.models.configs_read.env import env_settings from src.models.configs_read.env import env_settings
from src.models.pydantic_models.model import RefreshTokensCreate, UserOut
from .jwt import HashService, JwtService
class CurrentUser: class CurrentUserService:
def __init__(self) -> None: def __init__(self) -> None:
self.jwt_service=Jwt() self.jwt_service=JwtService()
self.hash=Hashes() self.hash=HashService()
self.crud_db_actions=UsersCrudActions() self.crud_db_actions=UsersCrudActions()
self.jwt_db_actions=JwtCrudActions() self.jwt_db_actions=JwtCrudActions()
self.error=Errors() self.error=Errors()
def _check(self, form_data_email:str, form_data_password:str,): async def _check(self, form_data_email:str, form_data_password:str,):
'''check user by email''' '''check user by email'''
user=self.crud_db_actions.get_user_by_email(form_data_email) user=await self.crud_db_actions.get_user_by_email(form_data_email)
if user is None: if user is None:
raise self.error.credentials_error(detail="Wrong credentials") raise self.error.credentials_error(detail="Wrong credentials")
if not self.hash.verify_password(plain_password=form_data_password, hashed_password=user.hashed_password): if not await asyncio.to_thread(self.hash.verify_password, plain_password=form_data_password, hashed_password=user.hashed_password):
raise self.error.credentials_error(detail="Wrong credentials") raise self.error.credentials_error(detail="Wrong credentials")
if user.status is False: if user.status is False:
@@ -33,86 +37,102 @@ class CurrentUser:
return user return user
def get_current_user(self, token:str)->UserOut: async def _token_record_create(self, jti:UUID,user_id:UUID,token:str, request:Request)->RefreshTokensCreate:
payload=self.jwt_service.jwt_decode(token) return RefreshTokensCreate(
if (sub:=payload.get("sub")) is None:
raise self.error.credentials_error(detail="Jwt token is incorrect")
try:
sub=UUID(sub)
except (ValueError, TypeError):
raise self.error.credentials_error(detail="Jwt token is incorrect")
user=self.crud_db_actions.get_user_by_id(sub)
if user is None:
raise self.error.not_found_error(detail="User with this email address not found")
return UserOut.model_validate(user)
def create_access_token(self, user_id:UUID)->str:
'''create new access token if all the checks are successful'''
return self.jwt_service.create_access_token({"sub":str(user_id)})
def create_refresh_token(self,user_id:UUID, request:Request)->str:
token, jti=self.jwt_service.create_refresh_token({"sub":str(user_id)})
try:
jti=UUID(jti)
except (ValueError, TypeError):
raise self.error.credentials_error(detail="Jwt token is incorrect")
'''create new refresh token if all the checks are successful'''
token_record=RefreshTokensCreate(
id=jti, id=jti,
user_id=user_id, user_id=user_id,
token_hash=self.hash.token_to_hash(token), token_hash=self.hash.token_to_hash(token),
device_info=request.headers.get("user-agent", "unknown"), device_info=request.headers.get("user-agent", "unknown"),
ip_address=request.headers.get("x-forwarded-for", "").split(",")[0].strip() or (request.client.host if request.client else "unknown"), ip_address=request.headers.get("x-forwarded-for", "").split(",")[0].strip() or (request.client.host if request.client else "unknown"),
expires_at=datetime.now(timezone.utc)+timedelta(days=env_settings.REFRESH_TOKEN_EXPIRE_DAYS) expires_at=datetime.now(UTC)+timedelta(days=env_settings.REFRESH_TOKEN_EXPIRE_DAYS)
) )
self.jwt_db_actions.create_token(RefreshTokensCreate.model_dump(token_record))
async def get_current_user(self, token:str)->UserOut:
payload= await self.jwt_service.jwt_decode(token)
sub=payload.get("sub")
try:
sub=UUID(sub)
except (ValueError, TypeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
if not (payload.get("token_type")=="access"):
raise self.error.credentials_error(detail="Jwt token type is incorrect")
user=await self.crud_db_actions.get_user_by_id(sub)
if user is None:
raise self.error.not_found_error(detail="User with this email address not found")
if user.status is False:
raise self.error.credentials_error(detail="This user is deactivated")
return UserOut.model_validate(user)
async def create_access_token(self, user_id:UUID)->str:
'''create new access token if all the checks are successful'''
return await self.jwt_service.create_access_token({"sub":str(user_id)})
async def create_refresh_token(self,user_id:UUID, request:Request)->str:
token, jti= await self.jwt_service.create_refresh_token({"sub":str(user_id)})
try:
jti=UUID(jti)
except (ValueError, TypeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
'''create new refresh token if all the checks are successful'''
token_record=await self._token_record_create(jti=jti, user_id=user_id, token=token, request=request)
await self.jwt_db_actions.create_token(RefreshTokensCreate.model_dump(token_record))
return token return token
def refresh_token(self, refresh_token:str, request:Request)->tuple[str, str]: async def refresh_token(self, refresh_token:str, request:Request)->tuple[str, str]:
'''decode old refresh token''' '''decode old refresh token'''
old_refresh_token=self.jwt_service.jwt_decode(refresh_token) old_refresh_token= await self.jwt_service.jwt_decode(refresh_token)
if (sub:=old_refresh_token.get("sub")) is None or (old_jti:=old_refresh_token.get("jti")) is None: sub=old_refresh_token.get("sub")
if (old_jti:=old_refresh_token.get("jti")) is None:
raise self.error.credentials_error(detail="Jwt token is incorrect") raise self.error.credentials_error(detail="Jwt token is incorrect")
try: try:
old_jti=UUID(old_jti) old_jti=UUID(old_jti)
sub=UUID(sub) sub=UUID(sub)
except (ValueError, TypeError): except (ValueError, TypeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") raise self.error.credentials_error(detail="Jwt token is incorrect") from e
'''old refresh token check''' '''old refresh token check'''
old_record=self.jwt_db_actions.get_token_by_id(old_jti)
if (old_refresh_token.get("token_type")=="access"):
raise self.error.credentials_error(detail="Jwt token type is incorrect")
old_record=await self.jwt_db_actions.get_token_by_id(old_jti)
if old_record is None: if old_record is None:
raise self.error.not_found_error(detail="Token not found") raise self.error.not_found_error(detail="Token not found")
if old_record.is_revoked:
self.jwt_db_actions.revoke_all(old_record.user_id)
raise self.error.credentials_error(detail="Reuse token detected")
'''sqlite constraints about timezone''' '''sqlite constraints about timezone'''
expires_at=old_record.expires_at expires_at=old_record.expires_at
if expires_at.tzinfo is None: if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc) expires_at = expires_at.replace(tzinfo=UTC)
if expires_at<datetime.now(timezone.utc): if expires_at<datetime.now(UTC):
raise self.error.credentials_error(detail="Token expired") raise self.error.credentials_error(detail="Token expired")
'''user check''' '''user check'''
user = self.crud_db_actions.get_user_by_id(sub) user = await self.crud_db_actions.get_user_by_id(sub)
if user is None: if user is None:
raise self.error.not_found_error(detail="User not found") raise self.error.not_found_error(detail="User not found")
if user.status is False: if user.status is False:
@@ -120,62 +140,58 @@ class CurrentUser:
'''create new refresh token if all the checks are successful''' '''create new refresh token if all the checks are successful'''
new_refresh_token, new_jti=self.jwt_service.create_refresh_token({"sub":str(sub)}) new_refresh_token, new_jti= await self.jwt_service.create_refresh_token({"sub":str(sub)})
new_access_token=self.create_access_token(user_id=sub) new_access_token=await self.create_access_token(user_id=sub)
try: try:
new_jti=UUID(new_jti) new_jti=UUID(new_jti)
except (ValueError, TypeError): except (ValueError, TypeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") raise self.error.credentials_error(detail="Jwt token is incorrect") from e
'''create database record with the new token''' '''create database record with the new token'''
new_token_record=RefreshTokensCreate( new_token_record=await self._token_record_create(jti=new_jti, user_id=sub, token=new_refresh_token, request=request)
id=new_jti,
user_id=sub,
token_hash=self.hash.token_to_hash(new_refresh_token),
device_info=request.headers.get("user-agent", "unknown"),
ip_address=request.headers.get("x-forwarded-for", "").split(",")[0].strip() or (request.client.host if request.client else "unknown"),
expires_at=datetime.now(timezone.utc)+timedelta(days=env_settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
self.jwt_db_actions.create_token(RefreshTokensCreate.model_dump(new_token_record))
'''update old token to deactivate it and assign replaced_by''' success = await self.jwt_db_actions.create_and_update_token(RefreshTokensCreate.model_dump(new_token_record), old_jti, new_jti)
self.jwt_db_actions.update_token(old_jti, new_jti)
return (new_access_token,new_refresh_token) if not success:
raise self.error.not_found_error(detail="Token not found")
return (new_access_token, new_refresh_token)
def logout(self, refresh_token:str)->bool: async def logout(self, refresh_token:str)->bool:
'''decode current refresh token''' '''decode current refresh token'''
payload=self.jwt_service.jwt_decode(refresh_token) payload=await self.jwt_service.jwt_decode(refresh_token)
if (jti:=payload.get("jti")) is None: if (jti:=payload.get("jti")) is None:
raise self.error.credentials_error(detail="Invalid Refresh Token")
try:
jti=UUID(jti)
except (ValueError, TypeError):
raise self.error.credentials_error(detail="Jwt token is incorrect") raise self.error.credentials_error(detail="Jwt token is incorrect")
current_token = self.jwt_db_actions.get_token_by_id(jti) try:
if current_token is None: jti=UUID(jti)
raise self.error.not_found_error(detail="Refresh Token Not Found") except (ValueError, TypeError, AttributeError) as e:
raise self.error.credentials_error(detail="Jwt token is incorrect") from e
'''logout by assigning revoked flag''' '''logout by assigning revoked flag'''
return self.jwt_db_actions.logout(jti) if await self.jwt_db_actions.logout(jti):
return True
else:
raise self.error.not_found_error(detail="Refresh Token Not Found")
def login(self, form_data_email:str, form_data_password:str, request:Request)->tuple[str, str]: async def login(self, form_data_email:str, form_data_password:str, request:Request)->tuple[str, str]:
'''revoke all the old refresh tokens''' '''revoke all the old refresh tokens'''
user = self._check(form_data_email, form_data_password) user = await self._check(form_data_email, form_data_password)
self.jwt_db_actions.revoke_all(user_id=user.id) await self.jwt_db_actions.revoke_all(user_id=user.id)
'''create access and refresh tokens''' '''create access and refresh tokens'''
access_token=self.create_access_token(user_id=user.id) access_token=await self.create_access_token(user_id=user.id)
refresh_token=self.create_refresh_token(user_id=user.id,request=request) refresh_token=await self.create_refresh_token(user_id=user.id,request=request)
return (access_token, refresh_token) return (access_token, refresh_token)
auth=CurrentUser() async def auth_service()->CurrentUserService:
return CurrentUserService()
+28 -17
View File
@@ -1,13 +1,15 @@
from jose import JWTError, jwt
import bcrypt
from src.errors.http_errors.errors import Errors
from datetime import datetime, timedelta, timezone
from src.models.configs_read.env import env_settings
from uuid import uuid4
import hashlib import hashlib
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import bcrypt
from jose import JWTError, jwt
from src.errors.http_errors.errors import Errors
from src.models.configs_read.env import env_settings
'''Hash/Check hash''' '''Hash/Check hash'''
class Hashes: class HashService:
def __init__(self) -> None: def __init__(self) -> None:
pass pass
@@ -23,26 +25,35 @@ class Hashes:
'''jwt''' '''jwt'''
class Jwt: class JwtService:
def __init__(self) -> None: def __init__(self) -> None:
self.error=Errors() self.error=Errors()
def create_access_token(self, data:dict)->str: async def _validate_sub(self,data:dict)->None:
if not (data.get("sub")) or data.get("sub") == "":
raise self.error.credentials_error(detail="Jwt token is incorrect")
async def create_access_token(self, data:dict)->str:
user_info=data.copy() user_info=data.copy()
user_info.update({"exp": datetime.now(timezone.utc)+timedelta(minutes=env_settings.ACCESS_TOKEN_EXPIRE_MINUTES),
await self._validate_sub(user_info)
user_info.update({"exp": datetime.now(UTC)+timedelta(minutes=env_settings.ACCESS_TOKEN_EXPIRE_MINUTES),
"token_type":"access"}) "token_type":"access"})
print(f"DEBUG: expires at {datetime.now(timezone.utc)+timedelta(minutes=env_settings.ACCESS_TOKEN_EXPIRE_MINUTES)}, minutes={env_settings.ACCESS_TOKEN_EXPIRE_MINUTES}")
return jwt.encode(user_info, env_settings.SECRET_KEY, env_settings.ALGORITHM) return jwt.encode(user_info, env_settings.SECRET_KEY, env_settings.ALGORITHM)
def create_refresh_token(self, data:dict)->tuple[str, str]: async def create_refresh_token(self, data:dict)->tuple[str, str]:
user_info=data.copy() user_info=data.copy()
jti=str(uuid4()) jti=str(uuid4())
user_info.update({"exp":datetime.now(timezone.utc)+timedelta(days=env_settings.REFRESH_TOKEN_EXPIRE_DAYS),
await self._validate_sub(user_info)
user_info.update({"exp":datetime.now(UTC)+timedelta(days=env_settings.REFRESH_TOKEN_EXPIRE_DAYS),
"token_type":"refresh", "token_type":"refresh",
"jti":jti "jti":jti
}) })
@@ -51,13 +62,13 @@ class Jwt:
def jwt_decode(self, token:str)->dict: async def jwt_decode(self, token:str)->dict:
try: try:
payload=jwt.decode(token, env_settings.SECRET_KEY, algorithms=[env_settings.ALGORITHM]) payload=jwt.decode(token, env_settings.SECRET_KEY, algorithms=[env_settings.ALGORITHM], options={"require_exp": True} )
if (payload.get("sub")) is None: if not (payload.get("sub")) or not (payload.get("token_type")):
raise self.error.credentials_error(detail="Sub block is missing") raise self.error.credentials_error(detail="Jwt token is incorrect")
except JWTError as e: except JWTError as e:
raise self.error.credentials_error(detail="JWTerror") from e raise self.error.credentials_error(detail="JWTerror") from e
+80
View File
@@ -0,0 +1,80 @@
import asyncio
from src.database.users.crud import UsersCrudActions
from src.errors.http_errors.errors import Errors
from src.models.pydantic_models.model import UserCreate, UserOut, UserUpdate
from src.service.auth.jwt import HashService
class CrudService:
def __init__(self) -> None:
self.errors=Errors()
self.crud_db_actions=UsersCrudActions()
self.hash_service=HashService()
async def _plain_to_hash(self, user_data:dict)->dict:
user_data["hashed_password"]=user_data.pop("plain_password")
user_data["hashed_password"]= await asyncio.to_thread(self.hash_service.plain_to_hash, user_data["hashed_password"])
return user_data
async def get_user_by_email(self, email:str)->UserOut:
user_entity=await self.crud_db_actions.get_user_by_email(email)
if not user_entity:
raise self.errors.not_found_error(detail="User wasn't found")
return UserOut.model_validate(user_entity)
async def create_user(self, data:UserCreate)->UserOut:
user_data=UserCreate.model_dump(data)
user_data=await self._plain_to_hash(user_data)
user_entity=await self.crud_db_actions.create_user(user_data)
if not user_entity:
raise self.errors.validation_error(detail="User creation gone wrong")
return UserOut.model_validate(user_entity)
async def delete_user_soft(self, email:str)->bool:
user_entity=await self.crud_db_actions.delete_user_soft(email)
if not user_entity:
raise self.errors.not_found_error(detail="User not found")
return user_entity
async def delete_user_hard(self, email:str, current_user)->bool:
user_entity=await self.crud_db_actions.delete_user_hard(email)
if not user_entity:
raise self.errors.not_found_error(detail="User not found")
return user_entity
async def update_user(self, email:str, data:UserUpdate)->UserOut:
user_data=UserUpdate.model_dump(data, exclude_unset=True)
if not user_data:
raise self.errors.bad_request_error(detail="User info to update can not be empty")
if user_data.get("plain_password"):
user_data=await self._plain_to_hash(user_data)
user_entity=await self.crud_db_actions.update_user_partially(email, user_data)
if not user_entity:
raise self.errors.not_found_error(detail="User not found")
return UserOut.model_validate(user_entity)
async def crud_service()->CrudService:
return CrudService()
-1
View File
@@ -1 +0,0 @@
'''unit, integr, e2e tests'''
@@ -1,17 +1,17 @@
from fastapi import APIRouter, Depends, Request, Response, Cookie from fastapi import APIRouter, Cookie, Depends, Request, Response
from fastapi.security import OAuth2PasswordRequestForm, OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from src.models.configs_read.env import env_settings from src.models.configs_read.env import env_settings
from src.models.pydantic_models.model import UserOut from src.models.pydantic_models.model import UserOut
from src.service.auth.auth import CurrentUserService, auth_service
from src.service.auth.auth import auth
router=APIRouter(prefix="/protected") router=APIRouter(prefix="/protected")
oauth2_schema=OAuth2PasswordBearer(tokenUrl="/protected/token", refreshUrl="/protected/refresh") oauth2_schema=OAuth2PasswordBearer(tokenUrl="/protected/token", refreshUrl="/protected/refresh")
@router.post("/token") @router.post("/token")
async def get_access_token(request: Request,response:Response, form_data:OAuth2PasswordRequestForm=Depends())->dict: async def get_access_token(request: Request,response:Response,auth:CurrentUserService=Depends(auth_service), form_data:OAuth2PasswordRequestForm=Depends())->dict: # noqa: B008
access_token, refresh_token=auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request) access_token, refresh_token=await auth.login(form_data_email=form_data.username, form_data_password=form_data.password, request=request)
response.set_cookie( response.set_cookie(
key="refresh_token", key="refresh_token",
@@ -25,9 +25,9 @@ async def get_access_token(request: Request,response:Response, form_data:OAuth2P
@router.post("/refresh") @router.post("/refresh")
async def get_refresh_token(request:Request,response:Response, refresh_token: str = Cookie())->dict: async def get_refresh_token(request:Request,response:Response, refresh_token: str = Cookie(), auth:CurrentUserService=Depends(auth_service))->dict: # noqa: B008
access_token, refresh_token= auth.refresh_token(refresh_token=refresh_token,request=request) access_token, refresh_token= await auth.refresh_token(refresh_token=refresh_token,request=request)
response.set_cookie( response.set_cookie(
key="refresh_token", key="refresh_token",
@@ -41,15 +41,15 @@ async def get_refresh_token(request:Request,response:Response, refresh_token: st
return {"access_token":access_token, "token_type": "bearer"} return {"access_token":access_token, "token_type": "bearer"}
async def get_current_user(token:str = Depends(oauth2_schema)) -> UserOut: async def get_current_user(token:str = Depends(oauth2_schema), auth:CurrentUserService=Depends(auth_service)) -> UserOut: # noqa: B008
return UserOut.model_validate(auth.get_current_user(token)) return UserOut.model_validate(await auth.get_current_user(token))
@router.get("/logout") @router.get("/logout")
async def logout(response:Response,refresh_token: str = Cookie(),current_user:UserOut=Depends(get_current_user))->bool: async def logout(response:Response,refresh_token: str = Cookie(),auth:CurrentUserService=Depends(auth_service),current_user:UserOut=Depends(get_current_user))->bool: # noqa: B008
response.delete_cookie("refresh_token") response.delete_cookie("refresh_token")
return auth.logout(refresh_token) return await auth.logout(refresh_token)
@router.get("") @router.get("")
async def protected(current_user:UserOut=Depends(get_current_user))->dict: async def protected(current_user:UserOut=Depends(get_current_user))->dict: # noqa: B008
return {"protected router": "Hello, this is a protected router"} return {"protected router": "Hello, this is a protected router"}
@@ -0,0 +1,28 @@
from fastapi import APIRouter, Depends
from src.models.pydantic_models.model import UserCreate, UserOut, UserUpdate
from src.service.users_crud.users_crud import CrudService, crud_service
from src.web.protected_routes.auth_routes import get_current_user
router=APIRouter(prefix="/user")
@router.get("/get_by_email")
async def get_current_user_by_email(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(get_current_user))->UserOut: # noqa: B008
return await crud.get_user_by_email(email)
@router.post("/create_user")
async def create_user(data:UserCreate, crud:CrudService=Depends(crud_service), current_user=Depends(get_current_user))->UserOut: #noqa: B008
return await crud.create_user(data)
@router.post("/delete_user_soft")
async def delete_user_soft(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(get_current_user))->bool: #noqa: B008
return await crud.delete_user_soft(email)
@router.post("/delete_user_hard")
async def delete_user_hard(email:str, crud:CrudService=Depends(crud_service), current_user=Depends(get_current_user))->bool: #noqa: B008
return await crud.delete_user_hard(email, current_user)
@router.patch("/patch_user")
async def patch_user(email:str, data:UserUpdate, crud:CrudService=Depends(crud_service), current_user=Depends(get_current_user))->UserOut: #noqa: B008
return await crud.update_user(email, data)
+1
View File
@@ -0,0 +1 @@
'''unit, integr, e2e tests'''
+14
View File
@@ -0,0 +1,14 @@
import pytest_asyncio
from src.service.auth.jwt import HashService, JwtService
@pytest_asyncio.fixture
async def jwt_service()->JwtService:
jwt_service=JwtService()
return jwt_service
@pytest_asyncio.fixture
async def hash_service()->HashService:
hash_service=HashService()
return hash_service
+77
View File
@@ -0,0 +1,77 @@
from uuid import uuid4
import pytest_asyncio
import requests_async
from pydantic_settings import BaseSettings, SettingsConfigDict
class Env(BaseSettings):
HOST:str
PORT:str
TEST_USERNAME:str
TEST_PASSWORD:str
model_config=SettingsConfigDict(env_file="configs/.e2e.env", extra=None)
e2e_settings=Env() # type: ignore[call-arg]
class MySession(requests_async.AsyncSession):
def __init__(self):
super().__init__()
self.headers = {}
self.token = None
async def request(self, method:str, url:str, **kwargs):
if self.token:
self.headers['Authorization'] = f"Bearer {self.token}"
kwargs.setdefault('headers', self.headers)
return await super().request(method, url, **kwargs)
@pytest_asyncio.fixture(scope="function")
async def auth_fixture(target_url:str):
payload = {"username": e2e_settings.TEST_USERNAME, "password": e2e_settings.TEST_PASSWORD}
async with MySession() as session:
response = await session.post(target_url + "/protected/token", data=payload)
response.raise_for_status()
session.token = response.json()["access_token"]
yield session
@pytest_asyncio.fixture(scope="function")
async def test_user_fixture(request, auth_fixture: MySession, target_url:str):
test_id=uuid4()
direct_permission_param, group_param = request.param
new_user_record={
"first_name":f"TEST_{test_id}",
"last_name":f"TEST_{test_id}",
"middle_name":f"TEST_{test_id}",
"email":f"TEST_{test_id}@d.d",
"plain_password":"Test1234!",
"direct_permissions":direct_permission_param,
"group":group_param
}
response=await auth_fixture.post(f"{target_url}/user/create_user", json=new_user_record)
response.raise_for_status()
async with MySession() as session:
payload={"username": new_user_record.get("email"), "password": new_user_record.get("plain_password")}
response = await session.post(target_url + "/protected/token", data=payload)
response.raise_for_status()
session.token = response.json()["access_token"]
yield (session, new_user_record)
response=await auth_fixture.post(f"{target_url}/user/delete_user_hard", params={"email":new_user_record.get("email")})
response.raise_for_status()
@pytest_asyncio.fixture()
async def target_url()->str:
return f"http://{e2e_settings.HOST}:{e2e_settings.PORT}"
+260
View File
@@ -0,0 +1,260 @@
from uuid import uuid4
import allure
import pytest
from httpx import HTTPStatusError
from tests.e2e.conftest import MySession
@pytest.mark.integra
class TestCrud:
@pytest.mark.parametrize("test_user_fixture", [(["admin"], ["admin_group"])], indirect=True)
async def test_get_user_by_email_positive(self,test_user_fixture, target_url:str)->None:
session, new_user_record=test_user_fixture
with allure.step("Get user by email"):
email = new_user_record.get("email") #get email from the fixture in yield sector
response = await session.get(f"{target_url}/user/get_by_email",params={"email":email})
response.raise_for_status()
response=response.json()
with allure.step("Validate response"):
assert response.get("email")==email
assert "TEST_" in response.get("first_name")
assert "TEST_" in response.get("last_name")
assert "TEST_" in response.get("middle_name")
assert response.get("direct_permissions") != []
assert response.get("group") != []
assert not response.get("hashed_password") or not response.get("plain_password") or not response.get("password")
@pytest.mark.parametrize("email, expected_status", [
pytest.param("test@test.test", 404, id="non_existed_email"),
pytest.param("test",404, id="wrong_email"),
pytest.param("@d", 404, id="wrong_email")
])
async def test_get_user_by_email_negative(self, email:str, expected_status:int, auth_fixture:MySession,target_url:str)->None:
with allure.step("Get user by email"):
with pytest.raises(HTTPStatusError) as exc_info:
response = await auth_fixture.get(f"{target_url}/user/get_by_email", params={"email": email})
response.raise_for_status()
assert exc_info.value.response.status_code == expected_status
@pytest.mark.parametrize("new_user_record",[
pytest.param({ "first_name":"TEST",
"last_name":"TEST",
"middle_name":"TEST",
"email":f"TEST_{uuid4()}@d.d",
"plain_password":"Test1234!",
"direct_permissions":[],
"group":[]}, id="Positive_user_creation_with_all_the_fields"),
pytest.param({ "first_name":"TEST",
"last_name":"TEST",
"middle_name":"TEST",
"email":f"TEST_{uuid4()}@d.d",
"plain_password":"Test1234!",
"direct_permissions":["WRONG_PERMISSIONS"],
"group":["WRONG_GROUP"]},id="Positive_wrong_permissions"),
])
async def test_create_delete_user_hard_positive(self, new_user_record:dict,auth_fixture:MySession, target_url:str)->None:
with allure.step("Create new test user and check for the new user"):
response=await auth_fixture.post(f"{target_url}/user/create_user", json=new_user_record)
response.raise_for_status()
try:
with allure.step("Check for the new user"):
response = await auth_fixture.get(f"{target_url}/user/get_by_email",params={"email":new_user_record.get("email")})
response.raise_for_status()
response=response.json()
assert response.get("email")==new_user_record["email"]
assert response.get("first_name")==new_user_record["first_name"]
assert response.get("last_name")==new_user_record["last_name"]
assert response.get("middle_name")==new_user_record["middle_name"]
assert response.get("direct_permissions") == new_user_record["direct_permissions"] or response.get("direct_permissions") == []
assert response.get("group") == new_user_record["group"] or response.get("group") == []
assert not response.get("hashed_password") or not response.get("plain_password") or not response.get("password")
finally:
with allure.step("delete new user"):
response=await auth_fixture.post(f"{target_url}/user/delete_user_hard", params={"email":new_user_record["email"]})
response.raise_for_status()
@pytest.mark.parametrize("new_user_record, expected_status", [
pytest.param({ "first_name":"TEST",
"last_name":"TEST",
"middle_name":"TEST",
"email":"WRONGEMAIL",
"plain_password":"Test1234!",
"direct_permissions":[],
"group":[]},422,id="Non_existed_email"),
pytest.param({ "first_name":"TEST",
"last_name":"TEST",
"middle_name":"TEST",
"email":"TEST1@d.d",
"plain_password":"1234",
"direct_permissions":[],
"group":[]},422,id="Wrong_password"),
pytest.param({ "first_name":"TEST",
"email":"TEST1@d.d",
"plain_password":"Test1234!",
},422,id="Not_all_the_fields"),
])
async def test_create_user_negative(self, new_user_record:dict, auth_fixture:MySession, expected_status:int, target_url:str):
with allure.step("Preparing data to create new user negative"):
user_created=False
try:
with allure.step("Create new test user and check for the new user"):
response=await auth_fixture.post(f"{target_url}/user/create_user", json=new_user_record)
if response.status_code < 400:
user_created = True
with pytest.raises(HTTPStatusError) as exc_info:
response.raise_for_status()
assert exc_info.value.response.status_code == expected_status
finally:
if user_created:
with allure.step("delete new user"):
response = await auth_fixture.post(
f"{target_url}/user/delete_user_hard",
params={"email": new_user_record["email"]}
)
response.raise_for_status()
@pytest.mark.parametrize("new_user_record",[
pytest.param({ "first_name":"TEST",
"last_name":"TEST",
"middle_name":"TEST",
"email":f"TEST_{uuid4()}@d.d",
"plain_password":"Test1234!",
"direct_permissions":[],
"group":[]}, id="Positive_user_delete_soft"),
])
async def test_user_create_delete_soft_positive(self, new_user_record:dict, auth_fixture:MySession, target_url:str)->None:
with allure.step("Create new test user and check for the new user"):
response=await auth_fixture.post(f"{target_url}/user/create_user", json=new_user_record)
response.raise_for_status()
try:
with allure.step("Check for the new user"):
response = await auth_fixture.get(f"{target_url}/user/get_by_email",params={"email":new_user_record.get("email")})
response.raise_for_status()
with allure.step("Delete user soft"):
response=await auth_fixture.post(f"{target_url}/user/delete_user_soft", params={"email":new_user_record["email"]})
response.raise_for_status()
finally:
with allure.step("delete new user"):
response=await auth_fixture.post(f"{target_url}/user/delete_user_hard", params={"email":new_user_record["email"]})
response.raise_for_status()
@pytest.mark.parametrize("email, expected_status, ",[
pytest.param("Test", 404,id="Wrong_email")
])
async def test_user_delete_soft_negative(self, email:str,expected_status:int, auth_fixture:MySession, target_url:str)->None:
with allure.step("Delete user soft"):
with pytest.raises(HTTPStatusError) as exc_info:
response=await auth_fixture.post(f"{target_url}/user/delete_user_soft", params={"email":email})
response.raise_for_status()
assert exc_info.value.response.status_code == expected_status
@pytest.mark.parametrize("email, expected_status, ",[
pytest.param("Test", 404,id="Wrong_email")
])
async def test_user_delete_hard_negative(self, email:str,expected_status:int, auth_fixture:MySession, target_url:str)->None:
with allure.step("Delete user hard"):
with pytest.raises(HTTPStatusError) as exc_info:
response=await auth_fixture.post(f"{target_url}/user/delete_user_soft", params={"email":email})
response.raise_for_status()
assert exc_info.value.response.status_code == expected_status
@pytest.mark.parametrize("test_user_fixture", [
(["admin"], ["admin_group"])
], indirect=True)
@pytest.mark.parametrize("user_record_to_update",[
pytest.param({"first_name": "Test_New"},id="Positive_user_update_partially")
])
async def test_user_update_partially_positive(self, test_user_fixture, user_record_to_update:dict, target_url:str)->None:
session, new_user_record=test_user_fixture
with allure.step("Update user"):
response= await session.patch(f"{target_url}/user/patch_user", json=user_record_to_update, params={"email":new_user_record["email"]})
response.raise_for_status()
with allure.step("Check for the updated user"):
response=await session.get(f"{target_url}/user/get_by_email", params={"email":new_user_record["email"]})
response.raise_for_status()
response=response.json()
actual_permissions = [item.get("permission") for item in response.get("direct_permissions")] #unpacking json like {group:[{},{}]}
actual_groups =[item.get("group") for item in response.get("group")]
assert response.get("email")==new_user_record["email"]
assert response.get("first_name")==user_record_to_update["first_name"]
assert response.get("last_name")==new_user_record["last_name"]
assert response.get("middle_name")==new_user_record["middle_name"]
assert actual_permissions == new_user_record["direct_permissions"]
assert actual_groups == new_user_record["group"]
assert not response.get("hashed_password") or not response.get("plain_password") or not response.get("password")
@pytest.mark.parametrize("test_user_fixture", [
([], [])
], indirect=True)
@pytest.mark.parametrize("user_record_to_update, expected_exception, expected_status",[
pytest.param({"plain_password": "Wrong_pass"},HTTPStatusError,422,id="Wrong_password"),
pytest.param({"email": "Wrong_email"},HTTPStatusError,422,id="Wrong_email"),
pytest.param({},HTTPStatusError, 400,id="Negative_user_update_nothing")
])
async def test_user_update_partially_negative(self, test_user_fixture, user_record_to_update:dict, expected_exception, expected_status:int, target_url:str)->None:
session, new_user_record=test_user_fixture
with allure.step("Update user"), pytest.raises(expected_exception) as exc_info:
response= await session.patch(f"{target_url}/user/patch_user", json=user_record_to_update, params={"email":new_user_record["email"]})
response.raise_for_status()
assert exc_info.value.response.status_code == expected_status
+22
View File
@@ -0,0 +1,22 @@
import pytest_asyncio
from fastapi import Request
from src.service.auth.auth import CurrentUserService
from src.service.users_crud.users_crud import CrudService
@pytest_asyncio.fixture
async def current_user_service()->CurrentUserService:
current_user_service=CurrentUserService()
return current_user_service
@pytest_asyncio.fixture
async def requests(mocker):
fake_request = mocker.MagicMock(spec=Request)
fake_request.headers = {"user-agent": "pytest-agent", "x-forwarded-for":"127.0.0.1"}
return fake_request
@pytest_asyncio.fixture
async def crud_service()->CrudService:
crud_service=CrudService()
return crud_service
+226
View File
@@ -0,0 +1,226 @@
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
from uuid import uuid4
import allure
import pytest
from fastapi import HTTPException, Request
from jose import jwt
from pydantic import ValidationError
from src.models.configs_read.env import env_settings
from src.service.auth.auth import CurrentUserService
from src.service.auth.jwt import HashService, JwtService
current_user_service=CurrentUserService()
@pytest.mark.integra
class TestAuth:
@pytest.mark.parametrize("user_data",[
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True), id="correct_data")
])
async def test_get_current_user_positive(self,current_user_service:CurrentUserService, jwt_service:JwtService, monkeypatch, user_data:SimpleNamespace)->None:
with allure.step("create token"):
token=await jwt_service.create_access_token({"sub":str(uuid4())})
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_id", AsyncMock(return_value=user_data))
with allure.step("test get_current_user_with_fake_token"):
test_result= await current_user_service.get_current_user(token)
assert test_result.first_name==user_data.first_name
assert test_result.last_name==user_data.last_name
assert test_result.middle_name==user_data.middle_name
assert test_result.email==user_data.email
assert test_result.direct_permissions==user_data.direct_permissions
assert test_result.group==user_data.group
assert not hasattr(test_result, "password") or not hasattr(test_result, "plain_password") or not hasattr(test_result, "hashed_password")
assert not hasattr(test_result, "status")
@pytest.mark.parametrize("user_data, uuid, expected_exception,expected_status",[
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=False), uuid4(), HTTPException,401, id="false_status"),
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True),1234, HTTPException,401, id="wrong_id"),
pytest.param(SimpleNamespace(status=True),uuid4(), ValidationError,None,id="empty_model_data")
])
async def test_get_current_user_negative(self,current_user_service:CurrentUserService, jwt_service:JwtService, monkeypatch, user_data:SimpleNamespace,expected_exception, expected_status:int, uuid)->None:
with allure.step("create token"):
token=await jwt_service.create_access_token({"sub":str(uuid)})
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_id", AsyncMock(return_value=user_data))
with allure.step("test get_current_user_with_fake_token"), pytest.raises(expected_exception) as exc_info:
await current_user_service.get_current_user(token)
if expected_exception is HTTPException:
assert exc_info.value.status_code == expected_status
@pytest.mark.parametrize("user_data, form_data_email,form_data_password",[
pytest.param(SimpleNamespace(id=uuid4(),hashed_password="1234", first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True), "d@d.d", "1234", id="correct_data"),
])
async def test_login_positive(self, jwt_service:JwtService,current_user_service:CurrentUserService, monkeypatch, user_data:SimpleNamespace, hash_service:HashService, form_data_email:str, form_data_password:str, requests)->None:
with allure.step("patching db call functions"):
user_data.hashed_password=hash_service.plain_to_hash(user_data.hashed_password)
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_email", AsyncMock(return_value=user_data))
monkeypatch.setattr(current_user_service.jwt_db_actions, "revoke_all", AsyncMock(return_value=True))
monkeypatch.setattr(current_user_service.jwt_db_actions, "create_token", AsyncMock(return_value=True))
fake_request = requests
with allure.step("test login_with_fake_data"):
access, refresh=await current_user_service.login(form_data_email, form_data_password,fake_request)
parts_a=access.split(".")
parts_b=refresh.split(".")
assert isinstance(access, str)
assert len(parts_a)==3
assert isinstance(refresh, str)
assert len(parts_b)==3
@pytest.mark.parametrize("user_data, form_data_email,form_data_password, expected_exception, expected_status",[
pytest.param(SimpleNamespace(id=uuid4(),hashed_password="1234", first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True), "d@d.d", "wrong_password", HTTPException,401, id="wrong_password"),
pytest.param(SimpleNamespace(id=uuid4(),hashed_password="1234", first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=False), "d@d.d", "1234",HTTPException,401, id="false_status"),
pytest.param(SimpleNamespace(id=1234,hashed_password="1234", first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True), "d@d.d", "1234",ValidationError,None, id="wrong_id"),
])
async def test_login_negative(self, current_user_service:CurrentUserService, user_data:SimpleNamespace, jwt_service:JwtService, monkeypatch, requests, hash_service:HashService, form_data_email:str, form_data_password:str, expected_exception, expected_status:int):
with allure.step("patching db call functions"):
user_data.hashed_password=hash_service.plain_to_hash(user_data.hashed_password)
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_email", AsyncMock(return_value=user_data))
monkeypatch.setattr(current_user_service.jwt_db_actions, "revoke_all", AsyncMock(return_value=True))
monkeypatch.setattr(current_user_service.jwt_db_actions, "create_token", AsyncMock(return_value=True))
fake_request = requests
with allure.step("test login_with_fake_data"), pytest.raises(expected_exception) as exc_info:
await current_user_service.login(form_data_email, form_data_password,fake_request)
if expected_exception is HTTPException:
assert exc_info.value.status_code == expected_status
async def test_logout_positive(self, jwt_service:JwtService, monkeypatch, current_user_service:CurrentUserService)->None:
with allure.step("create fake refresh token"):
token=await jwt_service.create_refresh_token({"sub":str(uuid4())})
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.jwt_db_actions, "logout", AsyncMock(return_value=True))
with allure.step("test logout with fake data"):
status=await current_user_service.logout(token[0])
assert status is True
@pytest.mark.parametrize("jti,db_result, expected_exception, expected_status",[
pytest.param(None, True, HTTPException,401, id="jti_none"),
pytest.param(1234, True, HTTPException,401, id="jti_int"),
pytest.param(str(uuid4()), False, HTTPException,404,id="db_result_none"),
])
async def test_logout_negative(self, jwt_service:JwtService, monkeypatch, current_user_service:CurrentUserService, expected_exception, jti, db_result, expected_status:int)->None:
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.jwt_db_actions, "logout", AsyncMock(return_value=db_result) )
async def fake_create_refresh_token(data:dict)->str:
return jwt.encode(data, env_settings.SECRET_KEY, env_settings.ALGORITHM)
monkeypatch.setattr(jwt_service, "create_refresh_token", fake_create_refresh_token)
with allure.step("create fake refresh token"):
token=await fake_create_refresh_token({"sub":str(uuid4()), "jti":jti, "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)})
with allure.step("test logout with fake data"), pytest.raises(expected_exception) as exc_info:
await current_user_service.logout(token)
if expected_exception is HTTPException:
assert exc_info.value.status_code==expected_status
@pytest.mark.parametrize("db_result_token, user_data_result_db", [
pytest.param(SimpleNamespace(is_revoked=False, expires_at=datetime.now(UTC)+timedelta(days=15)),SimpleNamespace(status=True), id="correct_data")
])
async def test_refresh_token_positive(self, monkeypatch, current_user_service:CurrentUserService, db_result_token, requests:Request, jwt_service:JwtService,user_data_result_db )->None:
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.jwt_db_actions,"get_token_by_id", AsyncMock(return_value=db_result_token))
monkeypatch.setattr(current_user_service.jwt_db_actions, "revoke_all", AsyncMock(return_value=True))
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_id", AsyncMock(return_value=user_data_result_db))
monkeypatch.setattr(current_user_service.jwt_db_actions,"create_and_update_token", AsyncMock(return_value=True))
fake_request = requests
async def fake_create_refresh_token(data:dict)->str:
return jwt.encode(data, env_settings.SECRET_KEY, env_settings.ALGORITHM)
monkeypatch.setattr(jwt_service, "create_refresh_token", fake_create_refresh_token)
with allure.step("create fake refresh token"):
token=await fake_create_refresh_token({"sub":str(uuid4()), "jti":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)})
with allure.step("test refresh token with fake data"):
new_access_token, new_refresh_token = await current_user_service.refresh_token(token, fake_request)
parts_a=new_access_token.split(".")
parts_b=new_refresh_token.split(".")
assert isinstance(new_access_token, str)
assert len(parts_a)==3
assert isinstance(new_refresh_token, str)
assert len(parts_b)==3
assert new_access_token!=new_refresh_token
assert new_access_token!=token
assert new_refresh_token!=token
@pytest.mark.parametrize("db_result_token, user_data_result_db, update_result, fake_token_data,expected_exception, expected_status", [
pytest.param(SimpleNamespace(is_revoked=True,user_id="123", expires_at=datetime.now(UTC)+timedelta(days=15)),SimpleNamespace(status=True),False,{"sub":str(uuid4()), "jti":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException,404,id="false_revoke_status"),
pytest.param(SimpleNamespace(is_revoked=False,user_id="123", expires_at=datetime.now(UTC)+timedelta(days=15)),SimpleNamespace(status=True),True,{"sub":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException,401, id="jti_missing"),
pytest.param(None,SimpleNamespace(status=True),True,{"sub":str(uuid4()), "jti":str(uuid4()),"token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException, 404,id="token_missing"),
pytest.param(SimpleNamespace(is_revoked=False,user_id="123", expires_at=datetime.now(UTC)+timedelta(days=15)),SimpleNamespace(status=False),True,{"sub":str(uuid4()), "jti":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException,401,id="false_user_status"),
pytest.param(SimpleNamespace(is_revoked=False, user_id="123",expires_at=datetime.now(UTC)+timedelta(days=15)),None,True,{"sub":str(uuid4()), "jti":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException,404,id="user_missing"),
pytest.param(SimpleNamespace(is_revoked=False,user_id="123", expires_at=datetime.now(UTC)-timedelta(days=15)),SimpleNamespace(status=True),True,{"sub":str(uuid4()), "jti":str(uuid4()), "token_type":"refresh", "exp":datetime.now(UTC)+timedelta(days=45)}, HTTPException,401,id="wrong_exp")
])
async def test_refresh_token_negative(self, monkeypatch, current_user_service:CurrentUserService, db_result_token, requests, jwt_service:JwtService,user_data_result_db, expected_exception, fake_token_data, update_result, expected_status:int)->None:
with allure.step("patching db call functions"):
monkeypatch.setattr(current_user_service.jwt_db_actions,"get_token_by_id", AsyncMock(return_value=db_result_token))
monkeypatch.setattr(current_user_service.jwt_db_actions, "revoke_all", AsyncMock(return_value=True))
monkeypatch.setattr(current_user_service.crud_db_actions, "get_user_by_id", AsyncMock(return_value=user_data_result_db))
monkeypatch.setattr(current_user_service.jwt_db_actions, "create_and_update_token", AsyncMock(return_value=update_result))
fake_request = requests
async def fake_create_refresh_token(data:dict)->str:
return jwt.encode(data, env_settings.SECRET_KEY, env_settings.ALGORITHM)
monkeypatch.setattr(jwt_service, "create_refresh_token", fake_create_refresh_token)
with allure.step("create fake refresh token"):
token=await fake_create_refresh_token(fake_token_data)
with allure.step("test refresh token with fake data"), pytest.raises(expected_exception) as exc_info:
await current_user_service.refresh_token(token, fake_request)
if expected_exception is HTTPException:
assert exc_info.value.status_code==expected_status
+68
View File
@@ -0,0 +1,68 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import allure
import pytest
from fastapi import HTTPException
from src.models.pydantic_models.model import UserCreate, UserUpdate
from src.service.users_crud.users_crud import CrudService
@pytest.mark.integra
class TestCrud:
@pytest.mark.parametrize("user_data", [
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[], plain_password="Test1234!"),id="Create_user_positive")
])
async def test_create_user_positive(self, crud_service:CrudService, monkeypatch, user_data:SimpleNamespace)->None:
with allure.step("Patching functions"):
monkeypatch.setattr(crud_service.crud_db_actions, "create_user", AsyncMock(return_value=user_data))
with allure.step("Test Create User"):
await crud_service.create_user(UserCreate.model_validate(user_data))
@pytest.mark.parametrize("user_data,expected_exception,expected_status ", [
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[], plain_password="Test1234!"),HTTPException,422,id="Create_user_None")
])
async def test_create_user_negative(self, crud_service:CrudService, monkeypatch, user_data:SimpleNamespace, expected_exception, expected_status:int)->None:
with allure.step("Patching functions"):
monkeypatch.setattr(crud_service.crud_db_actions, "create_user", AsyncMock(return_value=None))
with allure.step("Test Create User"), pytest.raises(expected_exception) as exc_info:
await crud_service.create_user(UserCreate.model_validate(user_data))
if expected_exception is HTTPException:
assert exc_info.value.status_code == expected_status
@pytest.mark.parametrize("user_data", [
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[], plain_password="Test1234!"),id="Create_user_positive")
])
async def test_update_user_positive(self, crud_service:CrudService, monkeypatch, user_data:SimpleNamespace)->None:
with allure.step("Patching functions"):
monkeypatch.setattr(crud_service.crud_db_actions, "update_user_partially", AsyncMock(return_value=user_data))
with allure.step("Test Update User"):
await crud_service.update_user(user_data.email,UserUpdate.model_validate(user_data))
@pytest.mark.parametrize("user_data,expected_exception,expected_status ", [
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[], plain_password="Test1234!"),HTTPException,404,id="Update_user_None"),
pytest.param(SimpleNamespace(),HTTPException,400,id="Empty_user_data")
])
async def test_update_user_negative(self, crud_service:CrudService, monkeypatch, user_data:SimpleNamespace, expected_exception, expected_status:int)->None:
with allure.step("Patching functions"):
monkeypatch.setattr(crud_service.crud_db_actions, "update_user_partially", AsyncMock(return_value=None))
with allure.step("Test Update User"), pytest.raises(expected_exception) as exc_info:
await crud_service.update_user("d@d.d",UserUpdate.model_validate(user_data))
if expected_exception is HTTPException:
assert exc_info.value.status_code == expected_status
+9
View File
@@ -0,0 +1,9 @@
import pytest_asyncio
from src.service.users_crud.users_crud import CrudService
@pytest_asyncio.fixture
async def crud_service()->CrudService:
crud_service=CrudService()
return crud_service
+103
View File
@@ -0,0 +1,103 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock
import allure
import pytest
from fastapi import HTTPException
from src.service.users_crud.users_crud import CrudService
@pytest.mark.unit
class TestCrud:
@pytest.mark.parametrize("user_data",[
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True),id="Get_user_by_email_positive")
])
async def test_get_user_by_email_positive(self, monkeypatch, user_data:SimpleNamespace, crud_service:CrudService)->None:
with allure.step("Patching functions"):
monkeypatch.setattr(crud_service.crud_db_actions, "get_user_by_email",AsyncMock(return_value=user_data))
with allure.step("Test get_by_email"):
test_result = await crud_service.get_user_by_email(user_data.email)
assert test_result.email==user_data.email
assert test_result.first_name==user_data.first_name
assert test_result.last_name==user_data.last_name
assert test_result.middle_name==user_data.middle_name
assert test_result.email==user_data.email
assert test_result.email==user_data.email
assert not hasattr(test_result, "password") or not hasattr(test_result, "plain_password") or not hasattr(test_result, "hashed_password")
assert test_result.direct_permissions==user_data.direct_permissions
assert test_result.group==user_data.group
assert not hasattr(test_result, "status")
@pytest.mark.parametrize("email, expected_exception, expected_status",[
pytest.param("Wrong_email", HTTPException, 404,id="Wrong_email"),
pytest.param("",HTTPException, 404,id="Empty_email"),
])
async def test_get_user_by_email_negative(self, email, crud_service:CrudService, expected_exception, expected_status:int)->None:
with allure.step("Test get_by_email"), pytest.raises(expected_exception) as exc_info:
await crud_service.get_user_by_email(email)
if expected_exception is HTTPException:
assert exc_info.value.status_code==expected_status
@pytest.mark.parametrize("user_data",[
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True),id="Get_user_by_email_positive")
])
async def test_delete_user_soft_positive(self, monkeypatch, user_data:SimpleNamespace, crud_service:CrudService)->None:
with allure.step("Patching functions"):
monkeypatch.setattr(crud_service.crud_db_actions, "delete_user_soft",AsyncMock(return_value=True))
with allure.step("Test get_by_email"):
test_result = await crud_service.delete_user_soft(user_data.email)
assert test_result == True
@pytest.mark.parametrize("email, expected_exception, expected_status",[
pytest.param("Wrong_email", HTTPException, 404,id="Wrong_email"),
pytest.param("",HTTPException, 404,id="Empty_email"),
])
async def test_delete_user_soft_negative(self, email, crud_service:CrudService, expected_exception, expected_status:int)->None:
with allure.step("Test get_by_email"), pytest.raises(expected_exception) as exc_info:
await crud_service.delete_user_soft(email)
if expected_exception is HTTPException:
assert exc_info.value.status_code==expected_status
@pytest.mark.parametrize("user_data",[
pytest.param(SimpleNamespace(first_name="test",last_name="test",middle_name="test",email="d@d.d",direct_permissions=[],group=[],status=True),id="Get_user_by_email_positive")
])
async def test_delete_user_hard_positive(self, monkeypatch, user_data:SimpleNamespace, crud_service:CrudService)->None:
with allure.step("Patching functions"):
monkeypatch.setattr(crud_service.crud_db_actions, "delete_user_hard",AsyncMock(return_value=True))
with allure.step("Test get_by_email"):
test_result = await crud_service.delete_user_hard(user_data.email, "current_user")
assert test_result==True
@pytest.mark.parametrize("email, expected_exception, expected_status",[
pytest.param("Wrong_email", HTTPException, 404,id="Wrong_email"),
pytest.param("",HTTPException, 404,id="Empty_email"),
])
async def test_delete_user_hard_negative(self, email, crud_service:CrudService, expected_exception, expected_status:int)->None:
with allure.step("Test get_by_email"), pytest.raises(expected_exception) as exc_info:
await crud_service.delete_user_hard(email, "current_user")
if expected_exception is HTTPException:
assert exc_info.value.status_code==expected_status
+148
View File
@@ -0,0 +1,148 @@
from datetime import UTC, datetime, timedelta
import allure
import pytest
from fastapi import HTTPException
from jose import jwt
from src.models.configs_read.env import env_settings
from src.service.auth.jwt import HashService, JwtService
@pytest.mark.unit
class TestJwt:
@pytest.mark.parametrize("data", [
pytest.param({"sub": "123"}, id="full_sub")
])
async def test_access_create_positive(self, jwt_service:JwtService, data:dict)->None:
with allure.step("create correct access token"):
token = await jwt_service.create_access_token(data)
parts=token.split(".")
assert isinstance(token, str)
assert len(parts)==3
@pytest.mark.parametrize("data, expected_exception, expected_status",[
pytest.param("", AttributeError,None,id="not_dict_value"),
pytest.param({"sub":""},HTTPException,401,id="empty_value"),
pytest.param({"":""},HTTPException,401,id="empty_key_value")
])
async def test_access_create_negative(self, jwt_service:JwtService, data:dict, expected_exception, expected_status:int)->None:
with allure.step("create invalid access token"),pytest.raises(expected_exception) as exc_info:
await jwt_service.create_access_token(data)
if expected_exception is HTTPException:
assert expected_status == exc_info.value.status_code
@pytest.mark.parametrize("data", [
pytest.param({"sub": "123"}, id="full_sub")
])
async def test_refresh_create_positive(self, jwt_service:JwtService, data:dict)->None:
with allure.step("create correct access token"):
token = await jwt_service.create_refresh_token(data)
parts=token[0].split(".")
assert isinstance(token[0], str)
assert isinstance(token[1], str)
assert len(parts)==3
@pytest.mark.parametrize("data, expected_exception",[
pytest.param("", AttributeError,id="not_dict_value"),
pytest.param({"sub":""},HTTPException, id="empty_value"),
pytest.param({"":""},HTTPException, id="empty_key_value")
])
async def test_refresh_create_negative(self, jwt_service:JwtService, data, expected_exception)->None:
with allure.step("create invalid access token"), pytest.raises(expected_exception):
await jwt_service.create_refresh_token(data)
@pytest.mark.parametrize("data", [
pytest.param({"sub": "123", "exp":datetime.now(UTC)+timedelta(minutes=15), "token_type":"access"}, id="correct_data"),
])
async def test_jwt_decode_positive(self, data:dict, monkeypatch, jwt_service:JwtService)->None:
with allure.step("patch a create token function"):
async def fake_create_access_token(data:dict)->str:
return jwt.encode(data, env_settings.SECRET_KEY, env_settings.ALGORITHM)
monkeypatch.setattr(jwt_service, "create_access_token", fake_create_access_token)
with allure.step("create and decode correct token"):
fake_token = await jwt_service.create_access_token(data)
payload=await jwt_service.jwt_decode(fake_token)
assert payload.get("sub")
assert payload.get("exp")
assert payload.get("token_type")
@pytest.mark.parametrize("data, expected_exception, expected_status", [
pytest.param({"sub": "123", "exp":datetime.now(UTC)-timedelta(minutes=15), "token_type":"access"}, HTTPException,401, id="wrong_exp"),
pytest.param({"sub": "123", "exp":datetime.now(UTC)+timedelta(minutes=15)}, HTTPException, 401,id="no_token_type"),
pytest.param({"sub": "123", "token_type":"access"}, HTTPException,401,id="no_exp"),
pytest.param({}, HTTPException, 401,id="empty_data"),
pytest.param("", AttributeError, None,id="not_dict_data")
])
async def test_jwt_decode_invalid(self,jwt_service:JwtService, expected_exception, data, monkeypatch, expected_status)->None:
with allure.step("patch a create token function"):
async def fake_create_access_token(data:dict)->str:
return jwt.encode(data, env_settings.SECRET_KEY, env_settings.ALGORITHM)
monkeypatch.setattr(jwt_service, "create_access_token", fake_create_access_token)
with allure.step("create and decode invalid token"), pytest.raises(expected_exception) as exc_info:
fake_token=await jwt_service.create_access_token(data)
await jwt_service.jwt_decode(fake_token)
if expected_exception is HTTPException:
assert expected_status == exc_info.value.status_code
@pytest.mark.parametrize("time, key, algorithm", [
pytest.param(15, "wrong_key", "HS256",id="wrong_key"),
pytest.param(-15, "correct_key", "HS256", id="wrong_time"),
pytest.param(15, "correct_key", "HS512", id="wrong_algorithm"),
])
async def test_jwt_decode_wrong_env(self, monkeypatch, time:int, key:str, algorithm:str, jwt_service)->None:
with allure.step("patch a create token function"):
async def fake_create_access_token(data:dict, key:str, algorithm:str)->str:
data.update({"exp":datetime.now(UTC)+timedelta(minutes=time)})
return jwt.encode(data, key, algorithm)
monkeypatch.setattr(jwt_service, "create_access_token", fake_create_access_token)
with allure.step("create and decode token with wrong data inside"), pytest.raises(HTTPException):
fake_token=await jwt_service.create_access_token({"sub": "123", "token_type":"access"}, key, algorithm)
await jwt_service.jwt_decode(fake_token)
@pytest.mark.parametrize("password",[
pytest.param("plain_password", id="correct_plain_password")
])
async def test_hash_and_veryfy_positive(self, password:str, hash_service:HashService)->None:
with allure.step("encode password"):
encoded_password=hash_service.plain_to_hash(password)
assert isinstance(encoded_password, str)
assert encoded_password!=password
with allure.step("decode password"):
assert hash_service.verify_password(password, encoded_password) is True
async def test_verify_wrong_password(self, hash_service:HashService)->None:
with allure.step("encode password"):
encoded_password=hash_service.plain_to_hash("plain_password")
with allure.step("decode password"):
assert hash_service.verify_password("wrong_password", encoded_password) is False
async def test_token_to_hash_determistic(self, hash_service:HashService)->None:
assert hash_service.token_to_hash("abc")==hash_service.token_to_hash("abc")
async def test_token_to_hash_different_input(self, hash_service:HashService)->None:
assert hash_service.token_to_hash("abc")!=hash_service.token_to_hash("xyz")