From 83d4246f7291a5aa3f12b9e3fb9e77df985fadb0 Mon Sep 17 00:00:00 2001 From: Paul Sanders Date: Thu, 14 Nov 2024 16:50:05 -0500 Subject: [PATCH 1/6] Setup backend for users --- .github/dependabot.yml | 18 + .github/workflows/testing.yml | 64 ++ .gitignore | 141 ++++ .pre-commit-config.yaml | 20 + backend/.dockerignore | 12 + backend/.gitignore | 141 ++++ backend/Dockerfile | 49 ++ backend/app/__init__.py | 3 + backend/app/_version.py | 1 + backend/app/api/__init__.py | 0 backend/app/api/deps.py | 76 ++ backend/app/api/router.py | 7 + backend/app/api/routes/__init__.py | 0 backend/app/api/routes/health.py | 28 + backend/app/api/routes/login.py | 45 ++ backend/app/api/routes/users.py | 339 ++++++++ backend/app/core/__init__.py | 0 backend/app/core/config.py | 93 +++ backend/app/core/db.py | 153 ++++ backend/app/core/security.py | 28 + backend/app/core/utils.py | 38 + backend/app/exceptions.py | 10 + backend/app/main.py | 59 ++ backend/app/migrations/0001_initial_setup.sql | 10 + backend/app/models/__init__.py | 0 backend/app/models/message.py | 7 + backend/app/models/token.py | 14 + backend/app/models/users.py | 65 ++ backend/app/py.typed | 0 backend/app/services/__init__.py | 0 backend/app/services/db_services.py | 5 + backend/app/services/user_services.py | 201 +++++ backend/pyproject.toml | 98 +++ backend/scripts/entrypoint.sh | 3 + backend/tests/__init__.py | 0 backend/tests/api/__init__.py | 0 backend/tests/api/routes/__init__.py | 0 backend/tests/api/routes/test_health_route.py | 6 + backend/tests/api/routes/test_login_routes.py | 32 + backend/tests/api/routes/test_user_routes.py | 483 +++++++++++ backend/tests/conftest.py | 94 +++ backend/tests/core/__init__.py | 0 backend/tests/core/test_config.py | 35 + backend/tests/core/test_security.py | 11 + backend/tests/utils.py | 24 + backend/uv.lock | 748 ++++++++++++++++++ docker-compose.yml | 19 + justfile | 62 ++ 48 files changed, 3242 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/testing.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 backend/.dockerignore create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/app/__init__.py create mode 100644 backend/app/_version.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/router.py create mode 100644 backend/app/api/routes/__init__.py create mode 100644 backend/app/api/routes/health.py create mode 100644 backend/app/api/routes/login.py create mode 100644 backend/app/api/routes/users.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/db.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/core/utils.py create mode 100644 backend/app/exceptions.py create mode 100644 backend/app/main.py create mode 100644 backend/app/migrations/0001_initial_setup.sql create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/message.py create mode 100644 backend/app/models/token.py create mode 100644 backend/app/models/users.py create mode 100644 backend/app/py.typed create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/db_services.py create mode 100644 backend/app/services/user_services.py create mode 100644 backend/pyproject.toml create mode 100755 backend/scripts/entrypoint.sh create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/api/__init__.py create mode 100644 backend/tests/api/routes/__init__.py create mode 100644 backend/tests/api/routes/test_health_route.py create mode 100644 backend/tests/api/routes/test_login_routes.py create mode 100644 backend/tests/api/routes/test_user_routes.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/core/__init__.py create mode 100644 backend/tests/core/test_config.py create mode 100644 backend/tests/core/test_security.py create mode 100644 backend/tests/utils.py create mode 100644 backend/uv.lock create mode 100644 docker-compose.yml create mode 100644 justfile diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..f4e104f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/backend" + schedule: + interval: weekly + day: monday + labels: + - skip-changelog + - dependencies + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + labels: + - skip-changelog + - dependencies diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 0000000..6ceffdb --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,64 @@ +name: Testing + +on: + push: + branches: + - main + pull_request: +env: + PYTHON_VERSION: "3.13" +jobs: + docker-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: install Just + uses: taiki-e/install-action@just + - name: Build backend image + run: just docker-build-backend + linting: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: install Just + uses: taiki-e/install-action@just + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: Install Dependencies + run: just backend-install + - name: Ruff format check + run: just ruff-format-ci + - name: Lint with ruff + run: just ruff-check + - name: mypy check + run: just mypy + testing: + strategy: + fail-fast: false + matrix: + python-version: ["3.13"] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: install Just + uses: taiki-e/install-action@just + - name: Install uv + uses: astral-sh/setup-uv@v3 + with: + enable-cache: true + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install Dependencies + run: just backend-install + - name: Start docker containers + run: just docker-up-detach + - name: Test with pytest + run: just backend-test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e15ed18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,141 @@ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# OS Files +*.swp +*.DS_Store + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pixi environments +.pixi + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# editors +.idea +.vscode diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..fe88ee7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,20 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: check-added-large-files + - id: check-toml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: trailing-whitespace + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.13.0 + hooks: + - id: mypy + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.7.3 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + - id: ruff-format diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..37eeb9b --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,12 @@ +__pycache__ +app.egg-info +*.pyc +.mypy_cache +.pytest_cache +.ruff_cache +.coverage +htmlcov +.cache +.venv +.env +*.log diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..e15ed18 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,141 @@ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# OS Files +*.swp +*.DS_Store + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pixi environments +.pixi + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# editors +.idea +.vscode diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..f0c3ce6 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,49 @@ +FROM python:3.13-slim-bookworm AS builder + +WORKDIR /app + +ENV \ + PYTHONUNBUFFERED=true \ + PATH=/root/.cargo/bin:$PATH + +RUN : \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + curl \ + ca-certificates \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +ADD https://astral.sh/uv/install.sh /uv-installer.sh + +RUN sh /uv-installer.sh && rm /uv-installer.sh + +ENV PATH="/root/.local/bin/:$PATH" + +COPY . /app + +RUN : \ + && uv venv $VIRTUAL_ENV \ + && uv sync --frozen --no-cache --no-dev --no-install-project + +FROM python:3.13-slim-bookworm + +WORKDIR /app + +ENV PYTHONBUFFERED=true + +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /app/app /app/app +COPY ./scripts/entrypoint.sh /app + +RUN chmod +x /app/entrypoint.sh + +ENV \ + PATH="/app/.venv/bin:$PATH" \ + WORKERS="1" \ + PORT="8000" + + +EXPOSE 8000 + +ENTRYPOINT ["./entrypoint.sh"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..7054ce7 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,3 @@ +from app._version import VERSION + +__version__ = VERSION diff --git a/backend/app/_version.py b/backend/app/_version.py new file mode 100644 index 0000000..1cf6267 --- /dev/null +++ b/backend/app/_version.py @@ -0,0 +1 @@ +VERSION = "0.1.0" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..619afd1 --- /dev/null +++ b/backend/app/api/deps.py @@ -0,0 +1,76 @@ +from collections.abc import AsyncGenerator +from typing import Annotated + +import asyncpg +import jwt +from fastapi import Depends, HTTPException +from fastapi.security import OAuth2PasswordBearer +from jwt.exceptions import InvalidTokenError +from loguru import logger +from pydantic import ValidationError +from starlette.status import ( + HTTP_400_BAD_REQUEST, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_503_SERVICE_UNAVAILABLE, +) + +from app.core.config import settings +from app.core.db import db +from app.core.security import ALGORITHM +from app.models.token import TokenPayload +from app.models.users import UserInDb +from app.services.user_services import get_user_by_id + +reusable_oauth2 = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_PREFIX}/login/access-token") +TokenDep = Annotated[str, Depends(reusable_oauth2)] + + +async def get_db_conn() -> AsyncGenerator[asyncpg.Connection, None]: + if db.pool is None: + logger.error("No database pool created") + raise HTTPException( + status_code=HTTP_503_SERVICE_UNAVAILABLE, detail="The database is currently unavailable" + ) + async with db.pool.acquire() as connection: + yield connection + + +DbConn = Annotated[asyncpg.Connection, Depends(get_db_conn)] + + +async def get_current_user(conn: DbConn, token: TokenDep) -> UserInDb: + try: + logger.debug("Decoding JWT token") + payload = jwt.decode(token, key=settings.SECRET_KEY, algorithms=[ALGORITHM]) + token_data = TokenPayload(**payload) + except (InvalidTokenError, ValidationError) as e: + logger.debug(f"Error decoding token: {e}") + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail="Could not validate credentials", + ) from e + if not token_data.sub: + logger.debug("Token does not countain sub data") + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, detail="Count not validate credientials" + ) + user_id = token_data.sub + user = await get_user_by_id(conn, user_id=user_id) + if not user: + logger.debug("User not found") + raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="User not found") + if not user.is_active: + logger.debug("User is inactive") + raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="Inactive user") + return user + + +CurrentUser = Annotated[UserInDb, Depends(get_current_user)] + + +def get_current_active_superuser(current_user: CurrentUser) -> UserInDb: + if not current_user.is_superuser: + logger.debug("The current user is not a super user") + raise HTTPException(status_code=403, detail="The user doesn't have enough privileges") + return current_user diff --git a/backend/app/api/router.py b/backend/app/api/router.py new file mode 100644 index 0000000..e9cc08a --- /dev/null +++ b/backend/app/api/router.py @@ -0,0 +1,7 @@ +from app.api.routes import health, login, users +from app.core.utils import APIRouter + +api_router = APIRouter() +api_router.include_router(health.router) +api_router.include_router(login.router) +api_router.include_router(users.router) diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routes/health.py b/backend/app/api/routes/health.py new file mode 100644 index 0000000..e24bfcc --- /dev/null +++ b/backend/app/api/routes/health.py @@ -0,0 +1,28 @@ +from loguru import logger + +from app.api.deps import DbConn +from app.core.config import settings +from app.core.utils import APIRouter +from app.services.db_services import ping + +router = APIRouter( + tags=["Health"], prefix=f"{settings.API_V1_PREFIX}/health", include_in_schema=False +) + + +@router.get("/") +async def health(*, db_conn: DbConn) -> dict[str, str]: + """Check the health of the server.""" + + logger.debug("Checking health") + health = {"server": "healthy"} + + logger.debug("Checking db health") + try: + await ping(db_conn) + health["db"] = "healthy" + except Exception as e: + logger.error(f"Unable to ping the database: {e}") + health["db"] = "unhealthy" + + return health diff --git a/backend/app/api/routes/login.py b/backend/app/api/routes/login.py new file mode 100644 index 0000000..a6c3ec2 --- /dev/null +++ b/backend/app/api/routes/login.py @@ -0,0 +1,45 @@ +from datetime import timedelta +from typing import Annotated, Any + +from fastapi import Depends, HTTPException +from fastapi.security import OAuth2PasswordRequestForm +from loguru import logger +from starlette.status import HTTP_400_BAD_REQUEST + +from app.api.deps import CurrentUser, DbConn +from app.core import security +from app.core.config import settings +from app.core.utils import APIRouter +from app.models.token import Token +from app.models.users import UserPublic +from app.services import user_services + +router = APIRouter(tags=["Login"], prefix=f"{settings.API_V1_PREFIX}") + + +@router.post("/login/access-token") +async def login_access_token( + conn: DbConn, form_data: Annotated[OAuth2PasswordRequestForm, Depends()] +) -> Token: + """OAuth2 compatible token login, get an access token for future requests.""" + + logger.debug("Authenticating user") + user = await user_services.authenticate( + conn, email=form_data.username, password=form_data.password + ) + if not user: + logger.debug("Incorrect email or password") + raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="Incorrect email or password") + elif not user.is_active: + logger.debug("Inactive user") + raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="Inactive user") + access_token_expires = timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + return Token( + access_token=security.create_access_token(str(user.id), expires_delta=access_token_expires) + ) + + +@router.post("/login/test-token", response_model=UserPublic) +async def test_token(*, current_user: CurrentUser) -> Any: + """Test access token.""" + return current_user diff --git a/backend/app/api/routes/users.py b/backend/app/api/routes/users.py new file mode 100644 index 0000000..a6048a7 --- /dev/null +++ b/backend/app/api/routes/users.py @@ -0,0 +1,339 @@ +from fastapi import Depends, HTTPException +from loguru import logger +from starlette.status import ( + HTTP_400_BAD_REQUEST, + HTTP_403_FORBIDDEN, + HTTP_404_NOT_FOUND, + HTTP_409_CONFLICT, + HTTP_500_INTERNAL_SERVER_ERROR, +) + +from app.api.deps import CurrentUser, DbConn, get_current_active_superuser +from app.core.config import settings +from app.core.security import verify_password +from app.core.utils import APIRouter +from app.models.message import Message +from app.models.users import ( + UpdatePassword, + UserCreate, + UserInDb, + UserPublic, + UserRegister, + UsersPublic, + UserUpdate, + UserUpdateMe, +) +from app.services import user_services + +router = APIRouter(tags=["Users"], prefix=f"{settings.API_V1_PREFIX}/users") + + +@router.get("/", dependencies=[Depends(get_current_active_superuser)]) +async def read_users(*, db_conn: DbConn, offset: int = 0, limit: int = 100) -> UsersPublic: + """Retrieve users.""" + + logger.debug(f"Getting users with offset {offset} and limit {limit}") + try: + users = await user_services.get_users_public(db_conn, offset=offset, limit=limit) + except Exception as e: + logger.error( + f"An error occurred while retrieving users with skip: {offset} and limit: {limit}: {e}" + ) + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while retrieving users", + ) from e + + count = len(users) if users else 0 + data = users if users else [] + + return UsersPublic(data=data, count=count) + + +@router.post("/", dependencies=[Depends(get_current_active_superuser)], response_model=UserPublic) +async def create_user(*, db_conn: DbConn, user_in: UserCreate) -> UserInDb: + """Create a new user.""" + + logger.debug("Creating new user") + try: + user = await user_services.get_user_by_email(db_conn, email=user_in.email) + except Exception as e: + logger.error( + f"An error occurred while checking if the email {user_in.email} already exists for creating a user: {e}" + ) + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while creating the user.", + ) from e + + if user: + logger.debug(f"User with email address {user_in.email} already exists") + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail="A user with this email address already exists in the system", + ) + + try: + created_user = await user_services.create_user(db_conn, user=user_in) + except Exception as e: + logger.error( + f"An error occurred while creating the user with email address {user_in.email}: {e}" + ) + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while creating the user", + ) from e + + return created_user + + +@router.patch("/me", response_model=UserPublic) +async def update_user_me( + *, db_conn: DbConn, user_in: UserUpdateMe, current_user: CurrentUser +) -> UserInDb: + """Update own user.""" + + logger.debug("Updating current user") + if user_in.email: + try: + existing_user = await user_services.get_user_by_email(db_conn, email=user_in.email) + except Exception as e: + logger.error( + f"An error occurred while updating me, checking if the email already exists: {e}" + ) + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while updating the user", + ) from e + + if existing_user and existing_user.id != current_user.id: + logger.debug(f"User with email address {user_in.email} already exists") + raise HTTPException( + status_code=HTTP_409_CONFLICT, + detail="A user with this email address already exists", + ) + + try: + updated_user = await user_services.update_user( + db_conn, db_user=current_user, user_in=user_in + ) + except Exception as e: + logger.error(f"An error occurred while updating me: {e}") + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while updating the user", + ) from e + + return updated_user + + +@router.patch("/me/password") +async def update_password_me( + *, + db_conn: DbConn, + user_in: UpdatePassword, + current_user: CurrentUser, +) -> Message: + """Update own password.""" + + if not verify_password(user_in.current_password, current_user.hashed_password): + logger.debug("Passwords do not match") + raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="Incorrect password") + if user_in.current_password == user_in.new_password: + logger.debug("Password not changed") + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail="New password cannot be the same as the current one", + ) + + try: + logger.debug("Updating password") + await user_services.update_user(db_conn, db_user=current_user, user_in=user_in) + except Exception as e: + logger.error(f"An error occurred updating the password: {e}") + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while updating the password", + ) from e + + return Message(message="Password updated successfully") + + +@router.get("/me", response_model=UserPublic) +async def read_user_me(*, current_user: CurrentUser) -> UserInDb: + """Get current user.""" + + return current_user + + +@router.delete("/me") +async def delete_user_me(*, db_conn: DbConn, current_user: CurrentUser) -> Message: + """Delete own user.""" + + logger.debug("Deleting current user") + if current_user.is_superuser: + logger.debug("Super users are not allowed to delete themselves") + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail="Super users are not allowed to delete themselves", + ) + + try: + await user_services.delete_user(db_conn, user_id=current_user.id) + except Exception as e: + logger.error(f"An error occurred while deleting the user: {e}") + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while deleting the user", + ) from e + return Message(message="User deleted successfully") + + +@router.post("/signup", response_model=UserPublic) +async def register_user(*, db_conn: DbConn, user_in: UserRegister) -> UserInDb: + """Create new user without the need to be logged in.""" + + logger.debug("Registering user") + try: + user = await user_services.get_user_by_email(db_conn, email=user_in.email) + except Exception as e: + logger.error( + f"An error occurred while checking if user with email {user_in.email} exists for registering: {e}" + ) + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while registering" + ) from e + + if user: + logger.debug(f"User with email address {user_in.email} already exists") + raise HTTPException( + status_code=HTTP_400_BAD_REQUEST, + detail="The user with this email already exists in the system", + ) + user_create = UserCreate(**user_in.model_dump()) + try: + created_user = await user_services.create_user(db_conn, user=user_create) + except Exception as e: + logger.error(f"An error occurred while creating the user in registration: {e}") + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, detail="An error occurred while registering" + ) from e + + return created_user + + +@router.get("/{user_id}") +async def read_user_by_id( + *, db_conn: DbConn, user_id: str, current_user: CurrentUser +) -> UserPublic: + """Get a specific user by id.""" + + logger.debug(f"Getting user with id {user_id}") + try: + user = await user_services.get_user_public_by_id(db_conn, user_id=user_id) + except Exception as e: + logger.error(f"An error occurred while retrieving user with id {user_id}: {e}") + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while retrieving the user", + ) from e + + if user is None: + logger.debug(f"User with id {user_id} not found") + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail="The user with this id does not exist in the system", + ) + + if user.id == current_user.id: + return user + if not current_user.is_superuser: + logger.debug("Current user is not an admin and does not have enough provileges to get user") + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail="The user doesn't have enough privileges", + ) + return user + + +@router.patch( + "/{user_id}", + dependencies=[Depends(get_current_active_superuser)], + response_model=UserPublic, +) +async def update_user( + *, + db_conn: DbConn, + user_id: str, + user_in: UserUpdate, +) -> UserInDb: + """Update a user.""" + + logger.debug(f"Updating user {user_id}") + try: + db_user = await user_services.get_user_by_id(db_conn, user_id=user_id) + except Exception as e: + logger.error(f"An error occurred while retrieving user {user_id} for updating: {e}") + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while retrieving the user for updating", + ) from e + + if not db_user: + logger.debug(f"User with id {user_id} not found") + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail="The user with this id does not exist in the system", + ) + if user_in.email: + existing_user = await user_services.get_user_by_email(db_conn, email=user_in.email) + if existing_user and existing_user.id != user_id: + logger.debug(f"A user with email {user_in.email} already exists") + raise HTTPException( + status_code=HTTP_409_CONFLICT, detail="User with this email already exists" + ) + + try: + db_user = await user_services.update_user(db_conn, db_user=db_user, user_in=user_in) + except Exception as e: + logger.error(f"An error occurred while updating user {user_id}: {e}") + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while updating the user", + ) from e + + return db_user + + +@router.delete("/{user_id}", dependencies=[Depends(get_current_active_superuser)]) +async def delete_user(*, db_conn: DbConn, current_user: CurrentUser, user_id: str) -> Message: + """Delete a user.""" + + logger.debug(f"Deleting user with id {user_id}") + try: + user = await user_services.get_user_by_id(db_conn, user_id=user_id) + except Exception as e: + logger.error(f"An error occurred while retrieving user {user_id} for deleting: {e}") + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while retrieving the user for deleting", + ) from e + + if not user: + logger.debug(f"User with id {user_id} not found") + raise HTTPException(status_code=HTTP_404_NOT_FOUND, detail="User not found") + if user == current_user: + logger.debug("Super users are not allowed to delete themselves") + raise HTTPException( + status_code=HTTP_403_FORBIDDEN, + detail="Super users are not allowed to delete themselves", + ) + try: + await user_services.delete_user(db_conn, user_id=user_id) + except Exception as e: + logger.error(f"An error occurred while delete the user: {e}") + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred while deleting the user", + ) from e + return Message(message="User deleted successfully") diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..279c864 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,93 @@ +import warnings +from pathlib import Path +from typing import Annotated, Any, Final, Literal, Self + +from pydantic import ( + AnyUrl, + BeforeValidator, + EmailStr, + computed_field, + field_validator, + model_validator, +) +from pydantic_settings import BaseSettings, SettingsConfigDict + + +def _parse_cors(v: Any) -> list[str] | str: + if isinstance(v, str) and not v.startswith("["): + return [i.strip() for i in v.split(",")] + elif isinstance(v, list | str): + return v + raise ValueError(v) + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8") + + API_V1_PREFIX: str = "/api/v1" + TITLE: Final = "SCAN" + SECRET_KEY: str + # 60 minutes * 24 hours * 8 days = 8 days + ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 * 24 * 8 + ENVIRONMENT: Literal["local", "production"] = "local" + DOMAIN: str = "127.0.0.1" + FIRST_SUPERUSER_EMAIL: EmailStr + FIRST_SUPERUSER_PASSWORD: str + ORGANIZATION: Final = "SCAN" + BACKEND_CORS_ORIGINS: Annotated[list[AnyUrl] | str, BeforeValidator(_parse_cors)] = [] + LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" + LOG_PATH: str | Path | None = None + LOG_TO_SCREEN_AND_FILE: bool = False + PRODUCTION_MODE: bool = True + POSTGRES_HOST: str + POSTGRES_PORT: int = 5432 + POSTGRES_USER: str + POSTGRES_PASSWORD: str + POSTGRES_DB: str = "scan" + OPENAI_API_KEY: str + DLPFC_MODEL: str = "gpt-3.5-turbo" + VMPFC_MODEL: str = "gpt-3.5-turbo" + OFC_MODEL: str = "gpt-3.5-turbo" + ACC_MODEL: str = "gpt-3.5-turbo" + MPFC_MODEL: str = "gpt-3.5-turbo" + MAX_TOKENS: int = 500 + TEMPERATURE: float = 0.7 + + @computed_field # type: ignore[prop-decorator] + @property + def server_host(self) -> str: + # Use HTTPS for anything other than local development + if self.ENVIRONMENT == "local": + return f"http://{self.DOMAIN}" + return f"https://{self.DOMAIN}" + + def _check_default_secret(self, var_name: str, value: str | None) -> None: + if value == "changethis": + message = ( + f'The value of {var_name} is "changethis", ' + "for security, please change it, at least for deployments." + ) + if self.ENVIRONMENT == "local": + warnings.warn(message, stacklevel=1) + else: + raise ValueError(message) + + @field_validator("TEMPERATURE", mode="before") + @classmethod + def validate_temperature(cls, temperature: float) -> float: + if not (0 <= temperature <= 1.0): + raise ValueError("Temperature must be between 0.0 and 1.0") + + return temperature + + @model_validator(mode="after") + def _enforce_non_default_secrets(self) -> Self: + self._check_default_secret("SECRET_KEY", self.SECRET_KEY) + self._check_default_secret("FIRST_SUPERUSER_PASSWORD", self.FIRST_SUPERUSER_PASSWORD) + self._check_default_secret("POSTGRES_USER", self.POSTGRES_USER) + self._check_default_secret("POSTGRES_PASSWORD", self.POSTGRES_PASSWORD) + + return self + + +settings = Settings() # type: ignore diff --git a/backend/app/core/db.py b/backend/app/core/db.py new file mode 100644 index 0000000..01289c8 --- /dev/null +++ b/backend/app/core/db.py @@ -0,0 +1,153 @@ +from pathlib import Path + +import asyncpg +from loguru import logger + +from app.core.config import settings +from app.core.security import get_password_hash +from app.core.utils import create_db_primary_key +from app.exceptions import NoDbPoolError +from app.services.user_services import get_user_by_email + + +class Database: + def __init__(self) -> None: + self.pool: asyncpg.Pool | None = None + + async def create_pool(self) -> None: + self.pool = await asyncpg.create_pool( + user=settings.POSTGRES_USER, + password=settings.POSTGRES_PASSWORD, + database=settings.POSTGRES_DB, + host=settings.POSTGRES_HOST, + port=settings.POSTGRES_PORT, + max_size=10, + ) + + async def close_pool(self) -> None: + if self.pool: + await self.pool.close() + + async def _create_migration_table(self) -> None: + if self.pool is None: + logger.error("No db pool created") + raise NoDbPoolError("No db pool created") + + query = """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + migrated_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + """ + + logger.debug("Creating migration table") + async with self.pool.acquire() as connection: + await connection.execute(query) + + async def _get_pending_migrations(self) -> list[dict[str, str | int]]: + if self.pool is None: + logger.error("No db pool created") + raise NoDbPoolError("No db pool created") + + logger.debug("Checking for migrations") + migrations = [] + try: + migrations_path = Path().absolute() / "app" / "migrations" + for path in migrations_path.iterdir(): + if not path.is_file(): + continue + migration: dict[str, str | int] = {} + migration["name"] = path.name + migration["content"] = path.read_text() + migration["version"] = int(path.name.split("_")[0]) + migrations.append(migration) + except FileNotFoundError as e: + logger.error(f"Error reading migration files: {e}") + + # Get all applied versions + query = "SELECT version from schema_migrations ORDER BY version ASC" + async with self.pool.acquire() as connection: + records = await connection.fetch(query) + applied_versions = [r["version"] for r in records] + + # Filter out applied migrations + migrations = [m for m in migrations if m["version"] not in applied_versions] + + if not migrations: + logger.debug("No new migrations found") + return migrations + + # Sort migrations by version + migrations = sorted(migrations, key=lambda m: m["version"]) + logger.debug(f"{len(migrations)} new migrations found") + return migrations + + async def apply_migrations(self) -> None: + if self.pool is None: + raise NoDbPoolError("No db pool available") + + await self._create_migration_table() + migrations = await self._get_pending_migrations() + + if not migrations: + return None + + async with self.pool.acquire() as connection: + async with connection.transaction(): + for migration in migrations: + logger.debug(f"Running migration {migration['name']}") + await connection.execute(migration["content"]) + await connection.execute( + "INSERT INTO schema_migrations (version, name) VALUES ($1, $2)", + migration["version"], + migration["name"], + ) + + async def create_first_superuser(self) -> None: + if self.pool is None: + logger.error("No db pool created") + raise NoDbPoolError("No db pool created") + + async with self.pool.acquire() as connection: + db_user = await get_user_by_email(connection, email=settings.FIRST_SUPERUSER_EMAIL) + + if db_user: + if db_user.is_active and db_user.is_superuser: + logger.debug("First super user already exists, skipping.") + return None + else: + logger.info( + f"User with email {settings.FIRST_SUPERUSER_EMAIL} found, but is not active or is not a superuser, updating." + ) + update_query = """ + UPDATE users + SET is_active = true, is_superuser = true + WHERE email = $1 + """ + + await connection.execute(update_query, settings.FIRST_SUPERUSER_EMAIL) + + return None + + logger.debug(f"User with email {settings.FIRST_SUPERUSER_EMAIL} not found, adding") + query = """ + INSERT INTO users ( + id, email, full_name, hashed_password, is_active, is_superuser + ) + VALUES ($1, $2, $3, $4, $5, $6) + """ + + hashed_password = get_password_hash(settings.FIRST_SUPERUSER_PASSWORD) + await connection.execute( + query, + create_db_primary_key(), + settings.FIRST_SUPERUSER_EMAIL, + "Super User", + hashed_password, + True, + True, + ) + + +db = Database() diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..e526138 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,28 @@ +from datetime import UTC, datetime, timedelta +from typing import Any + +import jwt +from pwdlib import PasswordHash +from pwdlib.hashers.argon2 import Argon2Hasher + +from app.core.config import settings + +password_hash = PasswordHash((Argon2Hasher(),)) + + +ALGORITHM = "HS256" + + +def create_access_token(subject: str | Any, expires_delta: timedelta) -> str: + expire = datetime.now(UTC) + expires_delta + to_encode = {"exp": expire, "sub": subject} + encoded_jwt = jwt.encode(to_encode, key=settings.SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + return password_hash.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + return password_hash.hash(password) diff --git a/backend/app/core/utils.py b/backend/app/core/utils.py new file mode 100644 index 0000000..e22415f --- /dev/null +++ b/backend/app/core/utils.py @@ -0,0 +1,38 @@ +from collections.abc import Callable +from typing import Any +from uuid import uuid4 + +from fastapi import APIRouter as FastAPIRouter +from fastapi.types import DecoratedCallable + + +class APIRouter(FastAPIRouter): + """This resolves both paths that end in a / slash and those that don't. + + For example https://my_site and https://my_site/ will be routed to the same place. + """ + + def api_route( + self, path: str, *, include_in_schema: bool = True, **kwargs: Any + ) -> Callable[[DecoratedCallable], DecoratedCallable]: + """Updated api_route function that automatically configures routes to have 2 versions. + + One without a trailing slash and another with it. + """ + if path.endswith("/"): + path = path[:-1] + + add_path = super().api_route(path, include_in_schema=include_in_schema, **kwargs) + + alternate_path = f"{path}/" + add_alternate_path = super().api_route(alternate_path, include_in_schema=False, **kwargs) + + def decorator(func: DecoratedCallable) -> DecoratedCallable: + add_alternate_path(func) + return add_path(func) + + return decorator + + +def create_db_primary_key() -> str: + return str(uuid4()) diff --git a/backend/app/exceptions.py b/backend/app/exceptions.py new file mode 100644 index 0000000..b701bf9 --- /dev/null +++ b/backend/app/exceptions.py @@ -0,0 +1,10 @@ +class DbInsertError(Exception): + pass + + +class DbUpdateError(Exception): + pass + + +class NoDbPoolError(Exception): + pass diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..cf54b6e --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,59 @@ +import sys +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.responses import ORJSONResponse +from loguru import logger + +from app.api.router import api_router +from app.core.config import settings +from app.core.db import db + +logger.remove() # Remove the default logger so log level can be set +if settings.LOG_TO_SCREEN_AND_FILE or settings.LOG_PATH is None: + logger.add(sys.stderr, level=settings.LOG_LEVEL) +if settings.LOG_PATH: + logger.add(settings.LOG_PATH, level=settings.LOG_LEVEL) + + +@asynccontextmanager +async def lifespan(_: FastAPI) -> AsyncGenerator: + logger.info("Initalizing database connection pool") + try: + await db.create_pool() + except Exception as e: + logger.error(f"Error creating db connection pool: {e}") + raise + + logger.info("Running database migrations") + try: + await db.apply_migrations() + except Exception as e: + logger.error(f"Error applying migrations: {e}") + raise + + logger.info("Saving first superuser") + try: + await db.create_first_superuser() + except Exception as e: + logger.error(f"Error creating first superuser: {e}") + raise e + + yield + logger.info("Closing database connection pool") + try: + await db.close_pool() + except Exception as e: + logger.error(f"Error closing db connection pool: {e}") + raise + + +app = FastAPI( + title=settings.TITLE, + lifespan=lifespan, + openapi_url=f"{settings.API_V1_PREFIX}/openapi.json", + default_response_class=ORJSONResponse, +) + +app.include_router(api_router) diff --git a/backend/app/migrations/0001_initial_setup.sql b/backend/app/migrations/0001_initial_setup.sql new file mode 100644 index 0000000..f236b59 --- /dev/null +++ b/backend/app/migrations/0001_initial_setup.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + full_name TEXT NOT NULL, + hashed_password TEXT NOT NULL, + hashed_openai_api_key TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + is_superuser BOOLEAN NOT NULL DEFAULT false, + last_login TIMESTAMP NOT NULL DEFAULT NOW() +); diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/models/message.py b/backend/app/models/message.py new file mode 100644 index 0000000..855ea79 --- /dev/null +++ b/backend/app/models/message.py @@ -0,0 +1,7 @@ +from camel_converter.pydantic_base import CamelBase + + +class Message(CamelBase): + """Used for generic messages.""" + + message: str diff --git a/backend/app/models/token.py b/backend/app/models/token.py new file mode 100644 index 0000000..52e119d --- /dev/null +++ b/backend/app/models/token.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +class Token(BaseModel): + """Don't use CamelBase here because FastAPI requires snake case vairables for the token.""" + + access_token: str + token_type: str = "bearer" + + +class TokenPayload(BaseModel): + """Contents of the JWT token.""" + + sub: str | None = None diff --git a/backend/app/models/users.py b/backend/app/models/users.py new file mode 100644 index 0000000..0461c87 --- /dev/null +++ b/backend/app/models/users.py @@ -0,0 +1,65 @@ +from datetime import datetime + +from camel_converter.pydantic_base import CamelBase +from pydantic import EmailStr, Field + + +class UserBase(CamelBase): + email: EmailStr = Field(max_length=255) + is_active: bool = True + is_superuser: bool = False + full_name: str = Field(max_length=255) + + +class UserCreate(UserBase): + password: str = Field(min_length=8, max_length=255) + openai_api_key: str | None = Field(default=None, max_length=255) + + +class UserRegister(CamelBase): + email: EmailStr = Field(max_length=255) + password: str = Field(min_length=8, max_length=255) + openai_api_key: str | None = Field(default=None, max_length=255) + full_name: str = Field(max_length=255) + + +class UserUpdate(CamelBase): + email: EmailStr | None = Field(default=None, max_length=255) + is_active: bool | None = None + is_superuser: bool | None = None + password: str | None = Field(default=None, min_length=8, max_length=255) + openai_api_key: str | None = Field(default=None, max_length=255) + full_name: str | None = Field(default=None, max_length=255) + + +class UserUpdateMe(CamelBase): + email: EmailStr | None = Field(default=None, max_length=255) + full_name: str | None = Field(default=None, max_length=255) + openai_api_key: str | None = Field(default=None, max_length=255) + + +class UpdatePassword(CamelBase): + current_password: str = Field(min_length=8, max_length=255) + new_password: str = Field(min_length=8, max_length=255) + + +class User(UserBase): + id: str + hashed_password: str + hashed_openai_api_key: str + + +class UserPublic(UserBase): + id: str + + +class UsersPublic(CamelBase): + data: list[UserPublic] + count: int + + +class UserInDb(UserBase): + id: str + hashed_password: str + hashed_openai_api_key: str | None + last_login: datetime diff --git a/backend/app/py.typed b/backend/app/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/db_services.py b/backend/app/services/db_services.py new file mode 100644 index 0000000..548471f --- /dev/null +++ b/backend/app/services/db_services.py @@ -0,0 +1,5 @@ +from asyncpg import Connection + + +async def ping(conn: Connection) -> None: + await conn.execute("SELECT 1") diff --git a/backend/app/services/user_services.py b/backend/app/services/user_services.py new file mode 100644 index 0000000..78782ab --- /dev/null +++ b/backend/app/services/user_services.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from app.core.security import get_password_hash, verify_password +from app.core.utils import create_db_primary_key +from app.exceptions import DbInsertError, DbUpdateError +from app.models.users import ( + UpdatePassword, + UserCreate, + UserInDb, + UserPublic, + UserUpdate, + UserUpdateMe, +) + +if TYPE_CHECKING: + from asyncpg import Connection as DbConnection + + +async def authenticate(conn: DbConnection, *, email: str, password: str) -> UserInDb | None: + db_user = await get_user_by_email(conn, email=email) + + if not db_user: + return None + if not verify_password(password, db_user.hashed_password): + return None + + return db_user + + +async def create_user(conn: DbConnection, *, user: UserCreate) -> UserInDb: + query = """ + INSERT INTO users ( + id, + email, + full_name, + hashed_password, + is_active, + is_superuser + ) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + """ + + new_id = await conn.fetchval( + query, + create_db_primary_key(), + user.email, + user.full_name, + get_password_hash(user.password), + user.is_active, + user.is_superuser, + ) + + result = await get_user_by_id(conn, user_id=new_id) + + if not result: + raise DbInsertError("Unable to find user after inserting") + + return result + + +async def delete_user(conn: DbConnection, *, user_id: str) -> None: + query = "DELETE FROM users WHERE id = $1" + await conn.execute(query, user_id) + + +async def get_users(conn: DbConnection, *, offset: int, limit: int) -> list[UserInDb] | None: + query = """ + SELECT id, + email, + full_name, + hashed_password, + hashed_openai_api_key, + is_active, + is_superuser, + last_login + FROM users + OFFSET $1 + LIMIT $2 + """ + + results = await conn.fetch(query, offset, limit) + + if not results: + return None + + return [UserInDb(**x) for x in results] + + +async def get_users_public( + conn: DbConnection, + *, + offset: int, + limit: int, +) -> list[UserPublic] | None: + db_users = await get_users(conn, offset=offset, limit=limit) + if not db_users: + return None + + public_users = [] + for user in db_users: + public_users.append(UserPublic(**user.model_dump())) + + return public_users + + +async def get_user_by_email(conn: DbConnection, *, email: str) -> UserInDb | None: + query = """ + SELECT id, + email, + full_name, + hashed_password, + hashed_openai_api_key, + is_active, + is_superuser, + last_login + FROM users + WHERE email = $1 + """ + db_user = await conn.fetchrow(query, email) + + if not db_user: + return None + + return UserInDb(**db_user) + + +async def get_user_public_by_email(conn: DbConnection, *, email: str) -> UserPublic | None: + db_user = await get_user_by_email(conn, email=email) + if not db_user: + return None + + return UserPublic(**db_user.model_dump()) + + +async def get_user_by_id(conn: DbConnection, *, user_id: str) -> UserInDb | None: + query = """ + SELECT id, + email, + full_name, + hashed_password, + hashed_openai_api_key, + is_active, + is_superuser, + last_login + FROM users + WHERE id = $1 + """ + db_user = await conn.fetchrow(query, user_id) + + if not db_user: + return None + + return UserInDb(**db_user) + + +async def get_user_public_by_id(conn: DbConnection, *, user_id: str) -> UserPublic | None: + db_user = await get_user_by_id(conn, user_id=user_id) + if not db_user: + return None + + return UserPublic(**db_user.model_dump()) + + +async def update_user( + conn: DbConnection, + *, + db_user: UserInDb, + user_in: UserUpdate | UserUpdateMe | UpdatePassword, +) -> UserInDb: + if isinstance(user_in, UpdatePassword): + query = """ + UPDATE users + SET hashed_password=$1 + WHERE id = $2 + """ + + await conn.execute(query, get_password_hash(user_in.new_password), db_user.id) + else: + user_data = user_in.model_dump(exclude_unset=True) + if "password" in user_data: + user_data["hashed_password"] = get_password_hash(user_data.pop("password")) + if "openai_api_key" in user_data: + user_data["hashed_openai_api_key"] = get_password_hash(user_data.pop("openai_api_key")) + set_clause = ", ".join([f"{key} = ${i+2}" for i, key in enumerate(user_data.keys())]) + query = f""" + UPDATE users + SET {set_clause} + WHERE id = $1 + """ + + await conn.execute(query, db_user.id, *user_data.values()) + + updated_user = await get_user_by_id(conn, user_id=db_user.id) + + if not updated_user: + raise DbUpdateError("Unable to find user after updating") + + return updated_user diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..7e1da7a --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,98 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "app" +description = "SCAN backend" +authors = [ + { name = "Paul Sanders", email = "paul@paulsanders.dev" } +] +requires-python = ">=3.13" +dynamic = ["version"] +dependencies = [ + "asyncpg==0.30.0", + "camel-converter[pydantic]==4.0.1", + "fastapi==0.115.5", + "httptools==0.6.4", + "loguru==0.7.2", + "orjson==3.10.11", + "pwdlib[argon2]==0.2.1", + "pydantic[email]==2.9.2", + "pydantic-settings==2.6.1", + "pyjwt==2.9.0", + "python-multipart==0.0.17", + "uvicorn==0.32.0", + "uvloop==0.21.0", + "httpx>=0.27.2", +] + +[dependency-groups] +dev = [ + "mypy[faster-cache]==1.13.0", + "pre-commit==4.0.1", + "pytest==8.3.3", + "pytest-asyncio==0.24.0", + "pytest-cov==6.0.0", + "ruff==0.7.3", +] + +[tool.hatch.version] +path = "app/_version.py" + +[tool.mypy] +check_untyped_defs = true +disallow_untyped_defs = true + +[[tool.mypy.overrides]] +module = ["tests.*"] +disallow_untyped_defs = false + +[[tool.mypy.overrides]] +module = ["asyncpg.*"] +ignore_missing_imports = true + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "--cov=app --cov-report term-missing --no-cov-on-fail" +asyncio_mode = "auto" + +[tool.coverage.report] +exclude_lines = ["if __name__ == .__main__.:", "pragma: no cover"] + +[tool.ruff] +line-length = 100 +target-version = "py313" +fix = true + +[tool.ruff.lint] +select = [ + "E", # pycodestyle + "B", # flake8-bugbear + "W", # Warning + "F", # pyflakes + "UP", # pyupgrade + "I001", # unsorted-imports + "T201", # print found + "T203", # pprint found + "ASYNC", # flake8-async + +] +ignore=[ + # Recommended ignores by ruff when using formatter + "E501", + "W191", + "E111", + "E114", + "E117", + "D206", + "D300", + "Q000", + "Q001", + "Q002", + "Q003", + "COM812", + "COM819", + "ISC001", + "ISC002", +] diff --git a/backend/scripts/entrypoint.sh b/backend/scripts/entrypoint.sh new file mode 100755 index 0000000..1605291 --- /dev/null +++ b/backend/scripts/entrypoint.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000} --workers ${WORKERS:-1} diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/api/__init__.py b/backend/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/api/routes/__init__.py b/backend/tests/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/api/routes/test_health_route.py b/backend/tests/api/routes/test_health_route.py new file mode 100644 index 0000000..ad076e8 --- /dev/null +++ b/backend/tests/api/routes/test_health_route.py @@ -0,0 +1,6 @@ +async def test_health(test_client): + result = await test_client.get("health") + + assert result.status_code == 200 + assert result.json()["server"] == "healthy" + assert result.json()["db"] == "healthy" diff --git a/backend/tests/api/routes/test_login_routes.py b/backend/tests/api/routes/test_login_routes.py new file mode 100644 index 0000000..5b2fb34 --- /dev/null +++ b/backend/tests/api/routes/test_login_routes.py @@ -0,0 +1,32 @@ +from app.core.config import settings + + +async def test_get_access_token(test_client): + login_data = { + "username": settings.FIRST_SUPERUSER_EMAIL, + "password": settings.FIRST_SUPERUSER_PASSWORD, + } + response = await test_client.post("/login/access-token", data=login_data) + tokens = response.json() + assert response.status_code == 200 + assert "access_token" in tokens + assert tokens["access_token"] + + +async def test_get_access_token_incorrect_password(test_client): + login_data = { + "username": settings.FIRST_SUPERUSER_EMAIL, + "password": "incorrect", + } + response = await test_client.post("/login/access-token", data=login_data) + assert response.status_code == 400 + + +async def test_use_access_token(test_client, superuser_token_headers): + response = await test_client.post( + "/login/test-token", + headers=superuser_token_headers, + ) + result = response.json() + assert response.status_code == 200 + assert "email" in result diff --git a/backend/tests/api/routes/test_user_routes.py b/backend/tests/api/routes/test_user_routes.py new file mode 100644 index 0000000..7a81e84 --- /dev/null +++ b/backend/tests/api/routes/test_user_routes.py @@ -0,0 +1,483 @@ +from uuid import uuid4 + +from app.core.config import settings +from app.core.security import verify_password +from app.models.users import UserCreate +from app.services import user_services +from tests.utils import random_email, random_lower_string + + +async def test_get_users_superuser_me(test_client, superuser_token_headers): + response = await test_client.get("/users/me", headers=superuser_token_headers) + current_user = response.json() + assert current_user + assert current_user["isActive"] is True + assert current_user["isSuperuser"] + assert current_user["email"] == settings.FIRST_SUPERUSER_EMAIL + + +async def test_get_users_normal_user_me(test_client, normal_user_token_headers): + response = await test_client.get("/users/me", headers=normal_user_token_headers) + current_user = response.json() + assert current_user + assert current_user["isActive"] is True + assert current_user["isSuperuser"] is False + assert current_user["email"] is not None + + +async def test_get_existing_user(test_db, test_client, superuser_token_headers, test_user): + user_id = test_user.id + response = await test_client.get( + f"/users/{user_id}", + headers=superuser_token_headers, + ) + assert 200 <= response.status_code < 300 + api_user = response.json() + async with test_db.pool.acquire() as conn: + existing_user = await user_services.get_user_by_email(conn, email=test_user.email) + assert existing_user + assert existing_user.email == api_user["email"] + + +async def test_get_existing_user_current_user(test_client, test_db): + email = random_email() + password = random_lower_string() + openai_api_key = random_lower_string() + full_name = random_lower_string() + async with test_db.pool.acquire() as conn: + user = await user_services.create_user( + conn, + user=UserCreate( + email=email, + password=password, + openai_api_key=openai_api_key, + full_name=full_name, + ), + ) + user_id = user.id + login_data = { + "username": email, + "password": password, + } + response = await test_client.post("/login/access-token", data=login_data) + tokens = response.json() + a_token = tokens["access_token"] + headers = {"Authorization": f"Bearer {a_token}"} + + response = await test_client.get( + f"/users/{user_id}", + headers=headers, + ) + assert 200 <= response.status_code < 300 + api_user = response.json() + async with test_db.pool.acquire() as conn: + existing_user = await user_services.get_user_by_email(conn, email=email) + assert existing_user + assert existing_user.email == api_user["email"] + + +async def test_get_existing_user_permissions_error( + test_client, normal_user_token_headers, test_user +): + response = await test_client.get( + f"/users/{test_user.id}", + headers=normal_user_token_headers, + ) + assert response.status_code == 403 + assert response.json() == {"detail": "The user doesn't have enough privileges"} + + +async def test_create_user_existing_username(test_client, superuser_token_headers, test_db): + username = random_email() + password = random_lower_string() + openai_api_key = random_lower_string() + full_name = random_lower_string() + async with test_db.pool.acquire() as conn: + await user_services.create_user( + conn, + user=UserCreate( + email=username, + password=password, + openai_api_key=openai_api_key, + full_name=full_name, + ), + ) + data = { + "email": username, + "password": password, + "openai_api_key": openai_api_key, + "fullName": full_name, + } + response = await test_client.post( + "/users/", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 400 + created_user = response.json() + assert "A user with this email address already exists" in created_user["detail"] + + +async def test_create_user_by_normal_user(test_client, normal_user_token_headers): + username = random_email() + password = random_lower_string() + openai_api_key = random_lower_string() + full_name = random_lower_string() + data = { + "email": username, + "password": password, + "openai_api_key": openai_api_key, + "fullName": full_name, + } + response = await test_client.post( + "/users/", + headers=normal_user_token_headers, + json=data, + ) + assert response.status_code == 403 + + +async def test_retrieve_users(test_client, superuser_token_headers, test_db): + username = random_email() + password = random_lower_string() + openai_api_key = random_lower_string() + full_name = random_lower_string() + username2 = random_email() + password2 = random_lower_string() + openai_api_key2 = random_lower_string() + full_name2 = random_lower_string() + async with test_db.pool.acquire() as conn: + await user_services.create_user( + conn, + user=UserCreate( + email=username, + password=password, + openai_api_key=openai_api_key, + full_name=full_name, + ), + ) + + await user_services.create_user( + conn, + user=UserCreate( + email=username2, + password=password2, + openai_api_key=openai_api_key2, + full_name=full_name2, + ), + ) + + response = await test_client.get("/users/", headers=superuser_token_headers) + all_users = response.json() + + assert len(all_users["data"]) > 1 + assert "count" in all_users + for item in all_users["data"]: + assert "email" in item + + +async def test_update_user_me(test_client, normal_user_token_headers, test_db): + full_name = "Updated" + email = random_email() + data = {"fullName": full_name, "email": email} + response = await test_client.patch( + "/users/me", + headers=normal_user_token_headers, + json=data, + ) + assert response.status_code == 200 + updated_user = response.json() + assert updated_user["email"] == email + assert updated_user["fullName"] == full_name + + async with test_db.pool.acquire() as conn: + user_db = await user_services.get_user_by_email(conn, email=email) + assert user_db + assert user_db.email == email + assert user_db.full_name == full_name + + +async def test_update_password_me(test_client, superuser_token_headers, test_db): + new_password = random_lower_string() + data = { + "current_password": settings.FIRST_SUPERUSER_PASSWORD, + "new_password": new_password, + } + response = await test_client.patch( + "/users/me/password", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 200 + updated_user = response.json() + assert updated_user["message"] == "Password updated successfully" + + async with test_db.pool.acquire() as conn: + user_db = await user_services.get_user_by_email(conn, email=settings.FIRST_SUPERUSER_EMAIL) + assert user_db + assert user_db.email == settings.FIRST_SUPERUSER_EMAIL + assert verify_password(new_password, user_db.hashed_password) + + +async def test_update_password_me_incorrect_password(test_client, superuser_token_headers): + bad_password = random_lower_string() + new_password = random_lower_string() + data = {"current_password": bad_password, "new_password": new_password} + response = await test_client.patch( + "/users/me/password", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 400 + updated_user = response.json() + assert updated_user["detail"] == "Incorrect password" + + +async def test_update_user_me_email_exists(test_client, test_db, normal_user_token_headers): + email = random_email() + password = random_lower_string() + openai_api_key = random_lower_string() + full_name = random_lower_string() + async with test_db.pool.acquire() as conn: + await user_services.create_user( + conn, + user=UserCreate( + email=email, + password=password, + openai_api_key=openai_api_key, + full_name=full_name, + ), + ) + data = {"email": email} + response = await test_client.patch( + "/users/me", + headers=normal_user_token_headers, + json=data, + ) + assert response.status_code == 409 + assert response.json()["detail"] == "A user with this email address already exists" + + +async def test_update_password_me_same_password_error(test_client, superuser_token_headers): + data = { + "currentPassword": settings.FIRST_SUPERUSER_PASSWORD, + "newPassword": settings.FIRST_SUPERUSER_PASSWORD, + } + response = await test_client.patch( + "/users/me/password", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 400 + updated_user = response.json() + assert updated_user["detail"] == "New password cannot be the same as the current one" + + +async def test_register_user(test_client, test_db): + username = random_email() + password = random_lower_string() + openai_api_key = random_lower_string() + full_name = random_lower_string() + data = { + "email": username, + "password": password, + "openaiApiKey": openai_api_key, + "fullName": full_name, + } + response = await test_client.post( + "/users/signup", + json=data, + ) + assert response.status_code == 200 + created_user = response.json() + assert created_user["email"] == username + assert created_user["fullName"] == full_name + + async with test_db.pool.acquire() as conn: + user_db = await user_services.get_user_by_id(conn, user_id=created_user["id"]) + assert user_db + assert user_db.email == username + assert user_db.full_name == full_name + assert verify_password(password, user_db.hashed_password) + + +async def test_register_user_already_exists_error(test_client): + password = random_lower_string() + openai_api_key = random_lower_string() + full_name = random_lower_string() + data = { + "email": settings.FIRST_SUPERUSER_EMAIL, + "password": password, + "openaiApiKey": openai_api_key, + "fullName": full_name, + } + response = await test_client.post( + "/users/signup", + json=data, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "The user with this email already exists in the system" + + +async def test_update_user(test_client, superuser_token_headers, test_db, test_user): + data = {"fullName": "Updated_full_name"} + response = await test_client.patch( + f"/users/{test_user.id}", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 200 + updated_user = response.json() + + assert updated_user["fullName"] == "Updated_full_name" + + async with test_db.pool.acquire() as conn: + user_db = await user_services.get_user_by_email(conn, email=test_user.email) + assert user_db + assert user_db.full_name == "Updated_full_name" + + +async def test_update_user_not_exists(test_client, superuser_token_headers): + data = {"fullName": "Updated_full_name"} + response = await test_client.patch( + f"/users/{str(uuid4())}", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "The user with this id does not exist in the system" + + +async def test_update_user_email_exists(test_client, superuser_token_headers, test_db): + username = random_email() + password = random_lower_string() + openai_api_key = random_lower_string() + full_name = random_lower_string() + username2 = random_email() + password2 = random_lower_string() + openai_api_key2 = random_lower_string() + full_name_2 = random_lower_string() + async with test_db.pool.acquire() as conn: + user = await user_services.create_user( + conn, + user=UserCreate( + email=username, + password=password, + openai_api_key=openai_api_key, + full_name=full_name, + ), + ) + + user2 = await user_services.create_user( + conn, + user=UserCreate( + email=username2, + password=password2, + openai_api_key=openai_api_key2, + full_name=full_name_2, + ), + ) + + data = {"email": user2.email} + response = await test_client.patch( + f"/users/{user.id}", + headers=superuser_token_headers, + json=data, + ) + assert response.status_code == 409 + assert response.json()["detail"] == "User with this email already exists" + + +async def test_delete_user_me(test_client, test_db): + username = random_email() + password = random_lower_string() + openai_api_key = random_lower_string() + full_name = random_lower_string() + async with test_db.pool.acquire() as conn: + user = await user_services.create_user( + conn, + user=UserCreate( + email=username, + password=password, + openai_api_key=openai_api_key, + full_name=full_name, + ), + ) + user_id = user.id + + login_data = { + "username": username, + "password": password, + } + response = await test_client.post("/login/access-token", data=login_data) + tokens = response.json() + a_token = tokens["access_token"] + headers = {"Authorization": f"Bearer {a_token}"} + + response = await test_client.delete( + "/users/me", + headers=headers, + ) + assert response.status_code == 200 + deleted_user = response.json() + assert deleted_user["message"] == "User deleted successfully" + result = await user_services.get_user_by_id(conn, user_id=user_id) + assert result is None + + +async def test_delete_user_me_as_superuser(test_client, superuser_token_headers): + response = await test_client.delete( + "/users/me", + headers=superuser_token_headers, + ) + assert response.status_code == 403 + response = response.json() + assert response["detail"] == "Super users are not allowed to delete themselves" + + +async def test_delete_user_super_user(test_client, superuser_token_headers, test_db, test_user): + user_id = test_user.id + response = await test_client.delete( + f"/users/{user_id}", + headers=superuser_token_headers, + ) + assert response.status_code == 200 + deleted_user = response.json() + assert deleted_user["message"] == "User deleted successfully" + async with test_db.pool.acquire() as conn: + result = await user_services.get_user_by_id(conn, user_id=user_id) + assert result is None + + +async def test_delete_user_not_found(test_client, superuser_token_headers): + response = await test_client.delete( + f"/users/{str(uuid4())}", + headers=superuser_token_headers, + ) + assert response.status_code == 404 + assert response.json()["detail"] == "User not found" + + +async def test_delete_user_current_super_user_error(test_client, superuser_token_headers, test_db): + async with test_db.pool.acquire() as conn: + super_user = await user_services.get_user_by_email( + conn, email=settings.FIRST_SUPERUSER_EMAIL + ) + assert super_user + user_id = super_user.id + + response = await test_client.delete( + f"/users/{user_id}", + headers=superuser_token_headers, + ) + assert response.status_code == 403 + assert response.json()["detail"] == "Super users are not allowed to delete themselves" + + +async def test_delete_user_without_privileges(test_client, normal_user_token_headers, test_user): + response = await test_client.delete( + f"/users/{test_user.id}", + headers=normal_user_token_headers, + ) + assert response.status_code == 403 + assert response.json()["detail"] == "The user doesn't have enough privileges" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..59d4f73 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,94 @@ +import pytest +from httpx import ASGITransport, AsyncClient + +from app.core.config import settings +from app.core.db import db +from app.main import app +from app.models.users import UserCreate, UserUpdate +from app.services import user_services +from tests.utils import get_superuser_token_headers, random_email, random_lower_string + + +async def user_authentication_headers(test_client, email, password): + data = {"username": email, "password": password} + + result = await test_client.post("/login/access-token", data=data) + response = result.json() + auth_token = response["access_token"] + return {"Authorization": f"Bearer {auth_token}"} + + +@pytest.fixture(autouse=True) +async def test_db(): + await db.create_pool() + await db.apply_migrations() + await db.create_first_superuser() + yield db + if not db.pool: + await db.create_pool() + + async with db.pool.acquire() as conn: # type: ignore + tables = ", ".join(("users",)) + await conn.execute(f"TRUNCATE {tables}") + await db.close_pool() + + +@pytest.fixture +async def test_client(): + async with AsyncClient( + transport=ASGITransport(app=app), base_url=f"http://127.0.0.1{settings.API_V1_PREFIX}" + ) as client: + yield client + + +@pytest.fixture +async def superuser_token_headers(test_client): + return await get_superuser_token_headers(test_client) + + +@pytest.fixture +async def test_user(test_db): + email = random_email() + password = random_lower_string() + openai_api_key = random_lower_string() + full_name = random_lower_string() + async with test_db.pool.acquire() as conn: + user = await user_services.create_user( + conn, + user=UserCreate( + email=email, + password=password, + openai_api_key=openai_api_key, + full_name=full_name, + ), + ) + return user + + +@pytest.fixture +async def normal_user_token_headers(test_db, test_client): + password = random_lower_string() + openai_api_key = random_lower_string() + full_name = random_lower_string() + email = random_email() + async with test_db.pool.acquire() as conn: + user = await user_services.get_user_by_email(conn, email=email) + if not user: + user = await user_services.create_user( + conn, + user=UserCreate( + email=email, + password=password, + openai_api_key=openai_api_key, + full_name=full_name, + ), + ) + else: + user_in = UserUpdate(password=password, openai_api_key=openai_api_key) + if not user.id: + raise Exception("User id not set") + user = await user_services.update_user(conn, db_user=user, user_in=user_in) + + return await user_authentication_headers( + test_client=test_client, email=email, password=password + ) diff --git a/backend/tests/core/__init__.py b/backend/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/core/test_config.py b/backend/tests/core/test_config.py new file mode 100644 index 0000000..8c2dd7e --- /dev/null +++ b/backend/tests/core/test_config.py @@ -0,0 +1,35 @@ +import pytest +from pydantic import ValidationError + +from app.core.config import Settings + + +@pytest.mark.parametrize("temperature", (0.0, 0.5, 1.0)) +def test_temperature(temperature): + settings = Settings( + SECRET_KEY="a", + FIRST_SUPERUSER_EMAIL="user@email.com", + FIRST_SUPERUSER_PASSWORD="Abc123!@#", + POSTGRES_HOST="some_host", + POSTGRES_USER="pg", + POSTGRES_PASSWORD="pgpassword", + OPENAI_API_KEY="some_key", + TEMPERATURE=temperature, + ) + + assert settings.TEMPERATURE == temperature + + +@pytest.mark.parametrize("temperature", (-0.1, 1.1)) +def test_invalid_temperature(temperature): + with pytest.raises(ValidationError): + Settings( + SECRET_KEY="a", + FIRST_SUPERUSER_EMAIL="user@email.com", + FIRST_SUPERUSER_PASSWORD="Abc123!@#", + POSTGRES_HOST="some_host", + POSTGRES_USER="pg", + POSTGRES_PASSWORD="pgpassword", + OPENAI_API_KEY="some_key", + TEMPERATURE=temperature, + ) diff --git a/backend/tests/core/test_security.py b/backend/tests/core/test_security.py new file mode 100644 index 0000000..86211f7 --- /dev/null +++ b/backend/tests/core/test_security.py @@ -0,0 +1,11 @@ +from app.core.security import get_password_hash, verify_password + + +def test_verify_password_match(): + hashed_password = get_password_hash("test") + assert verify_password("test", hashed_password) is True + + +def test_verify_password_no_match(): + hashed_password = get_password_hash("t") + assert verify_password("test", hashed_password) is False diff --git a/backend/tests/utils.py b/backend/tests/utils.py new file mode 100644 index 0000000..a49dfab --- /dev/null +++ b/backend/tests/utils.py @@ -0,0 +1,24 @@ +import random +import string + +from app.core.config import settings + + +def random_email() -> str: + return f"{random_lower_string()}@{random_lower_string()}.com" + + +def random_lower_string() -> str: + return "".join(random.choices(string.ascii_lowercase, k=32)) + + +async def get_superuser_token_headers(test_client): + login_data = { + "username": settings.FIRST_SUPERUSER_EMAIL, + "password": settings.FIRST_SUPERUSER_PASSWORD, + } + response = await test_client.post("/login/access-token", data=login_data) + tokens = response.json() + a_token = tokens["access_token"] + headers = {"Authorization": f"Bearer {a_token}"} + return headers diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..71955d0 --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,748 @@ +version = 1 +requires-python = ">=3.13" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 }, +] + +[[package]] +name = "anyio" +version = "4.6.2.post1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/09/45b9b7a6d4e45c6bcb5bf61d19e3ab87df68e0601fa8c5293de3542546cc/anyio-4.6.2.post1.tar.gz", hash = "sha256:4c8bc31ccdb51c7f7bd251f51c609e038d63e34219b44aa86e47576389880b4c", size = 173422 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/f5/f2b75d2fc6f1a260f340f0e7c6a060f4dd2961cc16884ed851b0d18da06a/anyio-4.6.2.post1-py3-none-any.whl", hash = "sha256:6d170c36fba3bdd840c73d3868c1e777e33676a69c3a72cf0a0d5d6d8009b61d", size = 90377 }, +] + +[[package]] +name = "app" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "asyncpg" }, + { name = "camel-converter", extra = ["pydantic"] }, + { name = "fastapi" }, + { name = "httptools" }, + { name = "httpx" }, + { name = "loguru" }, + { name = "orjson" }, + { name = "pwdlib", extra = ["argon2"] }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "pyjwt" }, + { name = "python-multipart" }, + { name = "uvicorn" }, + { name = "uvloop" }, +] + +[package.dev-dependencies] +dev = [ + { name = "mypy", extra = ["faster-cache"] }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "asyncpg", specifier = "==0.30.0" }, + { name = "camel-converter", extras = ["pydantic"], specifier = "==4.0.1" }, + { name = "fastapi", specifier = "==0.115.5" }, + { name = "httptools", specifier = "==0.6.4" }, + { name = "httpx", specifier = ">=0.27.2" }, + { name = "loguru", specifier = "==0.7.2" }, + { name = "orjson", specifier = "==3.10.11" }, + { name = "pwdlib", extras = ["argon2"], specifier = "==0.2.1" }, + { name = "pydantic", extras = ["email"], specifier = "==2.9.2" }, + { name = "pydantic-settings", specifier = "==2.6.1" }, + { name = "pyjwt", specifier = "==2.9.0" }, + { name = "python-multipart", specifier = "==0.0.17" }, + { name = "uvicorn", specifier = "==0.32.0" }, + { name = "uvloop", specifier = "==0.21.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "mypy", extras = ["faster-cache"], specifier = "==1.13.0" }, + { name = "pre-commit", specifier = "==4.0.1" }, + { name = "pytest", specifier = "==8.3.3" }, + { name = "pytest-asyncio", specifier = "==0.24.0" }, + { name = "pytest-cov", specifier = "==6.0.0" }, + { name = "ruff", specifier = "==0.7.3" }, +] + +[[package]] +name = "argon2-cffi" +version = "23.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/fa/57ec2c6d16ecd2ba0cf15f3c7d1c3c2e7b5fcb83555ff56d7ab10888ec8f/argon2_cffi-23.1.0.tar.gz", hash = "sha256:879c3e79a2729ce768ebb7d36d4609e3a78a4ca2ec3a9f12286ca057e3d0db08", size = 42798 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/6a/e8a041599e78b6b3752da48000b14c8d1e8a04ded09c88c714ba047f34f5/argon2_cffi-23.1.0-py3-none-any.whl", hash = "sha256:c670642b78ba29641818ab2e68bd4e6a78ba53b7eff7b4c3815ae16abf91c7ea", size = 15124 }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/e9/184b8ccce6683b0aa2fbb7ba5683ea4b9c5763f1356347f1312c32e3c66e/argon2-cffi-bindings-21.2.0.tar.gz", hash = "sha256:bb89ceffa6c791807d1305ceb77dbfacc5aa499891d2c55661c6459651fc39e3", size = 1779911 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/13/838ce2620025e9666aa8f686431f67a29052241692a3dd1ae9d3692a89d3/argon2_cffi_bindings-21.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ccb949252cb2ab3a08c02024acb77cfb179492d5701c7cbdbfd776124d4d2367", size = 29658 }, + { url = "https://files.pythonhosted.org/packages/b3/02/f7f7bb6b6af6031edb11037639c697b912e1dea2db94d436e681aea2f495/argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9524464572e12979364b7d600abf96181d3541da11e23ddf565a32e70bd4dc0d", size = 80583 }, + { url = "https://files.pythonhosted.org/packages/ec/f7/378254e6dd7ae6f31fe40c8649eea7d4832a42243acaf0f1fff9083b2bed/argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b746dba803a79238e925d9046a63aa26bf86ab2a2fe74ce6b009a1c3f5c8f2ae", size = 86168 }, + { url = "https://files.pythonhosted.org/packages/74/f6/4a34a37a98311ed73bb80efe422fed95f2ac25a4cacc5ae1d7ae6a144505/argon2_cffi_bindings-21.2.0-cp36-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58ed19212051f49a523abb1dbe954337dc82d947fb6e5a0da60f7c8471a8476c", size = 82709 }, + { url = "https://files.pythonhosted.org/packages/74/2b/73d767bfdaab25484f7e7901379d5f8793cccbb86c6e0cbc4c1b96f63896/argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:bd46088725ef7f58b5a1ef7ca06647ebaf0eb4baff7d1d0d177c6cc8744abd86", size = 83613 }, + { url = "https://files.pythonhosted.org/packages/4f/fd/37f86deef67ff57c76f137a67181949c2d408077e2e3dd70c6c42912c9bf/argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_i686.whl", hash = "sha256:8cd69c07dd875537a824deec19f978e0f2078fdda07fd5c42ac29668dda5f40f", size = 84583 }, + { url = "https://files.pythonhosted.org/packages/6f/52/5a60085a3dae8fded8327a4f564223029f5f54b0cb0455a31131b5363a01/argon2_cffi_bindings-21.2.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f1152ac548bd5b8bcecfb0b0371f082037e47128653df2e8ba6e914d384f3c3e", size = 88475 }, + { url = "https://files.pythonhosted.org/packages/8b/95/143cd64feb24a15fa4b189a3e1e7efbaeeb00f39a51e99b26fc62fbacabd/argon2_cffi_bindings-21.2.0-cp36-abi3-win32.whl", hash = "sha256:603ca0aba86b1349b147cab91ae970c63118a0f30444d4bc80355937c950c082", size = 27698 }, + { url = "https://files.pythonhosted.org/packages/37/2c/e34e47c7dee97ba6f01a6203e0383e15b60fb85d78ac9a15cd066f6fe28b/argon2_cffi_bindings-21.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:b2ef1c30440dbbcba7a5dc3e319408b59676e2e039e2ae11a8775ecf482b192f", size = 30817 }, + { url = "https://files.pythonhosted.org/packages/5a/e4/bf8034d25edaa495da3c8a3405627d2e35758e44ff6eaa7948092646fdcc/argon2_cffi_bindings-21.2.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e415e3f62c8d124ee16018e491a009937f8cf7ebf5eb430ffc5de21b900dad93", size = 53104 }, +] + +[[package]] +name = "asyncpg" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373 }, + { url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745 }, + { url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103 }, + { url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471 }, + { url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253 }, + { url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720 }, + { url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404 }, + { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623 }, +] + +[[package]] +name = "camel-converter" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/3d/dd783586dc0c4aee5b6b88489666fdb2c0c344ea0aa8a5c10746cc423707/camel_converter-4.0.1.tar.gz", hash = "sha256:401414549ae4ac4073e38cdc4aa6d464dc534fc40aa06ff787bf0960b0c86535", size = 38915 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/e5/806359514cc8305f047bd6d39d63890298c0596f7328b534059724bd1a9e/camel_converter-4.0.1-py3-none-any.whl", hash = "sha256:0cba7ca1354a29ca2191983deecc9dcf28889f606c28d6ed18ac7d4586b163ac", size = 6243 }, +] + +[package.optional-dependencies] +pydantic = [ + { name = "pydantic" }, +] + +[[package]] +name = "certifi" +version = "2024.8.30" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/ee/9b19140fe824b367c04c5e1b369942dd754c4c5462d5674002f75c4dedc1/certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9", size = 168507 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/90/3c9ff0512038035f59d279fddeb79f5f1eccd8859f06d6163c58798b9487/certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8", size = 167321 }, +] + +[[package]] +name = "cffi" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 }, + { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 }, + { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 }, + { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 }, + { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 }, + { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 }, + { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 }, + { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 }, + { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 }, + { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 }, + { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, +] + +[[package]] +name = "click" +version = "8.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/d3/f04c7bfcf5c1862a2a5b845c6b2b360488cf47af55dfa79c98f6a6bf98b5/click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de", size = 336121 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/2e/d53fa4befbf2cfa713304affc7ca780ce4fc1fd8710527771b58311a3229/click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", size = 97941 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "coverage" +version = "7.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/12/3669b6382792783e92046730ad3327f53b2726f0603f4c311c4da4824222/coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73", size = 798716 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/4d/2dede4f7cb5a70fb0bb40a57627fddf1dbdc6b9c1db81f7c4dcdcb19e2f4/coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9", size = 207039 }, + { url = "https://files.pythonhosted.org/packages/3f/f9/d86368ae8c79e28f1fb458ebc76ae9ff3e8bd8069adc24e8f2fed03c58b7/coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba", size = 207298 }, + { url = "https://files.pythonhosted.org/packages/64/c5/b4cc3c3f64622c58fbfd4d8b9a7a8ce9d355f172f91fcabbba1f026852f6/coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c", size = 239813 }, + { url = "https://files.pythonhosted.org/packages/8a/86/14c42e60b70a79b26099e4d289ccdfefbc68624d096f4481163085aa614c/coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06", size = 236959 }, + { url = "https://files.pythonhosted.org/packages/7f/f8/4436a643631a2fbab4b44d54f515028f6099bfb1cd95b13cfbf701e7f2f2/coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f", size = 238950 }, + { url = "https://files.pythonhosted.org/packages/49/50/1571810ddd01f99a0a8be464a4ac8b147f322cd1e8e296a1528984fc560b/coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b", size = 238610 }, + { url = "https://files.pythonhosted.org/packages/f3/8c/6312d241fe7cbd1f0cade34a62fea6f333d1a261255d76b9a87074d8703c/coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21", size = 236697 }, + { url = "https://files.pythonhosted.org/packages/ce/5f/fef33dfd05d87ee9030f614c857deb6df6556b8f6a1c51bbbb41e24ee5ac/coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a", size = 238541 }, + { url = "https://files.pythonhosted.org/packages/a9/64/6a984b6e92e1ea1353b7ffa08e27f707a5e29b044622445859200f541e8c/coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e", size = 209707 }, + { url = "https://files.pythonhosted.org/packages/5c/60/ce5a9e942e9543783b3db5d942e0578b391c25cdd5e7f342d854ea83d6b7/coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963", size = 210439 }, + { url = "https://files.pythonhosted.org/packages/78/53/6719677e92c308207e7f10561a1b16ab8b5c00e9328efc9af7cfd6fb703e/coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f", size = 207784 }, + { url = "https://files.pythonhosted.org/packages/fa/dd/7054928930671fcb39ae6a83bb71d9ab5f0afb733172543ced4b09a115ca/coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806", size = 208058 }, + { url = "https://files.pythonhosted.org/packages/b5/7d/fd656ddc2b38301927b9eb3aae3fe827e7aa82e691923ed43721fd9423c9/coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11", size = 250772 }, + { url = "https://files.pythonhosted.org/packages/90/d0/eb9a3cc2100b83064bb086f18aedde3afffd7de6ead28f69736c00b7f302/coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3", size = 246490 }, + { url = "https://files.pythonhosted.org/packages/45/44/3f64f38f6faab8a0cfd2c6bc6eb4c6daead246b97cf5f8fc23bf3788f841/coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a", size = 248848 }, + { url = "https://files.pythonhosted.org/packages/5d/11/4c465a5f98656821e499f4b4619929bd5a34639c466021740ecdca42aa30/coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc", size = 248340 }, + { url = "https://files.pythonhosted.org/packages/f1/96/ebecda2d016cce9da812f404f720ca5df83c6b29f65dc80d2000d0078741/coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70", size = 246229 }, + { url = "https://files.pythonhosted.org/packages/16/d9/3d820c00066ae55d69e6d0eae11d6149a5ca7546de469ba9d597f01bf2d7/coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef", size = 247510 }, + { url = "https://files.pythonhosted.org/packages/8f/c3/4fa1eb412bb288ff6bfcc163c11700ff06e02c5fad8513817186e460ed43/coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e", size = 210353 }, + { url = "https://files.pythonhosted.org/packages/7e/77/03fc2979d1538884d921c2013075917fc927f41cd8526909852fe4494112/coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1", size = 211502 }, +] + +[[package]] +name = "distlib" +version = "0.3.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, +] + +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 }, +] + +[[package]] +name = "email-validator" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521 }, +] + +[[package]] +name = "fastapi" +version = "0.115.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/29/f71316b9273b6552a263748e49cd7b83898dc9499a663d30c7b9cb853cb8/fastapi-0.115.5.tar.gz", hash = "sha256:0e7a4d0dc0d01c68df21887cce0945e72d3c48b9f4f79dfe7a7d53aa08fbb289", size = 301047 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/c4/148d5046a96c428464557264877ae5a9338a83bbe0df045088749ec89820/fastapi-0.115.5-py3-none-any.whl", hash = "sha256:596b95adbe1474da47049e802f9a65ab2ffa9c2b07e7efee70eb8a66c9f2f796", size = 94866 }, +] + +[[package]] +name = "filelock" +version = "3.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, +] + +[[package]] +name = "h11" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 }, +] + +[[package]] +name = "httpcore" +version = "1.0.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b6/44/ed0fa6a17845fb033bd885c03e842f08c1b9406c86a2e60ac1ae1b9206a6/httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f", size = 85180 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/89/b161908e2f51be56568184aeb4a880fd287178d176fd1c860d2217f41106/httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", size = 78011 }, +] + +[[package]] +name = "httptools" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214 }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431 }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121 }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805 }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858 }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042 }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682 }, +] + +[[package]] +name = "httpx" +version = "0.27.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/82/08f8c936781f67d9e6b9eeb8a0c8b4e406136ea4c3d1f89a5db71d42e0e6/httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2", size = 144189 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, +] + +[[package]] +name = "identify" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/79/7a520fc5011e02ca3f3285b5f6820eaf80443eb73e3733f73c02fb42ba0b/identify-2.6.2.tar.gz", hash = "sha256:fab5c716c24d7a789775228823797296a2994b075fb6080ac83a102772a98cbd", size = 99113 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/86/c4395700f3c5475424fb5c41e20c16be28d10c904aee4d005ba3217fc8e7/identify-2.6.2-py2.py3-none-any.whl", hash = "sha256:c097384259f49e372f4ea00a19719d95ae27dd5ff0fd77ad630aa891306b82f3", size = 98982 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "iniconfig" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/4b/cbd8e699e64a6f16ca3a8220661b5f83792b3017d0f79807cb8708d33913/iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3", size = 4646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, +] + +[[package]] +name = "loguru" +version = "0.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "win32-setctime", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/30/d87a423766b24db416a46e9335b9602b054a72b96a88a241f2b09b560fa8/loguru-0.7.2.tar.gz", hash = "sha256:e671a53522515f34fd406340ee968cb9ecafbc4b36c679da03c18fd8d0bd51ac", size = 145103 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/0a/4f6fed21aa246c6b49b561ca55facacc2a44b87d65b8b92362a8e99ba202/loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb", size = 62549 }, +] + +[[package]] +name = "mypy" +version = "1.13.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/21/7e9e523537991d145ab8a0a2fd98548d67646dc2aaaf6091c31ad883e7c1/mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e", size = 3152532 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/bb/ab4cfdc562cad80418f077d8be9b4491ee4fb257440da951b85cbb0a639e/mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7", size = 11069721 }, + { url = "https://files.pythonhosted.org/packages/59/3b/a393b1607cb749ea2c621def5ba8c58308ff05e30d9dbdc7c15028bca111/mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62", size = 10063996 }, + { url = "https://files.pythonhosted.org/packages/d1/1f/6b76be289a5a521bb1caedc1f08e76ff17ab59061007f201a8a18cc514d1/mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8", size = 12584043 }, + { url = "https://files.pythonhosted.org/packages/a6/83/5a85c9a5976c6f96e3a5a7591aa28b4a6ca3a07e9e5ba0cec090c8b596d6/mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7", size = 13036996 }, + { url = "https://files.pythonhosted.org/packages/b4/59/c39a6f752f1f893fccbcf1bdd2aca67c79c842402b5283563d006a67cf76/mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc", size = 9737709 }, + { url = "https://files.pythonhosted.org/packages/3b/86/72ce7f57431d87a7ff17d442f521146a6585019eb8f4f31b7c02801f78ad/mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a", size = 2647043 }, +] + +[package.optional-dependencies] +faster-cache = [ + { name = "orjson" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/a4/1ab47638b92648243faf97a5aeb6ea83059cc3624972ab6b8d2316078d3f/mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782", size = 4433 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/e2/5d3f6ada4297caebe1a2add3b126fe800c96f56dbe5d1988a2cbe0b267aa/mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d", size = 4695 }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, +] + +[[package]] +name = "orjson" +version = "3.10.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/3a/10320029954badc7eaa338a15ee279043436f396e965dafc169610e4933f/orjson-3.10.11.tar.gz", hash = "sha256:e35b6d730de6384d5b2dab5fd23f0d76fae8bbc8c353c2f78210aa5fa4beb3ef", size = 5444879 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/92/400970baf46b987c058469e9e779fb7a40d54a5754914d3634cca417e054/orjson-3.10.11-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:c46294faa4e4d0eb73ab68f1a794d2cbf7bab33b1dda2ac2959ffb7c61591899", size = 266402 }, + { url = "https://files.pythonhosted.org/packages/3c/fa/f126fc2d817552bd1f67466205abdcbff64eab16f6844fe6df2853528675/orjson-3.10.11-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52e5834d7d6e58a36846e059d00559cb9ed20410664f3ad156cd2cc239a11230", size = 140826 }, + { url = "https://files.pythonhosted.org/packages/ad/18/9b9664d7d4af5b4fe9fe6600b7654afc0684bba528260afdde10c4a530aa/orjson-3.10.11-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a2fc947e5350fdce548bfc94f434e8760d5cafa97fb9c495d2fef6757aa02ec0", size = 142593 }, + { url = "https://files.pythonhosted.org/packages/20/f9/a30c68f12778d5e58e6b5cdd26f86ee2d0babce1a475073043f46fdd8402/orjson-3.10.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0efabbf839388a1dab5b72b5d3baedbd6039ac83f3b55736eb9934ea5494d258", size = 146777 }, + { url = "https://files.pythonhosted.org/packages/f2/97/12047b0c0e9b391d589fb76eb40538f522edc664f650f8e352fdaaf77ff5/orjson-3.10.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3f29634260708c200c4fe148e42b4aae97d7b9fee417fbdd74f8cfc265f15b0", size = 142961 }, + { url = "https://files.pythonhosted.org/packages/a4/97/d904e26c1cabf2dd6ab1b0909e9b790af28a7f0fcb9d8378d7320d4869eb/orjson-3.10.11-cp313-none-win32.whl", hash = "sha256:1a1222ffcee8a09476bbdd5d4f6f33d06d0d6642df2a3d78b7a195ca880d669b", size = 144486 }, + { url = "https://files.pythonhosted.org/packages/42/62/3760bd1e6e949321d99bab238d08db2b1266564d2f708af668f57109bb36/orjson-3.10.11-cp313-none-win_amd64.whl", hash = "sha256:bc274ac261cc69260913b2d1610760e55d3c0801bb3457ba7b9004420b6b4270", size = 136361 }, +] + +[[package]] +name = "packaging" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 }, +] + +[[package]] +name = "platformdirs" +version = "4.3.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, +] + +[[package]] +name = "pluggy" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 }, +] + +[[package]] +name = "pre-commit" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c8/e22c292035f1bac8b9f5237a2622305bc0304e776080b246f3df57c4ff9f/pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", size = 191678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713 }, +] + +[[package]] +name = "pwdlib" +version = "0.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/a0/9daed437a6226f632a25d98d65d60ba02bdafa920c90dcb6454c611ead6c/pwdlib-0.2.1.tar.gz", hash = "sha256:9a1d8a8fa09a2f7ebf208265e55d7d008103cbdc82b9e4902ffdd1ade91add5e", size = 11699 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/f3/0dae5078a486f0fdf4d4a1121e103bc42694a9da9bea7b0f2c63f29cfbd3/pwdlib-0.2.1-py3-none-any.whl", hash = "sha256:1823dc6f22eae472b540e889ecf57fd424051d6a4023ec0bcf7f0de2d9d7ef8c", size = 8082 }, +] + +[package.optional-dependencies] +argon2 = [ + { name = "argon2-cffi" }, +] + +[[package]] +name = "pycparser" +version = "2.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 }, +] + +[[package]] +name = "pydantic" +version = "2.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/b7/d9e3f12af310e1120c21603644a1cd86f59060e040ec5c3a80b8f05fae30/pydantic-2.9.2.tar.gz", hash = "sha256:d155cef71265d1e9807ed1c32b4c8deec042a44a50a4188b25ac67ecd81a9c0f", size = 769917 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/e4/ba44652d562cbf0bf320e0f3810206149c8a4e99cdbf66da82e97ab53a15/pydantic-2.9.2-py3-none-any.whl", hash = "sha256:f048cec7b26778210e28a0459867920654d48e5e62db0958433636cde4254f12", size = 434928 }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.23.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/aa/6b6a9b9f8537b872f552ddd46dd3da230367754b6f707b8e1e963f515ea3/pydantic_core-2.23.4.tar.gz", hash = "sha256:2584f7cf844ac4d970fba483a717dbe10c1c1c96a969bf65d61ffe94df1b2863", size = 402156 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/ef/16ee2df472bf0e419b6bc68c05bf0145c49247a1095e85cee1463c6a44a1/pydantic_core-2.23.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:7530e201d10d7d14abce4fb54cfe5b94a0aefc87da539d0346a484ead376c3cc", size = 1856143 }, + { url = "https://files.pythonhosted.org/packages/da/fa/bc3dbb83605669a34a93308e297ab22be82dfb9dcf88c6cf4b4f264e0a42/pydantic_core-2.23.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df933278128ea1cd77772673c73954e53a1c95a4fdf41eef97c2b779271bd0bd", size = 1770063 }, + { url = "https://files.pythonhosted.org/packages/4e/48/e813f3bbd257a712303ebdf55c8dc46f9589ec74b384c9f652597df3288d/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cb3da3fd1b6a5d0279a01877713dbda118a2a4fc6f0d821a57da2e464793f05", size = 1790013 }, + { url = "https://files.pythonhosted.org/packages/b4/e0/56eda3a37929a1d297fcab1966db8c339023bcca0b64c5a84896db3fcc5c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42c6dcb030aefb668a2b7009c85b27f90e51e6a3b4d5c9bc4c57631292015b0d", size = 1801077 }, + { url = "https://files.pythonhosted.org/packages/04/be/5e49376769bfbf82486da6c5c1683b891809365c20d7c7e52792ce4c71f3/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:696dd8d674d6ce621ab9d45b205df149399e4bb9aa34102c970b721554828510", size = 1996782 }, + { url = "https://files.pythonhosted.org/packages/bc/24/e3ee6c04f1d58cc15f37bcc62f32c7478ff55142b7b3e6d42ea374ea427c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2971bb5ffe72cc0f555c13e19b23c85b654dd2a8f7ab493c262071377bfce9f6", size = 2661375 }, + { url = "https://files.pythonhosted.org/packages/c1/f8/11a9006de4e89d016b8de74ebb1db727dc100608bb1e6bbe9d56a3cbbcce/pydantic_core-2.23.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8394d940e5d400d04cad4f75c0598665cbb81aecefaca82ca85bd28264af7f9b", size = 2071635 }, + { url = "https://files.pythonhosted.org/packages/7c/45/bdce5779b59f468bdf262a5bc9eecbae87f271c51aef628d8c073b4b4b4c/pydantic_core-2.23.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0dff76e0602ca7d4cdaacc1ac4c005e0ce0dcfe095d5b5259163a80d3a10d327", size = 1916994 }, + { url = "https://files.pythonhosted.org/packages/d8/fa/c648308fe711ee1f88192cad6026ab4f925396d1293e8356de7e55be89b5/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7d32706badfe136888bdea71c0def994644e09fff0bfe47441deaed8e96fdbc6", size = 1968877 }, + { url = "https://files.pythonhosted.org/packages/16/16/b805c74b35607d24d37103007f899abc4880923b04929547ae68d478b7f4/pydantic_core-2.23.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ed541d70698978a20eb63d8c5d72f2cc6d7079d9d90f6b50bad07826f1320f5f", size = 2116814 }, + { url = "https://files.pythonhosted.org/packages/d1/58/5305e723d9fcdf1c5a655e6a4cc2a07128bf644ff4b1d98daf7a9dbf57da/pydantic_core-2.23.4-cp313-none-win32.whl", hash = "sha256:3d5639516376dce1940ea36edf408c554475369f5da2abd45d44621cb616f769", size = 1738360 }, + { url = "https://files.pythonhosted.org/packages/a5/ae/e14b0ff8b3f48e02394d8acd911376b7b66e164535687ef7dc24ea03072f/pydantic_core-2.23.4-cp313-none-win_amd64.whl", hash = "sha256:5a1504ad17ba4210df3a045132a7baeeba5a200e930f57512ee02909fc5c4cb5", size = 1919411 }, +] + +[[package]] +name = "pydantic-settings" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/d4/9dfbe238f45ad8b168f5c96ee49a3df0598ce18a0795a983b419949ce65b/pydantic_settings-2.6.1.tar.gz", hash = "sha256:e0f92546d8a9923cb8941689abf85d6601a8c19a23e97a34b2964a2e3f813ca0", size = 75646 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f9/ff95fd7d760af42f647ea87f9b8a383d891cdb5e5dbd4613edaeb094252a/pydantic_settings-2.6.1-py3-none-any.whl", hash = "sha256:7fb0637c786a558d3103436278a7c4f1cfd29ba8973238a50c5bb9a55387da87", size = 28595 }, +] + +[[package]] +name = "pyjwt" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/68/ce067f09fca4abeca8771fe667d89cc347d1e99da3e093112ac329c6020e/pyjwt-2.9.0.tar.gz", hash = "sha256:7e1e5b56cc735432a7369cbfa0efe50fa113ebecdc04ae6922deba8b84582d0c", size = 78825 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/84/0fdf9b18ba31d69877bd39c9cd6052b47f3761e9910c15de788e519f079f/PyJWT-2.9.0-py3-none-any.whl", hash = "sha256:3b02fb0f44517787776cf48f2ae25d8e14f300e6d7545a4315cee571a415e850", size = 22344 }, +] + +[[package]] +name = "pytest" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/6c/62bbd536103af674e227c41a8f3dcd022d591f6eed5facb5a0f31ee33bbc/pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181", size = 1442487 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/77/7440a06a8ead44c7757a64362dd22df5760f9b12dc5f11b6188cd2fc27a0/pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2", size = 342341 }, +] + +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/c6cf50ce320cf8611df7a1254d86233b3df7cc07f9b5f5cbcb82e08aa534/pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276", size = 49855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024 }, +] + +[[package]] +name = "pytest-cov" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949 }, +] + +[[package]] +name = "python-dotenv" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/57/e84d88dfe0aec03b7a2d4327012c1627ab5f03652216c63d49846d7a6c58/python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca", size = 39115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/3e/b68c118422ec867fa7ab88444e1274aa40681c606d59ac27de5a5588f082/python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a", size = 19863 }, +] + +[[package]] +name = "python-multipart" +version = "0.0.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/22/edea41c2d4a22e666c0c7db7acdcbf7bc8c1c1f7d3b3ca246ec982fec612/python_multipart-0.0.17.tar.gz", hash = "sha256:41330d831cae6e2f22902704ead2826ea038d0419530eadff3ea80175aec5538", size = 36452 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/fb/275137a799169392f1fa88fff2be92f16eee38e982720a8aaadefc4a36b2/python_multipart-0.0.17-py3-none-any.whl", hash = "sha256:15dc4f487e0a9476cc1201261188ee0940165cffc94429b6fc565c4d3045cb5d", size = 24453 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, +] + +[[package]] +name = "ruff" +version = "0.7.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/06/09d1276df977eece383d0ed66052fc24ec4550a61f8fbc0a11200e690496/ruff-0.7.3.tar.gz", hash = "sha256:e1d1ba2e40b6e71a61b063354d04be669ab0d39c352461f3d789cac68b54a313", size = 3243664 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/56/933d433c2489e4642487b835f53dd9ff015fb3d8fa459b09bb2ce42d7c4b/ruff-0.7.3-py3-none-linux_armv6l.whl", hash = "sha256:34f2339dc22687ec7e7002792d1f50712bf84a13d5152e75712ac08be565d344", size = 10372090 }, + { url = "https://files.pythonhosted.org/packages/20/ea/1f0a22a6bcdd3fc26c73f63a025d05bd565901b729d56bcb093c722a6c4c/ruff-0.7.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fb397332a1879b9764a3455a0bb1087bda876c2db8aca3a3cbb67b3dbce8cda0", size = 10190037 }, + { url = "https://files.pythonhosted.org/packages/16/74/aca75666e0d481fe394e76a8647c44ea919087748024924baa1a17371e3e/ruff-0.7.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:37d0b619546103274e7f62643d14e1adcbccb242efda4e4bdb9544d7764782e9", size = 9811998 }, + { url = "https://files.pythonhosted.org/packages/20/a1/cf446a0d7f78ea1f0bd2b9171c11dfe746585c0c4a734b25966121eb4f5d/ruff-0.7.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d59f0c3ee4d1a6787614e7135b72e21024875266101142a09a61439cb6e38a5", size = 10620626 }, + { url = "https://files.pythonhosted.org/packages/cd/c1/82b27d09286ae855f5d03b1ad37cf243f21eb0081732d4d7b0d658d439cb/ruff-0.7.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:44eb93c2499a169d49fafd07bc62ac89b1bc800b197e50ff4633aed212569299", size = 10177598 }, + { url = "https://files.pythonhosted.org/packages/b9/42/c0acac22753bf74013d035a5ef6c5c4c40ad4d6686bfb3fda7c6f37d9b37/ruff-0.7.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d0242ce53f3a576c35ee32d907475a8d569944c0407f91d207c8af5be5dae4e", size = 11171963 }, + { url = "https://files.pythonhosted.org/packages/43/18/bb0befb7fb9121dd9009e6a72eb98e24f1bacb07c6f3ecb55f032ba98aed/ruff-0.7.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:6b6224af8b5e09772c2ecb8dc9f3f344c1aa48201c7f07e7315367f6dd90ac29", size = 11856157 }, + { url = "https://files.pythonhosted.org/packages/5e/91/04e98d7d6e32eca9d1372be595f9abc7b7f048795e32eb2edbd8794d50bd/ruff-0.7.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c50f95a82b94421c964fae4c27c0242890a20fe67d203d127e84fbb8013855f5", size = 11440331 }, + { url = "https://files.pythonhosted.org/packages/f5/dc/3fe99f2ce10b76d389041a1b9f99e7066332e479435d4bebcceea16caff5/ruff-0.7.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f3eff9961b5d2644bcf1616c606e93baa2d6b349e8aa8b035f654df252c8c67", size = 12725354 }, + { url = "https://files.pythonhosted.org/packages/43/7b/1daa712de1c5bc6cbbf9fa60e9c41cc48cda962dc6d2c4f2a224d2c3007e/ruff-0.7.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8963cab06d130c4df2fd52c84e9f10d297826d2e8169ae0c798b6221be1d1d2", size = 11010091 }, + { url = "https://files.pythonhosted.org/packages/b6/db/1227a903587432eb569e57a95b15a4f191a71fe315cde4c0312df7bc85da/ruff-0.7.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:61b46049d6edc0e4317fb14b33bd693245281a3007288b68a3f5b74a22a0746d", size = 10610687 }, + { url = "https://files.pythonhosted.org/packages/db/e2/dc41ee90c3085aadad4da614d310d834f641aaafddf3dfbba08210c616ce/ruff-0.7.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:10ebce7696afe4644e8c1a23b3cf8c0f2193a310c18387c06e583ae9ef284de2", size = 10254843 }, + { url = "https://files.pythonhosted.org/packages/6f/09/5f6cac1c91542bc5bd33d40b4c13b637bf64d7bb29e091dadb01b62527fe/ruff-0.7.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3f36d56326b3aef8eeee150b700e519880d1aab92f471eefdef656fd57492aa2", size = 10730962 }, + { url = "https://files.pythonhosted.org/packages/d3/42/89a4b9a24ef7d00269e24086c417a006f9a3ffeac2c80f2629eb5ce140ee/ruff-0.7.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5d024301109a0007b78d57ab0ba190087b43dce852e552734ebf0b0b85e4fb16", size = 11101907 }, + { url = "https://files.pythonhosted.org/packages/b0/5c/efdb4777686683a8edce94ffd812783bddcd3d2454d38c5ac193fef7c500/ruff-0.7.3-py3-none-win32.whl", hash = "sha256:4ba81a5f0c5478aa61674c5a2194de8b02652f17addf8dfc40c8937e6e7d79fc", size = 8611095 }, + { url = "https://files.pythonhosted.org/packages/bb/b8/28fbc6a4efa50178f973972d1c84b2d0a33cdc731588522ab751ac3da2f5/ruff-0.7.3-py3-none-win_amd64.whl", hash = "sha256:588a9ff2fecf01025ed065fe28809cd5a53b43505f48b69a1ac7707b1b7e4088", size = 9418283 }, + { url = "https://files.pythonhosted.org/packages/3f/77/b587cba6febd5e2003374f37eb89633f79f161e71084f94057c8653b7fb3/ruff-0.7.3-py3-none-win_arm64.whl", hash = "sha256:1713e2c5545863cdbfe2cbce21f69ffaf37b813bfd1fb3b90dc9a6f1963f5a8c", size = 8725228 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "starlette" +version = "0.41.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3e/da/1fb4bdb72ae12b834becd7e1e7e47001d32f91ec0ce8d7bc1b618d9f0bd9/starlette-0.41.2.tar.gz", hash = "sha256:9834fd799d1a87fd346deb76158668cfa0b0d56f85caefe8268e2d97c3468b62", size = 2573867 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/43/f185bfd0ca1d213beb4293bed51d92254df23d8ceaf6c0e17146d508a776/starlette-0.41.2-py3-none-any.whl", hash = "sha256:fbc189474b4731cf30fcef52f18a8d070e3f3b46c6a04c97579e85e6ffca942d", size = 73259 }, +] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, +] + +[[package]] +name = "uvicorn" +version = "0.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e0/fc/1d785078eefd6945f3e5bab5c076e4230698046231eb0f3747bc5c8fa992/uvicorn-0.32.0.tar.gz", hash = "sha256:f78b36b143c16f54ccdb8190d0a26b5f1901fe5a3c777e1ab29f26391af8551e", size = 77564 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/14/78bd0e95dd2444b6caacbca2b730671d4295ccb628ef58b81bee903629df/uvicorn-0.32.0-py3-none-any.whl", hash = "sha256:60b8f3a5ac027dcd31448f411ced12b5ef452c646f76f02f8cc3f25d8d26fd82", size = 63723 }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123 }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325 }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806 }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068 }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428 }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018 }, +] + +[[package]] +name = "virtualenv" +version = "20.27.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/b3/7b6a79c5c8cf6d90ea681310e169cf2db2884f4d583d16c6e1d5a75a4e04/virtualenv-20.27.1.tar.gz", hash = "sha256:142c6be10212543b32c6c45d3d3893dff89112cc588b7d0879ae5a1ec03a47ba", size = 6491145 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/92/78324ff89391e00c8f4cf6b8526c41c6ef36b4ea2d2c132250b1a6fc2b8d/virtualenv-20.27.1-py3-none-any.whl", hash = "sha256:f11f1b8a29525562925f745563bfd48b189450f61fb34c4f9cc79dd5aa32a1f4", size = 3117838 }, +] + +[[package]] +name = "win32-setctime" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/dd/f95a13d2b235a28d613ba23ebad55191514550debb968b46aab99f2e3a30/win32_setctime-1.1.0.tar.gz", hash = "sha256:15cf5750465118d6929ae4de4eb46e8edae9a5634350c01ba582df868e932cb2", size = 3676 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/e6/a7d828fef907843b2a5773ebff47fb79ac0c1c88d60c0ca9530ee941e248/win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad", size = 3604 }, +] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c674f97 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + db: + image: postgres:17-alpine + expose: + - 5432 + ports: + - 5432:5432 + environment: + POSTGRES_USER: "postgres" + POSTGRES_PASSWORD: "test_password" + POSTGRES_DB: "scan" + volumes: + - db-data:/var/lib/postgresql/data + +volumes: + db-data: + +networks: + scan-network: diff --git a/justfile b/justfile new file mode 100644 index 0000000..215af39 --- /dev/null +++ b/justfile @@ -0,0 +1,62 @@ +@_default: + just --list + +@lint: + echo mypy + just --justfile {{justfile()}} mypy + echo ruff-check + just --justfile {{justfile()}} ruff-check + echo ruff-format + just --justfile {{justfile()}} ruff-format + +@mypy: + cd backend && \ + uv run mypy app tests + +@ruff-check: + cd backend && \ + uv run ruff check app tests + +@ruff-format: + cd backend && \ + uv run ruff format app tests + +@ruff-format-ci: + cd backend && \ + uv run ruff format app tests --check + +@backend-test *args="": + -cd backend && \ + uv run pytest {{args}} + +@backend-lock: + cd backend && \ + uv lock + +@backend-lock-upgrade: + cd backend && \ + uv lock --upgrade + +@backend-install: + cd backend && \ + uv sync --frozen --all-extras + +@backend-server: + cd backend && \ + uv run uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload + +@docker-up: + docker compose up + +@docker-up-detached: + docker compose up -d + +@docker-down: + docker compose down + +@docker-down-volumes: + docker compose down --volumes + +@docker-build-backend: + cd backend && \ + docker build -t scanue-v . From 92c0aabd82962ddeca288f205d54f7d354da19c1 Mon Sep 17 00:00:00 2001 From: Paul Sanders Date: Sat, 16 Nov 2024 18:22:43 -0500 Subject: [PATCH 2/6] Setup scan route --- backend/app/api/router.py | 3 +- backend/app/api/routes/scan.py | 29 ++ backend/app/models/scan.py | 13 + backend/app/scan/__init__.py | 0 backend/app/scan/graph.py | 86 +++++ backend/app/scan/openai.py | 34 ++ backend/app/scan/pfc_agents.py | 333 ++++++++++++++++++ backend/app/types.py | 3 + backend/pyproject.toml | 6 +- backend/tests/scan/__init__.py | 0 backend/tests/scan/test_pfc_agents.py | 135 +++++++ backend/uv.lock | 485 +++++++++++++++++++++++++- 12 files changed, 1124 insertions(+), 3 deletions(-) create mode 100644 backend/app/api/routes/scan.py create mode 100644 backend/app/models/scan.py create mode 100644 backend/app/scan/__init__.py create mode 100644 backend/app/scan/graph.py create mode 100644 backend/app/scan/openai.py create mode 100644 backend/app/scan/pfc_agents.py create mode 100644 backend/app/types.py create mode 100644 backend/tests/scan/__init__.py create mode 100644 backend/tests/scan/test_pfc_agents.py diff --git a/backend/app/api/router.py b/backend/app/api/router.py index e9cc08a..004f0fa 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -1,7 +1,8 @@ -from app.api.routes import health, login, users +from app.api.routes import health, login, scan, users from app.core.utils import APIRouter api_router = APIRouter() api_router.include_router(health.router) api_router.include_router(login.router) +api_router.include_router(scan.router) api_router.include_router(users.router) diff --git a/backend/app/api/routes/scan.py b/backend/app/api/routes/scan.py new file mode 100644 index 0000000..8ca8f21 --- /dev/null +++ b/backend/app/api/routes/scan.py @@ -0,0 +1,29 @@ +from fastapi import HTTPException +from loguru import logger +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR + +from app.api.deps import CurrentUser +from app.core.config import settings +from app.core.utils import APIRouter +from app.models.scan import AnalysisReport, Topic +from app.scan.graph import CustomGraph + +router = APIRouter(tags=["SCAN"], prefix=f"{settings.API_V1_PREFIX}/scan") + + +@router.post("/") +async def ask_question(*, topic: Topic, _: CurrentUser) -> AnalysisReport: + """Ask SCAN for help with a questions.""" + + graph = CustomGraph(topic.topic) + try: + logger.debug("Preparing analysis") + analysis = await graph.execute() + except Exception as e: + logger.error(f"An error occurred while answering question: {e}") + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail="An error occurred when getting an answer", + ) from e + + return analysis diff --git a/backend/app/models/scan.py b/backend/app/models/scan.py new file mode 100644 index 0000000..413b876 --- /dev/null +++ b/backend/app/models/scan.py @@ -0,0 +1,13 @@ +from camel_converter.pydantic_base import CamelBase + + +class AnalysisReport(CamelBase): + dlpfc_analysis: str | None = None + vmpfc_analysis: str | None = None + ofc_analysis: str | None = None + acc_analysis: str | None = None + mpfc_analysis: str | None = None + + +class Topic(CamelBase): + topic: str diff --git a/backend/app/scan/__init__.py b/backend/app/scan/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/scan/graph.py b/backend/app/scan/graph.py new file mode 100644 index 0000000..555d665 --- /dev/null +++ b/backend/app/scan/graph.py @@ -0,0 +1,86 @@ +from collections.abc import Hashable +from typing import cast + +from langgraph.graph import END, StateGraph + +from app.models.scan import AnalysisReport +from app.scan.pfc_agents import AgentState, PFCAgents + + +class CustomGraph: + def __init__(self, topic: str) -> None: + self.topic = topic + self.agents = PFCAgents(topic=self.topic) + self.workflow = StateGraph(AgentState) + + def build_graph(self) -> None: + for role, agent_function in self.agents.agents.items(): + self.workflow.add_node(role, agent_function) + + def router(state: AgentState) -> str | None: + next_agent = state["next"] + if next_agent == END: + return None + + if next_agent is not None: + return next_agent + return END + + dlpfc_edges: dict[str, str] = { + "VMPFC": "VMPFC", + "OFC": "OFC", + "ACC": "ACC", + "MPFC": "MPFC", + END: END, + } + + self.workflow.add_conditional_edges("DLPFC", router, cast(dict[Hashable, str], dlpfc_edges)) + + for role in ("VMPFC", "OFC", "ACC", "MPFC"): + self.workflow.add_conditional_edges( + role, + router, + ) + + self.workflow.set_entry_point("DLPFC") + self.graph = self.workflow.compile() + + async def execute(self) -> AnalysisReport: + self.build_graph() + + initial_state = AgentState( + input=self.topic, + history=[], + next="DLPFC", + current_role="DLPFC", + ) + + final_state = await self.graph.ainvoke(initial_state) + return self.prepare_output(cast(AgentState, final_state)) + + def prepare_output(self, state: AgentState) -> AnalysisReport: + dlpfc_analysis = None + vmpfc_analysis = None + ofc_analysis = None + acc_analysis = None + mpfc_analysis = None + + for role, analysis in state["history"]: + if role == "DLPFC": + dlpfc_analysis = " ".join(analysis) if analysis else None + elif role == "VMPFC": + vmpfc_analysis = " ".join(analysis) if analysis else None + elif role == "OFC": + ofc_analysis = " ".join(analysis) if analysis else None + elif role == "ACC": + acc_analysis = " ".join(analysis) if analysis else None + elif role == "MPFC": + mpfc_analysis = " ".join(analysis) if analysis else None + + return AnalysisReport( + dlpfc_analysis=dlpfc_analysis, + vmpfc_analysis=vmpfc_analysis, + ofc_analysis=ofc_analysis, + acc_analysis=acc_analysis, + mpfc_analysis=mpfc_analysis, + ) diff --git a/backend/app/scan/openai.py b/backend/app/scan/openai.py new file mode 100644 index 0000000..f7d3288 --- /dev/null +++ b/backend/app/scan/openai.py @@ -0,0 +1,34 @@ +from collections.abc import Iterable + +from openai import AsyncOpenAI +from openai.types.chat import ChatCompletionMessageParam + +from app.core.config import settings + + +class OpenAIWrapper: + """Wrapper for OpenAI API interactions.""" + + def __init__(self, model_name: str): + self.model_name = model_name + self.client = AsyncOpenAI(api_key=settings.OPENAI_API_KEY) + + async def create_chat_completion( + self, + messages: Iterable[ChatCompletionMessageParam], + max_tokens: int = settings.MAX_TOKENS, + temperature: float = settings.TEMPERATURE, + ) -> str | None: + """Create a chat completion with error handling.""" + + response = await self.client.chat.completions.create( + model=self.model_name, + messages=messages, + max_tokens=max_tokens, + temperature=temperature, + ) + + if not response.choices[0].message.content: + return None + + return response.choices[0].message.content.strip() diff --git a/backend/app/scan/pfc_agents.py b/backend/app/scan/pfc_agents.py new file mode 100644 index 0000000..883bac1 --- /dev/null +++ b/backend/app/scan/pfc_agents.py @@ -0,0 +1,333 @@ +from collections.abc import Callable, Coroutine +from functools import cached_property +from typing import Annotated, Any, TypedDict, cast + +from langchain.tools import Tool +from langchain_core.messages import AIMessage +from langgraph.prebuilt import ToolNode + +from app.core.config import settings +from app.scan.openai import OpenAIWrapper +from app.types import RoleName + + +class AgentState(TypedDict): + current_role: RoleName + input: str + history: list[tuple[str, str]] # List of (role, message) tuples + next: str | None + + +class ToolInput(TypedDict): + input: str # The topic or content to analyze + history: list[tuple[str, str]] # List of (role, message) tuples + role: str # The PFC role performing the analysis + + +class AgentConfig(TypedDict): + role: str + model: str + backstory: str + goal: str + temperature: float + max_tokens: int + + +class RolesConfig(TypedDict): + DLPFC: AgentConfig + VMPFC: AgentConfig + OFC: AgentConfig + ACC: AgentConfig + MPFC: AgentConfig + + +class PFCAgents: + """Implements a network of PFC region agents that collaborate in cognitive processing. + + Each agent represents a distinct prefrontal cortex region with specialized functions. + """ + + def __init__(self, topic: str) -> None: + self.topic = topic + + @cached_property + def agents( + self, + ) -> dict[RoleName, Callable[[AgentState], Coroutine[None, None, AgentState]]]: + agents_dict = {} + for role in ("DLPFC", "VMPFC", "OFC", "ACC", "MPFC"): + agents_dict[cast(RoleName, role)] = self.create_agent_function(cast(RoleName, role)) + + return agents_dict + + def create_agent_function( + self, role_name: RoleName + ) -> Callable[[AgentState], Coroutine[None, None, AgentState]]: + config = self.create_agent(role_name) + + async def agent_function(state: AgentState) -> AgentState: + current_input = state["input"] + history = state["history"] + + context_messages = [] + for role, message in history: + context_messages.append(f"{role}:\n{message}\n") + context = "\n".join(context_messages) if context_messages else "" + + messages = [ + {"role": "system", "content": config["backstory"] + "\n" + config["goal"]}, + { + "role": "user", + "content": ( + f"Previous Analysis:\n{context}\n\n" + f"Current Topic: {current_input}\n\n" + f"Provide your analysis as {role_name}, considering the previous analyses " + f"and focusing on your specific role in the decision-making process." + ), + }, + ] + + llm_response = await config["llm"].create_chat_completion( + messages=messages, + max_tokens=settings.MAX_TOKENS, + temperature=settings.TEMPERATURE, + ) + + tool_response = await config["tool_node"].ainvoke( + {"messages": [AIMessage(content=llm_response, history=history, role=role_name)]} + ) + next_agent = _determine_next_agent(role_name) + + return AgentState( + current_role=role_name, + input=tool_response, + history=history + [(role_name, tool_response["messages"])], + next=next_agent, + ) + + return agent_function + + def create_agent(self, role_name: RoleName) -> dict[str, Any]: + """Creates base agent configuration with enhanced setup.""" + model_name = getattr(settings, f"{role_name}_MODEL") + client = OpenAIWrapper(model_name=model_name) + tools = _get_tools(role_name) + tool_node = ToolNode(tools) + backstory = _get_backstory(role_name) + goal = _get_goal(role_name) + + return { + "role": role_name, + "llm": client, + "tools": tools, + "tool_node": tool_node, + "backstory": f"You are the {role_name}, specializing in {backstory} for the topic '{self.topic}'.", + "goal": goal, + } + + def communicate(self, state: AgentState) -> AgentState: + state["next"] = _determine_next_agent(cast(RoleName, state["current_role"])) + + return state + + +def _get_prompt_templates(context: str, topic: str, role_name: RoleName) -> dict[str, str]: + return { + "analysis": f""" + Previous Analysis: + {context} + + Current Topic: {topic} + Role: {role_name} + + Instructions: + 1. Review previous analyses if available + 2. Analyze from your role's perspective + 3. Consider interactions with other PFC regions + 4. Provide structured insights + + Format your response with clear sections and bullet points. + """, + "integration": """ + As the DLPFC, integrate the following analyses: + {context} + + Focus on: + 1. Key patterns and insights + 2. Conflicts or contradictions + 3. Integrated recommendations + 4. Next steps + """, + "error": "Error in {role} processing: {error}", + } + + +async def _complex_analyzer( + tool_input: Annotated[ToolInput, "Input for complex analysis"], +) -> str: + return await _build_analyzer(tool_input, "analysis", settings.DLPFC_MODEL) + + +async def _integration_analyzer( + tool_input: Annotated[ToolInput, "Input for integration analysis"], +) -> str: + return await _build_analyzer(tool_input, "integration", settings.MPFC_MODEL) + + +async def _emotional_analyzer( + tool_input: Annotated[ToolInput, "Input for emotional analysis"], +) -> str: + return await _build_analyzer(tool_input, "emotional", settings.VMPFC_MODEL) + + +async def _reward_analyzer( + tool_input: Annotated[ToolInput, "Input for reward analysis"], +) -> str: + return await _build_analyzer(tool_input, "reward", settings.OFC_MODEL) + + +async def _conflict_analyzer( + tool_input: Annotated[ToolInput, "Input for conflict analysis"], +) -> str: + return await _build_analyzer(tool_input, "conflict", settings.ACC_MODEL) + + +async def _social_analyzer( + tool_input: Annotated[ToolInput, "Input for social analysis"], +) -> str: + return await _build_analyzer(tool_input, "social", settings.MPFC_MODEL) + + +async def _build_analyzer(tool_input: ToolInput, prompt_template: str, model: str) -> str: + topic = tool_input["input"] + role = tool_input["role"] + history = tool_input["history"] + context_messages = [f"{prev_role}:\n{message}\n" for prev_role, message in history] + context = "\n".join(context_messages) if context_messages else "" + prompt_template = _get_prompt_templates(context, topic, cast(RoleName, role))[prompt_template] + prompt = prompt_template.format(context=context, role=role, topic=topic) + openai_wrapper = OpenAIWrapper(model_name=model) + analysis_report = await openai_wrapper.create_chat_completion( + messages=[ + { + "role": "system", + "content": f"You are the {role}, focusing on specialized analysis. " + f"Format your response with clear sections and bullet points.", + }, + {"role": "user", "content": prompt}, + ], + max_tokens=settings.MAX_TOKENS, + temperature=settings.TEMPERATURE, + ) + return f"{role} Analysis:\n{analysis_report}" + + +def _get_tools(role_name: RoleName) -> list[Tool]: + tools = [ + Tool( + name="complex_analyzer", + description="Conducts comprehensive analysis based on role specialization", + func=None, # langchain forces you to set this to None when using a coroutine + coroutine=_complex_analyzer, + input_schema=ToolInput, + ) + ] + + if role_name == "DLPFC": + tools.append( + Tool( + name="integration_analyzer", + description="Integrates information from other PFC regions", + func=None, + coroutine=_integration_analyzer, + input_schema=ToolInput, + ) + ) + return tools + + if role_name == "VMPFC": + tools.append( + Tool( + name="emotional_analyzer", + description="Analyzes emotional implications", + func=None, + coroutine=_emotional_analyzer, + input_schema=ToolInput, + ) + ) + return tools + + if role_name == "OFC": + tools.append( + Tool( + name="reward_analyzer", + description="Analyzes reward implications", + func=None, + coroutine=_reward_analyzer, + input_schema=ToolInput, + ) + ) + return tools + + if role_name == "ACC": + tools.append( + Tool( + name="confict_analyzer", + description="Analyzes conflict implications", + func=None, + coroutine=_conflict_analyzer, + input_schema=ToolInput, + ) + ) + return tools + + tools.append( + Tool( + name="social_analyzer", + description="Analyzes social implications", + func=None, + coroutine=_social_analyzer, + input_schema=ToolInput, + ) + ) + + return tools + + +def _determine_next_agent(current_role: RoleName) -> str | None: + if current_role == "DLPFC": + return "VMPFC" + elif current_role == "VMPFC": + return "OFC" + elif current_role == "OFC": + return "ACC" + elif current_role == "ACC": + return "MPFC" + else: + return None + + +def _get_backstory(role_name: RoleName) -> str: + if role_name == "DLPFC": + return "executive functions including working memory, planning, and cognitive control. You integrate information to guide complex decision-making and regulate behavior." + elif role_name == "VMPFC": + return "processing emotional value and risk assessment. You evaluate the emotional significance of choices and predict their emotional outcomes." + elif role_name == "OFC": + return "reward processing and value-based decision making. You integrate sensory and emotional information to evaluate rewards and guide behavior optimization." + elif role_name == "ACC": + return "error detection, conflict monitoring, and emotional regulation. You identify conflicts between competing responses and help regulate emotional reactions." + else: + return "self-referential thinking and social cognition. You process information about self and others, supporting social decision-making and perspective-taking." + + +def _get_goal(role_name: RoleName) -> str: + if role_name == "DLPFC": + return "Analyze the situation using executive control, maintain relevant information in working memory, and develop strategic plans for optimal outcomes." + elif role_name == "VMPFC": + return "Evaluate the emotional implications and risks, considering how different choices might affect emotional wellbeing and social relationships." + elif role_name == "OFC": + return "Assess the reward value of different options, integrate sensory and emotional information, and optimize decision-making for maximum benefit." + elif role_name == "ACC": + return "Monitor for conflicts between competing options, detect potential errors, and help regulate emotional responses to support optimal choices." + else: + return "Consider social and self-relevant implications, integrate personal and social knowledge, and support perspective-taking in decision-making." diff --git a/backend/app/types.py b/backend/app/types.py new file mode 100644 index 0000000..a4fe358 --- /dev/null +++ b/backend/app/types.py @@ -0,0 +1,3 @@ +from typing import Literal + +type RoleName = Literal["DLPFC", "VMPFC", "OFC", "ACC", "MPFC"] diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7e1da7a..a2128de 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -15,7 +15,11 @@ dependencies = [ "camel-converter[pydantic]==4.0.1", "fastapi==0.115.5", "httptools==0.6.4", + "httpx==0.27.2", + "langchain==0.3.7", + "langgraph==0.2.48", "loguru==0.7.2", + "openai==1.54.4", "orjson==3.10.11", "pwdlib[argon2]==0.2.1", "pydantic[email]==2.9.2", @@ -24,7 +28,6 @@ dependencies = [ "python-multipart==0.0.17", "uvicorn==0.32.0", "uvloop==0.21.0", - "httpx>=0.27.2", ] [dependency-groups] @@ -56,6 +59,7 @@ ignore_missing_imports = true minversion = "6.0" addopts = "--cov=app --cov-report term-missing --no-cov-on-fail" asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" [tool.coverage.report] exclude_lines = ["if __name__ == .__main__.:", "pragma: no cover"] diff --git a/backend/tests/scan/__init__.py b/backend/tests/scan/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/scan/test_pfc_agents.py b/backend/tests/scan/test_pfc_agents.py new file mode 100644 index 0000000..3829650 --- /dev/null +++ b/backend/tests/scan/test_pfc_agents.py @@ -0,0 +1,135 @@ +from typing import cast + +import pytest + +from app.scan.pfc_agents import ( + PFCAgents, + _determine_next_agent, + _get_backstory, + _get_goal, + _get_prompt_templates, +) +from app.types import RoleName + + +def test_agents(): + pfc_agents = PFCAgents("some topic") + + assert list(pfc_agents.agents.keys()) == ["DLPFC", "VMPFC", "OFC", "ACC", "MPFC"] + + +def test_create_agent(): + role = "DLPFC" + pfc_agents = PFCAgents("some topic") + + result = pfc_agents.create_agent(cast(RoleName, role)) + + assert result["role"] == role + assert ( + result["backstory"] + == f"You are the {role}, specializing in executive functions including working memory, planning, and cognitive control. You integrate information to guide complex decision-making and regulate behavior. for the topic 'some topic'." + ) + assert ( + result["goal"] + == "Analyze the situation using executive control, maintain relevant information in working memory, and develop strategic plans for optimal outcomes." + ) + + +@pytest.mark.parametrize( + "role, expected", + ( + ( + "DLPFC", + "executive functions including working memory, planning, and cognitive control. You integrate information to guide complex decision-making and regulate behavior.", + ), + ( + "VMPFC", + "processing emotional value and risk assessment. You evaluate the emotional significance of choices and predict their emotional outcomes.", + ), + ( + "ACC", + "error detection, conflict monitoring, and emotional regulation. You identify conflicts between competing responses and help regulate emotional reactions.", + ), + ( + "MPFC", + "self-referential thinking and social cognition. You process information about self and others, supporting social decision-making and perspective-taking.", + ), + ), +) +def test_get_backstory(role, expected): + assert _get_backstory(role) == expected + + +@pytest.mark.parametrize( + "role, expected", + ( + ( + "DLPFC", + "Analyze the situation using executive control, maintain relevant information in working memory, and develop strategic plans for optimal outcomes.", + ), + ( + "VMPFC", + "Evaluate the emotional implications and risks, considering how different choices might affect emotional wellbeing and social relationships.", + ), + ( + "OFC", + "Assess the reward value of different options, integrate sensory and emotional information, and optimize decision-making for maximum benefit.", + ), + ( + "ACC", + "Monitor for conflicts between competing options, detect potential errors, and help regulate emotional responses to support optimal choices.", + ), + ( + "MPFC", + "Consider social and self-relevant implications, integrate personal and social knowledge, and support perspective-taking in decision-making.", + ), + ), +) +def test_get_goal(role, expected): + assert _get_goal(role) == expected + + +@pytest.mark.parametrize( + "role, expected", + (("DLPFC", "VMPFC"), ("VMPFC", "DLPFC"), ("OFC", "DLPFC"), ("ACC", "DLPFC"), ("MPFC", "DLPFC")), +) +def test_determine_next_agent(role, expected): + result = _determine_next_agent(role) + + assert result == expected + + +def test_get_prompt_templates(): + context = "some context" + topic = "some topic" + role_name = "DLPFC" + result = _get_prompt_templates(context, topic, cast(RoleName, role_name)) + + assert result == { + "analysis": f""" + Previous Analysis: + {context} + + Current Topic: {topic} + Role: {role_name} + + Instructions: + 1. Review previous analyses if available + 2. Analyze from your role's perspective + 3. Consider interactions with other PFC regions + 4. Provide structured insights + + Format your response with clear sections and bullet points. + """, + "integration": """ + As the DLPFC, integrate the following analyses: + {context} + + Focus on: + 1. Key patterns and insights + 2. Conflicts or contradictions + 3. Integrated recommendations + 4. Next steps + """, + "error": "Error in {role} processing: {error}", + } diff --git a/backend/uv.lock b/backend/uv.lock index 71955d0..1a01313 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -1,6 +1,59 @@ version = 1 requires-python = ">=3.13" +[[package]] +name = "aiohappyeyeballs" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/69/2f6d5a019bd02e920a3417689a89887b39ad1e350b562f9955693d900c40/aiohappyeyeballs-2.4.3.tar.gz", hash = "sha256:75cf88a15106a5002a8eb1dab212525c00d1f4c0fa96e551c9fbe6f09a621586", size = 21809 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/d8/120cd0fe3e8530df0539e71ba9683eade12cae103dd7543e50d15f737917/aiohappyeyeballs-2.4.3-py3-none-any.whl", hash = "sha256:8a7a83727b2756f394ab2895ea0765a0a8c475e3c71e98d43d76f22b4b435572", size = 14742 }, +] + +[[package]] +name = "aiohttp" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/68/97e4fab2add44bbd4b0107379d6900e80556c9a5d8ff548385690807b3f6/aiohttp-3.11.2.tar.gz", hash = "sha256:68d1f46f9387db3785508f5225d3acbc5825ca13d9c29f2b5cce203d5863eb79", size = 7658216 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/54/d1f8f63bccc5329580cb473dedc2f0d9e8682491163d98e182f9b3eb53db/aiohttp-3.11.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5d90b5a3b0f32a5fecf5dd83d828713986c019585f5cddf40d288ff77f366615", size = 695491 }, + { url = "https://files.pythonhosted.org/packages/19/8d/7f66861a7239f895b271fdffc3a4308c6e619a5020014437b995c5b71c9e/aiohttp-3.11.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d23854e5867650d40cba54d49956aad8081452aa80b2cf0d8c310633f4f48510", size = 458268 }, + { url = "https://files.pythonhosted.org/packages/4b/6f/cd7477819050ff819b5affd724a13d52832771d3b3da310f3abedafcaf1c/aiohttp-3.11.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:486273d3b5af75a80c31c311988931bdd2a4b96a74d5c7f422bad948f99988ef", size = 451154 }, + { url = "https://files.pythonhosted.org/packages/b2/bf/f87345e82156dcdc5d5b547f57074a5144d8036db2e9a7ea3f2047ae04b8/aiohttp-3.11.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9075313f8e41b481e4cb10af405054564b0247dc335db5398ed05f8ec38787e2", size = 1662053 }, + { url = "https://files.pythonhosted.org/packages/33/b0/689ebc9582c3db2aa7f8c2752179264087f11b25d3dbb9f9a3f53257c829/aiohttp-3.11.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44b69c69c194ffacbc50165911cf023a4b1b06422d1e1199d3aea82eac17004e", size = 1713248 }, + { url = "https://files.pythonhosted.org/packages/f5/0d/ce17f71443d4c0ab1c097be0bab32c82e95e41e2c7e12dd8445a8f000e35/aiohttp-3.11.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b339d91ac9060bd6ecdc595a82dc151045e5d74f566e0864ef3f2ba0887fec42", size = 1769628 }, + { url = "https://files.pythonhosted.org/packages/9c/a6/2eabd3a480d7a8ef0800d9e9ad40f7411c868256343ccd655ba809ed1856/aiohttp-3.11.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64e8f5178958a9954043bc8cd10a5ae97352c3f2fc99aa01f2aebb0026010910", size = 1672970 }, + { url = "https://files.pythonhosted.org/packages/f9/4e/fb1184d90a4a1db78d57193434d0a63f78ff4985852b66ed0b1fc91969d7/aiohttp-3.11.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3129151378f858cdc4a0a4df355c9a0d060ab49e2eea7e62e9f085bac100551b", size = 1599866 }, + { url = "https://files.pythonhosted.org/packages/8b/f3/2e8e9cb2b6e623d17c685b5112d76adba0a305905b1fcc4f1dc5d2ce6aab/aiohttp-3.11.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:14eb6c628432720e41b4fab1ada879d56cfe7034159849e083eb536b4c2afa99", size = 1616817 }, + { url = "https://files.pythonhosted.org/packages/c3/78/598d8a49d7aea14fa8a274d115ab09d2f6c0b35de0db26cc68dc6d6dda6d/aiohttp-3.11.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e57a10aacedcf24666f4c90d03e599f71d172d1c5e00dcf48205c445806745b0", size = 1616944 }, + { url = "https://files.pythonhosted.org/packages/02/53/aa9491b43f7f2a0b0ea43b6b269074a795b2964e7562389010dab1503531/aiohttp-3.11.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:66e58a2e8c7609a3545c4b38fb8b01a6b8346c4862e529534f7674c5265a97b8", size = 1684225 }, + { url = "https://files.pythonhosted.org/packages/4e/0a/3b6aa94de5e88a2a0d629740fa725101228ad005ee244259e4cc8a837def/aiohttp-3.11.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9b6d15adc9768ff167614ca853f7eeb6ee5f1d55d5660e3af85ce6744fed2b82", size = 1714720 }, + { url = "https://files.pythonhosted.org/packages/22/77/75b3a9cfe9c9ec901787f094379f4763c277aac8260f102e6056265dc0de/aiohttp-3.11.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2914061f5ca573f990ec14191e6998752fa8fe50d518e3405410353c3f44aa5d", size = 1670310 }, + { url = "https://files.pythonhosted.org/packages/db/6b/7a99a3d14df8a383f842ee96f17ee9a53b6ebba0d9ed82adbd40e5c70a0e/aiohttp-3.11.2-cp313-cp313-win32.whl", hash = "sha256:1c2496182e577042e0e07a328d91c949da9e77a2047c7291071e734cd7a6e780", size = 408542 }, + { url = "https://files.pythonhosted.org/packages/db/aa/ed3747ecd096c3cd85d8aca0c419079494b344f827349998570233428813/aiohttp-3.11.2-cp313-cp313-win_amd64.whl", hash = "sha256:cccb2937bece1310c5c0163d0406aba170a2e5fb1f0444d7b0e7fdc9bd6bb713", size = 434560 }, +] + +[[package]] +name = "aiosignal" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ae/67/0952ed97a9793b4958e5736f6d2b346b414a2cd63e82d05940032f45b32f/aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc", size = 19422 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/ac/a7305707cb852b7e16ff80eaf5692309bde30e2b1100a1fcacdc8f731d97/aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17", size = 7617 }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -33,7 +86,10 @@ dependencies = [ { name = "fastapi" }, { name = "httptools" }, { name = "httpx" }, + { name = "langchain" }, + { name = "langgraph" }, { name = "loguru" }, + { name = "openai" }, { name = "orjson" }, { name = "pwdlib", extra = ["argon2"] }, { name = "pydantic", extra = ["email"] }, @@ -60,8 +116,11 @@ requires-dist = [ { name = "camel-converter", extras = ["pydantic"], specifier = "==4.0.1" }, { name = "fastapi", specifier = "==0.115.5" }, { name = "httptools", specifier = "==0.6.4" }, - { name = "httpx", specifier = ">=0.27.2" }, + { name = "httpx", specifier = "==0.27.2" }, + { name = "langchain", specifier = "==0.3.7" }, + { name = "langgraph", specifier = "==0.2.48" }, { name = "loguru", specifier = "==0.7.2" }, + { name = "openai", specifier = "==1.54.4" }, { name = "orjson", specifier = "==3.10.11" }, { name = "pwdlib", extras = ["argon2"], specifier = "==0.2.1" }, { name = "pydantic", extras = ["email"], specifier = "==2.9.2" }, @@ -131,6 +190,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623 }, ] +[[package]] +name = "attrs" +version = "24.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/0f/aafca9af9315aee06a89ffde799a10a582fe8de76c563ee80bbcdc08b3fb/attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346", size = 792678 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/21/5b6702a7f963e95456c0de2d495f67bf5fd62840ac655dc451586d23d39a/attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2", size = 63001 }, +] + [[package]] name = "camel-converter" version = "4.0.1" @@ -185,6 +253,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 }, ] +[[package]] +name = "charset-normalizer" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/4f/e1808dc01273379acc506d18f1504eb2d299bd4131743b9fc54d7be4df1e/charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e", size = 106620 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/89/68a4c86f1a0002810a27f12e9a7b22feb198c59b2f05231349fbce5c06f4/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114", size = 194617 }, + { url = "https://files.pythonhosted.org/packages/4f/cd/8947fe425e2ab0aa57aceb7807af13a0e4162cd21eee42ef5b053447edf5/charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed", size = 125310 }, + { url = "https://files.pythonhosted.org/packages/5b/f0/b5263e8668a4ee9becc2b451ed909e9c27058337fda5b8c49588183c267a/charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250", size = 119126 }, + { url = "https://files.pythonhosted.org/packages/ff/6e/e445afe4f7fda27a533f3234b627b3e515a1b9429bc981c9a5e2aa5d97b6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920", size = 139342 }, + { url = "https://files.pythonhosted.org/packages/a1/b2/4af9993b532d93270538ad4926c8e37dc29f2111c36f9c629840c57cd9b3/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64", size = 149383 }, + { url = "https://files.pythonhosted.org/packages/fb/6f/4e78c3b97686b871db9be6f31d64e9264e889f8c9d7ab33c771f847f79b7/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23", size = 142214 }, + { url = "https://files.pythonhosted.org/packages/2b/c9/1c8fe3ce05d30c87eff498592c89015b19fade13df42850aafae09e94f35/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc", size = 144104 }, + { url = "https://files.pythonhosted.org/packages/ee/68/efad5dcb306bf37db7db338338e7bb8ebd8cf38ee5bbd5ceaaaa46f257e6/charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d", size = 146255 }, + { url = "https://files.pythonhosted.org/packages/0c/75/1ed813c3ffd200b1f3e71121c95da3f79e6d2a96120163443b3ad1057505/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88", size = 140251 }, + { url = "https://files.pythonhosted.org/packages/7d/0d/6f32255c1979653b448d3c709583557a4d24ff97ac4f3a5be156b2e6a210/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90", size = 148474 }, + { url = "https://files.pythonhosted.org/packages/ac/a0/c1b5298de4670d997101fef95b97ac440e8c8d8b4efa5a4d1ef44af82f0d/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b", size = 151849 }, + { url = "https://files.pythonhosted.org/packages/04/4f/b3961ba0c664989ba63e30595a3ed0875d6790ff26671e2aae2fdc28a399/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d", size = 149781 }, + { url = "https://files.pythonhosted.org/packages/d8/90/6af4cd042066a4adad58ae25648a12c09c879efa4849c705719ba1b23d8c/charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482", size = 144970 }, + { url = "https://files.pythonhosted.org/packages/cc/67/e5e7e0cbfefc4ca79025238b43cdf8a2037854195b37d6417f3d0895c4c2/charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67", size = 94973 }, + { url = "https://files.pythonhosted.org/packages/65/97/fc9bbc54ee13d33dc54a7fcf17b26368b18505500fc01e228c27b5222d80/charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b", size = 102308 }, + { url = "https://files.pythonhosted.org/packages/bf/9b/08c0432272d77b04803958a4598a51e2a4b51c06640af8b8f0f908c18bf2/charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079", size = 49446 }, +] + [[package]] name = "click" version = "8.1.7" @@ -243,6 +335,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, ] +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277 }, +] + [[package]] name = "dnspython" version = "2.7.0" @@ -288,6 +389,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, ] +[[package]] +name = "frozenlist" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8f/ed/0f4cec13a93c02c47ec32d81d11c0c1efbadf4a471e3f3ce7cad366cbbd3/frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817", size = 39930 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/3b/915f0bca8a7ea04483622e84a9bd90033bab54bdf485479556c74fd5eaf5/frozenlist-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953", size = 91538 }, + { url = "https://files.pythonhosted.org/packages/c7/d1/a7c98aad7e44afe5306a2b068434a5830f1470675f0e715abb86eb15f15b/frozenlist-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0", size = 52849 }, + { url = "https://files.pythonhosted.org/packages/3a/c8/76f23bf9ab15d5f760eb48701909645f686f9c64fbb8982674c241fbef14/frozenlist-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2", size = 50583 }, + { url = "https://files.pythonhosted.org/packages/1f/22/462a3dd093d11df623179d7754a3b3269de3b42de2808cddef50ee0f4f48/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f", size = 265636 }, + { url = "https://files.pythonhosted.org/packages/80/cf/e075e407fc2ae7328155a1cd7e22f932773c8073c1fc78016607d19cc3e5/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608", size = 270214 }, + { url = "https://files.pythonhosted.org/packages/a1/58/0642d061d5de779f39c50cbb00df49682832923f3d2ebfb0fedf02d05f7f/frozenlist-1.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b", size = 273905 }, + { url = "https://files.pythonhosted.org/packages/ab/66/3fe0f5f8f2add5b4ab7aa4e199f767fd3b55da26e3ca4ce2cc36698e50c4/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840", size = 250542 }, + { url = "https://files.pythonhosted.org/packages/f6/b8/260791bde9198c87a465224e0e2bb62c4e716f5d198fc3a1dacc4895dbd1/frozenlist-1.5.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439", size = 267026 }, + { url = "https://files.pythonhosted.org/packages/2e/a4/3d24f88c527f08f8d44ade24eaee83b2627793fa62fa07cbb7ff7a2f7d42/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de", size = 257690 }, + { url = "https://files.pythonhosted.org/packages/de/9a/d311d660420b2beeff3459b6626f2ab4fb236d07afbdac034a4371fe696e/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641", size = 253893 }, + { url = "https://files.pythonhosted.org/packages/c6/23/e491aadc25b56eabd0f18c53bb19f3cdc6de30b2129ee0bc39cd387cd560/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e", size = 267006 }, + { url = "https://files.pythonhosted.org/packages/08/c4/ab918ce636a35fb974d13d666dcbe03969592aeca6c3ab3835acff01f79c/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9", size = 276157 }, + { url = "https://files.pythonhosted.org/packages/c0/29/3b7a0bbbbe5a34833ba26f686aabfe982924adbdcafdc294a7a129c31688/frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03", size = 264642 }, + { url = "https://files.pythonhosted.org/packages/ab/42/0595b3dbffc2e82d7fe658c12d5a5bafcd7516c6bf2d1d1feb5387caa9c1/frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c", size = 44914 }, + { url = "https://files.pythonhosted.org/packages/17/c4/b7db1206a3fea44bf3b838ca61deb6f74424a8a5db1dd53ecb21da669be6/frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28", size = 51167 }, + { url = "https://files.pythonhosted.org/packages/c6/c8/a5be5b7550c10858fcf9b0ea054baccab474da77d37f1e828ce043a3a5d4/frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3", size = 11901 }, +] + [[package]] name = "h11" version = "0.14.0" @@ -341,6 +466,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/95/9377bcb415797e44274b51d46e3249eba641711cf3348050f76ee7b15ffc/httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0", size = 76395 }, ] +[[package]] +name = "httpx-sse" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/60/8f4281fa9bbf3c8034fd54c0e7412e66edbab6bc74c4996bd616f8d0406e/httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721", size = 12624 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f", size = 7819 }, +] + [[package]] name = "identify" version = "2.6.2" @@ -368,6 +502,155 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/a6/62565a6e1cf69e10f5727360368e451d4b7f58beeac6173dc9db836a5b46/iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374", size = 5892 }, ] +[[package]] +name = "jiter" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/e5/50ff23c9bba2722d2f0f55ba51e57f7cbab9a4be758e6b9b263ef51e6024/jiter-0.7.1.tar.gz", hash = "sha256:448cf4f74f7363c34cdef26214da527e8eeffd88ba06d0b80b485ad0667baf5d", size = 162334 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/cf/00a93a9968fc21b9ecfcabb130a8c822138594ac4a00b7bff9cbb38daa7f/jiter-0.7.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:097676a37778ba3c80cb53f34abd6943ceb0848263c21bf423ae98b090f6c6ba", size = 291039 }, + { url = "https://files.pythonhosted.org/packages/22/9a/0eb3eddffeca703f6adaaf117ba93ac3336fb323206259a86c2993cec9ad/jiter-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3298af506d4271257c0a8f48668b0f47048d69351675dd8500f22420d4eec378", size = 302468 }, + { url = "https://files.pythonhosted.org/packages/b1/95/b4da75e93752edfd6dd0df8f7723a6575e8a8bdce2e82f4458eb5564936a/jiter-0.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:12fd88cfe6067e2199964839c19bd2b422ca3fd792949b8f44bb8a4e7d21946a", size = 328401 }, + { url = "https://files.pythonhosted.org/packages/28/af/7fa53804a2e7e309ce66822c9484fd7d4f8ef452be3937aab8a93a82c54b/jiter-0.7.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dacca921efcd21939123c8ea8883a54b9fa7f6545c8019ffcf4f762985b6d0c8", size = 347237 }, + { url = "https://files.pythonhosted.org/packages/30/0c/0b89bd3dce7d330d8ee878b0a95899b73e30cb55d2b2c41998276350d4a0/jiter-0.7.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de3674a5fe1f6713a746d25ad9c32cd32fadc824e64b9d6159b3b34fd9134143", size = 373558 }, + { url = "https://files.pythonhosted.org/packages/24/96/c75633b99d57dd8b8457f88f51201805c93b314e369fba69829d726bc2a5/jiter-0.7.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65df9dbae6d67e0788a05b4bad5706ad40f6f911e0137eb416b9eead6ba6f044", size = 388251 }, + { url = "https://files.pythonhosted.org/packages/64/39/369e6ff198003f55acfcdb58169c774473082d3303cddcd24334af534c4e/jiter-0.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ba9a358d59a0a55cccaa4957e6ae10b1a25ffdabda863c0343c51817610501d", size = 325020 }, + { url = "https://files.pythonhosted.org/packages/80/26/0c386fa233a78997db5fa7b362e6f35a37d2656d09e521b0600f29933992/jiter-0.7.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:576eb0f0c6207e9ede2b11ec01d9c2182973986514f9c60bc3b3b5d5798c8f50", size = 365211 }, + { url = "https://files.pythonhosted.org/packages/21/4e/bfebe799924a39f181874b5e9041b792ee67768a8b160814e016a7c9a40d/jiter-0.7.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:e550e29cdf3577d2c970a18f3959e6b8646fd60ef1b0507e5947dc73703b5627", size = 514904 }, + { url = "https://files.pythonhosted.org/packages/a7/81/b3c72c6691acd29cf707df1a0b300e6726385b3c1ced8dc20424c4452699/jiter-0.7.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:81d968dbf3ce0db2e0e4dec6b0a0d5d94f846ee84caf779b07cab49f5325ae43", size = 497102 }, + { url = "https://files.pythonhosted.org/packages/1e/c3/766f9ec97df0441597878c7949da2b241a12a381c3affa7ca761734c8c74/jiter-0.7.1-cp313-none-win32.whl", hash = "sha256:f892e547e6e79a1506eb571a676cf2f480a4533675f834e9ae98de84f9b941ac", size = 198119 }, + { url = "https://files.pythonhosted.org/packages/76/01/cbc0136784a3ffefb5ca5326f8167780c5c3de0c81b6b81b773a973c571e/jiter-0.7.1-cp313-none-win_amd64.whl", hash = "sha256:0302f0940b1455b2a7fb0409b8d5b31183db70d2b07fd177906d83bf941385d1", size = 199236 }, +] + +[[package]] +name = "jsonpatch" +version = "1.33" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpointer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/78/18813351fe5d63acad16aec57f94ec2b70a09e53ca98145589e185423873/jsonpatch-1.33.tar.gz", hash = "sha256:9fcd4009c41e6d12348b4a0ff2563ba56a2923a7dfee731d004e212e1ee5030c", size = 21699 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898 }, +] + +[[package]] +name = "jsonpointer" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/0a/eebeb1fa92507ea94016a2a790b93c2ae41a7e18778f85471dc54475ed25/jsonpointer-3.0.0.tar.gz", hash = "sha256:2b2d729f2091522d61c3b31f82e11870f60b68f43fbc705cb76bf4b832af59ef", size = 9114 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595 }, +] + +[[package]] +name = "langchain" +version = "0.3.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "langchain-core" }, + { name = "langchain-text-splitters" }, + { name = "langsmith" }, + { name = "numpy" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sqlalchemy" }, + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/59/1ce98c59890fbdbf865510fc6821c9f4d2455ca065b11e7aaa8635239b92/langchain-0.3.7.tar.gz", hash = "sha256:2e4f83bf794ba38562f7ba0ede8171d7e28a583c0cec6f8595cfe72147d336b2", size = 416796 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/09/72630413a7ded27684e33392a0ff52ff1c8ea6749fee641319e75f82072b/langchain-0.3.7-py3-none-any.whl", hash = "sha256:cf4af1d5751dacdc278df3de1ff3cbbd8ca7eb55d39deadccdd7fb3d3ee02ac0", size = 1005562 }, +] + +[[package]] +name = "langchain-core" +version = "0.3.18" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonpatch" }, + { name = "langsmith" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tenacity" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/b4/e77c2126193c010479fca53a2690250c08cb6d663a1f3c1a4fb7061604e5/langchain_core-0.3.18.tar.gz", hash = "sha256:a14e9b9c0525b6fc9a7e4fe7f54a48b272d91ea855b1b081b364fabb966ae7af", size = 328350 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/bf/bce8f38fa59038e1d67fbc145eab86c00229e366956191a0a452ee24194e/langchain_core-0.3.18-py3-none-any.whl", hash = "sha256:c38bb198152082e76859402bfff08f785ac66bcfd44c04d132708e16ee5f999c", size = 409313 }, +] + +[[package]] +name = "langchain-text-splitters" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/63/0f7dae88d87e924d819e6a6375043499e3bc9931e306edd48b396abb4e42/langchain_text_splitters-0.3.2.tar.gz", hash = "sha256:81e6515d9901d6dd8e35fb31ccd4f30f76d44b771890c789dc835ef9f16204df", size = 20229 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/c6/5ba25c8bad647e92a92b3066177ab10d78efbd16c0b9919948cdcd18b027/langchain_text_splitters-0.3.2-py3-none-any.whl", hash = "sha256:0db28c53f41d1bc024cdb3b1646741f6d46d5371e90f31e7e7c9fbe75d01c726", size = 25564 }, +] + +[[package]] +name = "langgraph" +version = "0.2.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langgraph-checkpoint" }, + { name = "langgraph-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/f2/c43bdc18d754636fe8b948b1aba8eadcd1f16cfd8b1f2fb7462cf4dddb9f/langgraph-0.2.48.tar.gz", hash = "sha256:ab8e5d0c2d7ee68bc45054073637afa8519f42da8b4a09f1b0fb97bb096c74ac", size = 106253 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/81/16825faa2165c02490cbe581abc160f3291ba1bbf9f10e51caab8e2d5b6d/langgraph-0.2.48-py3-none-any.whl", hash = "sha256:919d0a2b5cbdedbf4d668da60c2467b81129190b7b6e6892ca1fe40caedd3678", size = 124822 }, +] + +[[package]] +name = "langgraph-checkpoint" +version = "2.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "msgpack" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/f0/611786da5e973264a0e24ed04eabf7e04fac62598fe3ca16e4a67bda41d6/langgraph_checkpoint-2.0.4.tar.gz", hash = "sha256:17a20857090f805629a062986da739f003030e90388292e01614adcfb323d502", size = 20557 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/78/56f96f1151697fc4c826f59845a09cdb0f5c20917d84f4cfbeb50e3e810b/langgraph_checkpoint-2.0.4-py3-none-any.whl", hash = "sha256:0039b937d5de951145acc196e7cf64e2e38cc9475c75040855cbf9edcc69ff89", size = 23454 }, +] + +[[package]] +name = "langgraph-sdk" +version = "0.1.36" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "orjson" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/df/9316a356f75027d5d70a6b694fa31ed62b1070d5ae9013e84c3c8829a9b6/langgraph_sdk-0.1.36.tar.gz", hash = "sha256:2a2c651b7851ba15aeaab7e4e3ea7fd8357ef1cb0b592f264916fa990cdda6e7", size = 28215 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/ad/d2fdb570373afe03bc7110561a4866e9d5175d8f7b2cda2a04f84265b1fd/langgraph_sdk-0.1.36-py3-none-any.whl", hash = "sha256:b11e1f0bc67631134d09d50c812dc73f9eb30394764ae1144d7d2a786a715355", size = 29071 }, +] + +[[package]] +name = "langsmith" +version = "0.1.143" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "orjson" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "requests-toolbelt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/a1/e47eb77a648b37cfdaa35b69c288974ddf5109990e6de63730e2e94a3f38/langsmith-0.1.143.tar.gz", hash = "sha256:4c5159e5cd84b3f8499433009e72d2076dd2daf6c044ac8a3611b30d0d0161c5", size = 295197 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/53/0a22394aa520176b1981e9b7f02090425731b575e9ae28f86a7f5341208c/langsmith-0.1.143-py3-none-any.whl", hash = "sha256:ba0d827269e9b03a90fababe41fa3e4e3f833300b95add10184f7e67167dde6f", size = 306964 }, +] + [[package]] name = "loguru" version = "0.7.2" @@ -381,6 +664,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/0a/4f6fed21aa246c6b49b561ca55facacc2a44b87d65b8b92362a8e99ba202/loguru-0.7.2-py3-none-any.whl", hash = "sha256:003d71e3d3ed35f0f8984898359d65b79e5b21943f78af86aa5491210429b8eb", size = 62549 }, ] +[[package]] +name = "msgpack" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/d0/7555686ae7ff5731205df1012ede15dd9d927f6227ea151e901c7406af4f/msgpack-1.1.0.tar.gz", hash = "sha256:dd432ccc2c72b914e4cb77afce64aab761c1137cc698be3984eee260bcb2896e", size = 167260 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/b0/380f5f639543a4ac413e969109978feb1f3c66e931068f91ab6ab0f8be00/msgpack-1.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:071603e2f0771c45ad9bc65719291c568d4edf120b44eb36324dcb02a13bfddf", size = 151142 }, + { url = "https://files.pythonhosted.org/packages/c8/ee/be57e9702400a6cb2606883d55b05784fada898dfc7fd12608ab1fdb054e/msgpack-1.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0f92a83b84e7c0749e3f12821949d79485971f087604178026085f60ce109330", size = 84523 }, + { url = "https://files.pythonhosted.org/packages/7e/3a/2919f63acca3c119565449681ad08a2f84b2171ddfcff1dba6959db2cceb/msgpack-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4a1964df7b81285d00a84da4e70cb1383f2e665e0f1f2a7027e683956d04b734", size = 81556 }, + { url = "https://files.pythonhosted.org/packages/7c/43/a11113d9e5c1498c145a8925768ea2d5fce7cbab15c99cda655aa09947ed/msgpack-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59caf6a4ed0d164055ccff8fe31eddc0ebc07cf7326a2aaa0dbf7a4001cd823e", size = 392105 }, + { url = "https://files.pythonhosted.org/packages/2d/7b/2c1d74ca6c94f70a1add74a8393a0138172207dc5de6fc6269483519d048/msgpack-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0907e1a7119b337971a689153665764adc34e89175f9a34793307d9def08e6ca", size = 399979 }, + { url = "https://files.pythonhosted.org/packages/82/8c/cf64ae518c7b8efc763ca1f1348a96f0e37150061e777a8ea5430b413a74/msgpack-1.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65553c9b6da8166e819a6aa90ad15288599b340f91d18f60b2061f402b9a4915", size = 383816 }, + { url = "https://files.pythonhosted.org/packages/69/86/a847ef7a0f5ef3fa94ae20f52a4cacf596a4e4a010197fbcc27744eb9a83/msgpack-1.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7a946a8992941fea80ed4beae6bff74ffd7ee129a90b4dd5cf9c476a30e9708d", size = 380973 }, + { url = "https://files.pythonhosted.org/packages/aa/90/c74cf6e1126faa93185d3b830ee97246ecc4fe12cf9d2d31318ee4246994/msgpack-1.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4b51405e36e075193bc051315dbf29168d6141ae2500ba8cd80a522964e31434", size = 387435 }, + { url = "https://files.pythonhosted.org/packages/7a/40/631c238f1f338eb09f4acb0f34ab5862c4e9d7eda11c1b685471a4c5ea37/msgpack-1.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4c01941fd2ff87c2a934ee6055bda4ed353a7846b8d4f341c428109e9fcde8c", size = 399082 }, + { url = "https://files.pythonhosted.org/packages/e9/1b/fa8a952be252a1555ed39f97c06778e3aeb9123aa4cccc0fd2acd0b4e315/msgpack-1.1.0-cp313-cp313-win32.whl", hash = "sha256:7c9a35ce2c2573bada929e0b7b3576de647b0defbd25f5139dcdaba0ae35a4cc", size = 69037 }, + { url = "https://files.pythonhosted.org/packages/b6/bc/8bd826dd03e022153bfa1766dcdec4976d6c818865ed54223d71f07862b3/msgpack-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:bce7d9e614a04d0883af0b3d4d501171fbfca038f12c77fa838d9f198147a23f", size = 75140 }, +] + +[[package]] +name = "multidict" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/be/504b89a5e9ca731cd47487e91c469064f8ae5af93b7259758dcfc2b9c848/multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a", size = 64002 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/67/1c7c0f39fe069aa4e5d794f323be24bf4d33d62d2a348acdb7991f8f30db/multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008", size = 48771 }, + { url = "https://files.pythonhosted.org/packages/3c/25/c186ee7b212bdf0df2519eacfb1981a017bda34392c67542c274651daf23/multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f", size = 29533 }, + { url = "https://files.pythonhosted.org/packages/67/5e/04575fd837e0958e324ca035b339cea174554f6f641d3fb2b4f2e7ff44a2/multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28", size = 29595 }, + { url = "https://files.pythonhosted.org/packages/d3/b2/e56388f86663810c07cfe4a3c3d87227f3811eeb2d08450b9e5d19d78876/multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b", size = 130094 }, + { url = "https://files.pythonhosted.org/packages/6c/ee/30ae9b4186a644d284543d55d491fbd4239b015d36b23fea43b4c94f7052/multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c", size = 134876 }, + { url = "https://files.pythonhosted.org/packages/84/c7/70461c13ba8ce3c779503c70ec9d0345ae84de04521c1f45a04d5f48943d/multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3", size = 133500 }, + { url = "https://files.pythonhosted.org/packages/4a/9f/002af221253f10f99959561123fae676148dd730e2daa2cd053846a58507/multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44", size = 131099 }, + { url = "https://files.pythonhosted.org/packages/82/42/d1c7a7301d52af79d88548a97e297f9d99c961ad76bbe6f67442bb77f097/multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2", size = 120403 }, + { url = "https://files.pythonhosted.org/packages/68/f3/471985c2c7ac707547553e8f37cff5158030d36bdec4414cb825fbaa5327/multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3", size = 125348 }, + { url = "https://files.pythonhosted.org/packages/67/2c/e6df05c77e0e433c214ec1d21ddd203d9a4770a1f2866a8ca40a545869a0/multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa", size = 119673 }, + { url = "https://files.pythonhosted.org/packages/c5/cd/bc8608fff06239c9fb333f9db7743a1b2eafe98c2666c9a196e867a3a0a4/multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa", size = 129927 }, + { url = "https://files.pythonhosted.org/packages/44/8e/281b69b7bc84fc963a44dc6e0bbcc7150e517b91df368a27834299a526ac/multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4", size = 128711 }, + { url = "https://files.pythonhosted.org/packages/12/a4/63e7cd38ed29dd9f1881d5119f272c898ca92536cdb53ffe0843197f6c85/multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6", size = 125519 }, + { url = "https://files.pythonhosted.org/packages/38/e0/4f5855037a72cd8a7a2f60a3952d9aa45feedb37ae7831642102604e8a37/multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81", size = 26426 }, + { url = "https://files.pythonhosted.org/packages/7e/a5/17ee3a4db1e310b7405f5d25834460073a8ccd86198ce044dfaf69eac073/multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774", size = 28531 }, + { url = "https://files.pythonhosted.org/packages/99/b7/b9e70fde2c0f0c9af4cc5277782a89b66d35948ea3369ec9f598358c3ac5/multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506", size = 10051 }, +] + [[package]] name = "mypy" version = "1.13.0" @@ -422,6 +748,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 }, ] +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129 } + +[[package]] +name = "openai" +version = "1.54.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/95/83845be5ddd46ce0a35fd602a3366ec2d7fd6b2be6fb760ca553e2488ea1/openai-1.54.4.tar.gz", hash = "sha256:50f3656e45401c54e973fa05dc29f3f0b0d19348d685b2f7ddb4d92bf7b1b6bf", size = 314159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d8/3e4cf8a5f544bef575d3502fedd81a15e317f591022de940647bdd0cc017/openai-1.54.4-py3-none-any.whl", hash = "sha256:0d95cef99346bf9b6d7fbf57faf61a673924c3e34fa8af84c9ffe04660673a7e", size = 389581 }, +] + [[package]] name = "orjson" version = "3.10.11" @@ -480,6 +831,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713 }, ] +[[package]] +name = "propcache" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/4d/5e5a60b78dbc1d464f8a7bbaeb30957257afdc8512cbb9dfd5659304f5cd/propcache-0.2.0.tar.gz", hash = "sha256:df81779732feb9d01e5d513fad0122efb3d53bbc75f61b2a4f29a020bc985e70", size = 40951 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a7/5f37b69197d4f558bfef5b4bceaff7c43cc9b51adf5bd75e9081d7ea80e4/propcache-0.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ecddc221a077a8132cf7c747d5352a15ed763b674c0448d811f408bf803d9ad7", size = 78120 }, + { url = "https://files.pythonhosted.org/packages/c8/cd/48ab2b30a6b353ecb95a244915f85756d74f815862eb2ecc7a518d565b48/propcache-0.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0e53cb83fdd61cbd67202735e6a6687a7b491c8742dfc39c9e01e80354956763", size = 45127 }, + { url = "https://files.pythonhosted.org/packages/a5/ba/0a1ef94a3412aab057bd996ed5f0ac7458be5bf469e85c70fa9ceb43290b/propcache-0.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92fe151145a990c22cbccf9ae15cae8ae9eddabfc949a219c9f667877e40853d", size = 44419 }, + { url = "https://files.pythonhosted.org/packages/b4/6c/ca70bee4f22fa99eacd04f4d2f1699be9d13538ccf22b3169a61c60a27fa/propcache-0.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d6a21ef516d36909931a2967621eecb256018aeb11fc48656e3257e73e2e247a", size = 229611 }, + { url = "https://files.pythonhosted.org/packages/19/70/47b872a263e8511ca33718d96a10c17d3c853aefadeb86dc26e8421184b9/propcache-0.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f88a4095e913f98988f5b338c1d4d5d07dbb0b6bad19892fd447484e483ba6b", size = 234005 }, + { url = "https://files.pythonhosted.org/packages/4f/be/3b0ab8c84a22e4a3224719099c1229ddfdd8a6a1558cf75cb55ee1e35c25/propcache-0.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a5b3bb545ead161be780ee85a2b54fdf7092815995661947812dde94a40f6fb", size = 237270 }, + { url = "https://files.pythonhosted.org/packages/04/d8/f071bb000d4b8f851d312c3c75701e586b3f643fe14a2e3409b1b9ab3936/propcache-0.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:67aeb72e0f482709991aa91345a831d0b707d16b0257e8ef88a2ad246a7280bf", size = 231877 }, + { url = "https://files.pythonhosted.org/packages/93/e7/57a035a1359e542bbb0a7df95aad6b9871ebee6dce2840cb157a415bd1f3/propcache-0.2.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c997f8c44ec9b9b0bcbf2d422cc00a1d9b9c681f56efa6ca149a941e5560da2", size = 217848 }, + { url = "https://files.pythonhosted.org/packages/f0/93/d1dea40f112ec183398fb6c42fde340edd7bab202411c4aa1a8289f461b6/propcache-0.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2a66df3d4992bc1d725b9aa803e8c5a66c010c65c741ad901e260ece77f58d2f", size = 216987 }, + { url = "https://files.pythonhosted.org/packages/62/4c/877340871251145d3522c2b5d25c16a1690ad655fbab7bb9ece6b117e39f/propcache-0.2.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3ebbcf2a07621f29638799828b8d8668c421bfb94c6cb04269130d8de4fb7136", size = 212451 }, + { url = "https://files.pythonhosted.org/packages/7c/bb/a91b72efeeb42906ef58ccf0cdb87947b54d7475fee3c93425d732f16a61/propcache-0.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1235c01ddaa80da8235741e80815ce381c5267f96cc49b1477fdcf8c047ef325", size = 212879 }, + { url = "https://files.pythonhosted.org/packages/9b/7f/ee7fea8faac57b3ec5d91ff47470c6c5d40d7f15d0b1fccac806348fa59e/propcache-0.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3947483a381259c06921612550867b37d22e1df6d6d7e8361264b6d037595f44", size = 222288 }, + { url = "https://files.pythonhosted.org/packages/ff/d7/acd67901c43d2e6b20a7a973d9d5fd543c6e277af29b1eb0e1f7bd7ca7d2/propcache-0.2.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d5bed7f9805cc29c780f3aee05de3262ee7ce1f47083cfe9f77471e9d6777e83", size = 228257 }, + { url = "https://files.pythonhosted.org/packages/8d/6f/6272ecc7a8daad1d0754cfc6c8846076a8cb13f810005c79b15ce0ef0cf2/propcache-0.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e4a91d44379f45f5e540971d41e4626dacd7f01004826a18cb048e7da7e96544", size = 221075 }, + { url = "https://files.pythonhosted.org/packages/7c/bd/c7a6a719a6b3dd8b3aeadb3675b5783983529e4a3185946aa444d3e078f6/propcache-0.2.0-cp313-cp313-win32.whl", hash = "sha256:f902804113e032e2cdf8c71015651c97af6418363bea8d78dc0911d56c335032", size = 39654 }, + { url = "https://files.pythonhosted.org/packages/88/e7/0eef39eff84fa3e001b44de0bd41c7c0e3432e7648ffd3d64955910f002d/propcache-0.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8f188cfcc64fb1266f4684206c9de0e80f54622c3f22a910cbd200478aeae61e", size = 43705 }, + { url = "https://files.pythonhosted.org/packages/3d/b6/e6d98278f2d49b22b4d033c9f792eda783b9ab2094b041f013fc69bcde87/propcache-0.2.0-py3-none-any.whl", hash = "sha256:2ccc28197af5313706511fab3a8b66dcd6da067a1331372c82ea1cb74285e036", size = 11603 }, +] + [[package]] name = "pwdlib" version = "0.2.1" @@ -642,6 +1018,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, ] +[[package]] +name = "requests" +version = "2.32.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, +] + +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481 }, +] + [[package]] name = "ruff" version = "0.7.3" @@ -676,6 +1079,26 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.36" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/65/9cbc9c4c3287bed2499e05033e207473504dc4df999ce49385fb1f8b058a/sqlalchemy-2.0.36.tar.gz", hash = "sha256:7f2767680b6d2398aea7082e45a774b2b0767b5c8d8ffb9c8b683088ea9b29c5", size = 9574485 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/5c/236398ae3678b3237726819b484f15f5c038a9549da01703a771f05a00d6/SQLAlchemy-2.0.36-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5cc79df7f4bc3d11e4b542596c03826063092611e481fcf1c9dfee3c94355ef", size = 2087651 }, + { url = "https://files.pythonhosted.org/packages/a8/14/55c47420c0d23fb67a35af8be4719199b81c59f3084c28d131a7767b0b0b/SQLAlchemy-2.0.36-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3c01117dd36800f2ecaa238c65365b7b16497adc1522bf84906e5710ee9ba0e8", size = 2078132 }, + { url = "https://files.pythonhosted.org/packages/3d/97/1e843b36abff8c4a7aa2e37f9bea364f90d021754c2de94d792c2d91405b/SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bc633f4ee4b4c46e7adcb3a9b5ec083bf1d9a97c1d3854b92749d935de40b9b", size = 3164559 }, + { url = "https://files.pythonhosted.org/packages/7b/c5/07f18a897b997f6d6b234fab2bf31dccf66d5d16a79fe329aefc95cd7461/SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e46ed38affdfc95d2c958de328d037d87801cfcbea6d421000859e9789e61c2", size = 3177897 }, + { url = "https://files.pythonhosted.org/packages/b3/cd/e16f3cbefd82b5c40b33732da634ec67a5f33b587744c7ab41699789d492/SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b2985c0b06e989c043f1dc09d4fe89e1616aadd35392aea2844f0458a989eacf", size = 3111289 }, + { url = "https://files.pythonhosted.org/packages/15/85/5b8a3b0bc29c9928aa62b5c91fcc8335f57c1de0a6343873b5f372e3672b/SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a121d62ebe7d26fec9155f83f8be5189ef1405f5973ea4874a26fab9f1e262c", size = 3139491 }, + { url = "https://files.pythonhosted.org/packages/a1/95/81babb6089938680dfe2cd3f88cd3fd39cccd1543b7cb603b21ad881bff1/SQLAlchemy-2.0.36-cp313-cp313-win32.whl", hash = "sha256:0572f4bd6f94752167adfd7c1bed84f4b240ee6203a95e05d1e208d488d0d436", size = 2060439 }, + { url = "https://files.pythonhosted.org/packages/c1/ce/5f7428df55660d6879d0522adc73a3364970b5ef33ec17fa125c5dbcac1d/SQLAlchemy-2.0.36-cp313-cp313-win_amd64.whl", hash = "sha256:8c78ac40bde930c60e0f78b3cd184c580f89456dd87fc08f9e3ee3ce8765ce88", size = 2084574 }, + { url = "https://files.pythonhosted.org/packages/b8/49/21633706dd6feb14cd3f7935fc00b60870ea057686035e1a99ae6d9d9d53/SQLAlchemy-2.0.36-py3-none-any.whl", hash = "sha256:fddbe92b4760c6f5d48162aef14824add991aeda8ddadb3c31d56eb15ca69f8e", size = 1883787 }, +] + [[package]] name = "starlette" version = "0.41.2" @@ -688,6 +1111,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/43/f185bfd0ca1d213beb4293bed51d92254df23d8ceaf6c0e17146d508a776/starlette-0.41.2-py3-none-any.whl", hash = "sha256:fbc189474b4731cf30fcef52f18a8d070e3f3b46c6a04c97579e85e6ffca942d", size = 73259 }, ] +[[package]] +name = "tenacity" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/94/91fccdb4b8110642462e653d5dcb27e7b674742ad68efd146367da7bdb10/tenacity-9.0.0.tar.gz", hash = "sha256:807f37ca97d62aa361264d497b0e31e92b8027044942bfa756160d908320d73b", size = 47421 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/cb/b86984bed139586d01532a587464b5805f12e397594f19f931c4c2fbfa61/tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539", size = 28169 }, +] + +[[package]] +name = "tqdm" +version = "4.67.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "platform_system == 'Windows'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e8/4f/0153c21dc5779a49a0598c445b1978126b1344bab9ee71e53e44877e14e0/tqdm-4.67.0.tar.gz", hash = "sha256:fe5a6f95e6fe0b9755e9469b77b9c3cf850048224ecaa8293d7d2d31f97d869a", size = 169739 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/78/57043611a16c655c8350b4c01b8d6abfb38cc2acb475238b62c2146186d7/tqdm-4.67.0-py3-none-any.whl", hash = "sha256:0cd8af9d56911acab92182e88d763100d4788bdf421d251616040cc4d44863be", size = 78590 }, +] + [[package]] name = "typing-extensions" version = "4.12.2" @@ -697,6 +1141,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 }, ] +[[package]] +name = "urllib3" +version = "2.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ed/63/22ba4ebfe7430b76388e7cd448d5478814d3032121827c12a2cc287e2260/urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9", size = 300677 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/d9/5f4c13cecde62396b0d3fe530a50ccea91e7dfc1ccf0e09c228841bb5ba8/urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac", size = 126338 }, +] + [[package]] name = "uvicorn" version = "0.32.0" @@ -746,3 +1199,33 @@ sdist = { url = "https://files.pythonhosted.org/packages/6b/dd/f95a13d2b235a28d6 wheels = [ { url = "https://files.pythonhosted.org/packages/0a/e6/a7d828fef907843b2a5773ebff47fb79ac0c1c88d60c0ca9530ee941e248/win32_setctime-1.1.0-py3-none-any.whl", hash = "sha256:231db239e959c2fe7eb1d7dc129f11172354f98361c4fa2d6d2d7e278baa8aad", size = 3604 }, ] + +[[package]] +name = "yarl" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/54/9c/9c0a9bfa683fc1be7fdcd9687635151544d992cccd48892dc5e0a5885a29/yarl-1.17.1.tar.gz", hash = "sha256:067a63fcfda82da6b198fa73079b1ca40b7c9b7994995b6ee38acda728b64d47", size = 178163 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/1e/5a93e3743c20eefbc68bd89334d9c9f04f3f2334380f7bbf5e950f29511b/yarl-1.17.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5d1d42556b063d579cae59e37a38c61f4402b47d70c29f0ef15cee1acaa64488", size = 139974 }, + { url = "https://files.pythonhosted.org/packages/a1/be/4e0f6919013c7c5eaea5c31811c551ccd599d2fc80aa3dd6962f1bbdcddd/yarl-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0167540094838ee9093ef6cc2c69d0074bbf84a432b4995835e8e5a0d984374", size = 93364 }, + { url = "https://files.pythonhosted.org/packages/73/f0/650f994bc491d0cb85df8bb45392780b90eab1e175f103a5edc61445ff67/yarl-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2f0a6423295a0d282d00e8701fe763eeefba8037e984ad5de44aa349002562ac", size = 91177 }, + { url = "https://files.pythonhosted.org/packages/f3/e8/9945ed555d14b43ede3ae8b1bd73e31068a694cad2b9d3cad0a28486c2eb/yarl-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5b078134f48552c4d9527db2f7da0b5359abd49393cdf9794017baec7506170", size = 333086 }, + { url = "https://files.pythonhosted.org/packages/a6/c0/7d167e48e14d26639ca066825af8da7df1d2fcdba827e3fd6341aaf22a3b/yarl-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d401f07261dc5aa36c2e4efc308548f6ae943bfff20fcadb0a07517a26b196d8", size = 343661 }, + { url = "https://files.pythonhosted.org/packages/fa/81/80a266517531d4e3553aecd141800dbf48d02e23ebd52909e63598a80134/yarl-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5f1ac7359e17efe0b6e5fec21de34145caef22b260e978336f325d5c84e6938", size = 345196 }, + { url = "https://files.pythonhosted.org/packages/b0/77/6adc482ba7f2dc6c0d9b3b492e7cd100edfac4cfc3849c7ffa26fd7beb1a/yarl-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f63d176a81555984e91f2c84c2a574a61cab7111cc907e176f0f01538e9ff6e", size = 338743 }, + { url = "https://files.pythonhosted.org/packages/6d/cc/f0c4c0b92ff3ada517ffde2b127406c001504b225692216d969879ada89a/yarl-1.17.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e275792097c9f7e80741c36de3b61917aebecc08a67ae62899b074566ff8556", size = 326719 }, + { url = "https://files.pythonhosted.org/packages/18/3b/7bfc80d3376b5fa162189993a87a5a6a58057f88315bd0ea00610055b57a/yarl-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81713b70bea5c1386dc2f32a8f0dab4148a2928c7495c808c541ee0aae614d67", size = 345826 }, + { url = "https://files.pythonhosted.org/packages/2e/66/cf0b0338107a5c370205c1a572432af08f36ca12ecce127f5b558398b4fd/yarl-1.17.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:aa46dce75078fceaf7cecac5817422febb4355fbdda440db55206e3bd288cfb8", size = 340335 }, + { url = "https://files.pythonhosted.org/packages/2f/52/b084b0eec0fd4d2490e1d33ace3320fad704c5f1f3deaa709f929d2d87fc/yarl-1.17.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1ce36ded585f45b1e9bb36d0ae94765c6608b43bd2e7f5f88079f7a85c61a4d3", size = 345301 }, + { url = "https://files.pythonhosted.org/packages/ef/38/9e2036d948efd3bafcdb4976cb212166fded76615f0dfc6c1492c4ce4784/yarl-1.17.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2d374d70fdc36f5863b84e54775452f68639bc862918602d028f89310a034ab0", size = 354205 }, + { url = "https://files.pythonhosted.org/packages/81/c1/13dfe1e70b86811733316221c696580725ceb1c46d4e4db852807e134310/yarl-1.17.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2d9f0606baaec5dd54cb99667fcf85183a7477f3766fbddbe3f385e7fc253299", size = 360501 }, + { url = "https://files.pythonhosted.org/packages/91/87/756e05c74cd8bf9e71537df4a2cae7e8211a9ebe0d2350a3e26949e1e41c/yarl-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b0341e6d9a0c0e3cdc65857ef518bb05b410dbd70d749a0d33ac0f39e81a4258", size = 359452 }, + { url = "https://files.pythonhosted.org/packages/06/b2/b2bb09c1e6d59e1c9b1b36a86caa473e22c3dbf26d1032c030e9bfb554dc/yarl-1.17.1-cp313-cp313-win32.whl", hash = "sha256:2e7ba4c9377e48fb7b20dedbd473cbcbc13e72e1826917c185157a137dac9df2", size = 308904 }, + { url = "https://files.pythonhosted.org/packages/f3/27/f084d9a5668853c1f3b246620269b14ee871ef3c3cc4f3a1dd53645b68ec/yarl-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:949681f68e0e3c25377462be4b658500e85ca24323d9619fdc41f68d46a1ffda", size = 314637 }, + { url = "https://files.pythonhosted.org/packages/52/ad/1fe7ff5f3e8869d4c5070f47b96bac2b4d15e67c100a8278d8e7876329fc/yarl-1.17.1-py3-none-any.whl", hash = "sha256:f1790a4b1e8e8e028c391175433b9c8122c39b46e1663228158e61e6f915bf06", size = 44352 }, +] From 1e4ae84ac7d8affcb5b08754fd984220b771cc2e Mon Sep 17 00:00:00 2001 From: Paul Sanders Date: Sat, 16 Nov 2024 18:42:45 -0500 Subject: [PATCH 3/6] Update lock file --- backend/uv.lock | 60 ++++++++++++++++++++++++------------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/backend/uv.lock b/backend/uv.lock index 1a01313..0f25164 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -300,30 +300,30 @@ wheels = [ [[package]] name = "coverage" -version = "7.6.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/52/12/3669b6382792783e92046730ad3327f53b2726f0603f4c311c4da4824222/coverage-7.6.4.tar.gz", hash = "sha256:29fc0f17b1d3fea332f8001d4558f8214af7f1d87a345f3a133c901d60347c73", size = 798716 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c2/4d/2dede4f7cb5a70fb0bb40a57627fddf1dbdc6b9c1db81f7c4dcdcb19e2f4/coverage-7.6.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:023bf8ee3ec6d35af9c1c6ccc1d18fa69afa1cb29eaac57cb064dbb262a517f9", size = 207039 }, - { url = "https://files.pythonhosted.org/packages/3f/f9/d86368ae8c79e28f1fb458ebc76ae9ff3e8bd8069adc24e8f2fed03c58b7/coverage-7.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0ac3d42cb51c4b12df9c5f0dd2f13a4f24f01943627120ec4d293c9181219ba", size = 207298 }, - { url = "https://files.pythonhosted.org/packages/64/c5/b4cc3c3f64622c58fbfd4d8b9a7a8ce9d355f172f91fcabbba1f026852f6/coverage-7.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8fe4984b431f8621ca53d9380901f62bfb54ff759a1348cd140490ada7b693c", size = 239813 }, - { url = "https://files.pythonhosted.org/packages/8a/86/14c42e60b70a79b26099e4d289ccdfefbc68624d096f4481163085aa614c/coverage-7.6.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5fbd612f8a091954a0c8dd4c0b571b973487277d26476f8480bfa4b2a65b5d06", size = 236959 }, - { url = "https://files.pythonhosted.org/packages/7f/f8/4436a643631a2fbab4b44d54f515028f6099bfb1cd95b13cfbf701e7f2f2/coverage-7.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dacbc52de979f2823a819571f2e3a350a7e36b8cb7484cdb1e289bceaf35305f", size = 238950 }, - { url = "https://files.pythonhosted.org/packages/49/50/1571810ddd01f99a0a8be464a4ac8b147f322cd1e8e296a1528984fc560b/coverage-7.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dab4d16dfef34b185032580e2f2f89253d302facba093d5fa9dbe04f569c4f4b", size = 238610 }, - { url = "https://files.pythonhosted.org/packages/f3/8c/6312d241fe7cbd1f0cade34a62fea6f333d1a261255d76b9a87074d8703c/coverage-7.6.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:862264b12ebb65ad8d863d51f17758b1684560b66ab02770d4f0baf2ff75da21", size = 236697 }, - { url = "https://files.pythonhosted.org/packages/ce/5f/fef33dfd05d87ee9030f614c857deb6df6556b8f6a1c51bbbb41e24ee5ac/coverage-7.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5beb1ee382ad32afe424097de57134175fea3faf847b9af002cc7895be4e2a5a", size = 238541 }, - { url = "https://files.pythonhosted.org/packages/a9/64/6a984b6e92e1ea1353b7ffa08e27f707a5e29b044622445859200f541e8c/coverage-7.6.4-cp313-cp313-win32.whl", hash = "sha256:bf20494da9653f6410213424f5f8ad0ed885e01f7e8e59811f572bdb20b8972e", size = 209707 }, - { url = "https://files.pythonhosted.org/packages/5c/60/ce5a9e942e9543783b3db5d942e0578b391c25cdd5e7f342d854ea83d6b7/coverage-7.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:182e6cd5c040cec0a1c8d415a87b67ed01193ed9ad458ee427741c7d8513d963", size = 210439 }, - { url = "https://files.pythonhosted.org/packages/78/53/6719677e92c308207e7f10561a1b16ab8b5c00e9328efc9af7cfd6fb703e/coverage-7.6.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a181e99301a0ae128493a24cfe5cfb5b488c4e0bf2f8702091473d033494d04f", size = 207784 }, - { url = "https://files.pythonhosted.org/packages/fa/dd/7054928930671fcb39ae6a83bb71d9ab5f0afb733172543ced4b09a115ca/coverage-7.6.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:df57bdbeffe694e7842092c5e2e0bc80fff7f43379d465f932ef36f027179806", size = 208058 }, - { url = "https://files.pythonhosted.org/packages/b5/7d/fd656ddc2b38301927b9eb3aae3fe827e7aa82e691923ed43721fd9423c9/coverage-7.6.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bcd1069e710600e8e4cf27f65c90c7843fa8edfb4520fb0ccb88894cad08b11", size = 250772 }, - { url = "https://files.pythonhosted.org/packages/90/d0/eb9a3cc2100b83064bb086f18aedde3afffd7de6ead28f69736c00b7f302/coverage-7.6.4-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:99b41d18e6b2a48ba949418db48159d7a2e81c5cc290fc934b7d2380515bd0e3", size = 246490 }, - { url = "https://files.pythonhosted.org/packages/45/44/3f64f38f6faab8a0cfd2c6bc6eb4c6daead246b97cf5f8fc23bf3788f841/coverage-7.6.4-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a6b1e54712ba3474f34b7ef7a41e65bd9037ad47916ccb1cc78769bae324c01a", size = 248848 }, - { url = "https://files.pythonhosted.org/packages/5d/11/4c465a5f98656821e499f4b4619929bd5a34639c466021740ecdca42aa30/coverage-7.6.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:53d202fd109416ce011578f321460795abfe10bb901b883cafd9b3ef851bacfc", size = 248340 }, - { url = "https://files.pythonhosted.org/packages/f1/96/ebecda2d016cce9da812f404f720ca5df83c6b29f65dc80d2000d0078741/coverage-7.6.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c48167910a8f644671de9f2083a23630fbf7a1cb70ce939440cd3328e0919f70", size = 246229 }, - { url = "https://files.pythonhosted.org/packages/16/d9/3d820c00066ae55d69e6d0eae11d6149a5ca7546de469ba9d597f01bf2d7/coverage-7.6.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cc8ff50b50ce532de2fa7a7daae9dd12f0a699bfcd47f20945364e5c31799fef", size = 247510 }, - { url = "https://files.pythonhosted.org/packages/8f/c3/4fa1eb412bb288ff6bfcc163c11700ff06e02c5fad8513817186e460ed43/coverage-7.6.4-cp313-cp313t-win32.whl", hash = "sha256:b8d3a03d9bfcaf5b0141d07a88456bb6a4c3ce55c080712fec8418ef3610230e", size = 210353 }, - { url = "https://files.pythonhosted.org/packages/7e/77/03fc2979d1538884d921c2013075917fc927f41cd8526909852fe4494112/coverage-7.6.4-cp313-cp313t-win_amd64.whl", hash = "sha256:f3ddf056d3ebcf6ce47bdaf56142af51bb7fad09e4af310241e9db7a3a8022e1", size = 211502 }, +version = "7.6.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/68/26895f8b068e384b1ec9ab122565b913b735e6b4c618b3d265a280607edc/coverage-7.6.7.tar.gz", hash = "sha256:d79d4826e41441c9a118ff045e4bccb9fdbdcb1d02413e7ea6eb5c87b5439d24", size = 799938 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/21/87/c590d0c7eeb884995d9d06b429c5e88e9fcd65d3a6a686d9476cb50b72a9/coverage-7.6.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46f21663e358beae6b368429ffadf14ed0a329996248a847a4322fb2e35d64d3", size = 207199 }, + { url = "https://files.pythonhosted.org/packages/40/ee/c88473c4f69c952f4425fabe045cb78d2027634ce50c9d7f7987d389b604/coverage-7.6.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40cca284c7c310d622a1677f105e8507441d1bb7c226f41978ba7c86979609ab", size = 207454 }, + { url = "https://files.pythonhosted.org/packages/b8/07/afda6e10c50e3a8c21020c5c1d1b4f3d7eff1c190305cef2962adf8de018/coverage-7.6.7-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77256ad2345c29fe59ae861aa11cfc74579c88d4e8dbf121cbe46b8e32aec808", size = 239971 }, + { url = "https://files.pythonhosted.org/packages/85/43/bd1934b75e31f2a49665be6a6b7f8bfaff7266ba19721bdb90239f5e9ed7/coverage-7.6.7-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:87ea64b9fa52bf395272e54020537990a28078478167ade6c61da7ac04dc14bc", size = 237119 }, + { url = "https://files.pythonhosted.org/packages/2b/19/7a70458c1624724086195b40628e91bc5b9ca180cdfefcc778285c49c7b2/coverage-7.6.7-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d608a7808793e3615e54e9267519351c3ae204a6d85764d8337bd95993581a8", size = 239109 }, + { url = "https://files.pythonhosted.org/packages/f3/2c/3dee671415ff13c05ca68243b2264fc95a5eea57697cffa7986b68b8f608/coverage-7.6.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdd94501d65adc5c24f8a1a0eda110452ba62b3f4aeaba01e021c1ed9cb8f34a", size = 238769 }, + { url = "https://files.pythonhosted.org/packages/37/ad/e0d1228638711aeacacc98d1197af6226b6d062d12c81a6bcc17d3234533/coverage-7.6.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:82c809a62e953867cf57e0548c2b8464207f5f3a6ff0e1e961683e79b89f2c55", size = 236854 }, + { url = "https://files.pythonhosted.org/packages/90/95/6467e9d9765a63c7f142703a7f212f6af114bd73a6c1cffeb7ad7f003a86/coverage-7.6.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bb684694e99d0b791a43e9fc0fa58efc15ec357ac48d25b619f207c41f2fd384", size = 238701 }, + { url = "https://files.pythonhosted.org/packages/b2/7a/fc11a163f0fd6ce8539d0f1b565873fe6903b900214ff71b5d80d16154c3/coverage-7.6.7-cp313-cp313-win32.whl", hash = "sha256:963e4a08cbb0af6623e61492c0ec4c0ec5c5cf74db5f6564f98248d27ee57d30", size = 209865 }, + { url = "https://files.pythonhosted.org/packages/f2/91/58be3a56efff0c3481e48e2caa56d5d6f3c5c8d385bf4adbecdfd85484b0/coverage-7.6.7-cp313-cp313-win_amd64.whl", hash = "sha256:14045b8bfd5909196a90da145a37f9d335a5d988a83db34e80f41e965fb7cb42", size = 210597 }, + { url = "https://files.pythonhosted.org/packages/34/7e/fed983809c2eccb09c5ddccfdb08efb7f2dd1ae3454dabf1c92c5a2e9946/coverage-7.6.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f2c7a045eef561e9544359a0bf5784b44e55cefc7261a20e730baa9220c83413", size = 207944 }, + { url = "https://files.pythonhosted.org/packages/c7/e0/2c1a157986a3927c3920e8e3938a3fdf33ea22b6f371dc3b679f13f619e2/coverage-7.6.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dd4e4a49d9c72a38d18d641135d2fb0bdf7b726ca60a103836b3d00a1182acd", size = 208215 }, + { url = "https://files.pythonhosted.org/packages/35/2f/77b086b228f6443ae5499467d1629c7428925b390cd171350c403bc00f14/coverage-7.6.7-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c95e0fa3d1547cb6f021ab72f5c23402da2358beec0a8e6d19a368bd7b0fb37", size = 250930 }, + { url = "https://files.pythonhosted.org/packages/60/d8/2ffea937d89ee328fc6e47c2515b890735bdf3f195d507d1c78b5fa96939/coverage-7.6.7-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f63e21ed474edd23f7501f89b53280014436e383a14b9bd77a648366c81dce7b", size = 246647 }, + { url = "https://files.pythonhosted.org/packages/b2/81/efbb3b00a7f7eb5f54a3b3b9f19b26d770a0b7d3870d651f07d2451c5504/coverage-7.6.7-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead9b9605c54d15be228687552916c89c9683c215370c4a44f1f217d2adcc34d", size = 249006 }, + { url = "https://files.pythonhosted.org/packages/eb/91/ce36990cbefaf7909e96c888ed4d83f3471fc1be3273a5beda10896cde0f/coverage-7.6.7-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0573f5cbf39114270842d01872952d301027d2d6e2d84013f30966313cadb529", size = 248500 }, + { url = "https://files.pythonhosted.org/packages/75/3f/b8c87dfdd96276870fb4abc7e2957cba7d20d8a435fcd816d807869ec833/coverage-7.6.7-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:e2c8e3384c12dfa19fa9a52f23eb091a8fad93b5b81a41b14c17c78e23dd1d8b", size = 246388 }, + { url = "https://files.pythonhosted.org/packages/a0/51/62273e1d5c25bb8fbef5fbbadc75b4a3e08c11b80516d0a97c25e5cced5b/coverage-7.6.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:70a56a2ec1869e6e9fa69ef6b76b1a8a7ef709972b9cc473f9ce9d26b5997ce3", size = 247669 }, + { url = "https://files.pythonhosted.org/packages/75/e5/d7772e56a7eace80e98ac39f2756d4b690fc0ce2384418174e02519a26a8/coverage-7.6.7-cp313-cp313t-win32.whl", hash = "sha256:dbba8210f5067398b2c4d96b4e64d8fb943644d5eb70be0d989067c8ca40c0f8", size = 210510 }, + { url = "https://files.pythonhosted.org/packages/2d/12/f2666e4e36b43221391ffcd971ab0c50e19439c521c2c87cd7e0b49ddba2/coverage-7.6.7-cp313-cp313t-win_amd64.whl", hash = "sha256:dfd14bcae0c94004baba5184d1c935ae0d1231b8409eb6c103a5fd75e8ecdc56", size = 211660 }, ] [[package]] @@ -424,15 +424,15 @@ wheels = [ [[package]] name = "httpcore" -version = "1.0.6" +version = "1.0.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, { name = "h11" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b6/44/ed0fa6a17845fb033bd885c03e842f08c1b9406c86a2e60ac1ae1b9206a6/httpcore-1.0.6.tar.gz", hash = "sha256:73f6dbd6eb8c21bbf7ef8efad555481853f5f6acdeaff1edb0694289269ee17f", size = 85180 } +sdist = { url = "https://files.pythonhosted.org/packages/6a/41/d7d0a89eb493922c37d343b607bc1b5da7f5be7e383740b4753ad8943e90/httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c", size = 85196 } wheels = [ - { url = "https://files.pythonhosted.org/packages/06/89/b161908e2f51be56568184aeb4a880fd287178d176fd1c860d2217f41106/httpcore-1.0.6-py3-none-any.whl", hash = "sha256:27b59625743b85577a8c0e10e55b50b5368a4f2cfe8cc7bcfa9cf00829c2682f", size = 78011 }, + { url = "https://files.pythonhosted.org/packages/87/f5/72347bc88306acb359581ac4d52f23c0ef445b57157adedb9aee0cd689d2/httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd", size = 78551 }, ] [[package]] @@ -566,7 +566,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "0.3.18" +version = "0.3.19" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonpatch" }, @@ -577,9 +577,9 @@ dependencies = [ { name = "tenacity" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/b4/e77c2126193c010479fca53a2690250c08cb6d663a1f3c1a4fb7061604e5/langchain_core-0.3.18.tar.gz", hash = "sha256:a14e9b9c0525b6fc9a7e4fe7f54a48b272d91ea855b1b081b364fabb966ae7af", size = 328350 } +sdist = { url = "https://files.pythonhosted.org/packages/48/d9/9a22466b62e86628058b5e2ef6b0ccaff5f026e364f93c47b8e912654414/langchain_core-0.3.19.tar.gz", hash = "sha256:126d9e8cadb2a5b8d1793a228c0783a3b608e36064d5a2ef1a4d38d07a344523", size = 328367 } wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/bf/bce8f38fa59038e1d67fbc145eab86c00229e366956191a0a452ee24194e/langchain_core-0.3.18-py3-none-any.whl", hash = "sha256:c38bb198152082e76859402bfff08f785ac66bcfd44c04d132708e16ee5f999c", size = 409313 }, + { url = "https://files.pythonhosted.org/packages/a1/25/5bd49cda589e98908e40591214c98cac52f7eb37230bbe493dbd883b9a89/langchain_core-0.3.19-py3-none-any.whl", hash = "sha256:562b7cc3c15dfaa9270cb1496990c1f3b3e0b660c4d6a3236d7f693346f2a96c", size = 409323 }, ] [[package]] From 033a7cb1bc31b81ea2e133e9f865a1e434dd0599 Mon Sep 17 00:00:00 2001 From: Paul Sanders Date: Sat, 16 Nov 2024 18:44:59 -0500 Subject: [PATCH 4/6] Fix test --- backend/tests/scan/test_pfc_agents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/tests/scan/test_pfc_agents.py b/backend/tests/scan/test_pfc_agents.py index 3829650..56d4b21 100644 --- a/backend/tests/scan/test_pfc_agents.py +++ b/backend/tests/scan/test_pfc_agents.py @@ -91,7 +91,7 @@ def test_get_goal(role, expected): @pytest.mark.parametrize( "role, expected", - (("DLPFC", "VMPFC"), ("VMPFC", "DLPFC"), ("OFC", "DLPFC"), ("ACC", "DLPFC"), ("MPFC", "DLPFC")), + (("DLPFC", "VMPFC"), ("VMPFC", "OFC"), ("OFC", "ACC"), ("ACC", "MPFC"), ("MPFC", None)), ) def test_determine_next_agent(role, expected): result = _determine_next_agent(role) From 95122eb3b31aa183f93d70fc8d04ab7d390548bf Mon Sep 17 00:00:00 2001 From: Paul Sanders Date: Sat, 16 Nov 2024 19:08:25 -0500 Subject: [PATCH 5/6] Fix docker build --- backend/Dockerfile | 1 + justfile | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index f0c3ce6..75e502e 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -9,6 +9,7 @@ ENV \ RUN : \ && apt-get update \ && apt-get install -y --no-install-recommends \ + build-essential \ curl \ ca-certificates \ && apt-get clean \ diff --git a/justfile b/justfile index 215af39..b8b798e 100644 --- a/justfile +++ b/justfile @@ -59,4 +59,4 @@ @docker-build-backend: cd backend && \ - docker build -t scanue-v . + docker build -t scanue-v-backend:test . From 0076da913b0ab256b7ec5e5fec7cb8a7a7cfeb37 Mon Sep 17 00:00:00 2001 From: Paul Sanders Date: Sat, 16 Nov 2024 19:32:50 -0500 Subject: [PATCH 6/6] Add test --- backend/app/scan/graph.py | 10 ++--- backend/tests/scan/test_graph.py | 69 ++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 backend/tests/scan/test_graph.py diff --git a/backend/app/scan/graph.py b/backend/app/scan/graph.py index 555d665..bda6bed 100644 --- a/backend/app/scan/graph.py +++ b/backend/app/scan/graph.py @@ -67,15 +67,15 @@ def prepare_output(self, state: AgentState) -> AnalysisReport: for role, analysis in state["history"]: if role == "DLPFC": - dlpfc_analysis = " ".join(analysis) if analysis else None + dlpfc_analysis = analysis if analysis else None elif role == "VMPFC": - vmpfc_analysis = " ".join(analysis) if analysis else None + vmpfc_analysis = analysis if analysis else None elif role == "OFC": - ofc_analysis = " ".join(analysis) if analysis else None + ofc_analysis = analysis if analysis else None elif role == "ACC": - acc_analysis = " ".join(analysis) if analysis else None + acc_analysis = analysis if analysis else None elif role == "MPFC": - mpfc_analysis = " ".join(analysis) if analysis else None + mpfc_analysis = analysis if analysis else None return AnalysisReport( dlpfc_analysis=dlpfc_analysis, diff --git a/backend/tests/scan/test_graph.py b/backend/tests/scan/test_graph.py new file mode 100644 index 0000000..7ac86e3 --- /dev/null +++ b/backend/tests/scan/test_graph.py @@ -0,0 +1,69 @@ +import pytest + +from app.models.scan import AnalysisReport +from app.scan.graph import CustomGraph +from app.scan.pfc_agents import AgentState + + +@pytest.mark.parametrize( + "state, expected", + ( + ( + AgentState( + current_role="DLPFC", + input="some input", + history=[ + ("DLPFC", "dlpfc"), + ("VMPFC", "vmpfc"), + ("OFC", "ofc"), + ("ACC", "acc"), + ("MPFC", "mpfc"), + ], + next="VMPFC", + ), + AnalysisReport( + dlpfc_analysis="dlpfc", + vmpfc_analysis="vmpfc", + ofc_analysis="ofc", + acc_analysis="acc", + mpfc_analysis="mpfc", + ), + ), + ( + AgentState( + current_role="DLPFC", + input="some input", + history=[ + ("DLPFC", "dlpfc"), + ], + next="VMPFC", + ), + AnalysisReport( + dlpfc_analysis="dlpfc", + vmpfc_analysis=None, + ofc_analysis=None, + acc_analysis=None, + mpfc_analysis=None, + ), + ), + ( + AgentState( + current_role="DLPFC", + input="some input", + history=[], + next="VMPFC", + ), + AnalysisReport( + dlpfc_analysis=None, + vmpfc_analysis=None, + ofc_analysis=None, + acc_analysis=None, + mpfc_analysis=None, + ), + ), + ), +) +def test_prepare_output(state, expected): + graph = CustomGraph("some topic") + + assert graph.prepare_output(state) == expected