From 14d963ccdaece620232ea5c336cb79162e80a218 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Tue, 6 Oct 2026 22:52:36 -0700 Subject: [PATCH 1/2] chore: normalize line endings to LF The tree mixed CRLF and LF commits, so a ruff format (line-ending = lf) on Windows rewrote whole files. .gitattributes pins LF on every OS. --- .gitattributes | 2 + .vscode/launch.json | 44 ++-- Dockerfile | 84 +++---- app/__init__.py | 2 +- app/config/__init__.py | 26 +- app/config/admin.py | 34 +-- app/config/db.py | 88 +++---- app/config/log.py | 42 ++-- app/core/db/__init__.py | 28 +-- app/core/db/base.py | 36 +-- app/core/db/models.py | 22 +- app/core/db/session.py | 158 ++++++------ app/core/log.py | 106 ++++---- app/core/metadata.py | 200 +++++++-------- app/core/openapi.py | 160 ++++++------ app/core/security/auth.py | 268 ++++++++++---------- app/core/security/model.py | 158 ++++++------ app/main.py | 218 ++++++++-------- app/module/api/exception_handler.py | 80 +++--- app/module/api/router.py | 110 ++++---- app/module/api/schema.py | 128 +++++----- app/module/manage/router.py | 304 +++++++++++------------ app/module/manage/schema.py | 56 ++--- app/module/market/router.py | 12 +- app/module/pool/router.py | 220 ++++++++-------- app/schemas/constants.py | 78 +++--- app/schemas/enums/__init__.py | 18 +- app/schemas/enums/error.py | 26 +- app/schemas/response/__init__.py | 8 +- app/schemas/response/default_response.py | 8 +- app/schemas/response/response.py | 152 ++++++------ 31 files changed, 1439 insertions(+), 1437 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..65a39bf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# LF everywhere, on every OS (matches ruff's line-ending = "lf"). +* text=auto eol=lf diff --git a/.vscode/launch.json b/.vscode/launch.json index 9cbda87..6aaef9d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,23 +1,23 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "App Debug", - "type": "debugpy", - "request": "launch", - "module": "uvicorn", - "args": [ - "app.main:app", - "--reload" - ], - "jinja": false, - "justMyCode": false, - "env": { - "WCS_ADMIN_TOKEN": "wcsadmin" - }, - } - ] +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "App Debug", + "type": "debugpy", + "request": "launch", + "module": "uvicorn", + "args": [ + "app.main:app", + "--reload" + ], + "jinja": false, + "justMyCode": false, + "env": { + "WCS_ADMIN_TOKEN": "wcsadmin" + }, + } + ] } \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 8c786d3..3ad67f3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,43 +1,43 @@ -FROM bufbuild/buf:1.66.0 AS buf_installer - -# Use a Python image with uv pre-installed -FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim - -# Install the project into `/app` -WORKDIR /app -COPY . /app - -# Copy the buf binary from the buf_installer stage and make it executable -COPY --from=buf_installer /usr/local/bin/buf /usr/local/bin/buf - -RUN chmod +x /usr/local/bin/buf -RUN buf generate --template buf.gen.yaml - -# Enable bytecode compilation -ENV UV_COMPILE_BYTECODE=1 - -# Copy from the cache instead of linking since it's a mounted volume -ENV UV_LINK_MODE=copy - -# Install the project's dependencies using the lockfile and settings -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=bind,source=uv.lock,target=uv.lock \ - --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ - uv sync --locked --no-install-project --no-dev - -RUN --mount=type=cache,target=/root/.cache/uv \ - uv sync --locked --no-dev - -# Place executables in the environment at the front of the path -ENV PATH="/app/.venv/bin:$PATH" - -RUN addgroup --system --gid 1000 wynnsource && adduser --system --uid 1000 --ingroup wynnsource wynnsource -RUN chown -R 1000:1000 /app - -ENV UV_NO_CACHE=1 - -USER 1000 - -# Run the FastAPI application -EXPOSE 8000 +FROM bufbuild/buf:1.66.0 AS buf_installer + +# Use a Python image with uv pre-installed +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim + +# Install the project into `/app` +WORKDIR /app +COPY . /app + +# Copy the buf binary from the buf_installer stage and make it executable +COPY --from=buf_installer /usr/local/bin/buf /usr/local/bin/buf + +RUN chmod +x /usr/local/bin/buf +RUN buf generate --template buf.gen.yaml + +# Enable bytecode compilation +ENV UV_COMPILE_BYTECODE=1 + +# Copy from the cache instead of linking since it's a mounted volume +ENV UV_LINK_MODE=copy + +# Install the project's dependencies using the lockfile and settings +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --locked --no-install-project --no-dev + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --locked --no-dev + +# Place executables in the environment at the front of the path +ENV PATH="/app/.venv/bin:$PATH" + +RUN addgroup --system --gid 1000 wynnsource && adduser --system --uid 1000 --ingroup wynnsource wynnsource +RUN chown -R 1000:1000 /app + +ENV UV_NO_CACHE=1 + +USER 1000 + +# Run the FastAPI application +EXPOSE 8000 CMD fastapi run --forwarded-allow-ips="127.0.0.1,10.0.0.0/8,[::1]" \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 6cbe860..1a08626 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1 +1 @@ -# blank __init__.py file to mark the directory as a package for discoverability +# blank __init__.py file to mark the directory as a package for discoverability diff --git a/app/config/__init__.py b/app/config/__init__.py index 8b931ce..56d895f 100644 --- a/app/config/__init__.py +++ b/app/config/__init__.py @@ -1,13 +1,13 @@ -from .admin import ADMIN_CONFIG as ADMIN_CONFIG -from .db import DB_CONFIG as DB_CONFIG -from .log import LOG_CONFIG as LOG_CONFIG -from .sentry import SENTRY_CONFIG as SENTRY_CONFIG -from .user import USER_CONFIG as USER_CONFIG - -__all__ = [ - "ADMIN_CONFIG", - "DB_CONFIG", - "LOG_CONFIG", - "SENTRY_CONFIG", - "USER_CONFIG", -] +from .admin import ADMIN_CONFIG as ADMIN_CONFIG +from .db import DB_CONFIG as DB_CONFIG +from .log import LOG_CONFIG as LOG_CONFIG +from .sentry import SENTRY_CONFIG as SENTRY_CONFIG +from .user import USER_CONFIG as USER_CONFIG + +__all__ = [ + "ADMIN_CONFIG", + "DB_CONFIG", + "LOG_CONFIG", + "SENTRY_CONFIG", + "USER_CONFIG", +] diff --git a/app/config/admin.py b/app/config/admin.py index c17bd69..bed1c4c 100644 --- a/app/config/admin.py +++ b/app/config/admin.py @@ -1,17 +1,17 @@ -from pydantic import Field -from pydantic_settings import BaseSettings - - -class AdminConfig(BaseSettings): - """ - Configuration for the admin interface. - """ - - token: str | None = Field(alias="WCS_ADMIN_TOKEN", default=None) - - -ADMIN_CONFIG = AdminConfig() # Debugging line to check the environment variable - -__all__ = [ - "ADMIN_CONFIG", -] +from pydantic import Field +from pydantic_settings import BaseSettings + + +class AdminConfig(BaseSettings): + """ + Configuration for the admin interface. + """ + + token: str | None = Field(alias="WCS_ADMIN_TOKEN", default=None) + + +ADMIN_CONFIG = AdminConfig() # Debugging line to check the environment variable + +__all__ = [ + "ADMIN_CONFIG", +] diff --git a/app/config/db.py b/app/config/db.py index 137654b..9d2da81 100644 --- a/app/config/db.py +++ b/app/config/db.py @@ -1,44 +1,44 @@ -from typing import Annotated - -from pydantic import Field, PostgresDsn, RedisDsn, computed_field -from pydantic_settings import BaseSettings - - -class DbConfig(BaseSettings): - postgres_host: Annotated[str, Field(alias="POSTGRES_HOST")] = "localhost" - postgres_port: Annotated[int, Field(alias="POSTGRES_PORT")] = 5432 - postgres_user: Annotated[str, Field(alias="POSTGRES_USER")] = "postgres" - postgres_password: Annotated[str, Field(alias="POSTGRES_PASSWORD")] = "postgres" - postgres_db: Annotated[str, Field(alias="POSTGRES_DB")] = "wcs_db" - - redis_host: Annotated[str, Field(alias="REDIS_HOST")] = "localhost" - redis_port: Annotated[int, Field(alias="REDIS_PORT")] = 6379 - - @computed_field - @property - def postgres_dsn(self) -> PostgresDsn: - return PostgresDsn.build( - scheme="postgresql+asyncpg", - username=self.postgres_user, - password=self.postgres_password, - host=self.postgres_host, - port=self.postgres_port, - path=self.postgres_db, - ) - - @computed_field - @property - def redis_dsn(self) -> RedisDsn: - return RedisDsn.build( - scheme="redis", - host=self.redis_host, - port=self.redis_port, - path="0", - ) - - -DB_CONFIG = DbConfig() - -__all__ = [ - "DB_CONFIG", -] +from typing import Annotated + +from pydantic import Field, PostgresDsn, RedisDsn, computed_field +from pydantic_settings import BaseSettings + + +class DbConfig(BaseSettings): + postgres_host: Annotated[str, Field(alias="POSTGRES_HOST")] = "localhost" + postgres_port: Annotated[int, Field(alias="POSTGRES_PORT")] = 5432 + postgres_user: Annotated[str, Field(alias="POSTGRES_USER")] = "postgres" + postgres_password: Annotated[str, Field(alias="POSTGRES_PASSWORD")] = "postgres" + postgres_db: Annotated[str, Field(alias="POSTGRES_DB")] = "wcs_db" + + redis_host: Annotated[str, Field(alias="REDIS_HOST")] = "localhost" + redis_port: Annotated[int, Field(alias="REDIS_PORT")] = 6379 + + @computed_field + @property + def postgres_dsn(self) -> PostgresDsn: + return PostgresDsn.build( + scheme="postgresql+asyncpg", + username=self.postgres_user, + password=self.postgres_password, + host=self.postgres_host, + port=self.postgres_port, + path=self.postgres_db, + ) + + @computed_field + @property + def redis_dsn(self) -> RedisDsn: + return RedisDsn.build( + scheme="redis", + host=self.redis_host, + port=self.redis_port, + path="0", + ) + + +DB_CONFIG = DbConfig() + +__all__ = [ + "DB_CONFIG", +] diff --git a/app/config/log.py b/app/config/log.py index a8a4cb2..7605f7d 100644 --- a/app/config/log.py +++ b/app/config/log.py @@ -1,21 +1,21 @@ -from pydantic_settings import BaseSettings - - -class LoggerConfig(BaseSettings): - """ - Logger configuration. - """ - - level: str = "DEBUG" - format: str = ( - "{time:YY-MM-DD HH:mm:ss}" - + "[{level: <8}]" - + "{name}:{line}" - + " | {message}" - ) - - -LOG_CONFIG = LoggerConfig() -__all__ = [ - "LOG_CONFIG", -] +from pydantic_settings import BaseSettings + + +class LoggerConfig(BaseSettings): + """ + Logger configuration. + """ + + level: str = "DEBUG" + format: str = ( + "{time:YY-MM-DD HH:mm:ss}" + + "[{level: <8}]" + + "{name}:{line}" + + " | {message}" + ) + + +LOG_CONFIG = LoggerConfig() +__all__ = [ + "LOG_CONFIG", +] diff --git a/app/core/db/__init__.py b/app/core/db/__init__.py index b773634..250ee9e 100644 --- a/app/core/db/__init__.py +++ b/app/core/db/__init__.py @@ -1,14 +1,14 @@ -from .base import Base, BaseRepository -from .redis import RedisClient -from .session import SessionDep, close_db, get_engine, get_session, init_db - -__all__ = [ - "Base", - "BaseRepository", - "RedisClient", - "SessionDep", - "close_db", - "get_engine", - "get_session", - "init_db", -] +from .base import Base, BaseRepository +from .redis import RedisClient +from .session import SessionDep, close_db, get_engine, get_session, init_db + +__all__ = [ + "Base", + "BaseRepository", + "RedisClient", + "SessionDep", + "close_db", + "get_engine", + "get_session", + "init_db", +] diff --git a/app/core/db/base.py b/app/core/db/base.py index 59285b1..7528cd6 100644 --- a/app/core/db/base.py +++ b/app/core/db/base.py @@ -1,18 +1,18 @@ -from typing import dataclass_transform - -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import DeclarativeBase - - -@dataclass_transform() -class Base(DeclarativeBase): - """ - Base class for all SQLAlchemy models. - """ - - -class BaseRepository: - session: AsyncSession - - def __init__(self, session: AsyncSession): - self.session = session +from typing import dataclass_transform + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import DeclarativeBase + + +@dataclass_transform() +class Base(DeclarativeBase): + """ + Base class for all SQLAlchemy models. + """ + + +class BaseRepository: + session: AsyncSession + + def __init__(self, session: AsyncSession): + self.session = session diff --git a/app/core/db/models.py b/app/core/db/models.py index 2f1dafd..52bd5ca 100644 --- a/app/core/db/models.py +++ b/app/core/db/models.py @@ -1,11 +1,11 @@ -""" -This is a file for alembic to load all models for autogeneration. -It should not be imported anywhere else. -""" - -import app.core.security.model -import app.module.beta.model -import app.module.pool.model -import app.module.raid.model # noqa: F401 - -__all__ = [] +""" +This is a file for alembic to load all models for autogeneration. +It should not be imported anywhere else. +""" + +import app.core.security.model +import app.module.beta.model +import app.module.pool.model +import app.module.raid.model # noqa: F401 + +__all__ = [] diff --git a/app/core/db/session.py b/app/core/db/session.py index 24b3d59..e696340 100644 --- a/app/core/db/session.py +++ b/app/core/db/session.py @@ -1,79 +1,79 @@ -from collections.abc import AsyncGenerator -from contextlib import asynccontextmanager -from typing import Annotated - -from fastapi import Depends -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine - -from app.config import DB_CONFIG -from app.core.log import LOGGER - -engine: AsyncEngine | None = None -session_maker: async_sessionmaker[AsyncSession] - - -def get_dsn() -> str: - """ - Returns the database connection string based on the configuration. - """ - if DB_CONFIG.postgres_dsn is None: - LOGGER.warning("PostgreSQL DSN not provided, exiting application.") - raise RuntimeError("PostgreSQL DSN is required but not provided.") - return DB_CONFIG.postgres_dsn.encoded_string() - - -async def init_db() -> AsyncEngine: - global engine, session_maker - dsn = get_dsn() - engine = create_async_engine(dsn, echo=False) - session_maker = async_sessionmaker(engine, expire_on_commit=False) - LOGGER.debug(f"Database engine initialized with DSN: {dsn}") - return engine - - -def get_engine() -> AsyncEngine: - """ - Returns the SQLAlchemy engine. Initializes it if not already done. - """ - global engine - if engine is None: - LOGGER.error("Database engine is not initialized.") - raise RuntimeError("Database engine is not initialized.") - return engine - - -async def close_db(): - """ - Closes the database engine connection. - """ - global engine - if engine is not None: - await engine.dispose() - LOGGER.debug("Database engine connection closed.") - - -@asynccontextmanager -async def get_session(): - async with session_maker() as session: - try: - LOGGER.debug("Database session started.") - yield session - await session.commit() - LOGGER.debug("Database session committed.") - except Exception: - LOGGER.exception("Error occurred during database session, rolling back.") - await session.rollback() - raise - - -async def get_session_fastapi() -> AsyncGenerator[AsyncSession, None]: - """ - Returns the SQLAlchemy session maker. - """ - async with get_session() as session: - yield session - - -SessionDep = Annotated[AsyncSession, Depends(get_session_fastapi)] - -__all__ = ["SessionDep", "close_db", "get_engine", "get_session", "init_db"] +from collections.abc import AsyncGenerator +from contextlib import asynccontextmanager +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine + +from app.config import DB_CONFIG +from app.core.log import LOGGER + +engine: AsyncEngine | None = None +session_maker: async_sessionmaker[AsyncSession] + + +def get_dsn() -> str: + """ + Returns the database connection string based on the configuration. + """ + if DB_CONFIG.postgres_dsn is None: + LOGGER.warning("PostgreSQL DSN not provided, exiting application.") + raise RuntimeError("PostgreSQL DSN is required but not provided.") + return DB_CONFIG.postgres_dsn.encoded_string() + + +async def init_db() -> AsyncEngine: + global engine, session_maker + dsn = get_dsn() + engine = create_async_engine(dsn, echo=False) + session_maker = async_sessionmaker(engine, expire_on_commit=False) + LOGGER.debug(f"Database engine initialized with DSN: {dsn}") + return engine + + +def get_engine() -> AsyncEngine: + """ + Returns the SQLAlchemy engine. Initializes it if not already done. + """ + global engine + if engine is None: + LOGGER.error("Database engine is not initialized.") + raise RuntimeError("Database engine is not initialized.") + return engine + + +async def close_db(): + """ + Closes the database engine connection. + """ + global engine + if engine is not None: + await engine.dispose() + LOGGER.debug("Database engine connection closed.") + + +@asynccontextmanager +async def get_session(): + async with session_maker() as session: + try: + LOGGER.debug("Database session started.") + yield session + await session.commit() + LOGGER.debug("Database session committed.") + except Exception: + LOGGER.exception("Error occurred during database session, rolling back.") + await session.rollback() + raise + + +async def get_session_fastapi() -> AsyncGenerator[AsyncSession, None]: + """ + Returns the SQLAlchemy session maker. + """ + async with get_session() as session: + yield session + + +SessionDep = Annotated[AsyncSession, Depends(get_session_fastapi)] + +__all__ = ["SessionDep", "close_db", "get_engine", "get_session", "init_db"] diff --git a/app/core/log.py b/app/core/log.py index 0b10a2c..8dc41ce 100644 --- a/app/core/log.py +++ b/app/core/log.py @@ -1,53 +1,53 @@ -import inspect -import logging -import sys - -import loguru - -from app.config import LOG_CONFIG - -LOGGER = loguru.logger - -LOGGER.remove() -logger_id = LOGGER.add( - sys.stdout, - level=LOG_CONFIG.level, - diagnose=False, - format=LOG_CONFIG.format, -) - - -class LoguruHandler(logging.Handler): - """Redirects logging messages to Loguru's logger.""" - - def emit(self, record: logging.LogRecord): - try: - level = LOGGER.level(record.levelname).name - except ValueError: - level = record.levelno - - frame, depth = inspect.currentframe(), 0 - while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__): - frame = frame.f_back - depth += 1 - - LOGGER.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) - - -def setup_logging(): - logging_root = logging.getLogger() - logging_root.setLevel(0) - logging_root.handlers = [] - - logging_root.addHandler(LoguruHandler()) - - # Remove handlers from all existing loggers to prevent duplicate logs - for name in logging.root.manager.loggerDict.keys(): - logging.getLogger(name).handlers = [] - logging.getLogger(name).propagate = True - - -setup_logging() - - -__all__ = ["LOGGER"] +import inspect +import logging +import sys + +import loguru + +from app.config import LOG_CONFIG + +LOGGER = loguru.logger + +LOGGER.remove() +logger_id = LOGGER.add( + sys.stdout, + level=LOG_CONFIG.level, + diagnose=False, + format=LOG_CONFIG.format, +) + + +class LoguruHandler(logging.Handler): + """Redirects logging messages to Loguru's logger.""" + + def emit(self, record: logging.LogRecord): + try: + level = LOGGER.level(record.levelname).name + except ValueError: + level = record.levelno + + frame, depth = inspect.currentframe(), 0 + while frame and (depth == 0 or frame.f_code.co_filename == logging.__file__): + frame = frame.f_back + depth += 1 + + LOGGER.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + + +def setup_logging(): + logging_root = logging.getLogger() + logging_root.setLevel(0) + logging_root.handlers = [] + + logging_root.addHandler(LoguruHandler()) + + # Remove handlers from all existing loggers to prevent duplicate logs + for name in logging.root.manager.loggerDict.keys(): + logging.getLogger(name).handlers = [] + logging.getLogger(name).propagate = True + + +setup_logging() + + +__all__ = ["LOGGER"] diff --git a/app/core/metadata.py b/app/core/metadata.py index e353bb9..028da15 100644 --- a/app/core/metadata.py +++ b/app/core/metadata.py @@ -1,100 +1,100 @@ -import dataclasses -from collections.abc import Awaitable, Callable - -from app.core.rate_limiter import RateLimitKeyFunc, ip_based_key_func - - -@dataclasses.dataclass -class CacheConfig: - expire: int = 60 - - -@dataclasses.dataclass -class RateLimitMetadata: - limit: int - period: int - key_func: RateLimitKeyFunc - - -@dataclasses.dataclass -class EndpointMetadata: - """ - Metadata for an API endpoint. - """ - - # Indicates whether the metadata has been processed by the router. - processed: bool = False - - permission: str | set[str] | None = None - cache: CacheConfig | None = None - rate_limit: RateLimitMetadata | None = None - - -def cached(expire: int = 60): - """ - Decorator to add cache metadata to an API endpoint. - """ - - def decorator(func: Callable[..., Awaitable]): - meta = getattr(func, "__metadata__", None) - if meta is not None: - if not isinstance(meta, EndpointMetadata): - raise ValueError( - f"Function {func.__name__} already has metadata but is not an EndpointMetadata instance" - ) - meta = dataclasses.replace(meta, cache=CacheConfig(expire=expire)) - else: - meta = EndpointMetadata(cache=CacheConfig(expire=expire)) - setattr(func, "__metadata__", meta) - return func - - return decorator - - -def permission(permission: str | set[str] | None = None): - """ - Decorator to add metadata to an API endpoint. - """ - - def decorator(func): - meta = getattr(func, "__metadata__", None) - if meta is not None: - if not isinstance(meta, EndpointMetadata): - raise ValueError( - f"Function {func.__name__} already has metadata but is not an EndpointMetadata instance" - ) - meta = dataclasses.replace(meta, permission=permission) - else: - meta = EndpointMetadata(permission=permission) - setattr(func, "__metadata__", meta) - return func - - return decorator - - -def rate_limit(limit: int, period: int, key_func: RateLimitKeyFunc = ip_based_key_func): - """ - Decorator to add rate limit metadata to an API endpoint. - """ - - def decorator(func): - meta = getattr(func, "__metadata__", None) - if meta is not None: - if not isinstance(meta, EndpointMetadata): - raise ValueError( - f"Function {func.__name__} already has metadata but is not an EndpointMetadata instance" - ) - meta = dataclasses.replace( - meta, rate_limit=RateLimitMetadata(limit=limit, period=period, key_func=key_func) - ) - else: - meta = EndpointMetadata(rate_limit=RateLimitMetadata(limit=limit, period=period, key_func=key_func)) - setattr(func, "__metadata__", meta) - return func - - return decorator - - -# metadata = MetadataBuilder() - -__all__ = ["EndpointMetadata", "cached", "permission", "rate_limit"] +import dataclasses +from collections.abc import Awaitable, Callable + +from app.core.rate_limiter import RateLimitKeyFunc, ip_based_key_func + + +@dataclasses.dataclass +class CacheConfig: + expire: int = 60 + + +@dataclasses.dataclass +class RateLimitMetadata: + limit: int + period: int + key_func: RateLimitKeyFunc + + +@dataclasses.dataclass +class EndpointMetadata: + """ + Metadata for an API endpoint. + """ + + # Indicates whether the metadata has been processed by the router. + processed: bool = False + + permission: str | set[str] | None = None + cache: CacheConfig | None = None + rate_limit: RateLimitMetadata | None = None + + +def cached(expire: int = 60): + """ + Decorator to add cache metadata to an API endpoint. + """ + + def decorator(func: Callable[..., Awaitable]): + meta = getattr(func, "__metadata__", None) + if meta is not None: + if not isinstance(meta, EndpointMetadata): + raise ValueError( + f"Function {func.__name__} already has metadata but is not an EndpointMetadata instance" + ) + meta = dataclasses.replace(meta, cache=CacheConfig(expire=expire)) + else: + meta = EndpointMetadata(cache=CacheConfig(expire=expire)) + setattr(func, "__metadata__", meta) + return func + + return decorator + + +def permission(permission: str | set[str] | None = None): + """ + Decorator to add metadata to an API endpoint. + """ + + def decorator(func): + meta = getattr(func, "__metadata__", None) + if meta is not None: + if not isinstance(meta, EndpointMetadata): + raise ValueError( + f"Function {func.__name__} already has metadata but is not an EndpointMetadata instance" + ) + meta = dataclasses.replace(meta, permission=permission) + else: + meta = EndpointMetadata(permission=permission) + setattr(func, "__metadata__", meta) + return func + + return decorator + + +def rate_limit(limit: int, period: int, key_func: RateLimitKeyFunc = ip_based_key_func): + """ + Decorator to add rate limit metadata to an API endpoint. + """ + + def decorator(func): + meta = getattr(func, "__metadata__", None) + if meta is not None: + if not isinstance(meta, EndpointMetadata): + raise ValueError( + f"Function {func.__name__} already has metadata but is not an EndpointMetadata instance" + ) + meta = dataclasses.replace( + meta, rate_limit=RateLimitMetadata(limit=limit, period=period, key_func=key_func) + ) + else: + meta = EndpointMetadata(rate_limit=RateLimitMetadata(limit=limit, period=period, key_func=key_func)) + setattr(func, "__metadata__", meta) + return func + + return decorator + + +# metadata = MetadataBuilder() + +__all__ = ["EndpointMetadata", "cached", "permission", "rate_limit"] diff --git a/app/core/openapi.py b/app/core/openapi.py index 6cd08d9..daa757c 100644 --- a/app/core/openapi.py +++ b/app/core/openapi.py @@ -1,80 +1,80 @@ -from fastapi import FastAPI -from fastapi.openapi.utils import get_openapi - -from app.schemas.constants import __DESCRIPTION__, __NAME__, __VERSION__ -from app.schemas.enums import ApiTag - - -def custom_openapi(app: FastAPI): - def openapi(): - nonlocal app - - if app.openapi_schema: - return app.openapi_schema - - openapi_schema = get_openapi( - title=__NAME__, - version=__VERSION__, - summary=f"{__NAME__} API Documentation", - description=__DESCRIPTION__, - routes=app.routes, - ) - - openapi_schema["x-tagGroups"] = [{"name": "v2", "tags": [t.value for t in ApiTag]}] - - app.openapi_schema = openapi_schema - return app.openapi_schema - - return openapi - - -RATE_LIMIT_DOCS = { - 200: { - "headers": { - "X-RateLimit-Limit": { - "schema": {"type": "integer"}, - "description": "Requests allowed per window", - }, - "X-RateLimit-Remaining": { - "schema": {"type": "integer"}, - "description": "Requests remaining", - }, - "X-RateLimit-Reset": { - "schema": {"type": "integer"}, - "description": "Seconds until reset", - }, - }, - }, - 429: { - "description": "Too Many Requests", - "headers": { - "X-RateLimit-Limit": { - "schema": {"type": "integer"}, - "description": "Requests allowed per window", - }, - "X-RateLimit-Remaining": { - "schema": {"type": "integer"}, - "description": "Requests remaining", - }, - "X-RateLimit-Reset": { - "schema": {"type": "integer"}, - "description": "Seconds until reset", - }, - "X-Retry-After": { - "schema": {"type": "integer"}, - "description": "Seconds until you can retry", - }, - }, - }, -} - -CACHE_DOCS = { - 200: { - "headers": { - "X-Cache": { - "schema": {"type": "string", "enum": ["HIT", "MISS"]}, - "description": "Cache status", - }, - } - } -} +from fastapi import FastAPI +from fastapi.openapi.utils import get_openapi + +from app.schemas.constants import __DESCRIPTION__, __NAME__, __VERSION__ +from app.schemas.enums import ApiTag + + +def custom_openapi(app: FastAPI): + def openapi(): + nonlocal app + + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = get_openapi( + title=__NAME__, + version=__VERSION__, + summary=f"{__NAME__} API Documentation", + description=__DESCRIPTION__, + routes=app.routes, + ) + + openapi_schema["x-tagGroups"] = [{"name": "v2", "tags": [t.value for t in ApiTag]}] + + app.openapi_schema = openapi_schema + return app.openapi_schema + + return openapi + + +RATE_LIMIT_DOCS = { + 200: { + "headers": { + "X-RateLimit-Limit": { + "schema": {"type": "integer"}, + "description": "Requests allowed per window", + }, + "X-RateLimit-Remaining": { + "schema": {"type": "integer"}, + "description": "Requests remaining", + }, + "X-RateLimit-Reset": { + "schema": {"type": "integer"}, + "description": "Seconds until reset", + }, + }, + }, + 429: { + "description": "Too Many Requests", + "headers": { + "X-RateLimit-Limit": { + "schema": {"type": "integer"}, + "description": "Requests allowed per window", + }, + "X-RateLimit-Remaining": { + "schema": {"type": "integer"}, + "description": "Requests remaining", + }, + "X-RateLimit-Reset": { + "schema": {"type": "integer"}, + "description": "Seconds until reset", + }, + "X-Retry-After": { + "schema": {"type": "integer"}, + "description": "Seconds until you can retry", + }, + }, + }, +} + +CACHE_DOCS = { + 200: { + "headers": { + "X-Cache": { + "schema": {"type": "string", "enum": ["HIT", "MISS"]}, + "description": "Cache status", + }, + } + } +} diff --git a/app/core/security/auth.py b/app/core/security/auth.py index 2be9ec2..3ccd8d3 100644 --- a/app/core/security/auth.py +++ b/app/core/security/auth.py @@ -1,134 +1,134 @@ -import re -from datetime import UTC, datetime -from functools import lru_cache -from hashlib import sha256 -from typing import Annotated - -from fastapi import Depends, Header, HTTPException, Request, Security -from fastapi.security import APIKeyHeader -from starlette.status import HTTP_401_UNAUTHORIZED - -from app.config import ADMIN_CONFIG -from app.core.db import SessionDep -from app.core.log import LOGGER - -from .model import User, UserRepository - - -@lru_cache(maxsize=256) -def _compile_pattern(pattern: str) -> re.Pattern: - """ - Compile user permission pattern (with optional *) into a regex pattern. - """ - escaped = re.escape(pattern).replace(r"\*", ".*") - return re.compile(f"^{escaped}$") - - -def has_permission(required: str | set[str] | None, user: set[str]) -> bool: - """ - Check if the user's permissions satisfy the required permissions. - Only user permissions can contain wildcards (*). - """ - if required is None: - return True - - if isinstance(required, str): - required = {required} - - for req in required: - # Check for exact match or wildcard match in user permissions - if not any( - _compile_pattern(user_perm).fullmatch(req) if "*" in user_perm else user_perm == req - for user_perm in user - ): - return False - return True - - -async def verify_user(user: User): - if user.expires_at and user.expires_at < datetime.now(UTC): - raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="API key has expired") - - -api_key_scheme = APIKeyHeader(name="X-API-KEY", auto_error=False) - - -async def get_user( - request: Request, - session: SessionDep, - api_key: Annotated[str | None, Security(api_key_scheme)] = None, - x_real_ip: Annotated[str | None, Header(alias="X-Real-IP", include_in_schema=False)] = None, -) -> User: - if api_key == ADMIN_CONFIG.token and ADMIN_CONFIG.token is not None: - # Create a dummy token with all permissions for the admin token - LOGGER.info("Admin token used, granting all permissions") - return User( - id=-1, - token="", - permissions=["*"], - created_at=datetime.now(UTC), - expires_at=datetime.now(UTC), - is_active=True, - creation_ip="unknown", - common_ips=[], - score=0, - ) - else: - if api_key is None: - raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="missing API key") - - userRepo = UserRepository(session) - api_key = hash_token(api_key) - user = await userRepo.get_user_by_token(api_key) - - if not user: - raise HTTPException( - status_code=HTTP_401_UNAUTHORIZED, detail="User not found for token" - ) - - assert request.client is not None - - if request.client.host == "127.0.0.1": - LOGGER.warning(f"Token {user.token} used from localhost without X-Real-IP header") - elif x_real_ip is None: - LOGGER.warning(f"Token {user.token} used without X-Real-IP header") - raise HTTPException( - status_code=HTTP_401_UNAUTHORIZED, detail="Cannot determine client IP address" - ) - - await userRepo.update_user_ip(user, x_real_ip or request.client.host) - - await verify_user(user) - - request.state.user_id = ( - user.id - ) # Store user ID in request state for later use (e.g. rate limiting) - return user - - -UserDep = Annotated[User, Depends(get_user)] -IpDep = Annotated[str, Header(alias="X-Real-IP", include_in_schema=False)] - - -def depends_permission(permission: str | set[str]): - async def dependency(user: UserDep) -> User: - if not has_permission(permission, set(user.permissions)): - LOGGER.debug(f"User {user.token} does not have required permissions: {permission}") - LOGGER.debug(f"User {user.token} permissions: {user.permissions}") - raise HTTPException(status_code=403, detail="Insufficient permissions") - - return user - - return dependency - - -@lru_cache(maxsize=1024) -def hash_token(token_str: str) -> str: - return sha256(token_str.encode()).hexdigest() - - -def hash_tokens(token_strs: list[str]) -> list[str]: - return [hash_token(t) for t in token_strs] - - -__all__ = ["UserDep", "depends_permission", "get_user", "hash_token", "hash_tokens"] +import re +from datetime import UTC, datetime +from functools import lru_cache +from hashlib import sha256 +from typing import Annotated + +from fastapi import Depends, Header, HTTPException, Request, Security +from fastapi.security import APIKeyHeader +from starlette.status import HTTP_401_UNAUTHORIZED + +from app.config import ADMIN_CONFIG +from app.core.db import SessionDep +from app.core.log import LOGGER + +from .model import User, UserRepository + + +@lru_cache(maxsize=256) +def _compile_pattern(pattern: str) -> re.Pattern: + """ + Compile user permission pattern (with optional *) into a regex pattern. + """ + escaped = re.escape(pattern).replace(r"\*", ".*") + return re.compile(f"^{escaped}$") + + +def has_permission(required: str | set[str] | None, user: set[str]) -> bool: + """ + Check if the user's permissions satisfy the required permissions. + Only user permissions can contain wildcards (*). + """ + if required is None: + return True + + if isinstance(required, str): + required = {required} + + for req in required: + # Check for exact match or wildcard match in user permissions + if not any( + _compile_pattern(user_perm).fullmatch(req) if "*" in user_perm else user_perm == req + for user_perm in user + ): + return False + return True + + +async def verify_user(user: User): + if user.expires_at and user.expires_at < datetime.now(UTC): + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="API key has expired") + + +api_key_scheme = APIKeyHeader(name="X-API-KEY", auto_error=False) + + +async def get_user( + request: Request, + session: SessionDep, + api_key: Annotated[str | None, Security(api_key_scheme)] = None, + x_real_ip: Annotated[str | None, Header(alias="X-Real-IP", include_in_schema=False)] = None, +) -> User: + if api_key == ADMIN_CONFIG.token and ADMIN_CONFIG.token is not None: + # Create a dummy token with all permissions for the admin token + LOGGER.info("Admin token used, granting all permissions") + return User( + id=-1, + token="", + permissions=["*"], + created_at=datetime.now(UTC), + expires_at=datetime.now(UTC), + is_active=True, + creation_ip="unknown", + common_ips=[], + score=0, + ) + else: + if api_key is None: + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED, detail="missing API key") + + userRepo = UserRepository(session) + api_key = hash_token(api_key) + user = await userRepo.get_user_by_token(api_key) + + if not user: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="User not found for token" + ) + + assert request.client is not None + + if request.client.host == "127.0.0.1": + LOGGER.warning(f"Token {user.token} used from localhost without X-Real-IP header") + elif x_real_ip is None: + LOGGER.warning(f"Token {user.token} used without X-Real-IP header") + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Cannot determine client IP address" + ) + + await userRepo.update_user_ip(user, x_real_ip or request.client.host) + + await verify_user(user) + + request.state.user_id = ( + user.id + ) # Store user ID in request state for later use (e.g. rate limiting) + return user + + +UserDep = Annotated[User, Depends(get_user)] +IpDep = Annotated[str, Header(alias="X-Real-IP", include_in_schema=False)] + + +def depends_permission(permission: str | set[str]): + async def dependency(user: UserDep) -> User: + if not has_permission(permission, set(user.permissions)): + LOGGER.debug(f"User {user.token} does not have required permissions: {permission}") + LOGGER.debug(f"User {user.token} permissions: {user.permissions}") + raise HTTPException(status_code=403, detail="Insufficient permissions") + + return user + + return dependency + + +@lru_cache(maxsize=1024) +def hash_token(token_str: str) -> str: + return sha256(token_str.encode()).hexdigest() + + +def hash_tokens(token_strs: list[str]) -> list[str]: + return [hash_token(t) for t in token_strs] + + +__all__ = ["UserDep", "depends_permission", "get_user", "hash_token", "hash_tokens"] diff --git a/app/core/security/model.py b/app/core/security/model.py index ad78cbe..be82c5a 100644 --- a/app/core/security/model.py +++ b/app/core/security/model.py @@ -1,79 +1,79 @@ -from datetime import datetime - -from sqlalchemy import DateTime, String, delete, func, select -from sqlalchemy.orm import ( - Mapped, - mapped_column, -) -from sqlalchemy.types import ARRAY - -from app.config import USER_CONFIG -from app.core.db import Base, BaseRepository - - -class User(Base): - __tablename__ = "users" - - id: Mapped[int] = mapped_column(primary_key=True) - - token: Mapped[str] = mapped_column(nullable=False, index=True, unique=True) - permissions: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=[]) - - created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now()) - expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - is_active: Mapped[bool] = mapped_column(default=True) - - creation_ip: Mapped[str] = mapped_column(String, nullable=False) - # A list of common IPs used by this user, ordered from oldest to newest. - common_ips: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=[]) - - score: Mapped[int] = mapped_column(default=0) - - -class UserRepository(BaseRepository): - async def get_user_by_token(self, token_str: str, include_inactive: bool = False) -> User | None: - query = select(User).where(User.token == token_str) - if not include_inactive: - query = query.where(User.is_active == True) # noqa: E712 - result = await self.session.execute(query) - return result.scalars().first() - - async def get_users_by_tokens(self, token_strs: list[str], include_inactive: bool = False) -> list[User]: - query = select(User).where(User.token.in_(token_strs)) - if not include_inactive: - query = query.where(User.is_active == True) # noqa: E712 - result = await self.session.execute(query) - return list(result.scalars().all()) - - async def list_users(self, include_inactive: bool = False) -> list[User]: - query = select(User) - if not include_inactive: - query = query.where(User.is_active == True) # noqa: E712 - result = await self.session.execute(query) - return list(result.scalars().all()) - - async def save(self, user: User) -> None: - self.session.add(user) - await self.session.flush() - await self.session.refresh(user, attribute_names=["token"]) - - async def delete(self, token_strs: list[str]) -> None: - await self.session.execute(delete(User).where(User.token.in_(token_strs))) - await self.session.flush() - - async def get_users_by_ids(self, user_ids: list[int]) -> list[User]: - if not user_ids: - return [] - query = select(User).where(User.id.in_(user_ids)) - result = await self.session.execute(query) - return list(result.scalars().all()) - - async def update_user_ip(self, user: User, ip: str) -> None: - if ip not in user.common_ips: - if len(user.common_ips) >= USER_CONFIG.max_ip_records: - user.common_ips.pop(0) - user.common_ips.append(ip) - else: - user.common_ips.remove(ip) - user.common_ips.append(ip) - await self.session.flush() +from datetime import datetime + +from sqlalchemy import DateTime, String, delete, func, select +from sqlalchemy.orm import ( + Mapped, + mapped_column, +) +from sqlalchemy.types import ARRAY + +from app.config import USER_CONFIG +from app.core.db import Base, BaseRepository + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True) + + token: Mapped[str] = mapped_column(nullable=False, index=True, unique=True) + permissions: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=[]) + + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=func.now()) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + is_active: Mapped[bool] = mapped_column(default=True) + + creation_ip: Mapped[str] = mapped_column(String, nullable=False) + # A list of common IPs used by this user, ordered from oldest to newest. + common_ips: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=False, default=[]) + + score: Mapped[int] = mapped_column(default=0) + + +class UserRepository(BaseRepository): + async def get_user_by_token(self, token_str: str, include_inactive: bool = False) -> User | None: + query = select(User).where(User.token == token_str) + if not include_inactive: + query = query.where(User.is_active == True) # noqa: E712 + result = await self.session.execute(query) + return result.scalars().first() + + async def get_users_by_tokens(self, token_strs: list[str], include_inactive: bool = False) -> list[User]: + query = select(User).where(User.token.in_(token_strs)) + if not include_inactive: + query = query.where(User.is_active == True) # noqa: E712 + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def list_users(self, include_inactive: bool = False) -> list[User]: + query = select(User) + if not include_inactive: + query = query.where(User.is_active == True) # noqa: E712 + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def save(self, user: User) -> None: + self.session.add(user) + await self.session.flush() + await self.session.refresh(user, attribute_names=["token"]) + + async def delete(self, token_strs: list[str]) -> None: + await self.session.execute(delete(User).where(User.token.in_(token_strs))) + await self.session.flush() + + async def get_users_by_ids(self, user_ids: list[int]) -> list[User]: + if not user_ids: + return [] + query = select(User).where(User.id.in_(user_ids)) + result = await self.session.execute(query) + return list(result.scalars().all()) + + async def update_user_ip(self, user: User, ip: str) -> None: + if ip not in user.common_ips: + if len(user.common_ips) >= USER_CONFIG.max_ip_records: + user.common_ips.pop(0) + user.common_ips.append(ip) + else: + user.common_ips.remove(ip) + user.common_ips.append(ip) + await self.session.flush() diff --git a/app/main.py b/app/main.py index 345398e..71c0965 100644 --- a/app/main.py +++ b/app/main.py @@ -1,109 +1,109 @@ -from contextlib import asynccontextmanager - -from fastapi import FastAPI, HTTPException -from fastapi.exceptions import RequestValidationError, ResponseValidationError -from fastapi.staticfiles import StaticFiles -from scalar_fastapi import AgentScalarConfig, OpenAPISource, get_scalar_api_reference - -from app.config import DB_CONFIG -from app.core.db import RedisClient, close_db, init_db -from app.core.openapi import custom_openapi -from app.core.scheduler import SCHEDULER -from app.core.sentry import init_sentry -from app.module.api.exception_handler import ( - generic_exception_handler, - http_exception_handler, - validation_exception_handler, -) -from app.module.api.router import Router -from app.schemas.constants import __DESCRIPTION__, __NAME__, __VERSION__ -from app.schemas.response import STATUS_RESPONSE, StatusResponse - -init_sentry() - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """ - Lifespan context manager for application. - """ - try: - SCHEDULER.start() - await init_db() - if DB_CONFIG.redis_dsn is not None: - await RedisClient.init() - yield - finally: - SCHEDULER.shutdown(wait=False) - await close_db() - if DB_CONFIG.redis_dsn is not None: - await RedisClient.close() - - -app = FastAPI( - title=__NAME__, - description=__DESCRIPTION__, - version=__VERSION__, - docs_url=None, - redoc_url=None, - lifespan=lifespan, - license_info={ - "name": "GNU Affero General Public License v3.0 (AGPL-v3)", - "identifier": "AGPL-3.0-or-later", - "url": "https://www.gnu.org/licenses/agpl-3.0.html", - }, -) - -app.include_router(Router) -app.exception_handler(HTTPException)(http_exception_handler) -app.exception_handler(RequestValidationError)(validation_exception_handler) -app.exception_handler(ResponseValidationError)(validation_exception_handler) -app.exception_handler(Exception)(generic_exception_handler) -app.openapi = custom_openapi(app) - - -@app.get("/") -@app.get("/healthz", include_in_schema=False) -async def read_root() -> StatusResponse: - return STATUS_RESPONSE - - -@app.get("/readyz", include_in_schema=False) -async def read_readyz() -> StatusResponse: - # TODO check db connections and other dependencies here - # for now just return success for dev - return STATUS_RESPONSE - - -@app.get("/docs", include_in_schema=False) -@app.get("/doc", include_in_schema=False) -async def scalar_ui(): - return get_scalar_api_reference( - title=f"{__NAME__} API Documentation", - sources=[ - OpenAPISource( - slug="wynnsource-api", - url=app.openapi_url, - ) - ], - agent=AgentScalarConfig(disabled=True), - scalar_favicon_url="./static/favicon.ico", - default_open_all_tags=True, - authentication={ - "preferredSecurityScheme": "APIKeyHeader", - }, - ) - - -app.mount("/static", StaticFiles(directory="static"), name="static") - - -if __name__ == "__main__": - import uvicorn - - uvicorn.run( - "app.main:app", - host="0.0.0.0", - port=8000, - log_level="debug", - ) +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException +from fastapi.exceptions import RequestValidationError, ResponseValidationError +from fastapi.staticfiles import StaticFiles +from scalar_fastapi import AgentScalarConfig, OpenAPISource, get_scalar_api_reference + +from app.config import DB_CONFIG +from app.core.db import RedisClient, close_db, init_db +from app.core.openapi import custom_openapi +from app.core.scheduler import SCHEDULER +from app.core.sentry import init_sentry +from app.module.api.exception_handler import ( + generic_exception_handler, + http_exception_handler, + validation_exception_handler, +) +from app.module.api.router import Router +from app.schemas.constants import __DESCRIPTION__, __NAME__, __VERSION__ +from app.schemas.response import STATUS_RESPONSE, StatusResponse + +init_sentry() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """ + Lifespan context manager for application. + """ + try: + SCHEDULER.start() + await init_db() + if DB_CONFIG.redis_dsn is not None: + await RedisClient.init() + yield + finally: + SCHEDULER.shutdown(wait=False) + await close_db() + if DB_CONFIG.redis_dsn is not None: + await RedisClient.close() + + +app = FastAPI( + title=__NAME__, + description=__DESCRIPTION__, + version=__VERSION__, + docs_url=None, + redoc_url=None, + lifespan=lifespan, + license_info={ + "name": "GNU Affero General Public License v3.0 (AGPL-v3)", + "identifier": "AGPL-3.0-or-later", + "url": "https://www.gnu.org/licenses/agpl-3.0.html", + }, +) + +app.include_router(Router) +app.exception_handler(HTTPException)(http_exception_handler) +app.exception_handler(RequestValidationError)(validation_exception_handler) +app.exception_handler(ResponseValidationError)(validation_exception_handler) +app.exception_handler(Exception)(generic_exception_handler) +app.openapi = custom_openapi(app) + + +@app.get("/") +@app.get("/healthz", include_in_schema=False) +async def read_root() -> StatusResponse: + return STATUS_RESPONSE + + +@app.get("/readyz", include_in_schema=False) +async def read_readyz() -> StatusResponse: + # TODO check db connections and other dependencies here + # for now just return success for dev + return STATUS_RESPONSE + + +@app.get("/docs", include_in_schema=False) +@app.get("/doc", include_in_schema=False) +async def scalar_ui(): + return get_scalar_api_reference( + title=f"{__NAME__} API Documentation", + sources=[ + OpenAPISource( + slug="wynnsource-api", + url=app.openapi_url, + ) + ], + agent=AgentScalarConfig(disabled=True), + scalar_favicon_url="./static/favicon.ico", + default_open_all_tags=True, + authentication={ + "preferredSecurityScheme": "APIKeyHeader", + }, + ) + + +app.mount("/static", StaticFiles(directory="static"), name="static") + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + log_level="debug", + ) diff --git a/app/module/api/exception_handler.py b/app/module/api/exception_handler.py index e5e7901..3643b79 100644 --- a/app/module/api/exception_handler.py +++ b/app/module/api/exception_handler.py @@ -1,40 +1,40 @@ -from fastapi import HTTPException, Request -from fastapi.encoders import jsonable_encoder -from fastapi.responses import JSONResponse -from pydantic import ValidationError -from starlette.status import HTTP_422_UNPROCESSABLE_CONTENT, HTTP_500_INTERNAL_SERVER_ERROR - -from app.core.log import LOGGER -from app.schemas.enums import ErrorCodes - -from .schema import GenericExceptionResponse, HTTPErrorDefinition, HTTPErrorResponse, ValidationErrorResponse - - -def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse: - """ - Generic exception handler API. - """ - LOGGER.exception(exc) - return GenericExceptionResponse(data={"error": "INTERNAL_ERROR"}, code=ErrorCodes.UNKNOWN_ERROR).to_response( - HTTP_500_INTERNAL_SERVER_ERROR - ) - - -def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: - """ - Global exception handler API. - """ - LOGGER.error(f"HTTP {exc.status_code}: {exc.detail}") - return HTTPErrorResponse( - data=HTTPErrorDefinition(status_code=exc.status_code, error=exc.detail), code=ErrorCodes.HTTP_ERROR - ).to_response(exc.status_code, exc.headers) - - -def validation_exception_handler(request: Request, exc: ValidationError) -> JSONResponse: - """ - Validation exception handler API. - """ - LOGGER.error(exc) - return ValidationErrorResponse( - data=jsonable_encoder(exc.errors()), - ).to_response(HTTP_422_UNPROCESSABLE_CONTENT) +from fastapi import HTTPException, Request +from fastapi.encoders import jsonable_encoder +from fastapi.responses import JSONResponse +from pydantic import ValidationError +from starlette.status import HTTP_422_UNPROCESSABLE_CONTENT, HTTP_500_INTERNAL_SERVER_ERROR + +from app.core.log import LOGGER +from app.schemas.enums import ErrorCodes + +from .schema import GenericExceptionResponse, HTTPErrorDefinition, HTTPErrorResponse, ValidationErrorResponse + + +def generic_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """ + Generic exception handler API. + """ + LOGGER.exception(exc) + return GenericExceptionResponse(data={"error": "INTERNAL_ERROR"}, code=ErrorCodes.UNKNOWN_ERROR).to_response( + HTTP_500_INTERNAL_SERVER_ERROR + ) + + +def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: + """ + Global exception handler API. + """ + LOGGER.error(f"HTTP {exc.status_code}: {exc.detail}") + return HTTPErrorResponse( + data=HTTPErrorDefinition(status_code=exc.status_code, error=exc.detail), code=ErrorCodes.HTTP_ERROR + ).to_response(exc.status_code, exc.headers) + + +def validation_exception_handler(request: Request, exc: ValidationError) -> JSONResponse: + """ + Validation exception handler API. + """ + LOGGER.error(exc) + return ValidationErrorResponse( + data=jsonable_encoder(exc.errors()), + ).to_response(HTTP_422_UNPROCESSABLE_CONTENT) diff --git a/app/module/api/router.py b/app/module/api/router.py index ec036b7..39cfd22 100644 --- a/app/module/api/router.py +++ b/app/module/api/router.py @@ -1,55 +1,55 @@ -from fastapi import APIRouter - -from app.core import metadata -from app.core.router import DocedAPIRoute -from app.module.beta.router import BetaRouter -from app.module.manage.router import ManageRouter -from app.module.market.router import MarketRouter -from app.module.pool.router import PoolRouter -from app.module.raid.router import RaidRouter -from app.schemas.enums import ApiTag, ItemReturnType -from app.schemas.response import StatusResponse, WCSResponse - -from .schema import MappingResponse, MappingType, RandomItemResponse, ValidationErrorResponse -from .service import MappingStorage, generate_random_item - -Router = APIRouter(route_class=DocedAPIRoute, prefix="/api/v2", responses={422: {"model": ValidationErrorResponse}}) -Router.include_router(ManageRouter) -Router.include_router(PoolRouter) -Router.include_router(MarketRouter) -Router.include_router(BetaRouter) -Router.include_router(RaidRouter) - - -@Router.get("/test", summary="Test endpoint", tags=[ApiTag.MISC]) -@metadata.rate_limit(10, 60) -@metadata.cached(expire=60) -@metadata.permission("api.test") -async def test_endpoint() -> StatusResponse: - """ - Test endpoint for various features like rate limiting, caching, and permissions. - """ - return StatusResponse() - - -@Router.get("/mappings/{mapping_type}", summary="Get ID Mappings", tags=[ApiTag.MISC]) -@metadata.rate_limit(10, 60) -@metadata.cached(expire=3600) # TTL 1 hour, updated by scheduled job -async def get_mappings(mapping_type: MappingType) -> MappingResponse: - """ - Get ID mappings for a given type. - """ - - storage = MappingStorage.get_instance() - return MappingResponse.model_validate(await storage.get_mapping(mapping_type), extra="ignore") - - -@Router.get("/item/random", summary="Get Random Item", tags=[ApiTag.MISC]) -@metadata.rate_limit(10, 60) -async def get_random_item( - item_return_type: ItemReturnType = ItemReturnType.B64, -) -> WCSResponse[RandomItemResponse]: - """ - Get a random item for testing purposes. - """ - return WCSResponse(data=RandomItemResponse(item=item_return_type.format_item(generate_random_item()))) +from fastapi import APIRouter + +from app.core import metadata +from app.core.router import DocedAPIRoute +from app.module.beta.router import BetaRouter +from app.module.manage.router import ManageRouter +from app.module.market.router import MarketRouter +from app.module.pool.router import PoolRouter +from app.module.raid.router import RaidRouter +from app.schemas.enums import ApiTag, ItemReturnType +from app.schemas.response import StatusResponse, WCSResponse + +from .schema import MappingResponse, MappingType, RandomItemResponse, ValidationErrorResponse +from .service import MappingStorage, generate_random_item + +Router = APIRouter(route_class=DocedAPIRoute, prefix="/api/v2", responses={422: {"model": ValidationErrorResponse}}) +Router.include_router(ManageRouter) +Router.include_router(PoolRouter) +Router.include_router(MarketRouter) +Router.include_router(BetaRouter) +Router.include_router(RaidRouter) + + +@Router.get("/test", summary="Test endpoint", tags=[ApiTag.MISC]) +@metadata.rate_limit(10, 60) +@metadata.cached(expire=60) +@metadata.permission("api.test") +async def test_endpoint() -> StatusResponse: + """ + Test endpoint for various features like rate limiting, caching, and permissions. + """ + return StatusResponse() + + +@Router.get("/mappings/{mapping_type}", summary="Get ID Mappings", tags=[ApiTag.MISC]) +@metadata.rate_limit(10, 60) +@metadata.cached(expire=3600) # TTL 1 hour, updated by scheduled job +async def get_mappings(mapping_type: MappingType) -> MappingResponse: + """ + Get ID mappings for a given type. + """ + + storage = MappingStorage.get_instance() + return MappingResponse.model_validate(await storage.get_mapping(mapping_type), extra="ignore") + + +@Router.get("/item/random", summary="Get Random Item", tags=[ApiTag.MISC]) +@metadata.rate_limit(10, 60) +async def get_random_item( + item_return_type: ItemReturnType = ItemReturnType.B64, +) -> WCSResponse[RandomItemResponse]: + """ + Get a random item for testing purposes. + """ + return WCSResponse(data=RandomItemResponse(item=item_return_type.format_item(generate_random_item()))) diff --git a/app/module/api/schema.py b/app/module/api/schema.py index c139a4a..664064d 100644 --- a/app/module/api/schema.py +++ b/app/module/api/schema.py @@ -1,64 +1,64 @@ -from datetime import datetime -from enum import StrEnum - -from pydantic import BaseModel -from pydantic_core import ErrorDetails - -from app.schemas.enums import ErrorCodes -from app.schemas.response.response import WCSResponse - - -class GenericExceptionResponse(WCSResponse[dict]): - """ - Generic exception response model. - """ - - -class HTTPErrorDefinition(BaseModel): - status_code: int - error: str - - -class HTTPErrorResponse(WCSResponse[dict]): - """ - HTTP error response model. - """ - - data: HTTPErrorDefinition - code: ErrorCodes = ErrorCodes.HTTP_ERROR - - -class ValidationErrorResponse(WCSResponse[list]): - """ - Validation error response model. - """ - - data: list[ErrorDetails] - code: ErrorCodes = ErrorCodes.VALIDATION_ERROR - - -class MappingType(StrEnum): - IDENTIFICATION = "identification" - SHINY = "shiny" - - -class Mapping(BaseModel): - id: int - key: str - - -class MappingResponse(BaseModel): - """ - Response model for ID to name mappings. - """ - - lastUpdated: datetime - data: list[Mapping] - - -class RandomItemResponse(BaseModel): - """ - Response model for random item endpoint. - """ - - item: str | dict +from datetime import datetime +from enum import StrEnum + +from pydantic import BaseModel +from pydantic_core import ErrorDetails + +from app.schemas.enums import ErrorCodes +from app.schemas.response.response import WCSResponse + + +class GenericExceptionResponse(WCSResponse[dict]): + """ + Generic exception response model. + """ + + +class HTTPErrorDefinition(BaseModel): + status_code: int + error: str + + +class HTTPErrorResponse(WCSResponse[dict]): + """ + HTTP error response model. + """ + + data: HTTPErrorDefinition + code: ErrorCodes = ErrorCodes.HTTP_ERROR + + +class ValidationErrorResponse(WCSResponse[list]): + """ + Validation error response model. + """ + + data: list[ErrorDetails] + code: ErrorCodes = ErrorCodes.VALIDATION_ERROR + + +class MappingType(StrEnum): + IDENTIFICATION = "identification" + SHINY = "shiny" + + +class Mapping(BaseModel): + id: int + key: str + + +class MappingResponse(BaseModel): + """ + Response model for ID to name mappings. + """ + + lastUpdated: datetime + data: list[Mapping] + + +class RandomItemResponse(BaseModel): + """ + Response model for random item endpoint. + """ + + item: str | dict diff --git a/app/module/manage/router.py b/app/module/manage/router.py index 5445dc4..4d9fc63 100644 --- a/app/module/manage/router.py +++ b/app/module/manage/router.py @@ -1,152 +1,152 @@ -from fastapi import APIRouter, Query - -import app.core.metadata as metadata -from app.core.db import SessionDep -from app.core.log import LOGGER -from app.core.router import DocedAPIRoute -from app.core.security.auth import IpDep, UserDep, hash_token, hash_tokens -from app.core.security.model import User, UserRepository -from app.schemas.enums import ApiTag -from app.schemas.response import EMPTY_RESPONSE, WCSResponse - -from .schema import UserInfoRequest, UserInfoResponse - -ManageRouter = APIRouter(route_class=DocedAPIRoute, prefix="/manage", tags=[ApiTag.MANAGEMENT]) - - -@ManageRouter.get("/user", summary="Get User Info by Token") -@metadata.permission(permission="admin.users.read") -async def get_user_info_by_token( - session: SessionDep, - token: list[str] = Query(), -) -> WCSResponse[list[UserInfoResponse]]: - """ - Get information about a user by their token. - """ - userRepo = UserRepository(session) - tokens = hash_tokens(token) - - users = await userRepo.get_users_by_tokens(tokens) - return WCSResponse(data=[UserInfoResponse.model_validate(user) for user in users]) - - -@ManageRouter.get("/user/all", summary="List Users") -@metadata.permission("admin.users.read") -async def get_users( - session: SessionDep, - inactive: bool = False, -) -> WCSResponse[list[UserInfoResponse]]: - """ - List all users with their permissions. - """ - userRepo = UserRepository(session) - users = await userRepo.list_users(include_inactive=inactive) - return WCSResponse(data=[UserInfoResponse.model_validate(user) for user in users]) - - -@ManageRouter.get("/user/self", summary="Get Self User Info") -async def get_self_user_info(user: UserDep) -> WCSResponse[UserInfoResponse]: - """ - Get information about the current user, including permissions and common IPs. - """ - return WCSResponse(data=UserInfoResponse.model_validate(user)) - - -@ManageRouter.post("/user/register", summary="Register New User") -@metadata.rate_limit(1, 3600) -async def register_user( - token: str, - session: SessionDep, - ip: IpDep = "unknown", -) -> WCSResponse[UserInfoResponse]: - """ - Register a new user with the given token. - """ - userRepo = UserRepository(session) - user = User( - token=hash_token(token), - is_active=True, - permissions=[], - creation_ip=ip, - ) - await userRepo.save(user) - LOGGER.info(f"Registered new user with token: {token}") - return WCSResponse(data=UserInfoResponse.model_validate(user)) - - -@ManageRouter.post("/user/create", summary="Create New Users") -@metadata.permission(permission="admin.users.write") -async def create_user( - tokens: list[UserInfoRequest], session: SessionDep, ip: IpDep = "unknown" -) -> WCSResponse[list[UserInfoResponse]]: - """ - Create a new user with specified token and permissions. - """ - userRepo = UserRepository(session) - created_tokens: list[UserInfoResponse] = [] - for token_info in tokens: - user = User( - token=hash_token(token_info.token), - is_active=True, - permissions=token_info.permissions, - expires_at=token_info.expires_at, - creation_ip=ip, - ) - await userRepo.save(user) - created_tokens.append(UserInfoResponse.model_validate(user)) - - LOGGER.info(f"Created {len(created_tokens)} new users: {[hash_token(u.token) for u in created_tokens]}") - return WCSResponse(data=created_tokens) - - -@ManageRouter.delete("/user", summary="Delete User") -@metadata.permission(permission="admin.users.write") -async def delete_user(tokens: list[str], session: SessionDep) -> WCSResponse[dict]: - """ - Delete a user by their token string. - """ - tokens = hash_tokens(tokens) - userRepo = UserRepository(session) - await userRepo.delete(tokens) - LOGGER.info(f"Deleted {len(tokens)} users: {tokens}") - return EMPTY_RESPONSE - - -@ManageRouter.put("/user/permissions", summary="Add Permissions to User") -@metadata.permission(permission="admin.users.write") -async def add_permissions_to_user( - token_str: list[str], permissions: list[str], session: SessionDep -) -> WCSResponse[list[UserInfoResponse]]: - """ - Add permissions to existing users. - """ - userRepo = UserRepository(session) - token_strs = hash_tokens(token_str) - users = await userRepo.get_users_by_tokens(token_strs, include_inactive=True) - - for user in users: - user.permissions = list(set(user.permissions + permissions)) - - updated_user_infos = [UserInfoResponse.model_validate(user) for user in users] - LOGGER.info(f"Added permissions {permissions} to users: {token_strs}") - return WCSResponse(data=updated_user_infos) - - -@ManageRouter.delete("/user/permissions", summary="Remove Permissions from User") -@metadata.permission(permission="admin.users.write") -async def remove_permissions_from_user( - token_str: list[str], permissions: list[str], session: SessionDep -) -> WCSResponse[list[UserInfoResponse]]: - """ - Remove permissions from existing users. - """ - token_strs = hash_tokens(token_str) - userRepo = UserRepository(session) - users = await userRepo.get_users_by_tokens(token_strs, include_inactive=True) - - for user in users: - user.permissions = list(set(user.permissions) - set(permissions)) - - updated_user_infos = [UserInfoResponse.model_validate(user) for user in users] - LOGGER.info(f"Removed permissions {permissions} from users: {token_strs}") - return WCSResponse(data=updated_user_infos) +from fastapi import APIRouter, Query + +import app.core.metadata as metadata +from app.core.db import SessionDep +from app.core.log import LOGGER +from app.core.router import DocedAPIRoute +from app.core.security.auth import IpDep, UserDep, hash_token, hash_tokens +from app.core.security.model import User, UserRepository +from app.schemas.enums import ApiTag +from app.schemas.response import EMPTY_RESPONSE, WCSResponse + +from .schema import UserInfoRequest, UserInfoResponse + +ManageRouter = APIRouter(route_class=DocedAPIRoute, prefix="/manage", tags=[ApiTag.MANAGEMENT]) + + +@ManageRouter.get("/user", summary="Get User Info by Token") +@metadata.permission(permission="admin.users.read") +async def get_user_info_by_token( + session: SessionDep, + token: list[str] = Query(), +) -> WCSResponse[list[UserInfoResponse]]: + """ + Get information about a user by their token. + """ + userRepo = UserRepository(session) + tokens = hash_tokens(token) + + users = await userRepo.get_users_by_tokens(tokens) + return WCSResponse(data=[UserInfoResponse.model_validate(user) for user in users]) + + +@ManageRouter.get("/user/all", summary="List Users") +@metadata.permission("admin.users.read") +async def get_users( + session: SessionDep, + inactive: bool = False, +) -> WCSResponse[list[UserInfoResponse]]: + """ + List all users with their permissions. + """ + userRepo = UserRepository(session) + users = await userRepo.list_users(include_inactive=inactive) + return WCSResponse(data=[UserInfoResponse.model_validate(user) for user in users]) + + +@ManageRouter.get("/user/self", summary="Get Self User Info") +async def get_self_user_info(user: UserDep) -> WCSResponse[UserInfoResponse]: + """ + Get information about the current user, including permissions and common IPs. + """ + return WCSResponse(data=UserInfoResponse.model_validate(user)) + + +@ManageRouter.post("/user/register", summary="Register New User") +@metadata.rate_limit(1, 3600) +async def register_user( + token: str, + session: SessionDep, + ip: IpDep = "unknown", +) -> WCSResponse[UserInfoResponse]: + """ + Register a new user with the given token. + """ + userRepo = UserRepository(session) + user = User( + token=hash_token(token), + is_active=True, + permissions=[], + creation_ip=ip, + ) + await userRepo.save(user) + LOGGER.info(f"Registered new user with token: {token}") + return WCSResponse(data=UserInfoResponse.model_validate(user)) + + +@ManageRouter.post("/user/create", summary="Create New Users") +@metadata.permission(permission="admin.users.write") +async def create_user( + tokens: list[UserInfoRequest], session: SessionDep, ip: IpDep = "unknown" +) -> WCSResponse[list[UserInfoResponse]]: + """ + Create a new user with specified token and permissions. + """ + userRepo = UserRepository(session) + created_tokens: list[UserInfoResponse] = [] + for token_info in tokens: + user = User( + token=hash_token(token_info.token), + is_active=True, + permissions=token_info.permissions, + expires_at=token_info.expires_at, + creation_ip=ip, + ) + await userRepo.save(user) + created_tokens.append(UserInfoResponse.model_validate(user)) + + LOGGER.info(f"Created {len(created_tokens)} new users: {[hash_token(u.token) for u in created_tokens]}") + return WCSResponse(data=created_tokens) + + +@ManageRouter.delete("/user", summary="Delete User") +@metadata.permission(permission="admin.users.write") +async def delete_user(tokens: list[str], session: SessionDep) -> WCSResponse[dict]: + """ + Delete a user by their token string. + """ + tokens = hash_tokens(tokens) + userRepo = UserRepository(session) + await userRepo.delete(tokens) + LOGGER.info(f"Deleted {len(tokens)} users: {tokens}") + return EMPTY_RESPONSE + + +@ManageRouter.put("/user/permissions", summary="Add Permissions to User") +@metadata.permission(permission="admin.users.write") +async def add_permissions_to_user( + token_str: list[str], permissions: list[str], session: SessionDep +) -> WCSResponse[list[UserInfoResponse]]: + """ + Add permissions to existing users. + """ + userRepo = UserRepository(session) + token_strs = hash_tokens(token_str) + users = await userRepo.get_users_by_tokens(token_strs, include_inactive=True) + + for user in users: + user.permissions = list(set(user.permissions + permissions)) + + updated_user_infos = [UserInfoResponse.model_validate(user) for user in users] + LOGGER.info(f"Added permissions {permissions} to users: {token_strs}") + return WCSResponse(data=updated_user_infos) + + +@ManageRouter.delete("/user/permissions", summary="Remove Permissions from User") +@metadata.permission(permission="admin.users.write") +async def remove_permissions_from_user( + token_str: list[str], permissions: list[str], session: SessionDep +) -> WCSResponse[list[UserInfoResponse]]: + """ + Remove permissions from existing users. + """ + token_strs = hash_tokens(token_str) + userRepo = UserRepository(session) + users = await userRepo.get_users_by_tokens(token_strs, include_inactive=True) + + for user in users: + user.permissions = list(set(user.permissions) - set(permissions)) + + updated_user_infos = [UserInfoResponse.model_validate(user) for user in users] + LOGGER.info(f"Removed permissions {permissions} from users: {token_strs}") + return WCSResponse(data=updated_user_infos) diff --git a/app/module/manage/schema.py b/app/module/manage/schema.py index 78d358c..8840478 100644 --- a/app/module/manage/schema.py +++ b/app/module/manage/schema.py @@ -1,28 +1,28 @@ -from datetime import datetime - -from pydantic import BaseModel, ConfigDict - - -class UserInfoRequest(BaseModel): - token: str - permissions: list[str] - expires_at: datetime | None = None - - -class UserInfoResponse(BaseModel): - model_config = ConfigDict(from_attributes=True) - - id: int - - token: str - permissions: list[str] - - created_at: datetime - expires_at: datetime | None = None - is_active: bool = True - - creation_ip: str - # A list of common IPs used by this user, ordered from oldest to newest. - common_ips: list[str] - - score: int +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class UserInfoRequest(BaseModel): + token: str + permissions: list[str] + expires_at: datetime | None = None + + +class UserInfoResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + + token: str + permissions: list[str] + + created_at: datetime + expires_at: datetime | None = None + is_active: bool = True + + creation_ip: str + # A list of common IPs used by this user, ordered from oldest to newest. + common_ips: list[str] + + score: int diff --git a/app/module/market/router.py b/app/module/market/router.py index fd55c88..80aed85 100644 --- a/app/module/market/router.py +++ b/app/module/market/router.py @@ -1,6 +1,6 @@ -from fastapi import APIRouter - -from app.core.router import DocedAPIRoute -from app.schemas.enums import ApiTag - -MarketRouter = APIRouter(route_class=DocedAPIRoute, prefix="/market", tags=[ApiTag.MARKET]) +from fastapi import APIRouter + +from app.core.router import DocedAPIRoute +from app.schemas.enums import ApiTag + +MarketRouter = APIRouter(route_class=DocedAPIRoute, prefix="/market", tags=[ApiTag.MARKET]) diff --git a/app/module/pool/router.py b/app/module/pool/router.py index a2edcdf..378d529 100644 --- a/app/module/pool/router.py +++ b/app/module/pool/router.py @@ -1,110 +1,110 @@ -import datetime - -from fastapi import APIRouter, HTTPException -from starlette.status import HTTP_422_UNPROCESSABLE_CONTENT - -from app.core import metadata -from app.core.db import SessionDep, get_session -from app.core.rate_limiter import ip_based_key_func, user_based_key_func -from app.core.router import DocedAPIRoute -from app.core.security.auth import UserDep -from app.schemas.enums import ApiTag, ItemReturnType -from app.schemas.response import EMPTY_RESPONSE, EmptyResponse, WCSResponse - -from .config import POOL_REFRESH_CONFIG -from .schema import ( - LootPoolRegion, - PoolConsensusResponse, - PoolSubmissionSchema, - PoolType, - RaidRegion, -) -from .service import compute_pool_consensus, get_pool_consensus -from .service import submit_pool_data as svc_submit_pool_data - -PoolRouter = APIRouter(route_class=DocedAPIRoute, prefix="/pool", tags=[ApiTag.POOL]) - - -@PoolRouter.post("/submit", summary="Submit Pool Data") -@metadata.rate_limit(limit=30, period=60, key_func=user_based_key_func) -async def submit_pool_data(data: list[PoolSubmissionSchema], user: UserDep) -> EmptyResponse: - """ - Endpoint for clients to submit pool data. - """ - for submission in data: - try: - # We want to process each submission - # in a different sessions to avoid one bad submission breaking others - async with get_session() as session: - await svc_submit_pool_data(session, submission, user) - except ValueError: - continue - - return EMPTY_RESPONSE - - -@PoolRouter.get("/pools/{pool_type}/{region}", summary="Get Current Pool by Type and Region") -@metadata.rate_limit(limit=10, period=60, key_func=ip_based_key_func) -@metadata.cached(expire=120) -async def get_pools_by_type_and_region( - pool_type: PoolType, - region: LootPoolRegion | RaidRegion, - session: SessionDep, - item_return_type: ItemReturnType = ItemReturnType.B64, -) -> WCSResponse[PoolConsensusResponse]: - """ - Get pools by type and region. - """ - try: - rotation = POOL_REFRESH_CONFIG[pool_type].get_rotation( - datetime.datetime.now(tz=datetime.UTC) - ) - consensus_by_page = await get_pool_consensus( - session, - pool_type, - region, - rotation_start=rotation.start, - ) - - if not consensus_by_page: - data = PoolConsensusResponse( - pool_type=pool_type, - region=region, - rotation_start=rotation.start, - rotation_end=rotation.end, - page_consensus=[], - ) - else: - page_consensus = [] - for page, (items, confidence) in consensus_by_page.items(): - page_consensus.append( - PoolConsensusResponse.PageConsensus( - page=page, - items=item_return_type.format_items(items), - confidence=confidence, - ) - ) - - data = PoolConsensusResponse( - pool_type=pool_type, - region=region, - rotation_start=rotation.start, - rotation_end=rotation.end, - page_consensus=page_consensus, - ) - - except ValueError as e: - raise HTTPException(status_code=HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)) - - return WCSResponse(data=data) - - -@PoolRouter.get("/pools/recalc", summary="Force Recalculate Pool Consensus") -@metadata.permission("pool.recalc") -async def recalculate_pools() -> WCSResponse[dict]: - """ - Force recalculate pool consensus. - This is useful for admins to fix any issues with the consensus calculation. - """ - recalculated = await compute_pool_consensus() - return WCSResponse.from_message(f"Recalculated {recalculated} pool(s)") +import datetime + +from fastapi import APIRouter, HTTPException +from starlette.status import HTTP_422_UNPROCESSABLE_CONTENT + +from app.core import metadata +from app.core.db import SessionDep, get_session +from app.core.rate_limiter import ip_based_key_func, user_based_key_func +from app.core.router import DocedAPIRoute +from app.core.security.auth import UserDep +from app.schemas.enums import ApiTag, ItemReturnType +from app.schemas.response import EMPTY_RESPONSE, EmptyResponse, WCSResponse + +from .config import POOL_REFRESH_CONFIG +from .schema import ( + LootPoolRegion, + PoolConsensusResponse, + PoolSubmissionSchema, + PoolType, + RaidRegion, +) +from .service import compute_pool_consensus, get_pool_consensus +from .service import submit_pool_data as svc_submit_pool_data + +PoolRouter = APIRouter(route_class=DocedAPIRoute, prefix="/pool", tags=[ApiTag.POOL]) + + +@PoolRouter.post("/submit", summary="Submit Pool Data") +@metadata.rate_limit(limit=30, period=60, key_func=user_based_key_func) +async def submit_pool_data(data: list[PoolSubmissionSchema], user: UserDep) -> EmptyResponse: + """ + Endpoint for clients to submit pool data. + """ + for submission in data: + try: + # We want to process each submission + # in a different sessions to avoid one bad submission breaking others + async with get_session() as session: + await svc_submit_pool_data(session, submission, user) + except ValueError: + continue + + return EMPTY_RESPONSE + + +@PoolRouter.get("/pools/{pool_type}/{region}", summary="Get Current Pool by Type and Region") +@metadata.rate_limit(limit=10, period=60, key_func=ip_based_key_func) +@metadata.cached(expire=120) +async def get_pools_by_type_and_region( + pool_type: PoolType, + region: LootPoolRegion | RaidRegion, + session: SessionDep, + item_return_type: ItemReturnType = ItemReturnType.B64, +) -> WCSResponse[PoolConsensusResponse]: + """ + Get pools by type and region. + """ + try: + rotation = POOL_REFRESH_CONFIG[pool_type].get_rotation( + datetime.datetime.now(tz=datetime.UTC) + ) + consensus_by_page = await get_pool_consensus( + session, + pool_type, + region, + rotation_start=rotation.start, + ) + + if not consensus_by_page: + data = PoolConsensusResponse( + pool_type=pool_type, + region=region, + rotation_start=rotation.start, + rotation_end=rotation.end, + page_consensus=[], + ) + else: + page_consensus = [] + for page, (items, confidence) in consensus_by_page.items(): + page_consensus.append( + PoolConsensusResponse.PageConsensus( + page=page, + items=item_return_type.format_items(items), + confidence=confidence, + ) + ) + + data = PoolConsensusResponse( + pool_type=pool_type, + region=region, + rotation_start=rotation.start, + rotation_end=rotation.end, + page_consensus=page_consensus, + ) + + except ValueError as e: + raise HTTPException(status_code=HTTP_422_UNPROCESSABLE_CONTENT, detail=str(e)) + + return WCSResponse(data=data) + + +@PoolRouter.get("/pools/recalc", summary="Force Recalculate Pool Consensus") +@metadata.permission("pool.recalc") +async def recalculate_pools() -> WCSResponse[dict]: + """ + Force recalculate pool consensus. + This is useful for admins to fix any issues with the consensus calculation. + """ + recalculated = await compute_pool_consensus() + return WCSResponse.from_message(f"Recalculated {recalculated} pool(s)") diff --git a/app/schemas/constants.py b/app/schemas/constants.py index 55aaf39..d249132 100644 --- a/app/schemas/constants.py +++ b/app/schemas/constants.py @@ -1,39 +1,39 @@ -import enum -import os -import tomllib - - -class MediaType(enum.StrEnum): - JSON = "application/json" - HTML = "text/html" - TEXT = "text/plain" - XML = "application/xml" - FORM_URLENCODED = "application/x-www-form-urlencoded" - MULTIPART_FORM_DATA = "multipart/form-data" - - @classmethod - def get_media_type(cls, media_type: str) -> str: - return cls[media_type].value if media_type in cls.__members__ else cls.JSON.value - - -with open("pyproject.toml", "rb") as f: - pyproject = tomllib.load(f) - -__NAME__ = "WynnSource Server" -_base_version: str = pyproject["project"]["version"] -_version_suffix = os.environ.get("WCS_VERSION_SUFFIX", "") -__VERSION__: str = f"{_base_version}{_version_suffix}" if _version_suffix else _base_version -__DESCRIPTION__: str = pyproject["project"]["description"] -__REVISION__ = 2 - - -INJECTED_NAMESPACE = "__wcs_injected_" - -__all__ = [ - "INJECTED_NAMESPACE", - "__DESCRIPTION__", - "__NAME__", - "__REVISION__", - "__VERSION__", - "MediaType", -] +import enum +import os +import tomllib + + +class MediaType(enum.StrEnum): + JSON = "application/json" + HTML = "text/html" + TEXT = "text/plain" + XML = "application/xml" + FORM_URLENCODED = "application/x-www-form-urlencoded" + MULTIPART_FORM_DATA = "multipart/form-data" + + @classmethod + def get_media_type(cls, media_type: str) -> str: + return cls[media_type].value if media_type in cls.__members__ else cls.JSON.value + + +with open("pyproject.toml", "rb") as f: + pyproject = tomllib.load(f) + +__NAME__ = "WynnSource Server" +_base_version: str = pyproject["project"]["version"] +_version_suffix = os.environ.get("WCS_VERSION_SUFFIX", "") +__VERSION__: str = f"{_base_version}{_version_suffix}" if _version_suffix else _base_version +__DESCRIPTION__: str = pyproject["project"]["description"] +__REVISION__ = 2 + + +INJECTED_NAMESPACE = "__wcs_injected_" + +__all__ = [ + "INJECTED_NAMESPACE", + "__DESCRIPTION__", + "__NAME__", + "__REVISION__", + "__VERSION__", + "MediaType", +] diff --git a/app/schemas/enums/__init__.py b/app/schemas/enums/__init__.py index 9cc563f..0965efd 100644 --- a/app/schemas/enums/__init__.py +++ b/app/schemas/enums/__init__.py @@ -1,9 +1,9 @@ -from .error import ErrorCodes -from .item import ItemReturnType -from .tag import ApiTag - -__all__ = [ - "ApiTag", - "ErrorCodes", - "ItemReturnType", -] +from .error import ErrorCodes +from .item import ItemReturnType +from .tag import ApiTag + +__all__ = [ + "ApiTag", + "ErrorCodes", + "ItemReturnType", +] diff --git a/app/schemas/enums/error.py b/app/schemas/enums/error.py index 07e5b67..03e2378 100644 --- a/app/schemas/enums/error.py +++ b/app/schemas/enums/error.py @@ -1,13 +1,13 @@ -import enum - - -class ErrorCodes(enum.StrEnum): - """ - Enum for error codes. - """ - - OK = "OK" - UNKNOWN_ERROR = "UNKNOWN_ERROR" - VALIDATION_ERROR = "VALIDATION_ERROR" - NOT_FOUND = "NOT_FOUND" - HTTP_ERROR = "HTTP_ERROR" +import enum + + +class ErrorCodes(enum.StrEnum): + """ + Enum for error codes. + """ + + OK = "OK" + UNKNOWN_ERROR = "UNKNOWN_ERROR" + VALIDATION_ERROR = "VALIDATION_ERROR" + NOT_FOUND = "NOT_FOUND" + HTTP_ERROR = "HTTP_ERROR" diff --git a/app/schemas/response/__init__.py b/app/schemas/response/__init__.py index dfbcc31..712e7f9 100644 --- a/app/schemas/response/__init__.py +++ b/app/schemas/response/__init__.py @@ -1,4 +1,4 @@ -from .default_response import EMPTY_RESPONSE, STATUS_RESPONSE -from .response import EmptyResponse, StatusResponse, WCSResponse - -__all__ = ["EMPTY_RESPONSE", "STATUS_RESPONSE", "EmptyResponse", "StatusResponse", "WCSResponse"] +from .default_response import EMPTY_RESPONSE, STATUS_RESPONSE +from .response import EmptyResponse, StatusResponse, WCSResponse + +__all__ = ["EMPTY_RESPONSE", "STATUS_RESPONSE", "EmptyResponse", "StatusResponse", "WCSResponse"] diff --git a/app/schemas/response/default_response.py b/app/schemas/response/default_response.py index 3363bed..0e57548 100644 --- a/app/schemas/response/default_response.py +++ b/app/schemas/response/default_response.py @@ -1,4 +1,4 @@ -from .response import EmptyResponse, StatusResponse - -STATUS_RESPONSE = StatusResponse() -EMPTY_RESPONSE = EmptyResponse() +from .response import EmptyResponse, StatusResponse + +STATUS_RESPONSE = StatusResponse() +EMPTY_RESPONSE = EmptyResponse() diff --git a/app/schemas/response/response.py b/app/schemas/response/response.py index 3991bba..6a9afdf 100644 --- a/app/schemas/response/response.py +++ b/app/schemas/response/response.py @@ -1,76 +1,76 @@ -from collections.abc import Mapping -from datetime import UTC, datetime - -from fastapi.responses import JSONResponse -from pydantic import BaseModel, Field -from starlette.status import HTTP_200_OK - -from app.schemas.constants import __REVISION__, __VERSION__ -from app.schemas.enums import ErrorCodes - - -class WCSResponse[T: (BaseModel, dict, list, None)](BaseModel): - """ - Base class for all v1 response models. - """ - - data: T - code: ErrorCodes = Field(default=ErrorCodes.OK) - timestamp: int = Field( - default_factory=lambda: int(datetime.now(UTC).timestamp()), - frozen=True, - examples=[int(datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC).timestamp())], - ) - version: int = Field(default=__REVISION__, frozen=True) - - def to_response(self, response_code: int = HTTP_200_OK, headers: Mapping[str, str] | None = None) -> JSONResponse: - return JSONResponse( - status_code=response_code, - content=self.model_dump(mode="json"), - headers=headers, - ) - - @classmethod - def from_message(cls, message: str) -> "WCSResponse[dict]": - """ - Create a response with a message. - """ - return WCSResponse[dict]( - data={"message": message}, - code=ErrorCodes.OK, - ) - - @classmethod - def from_dict(cls, data: dict) -> "WCSResponse[dict]": - """ - Create a response from a dictionary. - """ - return WCSResponse[dict]( - data=data, - code=ErrorCodes.OK, - ) - - -class StatusData(BaseModel): - """ - Status data model for v1 responses. - """ - - status: str = Field(default="OK", frozen=True) - version: str = Field(default=__VERSION__, frozen=True) - - -class StatusResponse(WCSResponse[StatusData]): - """ - Status response model for v1. - """ - - data: StatusData = Field(default=StatusData(), frozen=True) - - -class EmptyResponse(WCSResponse[dict]): - """ - Empty response model for v1. - """ - - data: dict = Field(default={}, frozen=True, json_schema_extra={"example": {}}) +from collections.abc import Mapping +from datetime import UTC, datetime + +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field +from starlette.status import HTTP_200_OK + +from app.schemas.constants import __REVISION__, __VERSION__ +from app.schemas.enums import ErrorCodes + + +class WCSResponse[T: (BaseModel, dict, list, None)](BaseModel): + """ + Base class for all v1 response models. + """ + + data: T + code: ErrorCodes = Field(default=ErrorCodes.OK) + timestamp: int = Field( + default_factory=lambda: int(datetime.now(UTC).timestamp()), + frozen=True, + examples=[int(datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC).timestamp())], + ) + version: int = Field(default=__REVISION__, frozen=True) + + def to_response(self, response_code: int = HTTP_200_OK, headers: Mapping[str, str] | None = None) -> JSONResponse: + return JSONResponse( + status_code=response_code, + content=self.model_dump(mode="json"), + headers=headers, + ) + + @classmethod + def from_message(cls, message: str) -> "WCSResponse[dict]": + """ + Create a response with a message. + """ + return WCSResponse[dict]( + data={"message": message}, + code=ErrorCodes.OK, + ) + + @classmethod + def from_dict(cls, data: dict) -> "WCSResponse[dict]": + """ + Create a response from a dictionary. + """ + return WCSResponse[dict]( + data=data, + code=ErrorCodes.OK, + ) + + +class StatusData(BaseModel): + """ + Status data model for v1 responses. + """ + + status: str = Field(default="OK", frozen=True) + version: str = Field(default=__VERSION__, frozen=True) + + +class StatusResponse(WCSResponse[StatusData]): + """ + Status response model for v1. + """ + + data: StatusData = Field(default=StatusData(), frozen=True) + + +class EmptyResponse(WCSResponse[dict]): + """ + Empty response model for v1. + """ + + data: dict = Field(default={}, frozen=True, json_schema_extra={"example": {}}) From c722709fc1a25d2b471ead21cc2d0e790eacfea3 Mon Sep 17 00:00:00 2001 From: FYWinds Date: Tue, 6 Oct 2026 22:53:20 -0700 Subject: [PATCH 2/2] feat: OTel traces/metrics, JSON logs, job locks Requests now join the Cloudflare/Traefik trace (W3C propagation; Sentry kept to errors so its sentry-trace propagator can't break that) and metrics are pushed per pod so Grafana/Tempo cover the app itself. Each replica ran every scheduler job; a Redis lock held for just under the period now gives one run per period. Jobs that only touch process-local state stay unlocked. Query values are redacted in spans and access logs: manage endpoints take user tokens in the query. init_db logged the DSN with password. uvicorn starts directly in exec form: fastapi-cli re-attached its rich log handlers (bypassing LOG_JSON) and the shell form blocked SIGTERM. --- .dev/docker-compose.yaml | 12 +- .dev/otel-collector.yaml | 20 + Dockerfile | 7 +- README.md | 28 + app/config/__init__.py | 2 + app/config/log.py | 4 + app/config/sentry.py | 5 +- app/config/telemetry.py | 23 + app/core/cache/__init__.py | 4 + app/core/db/session.py | 4 +- app/core/log.py | 71 ++- app/core/metrics.py | 127 +++++ app/core/rate_limiter/base.py | 8 + app/core/rate_limiter/memory_rate_limiter.py | 1 + app/core/rate_limiter/redis_rate_limiter.py | 1 + app/core/scheduler.py | 109 +++- app/core/score.py | 7 +- app/core/sentry.py | 25 +- app/core/telemetry.py | 127 +++++ app/main.py | 7 +- app/module/api/service.py | 5 +- app/module/beta/service.py | 13 +- app/module/pool/router.py | 8 +- app/module/pool/service.py | 23 +- app/module/raid/router.py | 4 + app/module/raid/service.py | 11 +- app/utils/redact.py | 15 + deploy/chart/templates/deployment.yaml | 18 + deploy/chart/values.yaml | 13 +- pyproject.toml | 12 +- uv.lock | 520 ++++++++++++++++++- 31 files changed, 1189 insertions(+), 45 deletions(-) create mode 100644 .dev/otel-collector.yaml create mode 100644 app/config/telemetry.py create mode 100644 app/core/metrics.py create mode 100644 app/core/telemetry.py create mode 100644 app/utils/redact.py diff --git a/.dev/docker-compose.yaml b/.dev/docker-compose.yaml index 2992270..65d673e 100644 --- a/.dev/docker-compose.yaml +++ b/.dev/docker-compose.yaml @@ -13,4 +13,14 @@ services: image: redis:7-alpine ports: - "127.0.0.1:6379:6379" - restart: unless-stopped \ No newline at end of file + restart: unless-stopped + + # Optional: docker compose -f .dev/docker-compose.yaml --profile telemetry up -d + otel-collector: + image: otel/opentelemetry-collector-contrib:0.161.0 + command: ["--config=/etc/otelcol/config.yaml"] + volumes: + - ./otel-collector.yaml:/etc/otelcol/config.yaml:ro + ports: + - "127.0.0.1:4318:4318" + profiles: [telemetry] diff --git a/.dev/otel-collector.yaml b/.dev/otel-collector.yaml new file mode 100644 index 0000000..7265e86 --- /dev/null +++ b/.dev/otel-collector.yaml @@ -0,0 +1,20 @@ +# Local OpenTelemetry collector: prints what the app exports. +# Point the app at it with OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318. +receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 + +exporters: + debug: + verbosity: detailed + +service: + pipelines: + traces: + receivers: [otlp] + exporters: [debug] + metrics: + receivers: [otlp] + exporters: [debug] diff --git a/Dockerfile b/Dockerfile index 3ad67f3..4a1df9f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,6 +38,9 @@ ENV UV_NO_CACHE=1 USER 1000 -# Run the FastAPI application +# Run uvicorn directly in exec form: fastapi-cli re-attaches its own rich log +# handlers after importing the app, bypassing loguru (and LOG_JSON), and the +# shell form keeps uvicorn from receiving SIGTERM, which skips the telemetry +# flush on shutdown. EXPOSE 8000 -CMD fastapi run --forwarded-allow-ips="127.0.0.1,10.0.0.0/8,[::1]" \ No newline at end of file +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--forwarded-allow-ips", "127.0.0.1,10.0.0.0/8,[::1]"] \ No newline at end of file diff --git a/README.md b/README.md index ff526fc..8a9b7cc 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,9 @@ The server component of [WynnSource](https://github.com/WynnSource) — a Wynncr git clone --recurse-submodules https://github.com/WynnSource/WynnSourceServer.git cd WynnSourceServer +# Start PostgreSQL and Redis on localhost (add `--profile telemetry` for a local OTel collector) +docker compose -f .dev/docker-compose.yaml up -d + # Generate protobuf code buf generate --template buf.gen.yaml @@ -38,6 +41,22 @@ uv run alembic upgrade head uv run fastapi dev ``` +### Windows + +Everything above works natively on Windows. Differences: + +- **Containers without Docker:** `wslc` (WSL containers) has no compose, start the services individually: + + ```powershell + wslc run -d --name wcs-db -e POSTGRES_DB=wcs_db -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -p 127.0.0.1:5432:5432 postgres:15-alpine + wslc run -d --name wcs-redis -p 127.0.0.1:6379:6379 redis:7-alpine + # Optional local OTel collector (prints spans and metrics: wslc logs -f wcs-otelcol) + wslc run -d --name wcs-otelcol -p 127.0.0.1:4318:4318 -v "$PWD\.dev\otel-collector.yaml:/etc/otelcol/config.yaml" otel/opentelemetry-collector-contrib:0.161.0 --config=/etc/otelcol/config.yaml + ``` + +- **Non-UTF-8 console code page** (e.g. GBK on Chinese Windows): `fastapi dev` crashes printing its emoji banner. Set `$env:PYTHONUTF8 = "1"`, or run `uv run uvicorn app.main:app --reload` instead. +- **A `.venv` created from WSL or a container** contains Linux symlinks (`lib64`) that Windows `uv` cannot remove. Delete it from WSL (`rm -rf .venv`) and run `uv sync` again. + ### Environment Variables | Variable | Description | Default | @@ -51,7 +70,16 @@ uv run fastapi dev | `REDIS_PORT` | Redis port | `6379` | | `WCS_ADMIN_TOKEN` | Admin API token | None | | `LEVEL` | Log level | `DEBUG` | +| `LOG_JSON` | Log one JSON object per line (with `trace_id`/`span_id`) instead of colored text | `false` | | `BETA_ALLOWED_VERSIONS` | Comma-separated allowed mod versions | `` | +| `WCS_SENTRY_DSN` | Sentry DSN; errors and logs only, tagged with the OpenTelemetry trace id | None (disabled) | +| `WCS_SENTRY_ENVIRONMENT` | Sentry environment | `local` | +| `WCS_SENTRY_PROFILE_SESSION_SAMPLE_RATE` | Share of processes that run Sentry continuous profiling | `0` | +| `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP/HTTP collector, e.g. `http://localhost:4318`; enables OpenTelemetry traces and metrics | None (disabled) | +| `OTEL_RESOURCE_ATTRIBUTES` | Extra resource attributes, e.g. `service.instance.id=…,deployment.environment.name=dev`. Give every replica a distinct `service.instance.id` | — | +| `K8S_POD_NAME` | Holder name for the cross-replica scheduler job locks | hostname | + +Other standard `OTEL_*` variables (`OTEL_TRACES_SAMPLER`, `OTEL_METRIC_EXPORT_INTERVAL`, …) are honored. The default sampler is `parentbased_always_on`: requests follow the caller's sampling decision, scheduler jobs are always traced. ## Deploying to Kubernetes diff --git a/app/config/__init__.py b/app/config/__init__.py index 56d895f..2f21a90 100644 --- a/app/config/__init__.py +++ b/app/config/__init__.py @@ -2,6 +2,7 @@ from .db import DB_CONFIG as DB_CONFIG from .log import LOG_CONFIG as LOG_CONFIG from .sentry import SENTRY_CONFIG as SENTRY_CONFIG +from .telemetry import TELEMETRY_CONFIG as TELEMETRY_CONFIG from .user import USER_CONFIG as USER_CONFIG __all__ = [ @@ -9,5 +10,6 @@ "DB_CONFIG", "LOG_CONFIG", "SENTRY_CONFIG", + "TELEMETRY_CONFIG", "USER_CONFIG", ] diff --git a/app/config/log.py b/app/config/log.py index 7605f7d..54c06cf 100644 --- a/app/config/log.py +++ b/app/config/log.py @@ -1,3 +1,4 @@ +from pydantic import Field from pydantic_settings import BaseSettings @@ -12,7 +13,10 @@ class LoggerConfig(BaseSettings): + "[{level: <8}]" + "{name}:{line}" + " | {message}" + + "{extra[trace]}" ) + # One JSON object per line instead of the text format above (deployments). + json_output: bool = Field(alias="LOG_JSON", default=False) LOG_CONFIG = LoggerConfig() diff --git a/app/config/sentry.py b/app/config/sentry.py index b21b6fd..3857a39 100644 --- a/app/config/sentry.py +++ b/app/config/sentry.py @@ -9,8 +9,9 @@ class SentryConfig(BaseSettings): dsn: str | None = Field(alias="WCS_SENTRY_DSN", default=None) environment: str = Field(alias="WCS_SENTRY_ENVIRONMENT", default="local") - traces_sample_rate: float = Field(alias="WCS_SENTRY_TRACES_SAMPLE_RATE", default=0.1) - profiles_sample_rate: float = Field(alias="WCS_SENTRY_PROFILES_SAMPLE_RATE", default=0.1) + # Tracing is OpenTelemetry's job; Sentry only profiles. Decided once per + # process: a sampled process profiles continuously for its whole life. + profile_session_sample_rate: float = Field(alias="WCS_SENTRY_PROFILE_SESSION_SAMPLE_RATE", default=0.0) SENTRY_CONFIG = SentryConfig() diff --git a/app/config/telemetry.py b/app/config/telemetry.py new file mode 100644 index 0000000..38d9cce --- /dev/null +++ b/app/config/telemetry.py @@ -0,0 +1,23 @@ +from pydantic import Field +from pydantic_settings import BaseSettings + + +class TelemetryConfig(BaseSettings): + """ + OpenTelemetry switch. Everything else (service name, resource attributes, + sampler, export interval) comes from the standard ``OTEL_*`` variables, + which the SDK reads itself. + """ + + otlp_endpoint: str | None = Field(alias="OTEL_EXPORTER_OTLP_ENDPOINT", default=None) + sdk_disabled: bool = Field(alias="OTEL_SDK_DISABLED", default=False) + + @property + def enabled(self) -> bool: + return self.otlp_endpoint is not None and not self.sdk_disabled + + +TELEMETRY_CONFIG = TelemetryConfig() +__all__ = [ + "TELEMETRY_CONFIG", +] diff --git a/app/core/cache/__init__.py b/app/core/cache/__init__.py index 9798512..e9d34ba 100644 --- a/app/core/cache/__init__.py +++ b/app/core/cache/__init__.py @@ -10,6 +10,7 @@ from app.config import DB_CONFIG from app.core.log import LOGGER +from app.core.metrics import CACHE_REQUESTS, route_template from app.schemas.constants import INJECTED_NAMESPACE from .base import Cache @@ -95,15 +96,18 @@ async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: return await fn(*args, **kwargs) cache_key = _build_cache_key(request) + route = route_template(request) raw = await _cache.get(cache_key) if raw is not None: + CACHE_REQUESTS.add(1, {"route": route, "result": "hit"}) if response: response.headers["X-Cache"] = "HIT" if return_type is not None: return _deserialize(raw, return_type) return json.loads(raw) # type: ignore[return-value] + CACHE_REQUESTS.add(1, {"route": route, "result": "miss"}) if response: response.headers["X-Cache"] = "MISS" diff --git a/app/core/db/session.py b/app/core/db/session.py index e696340..b8ccee9 100644 --- a/app/core/db/session.py +++ b/app/core/db/session.py @@ -7,6 +7,7 @@ from app.config import DB_CONFIG from app.core.log import LOGGER +from app.core.telemetry import instrument_engine engine: AsyncEngine | None = None session_maker: async_sessionmaker[AsyncSession] @@ -26,8 +27,9 @@ async def init_db() -> AsyncEngine: global engine, session_maker dsn = get_dsn() engine = create_async_engine(dsn, echo=False) + instrument_engine(engine) session_maker = async_sessionmaker(engine, expire_on_commit=False) - LOGGER.debug(f"Database engine initialized with DSN: {dsn}") + LOGGER.debug(f"Database engine initialized for {engine.url.render_as_string(hide_password=True)}") return engine diff --git a/app/core/log.py b/app/core/log.py index 8dc41ce..00d0ad1 100644 --- a/app/core/log.py +++ b/app/core/log.py @@ -1,20 +1,70 @@ import inspect import logging import sys +import traceback import loguru +import orjson +from opentelemetry import trace from app.config import LOG_CONFIG +from app.utils.redact import redact_url LOGGER = loguru.logger + +def _add_trace_context(record: "loguru.Record") -> None: + """Attach the active span, so Grafana can jump from a trace to its log lines.""" + ctx = trace.get_current_span().get_span_context() + if ctx.is_valid: + trace_id, span_id = trace.format_trace_id(ctx.trace_id), trace.format_span_id(ctx.span_id) + record["extra"].update(trace_id=trace_id, span_id=span_id, trace=f" trace_id={trace_id} span_id={span_id}") + else: + record["extra"]["trace"] = "" + + +def _json_sink(message: "loguru.Message") -> None: + record = message.record + entry: dict[str, object] = { + "time": record["time"].isoformat(), + "level": record["level"].name, + "logger": record["name"], + "function": record["function"], + "line": record["line"], + "message": record["message"], + } + # trace_id / span_id and anything bound with LOGGER.bind(); "trace" is the text-format suffix. + entry.update((k, v) for k, v in record["extra"].items() if k != "trace") + if exc := record["exception"]: + entry["exception"] = "".join(traceback.format_exception(exc.type, exc.value, exc.traceback)) + sys.stdout.write(orjson.dumps(entry, default=str).decode() + "\n") + sys.stdout.flush() + + LOGGER.remove() -logger_id = LOGGER.add( - sys.stdout, - level=LOG_CONFIG.level, - diagnose=False, - format=LOG_CONFIG.format, -) +LOGGER.configure(patcher=_add_trace_context) +if LOG_CONFIG.json_output: + logger_id = LOGGER.add(_json_sink, level=LOG_CONFIG.level, diagnose=False) +else: + logger_id = LOGGER.add(sys.stdout, level=LOG_CONFIG.level, diagnose=False, format=LOG_CONFIG.format) + + +def _access_fields(record: logging.LogRecord) -> dict[str, object] | None: + """ + Fields of a uvicorn access record, logged as ``'%s - "%s %s HTTP/%s" %d'`` + with args (client, method, target, version, status). + """ + if record.name != "uvicorn.access" or not isinstance(record.args, tuple) or len(record.args) != 5: + return None + client, method, target, version, status = record.args + return { + "client_address": client, + "http_method": method, + # Same rule as the spans: manage endpoints carry user tokens in the query. + "http_target": redact_url(str(target)), + "http_version": version, + "status_code": status, + } class LoguruHandler(logging.Handler): @@ -31,7 +81,14 @@ def emit(self, record: logging.LogRecord): frame = frame.f_back depth += 1 - LOGGER.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage()) + logger, message = LOGGER, None + if fields := _access_fields(record): + logger = LOGGER.bind(**fields) + message = ( + f'{fields["client_address"]} - "{fields["http_method"]} {fields["http_target"]} ' + f'HTTP/{fields["http_version"]}" {fields["status_code"]}' + ) + logger.opt(depth=depth, exception=record.exc_info).log(level, message or record.getMessage()) def setup_logging(): diff --git a/app/core/metrics.py b/app/core/metrics.py new file mode 100644 index 0000000..3636ce3 --- /dev/null +++ b/app/core/metrics.py @@ -0,0 +1,127 @@ +""" +Application metrics. Instruments are created against the global meter, which +forwards to the real provider once ``setup_telemetry`` has run and is a no-op +otherwise. + +Attribute values must stay low-cardinality: route templates, enum values and +fixed job names only; never user ids, IPs or tokens. +""" + +import time +from collections.abc import Callable, Iterator +from contextlib import contextmanager + +from opentelemetry import metrics +from starlette.requests import Request + +from app.core.telemetry import TRACER + +_meter = metrics.get_meter("wynnsource") + +_SECONDS_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60, 120] +_COUNT_BUCKETS = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000] + +SUBMISSIONS = _meter.create_counter( + "wcs.submissions", + unit="{submission}", + description="Submitted entries by module and outcome (accepted, rejected, unchanged).", +) +SUBMISSION_ITEMS = _meter.create_histogram( + "wcs.submission.items", + unit="{item}", + description="Entries per submission request.", + explicit_bucket_boundaries_advisory=_COUNT_BUCKETS, +) + +CONSENSUS_RUNS = _meter.create_counter( + "wcs.consensus.runs", + unit="{run}", + description="Consensus computations by kind and status.", +) +CONSENSUS_DURATION = _meter.create_histogram( + "wcs.consensus.duration", + unit="s", + description="Consensus computation time.", + explicit_bucket_boundaries_advisory=_SECONDS_BUCKETS, +) +CONSENSUS_PENDING = _meter.create_gauge( + "wcs.consensus.pending", + unit="{row}", + description="Rows flagged needs_recalc when a consensus run started.", +) + +JOB_RUNS = _meter.create_counter( + "wcs.job.runs", + unit="{run}", + description="Scheduler job executions by status (ok, error, skipped = another replica holds the lock).", +) +JOB_DURATION = _meter.create_histogram( + "wcs.job.duration", + unit="s", + description="Scheduler job execution time.", + explicit_bucket_boundaries_advisory=_SECONDS_BUCKETS, +) +JOB_LAST_SUCCESS = _meter.create_gauge( + "wcs.job.last_success", + unit="s", + description="Unix time of the last successful run of a scheduler job.", +) + +CACHE_REQUESTS = _meter.create_counter( + "wcs.cache.requests", + unit="{request}", + description="Response cache lookups by route and result (hit, miss).", +) +RATE_LIMITED = _meter.create_counter( + "wcs.rate_limited", + unit="{request}", + description="Requests rejected by the rate limiter, by route and key type (ip, user).", +) + + +def route_template(request: Request) -> str: + """The matched route's path template, e.g. ``/api/v2/pool/pools/{pool_type}/{region}``.""" + route = request.scope.get("route") + return getattr(route, "path", None) or "unmatched" + + +@contextmanager +def consensus_run(kind: str, attributes: dict[str, str] | None = None) -> Iterator[Callable[[int], int]]: + """ + Span ``consensus `` plus run/duration metrics around a consensus + computation. Yields ``record(n)``, to be called with the number of rows + that were flagged for recalculation; it returns ``n``. + """ + attrs = {"kind": kind, **(attributes or {})} + start = time.perf_counter() + status = "error" + span_attributes = {f"wcs.{k}": v for k, v in attrs.items()} + with TRACER.start_as_current_span(f"consensus {kind}", attributes=span_attributes) as span: + + def record(rows: int) -> int: + span.set_attribute("wcs.rows", rows) + CONSENSUS_PENDING.set(rows, attrs) + return rows + + try: + yield record + status = "ok" + finally: + CONSENSUS_RUNS.add(1, {**attrs, "status": status}) + CONSENSUS_DURATION.record(time.perf_counter() - start, attrs) + + +__all__ = [ + "CACHE_REQUESTS", + "CONSENSUS_DURATION", + "CONSENSUS_PENDING", + "CONSENSUS_RUNS", + "JOB_DURATION", + "JOB_LAST_SUCCESS", + "JOB_RUNS", + "RATE_LIMITED", + "SUBMISSIONS", + "SUBMISSION_ITEMS", + "consensus_run", + "route_template", +] diff --git a/app/core/rate_limiter/base.py b/app/core/rate_limiter/base.py index b97ba28..ae3d3f5 100644 --- a/app/core/rate_limiter/base.py +++ b/app/core/rate_limiter/base.py @@ -3,6 +3,8 @@ from fastapi import Request, Response +from app.core.metrics import RATE_LIMITED, route_template + type RateLimitKeyFunc = Callable[[Request], str] @@ -38,6 +40,12 @@ def __init__(self, limit: int, period: int, key_func: RateLimitKeyFunc = ip_base self.period = period self.key_func = key_func + def record_rejection(self, request: Request) -> None: + RATE_LIMITED.add( + 1, + {"route": route_template(request), "key": "user" if self.key_func is user_based_key_func else "ip"}, + ) + @abc.abstractmethod async def __call__(self, request: Request, response: Response) -> Response: pass diff --git a/app/core/rate_limiter/memory_rate_limiter.py b/app/core/rate_limiter/memory_rate_limiter.py index 30aff2e..4259cca 100644 --- a/app/core/rate_limiter/memory_rate_limiter.py +++ b/app/core/rate_limiter/memory_rate_limiter.py @@ -46,6 +46,7 @@ async def __call__(self, request: Request, response: Response) -> Response: response.headers["X-RateLimit-Reset"] = str(math.ceil(reset_after)) if estimated >= self.limit: + self.record_rejection(request) response.headers["X-RateLimit-Remaining"] = "0" response.headers["Retry-After"] = str(math.ceil(reset_after)) raise HTTPException( diff --git a/app/core/rate_limiter/redis_rate_limiter.py b/app/core/rate_limiter/redis_rate_limiter.py index 2881c65..31e42b2 100644 --- a/app/core/rate_limiter/redis_rate_limiter.py +++ b/app/core/rate_limiter/redis_rate_limiter.py @@ -74,6 +74,7 @@ async def __call__(self, request: Request, response: Response) -> Response: response.headers["X-RateLimit-Reset"] = str(reset_after) if remaining < 0: + self.record_rejection(request) response.headers["X-RateLimit-Remaining"] = "0" response.headers["Retry-After"] = str(reset_after) raise HTTPException( diff --git a/app/core/scheduler.py b/app/core/scheduler.py index ab47c1a..e682b32 100644 --- a/app/core/scheduler.py +++ b/app/core/scheduler.py @@ -1,17 +1,112 @@ -from apscheduler.events import EVENT_JOB_ERROR, JobExecutionEvent +import datetime +import os +import socket +import time +from collections.abc import Awaitable, Callable +from functools import wraps +from typing import Any + from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.base import BaseTrigger +from opentelemetry.instrumentation.utils import suppress_instrumentation +from opentelemetry.trace import Status, StatusCode +from app.core.db.redis import RedisClient +from app.core.log import LOGGER +from app.core.metrics import JOB_DURATION, JOB_LAST_SUCCESS, JOB_RUNS from app.core.sentry import sentry_enabled +from app.core.telemetry import TRACER SCHEDULER = AsyncIOScheduler() -if sentry_enabled(): - import sentry_sdk as sentry +# Identifies the lock holder; the pod name in Kubernetes. +_HOLDER = os.environ.get("K8S_POD_NAME") or socket.gethostname() + + +async def _acquire_lock(key: str, ttl: datetime.timedelta) -> bool: + """ + Claim ``key`` for ``ttl`` across replicas. The lock is never released early: + interval triggers are anchored to each replica's start time, so their fire + times are offset; releasing after the run would let the other replica run + the same period again. A TTL just under the period gives one run per period. + Fails open (runs the job) if Redis is unreachable. + """ + try: + # The lock is bookkeeping, not part of the job's trace. + with suppress_instrumentation(): + acquired = await RedisClient.get_instance().set( + f"wcs:job-lock:{key}", _HOLDER, nx=True, px=int(ttl.total_seconds() * 1000) + ) + except Exception as e: + LOGGER.warning(f"Job lock {key} unavailable, running without it: {e}") + return True + return bool(acquired) + + +def instrument_job[**P, R]( + job: str, + fn: Callable[P, Awaitable[R]], + *, + lock_ttl: datetime.timedelta | None = None, + lock_key: str | None = None, +) -> Callable[P, Awaitable[R | None]]: + """ + Wrap a scheduler job: optional cross-replica lock, a root span ``job ``, + run/duration/last-success metrics, and Sentry capture of failures. + + Jobs that only touch process-local state (in-memory caches, registering + local jobs) must run on every replica and take no lock. + """ + attributes = {"job": job} + + @wraps(fn) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | None: + if lock_ttl is not None and not await _acquire_lock(lock_key or job, lock_ttl): + JOB_RUNS.add(1, {**attributes, "status": "skipped"}) + return None + + start = time.perf_counter() + with TRACER.start_as_current_span( + f"job {job}", attributes={"wcs.job": job}, record_exception=False, set_status_on_exception=False + ) as span: + try: + result = await fn(*args, **kwargs) + except Exception as e: + span.record_exception(e) + span.set_status(Status(StatusCode.ERROR, type(e).__name__)) + JOB_RUNS.add(1, {**attributes, "status": "error"}) + if sentry_enabled(): + import sentry_sdk as sentry + + sentry.capture_exception(e) + raise + finally: + JOB_DURATION.record(time.perf_counter() - start, attributes) + + JOB_RUNS.add(1, {**attributes, "status": "ok"}) + JOB_LAST_SUCCESS.set(time.time(), attributes) + return result + + return wrapper + + +def scheduled_job[**P, R]( + trigger: BaseTrigger, + *, + id: str, + lock_ttl: datetime.timedelta | None = None, + **job_kwargs: Any, +) -> Callable[[Callable[P, Awaitable[R]]], Callable[P, Awaitable[R]]]: + """ + Register an instrumented job and return the original function unchanged, + so direct callers (e.g. the manual recalc endpoints) bypass the lock. + """ - def sentry_listener(event: JobExecutionEvent): - sentry.capture_exception(event.exception) + def decorator(fn: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: + SCHEDULER.add_job(instrument_job(id, fn, lock_ttl=lock_ttl), trigger, id=id, **job_kwargs) + return fn - SCHEDULER.add_listener(sentry_listener, EVENT_JOB_ERROR) + return decorator -__all__ = ["SCHEDULER"] +__all__ = ["SCHEDULER", "instrument_job", "scheduled_job"] diff --git a/app/core/score.py b/app/core/score.py index 9190def..fa94c7d 100644 --- a/app/core/score.py +++ b/app/core/score.py @@ -5,8 +5,9 @@ from typing import TYPE_CHECKING from apscheduler.triggers.cron import CronTrigger +from opentelemetry import trace -from app.core.scheduler import SCHEDULER +from app.core.scheduler import scheduled_job if TYPE_CHECKING: from app.module.pool.model import Pool @@ -97,9 +98,10 @@ def score_to_next_tier(self, score: int) -> int: return next_tier.score_range.min - score -@SCHEDULER.scheduled_job( +@scheduled_job( CronTrigger(hour=0, minute=0), # Run daily at midnight id="update_user_scores", + lock_ttl=datetime.timedelta(minutes=30), misfire_grace_time=60, coalesce=True, ) @@ -160,6 +162,7 @@ async def update_user_scores(): f"User {user.id}: quality={quality:.3f}, activity={activity:.3f}, delta={delta}, new_score={new_score}" ) + trace.get_current_span().set_attributes({"wcs.pools": len(all_current_pools), "wcs.users": len(users)}) LOGGER.info(f"Updated scores for {len(users)} users") diff --git a/app/core/sentry.py b/app/core/sentry.py index dbfb8dd..5f5aa86 100644 --- a/app/core/sentry.py +++ b/app/core/sentry.py @@ -1,10 +1,12 @@ import logging import sentry_sdk as sentry +import sentry_sdk.profiler as sentry_profiler from fastapi import HTTPException from sentry_sdk.integrations.asyncio import AsyncioIntegration from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.loguru import LoguruIntegration +from sentry_sdk.integrations.otlp import OTLPIntegration from sentry_sdk.integrations.redis import RedisIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.types import Event, Hint @@ -34,8 +36,10 @@ def init_sentry() -> bool: dsn=SENTRY_CONFIG.dsn, environment=SENTRY_CONFIG.environment, release=__VERSION__, - traces_sample_rate=SENTRY_CONFIG.traces_sample_rate, - profiles_sample_rate=SENTRY_CONFIG.profiles_sample_rate, + # No traces_sample_rate: tracing is OpenTelemetry's. Errors and logs + # carry the active OTel trace id through OTLPIntegration. + profile_session_sample_rate=SENTRY_CONFIG.profile_session_sample_rate, + profile_lifecycle="manual", send_default_pii=False, before_send=filter_http_exception, integrations=[ @@ -43,6 +47,10 @@ def init_sentry() -> bool: LoguruIntegration(event_level=logging.CRITICAL), SqlalchemyIntegration(), RedisIntegration(), + # Spans are exported by app.core.telemetry, and the default + # Sentry propagator only speaks sentry-trace, which would break + # continuation of Cloudflare's W3C traceparent. + OTLPIntegration(setup_otlp_traces_exporter=False, setup_propagator=False), ], disabled_integrations=[LoggingIntegration()], enable_logs=True, @@ -50,7 +58,20 @@ def init_sentry() -> bool: return True +def start_profiling() -> None: + """Start continuous profiling; a no-op unless this process was sampled by profile_session_sample_rate.""" + if sentry_enabled(): + sentry_profiler.start_profiler() + + +def stop_profiling() -> None: + if sentry_enabled(): + sentry_profiler.stop_profiler() + + __all__ = [ "init_sentry", "sentry_enabled", + "start_profiling", + "stop_profiling", ] diff --git a/app/core/telemetry.py b/app/core/telemetry.py new file mode 100644 index 0000000..71975e3 --- /dev/null +++ b/app/core/telemetry.py @@ -0,0 +1,127 @@ +import os +import uuid +from typing import Any + +from fastapi import FastAPI +from opentelemetry import metrics, trace +from opentelemetry.exporter.otlp.proto.http.metric_exporter import OTLPMetricExporter +from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor +from opentelemetry.instrumentation.redis import RedisInstrumentor +from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor +from opentelemetry.instrumentation.system_metrics import SystemMetricsInstrumentor +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader +from opentelemetry.sdk.metrics.view import DropAggregation, View +from opentelemetry.sdk.resources import SERVICE_INSTANCE_ID, SERVICE_NAME, SERVICE_VERSION, Resource +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.trace import Span +from sqlalchemy.ext.asyncio import AsyncEngine + +from app.config import TELEMETRY_CONFIG +from app.schemas.constants import __VERSION__ +from app.utils.redact import redact_query, redact_url + +TRACER = trace.get_tracer("wynnsource") + +# Health probes would otherwise produce a root trace every few seconds. +_EXCLUDED_URLS = "/healthz,/readyz" + +# Process-level runtime metrics only; host metrics come from node-exporter. +_SYSTEM_METRICS: dict[str, list[str] | None] = { + "process.cpu.time": ["user", "system"], + "process.memory.usage": None, + "process.memory.virtual": None, + "process.thread.count": None, + "cpython.gc.collections": None, +} + +_tracer_provider: TracerProvider | None = None +_meter_provider: MeterProvider | None = None + + +def _server_request_hook(span: Span, scope: dict[str, Any]) -> None: + if not span.is_recording() or not scope.get("query_string"): + return + attributes = getattr(span, "attributes", None) or {} + for key in ("http.target", "http.url", "url.full"): + if isinstance(value := attributes.get(key), str): + span.set_attribute(key, redact_url(value)) + if isinstance(value := attributes.get("url.query"), str): + span.set_attribute("url.query", redact_query(value)) + + +def _resource() -> Resource: + # Resource.create() merges OTEL_RESOURCE_ATTRIBUTES and OTEL_SERVICE_NAME on top of these. + resource = Resource.create({SERVICE_NAME: "wynnsource-server", SERVICE_VERSION: __VERSION__}) + if SERVICE_INSTANCE_ID not in resource.attributes: + # Every replica pushes its own cumulative counters; without a unique + # instance id two pods would overwrite each other's series. + resource = resource.merge(Resource({SERVICE_INSTANCE_ID: str(uuid.uuid4())})) + return resource + + +def setup_telemetry(app: FastAPI) -> bool: + """ + Configure OpenTelemetry tracing and metrics, exported over OTLP/HTTP to + ``OTEL_EXPORTER_OTLP_ENDPOINT``. No-op when the endpoint is unset. + + The sampler is the SDK default (``OTEL_TRACES_SAMPLER``, default + ``parentbased_always_on``): requests follow the caller's decision, jobs + and other root spans are always recorded. + """ + global _tracer_provider, _meter_provider + if not TELEMETRY_CONFIG.enabled: + return False + + # Stable HTTP semantic conventions (http.route, url.path, ...), matching Traefik's spans. + os.environ.setdefault("OTEL_SEMCONV_STABILITY_OPT_IN", "http") + + resource = _resource() + + _tracer_provider = TracerProvider(resource=resource) + _tracer_provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + trace.set_tracer_provider(_tracer_provider) + + _meter_provider = MeterProvider( + resource=resource, + metric_readers=[PeriodicExportingMetricReader(OTLPMetricExporter())], + # HTTP RED metrics come from Tempo span metrics and Traefik. + views=[View(instrument_name="http.server.*", aggregation=DropAggregation())], + ) + metrics.set_meter_provider(_meter_provider) + + FastAPIInstrumentor.instrument_app( + app, + excluded_urls=_EXCLUDED_URLS, + server_request_hook=_server_request_hook, + exclude_spans=["receive", "send"], + ) + RedisInstrumentor().instrument() + HTTPXClientInstrumentor().instrument() + SystemMetricsInstrumentor(config=_SYSTEM_METRICS).instrument() + return True + + +def instrument_engine(engine: AsyncEngine) -> None: + if _tracer_provider is None: + return + SQLAlchemyInstrumentor().instrument(engine=engine.sync_engine, enable_commenter=False) + + +def shutdown_telemetry() -> None: + """Flush pending spans and metrics; called on shutdown so rolling updates don't lose the last batch.""" + if _tracer_provider is not None: + _tracer_provider.shutdown() + if _meter_provider is not None: + _meter_provider.shutdown() + + +__all__ = [ + "TRACER", + "instrument_engine", + "setup_telemetry", + "shutdown_telemetry", +] diff --git a/app/main.py b/app/main.py index 71c0965..34e54cc 100644 --- a/app/main.py +++ b/app/main.py @@ -9,7 +9,8 @@ from app.core.db import RedisClient, close_db, init_db from app.core.openapi import custom_openapi from app.core.scheduler import SCHEDULER -from app.core.sentry import init_sentry +from app.core.sentry import init_sentry, start_profiling, stop_profiling +from app.core.telemetry import setup_telemetry, shutdown_telemetry from app.module.api.exception_handler import ( generic_exception_handler, http_exception_handler, @@ -28,6 +29,7 @@ async def lifespan(app: FastAPI): Lifespan context manager for application. """ try: + start_profiling() SCHEDULER.start() await init_db() if DB_CONFIG.redis_dsn is not None: @@ -38,6 +40,8 @@ async def lifespan(app: FastAPI): await close_db() if DB_CONFIG.redis_dsn is not None: await RedisClient.close() + stop_profiling() + shutdown_telemetry() app = FastAPI( @@ -53,6 +57,7 @@ async def lifespan(app: FastAPI): "url": "https://www.gnu.org/licenses/agpl-3.0.html", }, ) +setup_telemetry(app) app.include_router(Router) app.exception_handler(HTTPException)(http_exception_handler) diff --git a/app/module/api/service.py b/app/module/api/service.py index bbf54fd..95939e6 100644 --- a/app/module/api/service.py +++ b/app/module/api/service.py @@ -7,7 +7,7 @@ from apscheduler.triggers.interval import IntervalTrigger from app.core.log import LOGGER -from app.core.scheduler import SCHEDULER +from app.core.scheduler import scheduled_job from wynnsource import WynnSourceItem from wynnsource.common.enums_pb2 import RARITY_CRAFTED @@ -73,7 +73,8 @@ async def update_mapping(self, mapping_type: MappingType): self.mappings[mapping_type] = mapping -@SCHEDULER.scheduled_job( +# Refreshes this replica's in-memory MappingStorage, so it runs everywhere (no lock). +@scheduled_job( IntervalTrigger(minutes=60), id="mapping_update_trigger", name="Mapping Update", diff --git a/app/module/beta/service.py b/app/module/beta/service.py index 8a711c3..2e67a91 100644 --- a/app/module/beta/service.py +++ b/app/module/beta/service.py @@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.log import LOGGER +from app.core.metrics import SUBMISSION_ITEMS, SUBMISSIONS from wynnsource import WynnSourceItem from .config import BETA_CONFIG @@ -13,8 +14,10 @@ async def handle_item_submission(submission: NewItemSubmission, session: AsyncSession) -> None: + SUBMISSION_ITEMS.record(len(submission.items), {"module": "beta"}) if not any(version in submission.mod_version for version in allowed_version): LOGGER.debug(f"Submission version {submission.mod_version} is not allowed, skipping submission") + SUBMISSIONS.add(len(submission.items), {"module": "beta", "result": "rejected"}) return itemRepo = BetaItemRepository(session) succeeds = 0 @@ -27,6 +30,7 @@ async def handle_item_submission(submission: NewItemSubmission, session: AsyncSe existing = WynnSourceItem.FromString(existing.item) if existing else None if existing and item == existing: LOGGER.debug(f"Item from submission is identical to existing item: {item.name}") + SUBMISSIONS.add(1, {"module": "beta", "result": "unchanged"}) continue if existing and item != existing: LOGGER.debug(f"Item from submission is different from existing item: {item.name}," + " overwriting") @@ -34,10 +38,11 @@ async def handle_item_submission(submission: NewItemSubmission, session: AsyncSe item.gear.powders.extend(existing.gear.powders) await itemRepo.add_item(item) succeeds += 1 + SUBMISSIONS.add(1, {"module": "beta", "result": "accepted"}) except Exception as e: LOGGER.debug(f"Failed to add item from submission, error: {e}") # Silently ignore failed items - pass + SUBMISSIONS.add(1, {"module": "beta", "result": "rejected"}) LOGGER.info(f"Processed {succeeds}/{len(submission.items)} items from beta submission") @@ -71,8 +76,10 @@ async def get_beta_ingredients_by_name(session: AsyncSession, name: list[str]) - async def handle_patch_submission(submission: ItemPatchSubmission, session: AsyncSession) -> None: + SUBMISSION_ITEMS.record(len(submission.items), {"module": "beta_patch"}) if not any(version in submission.mod_version for version in allowed_version): LOGGER.debug(f"Submission version {submission.mod_version} is not allowed, skipping submission") + SUBMISSIONS.add(len(submission.items), {"module": "beta_patch", "result": "rejected"}) return itemRepo = BetaItemRepository(session) succeeds = 0 @@ -84,16 +91,18 @@ async def handle_patch_submission(submission: ItemPatchSubmission, session: Asyn existing = await itemRepo.get_item(item.name) if not existing: LOGGER.debug(f"Item from patch submission does not exist in beta: {item.name}") + SUBMISSIONS.add(1, {"module": "beta_patch", "result": "rejected"}) continue existing_item = WynnSourceItem.FromString(existing.item) del existing_item.gear.powders[:] existing_item.gear.powders.extend(item.gear.powders) await itemRepo.add_item(existing_item) succeeds += 1 + SUBMISSIONS.add(1, {"module": "beta_patch", "result": "accepted"}) except Exception as e: LOGGER.debug(f"Failed to patch item from submission, error: {e}") # Silently ignore failed items - pass + SUBMISSIONS.add(1, {"module": "beta_patch", "result": "rejected"}) LOGGER.info(f"Processed {succeeds}/{len(submission.items)} items from beta patch submission") diff --git a/app/module/pool/router.py b/app/module/pool/router.py index 378d529..08c1967 100644 --- a/app/module/pool/router.py +++ b/app/module/pool/router.py @@ -5,6 +5,7 @@ from app.core import metadata from app.core.db import SessionDep, get_session +from app.core.metrics import SUBMISSION_ITEMS, SUBMISSIONS from app.core.rate_limiter import ip_based_key_func, user_based_key_func from app.core.router import DocedAPIRoute from app.core.security.auth import UserDep @@ -31,6 +32,7 @@ async def submit_pool_data(data: list[PoolSubmissionSchema], user: UserDep) -> E """ Endpoint for clients to submit pool data. """ + SUBMISSION_ITEMS.record(len(data), {"module": "pool"}) for submission in data: try: # We want to process each submission @@ -38,7 +40,9 @@ async def submit_pool_data(data: list[PoolSubmissionSchema], user: UserDep) -> E async with get_session() as session: await svc_submit_pool_data(session, submission, user) except ValueError: + SUBMISSIONS.add(1, {"module": "pool", "result": "rejected"}) continue + SUBMISSIONS.add(1, {"module": "pool", "result": "accepted"}) return EMPTY_RESPONSE @@ -56,9 +60,7 @@ async def get_pools_by_type_and_region( Get pools by type and region. """ try: - rotation = POOL_REFRESH_CONFIG[pool_type].get_rotation( - datetime.datetime.now(tz=datetime.UTC) - ) + rotation = POOL_REFRESH_CONFIG[pool_type].get_rotation(datetime.datetime.now(tz=datetime.UTC)) consensus_by_page = await get_pool_consensus( session, pool_type, diff --git a/app/module/pool/service.py b/app/module/pool/service.py index 2a2291d..e767b87 100644 --- a/app/module/pool/service.py +++ b/app/module/pool/service.py @@ -8,7 +8,8 @@ from app.core.db import get_session from app.core.log import LOGGER -from app.core.scheduler import SCHEDULER +from app.core.metrics import consensus_run +from app.core.scheduler import SCHEDULER, instrument_job, scheduled_job from app.core.score import Tier from app.core.security.model import User from wynnsource import WynnSourceItem @@ -100,9 +101,10 @@ def calculate_submission_weight(user: User, fuzzy: bool = False) -> float: return weight * (0.5 if fuzzy else 1.0) -@SCHEDULER.scheduled_job( +@scheduled_job( IntervalTrigger(minutes=20), id="compute_pool_consensus", + lock_ttl=datetime.timedelta(minutes=18), misfire_grace_time=60, coalesce=True, # Coalesce multiple missed executions into one ) @@ -114,6 +116,7 @@ async def compute_pool_consensus() -> int: BOOST_INTERVAL = datetime.timedelta(minutes=2) +BOOST_LOCK_TTL = BOOST_INTERVAL - datetime.timedelta(seconds=20) BOOST_LEAD = datetime.timedelta(minutes=30) # reset 前多久开始 BOOST_TAIL = datetime.timedelta(minutes=30) # reset 后多久结束 @@ -122,7 +125,9 @@ def _boost_job_id(pool_type: PoolType) -> str: return f"compute_pool_consensus_boost:{pool_type.value}" -@SCHEDULER.scheduled_job( +# Registers this replica's local boost jobs, so it runs everywhere (no lock); +# the boost jobs themselves write to the DB and are locked. +@scheduled_job( CronTrigger(hour=0, minute=5), id="schedule_pool_boosts", misfire_grace_time=600, @@ -148,7 +153,12 @@ async def _schedule_boost_for_pool(pool_type: PoolType) -> None: boost_end = reset_at + BOOST_TAIL SCHEDULER.add_job( - compute_pool_consensus_for_pool, + instrument_job( + "compute_pool_consensus_boost", + compute_pool_consensus_for_pool, + lock_ttl=BOOST_LOCK_TTL, + lock_key=_boost_job_id(pool_type), + ), args=[pool_type], trigger=IntervalTrigger( minutes=int(BOOST_INTERVAL.total_seconds() // 60), @@ -164,6 +174,11 @@ async def _schedule_boost_for_pool(pool_type: PoolType) -> None: async def compute_pool_consensus_for_pool(pool_type: PoolType) -> int: + with consensus_run("pool", {"pool_type": pool_type.value}) as record: + return record(await _compute_pool_consensus_for_pool(pool_type)) + + +async def _compute_pool_consensus_for_pool(pool_type: PoolType) -> int: async with get_session() as session: # Step 1: Fetch all active pools that need consensus computation poolRepo = PoolRepository(session) diff --git a/app/module/raid/router.py b/app/module/raid/router.py index 3af4d02..ed66766 100644 --- a/app/module/raid/router.py +++ b/app/module/raid/router.py @@ -5,6 +5,7 @@ from app.core import metadata from app.core.db import SessionDep, get_session +from app.core.metrics import SUBMISSION_ITEMS, SUBMISSIONS from app.core.rate_limiter import ip_based_key_func, user_based_key_func from app.core.router import DocedAPIRoute from app.core.security.auth import UserDep @@ -36,12 +37,15 @@ async def submit_gambit_data(data: list[GambitSubmissionSchema], user: UserDep) Submit gambit data for the current rotation. Partial submissions are supported (1-4 gambits per region). """ + SUBMISSION_ITEMS.record(len(data), {"module": "gambit"}) for submission in data: try: async with get_session() as session: await svc_submit_gambit_data(session, submission, user) except ValueError: + SUBMISSIONS.add(1, {"module": "gambit", "result": "rejected"}) continue + SUBMISSIONS.add(1, {"module": "gambit", "result": "accepted"}) return EMPTY_RESPONSE diff --git a/app/module/raid/service.py b/app/module/raid/service.py index b3df519..045f0b8 100644 --- a/app/module/raid/service.py +++ b/app/module/raid/service.py @@ -5,7 +5,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.core.db import get_session -from app.core.scheduler import SCHEDULER +from app.core.metrics import consensus_run +from app.core.scheduler import scheduled_job from app.core.security.model import User from app.module.pool.service import calculate_submission_weight @@ -53,13 +54,19 @@ async def submit_gambit_data(session: AsyncSession, data: GambitSubmissionSchema await submission_repo.save(submission) -@SCHEDULER.scheduled_job( +@scheduled_job( IntervalTrigger(minutes=5), id="compute_gambit_consensus", + lock_ttl=datetime.timedelta(seconds=270), misfire_grace_time=60, coalesce=True, ) async def compute_gambit_consensus() -> int: + with consensus_run("gambit") as record: + return record(await _compute_gambit_consensus()) + + +async def _compute_gambit_consensus() -> int: async with get_session() as session: gambit_repo = GambitRepository(session) rotation = get_gambit_rotation(datetime.datetime.now(tz=datetime.UTC)) diff --git a/app/utils/redact.py b/app/utils/redact.py new file mode 100644 index 0000000..2a57204 --- /dev/null +++ b/app/utils/redact.py @@ -0,0 +1,15 @@ +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + + +def redact_query(query: str) -> str: + """Keep parameter names, drop values: manage endpoints carry user tokens in the query.""" + return urlencode([(k, "REDACTED") for k, _ in parse_qsl(query, keep_blank_values=True)], safe="") + + +def redact_url(value: str) -> str: + """``redact_query`` applied to the query part of a URL or request target.""" + parts = urlsplit(value) + return urlunsplit(parts._replace(query=redact_query(parts.query))) if parts.query else value + + +__all__ = ["redact_query", "redact_url"] diff --git a/deploy/chart/templates/deployment.yaml b/deploy/chart/templates/deployment.yaml index 0919cd1..a7fab24 100644 --- a/deploy/chart/templates/deployment.yaml +++ b/deploy/chart/templates/deployment.yaml @@ -35,6 +35,24 @@ spec: - secretRef: name: {{ .Values.existingSecret }} env: + # Downward API first: later entries reference them via $(...). + # K8S_POD_NAME also identifies the holder of scheduler job locks. + - name: K8S_POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: K8S_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + {{- with .Values.telemetry }} + {{- if .otlpEndpoint }} + - name: OTEL_EXPORTER_OTLP_ENDPOINT + value: {{ .otlpEndpoint | quote }} + - name: OTEL_RESOURCE_ATTRIBUTES + value: "service.instance.id=$(K8S_POD_NAME),k8s.pod.name=$(K8S_POD_NAME),k8s.namespace.name=$(K8S_NAMESPACE){{ with .environment }},deployment.environment.name={{ . }}{{ end }}" + {{- end }} + {{- end }} - name: POSTGRES_HOST valueFrom: secretKeyRef: diff --git a/deploy/chart/values.yaml b/deploy/chart/values.yaml index 4843b52..ec32acc 100644 --- a/deploy/chart/values.yaml +++ b/deploy/chart/values.yaml @@ -16,11 +16,22 @@ revisionHistoryLimit: 3 config: LEVEL: INFO MAX_IP_RECORDS: "10" + # One JSON object per log line (trace_id/span_id included) for the log pipeline. + LOG_JSON: "true" # Extra environment variables appended to the server container -# (e.g. sentry environment / sample rates / version suffix). +# (e.g. sentry environment / profiling rate / version suffix). extraEnv: [] +# OpenTelemetry traces and metrics, pushed over OTLP/HTTP. Off while +# otlpEndpoint is empty. Each pod reports service.instance.id = its pod name, +# so replicas never share a metric series. +telemetry: + # e.g. http://otel-collector.monitoring.svc:4318 + otlpEndpoint: "" + # deployment.environment.name resource attribute (e.g. prod, dev). + environment: "" + # Secret consumed via envFrom; managed outside the chart (SealedSecret etc.). existingSecret: wynnsource-secrets diff --git a/pyproject.toml b/pyproject.toml index 1decf6a..eb93e66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wynnsource_server" -version = "0.1.5" +version = "0.1.7" description = "The server component of WynnSource - a Wynncraft Crowdsourcing Mod." readme = "README.md" requires-python = ">=3.12, <4.0" @@ -21,6 +21,13 @@ dependencies = [ "httpx>=0.28.1", "jsonschema>=4.26.0", "loguru>=0.7.3", + "opentelemetry-exporter-otlp-proto-http>=1.45.1", + "opentelemetry-instrumentation-fastapi>=0.66b1", + "opentelemetry-instrumentation-httpx>=0.66b1", + "opentelemetry-instrumentation-redis>=0.66b1", + "opentelemetry-instrumentation-sqlalchemy>=0.66b1", + "opentelemetry-instrumentation-system-metrics>=0.66b1", + "opentelemetry-sdk>=1.45.1", "orjson>=3.11.7", "pydantic-settings>=2.10.1", "redis>=7.1.1", @@ -77,6 +84,9 @@ known-first-party = ["app", "tests/*", "wynnsource"] [tool.pytest.ini_options] addopts = "-n 4 --cov=app --cov-report xml:cov.xml" +# Tests import `app` from the repo root; plain `pytest` would otherwise only +# put each test file's directory on sys.path. +pythonpath = ["."] [tool.uv.workspace] members = ["generated"] diff --git a/uv.lock b/uv.lock index 117d8f2..ec6b48b 100644 --- a/uv.lock +++ b/uv.lock @@ -70,6 +70,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, ] +[[package]] +name = "asgiref" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/26/3b59f2bdae5f640389becb1f673cded775287f5fc4f816309d9ca9a3f93d/asgiref-3.12.1.tar.gz", hash = "sha256:59dcb51c272ad209d59bed5708a64a333083e86017d7fcdd67498eeab7784340", size = 42378, upload-time = "2026-07-14T09:56:18.087Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/1b/54f4ad77cd8a584fa70746c47df988e002cf1ee1eba43364d46f87803647/asgiref-3.12.1-py3-none-any.whl", hash = "sha256:fe386d1c2bff7259ea95929266d12a8cf9a8b5a1c2598402967d8792e7a7c094", size = 25478, upload-time = "2026-07-14T09:56:16.926Z" }, +] + [[package]] name = "asyncpg" version = "0.31.0" @@ -140,6 +149,137 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -411,6 +551,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" }, ] +[[package]] +name = "googleapis-common-protos" +version = "1.75.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/2b/6ce81972d5c8cab9705fddce3153be63222d9e12fd96f8baba5038a744dd/googleapis_common_protos-1.75.5.tar.gz", hash = "sha256:c7a866fc34ed29a3b10af627a4b9b1dc2433313ca6e959f0ae4feb132047ed72", size = 156513, upload-time = "2026-09-29T19:26:14.863Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/b9/6b29500a1c581ff4d77fd83c6568d068bee06f1b139fb6eb0a4f2d4bce8a/googleapis_common_protos-1.75.5-py3-none-any.whl", hash = "sha256:d7285525c23039db98f2463e6d5a4f9b958b94d497f03a844ece3259c4e72d5d", size = 307737, upload-time = "2026-09-29T19:25:48.735Z" }, +] + [[package]] name = "greenlet" version = "3.3.2" @@ -420,7 +572,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" }, { url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" }, { url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" }, - { url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" }, { url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" }, { url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" }, { url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" }, @@ -429,7 +580,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, - { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, @@ -438,7 +588,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -447,7 +596,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, - { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -702,6 +850,236 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/c4/7532325f968ecfc078e8a028e69a52e4c3f95fb800906bf6931ac1e89e2b/nodejs_wheel_binaries-24.13.1-py2.py3-none-win_arm64.whl", hash = "sha256:caec398cb9e94c560bacdcba56b3828df22a355749eb291f47431af88cbf26dc", size = 38881194, upload-time = "2026-02-12T17:31:00.214Z" }, ] +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" }, +] + +[[package]] +name = "opentelemetry-exporter-http-transport" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/0c/e3ebdb4b507f66afcc905e6885a4946969bd75b45988492643356fbbdc63/opentelemetry_exporter_http_transport-0.66b1.tar.gz", hash = "sha256:443080203bf52586ce0b2ad901e8951c61833eab1aa539ae6f1f16fe9e8e7952", size = 11693, upload-time = "2026-10-06T17:32:59.65Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/69/6af86ff66492b481c6a4c05dcfd68beb47ed8ba046440a26a2aac76b95c7/opentelemetry_exporter_http_transport-0.66b1-py3-none-any.whl", hash = "sha256:2f95404bdee7f9d2d529c7de56c7bd86d014d774d8fbf137810e0167f8a492bf", size = 12155, upload-time = "2026-10-06T17:32:35.454Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-common" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/19/41de712173f43057e4532d42ece7d0c6d4210d353e5752433cb14987643f/opentelemetry_exporter_otlp_common-0.66b1.tar.gz", hash = "sha256:6b1403487a2185ac1feb45fd5546fdf8630ce71c36bcefaadf51e2130e9e23f9", size = 14325, upload-time = "2026-10-06T17:33:01.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/39/8c23d67665c762aa51840fa06f86e902e8f6f1693bc8d7e3d98cd6e2f753/opentelemetry_exporter_otlp_common-0.66b1-py3-none-any.whl", hash = "sha256:00ff8592c3a7cb729ff3fdc7ffa12372c243bdf2163e80c180994d0c7bd83ee9", size = 12385, upload-time = "2026-10-06T17:32:38.177Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-common" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-proto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/8e/65e85e5137991a3c493b11682151d198638a5bc1dd4b4c5f67e013c57d7c/opentelemetry_exporter_otlp_proto_common-1.45.1.tar.gz", hash = "sha256:2e4adcc3a67bcf57804fc49514f0ef64974ca7590aa3491da389852b4a0628f6", size = 18873, upload-time = "2026-10-06T17:33:04.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/aa/92f225d353904e7f70b8b3e3c1b02db0cf56f744c2e83c581dc372e78873/opentelemetry_exporter_otlp_proto_common-1.45.1-py3-none-any.whl", hash = "sha256:2f446183ae7047b036226f1d846c41a834b0e8755ad13b51a51dd38952eb466c", size = 15393, upload-time = "2026-10-06T17:32:41.911Z" }, +] + +[[package]] +name = "opentelemetry-exporter-otlp-proto-http" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "googleapis-common-protos" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-exporter-http-transport", extra = ["requests"] }, + { name = "opentelemetry-exporter-otlp-common" }, + { name = "opentelemetry-exporter-otlp-proto-common" }, + { name = "opentelemetry-proto" }, + { name = "opentelemetry-sdk" }, + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1b/17/26487707ea4caa97b17e6e4b5fa72133a53512ffa2f5cf7a49ef284b29cb/opentelemetry_exporter_otlp_proto_http-1.45.1.tar.gz", hash = "sha256:45c218405ce3fd879596924b1874bf9a8f6880206d61065c5a912c8e5c297fb7", size = 28839, upload-time = "2026-10-06T17:33:05.713Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/1f/517eaa0187ba106a9da97160ce2add3a371812681dc440930b267f714e42/opentelemetry_exporter_otlp_proto_http-1.45.1-py3-none-any.whl", hash = "sha256:24a97cf3753c7fb52fad44a696e452ff371686339e2acf3309e2eda3d0230700", size = 22180, upload-time = "2026-10-06T17:32:43.946Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/03/89e47ff8d52a4f83b343e6eb9ef1698ff45357216e5b6b2b21e0da5c5c7d/opentelemetry_instrumentation-0.66b1.tar.gz", hash = "sha256:e79a510f7d87c72d95e964ddb42193a0d9a75668c027d980eab032ea1322a5ce", size = 43308, upload-time = "2026-10-06T17:36:10.703Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/b2/d1413681ff43e13ac9860df27e1226d3199ab0b97b352ceea41abcc660a5/opentelemetry_instrumentation-0.66b1-py3-none-any.whl", hash = "sha256:4c4aa14dc9a24a02325a9d4c42c4d0208dbb1374c2b1b8fe6c9392d59f3e1008", size = 36904, upload-time = "2026-10-06T17:35:11.663Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-asgi" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d9/ff522f5c3e340e9007554923b1a4d2ac451676f8757bafb3d0057f68b5c3/opentelemetry_instrumentation_asgi-0.66b1.tar.gz", hash = "sha256:78cdc5e45e897e16a8dac9d282e8d5bdf9af2d58e1313fa0bdd4a134c6f9dafc", size = 25992, upload-time = "2026-10-06T17:36:14.593Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/ea/10ba99110bf3c9fb736af39c96ca8f3668b988cabb6b59309e058c44461c/opentelemetry_instrumentation_asgi-0.66b1-py3-none-any.whl", hash = "sha256:78b3f9bdf0fa38c65935a2ab46d59e0f9de873a51e0c95b0329f106e2ccb5274", size = 15812, upload-time = "2026-10-06T17:35:17.638Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-fastapi" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-instrumentation-asgi" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/2a/cd4125b7acbea2ed17f1d31b58c184cb0a79fcb5541ceb4de90ffc6d8c01/opentelemetry_instrumentation_fastapi-0.66b1.tar.gz", hash = "sha256:584cf9d2c4417ff8b2d6ff2bc606bfe13c8b3456018bf94f50f2cf658492505b", size = 26634, upload-time = "2026-10-06T17:36:25.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/70/676928d537978acc7bff2ac8657bd0836ba608ffc8f65455238f1fa2bd0f/opentelemetry_instrumentation_fastapi-0.66b1-py3-none-any.whl", hash = "sha256:97f8ac8fd7537517f9e6988bd0aca04bfa5aad564bcd46c245530739e2be72d1", size = 14216, upload-time = "2026-10-06T17:35:32.827Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-httpx" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "opentelemetry-util-http" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/50/41544799b043d14fdfa6fe62fa2518eaba22793fce03e9abde930b92e671/opentelemetry_instrumentation_httpx-0.66b1.tar.gz", hash = "sha256:5865a72c68098c85955a271ab8744b480a36e3ee492d35b8cadb93c7c4dbb618", size = 26637, upload-time = "2026-10-06T17:36:27.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/c6/e5682b1bfb320b32505e88255c34ae1e99fe9fb220cc465c244e91967eac/opentelemetry_instrumentation_httpx-0.66b1-py3-none-any.whl", hash = "sha256:0342a4002c6dbc6c4bf22cc7e698f50f5c8b77f63325c6f40c94ab87e016bf4d", size = 17723, upload-time = "2026-10-06T17:35:36.501Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-redis" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/f7/8a7f4d2178a79b4cca971836491d75884b8f2b3fcd0dc9a3d521ca65e7bd/opentelemetry_instrumentation_redis-0.66b1.tar.gz", hash = "sha256:d6cc6aa473e23692ae1be9011e2cb3ae5894376158533a71f9710e1fb0906dc7", size = 18060, upload-time = "2026-10-06T17:36:36.289Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/22/19286da994ae57c78ecc3f4bd692855ccedc54cd9b05ce56592ee28964c3/opentelemetry_instrumentation_redis-0.66b1-py3-none-any.whl", hash = "sha256:4e36651d553e63c8e8f4068d8a2362ae35ac5c9bbef53c27a0fcb96d9092dbb1", size = 14885, upload-time = "2026-10-06T17:35:50.394Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-sqlalchemy" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/3e/d69fb08dacc4c248daedf7357732ddca7a0aaff7072a55e311d3da3ea51c/opentelemetry_instrumentation_sqlalchemy-0.66b1.tar.gz", hash = "sha256:a10043953fcba71911bf29a024f8cc337260c1ef0b4fc844b96cae0de0947baa", size = 18181, upload-time = "2026-10-06T17:36:38.111Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/05/f8cff0c68a7f9ab8fab01904be5a49c5214435109c2a6b6d173a974c10d9/opentelemetry_instrumentation_sqlalchemy-0.66b1-py3-none-any.whl", hash = "sha256:aa30b10d880d7e91cf94b23a92ac85cec09ffddd8f0d40256d7c510e3dd33971", size = 14425, upload-time = "2026-10-06T17:35:53.436Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-system-metrics" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "psutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d6/81/ab8cabaf45c5248fa88a0d0d3acea4483c105d76cd6c849654ebdab16f92/opentelemetry_instrumentation_system_metrics-0.66b1.tar.gz", hash = "sha256:b0fccbc1aecf35c182431a035ec4809b8f9607eec692243cf70f23b3bda51231", size = 17207, upload-time = "2026-10-06T17:36:40.518Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/fd/ea072c512d65ba4054b02c8b7080cf1b362761d4727ee6c1f36dce53328d/opentelemetry_instrumentation_system_metrics-0.66b1-py3-none-any.whl", hash = "sha256:0654b41eb6db3c2f9ad9979487afcc6f461e5b35df282f0a11d0f465e906d967", size = 13913, upload-time = "2026-10-06T17:35:57.01Z" }, +] + +[[package]] +name = "opentelemetry-proto" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/7f/15f014fb195da6c2dbb6c71399b8e76824878718e94de6454038488eed28/opentelemetry_proto-1.45.1.tar.gz", hash = "sha256:79e0fb95e4616691a469439238aa9224d75779b3e108e895d1aa125ab29ca77c", size = 46488, upload-time = "2026-10-06T17:33:11.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/9a/42ec8180a769516ae757e893b69736826efceac7332553915b4528a91c6d/opentelemetry_proto-1.45.1-py3-none-any.whl", hash = "sha256:f38e2a8413053c180cd3d2637fbb279673ec2f6a6e09c995aafa2f452c52b46e", size = 72488, upload-time = "2026-10-06T17:32:53.057Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/79/7392e21a1c8f0c61d90b223e31c7e48cb9d452e91a6b820ad24cca5f23c4/opentelemetry_sdk-1.45.1.tar.gz", hash = "sha256:63d24a6ca645019a631e6a51999c73e93adcac1196ca640b8ae78a7cc4762bf3", size = 218324, upload-time = "2026-10-06T17:33:13.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/3c/87c42b4bd6dd297536f04cd9383d212ac557ecd49f2cbdcd46da1c9ef5c8/opentelemetry_sdk-1.45.1-py3-none-any.whl", hash = "sha256:c604c11dc429810812348989115fa44bd558772a3d7442afc43d024f2c250ca4", size = 140063, upload-time = "2026-10-06T17:32:55.04Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/e4/dbbfb2a010c4db2224a5114638acede6fe563d33cc20fb1752cebcbe6298/opentelemetry_semantic_conventions-0.66b1.tar.gz", hash = "sha256:497ca63bf383723411e8eaf60c8779e9877633c936bb641080adab59d0eb6ec8", size = 150250, upload-time = "2026-10-06T17:33:14.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/14/67f8aa798857f8cf686f515bf93d9bb877ce952ddc8efae0fa25b45ce0d6/opentelemetry_semantic_conventions-0.66b1-py3-none-any.whl", hash = "sha256:d4cddeb4315490b35213f55e2bdc9ac54bb1e4d318927475bed62b35545e581b", size = 206279, upload-time = "2026-10-06T17:32:56.103Z" }, +] + +[[package]] +name = "opentelemetry-util-http" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/b5/df4b61da899f6ebdffdbdf0c8b0f3189ee57151694ccd5b7d50ee2906241/opentelemetry_util_http-0.66b1.tar.gz", hash = "sha256:047dea1a628031f857a5a32261dc0e955bc162d39993ed1cffb8f2cff5ba8a62", size = 11586, upload-time = "2026-10-06T17:36:46.572Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/9b/c77ecaea79ba0de1a11e7f06a7f5eea7043ec23f1860dcf5f03536698e4c/opentelemetry_util_http-0.66b1-py3-none-any.whl", hash = "sha256:8f443d7abcaf29c4a07b373bbd31b5b39132c0ed3c27d015a59dc0323d5b1c58", size = 8387, upload-time = "2026-10-06T17:36:06.984Z" }, +] + [[package]] name = "orjson" version = "3.11.7" @@ -788,6 +1166,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/bf/2086963c69bdac3d7cff1cc7ff79b8ce5ea0bec6797a017e1be338a46248/protobuf-6.33.5-py3-none-any.whl", hash = "sha256:69915a973dd0f60f31a08b8318b73eab2bd6a392c79184b3612226b0a3f8ec02", size = 170687, upload-time = "2026-01-29T21:51:32.557Z" }, ] +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -1045,6 +1451,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "rich" version = "14.3.3" @@ -1587,6 +2008,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ] +[[package]] +name = "wrapt" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/d2/a254a26d8ceaea87e0eee2e89fcfe53ddc1858418647493bb2937549ab6f/wrapt-2.5.0.tar.gz", hash = "sha256:c48cdb6c904dca76d9915a579e4a5fab6b0c25f650c1019ce78a78effaf7a345", size = 183288, upload-time = "2026-09-27T01:41:56.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/44589f9b34160280a1fbccbcf206b18df034a1b3e9584336d3a7f039a33c/wrapt-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b898caea081303006decc562c7fca5126f7c96507e78dd8f1ae3285dfa50ddc7", size = 106192, upload-time = "2026-09-27T01:40:00.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2b/94db3ba2e9528400e4173cbb67a56822a4644d4c2b319feb0cc638094b36/wrapt-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8837fbe708cb9d8a2d32a37dee836d24a531f02560db26418e2b181986fa21cb", size = 105828, upload-time = "2026-09-27T01:40:02.494Z" }, + { url = "https://files.pythonhosted.org/packages/11/0e/3ce67af67525c0068680637c0eba823ce2f0fc6eff2d1779cab656fb8c3d/wrapt-2.5.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0cabb9c17ab79b2549d1f23b36f436473ad9253ef53995a817feba26fae69d5b", size = 250177, upload-time = "2026-09-27T01:40:03.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3b/262b2c3c38aca6fa32cfea6407dbb346ce26ff788179b98137b3b3f3d8ca/wrapt-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6761765cc520ff9616fb035c02a85d1d744f7f70edd4649718fd0d09c589eacf", size = 250332, upload-time = "2026-09-27T01:40:05.15Z" }, + { url = "https://files.pythonhosted.org/packages/2e/ec/d54d273a2223d1964ecd5b5954aca18e8fbf2c5d8471e60ef3cbaaed9325/wrapt-2.5.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a145a7826eddea3eb5814903f98f93756042b919bb5305544cb1331daa2705b1", size = 229600, upload-time = "2026-09-27T01:40:06.47Z" }, + { url = "https://files.pythonhosted.org/packages/8e/88/33c75ac47b13b0bf77b44d96d02b1d0a2e6db66180909bacce86ef8fcd8e/wrapt-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6a9ee62a970075738909909bdbef3a7da9f7ae03dfca584db283547a29503b56", size = 246890, upload-time = "2026-09-27T01:40:07.838Z" }, + { url = "https://files.pythonhosted.org/packages/d2/0e/c3a3a158801e5bee7a0d9aa3561a127bd80f166b02b7b520a39e45d8f9b9/wrapt-2.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:691671ea05684f921ffc2e935fd3f9311c1795a10fbbfa006b46269733668f66", size = 226463, upload-time = "2026-09-27T01:40:09.206Z" }, + { url = "https://files.pythonhosted.org/packages/7c/2c/4c48ba51698a87e2e8299fc04856bbef391cb725f195bc93f0bc6196e1f2/wrapt-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e716f47c7f61e11709d3c0904213c94fc22999abf0c41461276cef886c1e8b4d", size = 238583, upload-time = "2026-09-27T01:40:10.631Z" }, + { url = "https://files.pythonhosted.org/packages/33/d2/077835618ed96b131730f74301a7236efd841ec2f341afe36303389b56ef/wrapt-2.5.0-cp312-cp312-win32.whl", hash = "sha256:5421acb5c363a9bc959122a8645e3f1f42010c932dc53885b11a5ff5b5a6d730", size = 100221, upload-time = "2026-09-27T01:40:11.91Z" }, + { url = "https://files.pythonhosted.org/packages/96/9f/9e56db2a3492275be809082c6f63a7a063f52b3e1812aae1eb03ec33f64c/wrapt-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:ab45839c912777e2738fed369636589c8b2a6d9c44ca56de0fd0814581d467c2", size = 105786, upload-time = "2026-09-27T01:40:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e4/e37ff75e5254564aa50e13bc4313383d02d5ec78f5bd3b9b1994dd7052d5/wrapt-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:ce4cab32c37ef71e69cf88f909b7febd0dd79543e5ae3650e2b874e0f3d3b975", size = 102937, upload-time = "2026-09-27T01:40:14.534Z" }, + { url = "https://files.pythonhosted.org/packages/d6/4b/cc7bb5668f7ddc0e73e236e96a0c06cab8fddfca9c53538c9dffac62db6f/wrapt-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b312b3cc87951faaed3cfef984d768ee8bee7f935d9cc929aaa9946b0b96a98c", size = 105808, upload-time = "2026-09-27T01:40:15.826Z" }, + { url = "https://files.pythonhosted.org/packages/4a/13/5d15ef0e2f42d5f084930dc4863e6e52c160c28301c8780aae170c58421c/wrapt-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c57ddae24cf72eb6bd18112638a987cafe6109d90f2df111e6934362cc03ac1a", size = 105931, upload-time = "2026-09-27T01:40:17.136Z" }, + { url = "https://files.pythonhosted.org/packages/1e/02/c7174e78b0c38bb279b2d3c25a6bd7fb9d3b0200c3e5a8fad084e7cc3e85/wrapt-2.5.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b95a6eca3b927853529eea958310563c83140ae8451dd5dc4399c7da385dc4f3", size = 248259, upload-time = "2026-09-27T01:40:18.446Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f9/47ae1d7ef325c3f6c81ae3c1fb4a3fef9d98c8025ed676c0bfc1550903ce/wrapt-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6058e12e9caa33468f9a36fb88c15a4bb30a479f997b37834b83abdbf062f264", size = 248224, upload-time = "2026-09-27T01:40:19.713Z" }, + { url = "https://files.pythonhosted.org/packages/38/7b/a394448bcbbaf8e5a3f856520edbbb1b92fc42061def56284c9083f3ac87/wrapt-2.5.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d245ac03f5ae77f1eea6eb19edd9e778c2f772490c20496c2f1cd3a102ee1b6", size = 227137, upload-time = "2026-09-27T01:40:21.359Z" }, + { url = "https://files.pythonhosted.org/packages/41/45/fc252bda5aa1ca01bc838d3b108778e786a2a13d0c52fd17c5f6179aa246/wrapt-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f4ef4935962f7029b2058a99f1a47ccbffc3be919dddb3becb6c2c48eac3d9f0", size = 244710, upload-time = "2026-09-27T01:40:22.693Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1c/527d1bde7371dcc2c378d88c97de03b121b486fb4cd3dbb399bc332c0676/wrapt-2.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f12e80c3089ebc03727d368f8205b811b5af2cd4a72b5e4cac75e901dd316e39", size = 225053, upload-time = "2026-09-27T01:40:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/81/8e/2b823fded8c3b815408c58633929812eacd29d824fe57548b7868c4ee422/wrapt-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a346408f19b6d589bf029f25f65c0b4cdeed6302ef8f40da4e5d1552d22dc037", size = 236884, upload-time = "2026-09-27T01:40:25.654Z" }, + { url = "https://files.pythonhosted.org/packages/00/d7/5d185c1193b073a0bf4cbe862b5d31f81067eddc39eff30ae632f346563d/wrapt-2.5.0-cp313-cp313-win32.whl", hash = "sha256:79e68f0fd7d381b9bbd71776f602a2d5440d4d2077459128e02fd6607465422c", size = 100198, upload-time = "2026-09-27T01:40:27.111Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9a/51d95640e01d0ebdd04a7223755f076e4936b0c124ce99bb01a12b53e66c/wrapt-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:77f0a74ff6f6cf89f5b673a732d5afe1911a6e6b1c017260836fdfdf85518dc1", size = 105610, upload-time = "2026-09-27T01:40:28.465Z" }, + { url = "https://files.pythonhosted.org/packages/67/52/183d5ce7c2a9391774e6a623be6ae564351545713ec9a3693528f89c8e85/wrapt-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:b620d7559b6b2197c5730332fab0867ecf1c8cb74d45533ebbcbcad1eacf4616", size = 102641, upload-time = "2026-09-27T01:40:30Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4b/0009086ab8f2d5fb32405ef49fdd11104ce40f69ae9f4cdfba8326462816/wrapt-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:65f2ee406dc592a5b22a7dc6abac13e8a3e8de4b2ecf5dc3c22937865496e4b6", size = 106182, upload-time = "2026-09-27T01:40:31.503Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/8f38339a4c55a42df00296dcf6ad50598d2280049f7a6ffa525e9a1f66d1/wrapt-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d75d6366203c8d025c1a74bae0b565952187ddae79bd5c7bf10687652a56f020", size = 106123, upload-time = "2026-09-27T01:40:32.927Z" }, + { url = "https://files.pythonhosted.org/packages/23/38/285b433121d73c7a447b5b82d93c91dc3330f33ab5853975f34551e0c773/wrapt-2.5.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b640460f0ffb346b192686bd6fac5589e35a6c6640501c59a9fb6e82b0dd6bd8", size = 251245, upload-time = "2026-09-27T01:40:34.309Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a6/3f63f4637e89484c1839a9ba3aedda5b2912e7ce12617034c6bd49752cfa/wrapt-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f17c5a3836397bf59fd57b0e5b0dc42969b1daa70d31aa361c6e13cbf138b5a", size = 250483, upload-time = "2026-09-27T01:40:35.841Z" }, + { url = "https://files.pythonhosted.org/packages/e9/cd/f24ee96016da222dbb921cfb22e2beb5ca189a7b730ef49e8bb106b49449/wrapt-2.5.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4343880acd72e74233baf092285aaaf4306244e31d7601828bd2600316027df0", size = 231341, upload-time = "2026-09-27T01:40:37.295Z" }, + { url = "https://files.pythonhosted.org/packages/23/eb/c9b180124271e494f615a130f966be56143e3e26e87706bc28582f94bc09/wrapt-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ea4fdc79c0045d6bb1603c109127145245cafe888588888444e1e37fbeadbac3", size = 247736, upload-time = "2026-09-27T01:40:38.653Z" }, + { url = "https://files.pythonhosted.org/packages/fd/60/345b8c213389809435d1950136b09991a1af2a66b988d0cd930ecd1b9f19/wrapt-2.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f42239c89430eee2d8a6dec39e34677abdbb67fff63caf2467dd6124ea4d4d58", size = 228533, upload-time = "2026-09-27T01:40:40.12Z" }, + { url = "https://files.pythonhosted.org/packages/ea/15/c79f0f5827a9062c6be4fc25dc73e92fe1c014c7bfde2e61c8c0b56a91af/wrapt-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bad63bb4dea3c58e8078a3a173259ac2df5442a437e49c632b4099c0250e803b", size = 238830, upload-time = "2026-09-27T01:40:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/5f/de/79a95ac238c9cae7ae7eb3a18501afc646e3ed61d8d108c725b17bbee301/wrapt-2.5.0-cp314-cp314-win32.whl", hash = "sha256:b58138d19f34e32833e62de5e910bc2a8baae43310b921d783bd39b15227c2dd", size = 100664, upload-time = "2026-09-27T01:40:42.884Z" }, + { url = "https://files.pythonhosted.org/packages/f0/15/32de0f1e6a46a82c773430672562d53203406df14a6d73c93abb59b679e9/wrapt-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:1a3c4035d2026b87ef23dd8d165f1f8d3853ee2bd02791cfd22bd8c6226c41ce", size = 105986, upload-time = "2026-09-27T01:40:44.652Z" }, + { url = "https://files.pythonhosted.org/packages/d9/2a/10a7ff69097385de15b3db7d91587a54c26f8025fcbf36a1d9e83a1e0ad1/wrapt-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:def66258d97ebf1e4e97def12c5daa542d1cc728a3da83ed3a43933f56df6dab", size = 103718, upload-time = "2026-09-27T01:40:45.956Z" }, + { url = "https://files.pythonhosted.org/packages/f7/01/963f893b1906ac6c2aecb777c36e9ab2156a4cf89fabf6125e953ec4ad52/wrapt-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:23a9d6cb6413359b76f030d6bbb75340b7669c69da245dde2919a4c93708993b", size = 109290, upload-time = "2026-09-27T01:40:47.22Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6c/30e04d2b1284de2eea5411850008e0411d1876bf4552dc0990c904a0a783/wrapt-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:21cfe343ef9c2deb865ad0d5c57822266447c88dcf6d8805dd8c363fe367f30c", size = 109597, upload-time = "2026-09-27T01:40:49Z" }, + { url = "https://files.pythonhosted.org/packages/6a/34/3980fe5a899b69454f66db2991c144ecc828dbbd355ce6cd7b881056ebbc/wrapt-2.5.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dfc38cb672af51fc29696ba9c6f05d2315f5e62c4af2564e50f07f81198a163", size = 283840, upload-time = "2026-09-27T01:40:50.406Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b4/b37001235fd5871b3f31941229f8fef608279353b772dab3ccb248fd8726/wrapt-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:181a45000506a6382eb337354ca7e8525690702f1ccf2eae4f23a210ff339543", size = 293622, upload-time = "2026-09-27T01:40:51.868Z" }, + { url = "https://files.pythonhosted.org/packages/09/b3/9b751c6268fa2111efc7e43895105bc0f60a83896b08581009e77563f8c7/wrapt-2.5.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58b2a87c65cbfb20917ec48ace47f71b1962c1f81dbf18a4052e3037abf72028", size = 262840, upload-time = "2026-09-27T01:40:53.297Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/b7b51d601981ccc1f7b9e6023991548dec43dc9d40317597fbe0085bc876/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0ea62bc142f4fa8b2e0ab058f50699ccd679ef6199c8fa3cc1c2396c7a659000", size = 288781, upload-time = "2026-09-27T01:40:54.756Z" }, + { url = "https://files.pythonhosted.org/packages/d1/82/1a84f288246905d0a71d44aa1f470ff8c75df2b96ef791d2938124449cb0/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:425349a99b8c9540399d36620c376dc26e6aca93071cd6fafa239c2f1b5d53a4", size = 260116, upload-time = "2026-09-27T01:40:56.667Z" }, + { url = "https://files.pythonhosted.org/packages/5a/0e/0572224d1c4a3f0846f82614702ec3110d45c843dcc7781c5f33779e1fdc/wrapt-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fe09aac4837ec720af606a493e814dcc3f65631984e1b7231b589efcf9917024", size = 278485, upload-time = "2026-09-27T01:40:58.443Z" }, + { url = "https://files.pythonhosted.org/packages/76/44/5a5c111f8ac6dd15f54437c2161588431d3924718a7e4de59c471cd794e9/wrapt-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:bc5607c1911c92530cb402ea90d818933cffb28bd8de9b453a2542279816d8c7", size = 103157, upload-time = "2026-09-27T01:40:59.995Z" }, + { url = "https://files.pythonhosted.org/packages/e6/80/96cc2da58cbc0893f5165f6a0f4f9cb75d7574f409022ad792aa80a0ff3f/wrapt-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7138b0e7990e5555a905c519e8dad17c1da1f20e08b283e202c414229065740f", size = 109683, upload-time = "2026-09-27T01:41:01.43Z" }, + { url = "https://files.pythonhosted.org/packages/c7/70/10dab499970e66c926ba6d404ff456318b68092b8ad0c4608a52160e43a2/wrapt-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a5bb346a34499e091e4fa23df58251ad192173c088e5413d893ca1c730c133c7", size = 105953, upload-time = "2026-09-27T01:41:03.041Z" }, + { url = "https://files.pythonhosted.org/packages/ab/18/5154954f69afdbf5bdeddc07ed60f30bf6e83ed1e9fb6f96c66cc20e4223/wrapt-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a45a5249a6965d91aac9f991fda7c17e6b8b41fe91592a6099f182bf53c82724", size = 106186, upload-time = "2026-09-27T01:41:04.472Z" }, + { url = "https://files.pythonhosted.org/packages/5d/43/7db9952d26b1a89afcf22da8ec7948e6ca55c48721488d53f84754eed89d/wrapt-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cbd45dfba6b5c1bfbabe1feb3c0f117fbd62416e98268d9a7cd9ad8802875356", size = 106155, upload-time = "2026-09-27T01:41:05.795Z" }, + { url = "https://files.pythonhosted.org/packages/57/b6/41a0d7f9cf1f8e6aaecbf4b5b4eaacf4036fa3396c7d814364e07728a041/wrapt-2.5.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bc6491d3008ecabf685b0746f03ad8241a0950336939b14addb03af39b51a316", size = 251754, upload-time = "2026-09-27T01:41:07.26Z" }, + { url = "https://files.pythonhosted.org/packages/55/d4/dd2de1260a490cd55d083b3c1bc47a36aff0e8363249d108d3b34c091c0e/wrapt-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47abb2bb7f15b416e72fbe5e68e49a6f09331dae6af1ca6055f5aa2251d2bd2f", size = 250768, upload-time = "2026-09-27T01:41:08.681Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/d08297feb5728cd6d3c1133633cad0249c2b7a82eb0213062ebab9cc1266/wrapt-2.5.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e25e9697f60af41fb86b08697e470b1e7eb6cd6ac0eb25e4b1f519839adc271", size = 232618, upload-time = "2026-09-27T01:41:10.293Z" }, + { url = "https://files.pythonhosted.org/packages/54/52/d8ca61b26c2a34927cc999fc250f1018f415741691581280e6a76cced736/wrapt-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:bcd42e7b69c8c1e33a29b79b28de03bdc08876a49745830f9162a3af860e06d0", size = 248043, upload-time = "2026-09-27T01:41:12.132Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5e/ba02904736e2d3b05afd7447b4ddff677ce61762265939555541adb8c668/wrapt-2.5.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:36703cafc2ec059e118c2175e6cb7ad7299c2924aecdcb1b7a7ebbf7a3e20c19", size = 229577, upload-time = "2026-09-27T01:41:13.746Z" }, + { url = "https://files.pythonhosted.org/packages/a0/94/23968c18a6e37a8a130706dc52ccf4344a71f1fe53c99965eb0a7715a459/wrapt-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1ebc0d09906057ada57a32158a657364ca40b8f86e604da3e7d979069b601502", size = 239372, upload-time = "2026-09-27T01:41:15.459Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ee/8437e73fffa57c96a5f0f6721f942ccf7b1461b64cd965f83e2181e25252/wrapt-2.5.0-cp315-cp315-win32.whl", hash = "sha256:76fb341d5a707a4f211631b8c77259b2df149147e9d9c245ae6ba3dd936bfdfb", size = 100673, upload-time = "2026-09-27T01:41:16.925Z" }, + { url = "https://files.pythonhosted.org/packages/37/6d/6d640f98197d68e61fbeaded20478e4aa840f9c88d11e7a49c8d6d14ae15/wrapt-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:6269637d9a54990430b4a769df15833935a46c4d004d9fe8a153bbadf0b9a097", size = 105999, upload-time = "2026-09-27T01:41:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3e/8b8a0c94f2698c99afb499510824b107c81e9c4a34b3db7e877233a634b7/wrapt-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:1122f4f9e363da804ccba05a9f0f39baa3716eb82452c929258bc3f1420c899b", size = 103728, upload-time = "2026-09-27T01:41:19.729Z" }, + { url = "https://files.pythonhosted.org/packages/37/96/88f08f58759ee3739544cc51941853e946df1f400ba60c6efbeccfb589d7/wrapt-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e3c6fb1c1a516881353186bed9cfcb8899f968c03b3509720c79db0d967acf3b", size = 109275, upload-time = "2026-09-27T01:41:21.213Z" }, + { url = "https://files.pythonhosted.org/packages/29/cc/68846aa92814d0704d4b128a30d7707368be6951e8a8f42f1254ce4ab31c/wrapt-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0a7a369e7fca9fc8c2c50df634382009b09af416853da3d4515e4bb048a5b9ee", size = 109610, upload-time = "2026-09-27T01:41:22.647Z" }, + { url = "https://files.pythonhosted.org/packages/fe/87/bbaa188dace348b6a403bbf3cc483f3f419ac97700274340e17f2dbc700e/wrapt-2.5.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88bb24b9fdccb1d805258d5648533206eb58c58b6554985c47db53a89c11be85", size = 284602, upload-time = "2026-09-27T01:41:24.094Z" }, + { url = "https://files.pythonhosted.org/packages/be/2e/8a3309b0cbd3ab809ee6b76812c3be211f5a08732f321f131d26bb4f078a/wrapt-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e3d110d7f99644946f249927c346d9dba507a78815d1bcebdbf0d94c14c5649", size = 294893, upload-time = "2026-09-27T01:41:25.654Z" }, + { url = "https://files.pythonhosted.org/packages/15/b7/eda8bbdb6a3b7343d2c71e23fb0ebfc15c12fd470e3cce7ea42f7a57aaac/wrapt-2.5.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ffb2823c95dbeb8a47fedfba9636b2afeb0a8ef94b66df97bd081bdfe5a263f", size = 264658, upload-time = "2026-09-27T01:41:27.375Z" }, + { url = "https://files.pythonhosted.org/packages/41/f0/589bad71ca3ce5444a626ee10d657fd6aa5080ada76b3bcd4550468aa16e/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:557ebf4ce5568588368675014a2540405db687c2e4c7ad1eb83aa7e857be1864", size = 289974, upload-time = "2026-09-27T01:41:29.032Z" }, + { url = "https://files.pythonhosted.org/packages/dc/97/c48f3c820ae6687e87b537041cd49ad4caa41e05a8f0d8ec08e449ca303c/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:05246a100da68259af521b88788f131ba005465f1c95d358cc3c03ec5e351b52", size = 261945, upload-time = "2026-09-27T01:41:30.941Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ad/d96898f500cb1e4185474bac6cb14bb7ea670a32f37e8c354a0c647e3a92/wrapt-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c932273bc43b068538f3874fa5e6c2a60f33fa0b11c1ebc7768652f6a0608943", size = 279744, upload-time = "2026-09-27T01:41:32.617Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8b/7981d2ac838d0dc07e81145cb1c9911812e060b98e8c5a47c84fb92f8f81/wrapt-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:2fd8a61c31220840c7f52621cf51c961af5058bd009a55bcdf4a6732bdb13b35", size = 103124, upload-time = "2026-09-27T01:41:34.117Z" }, + { url = "https://files.pythonhosted.org/packages/2a/1d/374cec175b6087e1067a780374d81d74ca966e1e5e39e666402eaee19a65/wrapt-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:1d4da5f0e9a719471502b0db80d5c97503aeca796b7aeb9ab8f47403b2be76e6", size = 109671, upload-time = "2026-09-27T01:41:35.553Z" }, + { url = "https://files.pythonhosted.org/packages/c7/93/fc9e477a1771bec52d7677eee5e8404afe662a47efe1859405a18fff206c/wrapt-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:78b7bdaa8b27b7f7607c66bdb6ab15c1dcbd9e9a1556a253a347dad511f615d1", size = 105969, upload-time = "2026-09-27T01:41:36.973Z" }, + { url = "https://files.pythonhosted.org/packages/87/7d/5ed859fad4b5eddd598a846150aaab2703730ed4886c5c5e03b0df0cfdd5/wrapt-2.5.0-py3-none-any.whl", hash = "sha256:107eea1a511e98a3a5033b0c2cb403fbb37f05dee6ac1fb85c0460d311ec278c", size = 81586, upload-time = "2026-09-27T01:41:55.479Z" }, +] + [[package]] name = "wynnsource-proto" version = "0.1.0" @@ -1604,7 +2100,7 @@ requires-dist = [ [[package]] name = "wynnsource-server" -version = "0.1.5" +version = "0.1.7" source = { virtual = "." } dependencies = [ { name = "alembic" }, @@ -1614,6 +2110,13 @@ dependencies = [ { name = "httpx" }, { name = "jsonschema" }, { name = "loguru" }, + { name = "opentelemetry-exporter-otlp-proto-http" }, + { name = "opentelemetry-instrumentation-fastapi" }, + { name = "opentelemetry-instrumentation-httpx" }, + { name = "opentelemetry-instrumentation-redis" }, + { name = "opentelemetry-instrumentation-sqlalchemy" }, + { name = "opentelemetry-instrumentation-system-metrics" }, + { name = "opentelemetry-sdk" }, { name = "orjson" }, { name = "pydantic-settings" }, { name = "redis" }, @@ -1643,6 +2146,13 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "jsonschema", specifier = ">=4.26.0" }, { name = "loguru", specifier = ">=0.7.3" }, + { name = "opentelemetry-exporter-otlp-proto-http", specifier = ">=1.45.1" }, + { name = "opentelemetry-instrumentation-fastapi", specifier = ">=0.66b1" }, + { name = "opentelemetry-instrumentation-httpx", specifier = ">=0.66b1" }, + { name = "opentelemetry-instrumentation-redis", specifier = ">=0.66b1" }, + { name = "opentelemetry-instrumentation-sqlalchemy", specifier = ">=0.66b1" }, + { name = "opentelemetry-instrumentation-system-metrics", specifier = ">=0.66b1" }, + { name = "opentelemetry-sdk", specifier = ">=1.45.1" }, { name = "orjson", specifier = ">=3.11.7" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, { name = "redis", specifier = ">=7.1.1" },