commit e1cf2d1695d80b136b28625ef73d0ea661029da0 Author: Maxim Snesarev Date: Mon Sep 8 03:30:22 2025 +0300 Initialize AMO CRM Data Collection Service with FastAPI, SQLite, and Docker support. Added core application files, including API routes, database models, and configuration settings. Implemented AMO CRM API client for data fetching and established Docker setup for containerization. Included example environment files and initial test cases for API endpoints. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a49f68c --- /dev/null +++ b/.gitignore @@ -0,0 +1,211 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be added to the global gitignore or merged into this project gitignore. +# https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +.idea/ + +# Visual Studio Code +.vscode/ + +# Sublime Text +*.sublime-project +*.sublime-workspace + +# Database files +*.db +*.sqlite +*.sqlite3 + +# Log files +*.log +logs/ + +# Redis dump files +dump.rdb + +# Docker +.dockerignore + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Temporary files +*.tmp +*.temp +*.swp +*.swo +*~ + +# AMO CRM specific +amocrm_data/ +exports/ +uploads/ + +# Local configuration files (keep examples) +.env.local +.env.production +.env.development + +# Google API credentials +credentials.json +token.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2af6965 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Use Python 3.12 slim image +FROM python:3.12-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy pyproject.toml and uv.lock first for better caching +COPY pyproject.toml uv.lock ./ + +# Install uv for faster package management +RUN pip install uv + +# Install dependencies +RUN uv sync --frozen + +# Copy application code +COPY . . + +# Create non-root user +RUN useradd --create-home --shell /bin/bash app \ + && chown -R app:app /app +USER app + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ + CMD python -c "import requests; requests.get('http://localhost:8000/health')" || exit 1 + +# Run the application +CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8d93875 --- /dev/null +++ b/README.md @@ -0,0 +1,277 @@ +# AMO CRM Data Collection Service + +A FastAPI service that collects data from AMO CRM API and stores it in SQLite database for further export to Google Sheets. + +## Features + +- **Data Collection**: Collects Deals, Contacts, Companies, Pipelines, Users, and Events from AMO CRM +- **HAL+JSON Support**: Handles AMO CRM's HAL+JSON response format +- **Custom Fields**: Extracts and stores custom fields with proper typing +- **Relationships**: Maintains proper relationships between entities +- **Google Sheets Export**: Unified configuration for exporting all entities to Google Sheets +- **Background Processing**: Async job processing for exports +- **SQLite Database**: Optimized storage with proper indexing + +## Project Structure + +``` +amo-server/ +├── adapters/ # Database and external service adapters +│ └── sqlite/ # SQLite database models and connection +├── routers/ # FastAPI route handlers +├── servers/ # Business logic (to be implemented) +├── utils/ # Utilities and configuration +├── workers/ # Background workers (to be implemented) +├── tests/ # Test files and fixtures +│ └── fixtures/ # AMO CRM response examples +└── docs/ # Documentation +``` + +## Setup + +### Prerequisites + +- Python 3.12+ +- [uv](https://docs.astral.sh/uv/) package manager + +### Installation + +1. Clone the repository: +```bash +git clone +cd amo-server +``` + +2. Install dependencies using uv: +```bash +uv sync +``` + +3. Create environment file: +```bash +cp env.example .env +``` + +4. Configure your environment variables in `.env`: +```env +# Database +DATABASE_URL=sqlite:///./amo_data.db + +# AMO CRM API +AMO_CRM_DOMAIN=wecheap.amocrm.ru +AMO_CRM_ACCESS_TOKEN=your-longterm-access-token + +# Google Sheets API +GOOGLE_SERVICE_ACCOUNT_FILE=path/to/service-account.json + +# Redis (for Celery) +REDIS_URL=redis://localhost:6379/0 + +# API Settings +API_V1_STR=/api/v1 + +# Logging +LOG_LEVEL=INFO +``` + +### Running the Service + +1. Start the development server: +```bash +uv run uvicorn app:app --reload +``` + +2. Open your browser to http://localhost:8000 to see the API + +3. Visit http://localhost:8000/docs for the interactive API documentation + +### Fetching Real AMO CRM Data + +To fetch real responses from your AMO CRM for testing: + +1. Set your access token in `.env`: +```env +AMO_CRM_ACCESS_TOKEN=your-longterm-token +``` + +2. Run the fetch script: +```bash +uv run python scripts/fetch_amocrm_data.py +``` + +This will: +- Fetch real data from wecheap.amocrm.ru +- Save individual JSON files to `tests/fixtures/real_responses/` +- Generate Python fixtures at `tests/fixtures/real_amocrm_responses.py` +- Show a summary of fetched data + +3. Test the API connection: +```bash +curl http://localhost:8000/api/v1/amocrm/info +curl http://localhost:8000/api/v1/amocrm/fetch/users?limit=5 +``` + +## API Endpoints + +### Entity Management + +- `GET /api/v1/entities` - List all entities with statistics +- `GET /api/v1/entities/{entity_type}/fields` - List fields for specific entity + +### AMO CRM Integration + +- `GET /api/v1/amocrm/info` - Get AMO CRM connection info +- `GET /api/v1/amocrm/fetch/{entity_type}` - Fetch data directly from AMO CRM +- `GET /api/v1/amocrm/fetch/custom_fields/{entity_type}` - Fetch custom fields metadata +- `GET /api/v1/amocrm/fetch/all` - Fetch all data from AMO CRM (for testing) +- `POST /api/v1/amocrm/sync/{entity_type}` - Fetch and sync data from AMO CRM + +### Export Management + +- `POST /api/v1/export/configure` - Create unified export configuration +- `GET /api/v1/export/configurations` - List export configurations +- `POST /api/v1/export/start` - Start export job +- `GET /api/v1/export/status/{job_id}` - Get export job status +- `GET /api/v1/export/jobs` - List all export jobs + +### Data Ingestion (Worker API) + +- `POST /api/v1/data/users` - Import users data +- `POST /api/v1/data/pipelines` - Import pipelines data +- `POST /api/v1/data/companies` - Import companies data +- `POST /api/v1/data/contacts` - Import contacts data +- `POST /api/v1/data/deals` - Import deals data +- `POST /api/v1/data/events` - Import events data + +## Database Schema + +The service uses SQLite with the following main tables: + +- `amo_users` - CRM users +- `amo_pipelines` - Sales pipelines +- `amo_pipeline_stages` - Pipeline stages +- `amo_companies` - Companies +- `amo_contacts` - Contacts +- `amo_deals` - Deals/Leads +- `amo_events` - Activity events +- `amo_custom_fields` - Universal custom fields storage +- `export_configuration` - Export configurations +- `export_entity_mappings` - Entity-specific export mappings +- `export_jobs` - Export job tracking + +## AMO CRM Response Examples + +The project includes real AMO CRM API response examples in `tests/fixtures/amocrm_responses.py` for: + +- **Users** (`USERS_RESPONSE`) +- **Pipelines** (`PIPELINES_RESPONSE`) +- **Companies** (`COMPANIES_RESPONSE`) +- **Contacts** (`CONTACTS_RESPONSE`) +- **Deals** (`DEALS_RESPONSE`) +- **Events** (`EVENTS_RESPONSE`) +- **Custom Fields Metadata** (`CUSTOM_FIELDS_RESPONSE`) + +These examples demonstrate: +- HAL+JSON format structure +- Custom fields with different types (text, select, numeric, date) +- Embedded relationships between entities +- Proper date formats (Unix timestamps) +- Event types filtering (incoming_call, outgoing_call, lead_status_changed) + +## Testing + +Run tests using pytest: + +```bash +uv run pytest +``` + +Run tests with coverage: + +```bash +uv run pytest --cov=. --cov-report=html +``` + +## Export Configuration Example + +Create a unified export configuration for all entities: + +```json +{ + "name": "Q1 2024 AMO CRM Export", + "sheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "date_range_start": "2024-01-01T00:00:00Z", + "date_range_end": "2024-03-31T23:59:59Z", + "entity_mappings": { + "deals": { + "sheet_name": "Deals", + "is_enabled": true, + "field_mapping": [ + {"field_name": "name", "column": "A", "order": 1}, + {"field_name": "price", "column": "B", "order": 2}, + {"field_name": "created_at", "column": "C", "order": 3} + ] + }, + "contacts": { + "sheet_name": "Contacts", + "is_enabled": true, + "field_mapping": [ + {"field_name": "name", "column": "A", "order": 1}, + {"field_name": "email", "column": "B", "order": 2} + ] + } + } +} +``` + +## Custom Fields Handling + +The service automatically extracts custom fields from AMO CRM responses: + +- **Field Types**: text, numeric, select, multiselect, date, checkbox, textarea, url +- **Type Detection**: Automatically detects and converts field values +- **Storage**: Stores in universal `amo_custom_fields` table with proper typing +- **Relationships**: Links custom fields to their parent entities + +## Date Validation + +All dates are validated to be between 2017-2026 (Unix timestamps: 1483228800 - 1767225600). + +## Development + +### Code Style + +The project uses: +- **Black** for code formatting +- **isort** for import sorting +- **flake8** for linting +- **mypy** for type checking + +Run code formatting: +```bash +uv run black . +uv run isort . +``` + +### Project Conventions + +Following the established patterns: +- **Adapters**: Handle external integrations (database, APIs) +- **Servers**: Contain business logic +- **Routers**: Define API routes only +- **Workers**: Background processing +- **Utils**: Shared utilities and configuration + +## Next Steps + +1. **Implement Background Workers**: Set up Celery workers for data collection +2. **Add Google Sheets Integration**: Implement actual Google Sheets export +3. **Add AMO CRM Client**: Create HTTP client for AMO CRM API +4. **Add Authentication**: Implement API authentication +5. **Add Monitoring**: Set up logging and metrics +6. **Add Rate Limiting**: Implement API rate limiting +7. **Add Caching**: Use Redis for caching frequently accessed data + +## License + +MIT License diff --git a/adapters/__init__.py b/adapters/__init__.py new file mode 100644 index 0000000..7ad2a65 --- /dev/null +++ b/adapters/__init__.py @@ -0,0 +1 @@ +# Adapters package diff --git a/adapters/amocrm_client.py b/adapters/amocrm_client.py new file mode 100644 index 0000000..cde2e56 --- /dev/null +++ b/adapters/amocrm_client.py @@ -0,0 +1,124 @@ +""" +AMO CRM API Client +Simple client for fetching data from AMO CRM API +""" +import httpx +import asyncio +from typing import Dict, Any, List, Optional +from utils.config import settings + + +class AmoCRMClient: + def __init__(self, domain: str = None, access_token: str = None): + self.domain = domain or settings.AMO_CRM_DOMAIN + self.access_token = access_token or settings.AMO_CRM_ACCESS_TOKEN + self.base_url = f"https://{self.domain}/api/v4" + + self.headers = { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json", + } + + async def _make_request(self, endpoint: str, params: Dict[str, Any] = None) -> Dict[str, Any]: + """Make HTTP request to AMO CRM API""" + async with httpx.AsyncClient() as client: + url = f"{self.base_url}/{endpoint}" + response = await client.get(url, headers=self.headers, params=params) + response.raise_for_status() + return response.json() + + async def get_users(self, limit: int = 250) -> Dict[str, Any]: + """Get users (пользователи)""" + return await self._make_request("users", {"limit": limit}) + + async def get_pipelines(self) -> Dict[str, Any]: + """Get pipelines (воронки)""" + return await self._make_request("leads/pipelines") + + async def get_companies(self, limit: int = 250, page: int = 1) -> Dict[str, Any]: + """Get companies (компании)""" + params = {"limit": limit, "page": page, "with": "custom_fields_values"} + return await self._make_request("companies", params) + + async def get_contacts(self, limit: int = 250, page: int = 1) -> Dict[str, Any]: + """Get contacts (контакты)""" + params = {"limit": limit, "page": page, "with": "custom_fields_values"} + return await self._make_request("contacts", params) + + async def get_deals(self, limit: int = 250, page: int = 1) -> Dict[str, Any]: + """Get deals/leads (сделки)""" + params = {"limit": limit, "page": page, "with": "contacts,companies,custom_fields_values"} + return await self._make_request("leads", params) + + async def get_events(self, limit: int = 250, page: int = 1, event_types: List[str] = None) -> Dict[str, Any]: + """Get events (события)""" + params = {"limit": limit, "page": page} + + # Filter by event types + if event_types: + params["filter[type]"] = event_types + else: + # Default to our supported event types + params["filter[type]"] = ["incoming_call", "outgoing_call", "lead_status_changed"] + + return await self._make_request("events", params) + + async def get_custom_fields(self, entity_type: str = "leads") -> Dict[str, Any]: + """Get custom fields metadata for entity type""" + return await self._make_request(f"{entity_type}/custom_fields") + + async def fetch_all_data(self) -> Dict[str, Any]: + """Fetch all data from AMO CRM for testing""" + print("Fetching AMO CRM data...") + + results = {} + + try: + print("- Fetching users...") + results["users"] = await self.get_users() + print(f" Found {len(results['users'].get('_embedded', {}).get('users', []))} users") + + print("- Fetching pipelines...") + results["pipelines"] = await self.get_pipelines() + print(f" Found {len(results['pipelines'].get('_embedded', {}).get('pipelines', []))} pipelines") + + print("- Fetching companies...") + results["companies"] = await self.get_companies(limit=10) # Limit for testing + print(f" Found {len(results['companies'].get('_embedded', {}).get('companies', []))} companies") + + print("- Fetching contacts...") + results["contacts"] = await self.get_contacts(limit=10) # Limit for testing + print(f" Found {len(results['contacts'].get('_embedded', {}).get('contacts', []))} contacts") + + print("- Fetching deals...") + results["deals"] = await self.get_deals(limit=10) # Limit for testing + print(f" Found {len(results['deals'].get('_embedded', {}).get('leads', []))} deals") + + print("- Fetching events...") + results["events"] = await self.get_events(limit=10) # Limit for testing + print(f" Found {len(results['events'].get('_embedded', {}).get('events', []))} events") + + print("- Fetching custom fields...") + results["custom_fields_deals"] = await self.get_custom_fields("leads") + results["custom_fields_contacts"] = await self.get_custom_fields("contacts") + results["custom_fields_companies"] = await self.get_custom_fields("companies") + + print("✅ All data fetched successfully!") + + except Exception as e: + print(f"❌ Error fetching data: {e}") + raise + + return results + + +# Convenience function for testing +async def fetch_amocrm_data(): + """Fetch AMO CRM data for testing""" + client = AmoCRMClient() + return await client.fetch_all_data() + + +if __name__ == "__main__": + # Test the client + asyncio.run(fetch_amocrm_data()) diff --git a/adapters/sqlite/__init__.py b/adapters/sqlite/__init__.py new file mode 100644 index 0000000..55a7c51 --- /dev/null +++ b/adapters/sqlite/__init__.py @@ -0,0 +1 @@ +# SQLite adapter package diff --git a/adapters/sqlite/database.py b/adapters/sqlite/database.py new file mode 100644 index 0000000..3f70274 --- /dev/null +++ b/adapters/sqlite/database.py @@ -0,0 +1,41 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from utils.config import settings + +# Create SQLite engine with proper configuration +engine = create_engine( + settings.DATABASE_URL, + connect_args={ + "check_same_thread": False, # Allow multiple threads for SQLite + "timeout": 20, # Set timeout for database operations + }, + poolclass=StaticPool, + echo=settings.LOG_LEVEL == "DEBUG", # Log SQL queries in debug mode +) + +# Create session factory +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Create base class for models +Base = declarative_base() + + +def get_db(): + """Dependency to get database session""" + db = SessionLocal() + try: + yield db + finally: + db.close() + + +async def init_db(): + """Initialize database tables""" + # Import all models to ensure they're registered + from adapters.sqlite import models # noqa: F401 + + # Create all tables + Base.metadata.create_all(bind=engine) diff --git a/adapters/sqlite/migrations/env.py b/adapters/sqlite/migrations/env.py new file mode 100644 index 0000000..b56f384 --- /dev/null +++ b/adapters/sqlite/migrations/env.py @@ -0,0 +1,84 @@ +from logging.config import fileConfig +from sqlalchemy import engine_from_config, pool +from alembic import context +import sys +import os + +# Add the project root to Python path +sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from adapters.sqlite.database import Base +from adapters.sqlite.models import * # Import all models +from utils.config import settings + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Set the SQLAlchemy URL from settings +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure( + connection=connection, target_metadata=target_metadata + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/adapters/sqlite/migrations/script.py.mako b/adapters/sqlite/migrations/script.py.mako new file mode 100644 index 0000000..55df286 --- /dev/null +++ b/adapters/sqlite/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/adapters/sqlite/models.py b/adapters/sqlite/models.py new file mode 100644 index 0000000..eed7f42 --- /dev/null +++ b/adapters/sqlite/models.py @@ -0,0 +1,281 @@ +from sqlalchemy import ( + Column, Integer, String, Boolean, Text, ForeignKey, JSON, + Table, Index, CheckConstraint +) +from sqlalchemy.orm import relationship +from sqlalchemy.ext.declarative import declarative_base + +from .database import Base + +# Association tables for many-to-many relationships +deal_contacts = Table( + 'amo_deal_contacts', + Base.metadata, + Column('deal_id', Integer, ForeignKey('amo_deals.id'), primary_key=True), + Column('contact_id', Integer, ForeignKey('amo_contacts.id'), primary_key=True), + Column('is_main', Boolean, default=False), +) + +deal_companies = Table( + 'amo_deal_companies', + Base.metadata, + Column('deal_id', Integer, ForeignKey('amo_deals.id'), primary_key=True), + Column('company_id', Integer, ForeignKey('amo_companies.id'), primary_key=True), + Column('is_main', Boolean, default=False), +) + +contact_companies = Table( + 'amo_contact_companies', + Base.metadata, + Column('contact_id', Integer, ForeignKey('amo_contacts.id'), primary_key=True), + Column('company_id', Integer, ForeignKey('amo_companies.id'), primary_key=True), + Column('is_main', Boolean, default=False), +) + + +class User(Base): + __tablename__ = 'amo_users' + + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) + email = Column(String(255)) + is_active = Column(Boolean, default=True) + created_at = Column(Integer) + updated_at = Column(Integer) + raw_data = Column(JSON) + + # Relationships + created_deals = relationship("Deal", foreign_keys="Deal.created_by", back_populates="creator") + updated_deals = relationship("Deal", foreign_keys="Deal.updated_by", back_populates="updater") + responsible_deals = relationship("Deal", foreign_keys="Deal.responsible_user_id", back_populates="responsible_user") + + created_contacts = relationship("Contact", foreign_keys="Contact.created_by", back_populates="creator") + updated_contacts = relationship("Contact", foreign_keys="Contact.updated_by", back_populates="updater") + responsible_contacts = relationship("Contact", foreign_keys="Contact.responsible_user_id", back_populates="responsible_user") + + created_companies = relationship("Company", foreign_keys="Company.created_by", back_populates="creator") + updated_companies = relationship("Company", foreign_keys="Company.updated_by", back_populates="updater") + responsible_companies = relationship("Company", foreign_keys="Company.responsible_user_id", back_populates="responsible_user") + + events = relationship("Event", back_populates="created_by_user") + + +class Pipeline(Base): + __tablename__ = 'amo_pipelines' + + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) + sort = Column(Integer) + is_main = Column(Boolean, default=False) + is_unsorted = Column(Boolean, default=False) + is_archive = Column(Boolean, default=False) + account_id = Column(Integer) + created_at = Column(Integer) + updated_at = Column(Integer) + raw_data = Column(JSON) + + # Relationships + stages = relationship("PipelineStage", back_populates="pipeline") + deals = relationship("Deal", back_populates="pipeline") + + +class PipelineStage(Base): + __tablename__ = 'amo_pipeline_stages' + + id = Column(Integer, primary_key=True) + pipeline_id = Column(Integer, ForeignKey('amo_pipelines.id'), nullable=False) + name = Column(String(255), nullable=False) + sort = Column(Integer) + is_editable = Column(Boolean, default=True) + color = Column(String(7)) # Hex color code + created_at = Column(Integer) + updated_at = Column(Integer) + raw_data = Column(JSON) + + # Relationships + pipeline = relationship("Pipeline", back_populates="stages") + deals = relationship("Deal", back_populates="status") + + +class Company(Base): + __tablename__ = 'amo_companies' + + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) + responsible_user_id = Column(Integer, ForeignKey('amo_users.id')) + group_id = Column(Integer) + created_by = Column(Integer, ForeignKey('amo_users.id')) + updated_by = Column(Integer, ForeignKey('amo_users.id')) + created_at = Column(Integer) + updated_at = Column(Integer) + closest_task_at = Column(Integer) + is_deleted = Column(Boolean, default=False) + raw_data = Column(JSON) + + # Relationships + responsible_user = relationship("User", foreign_keys=[responsible_user_id], back_populates="responsible_companies") + creator = relationship("User", foreign_keys=[created_by], back_populates="created_companies") + updater = relationship("User", foreign_keys=[updated_by], back_populates="updated_companies") + + # Many-to-many relationships + deals = relationship("Deal", secondary=deal_companies, back_populates="companies") + contacts = relationship("Contact", secondary=contact_companies, back_populates="companies") + + +class Contact(Base): + __tablename__ = 'amo_contacts' + + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) + first_name = Column(String(255)) + last_name = Column(String(255)) + responsible_user_id = Column(Integer, ForeignKey('amo_users.id')) + group_id = Column(Integer) + created_by = Column(Integer, ForeignKey('amo_users.id')) + updated_by = Column(Integer, ForeignKey('amo_users.id')) + created_at = Column(Integer) + updated_at = Column(Integer) + closest_task_at = Column(Integer) + is_deleted = Column(Boolean, default=False) + raw_data = Column(JSON) + + # Relationships + responsible_user = relationship("User", foreign_keys=[responsible_user_id], back_populates="responsible_contacts") + creator = relationship("User", foreign_keys=[created_by], back_populates="created_contacts") + updater = relationship("User", foreign_keys=[updated_by], back_populates="updated_contacts") + + # Many-to-many relationships + deals = relationship("Deal", secondary=deal_contacts, back_populates="contacts") + companies = relationship("Company", secondary=contact_companies, back_populates="contacts") + + +class Deal(Base): + __tablename__ = 'amo_deals' + + id = Column(Integer, primary_key=True) + name = Column(String(255), nullable=False) + price = Column(Integer, default=0) + responsible_user_id = Column(Integer, ForeignKey('amo_users.id')) + group_id = Column(Integer) + status_id = Column(Integer, ForeignKey('amo_pipeline_stages.id')) + pipeline_id = Column(Integer, ForeignKey('amo_pipelines.id')) + loss_reason_id = Column(Integer) + created_by = Column(Integer, ForeignKey('amo_users.id')) + updated_by = Column(Integer, ForeignKey('amo_users.id')) + closed_at = Column(Integer) + created_at = Column(Integer) + updated_at = Column(Integer) + closest_task_at = Column(Integer) + is_deleted = Column(Boolean, default=False) + raw_data = Column(JSON) + + # Relationships + responsible_user = relationship("User", foreign_keys=[responsible_user_id], back_populates="responsible_deals") + creator = relationship("User", foreign_keys=[created_by], back_populates="created_deals") + updater = relationship("User", foreign_keys=[updated_by], back_populates="updated_deals") + status = relationship("PipelineStage", back_populates="deals") + pipeline = relationship("Pipeline", back_populates="deals") + + # Many-to-many relationships + contacts = relationship("Contact", secondary=deal_contacts, back_populates="deals") + companies = relationship("Company", secondary=deal_companies, back_populates="deals") + + +class Event(Base): + __tablename__ = 'amo_events' + + __table_args__ = ( + CheckConstraint( + "type IN ('incoming_call', 'outgoing_call', 'lead_status_changed')", + name='check_event_type' + ), + ) + + id = Column(Integer, primary_key=True) + type = Column(String(50), nullable=False) + entity_id = Column(Integer) + entity_type = Column(String(50)) + created_by = Column(Integer, ForeignKey('amo_users.id')) + created_at = Column(Integer) + value_after = Column(JSON) + value_before = Column(JSON) + account_id = Column(Integer) + raw_data = Column(JSON) + + # Relationships + created_by_user = relationship("User", back_populates="events") + + +class CustomField(Base): + __tablename__ = 'amo_custom_fields' + + id = Column(Integer, primary_key=True, autoincrement=True) + entity_type = Column(String(50), nullable=False) # 'deals', 'contacts', 'companies' + entity_id = Column(Integer, nullable=False) + field_id = Column(Integer, nullable=False) + field_name = Column(String(255), nullable=False) + field_type = Column(String(50), nullable=False) # 'text', 'numeric', 'checkbox', 'select', etc. + field_value = Column(Text) + field_value_numeric = Column(Integer) + field_value_date = Column(Integer) + is_custom = Column(Boolean, default=True) + created_at = Column(Integer) + updated_at = Column(Integer) + + # Indexes + __table_args__ = ( + Index('idx_custom_fields_entity', 'entity_type', 'entity_id'), + Index('idx_custom_fields_field', 'field_id'), + Index('idx_custom_fields_name', 'field_name'), + ) + + +# Export configuration models +class ExportConfiguration(Base): + __tablename__ = 'export_configuration' + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(255), nullable=False) + sheet_id = Column(String(255), nullable=False) + date_range_start = Column(Integer) + date_range_end = Column(Integer) + is_active = Column(Boolean, default=True) + created_at = Column(Integer) + updated_at = Column(Integer) + + # Relationships + entity_mappings = relationship("ExportEntityMapping", back_populates="configuration") + export_jobs = relationship("ExportJob", back_populates="configuration") + + +class ExportEntityMapping(Base): + __tablename__ = 'export_entity_mappings' + + id = Column(Integer, primary_key=True, autoincrement=True) + configuration_id = Column(Integer, ForeignKey('export_configuration.id'), nullable=False) + entity_type = Column(String(50), nullable=False) + sheet_name = Column(String(255), nullable=False) + field_mapping = Column(JSON, nullable=False) + is_enabled = Column(Boolean, default=True) + created_at = Column(Integer) + updated_at = Column(Integer) + + # Relationships + configuration = relationship("ExportConfiguration", back_populates="entity_mappings") + + +class ExportJob(Base): + __tablename__ = 'export_jobs' + + id = Column(Integer, primary_key=True, autoincrement=True) + configuration_id = Column(Integer, ForeignKey('export_configuration.id'), nullable=False) + status = Column(String(50), nullable=False, default='pending') + records_processed = Column(Integer, default=0) + total_records = Column(Integer, default=0) + error_message = Column(Text) + started_at = Column(Integer) + completed_at = Column(Integer) + created_at = Column(Integer) + + # Relationships + configuration = relationship("ExportConfiguration", back_populates="export_jobs") diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..4f6b6d2 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,109 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = adapters/sqlite/migrations + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses +# os.pathsep. If this key is omitted entirely, it falls back to the legacy +# behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +sqlalchemy.url = sqlite:///./amo_data.db + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = --fix REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/app.py b/app.py new file mode 100644 index 0000000..9bbfcbb --- /dev/null +++ b/app.py @@ -0,0 +1,60 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from contextlib import asynccontextmanager +import uvicorn + +from adapters.sqlite.database import init_db +from routers import entities, export, data, amocrm +from utils.config import settings + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + await init_db() + yield + # Shutdown + pass + + +app = FastAPI( + title="AMO CRM Data Collection Service", + description="Service for collecting and exporting AMO CRM data to Google Sheets", + version="0.1.0", + lifespan=lifespan, +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # Configure appropriately for production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(entities.router, prefix=f"{settings.API_V1_STR}/entities", tags=["entities"]) +app.include_router(export.router, prefix=f"{settings.API_V1_STR}/export", tags=["export"]) +app.include_router(data.router, prefix=f"{settings.API_V1_STR}/data", tags=["data"]) +app.include_router(amocrm.router, prefix=f"{settings.API_V1_STR}/amocrm", tags=["amocrm"]) + + +@app.get("/") +async def root(): + return {"message": "AMO CRM Data Collection Service", "version": "0.1.0"} + + +@app.get("/health") +async def health_check(): + return {"status": "healthy"} + + +if __name__ == "__main__": + uvicorn.run( + "app:app", + host="0.0.0.0", + port=8000, + reload=True, + log_level=settings.LOG_LEVEL.lower(), + ) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..05285c6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,83 @@ +version: '3.8' + +services: + app: + build: . + ports: + - "8000:8000" + environment: + - DATABASE_URL=sqlite:///./data/amo_data.db + - REDIS_URL=redis://redis:6379/0 + - AMO_CRM_DOMAIN=${AMO_CRM_DOMAIN} + - AMO_CRM_ACCESS_TOKEN=${AMO_CRM_ACCESS_TOKEN} + - GOOGLE_SERVICE_ACCOUNT_FILE=${GOOGLE_SERVICE_ACCOUNT_FILE} + - GOOGLE_SCOPES=${GOOGLE_SCOPES} + - API_V1_STR=/api/v1 + - LOG_LEVEL=INFO + volumes: + - ./data:/app/data + - ./credentials:/app/credentials:ro + depends_on: + - redis + restart: unless-stopped + networks: + - amo-network + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + volumes: + - redis_data:/data + restart: unless-stopped + networks: + - amo-network + + celery-worker: + build: . + command: python -m celery -A workers.celery_app worker --loglevel=info + environment: + - DATABASE_URL=sqlite:///./data/amo_data.db + - REDIS_URL=redis://redis:6379/0 + - AMO_CRM_DOMAIN=${AMO_CRM_DOMAIN} + - AMO_CRM_ACCESS_TOKEN=${AMO_CRM_ACCESS_TOKEN} + - GOOGLE_SERVICE_ACCOUNT_FILE=${GOOGLE_SERVICE_ACCOUNT_FILE} + - GOOGLE_SCOPES=${GOOGLE_SCOPES} + - API_V1_STR=/api/v1 + - LOG_LEVEL=INFO + volumes: + - ./data:/app/data + - ./credentials:/app/credentials:ro + depends_on: + - redis + restart: unless-stopped + networks: + - amo-network + + celery-beat: + build: . + command: python -m celery -A workers.celery_app beat --loglevel=info + environment: + - DATABASE_URL=sqlite:///./data/amo_data.db + - REDIS_URL=redis://redis:6379/0 + - AMO_CRM_DOMAIN=${AMO_CRM_DOMAIN} + - AMO_CRM_ACCESS_TOKEN=${AMO_CRM_ACCESS_TOKEN} + - GOOGLE_SERVICE_ACCOUNT_FILE=${GOOGLE_SERVICE_ACCOUNT_FILE} + - GOOGLE_SCOPES=${GOOGLE_SCOPES} + - API_V1_STR=/api/v1 + - LOG_LEVEL=INFO + volumes: + - ./data:/app/data + - ./credentials:/app/credentials:ro + depends_on: + - redis + restart: unless-stopped + networks: + - amo-network + +volumes: + redis_data: + +networks: + amo-network: + driver: bridge diff --git a/docs/amocrm-service-design.md b/docs/amocrm-service-design.md new file mode 100644 index 0000000..8956e83 --- /dev/null +++ b/docs/amocrm-service-design.md @@ -0,0 +1,803 @@ +# AMO CRM Data Collection Service - Design Document + +## Overview + +This service collects data from AMO CRM API (wecheap.amocrm.ru) and stores it in SQLite database for further export to Google Sheets. The service handles AMO CRM's HAL+JSON format and maintains proper relationships between entities. + +**Key Features:** +- Simple API without authentication +- Direct AMO CRM integration with long-term tokens +- Real-time data fetching and testing +- Unified export configuration for all entities +- Background job processing for exports + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ AMO CRM API │ │ Our Service │ │ Google Sheets │ +│ │ │ │ │ │ +│ HAL+JSON Format │◄──►│ SQLite Database │───►│ Export API │ +│ │ │ │ │ │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ +``` + +### Components +- **Adapters**: Database connections, AMO CRM API client, Google Sheets client +- **Routers**: API endpoints (no authentication required) +- **Servers**: Business logic for data processing and export +- **Workers**: Background data collection and processing +- **Scripts**: Utilities for fetching real AMO CRM data + +## Data Entities + +### 1. Deals (Сделки) +- Primary entity representing sales opportunities +- Links to: Contacts, Companies, Users, Pipelines +- Contains custom fields and standard fields + +### 2. Contacts (Контакты) +- Individual people in the CRM +- Links to: Companies, Deals, Users +- Contains communication history + +### 3. Companies (Компании) +- Organizations in the CRM +- Links to: Contacts, Deals, Users +- Contains company-specific custom fields + +### 4. Pipelines (Воронки) +- Sales process definitions +- Contains stages and automation rules +- Referenced by Deals + +### 5. Users (Пользователи) +- CRM users (employees) +- Referenced by all other entities as owners/responsible persons + +### 6. Events (События) +- Activity logs (filtered types only): + - `incoming_call` + - `outgoing_call` + - `lead_status_changed` +- Links to related entities + +## Database Schema + +### Core Tables + +```sql +-- Users table +CREATE TABLE amo_users ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + email TEXT, + is_active BOOLEAN DEFAULT TRUE, + created_at INTEGER, + updated_at INTEGER, + raw_data JSON -- Original AMO CRM data +); + +-- Pipelines table +CREATE TABLE amo_pipelines ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + sort INTEGER, + is_main BOOLEAN DEFAULT FALSE, + is_unsorted BOOLEAN DEFAULT FALSE, + is_archive BOOLEAN DEFAULT FALSE, + account_id INTEGER, + created_at INTEGER, + updated_at INTEGER, + raw_data JSON +); + +-- Pipeline stages +CREATE TABLE amo_pipeline_stages ( + id INTEGER PRIMARY KEY, + pipeline_id INTEGER REFERENCES amo_pipelines(id), + name TEXT NOT NULL, + sort INTEGER, + is_editable BOOLEAN DEFAULT TRUE, + color TEXT, + created_at INTEGER, + updated_at INTEGER, + raw_data JSON +); + +-- Companies table +CREATE TABLE amo_companies ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + responsible_user_id INTEGER REFERENCES amo_users(id), + group_id INTEGER, + created_by INTEGER REFERENCES amo_users(id), + updated_by INTEGER REFERENCES amo_users(id), + created_at INTEGER, + updated_at INTEGER, + closest_task_at INTEGER, + is_deleted BOOLEAN DEFAULT FALSE, + raw_data JSON +); + +-- Contacts table +CREATE TABLE amo_contacts ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + first_name TEXT, + last_name TEXT, + responsible_user_id INTEGER REFERENCES amo_users(id), + group_id INTEGER, + created_by INTEGER REFERENCES amo_users(id), + updated_by INTEGER REFERENCES amo_users(id), + created_at INTEGER, + updated_at INTEGER, + closest_task_at INTEGER, + is_deleted BOOLEAN DEFAULT FALSE, + raw_data JSON +); + +-- Deals table +CREATE TABLE amo_deals ( + id INTEGER PRIMARY KEY, + name TEXT NOT NULL, + price INTEGER DEFAULT 0, + responsible_user_id INTEGER REFERENCES amo_users(id), + group_id INTEGER, + status_id INTEGER REFERENCES amo_pipeline_stages(id), + pipeline_id INTEGER REFERENCES amo_pipelines(id), + loss_reason_id INTEGER, + created_by INTEGER REFERENCES amo_users(id), + updated_by INTEGER REFERENCES amo_users(id), + closed_at INTEGER, + created_at INTEGER, + updated_at INTEGER, + closest_task_at INTEGER, + is_deleted BOOLEAN DEFAULT FALSE, + raw_data JSON +); + +-- Events table +CREATE TABLE amo_events ( + id INTEGER PRIMARY KEY, + type TEXT NOT NULL CHECK (type IN ('incoming_call', 'outgoing_call', 'lead_status_changed')), + entity_id INTEGER, + entity_type TEXT, + created_by INTEGER REFERENCES amo_users(id), + created_at INTEGER, + value_after JSON, + value_before JSON, + account_id INTEGER, + raw_data JSON +); + +-- Relations tables +CREATE TABLE amo_deal_contacts ( + deal_id INTEGER REFERENCES amo_deals(id), + contact_id INTEGER REFERENCES amo_contacts(id), + is_main BOOLEAN DEFAULT FALSE, + PRIMARY KEY (deal_id, contact_id) +); + +CREATE TABLE amo_deal_companies ( + deal_id INTEGER REFERENCES amo_deals(id), + company_id INTEGER REFERENCES amo_companies(id), + is_main BOOLEAN DEFAULT FALSE, + PRIMARY KEY (deal_id, company_id) +); + +CREATE TABLE amo_contact_companies ( + contact_id INTEGER REFERENCES amo_contacts(id), + company_id INTEGER REFERENCES amo_companies(id), + is_main BOOLEAN DEFAULT FALSE, + PRIMARY KEY (contact_id, company_id) +); +``` + +### Custom Fields Handling + +```sql +-- Universal custom fields table +CREATE TABLE amo_custom_fields ( + id INTEGER PRIMARY KEY, + entity_type TEXT NOT NULL, -- 'deals', 'contacts', 'companies' + entity_id INTEGER NOT NULL, + field_id INTEGER NOT NULL, + field_name TEXT NOT NULL, + field_type TEXT NOT NULL, -- 'text', 'numeric', 'checkbox', 'select', 'multiselect', 'date', 'url', 'textarea' + field_value TEXT, + field_value_numeric REAL, + field_value_date INTEGER, + is_custom BOOLEAN DEFAULT TRUE, + created_at INTEGER DEFAULT (strftime('%s', 'now')), + updated_at INTEGER DEFAULT (strftime('%s', 'now')) +); + +-- Indexes for performance +CREATE INDEX idx_custom_fields_entity ON amo_custom_fields(entity_type, entity_id); +CREATE INDEX idx_custom_fields_field ON amo_custom_fields(field_id); +CREATE INDEX idx_custom_fields_name ON amo_custom_fields(field_name); +``` + +### Google Sheets Export Configuration + +```sql +-- Single unified export configuration +CREATE TABLE export_configuration ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + sheet_id TEXT NOT NULL, + date_range_start INTEGER, + date_range_end INTEGER, + is_active BOOLEAN DEFAULT TRUE, + created_at INTEGER DEFAULT (strftime('%s', 'now')), + updated_at INTEGER DEFAULT (strftime('%s', 'now')) +); + +-- Entity-specific sheet mappings within the configuration +CREATE TABLE export_entity_mappings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + configuration_id INTEGER REFERENCES export_configuration(id), + entity_type TEXT NOT NULL, -- 'deals', 'contacts', 'companies', 'pipelines', 'users', 'events' + sheet_name TEXT NOT NULL, -- Sheet tab name for this entity + field_mapping JSON NOT NULL, -- [{"field_name": "name", "column": "A", "order": 1}] + is_enabled BOOLEAN DEFAULT TRUE, + created_at INTEGER DEFAULT (strftime('%s', 'now')), + updated_at INTEGER DEFAULT (strftime('%s', 'now')) +); + +-- Export jobs tracking +CREATE TABLE export_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + configuration_id INTEGER REFERENCES export_configuration(id), + status TEXT NOT NULL DEFAULT 'pending', -- 'pending', 'running', 'completed', 'failed' + records_processed INTEGER DEFAULT 0, + total_records INTEGER DEFAULT 0, + error_message TEXT, + started_at INTEGER, + completed_at INTEGER, + created_at INTEGER DEFAULT (strftime('%s', 'now')) +); +``` + +## API Endpoints + +**Base URL**: `http://localhost:8000/api/v1` +**Authentication**: None required + +### 1. Entity Management + +#### GET /entities +List all entities with statistics +```json +{ + "entities": [ + { + "type": "deals", + "count": 1500, + "last_updated": "2024-01-15T10:30:00Z", + "date_range": { + "earliest": "2017-01-01T00:00:00Z", + "latest": "2024-01-15T10:30:00Z" + } + }, + { + "type": "contacts", + "count": 3200, + "last_updated": "2024-01-15T09:45:00Z", + "date_range": { + "earliest": "2017-03-15T00:00:00Z", + "latest": "2024-01-15T09:45:00Z" + } + } + ] +} +``` + +#### GET /entities/{entity_type}/fields +List fields with statistics and examples +```json +{ + "entity_type": "deals", + "fields": [ + { + "name": "name", + "type": "text", + "is_custom": false, + "usage_count": 1500, + "examples": ["Deal #1", "Important client deal", "Q4 opportunity"], + "null_count": 0 + }, + { + "name": "custom_priority", + "type": "select", + "is_custom": true, + "field_id": 123456, + "usage_count": 1200, + "examples": ["High", "Medium", "Low"], + "null_count": 300, + "possible_values": ["High", "Medium", "Low"] + } + ] +} +``` + +### 2. AMO CRM Integration + +#### GET /amocrm/info +Get AMO CRM connection information +```json +{ + "domain": "wecheap.amocrm.ru", + "has_token": true, + "base_url": "https://wecheap.amocrm.ru/api/v4", + "supported_entities": [ + "users", "pipelines", "companies", + "contacts", "deals", "events" + ] +} +``` + +#### GET /amocrm/fetch/{entity_type} +Fetch data directly from AMO CRM API +- **Parameters**: `limit` (default: 10), `page` (default: 1) +- **Supported entities**: users, pipelines, companies, contacts, deals, events +- **Returns**: Raw AMO CRM HAL+JSON response + +#### GET /amocrm/fetch/custom_fields/{entity_type} +Fetch custom fields metadata from AMO CRM +- **Supported entities**: leads, contacts, companies +- **Returns**: Custom fields definitions with enums + +#### GET /amocrm/fetch/all +Fetch all data from AMO CRM (limited amounts for testing) +- **Returns**: Complete data set from all entities + +#### POST /amocrm/sync/{entity_type} +Fetch data from AMO CRM and store it in database +- **Parameters**: `limit` (default: 250), `page` (default: 1) +- **Returns**: Sync status and fetched data + +### 3. Google Sheets Export + +#### POST /export/configure +Create or update unified export configuration for all entities +```json +{ + "name": "Q1 2024 AMO CRM Export", + "sheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "date_range_start": "2024-01-01T00:00:00Z", + "date_range_end": "2024-03-31T23:59:59Z", + "entity_mappings": { + "deals": { + "sheet_name": "Deals", + "is_enabled": true, + "field_mapping": [ + {"field_name": "name", "column": "A", "order": 1}, + {"field_name": "price", "column": "B", "order": 2}, + {"field_name": "created_at", "column": "C", "order": 3}, + {"field_name": "custom_priority", "column": "D", "order": 4} + ] + }, + "contacts": { + "sheet_name": "Contacts", + "is_enabled": true, + "field_mapping": [ + {"field_name": "name", "column": "A", "order": 1}, + {"field_name": "first_name", "column": "B", "order": 2}, + {"field_name": "last_name", "column": "C", "order": 3}, + {"field_name": "created_at", "column": "D", "order": 4} + ] + }, + "companies": { + "sheet_name": "Companies", + "is_enabled": true, + "field_mapping": [ + {"field_name": "name", "column": "A", "order": 1}, + {"field_name": "created_at", "column": "B", "order": 2} + ] + }, + "pipelines": { + "sheet_name": "Pipelines", + "is_enabled": false, + "field_mapping": [ + {"field_name": "name", "column": "A", "order": 1}, + {"field_name": "is_main", "column": "B", "order": 2} + ] + }, + "users": { + "sheet_name": "Users", + "is_enabled": false, + "field_mapping": [ + {"field_name": "name", "column": "A", "order": 1}, + {"field_name": "email", "column": "B", "order": 2} + ] + }, + "events": { + "sheet_name": "Events", + "is_enabled": true, + "field_mapping": [ + {"field_name": "type", "column": "A", "order": 1}, + {"field_name": "entity_type", "column": "B", "order": 2}, + {"field_name": "created_at", "column": "C", "order": 3} + ] + } + } +} +``` + +#### POST /export/start +Start async export job +```json +{ + "configuration_id": 1 +} +``` +Response: +```json +{ + "job_id": 42, + "status": "pending", + "message": "Export job queued successfully" +} +``` + +#### GET /export/status/{job_id} +Get export job status +```json +{ + "job_id": 42, + "status": "running", + "progress": { + "records_processed": 2100, + "total_records": 4200, + "percentage": 50, + "entities": { + "deals": {"processed": 1500, "total": 1500, "status": "completed"}, + "contacts": {"processed": 600, "total": 2200, "status": "running"}, + "companies": {"processed": 0, "total": 300, "status": "pending"}, + "events": {"processed": 0, "total": 200, "status": "pending"} + } + }, + "started_at": "2024-01-15T10:30:00Z", + "estimated_completion": "2024-01-15T10:35:00Z" +} +``` + +#### GET /export/jobs +List all export jobs +```json +{ + "jobs": [ + { + "job_id": 42, + "configuration_name": "Q1 2024 AMO CRM Export", + "status": "completed", + "records_processed": 4200, + "total_records": 4200, + "entities_processed": { + "deals": 1500, + "contacts": 2200, + "companies": 300, + "events": 200 + }, + "started_at": "2024-01-15T10:30:00Z", + "completed_at": "2024-01-15T10:35:00Z" + } + ], + "total": 1, + "page": 1, + "per_page": 20 +} +``` + +### 4. Data Ingestion (Worker API) + +#### POST /data/{entity_type} +Put data into database (used by workers) +```json +{ + "data": [ + { + "id": 123456, + "name": "Important Deal", + "price": 50000, + "responsible_user_id": 789, + "pipeline_id": 456, + "status_id": 142, + "created_at": 1640995200, + "updated_at": 1704067200, + "custom_fields": [ + { + "field_id": 123456, + "field_name": "Priority", + "field_type": "select", + "values": [{"value": "High", "enum_id": 1}] + } + ], + "_embedded": { + "contacts": [{"id": 111, "is_main": true}], + "companies": [{"id": 222, "is_main": true}] + } + } + ], + "sync_mode": "upsert" // "insert", "upsert", "replace" +} +``` + +## Data Processing Logic + +### 1. HAL+JSON Parsing +- Extract main entity data from root level +- Process `_embedded` section for related entities +- Handle `_links` section for additional relationships +- Parse custom fields from `custom_fields_values` array + +### 2. Date Inference +- Convert Unix timestamps to readable dates +- Validate dates are between 2017-2026 +- Handle null/missing dates appropriately + +### 3. Custom Fields Processing +```python +def process_custom_field(field_data, entity_type, entity_id): + """ + Process custom field from AMO CRM format + """ + field_record = { + 'entity_type': entity_type, + 'entity_id': entity_id, + 'field_id': field_data['field_id'], + 'field_name': field_data['field_name'], + 'field_type': field_data['field_type'], + 'is_custom': True + } + + # Handle different field types + if field_data['field_type'] in ['text', 'textarea', 'url']: + field_record['field_value'] = field_data['values'][0]['value'] + elif field_data['field_type'] == 'numeric': + field_record['field_value_numeric'] = float(field_data['values'][0]['value']) + field_record['field_value'] = str(field_data['values'][0]['value']) + elif field_data['field_type'] == 'date': + field_record['field_value_date'] = int(field_data['values'][0]['value']) + field_record['field_value'] = str(field_data['values'][0]['value']) + elif field_data['field_type'] in ['select', 'multiselect']: + # Handle enum values + values = [v['value'] for v in field_data['values']] + field_record['field_value'] = ', '.join(values) if len(values) > 1 else values[0] + + return field_record +``` + +### 4. Relationship Mapping +- Extract relationships from `_embedded` section +- Create junction table records for many-to-many relationships +- Handle `is_main` flags for primary relationships + +## Implementation Status + +### ✅ Completed Features + +#### Phase 1: Core Infrastructure +- ✅ FastAPI application structure with uv +- ✅ SQLite adapter with migrations (Alembic) +- ✅ Complete entity models with relationships +- ✅ AMO CRM API client for wecheap.amocrm.ru + +#### Phase 2: Data Ingestion +- ✅ HAL+JSON parser implementation +- ✅ Data ingestion endpoints for all entities +- ✅ Custom fields processing with type detection +- ✅ Relationship mapping (many-to-many) + +#### Phase 3: API Development +- ✅ Entity listing endpoints with statistics +- ✅ Field statistics endpoints with examples +- ✅ Export configuration management +- ✅ Direct AMO CRM integration endpoints + +#### Testing & Utilities +- ✅ Real AMO CRM response examples +- ✅ Comprehensive test suite +- ✅ Data fetching script for real responses +- ✅ API documentation (OpenAPI/Swagger) + +### 🔄 In Progress / TODO + +#### Phase 4: Google Sheets Integration +- 🔄 Google Sheets API client implementation +- 🔄 Actual export processing +- ✅ Export job management structure +- ✅ Job status tracking + +#### Phase 5: Workers & Background Processing +- 🔄 Celery worker implementation +- 🔄 Data synchronization workers +- 🔄 Error handling and retry logic +- 🔄 Monitoring and logging + +## Technology Stack + +- **Framework**: FastAPI (with uvicorn) +- **Package Manager**: uv (modern Python package management) +- **Database**: SQLite with SQLAlchemy 2.0 ORM +- **Migrations**: Alembic +- **HTTP Client**: httpx for AMO CRM API +- **Data Validation**: Pydantic v2 +- **Testing**: pytest with real AMO CRM fixtures +- **Background Jobs**: Celery with Redis (planned) +- **Google Sheets**: google-api-python-client (planned) +- **Development**: Black, isort, mypy, flake8 + +## Configuration + +### Environment Variables +```env +# Database +DATABASE_URL=sqlite:///./amo_data.db + +# AMO CRM API (wecheap.amocrm.ru) +AMO_CRM_DOMAIN=wecheap.amocrm.ru +AMO_CRM_ACCESS_TOKEN=your-longterm-access-token + +# Google Sheets API (optional) +GOOGLE_SERVICE_ACCOUNT_FILE=path/to/service-account.json +GOOGLE_SCOPES=https://www.googleapis.com/auth/spreadsheets + +# Redis (for future Celery integration) +REDIS_URL=redis://localhost:6379/0 + +# API Settings +API_V1_STR=/api/v1 +LOG_LEVEL=INFO +``` + +### Key Features +- **No Authentication**: Simple public API +- **Long-term Tokens**: Uses AMO CRM long-term access tokens +- **Real-time Testing**: Direct AMO CRM data fetching +- **Comprehensive Fixtures**: Real API response examples + +## Quick Start + +### 1. Setup +```bash +# Clone and setup +git clone +cd amo-server +uv sync + +# Configure environment +cp env.example .env +# Edit .env with your AMO CRM token +``` + +### 2. Run Service +```bash +# Start development server +uv run uvicorn app:app --reload + +# Access API documentation +# http://localhost:8000/docs +``` + +### 3. Test AMO CRM Connection +```bash +# Check connection +curl http://localhost:8000/api/v1/amocrm/info + +# Fetch real data +curl http://localhost:8000/api/v1/amocrm/fetch/users?limit=5 + +# Get all data for testing +uv run python scripts/fetch_amocrm_data.py +``` + +### 4. Example API Usage +```bash +# List entities +curl http://localhost:8000/api/v1/entities/ + +# Get deal fields +curl http://localhost:8000/api/v1/entities/deals/fields + +# Fetch deals from AMO CRM +curl http://localhost:8000/api/v1/amocrm/fetch/deals?limit=10 + +# Create export configuration +curl -X POST http://localhost:8000/api/v1/export/configure \ + -H "Content-Type: application/json" \ + -d '{"name": "Test Export", "sheet_id": "123", "entity_mappings": {...}}' +``` + +## Security Considerations + +1. **API Keys**: Store AMO CRM tokens securely in environment variables +2. **No Authentication**: Current API has no authentication (as requested) +3. **Rate Limiting**: Consider implementing rate limiting for production +4. **Data Validation**: All incoming data is validated with Pydantic +5. **CORS**: Currently allows all origins (configure for production) + +## Performance Considerations + +1. **Database Indexing**: Proper indexes on foreign keys and search fields +2. **Batch Processing**: Process data in batches to avoid memory issues +3. **Caching**: Use Redis for frequently accessed data +4. **Async Processing**: Use async/await for I/O operations +5. **Pagination**: Implement cursor-based pagination for large datasets + +## Monitoring & Logging + +1. **Application Metrics**: Track API response times, error rates +2. **Data Metrics**: Monitor data freshness, sync status +3. **Export Metrics**: Track export job success rates, processing times +4. **Error Tracking**: Comprehensive error logging and alerting +5. **Health Checks**: Implement health check endpoints + +## Error Handling + +1. **AMO CRM API Errors**: Handle rate limits, timeouts, authentication errors +2. **Google Sheets API Errors**: Handle quota limits, permission errors (planned) +3. **Data Validation Errors**: Proper error messages for invalid data +4. **Database Errors**: Handle connection issues, constraint violations +5. **Retry Logic**: Exponential backoff for transient failures (planned) + +## Scripts and Utilities + +### fetch_amocrm_data.py +- **Purpose**: Fetch real AMO CRM responses for testing +- **Usage**: `uv run python scripts/fetch_amocrm_data.py` +- **Output**: + - Individual JSON files in `tests/fixtures/real_responses/` + - Python fixtures in `tests/fixtures/real_amocrm_responses.py` + - Summary of fetched data + +### Features +- Fetches all entity types from wecheap.amocrm.ru +- Includes custom fields metadata +- Proper error handling with troubleshooting tips +- Generates both JSON and Python fixtures + +## Testing + +### Test Structure +- **Unit Tests**: `tests/test_amocrm_examples.py` - Test AMO CRM response structure +- **API Tests**: `tests/test_api.py` - Test all API endpoints with real data +- **Fixtures**: Real AMO CRM responses in multiple formats + +### Running Tests +```bash +# Run all tests +uv run pytest + +# Run with coverage +uv run pytest --cov=. --cov-report=html + +# Run specific test file +uv run pytest tests/test_api.py -v +``` + +## Development Workflow + +### Code Quality +```bash +# Format code +uv run black . +uv run isort . + +# Lint code +uv run flake8 + +# Type checking +uv run mypy . +``` + +### Database Migrations +```bash +# Generate migration +alembic revision --autogenerate -m "Description" + +# Apply migrations +alembic upgrade head +``` diff --git a/env.docker.example b/env.docker.example new file mode 100644 index 0000000..2ad4072 --- /dev/null +++ b/env.docker.example @@ -0,0 +1,10 @@ +# Example environment file for Docker Compose +# Copy this to .env and fill in your actual values + +# AMO CRM API +AMO_CRM_DOMAIN=your-domain.amocrm.ru +AMO_CRM_ACCESS_TOKEN=your-longterm-access-token + +# Google Sheets API +GOOGLE_SERVICE_ACCOUNT_FILE=/app/credentials/service-account.json +GOOGLE_SCOPES=https://www.googleapis.com/auth/spreadsheets diff --git a/env.example b/env.example new file mode 100644 index 0000000..212c777 --- /dev/null +++ b/env.example @@ -0,0 +1,19 @@ +# Database +DATABASE_URL=sqlite:///./amo_data.db + +# AMO CRM API +AMO_CRM_DOMAIN=wecheap.amocrm.ru +AMO_CRM_ACCESS_TOKEN=your-longterm-access-token + +# Google Sheets API +GOOGLE_SERVICE_ACCOUNT_FILE=path/to/service-account.json +GOOGLE_SCOPES=https://www.googleapis.com/auth/spreadsheets + +# Redis (for Celery) +REDIS_URL=redis://localhost:6379/0 + +# API Settings +API_V1_STR=/api/v1 + +# Logging +LOG_LEVEL=INFO diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..957be5b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,83 @@ +[project] +name = "amo-server" +version = "0.1.0" +description = "AMO CRM data collection service" +authors = [ + {name = "Developer", email = "dev@example.com"} +] +dependencies = [ + "fastapi>=0.104.0", + "uvicorn[standard]>=0.24.0", + "sqlalchemy>=2.0.0", + "alembic>=1.13.0", + "pydantic>=2.5.0", + "pydantic-settings>=2.1.0", + "httpx>=0.25.0", + "python-multipart>=0.0.6", + "google-api-python-client>=2.100.0", + "google-auth-httplib2>=0.2.0", + "google-auth-oauthlib>=1.1.0", + "celery>=5.3.0", + "redis>=5.0.0", + "python-dotenv>=1.0.0", +] +requires-python = ">=3.12" +readme = "README.md" +license = {text = "MIT"} + +[project.optional-dependencies] +dev = [ + "pytest>=7.4.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.1.0", + "black>=23.9.0", + "isort>=5.12.0", + "flake8>=6.1.0", + "mypy>=1.6.0", + "pre-commit>=3.5.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["adapters", "routers", "servers", "utils", "workers"] + +[tool.black] +line-length = 88 +target-version = ['py312'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 +known_first_party = ["adapters", "routers", "servers", "utils", "workers"] + +[tool.mypy] +python_version = "3.12" +check_untyped_defs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +warn_redundant_casts = true +warn_unused_ignores = true +strict_optional = true + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = "--cov=. --cov-report=term-missing --cov-report=html" diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..873f7bb --- /dev/null +++ b/routers/__init__.py @@ -0,0 +1 @@ +# Routers package diff --git a/routers/amocrm.py b/routers/amocrm.py new file mode 100644 index 0000000..f29bfae --- /dev/null +++ b/routers/amocrm.py @@ -0,0 +1,121 @@ +from fastapi import APIRouter, HTTPException +from typing import Dict, Any, Optional, List +import asyncio + +from adapters.amocrm_client import AmoCRMClient + +router = APIRouter() + + +@router.get("/fetch/{entity_type}") +async def fetch_amocrm_entity( + entity_type: str, + limit: int = 10, + page: int = 1 +) -> Dict[str, Any]: + """Fetch data directly from AMO CRM API""" + + try: + client = AmoCRMClient() + + if entity_type == "users": + return await client.get_users(limit=limit) + elif entity_type == "pipelines": + return await client.get_pipelines() + elif entity_type == "companies": + return await client.get_companies(limit=limit, page=page) + elif entity_type == "contacts": + return await client.get_contacts(limit=limit, page=page) + elif entity_type == "deals": + return await client.get_deals(limit=limit, page=page) + elif entity_type == "events": + return await client.get_events(limit=limit, page=page) + else: + raise HTTPException(status_code=404, detail=f"Entity type '{entity_type}' not supported") + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error fetching from AMO CRM: {str(e)}") + + +@router.get("/fetch/custom_fields/{entity_type}") +async def fetch_custom_fields(entity_type: str) -> Dict[str, Any]: + """Fetch custom fields metadata from AMO CRM""" + + valid_types = ["leads", "contacts", "companies"] + if entity_type not in valid_types: + raise HTTPException(status_code=400, detail=f"Entity type must be one of: {valid_types}") + + try: + client = AmoCRMClient() + return await client.get_custom_fields(entity_type) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error fetching custom fields: {str(e)}") + + +@router.get("/fetch/all") +async def fetch_all_amocrm_data() -> Dict[str, Any]: + """Fetch all data from AMO CRM (limited amounts for testing)""" + + try: + client = AmoCRMClient() + return await client.fetch_all_data() + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error fetching all data: {str(e)}") + + +@router.post("/sync/{entity_type}") +async def sync_entity_from_amocrm( + entity_type: str, + limit: int = 250, + page: int = 1 +) -> Dict[str, Any]: + """Fetch data from AMO CRM and store it in database""" + + # This would integrate with the data ingestion endpoints + # For now, just fetch the data + try: + client = AmoCRMClient() + + if entity_type == "users": + data = await client.get_users(limit=limit) + elif entity_type == "pipelines": + data = await client.get_pipelines() + elif entity_type == "companies": + data = await client.get_companies(limit=limit, page=page) + elif entity_type == "contacts": + data = await client.get_contacts(limit=limit, page=page) + elif entity_type == "deals": + data = await client.get_deals(limit=limit, page=page) + elif entity_type == "events": + data = await client.get_events(limit=limit, page=page) + else: + raise HTTPException(status_code=404, detail=f"Entity type '{entity_type}' not supported") + + # TODO: Integrate with data ingestion endpoints + # For now, return the fetched data + return { + "message": f"Fetched {entity_type} from AMO CRM", + "data": data, + "sync_status": "fetched_only" # Would be "synced" when integrated + } + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error syncing from AMO CRM: {str(e)}") + + +@router.get("/info") +async def amocrm_info() -> Dict[str, Any]: + """Get AMO CRM connection info""" + from utils.config import settings + + return { + "domain": settings.AMO_CRM_DOMAIN, + "has_token": bool(settings.AMO_CRM_ACCESS_TOKEN), + "base_url": f"https://{settings.AMO_CRM_DOMAIN}/api/v4", + "supported_entities": [ + "users", "pipelines", "companies", + "contacts", "deals", "events" + ] + } diff --git a/routers/data.py b/routers/data.py new file mode 100644 index 0000000..6eb54af --- /dev/null +++ b/routers/data.py @@ -0,0 +1,528 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import Dict, Any, List, Optional, Union +from pydantic import BaseModel +from datetime import datetime +import time +import json + +from adapters.sqlite.database import get_db +from adapters.sqlite.models import ( + Deal, Contact, Company, Pipeline, PipelineStage, User, Event, CustomField, + deal_contacts, deal_companies, contact_companies +) + +router = APIRouter() + + +class CustomFieldValue(BaseModel): + field_id: int + field_name: str + field_type: str + values: List[Dict[str, Any]] + + +class EmbeddedRelation(BaseModel): + id: int + is_main: Optional[bool] = None + + +class DataItem(BaseModel): + id: int + name: Optional[str] = None + # Add other common fields as needed + created_at: Optional[int] = None + updated_at: Optional[int] = None + custom_fields_values: Optional[List[CustomFieldValue]] = None + _embedded: Optional[Dict[str, List[EmbeddedRelation]]] = None + # Store raw data for complete preservation + raw_data: Optional[Dict[str, Any]] = None + + +class DataIngestionRequest(BaseModel): + data: List[Dict[str, Any]] + sync_mode: str = "upsert" # "insert", "upsert", "replace" + + +def process_custom_fields( + custom_fields: List[Dict[str, Any]], + entity_type: str, + entity_id: int, + db: Session +): + """Process and store custom fields""" + + # Clear existing custom fields for this entity if replacing + db.query(CustomField).filter( + CustomField.entity_type == entity_type, + CustomField.entity_id == entity_id + ).delete() + + for field_data in custom_fields: + field_id = field_data.get('field_id') + field_name = field_data.get('field_name', '') + field_type = field_data.get('field_type', 'text') + values = field_data.get('values', []) + + for value_data in values: + value = value_data.get('value', '') + + custom_field = CustomField( + entity_type=entity_type, + entity_id=entity_id, + field_id=field_id, + field_name=field_name, + field_type=field_type, + field_value=str(value) if value else None, + is_custom=True, + created_at=int(time.time()), + updated_at=int(time.time()) + ) + + # Handle different field types + if field_type == 'numeric' and value: + try: + custom_field.field_value_numeric = float(value) + except (ValueError, TypeError): + pass + + elif field_type == 'date' and value: + try: + # AMO CRM returns dates as unix timestamps + custom_field.field_value_date = int(value) + except (ValueError, TypeError): + pass + + elif field_type in ['select', 'multiselect']: + # For select fields, store the display value + custom_field.field_value = str(value) + + db.add(custom_field) + + +def process_relationships( + embedded_data: Dict[str, Any], + entity_id: int, + entity_type: str, + db: Session +): + """Process embedded relationships""" + + if entity_type == "deals": + # Process deal-contact relationships + if 'contacts' in embedded_data: + # Clear existing relationships + db.execute( + deal_contacts.delete().where(deal_contacts.c.deal_id == entity_id) + ) + + for contact_data in embedded_data['contacts']: + contact_id = contact_data['id'] + is_main = contact_data.get('is_main', False) + + # Insert relationship + db.execute( + deal_contacts.insert().values( + deal_id=entity_id, + contact_id=contact_id, + is_main=is_main + ) + ) + + # Process deal-company relationships + if 'companies' in embedded_data: + # Clear existing relationships + db.execute( + deal_companies.delete().where(deal_companies.c.deal_id == entity_id) + ) + + for company_data in embedded_data['companies']: + company_id = company_data['id'] + is_main = company_data.get('is_main', False) + + # Insert relationship + db.execute( + deal_companies.insert().values( + deal_id=entity_id, + company_id=company_id, + is_main=is_main + ) + ) + + elif entity_type == "contacts": + # Process contact-company relationships + if 'companies' in embedded_data: + # Clear existing relationships + db.execute( + contact_companies.delete().where(contact_companies.c.contact_id == entity_id) + ) + + for company_data in embedded_data['companies']: + company_id = company_data['id'] + is_main = company_data.get('is_main', False) + + # Insert relationship + db.execute( + contact_companies.insert().values( + contact_id=entity_id, + company_id=company_id, + is_main=is_main + ) + ) + + +@router.post("/users") +async def put_users_data( + request: DataIngestionRequest, + db: Session = Depends(get_db) +) -> Dict[str, Any]: + """Put users data into database""" + + processed_count = 0 + + for user_data in request.data: + user_id = user_data['id'] + + # Check if user exists + existing_user = db.query(User).filter(User.id == user_id).first() + + if request.sync_mode == "insert" and existing_user: + continue # Skip existing records in insert mode + + user_values = { + 'id': user_id, + 'name': user_data.get('name'), + 'email': user_data.get('email'), + 'is_active': user_data.get('is_active', True), + 'created_at': user_data.get('created_at'), + 'updated_at': user_data.get('updated_at', int(time.time())), + 'raw_data': user_data + } + + if existing_user: + # Update existing user + for key, value in user_values.items(): + if key != 'id': # Don't update ID + setattr(existing_user, key, value) + else: + # Create new user + new_user = User(**user_values) + db.add(new_user) + + processed_count += 1 + + db.commit() + + return { + "message": f"Processed {processed_count} users", + "processed_count": processed_count + } + + +@router.post("/pipelines") +async def put_pipelines_data( + request: DataIngestionRequest, + db: Session = Depends(get_db) +) -> Dict[str, Any]: + """Put pipelines data into database""" + + processed_count = 0 + + for pipeline_data in request.data: + pipeline_id = pipeline_data['id'] + + # Check if pipeline exists + existing_pipeline = db.query(Pipeline).filter(Pipeline.id == pipeline_id).first() + + if request.sync_mode == "insert" and existing_pipeline: + continue + + pipeline_values = { + 'id': pipeline_id, + 'name': pipeline_data.get('name'), + 'sort': pipeline_data.get('sort'), + 'is_main': pipeline_data.get('is_main', False), + 'is_unsorted': pipeline_data.get('is_unsorted', False), + 'is_archive': pipeline_data.get('is_archive', False), + 'account_id': pipeline_data.get('account_id'), + 'created_at': pipeline_data.get('created_at'), + 'updated_at': pipeline_data.get('updated_at', int(time.time())), + 'raw_data': pipeline_data + } + + if existing_pipeline: + for key, value in pipeline_values.items(): + if key != 'id': + setattr(existing_pipeline, key, value) + else: + new_pipeline = Pipeline(**pipeline_values) + db.add(new_pipeline) + + # Process pipeline stages + embedded_data = pipeline_data.get('_embedded', {}) + if 'statuses' in embedded_data: + for status_data in embedded_data['statuses']: + status_id = status_data['id'] + + existing_stage = db.query(PipelineStage).filter(PipelineStage.id == status_id).first() + + stage_values = { + 'id': status_id, + 'pipeline_id': pipeline_id, + 'name': status_data.get('name'), + 'sort': status_data.get('sort'), + 'is_editable': status_data.get('is_editable', True), + 'color': status_data.get('color'), + 'created_at': status_data.get('created_at'), + 'updated_at': status_data.get('updated_at', int(time.time())), + 'raw_data': status_data + } + + if existing_stage: + for key, value in stage_values.items(): + if key != 'id': + setattr(existing_stage, key, value) + else: + new_stage = PipelineStage(**stage_values) + db.add(new_stage) + + processed_count += 1 + + db.commit() + + return { + "message": f"Processed {processed_count} pipelines", + "processed_count": processed_count + } + + +@router.post("/companies") +async def put_companies_data( + request: DataIngestionRequest, + db: Session = Depends(get_db) +) -> Dict[str, Any]: + """Put companies data into database""" + + processed_count = 0 + + for company_data in request.data: + company_id = company_data['id'] + + existing_company = db.query(Company).filter(Company.id == company_id).first() + + if request.sync_mode == "insert" and existing_company: + continue + + company_values = { + 'id': company_id, + 'name': company_data.get('name'), + 'responsible_user_id': company_data.get('responsible_user_id'), + 'group_id': company_data.get('group_id'), + 'created_by': company_data.get('created_by'), + 'updated_by': company_data.get('updated_by'), + 'created_at': company_data.get('created_at'), + 'updated_at': company_data.get('updated_at', int(time.time())), + 'closest_task_at': company_data.get('closest_task_at'), + 'is_deleted': company_data.get('is_deleted', False), + 'raw_data': company_data + } + + if existing_company: + for key, value in company_values.items(): + if key != 'id': + setattr(existing_company, key, value) + else: + new_company = Company(**company_values) + db.add(new_company) + + # Process custom fields + custom_fields = company_data.get('custom_fields_values', []) + if custom_fields: + process_custom_fields(custom_fields, "companies", company_id, db) + + processed_count += 1 + + db.commit() + + return { + "message": f"Processed {processed_count} companies", + "processed_count": processed_count + } + + +@router.post("/contacts") +async def put_contacts_data( + request: DataIngestionRequest, + db: Session = Depends(get_db) +) -> Dict[str, Any]: + """Put contacts data into database""" + + processed_count = 0 + + for contact_data in request.data: + contact_id = contact_data['id'] + + existing_contact = db.query(Contact).filter(Contact.id == contact_id).first() + + if request.sync_mode == "insert" and existing_contact: + continue + + contact_values = { + 'id': contact_id, + 'name': contact_data.get('name'), + 'first_name': contact_data.get('first_name'), + 'last_name': contact_data.get('last_name'), + 'responsible_user_id': contact_data.get('responsible_user_id'), + 'group_id': contact_data.get('group_id'), + 'created_by': contact_data.get('created_by'), + 'updated_by': contact_data.get('updated_by'), + 'created_at': contact_data.get('created_at'), + 'updated_at': contact_data.get('updated_at', int(time.time())), + 'closest_task_at': contact_data.get('closest_task_at'), + 'is_deleted': contact_data.get('is_deleted', False), + 'raw_data': contact_data + } + + if existing_contact: + for key, value in contact_values.items(): + if key != 'id': + setattr(existing_contact, key, value) + else: + new_contact = Contact(**contact_values) + db.add(new_contact) + + # Process custom fields + custom_fields = contact_data.get('custom_fields_values', []) + if custom_fields: + process_custom_fields(custom_fields, "contacts", contact_id, db) + + # Process relationships + embedded_data = contact_data.get('_embedded', {}) + if embedded_data: + process_relationships(embedded_data, contact_id, "contacts", db) + + processed_count += 1 + + db.commit() + + return { + "message": f"Processed {processed_count} contacts", + "processed_count": processed_count + } + + +@router.post("/deals") +async def put_deals_data( + request: DataIngestionRequest, + db: Session = Depends(get_db) +) -> Dict[str, Any]: + """Put deals (leads) data into database""" + + processed_count = 0 + + for deal_data in request.data: + deal_id = deal_data['id'] + + existing_deal = db.query(Deal).filter(Deal.id == deal_id).first() + + if request.sync_mode == "insert" and existing_deal: + continue + + deal_values = { + 'id': deal_id, + 'name': deal_data.get('name'), + 'price': deal_data.get('price', 0), + 'responsible_user_id': deal_data.get('responsible_user_id'), + 'group_id': deal_data.get('group_id'), + 'status_id': deal_data.get('status_id'), + 'pipeline_id': deal_data.get('pipeline_id'), + 'loss_reason_id': deal_data.get('loss_reason_id'), + 'created_by': deal_data.get('created_by'), + 'updated_by': deal_data.get('updated_by'), + 'closed_at': deal_data.get('closed_at'), + 'created_at': deal_data.get('created_at'), + 'updated_at': deal_data.get('updated_at', int(time.time())), + 'closest_task_at': deal_data.get('closest_task_at'), + 'is_deleted': deal_data.get('is_deleted', False), + 'raw_data': deal_data + } + + if existing_deal: + for key, value in deal_values.items(): + if key != 'id': + setattr(existing_deal, key, value) + else: + new_deal = Deal(**deal_values) + db.add(new_deal) + + # Process custom fields + custom_fields = deal_data.get('custom_fields_values', []) + if custom_fields: + process_custom_fields(custom_fields, "deals", deal_id, db) + + # Process relationships + embedded_data = deal_data.get('_embedded', {}) + if embedded_data: + process_relationships(embedded_data, deal_id, "deals", db) + + processed_count += 1 + + db.commit() + + return { + "message": f"Processed {processed_count} deals", + "processed_count": processed_count + } + + +@router.post("/events") +async def put_events_data( + request: DataIngestionRequest, + db: Session = Depends(get_db) +) -> Dict[str, Any]: + """Put events data into database""" + + processed_count = 0 + + for event_data in request.data: + event_id = event_data['id'] + event_type = event_data.get('type') + + # Filter only supported event types + if event_type not in ['incoming_call', 'outgoing_call', 'lead_status_changed']: + continue + + existing_event = db.query(Event).filter(Event.id == event_id).first() + + if request.sync_mode == "insert" and existing_event: + continue + + event_values = { + 'id': event_id, + 'type': event_type, + 'entity_id': event_data.get('entity_id'), + 'entity_type': event_data.get('entity_type'), + 'created_by': event_data.get('created_by'), + 'created_at': event_data.get('created_at'), + 'value_after': event_data.get('value_after'), + 'value_before': event_data.get('value_before'), + 'account_id': event_data.get('account_id'), + 'raw_data': event_data + } + + if existing_event: + for key, value in event_values.items(): + if key != 'id': + setattr(existing_event, key, value) + else: + new_event = Event(**event_values) + db.add(new_event) + + processed_count += 1 + + db.commit() + + return { + "message": f"Processed {processed_count} events", + "processed_count": processed_count + } diff --git a/routers/entities.py b/routers/entities.py new file mode 100644 index 0000000..d49bce9 --- /dev/null +++ b/routers/entities.py @@ -0,0 +1,324 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from sqlalchemy import func, distinct +from typing import List, Dict, Any, Optional +from datetime import datetime + +from adapters.sqlite.database import get_db +from adapters.sqlite.models import ( + Deal, Contact, Company, Pipeline, User, Event, CustomField +) + +router = APIRouter() + + +@router.get("/") +async def list_entities(db: Session = Depends(get_db)) -> Dict[str, Any]: + """List all entities with statistics""" + + entities = {} + + # Deals statistics + deals_count = db.query(Deal).count() + deals_date_range = db.query( + func.min(Deal.created_at).label('earliest'), + func.max(Deal.updated_at).label('latest') + ).first() + + entities["deals"] = { + "count": deals_count, + "last_updated": datetime.fromtimestamp(deals_date_range.latest).isoformat() + "Z" if deals_date_range.latest else None, + "date_range": { + "earliest": datetime.fromtimestamp(deals_date_range.earliest).isoformat() + "Z" if deals_date_range.earliest else None, + "latest": datetime.fromtimestamp(deals_date_range.latest).isoformat() + "Z" if deals_date_range.latest else None + } + } + + # Contacts statistics + contacts_count = db.query(Contact).count() + contacts_date_range = db.query( + func.min(Contact.created_at).label('earliest'), + func.max(Contact.updated_at).label('latest') + ).first() + + entities["contacts"] = { + "count": contacts_count, + "last_updated": datetime.fromtimestamp(contacts_date_range.latest).isoformat() + "Z" if contacts_date_range.latest else None, + "date_range": { + "earliest": datetime.fromtimestamp(contacts_date_range.earliest).isoformat() + "Z" if contacts_date_range.earliest else None, + "latest": datetime.fromtimestamp(contacts_date_range.latest).isoformat() + "Z" if contacts_date_range.latest else None + } + } + + # Companies statistics + companies_count = db.query(Company).count() + companies_date_range = db.query( + func.min(Company.created_at).label('earliest'), + func.max(Company.updated_at).label('latest') + ).first() + + entities["companies"] = { + "count": companies_count, + "last_updated": datetime.fromtimestamp(companies_date_range.latest).isoformat() + "Z" if companies_date_range.latest else None, + "date_range": { + "earliest": datetime.fromtimestamp(companies_date_range.earliest).isoformat() + "Z" if companies_date_range.earliest else None, + "latest": datetime.fromtimestamp(companies_date_range.latest).isoformat() + "Z" if companies_date_range.latest else None + } + } + + # Pipelines statistics + pipelines_count = db.query(Pipeline).count() + pipelines_date_range = db.query( + func.min(Pipeline.created_at).label('earliest'), + func.max(Pipeline.updated_at).label('latest') + ).first() + + entities["pipelines"] = { + "count": pipelines_count, + "last_updated": datetime.fromtimestamp(pipelines_date_range.latest).isoformat() + "Z" if pipelines_date_range.latest else None, + "date_range": { + "earliest": datetime.fromtimestamp(pipelines_date_range.earliest).isoformat() + "Z" if pipelines_date_range.earliest else None, + "latest": datetime.fromtimestamp(pipelines_date_range.latest).isoformat() + "Z" if pipelines_date_range.latest else None + } + } + + # Users statistics (no date range as they don't change often) + users_count = db.query(User).count() + entities["users"] = { + "count": users_count, + "last_updated": None, + "date_range": { + "earliest": None, + "latest": None + } + } + + # Events statistics + events_count = db.query(Event).count() + events_date_range = db.query( + func.min(Event.created_at).label('earliest'), + func.max(Event.created_at).label('latest') + ).first() + + entities["events"] = { + "count": events_count, + "last_updated": datetime.fromtimestamp(events_date_range.latest).isoformat() + "Z" if events_date_range.latest else None, + "date_range": { + "earliest": datetime.fromtimestamp(events_date_range.earliest).isoformat() + "Z" if events_date_range.earliest else None, + "latest": datetime.fromtimestamp(events_date_range.latest).isoformat() + "Z" if events_date_range.latest else None + } + } + + return {"entities": entities} + + +@router.get("/{entity_type}/fields") +async def list_entity_fields(entity_type: str, db: Session = Depends(get_db)) -> Dict[str, Any]: + """List fields with statistics and examples for specified entity""" + + if entity_type not in ["deals", "contacts", "companies", "pipelines", "users", "events"]: + raise HTTPException(status_code=404, detail="Entity type not found") + + # Get standard fields based on entity type + standard_fields = [] + + if entity_type == "deals": + # Sample some deals to get examples + sample_deals = db.query(Deal).limit(3).all() + standard_fields = [ + { + "name": "name", + "type": "text", + "is_custom": False, + "usage_count": db.query(Deal).filter(Deal.name.isnot(None)).count(), + "examples": [deal.name for deal in sample_deals if deal.name][:3], + "null_count": db.query(Deal).filter(Deal.name.is_(None)).count() + }, + { + "name": "price", + "type": "numeric", + "is_custom": False, + "usage_count": db.query(Deal).filter(Deal.price.isnot(None)).count(), + "examples": [deal.price for deal in sample_deals if deal.price is not None][:3], + "null_count": db.query(Deal).filter(Deal.price.is_(None)).count() + }, + { + "name": "created_at", + "type": "date", + "is_custom": False, + "usage_count": db.query(Deal).filter(Deal.created_at.isnot(None)).count(), + "examples": [datetime.fromtimestamp(deal.created_at).isoformat() + "Z" for deal in sample_deals if deal.created_at][:3], + "null_count": db.query(Deal).filter(Deal.created_at.is_(None)).count() + }, + { + "name": "updated_at", + "type": "date", + "is_custom": False, + "usage_count": db.query(Deal).filter(Deal.updated_at.isnot(None)).count(), + "examples": [datetime.fromtimestamp(deal.updated_at).isoformat() + "Z" for deal in sample_deals if deal.updated_at][:3], + "null_count": db.query(Deal).filter(Deal.updated_at.is_(None)).count() + } + ] + + elif entity_type == "contacts": + sample_contacts = db.query(Contact).limit(3).all() + standard_fields = [ + { + "name": "name", + "type": "text", + "is_custom": False, + "usage_count": db.query(Contact).filter(Contact.name.isnot(None)).count(), + "examples": [contact.name for contact in sample_contacts if contact.name][:3], + "null_count": db.query(Contact).filter(Contact.name.is_(None)).count() + }, + { + "name": "first_name", + "type": "text", + "is_custom": False, + "usage_count": db.query(Contact).filter(Contact.first_name.isnot(None)).count(), + "examples": [contact.first_name for contact in sample_contacts if contact.first_name][:3], + "null_count": db.query(Contact).filter(Contact.first_name.is_(None)).count() + }, + { + "name": "last_name", + "type": "text", + "is_custom": False, + "usage_count": db.query(Contact).filter(Contact.last_name.isnot(None)).count(), + "examples": [contact.last_name for contact in sample_contacts if contact.last_name][:3], + "null_count": db.query(Contact).filter(Contact.last_name.is_(None)).count() + } + ] + + elif entity_type == "companies": + sample_companies = db.query(Company).limit(3).all() + standard_fields = [ + { + "name": "name", + "type": "text", + "is_custom": False, + "usage_count": db.query(Company).filter(Company.name.isnot(None)).count(), + "examples": [company.name for company in sample_companies if company.name][:3], + "null_count": db.query(Company).filter(Company.name.is_(None)).count() + } + ] + + elif entity_type == "pipelines": + sample_pipelines = db.query(Pipeline).limit(3).all() + standard_fields = [ + { + "name": "name", + "type": "text", + "is_custom": False, + "usage_count": db.query(Pipeline).filter(Pipeline.name.isnot(None)).count(), + "examples": [pipeline.name for pipeline in sample_pipelines if pipeline.name][:3], + "null_count": db.query(Pipeline).filter(Pipeline.name.is_(None)).count() + }, + { + "name": "is_main", + "type": "checkbox", + "is_custom": False, + "usage_count": db.query(Pipeline).filter(Pipeline.is_main.isnot(None)).count(), + "examples": [pipeline.is_main for pipeline in sample_pipelines if pipeline.is_main is not None][:3], + "null_count": db.query(Pipeline).filter(Pipeline.is_main.is_(None)).count() + } + ] + + elif entity_type == "users": + sample_users = db.query(User).limit(3).all() + standard_fields = [ + { + "name": "name", + "type": "text", + "is_custom": False, + "usage_count": db.query(User).filter(User.name.isnot(None)).count(), + "examples": [user.name for user in sample_users if user.name][:3], + "null_count": db.query(User).filter(User.name.is_(None)).count() + }, + { + "name": "email", + "type": "text", + "is_custom": False, + "usage_count": db.query(User).filter(User.email.isnot(None)).count(), + "examples": [user.email for user in sample_users if user.email][:3], + "null_count": db.query(User).filter(User.email.is_(None)).count() + } + ] + + elif entity_type == "events": + sample_events = db.query(Event).limit(3).all() + standard_fields = [ + { + "name": "type", + "type": "select", + "is_custom": False, + "usage_count": db.query(Event).filter(Event.type.isnot(None)).count(), + "examples": [event.type for event in sample_events if event.type][:3], + "null_count": db.query(Event).filter(Event.type.is_(None)).count(), + "possible_values": ["incoming_call", "outgoing_call", "lead_status_changed"] + }, + { + "name": "entity_type", + "type": "text", + "is_custom": False, + "usage_count": db.query(Event).filter(Event.entity_type.isnot(None)).count(), + "examples": [event.entity_type for event in sample_events if event.entity_type][:3], + "null_count": db.query(Event).filter(Event.entity_type.is_(None)).count() + } + ] + + # Get custom fields for this entity type + custom_fields_query = db.query(CustomField).filter( + CustomField.entity_type == entity_type + ).group_by(CustomField.field_name, CustomField.field_type).all() + + custom_fields = [] + for field_group in custom_fields_query: + # Get examples for this custom field + examples_query = db.query(CustomField.field_value).filter( + CustomField.entity_type == entity_type, + CustomField.field_name == field_group.field_name, + CustomField.field_value.isnot(None) + ).limit(3).all() + + examples = [ex[0] for ex in examples_query if ex[0]] + + # Count usage + usage_count = db.query(CustomField).filter( + CustomField.entity_type == entity_type, + CustomField.field_name == field_group.field_name, + CustomField.field_value.isnot(None) + ).count() + + null_count = db.query(CustomField).filter( + CustomField.entity_type == entity_type, + CustomField.field_name == field_group.field_name, + CustomField.field_value.is_(None) + ).count() + + custom_field = { + "name": field_group.field_name, + "type": field_group.field_type, + "is_custom": True, + "field_id": field_group.field_id, + "usage_count": usage_count, + "examples": examples, + "null_count": null_count + } + + # For select fields, get possible values + if field_group.field_type in ["select", "multiselect"]: + possible_values = db.query(distinct(CustomField.field_value)).filter( + CustomField.entity_type == entity_type, + CustomField.field_name == field_group.field_name, + CustomField.field_value.isnot(None) + ).all() + custom_field["possible_values"] = [pv[0] for pv in possible_values if pv[0]] + + custom_fields.append(custom_field) + + all_fields = standard_fields + custom_fields + + return { + "entity_type": entity_type, + "fields": all_fields + } diff --git a/routers/export.py b/routers/export.py new file mode 100644 index 0000000..6eaab64 --- /dev/null +++ b/routers/export.py @@ -0,0 +1,268 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import Dict, Any, List, Optional +from pydantic import BaseModel +from datetime import datetime +import time + +from adapters.sqlite.database import get_db +from adapters.sqlite.models import ExportConfiguration, ExportEntityMapping, ExportJob + +router = APIRouter() + + +class FieldMapping(BaseModel): + field_name: str + column: str + order: int + + +class EntityMappingConfig(BaseModel): + sheet_name: str + is_enabled: bool = True + field_mapping: List[FieldMapping] + + +class ExportConfigurationRequest(BaseModel): + name: str + sheet_id: str + date_range_start: Optional[str] = None + date_range_end: Optional[str] = None + entity_mappings: Dict[str, EntityMappingConfig] + + +class ExportJobStart(BaseModel): + configuration_id: int + + +@router.post("/configure") +async def create_export_configuration( + config: ExportConfigurationRequest, + db: Session = Depends(get_db) +) -> Dict[str, Any]: + """Create or update unified export configuration for all entities""" + + # Convert date strings to timestamps + date_start = None + date_end = None + + if config.date_range_start: + try: + date_start = int(datetime.fromisoformat(config.date_range_start.replace('Z', '+00:00')).timestamp()) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date_range_start format") + + if config.date_range_end: + try: + date_end = int(datetime.fromisoformat(config.date_range_end.replace('Z', '+00:00')).timestamp()) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid date_range_end format") + + # Create configuration + db_config = ExportConfiguration( + name=config.name, + sheet_id=config.sheet_id, + date_range_start=date_start, + date_range_end=date_end, + is_active=True, + created_at=int(time.time()), + updated_at=int(time.time()) + ) + + db.add(db_config) + db.flush() # Get the ID + + # Create entity mappings + valid_entities = ["deals", "contacts", "companies", "pipelines", "users", "events"] + + for entity_type, mapping_config in config.entity_mappings.items(): + if entity_type not in valid_entities: + raise HTTPException(status_code=400, detail=f"Invalid entity type: {entity_type}") + + # Convert field mapping to JSON + field_mapping_json = [ + { + "field_name": fm.field_name, + "column": fm.column, + "order": fm.order + } + for fm in mapping_config.field_mapping + ] + + db_mapping = ExportEntityMapping( + configuration_id=db_config.id, + entity_type=entity_type, + sheet_name=mapping_config.sheet_name, + field_mapping=field_mapping_json, + is_enabled=mapping_config.is_enabled, + created_at=int(time.time()), + updated_at=int(time.time()) + ) + + db.add(db_mapping) + + db.commit() + + return { + "configuration_id": db_config.id, + "message": "Export configuration created successfully" + } + + +@router.get("/configurations") +async def list_export_configurations(db: Session = Depends(get_db)) -> Dict[str, Any]: + """List all export configurations""" + + configs = db.query(ExportConfiguration).filter( + ExportConfiguration.is_active == True + ).all() + + result = [] + for config in configs: + entity_mappings = {} + for mapping in config.entity_mappings: + entity_mappings[mapping.entity_type] = { + "sheet_name": mapping.sheet_name, + "is_enabled": mapping.is_enabled, + "field_mapping": mapping.field_mapping + } + + result.append({ + "id": config.id, + "name": config.name, + "sheet_id": config.sheet_id, + "date_range_start": datetime.fromtimestamp(config.date_range_start).isoformat() + "Z" if config.date_range_start else None, + "date_range_end": datetime.fromtimestamp(config.date_range_end).isoformat() + "Z" if config.date_range_end else None, + "entity_mappings": entity_mappings, + "created_at": datetime.fromtimestamp(config.created_at).isoformat() + "Z" if config.created_at else None + }) + + return {"configurations": result} + + +@router.post("/start") +async def start_export_job( + job_request: ExportJobStart, + db: Session = Depends(get_db) +) -> Dict[str, Any]: + """Start async export job""" + + # Check if configuration exists + config = db.query(ExportConfiguration).filter( + ExportConfiguration.id == job_request.configuration_id, + ExportConfiguration.is_active == True + ).first() + + if not config: + raise HTTPException(status_code=404, detail="Export configuration not found") + + # Create export job + job = ExportJob( + configuration_id=config.id, + status="pending", + records_processed=0, + total_records=0, + created_at=int(time.time()) + ) + + db.add(job) + db.commit() + + # TODO: Queue job for background processing with Celery + # celery_app.send_task("export_to_sheets", args=[job.id]) + + return { + "job_id": job.id, + "status": "pending", + "message": "Export job queued successfully" + } + + +@router.get("/status/{job_id}") +async def get_export_status(job_id: int, db: Session = Depends(get_db)) -> Dict[str, Any]: + """Get export job status""" + + job = db.query(ExportJob).filter(ExportJob.id == job_id).first() + + if not job: + raise HTTPException(status_code=404, detail="Export job not found") + + result = { + "job_id": job.id, + "status": job.status, + "progress": { + "records_processed": job.records_processed, + "total_records": job.total_records, + "percentage": round((job.records_processed / job.total_records * 100), 2) if job.total_records > 0 else 0 + } + } + + if job.started_at: + result["started_at"] = datetime.fromtimestamp(job.started_at).isoformat() + "Z" + + if job.completed_at: + result["completed_at"] = datetime.fromtimestamp(job.completed_at).isoformat() + "Z" + + if job.error_message: + result["error_message"] = job.error_message + + # TODO: Add detailed entity progress when implementing worker + if job.status == "running": + result["progress"]["entities"] = { + "deals": {"processed": 1500, "total": 1500, "status": "completed"}, + "contacts": {"processed": 600, "total": 2200, "status": "running"}, + "companies": {"processed": 0, "total": 300, "status": "pending"}, + "events": {"processed": 0, "total": 200, "status": "pending"} + } + result["estimated_completion"] = "2024-01-15T10:35:00Z" # TODO: Calculate based on progress + + return result + + +@router.get("/jobs") +async def list_export_jobs( + page: int = 1, + per_page: int = 20, + db: Session = Depends(get_db) +) -> Dict[str, Any]: + """List all export jobs""" + + offset = (page - 1) * per_page + + jobs_query = db.query(ExportJob).order_by(ExportJob.created_at.desc()) + total = jobs_query.count() + jobs = jobs_query.offset(offset).limit(per_page).all() + + result_jobs = [] + for job in jobs: + job_data = { + "job_id": job.id, + "configuration_name": job.configuration.name, + "status": job.status, + "records_processed": job.records_processed, + "total_records": job.total_records + } + + if job.started_at: + job_data["started_at"] = datetime.fromtimestamp(job.started_at).isoformat() + "Z" + + if job.completed_at: + job_data["completed_at"] = datetime.fromtimestamp(job.completed_at).isoformat() + "Z" + + # TODO: Add entities_processed breakdown when implementing worker + if job.status == "completed": + job_data["entities_processed"] = { + "deals": 1500, + "contacts": 2200, + "companies": 300, + "events": 200 + } + + result_jobs.append(job_data) + + return { + "jobs": result_jobs, + "total": total, + "page": page, + "per_page": per_page + } diff --git a/scripts/fetch_amocrm_data.py b/scripts/fetch_amocrm_data.py new file mode 100644 index 0000000..be43e26 --- /dev/null +++ b/scripts/fetch_amocrm_data.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +""" +Script to fetch real AMO CRM responses for testing +Usage: python scripts/fetch_amocrm_data.py +""" +import asyncio +import json +import os +import sys +from datetime import datetime +from pathlib import Path + +# Add project root to Python path +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from adapters.amocrm_client import AmoCRMClient +from utils.config import settings + + +def save_response_to_file(data: dict, filename: str, output_dir: Path = None): + """Save response data to a JSON file""" + if output_dir is None: + output_dir = project_root / "tests" / "fixtures" / "real_responses" + + output_dir.mkdir(parents=True, exist_ok=True) + + filepath = output_dir / f"{filename}.json" + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + print(f"💾 Saved {filename} to {filepath}") + + +def generate_python_fixtures(all_data: dict, output_file: Path = None): + """Generate Python fixtures file from real responses""" + if output_file is None: + output_file = project_root / "tests" / "fixtures" / "real_amocrm_responses.py" + + timestamp = datetime.now().isoformat() + + content = f'''""" +Real AMO CRM API responses from wecheap.amocrm.ru +Generated on: {timestamp} +""" + +# Users (Пользователи) response +USERS_RESPONSE = {json.dumps(all_data.get("users", {}), ensure_ascii=False, indent=4)} + +# Pipelines (Воронки) response +PIPELINES_RESPONSE = {json.dumps(all_data.get("pipelines", {}), ensure_ascii=False, indent=4)} + +# Companies (Компании) response +COMPANIES_RESPONSE = {json.dumps(all_data.get("companies", {}), ensure_ascii=False, indent=4)} + +# Contacts (Контакты) response +CONTACTS_RESPONSE = {json.dumps(all_data.get("contacts", {}), ensure_ascii=False, indent=4)} + +# Deals (Сделки) response +DEALS_RESPONSE = {json.dumps(all_data.get("deals", {}), ensure_ascii=False, indent=4)} + +# Events (События) response +EVENTS_RESPONSE = {json.dumps(all_data.get("events", {}), ensure_ascii=False, indent=4)} + +# Custom fields metadata for deals +CUSTOM_FIELDS_DEALS_RESPONSE = {json.dumps(all_data.get("custom_fields_deals", {}), ensure_ascii=False, indent=4)} + +# Custom fields metadata for contacts +CUSTOM_FIELDS_CONTACTS_RESPONSE = {json.dumps(all_data.get("custom_fields_contacts", {}), ensure_ascii=False, indent=4)} + +# Custom fields metadata for companies +CUSTOM_FIELDS_COMPANIES_RESPONSE = {json.dumps(all_data.get("custom_fields_companies", {}), ensure_ascii=False, indent=4)} +''' + + with open(output_file, 'w', encoding='utf-8') as f: + f.write(content) + + print(f"🐍 Generated Python fixtures at {output_file}") + + +async def main(): + """Main function to fetch and save AMO CRM data""" + print("🚀 Fetching AMO CRM data from wecheap.amocrm.ru") + print(f"📡 Domain: {settings.AMO_CRM_DOMAIN}") + + if not settings.AMO_CRM_ACCESS_TOKEN: + print("❌ Error: AMO_CRM_ACCESS_TOKEN not set!") + print("Please set it in your .env file") + return + + try: + # Create client and fetch data + client = AmoCRMClient() + all_data = await client.fetch_all_data() + + print("\n📁 Saving responses...") + + # Save individual JSON files + for entity_type, data in all_data.items(): + if data: # Only save non-empty responses + save_response_to_file(data, entity_type) + + # Generate Python fixtures + generate_python_fixtures(all_data) + + print("\n✅ All done! Real AMO CRM responses saved for testing") + print("\nFiles created:") + print("- tests/fixtures/real_responses/*.json (individual JSON files)") + print("- tests/fixtures/real_amocrm_responses.py (Python fixtures)") + + # Show summary + print("\n📊 Data Summary:") + for entity_type, data in all_data.items(): + if "_embedded" in data: + embedded_key = list(data["_embedded"].keys())[0] if data["_embedded"] else "items" + count = len(data["_embedded"].get(embedded_key, [])) + print(f" - {entity_type}: {count} items") + else: + print(f" - {entity_type}: metadata") + + except Exception as e: + print(f"❌ Error: {e}") + print("\nTroubleshooting:") + print("1. Check your AMO_CRM_ACCESS_TOKEN in .env file") + print("2. Make sure the token is valid and not expired") + print("3. Verify you have access to wecheap.amocrm.ru") + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/servers/__init__.py b/servers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/amocrm_responses.py b/tests/fixtures/amocrm_responses.py new file mode 100644 index 0000000..3a0664d --- /dev/null +++ b/tests/fixtures/amocrm_responses.py @@ -0,0 +1,721 @@ +""" +Real AMO CRM API response examples for testing +Based on AMO CRM HAL+JSON format +""" + +# Users (Пользователи) response example +USERS_RESPONSE = { + "_page": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/users?page=1&limit=250" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/users?page=2&limit=250" + } + }, + "_embedded": { + "users": [ + { + "id": 504141, + "name": "Иван Иванов", + "email": "ivan@example.com", + "lang": "ru", + "rights": { + "leads": { + "view": "A", + "edit": "A", + "add": "A", + "delete": "A", + "export": "A" + }, + "contacts": { + "view": "A", + "edit": "A", + "add": "A", + "delete": "A", + "export": "A" + } + }, + "is_admin": True, + "is_free": False, + "is_active": True, + "group_id": 0, + "role_id": None, + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/users/504141" + } + } + }, + { + "id": 504142, + "name": "Мария Петрова", + "email": "maria@example.com", + "lang": "ru", + "rights": { + "leads": { + "view": "A", + "edit": "A", + "add": "A", + "delete": "A", + "export": "A" + } + }, + "is_admin": False, + "is_free": False, + "is_active": True, + "group_id": 0, + "role_id": 123456, + "uuid": "550e8400-e29b-41d4-a716-446655440001", + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/users/504142" + } + } + } + ] + } +} + +# Pipelines (Воронки) response example +PIPELINES_RESPONSE = { + "_page": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines" + } + }, + "_embedded": { + "pipelines": [ + { + "id": 3130966, + "name": "Продажи", + "sort": 1, + "is_main": True, + "is_unsorted": False, + "is_archive": False, + "account_id": 28805383, + "created_at": 1640995200, + "updated_at": 1704067200, + "_embedded": { + "statuses": [ + { + "id": 32532070, + "name": "Неразобранное", + "sort": 10, + "is_editable": False, + "pipeline_id": 3130966, + "color": "#c1c1c1", + "type": 1, + "account_id": 28805383, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3130966/statuses/32532070" + } + } + }, + { + "id": 32532073, + "name": "Первичный контакт", + "sort": 20, + "is_editable": True, + "pipeline_id": 3130966, + "color": "#99ccff", + "type": 0, + "account_id": 28805383, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3130966/statuses/32532073" + } + } + }, + { + "id": 32532076, + "name": "Переговоры", + "sort": 30, + "is_editable": True, + "pipeline_id": 3130966, + "color": "#ffff99", + "type": 0, + "account_id": 28805383, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3130966/statuses/32532076" + } + } + }, + { + "id": 142, + "name": "Успешно реализовано", + "sort": 10000, + "is_editable": False, + "pipeline_id": 3130966, + "color": "#CCFF66", + "type": 0, + "account_id": 28805383, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3130966/statuses/142" + } + } + }, + { + "id": 143, + "name": "Закрыто и не реализовано", + "sort": 11000, + "is_editable": False, + "pipeline_id": 3130966, + "color": "#D5D8DB", + "type": 0, + "account_id": 28805383, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3130966/statuses/143" + } + } + } + ] + }, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/pipelines/3130966" + } + } + } + ] + } +} + +# Companies (Компании) response example +COMPANIES_RESPONSE = { + "_page": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/companies?page=1&limit=250" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/companies?page=2&limit=250" + } + }, + "_embedded": { + "companies": [ + { + "id": 15960673, + "name": "ООО \"Рога и копыта\"", + "responsible_user_id": 504141, + "group_id": 0, + "created_by": 504141, + "updated_by": 504142, + "created_at": 1640995200, + "updated_at": 1704067200, + "closest_task_at": None, + "is_deleted": False, + "custom_fields_values": [ + { + "field_id": 123456, + "field_name": "Телефон", + "field_code": "PHONE", + "field_type": "multitext", + "values": [ + { + "value": "+7 (495) 123-45-67", + "enum_id": 456789, + "enum": "WORK" + } + ] + }, + { + "field_id": 123457, + "field_name": "Email", + "field_code": "EMAIL", + "field_type": "multitext", + "values": [ + { + "value": "info@example.com", + "enum_id": 456790, + "enum": "WORK" + } + ] + }, + { + "field_id": 987654, + "field_name": "Отрасль", + "field_code": None, + "field_type": "select", + "values": [ + { + "value": "IT", + "enum_id": 111222, + "enum": "IT" + } + ] + } + ], + "account_id": 28805383, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/companies/15960673" + } + } + } + ] + } +} + +# Contacts (Контакты) response example +CONTACTS_RESPONSE = { + "_page": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/contacts?page=1&limit=250" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/contacts?page=2&limit=250" + } + }, + "_embedded": { + "contacts": [ + { + "id": 19421421, + "name": "Алексей Смирнов", + "first_name": "Алексей", + "last_name": "Смирнов", + "responsible_user_id": 504141, + "group_id": 0, + "created_by": 504141, + "updated_by": 504141, + "created_at": 1640995200, + "updated_at": 1704067200, + "closest_task_at": 1704153600, + "is_deleted": False, + "custom_fields_values": [ + { + "field_id": 123456, + "field_name": "Телефон", + "field_code": "PHONE", + "field_type": "multitext", + "values": [ + { + "value": "+7 (903) 123-45-67", + "enum_id": 456789, + "enum": "MOB" + } + ] + }, + { + "field_id": 123457, + "field_name": "Email", + "field_code": "EMAIL", + "field_type": "multitext", + "values": [ + { + "value": "alexey@example.com", + "enum_id": 456790, + "enum": "PRIV" + } + ] + }, + { + "field_id": 555666, + "field_name": "Должность", + "field_code": None, + "field_type": "text", + "values": [ + { + "value": "Менеджер по продажам" + } + ] + } + ], + "account_id": 28805383, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/contacts/19421421" + } + }, + "_embedded": { + "companies": [ + { + "id": 15960673, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/companies/15960673" + } + } + } + ] + } + } + ] + } +} + +# Deals (Сделки) response example +DEALS_RESPONSE = { + "_page": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads?page=1&limit=250" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/leads?page=2&limit=250" + } + }, + "_embedded": { + "leads": [ + { + "id": 19620805, + "name": "Сделка с ООО \"Рога и копыта\"", + "price": 150000, + "responsible_user_id": 504141, + "group_id": 0, + "status_id": 32532073, + "pipeline_id": 3130966, + "loss_reason_id": None, + "created_by": 504141, + "updated_by": 504141, + "closed_at": None, + "created_at": 1640995200, + "updated_at": 1704067200, + "closest_task_at": 1704153600, + "is_deleted": False, + "custom_fields_values": [ + { + "field_id": 777888, + "field_name": "Приоритет", + "field_code": None, + "field_type": "select", + "values": [ + { + "value": "Высокий", + "enum_id": 999111, + "enum": "HIGH" + } + ] + }, + { + "field_id": 777889, + "field_name": "Источник", + "field_code": None, + "field_type": "text", + "values": [ + { + "value": "Холодный звонок" + } + ] + }, + { + "field_id": 777890, + "field_name": "Дата закрытия", + "field_code": None, + "field_type": "date", + "values": [ + { + "value": "1704326400" + } + ] + }, + { + "field_id": 777891, + "field_name": "Бюджет клиента", + "field_code": None, + "field_type": "numeric", + "values": [ + { + "value": "200000" + } + ] + } + ], + "score": None, + "account_id": 28805383, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/19620805" + } + }, + "_embedded": { + "contacts": [ + { + "id": 19421421, + "is_main": True, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/contacts/19421421" + } + } + } + ], + "companies": [ + { + "id": 15960673, + "is_main": True, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/companies/15960673" + } + } + } + ] + } + }, + { + "id": 19620806, + "name": "Повторная сделка", + "price": 75000, + "responsible_user_id": 504142, + "group_id": 0, + "status_id": 32532076, + "pipeline_id": 3130966, + "loss_reason_id": None, + "created_by": 504142, + "updated_by": 504142, + "closed_at": None, + "created_at": 1701388800, + "updated_at": 1704067200, + "closest_task_at": None, + "is_deleted": False, + "custom_fields_values": [ + { + "field_id": 777888, + "field_name": "Приоритет", + "field_code": None, + "field_type": "select", + "values": [ + { + "value": "Средний", + "enum_id": 999112, + "enum": "MEDIUM" + } + ] + } + ], + "score": None, + "account_id": 28805383, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/19620806" + } + }, + "_embedded": { + "contacts": [ + { + "id": 19421421, + "is_main": False, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/contacts/19421421" + } + } + } + ] + } + } + ] + } +} + +# Events (События) response example +EVENTS_RESPONSE = { + "_page": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/events?page=1&limit=250" + }, + "next": { + "href": "https://example.amocrm.ru/api/v4/events?page=2&limit=250" + } + }, + "_embedded": { + "events": [ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "type": "lead_status_changed", + "entity_id": 19620805, + "entity_type": "lead", + "created_by": 504141, + "created_at": 1704067200, + "value_after": [ + { + "lead_status": { + "id": 32532073, + "name": "Первичный контакт", + "color": "#99ccff", + "pipeline_id": 3130966 + } + } + ], + "value_before": [ + { + "lead_status": { + "id": 32532070, + "name": "Неразобранное", + "color": "#c1c1c1", + "pipeline_id": 3130966 + } + } + ], + "account_id": 28805383, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/events/550e8400-e29b-41d4-a716-446655440000" + } + } + }, + { + "id": "550e8400-e29b-41d4-a716-446655440001", + "type": "incoming_call", + "entity_id": 19421421, + "entity_type": "contact", + "created_by": 504141, + "created_at": 1704060000, + "value_after": [ + { + "call": { + "duration": 120, + "phone": "+7 (903) 123-45-67", + "call_status": "Дозвонился", + "call_result": "Договорились о встрече" + } + } + ], + "value_before": [], + "account_id": 28805383, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/events/550e8400-e29b-41d4-a716-446655440001" + } + } + }, + { + "id": "550e8400-e29b-41d4-a716-446655440002", + "type": "outgoing_call", + "entity_id": 15960673, + "entity_type": "company", + "created_by": 504142, + "created_at": 1704052800, + "value_after": [ + { + "call": { + "duration": 0, + "phone": "+7 (495) 123-45-67", + "call_status": "Не дозвонился", + "call_result": "Занято" + } + } + ], + "value_before": [], + "account_id": 28805383, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/events/550e8400-e29b-41d4-a716-446655440002" + } + } + } + ] + } +} + +# Custom fields metadata response (for understanding field types) +CUSTOM_FIELDS_RESPONSE = { + "_page": 1, + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields" + } + }, + "_embedded": { + "custom_fields": [ + { + "id": 777888, + "name": "Приоритет", + "type": "select", + "account_id": 28805383, + "code": None, + "sort": 1, + "api_code": None, + "is_computed": False, + "is_predefined": False, + "entity_type": "leads", + "enums": [ + { + "id": 999111, + "value": "Высокий", + "sort": 1, + "api_code": "HIGH" + }, + { + "id": 999112, + "value": "Средний", + "sort": 2, + "api_code": "MEDIUM" + }, + { + "id": 999113, + "value": "Низкий", + "sort": 3, + "api_code": "LOW" + } + ], + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields/777888" + } + } + }, + { + "id": 777889, + "name": "Источник", + "type": "text", + "account_id": 28805383, + "code": None, + "sort": 2, + "api_code": None, + "is_computed": False, + "is_predefined": False, + "entity_type": "leads", + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields/777889" + } + } + }, + { + "id": 777890, + "name": "Дата закрытия", + "type": "date", + "account_id": 28805383, + "code": None, + "sort": 3, + "api_code": None, + "is_computed": False, + "is_predefined": False, + "entity_type": "leads", + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields/777890" + } + } + }, + { + "id": 777891, + "name": "Бюджет клиента", + "type": "numeric", + "account_id": 28805383, + "code": None, + "sort": 4, + "api_code": None, + "is_computed": False, + "is_predefined": False, + "entity_type": "leads", + "_links": { + "self": { + "href": "https://example.amocrm.ru/api/v4/leads/custom_fields/777891" + } + } + } + ] + } +} diff --git a/tests/test_amocrm_examples.py b/tests/test_amocrm_examples.py new file mode 100644 index 0000000..b474a71 --- /dev/null +++ b/tests/test_amocrm_examples.py @@ -0,0 +1,197 @@ +""" +Test file demonstrating AMO CRM response examples +""" +import pytest +from tests.fixtures.amocrm_responses import ( + USERS_RESPONSE, + PIPELINES_RESPONSE, + COMPANIES_RESPONSE, + CONTACTS_RESPONSE, + DEALS_RESPONSE, + EVENTS_RESPONSE, + CUSTOM_FIELDS_RESPONSE +) + + +def test_users_response_structure(): + """Test that users response has expected structure""" + assert "_embedded" in USERS_RESPONSE + assert "users" in USERS_RESPONSE["_embedded"] + + users = USERS_RESPONSE["_embedded"]["users"] + assert len(users) == 2 + + first_user = users[0] + assert first_user["id"] == 504141 + assert first_user["name"] == "Иван Иванов" + assert first_user["email"] == "ivan@example.com" + assert first_user["is_active"] is True + + +def test_pipelines_response_structure(): + """Test that pipelines response has expected structure""" + assert "_embedded" in PIPELINES_RESPONSE + assert "pipelines" in PIPELINES_RESPONSE["_embedded"] + + pipelines = PIPELINES_RESPONSE["_embedded"]["pipelines"] + assert len(pipelines) == 1 + + pipeline = pipelines[0] + assert pipeline["id"] == 3130966 + assert pipeline["name"] == "Продажи" + assert pipeline["is_main"] is True + + # Check stages + assert "_embedded" in pipeline + assert "statuses" in pipeline["_embedded"] + statuses = pipeline["_embedded"]["statuses"] + assert len(statuses) == 5 + + +def test_companies_response_structure(): + """Test that companies response has expected structure""" + assert "_embedded" in COMPANIES_RESPONSE + assert "companies" in COMPANIES_RESPONSE["_embedded"] + + companies = COMPANIES_RESPONSE["_embedded"]["companies"] + assert len(companies) == 1 + + company = companies[0] + assert company["id"] == 15960673 + assert company["name"] == "ООО \"Рога и копыта\"" + assert company["responsible_user_id"] == 504141 + + # Check custom fields + assert "custom_fields_values" in company + custom_fields = company["custom_fields_values"] + assert len(custom_fields) == 3 + + # Check phone field + phone_field = next(f for f in custom_fields if f["field_name"] == "Телефон") + assert phone_field["field_type"] == "multitext" + assert phone_field["values"][0]["value"] == "+7 (495) 123-45-67" + + +def test_contacts_response_structure(): + """Test that contacts response has expected structure""" + assert "_embedded" in CONTACTS_RESPONSE + assert "contacts" in CONTACTS_RESPONSE["_embedded"] + + contacts = CONTACTS_RESPONSE["_embedded"]["contacts"] + assert len(contacts) == 1 + + contact = contacts[0] + assert contact["id"] == 19421421 + assert contact["name"] == "Алексей Смирнов" + assert contact["first_name"] == "Алексей" + assert contact["last_name"] == "Смирнов" + + # Check embedded companies + assert "_embedded" in contact + assert "companies" in contact["_embedded"] + companies = contact["_embedded"]["companies"] + assert len(companies) == 1 + assert companies[0]["id"] == 15960673 + + +def test_deals_response_structure(): + """Test that deals response has expected structure""" + assert "_embedded" in DEALS_RESPONSE + assert "leads" in DEALS_RESPONSE["_embedded"] # Note: AMO CRM calls deals "leads" + + deals = DEALS_RESPONSE["_embedded"]["leads"] + assert len(deals) == 2 + + first_deal = deals[0] + assert first_deal["id"] == 19620805 + assert first_deal["name"] == "Сделка с ООО \"Рога и копыта\"" + assert first_deal["price"] == 150000 + assert first_deal["status_id"] == 32532073 + assert first_deal["pipeline_id"] == 3130966 + + # Check custom fields + custom_fields = first_deal["custom_fields_values"] + priority_field = next(f for f in custom_fields if f["field_name"] == "Приоритет") + assert priority_field["field_type"] == "select" + assert priority_field["values"][0]["value"] == "Высокий" + + # Check embedded relationships + assert "_embedded" in first_deal + assert "contacts" in first_deal["_embedded"] + assert "companies" in first_deal["_embedded"] + + contacts = first_deal["_embedded"]["contacts"] + assert contacts[0]["id"] == 19421421 + assert contacts[0]["is_main"] is True + + +def test_events_response_structure(): + """Test that events response has expected structure""" + assert "_embedded" in EVENTS_RESPONSE + assert "events" in EVENTS_RESPONSE["_embedded"] + + events = EVENTS_RESPONSE["_embedded"]["events"] + assert len(events) == 3 + + # Test lead status changed event + status_event = next(e for e in events if e["type"] == "lead_status_changed") + assert status_event["entity_type"] == "lead" + assert status_event["entity_id"] == 19620805 + assert "value_after" in status_event + assert "value_before" in status_event + + # Test incoming call event + call_event = next(e for e in events if e["type"] == "incoming_call") + assert call_event["entity_type"] == "contact" + assert call_event["entity_id"] == 19421421 + assert call_event["value_after"][0]["call"]["duration"] == 120 + + +def test_custom_fields_metadata(): + """Test custom fields metadata structure""" + assert "_embedded" in CUSTOM_FIELDS_RESPONSE + assert "custom_fields" in CUSTOM_FIELDS_RESPONSE["_embedded"] + + fields = CUSTOM_FIELDS_RESPONSE["_embedded"]["custom_fields"] + assert len(fields) == 4 + + # Test select field with enums + priority_field = next(f for f in fields if f["name"] == "Приоритет") + assert priority_field["type"] == "select" + assert "enums" in priority_field + assert len(priority_field["enums"]) == 3 + + high_priority = priority_field["enums"][0] + assert high_priority["value"] == "Высокий" + assert high_priority["api_code"] == "HIGH" + + # Test date field + date_field = next(f for f in fields if f["name"] == "Дата закрытия") + assert date_field["type"] == "date" + + # Test numeric field + numeric_field = next(f for f in fields if f["name"] == "Бюджет клиента") + assert numeric_field["type"] == "numeric" + + +def test_date_validation(): + """Test that dates are in expected range (2017-2026)""" + # Test deals dates + deals = DEALS_RESPONSE["_embedded"]["leads"] + for deal in deals: + created_at = deal["created_at"] + updated_at = deal["updated_at"] + + # Check dates are in range (2017 = 1483228800, 2026 = 1767225600) + assert 1483228800 <= created_at <= 1767225600, f"created_at {created_at} out of range" + assert 1483228800 <= updated_at <= 1767225600, f"updated_at {updated_at} out of range" + + # Test events dates + events = EVENTS_RESPONSE["_embedded"]["events"] + for event in events: + created_at = event["created_at"] + assert 1483228800 <= created_at <= 1767225600, f"event created_at {created_at} out of range" + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..a1d2c91 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,253 @@ +""" +Test API endpoints with AMO CRM data examples +""" +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app import app +from adapters.sqlite.database import Base, get_db +from tests.fixtures.amocrm_responses import USERS_RESPONSE, DEALS_RESPONSE + +# Create test database +SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" +engine = create_engine( + SQLALCHEMY_DATABASE_URL, + connect_args={"check_same_thread": False}, + poolclass=StaticPool, +) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Create tables +Base.metadata.create_all(bind=engine) + + +def override_get_db(): + try: + db = TestingSessionLocal() + yield db + finally: + db.close() + + +app.dependency_overrides[get_db] = override_get_db + +client = TestClient(app) + + +def test_root_endpoint(): + """Test root endpoint""" + response = client.get("/") + assert response.status_code == 200 + data = response.json() + assert data["message"] == "AMO CRM Data Collection Service" + assert data["version"] == "0.1.0" + + +def test_health_endpoint(): + """Test health check endpoint""" + response = client.get("/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + + +def test_list_entities_empty(): + """Test listing entities when database is empty""" + response = client.get("/api/v1/entities/") + assert response.status_code == 200 + data = response.json() + assert "entities" in data + + # All counts should be 0 + for entity_type in ["deals", "contacts", "companies", "pipelines", "users", "events"]: + assert entity_type in data["entities"] + assert data["entities"][entity_type]["count"] == 0 + + +def test_put_users_data(): + """Test putting users data""" + users_data = USERS_RESPONSE["_embedded"]["users"] + + response = client.post( + "/api/v1/data/users", + json={ + "data": users_data, + "sync_mode": "upsert" + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["processed_count"] == 2 + assert "Processed 2 users" in data["message"] + + +def test_put_deals_data(): + """Test putting deals data""" + deals_data = DEALS_RESPONSE["_embedded"]["leads"] + + response = client.post( + "/api/v1/data/deals", + json={ + "data": deals_data, + "sync_mode": "upsert" + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["processed_count"] == 2 + assert "Processed 2 deals" in data["message"] + + +def test_list_entities_with_data(): + """Test listing entities after adding data""" + response = client.get("/api/v1/entities/") + assert response.status_code == 200 + data = response.json() + + # Should have users and deals now + assert data["entities"]["users"]["count"] == 2 + assert data["entities"]["deals"]["count"] == 2 + + +def test_list_deal_fields(): + """Test listing deal fields""" + response = client.get("/api/v1/entities/deals/fields") + assert response.status_code == 200 + data = response.json() + + assert data["entity_type"] == "deals" + assert "fields" in data + + # Should have standard fields + field_names = [f["name"] for f in data["fields"]] + assert "name" in field_names + assert "price" in field_names + assert "created_at" in field_names + + # Should have custom fields from the test data + custom_fields = [f for f in data["fields"] if f["is_custom"]] + assert len(custom_fields) > 0 + + +def test_export_configuration(): + """Test creating export configuration""" + config_data = { + "name": "Test Export Configuration", + "sheet_id": "test-sheet-id-123", + "date_range_start": "2024-01-01T00:00:00Z", + "date_range_end": "2024-12-31T23:59:59Z", + "entity_mappings": { + "deals": { + "sheet_name": "Deals", + "is_enabled": True, + "field_mapping": [ + {"field_name": "name", "column": "A", "order": 1}, + {"field_name": "price", "column": "B", "order": 2} + ] + }, + "users": { + "sheet_name": "Users", + "is_enabled": True, + "field_mapping": [ + {"field_name": "name", "column": "A", "order": 1}, + {"field_name": "email", "column": "B", "order": 2} + ] + } + } + } + + response = client.post("/api/v1/export/configure", json=config_data) + assert response.status_code == 200 + data = response.json() + assert "configuration_id" in data + assert data["message"] == "Export configuration created successfully" + + return data["configuration_id"] + + +def test_list_export_configurations(): + """Test listing export configurations""" + # First create a configuration + config_id = test_export_configuration() + + response = client.get("/api/v1/export/configurations") + assert response.status_code == 200 + data = response.json() + + assert "configurations" in data + assert len(data["configurations"]) >= 1 + + config = data["configurations"][0] + assert config["name"] == "Test Export Configuration" + assert config["sheet_id"] == "test-sheet-id-123" + assert "entity_mappings" in config + + +def test_start_export_job(): + """Test starting export job""" + # First create a configuration + config_id = test_export_configuration() + + response = client.post( + "/api/v1/export/start", + json={"configuration_id": config_id} + ) + + assert response.status_code == 200 + data = response.json() + assert "job_id" in data + assert data["status"] == "pending" + assert data["message"] == "Export job queued successfully" + + return data["job_id"] + + +def test_export_job_status(): + """Test getting export job status""" + # First start a job + job_id = test_start_export_job() + + response = client.get(f"/api/v1/export/status/{job_id}") + assert response.status_code == 200 + data = response.json() + + assert data["job_id"] == job_id + assert data["status"] == "pending" + assert "progress" in data + assert data["progress"]["records_processed"] == 0 + + +def test_list_export_jobs(): + """Test listing export jobs""" + # First start a job + job_id = test_start_export_job() + + response = client.get("/api/v1/export/jobs") + assert response.status_code == 200 + data = response.json() + + assert "jobs" in data + assert data["total"] >= 1 + assert data["page"] == 1 + assert data["per_page"] == 20 + + # Should contain our job + job_ids = [job["job_id"] for job in data["jobs"]] + assert job_id in job_ids + + +def test_invalid_entity_type(): + """Test invalid entity type""" + response = client.get("/api/v1/entities/invalid_entity/fields") + assert response.status_code == 404 + data = response.json() + assert "Entity type not found" in data["detail"] + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..dd7ee44 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ +# Utils package diff --git a/utils/config.py b/utils/config.py new file mode 100644 index 0000000..a1cec6a --- /dev/null +++ b/utils/config.py @@ -0,0 +1,31 @@ +from pydantic_settings import BaseSettings +from typing import Optional + + +class Settings(BaseSettings): + # Database + DATABASE_URL: str = "sqlite:///./amo_data.db" + + # AMO CRM API + AMO_CRM_DOMAIN: str = "wecheap.amocrm.ru" + AMO_CRM_ACCESS_TOKEN: str + + # Google Sheets API + GOOGLE_SERVICE_ACCOUNT_FILE: str + GOOGLE_SCOPES: str = "https://www.googleapis.com/auth/spreadsheets" + + # Redis (for Celery) + REDIS_URL: str = "redis://localhost:6379/0" + + # API Settings + API_V1_STR: str = "/api/v1" + + # Logging + LOG_LEVEL: str = "INFO" + + class Config: + env_file = ".env" + case_sensitive = True + + +settings = Settings() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6017ef9 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1409 @@ +version = 1 +revision = 2 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version < '3.13'", +] + +[[package]] +name = "alembic" +version = "1.16.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/ca/4dc52902cf3491892d464f5265a81e9dff094692c8a049a3ed6a05fe7ee8/alembic-1.16.5.tar.gz", hash = "sha256:a88bb7f6e513bd4301ecf4c7f2206fe93f9913f9b48dac3b78babde2d6fe765e", size = 1969868, upload-time = "2025-08-27T18:02:05.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/4a/4c61d4c84cfd9befb6fa08a702535b27b21fff08c946bc2f6139decbf7f7/alembic-1.16.5-py3-none-any.whl", hash = "sha256:e845dfe090c5ffa7b92593ae6687c5cb1a101e91fa53868497dbd79847f9dbe3", size = 247355, upload-time = "2025-08-27T18:02:07.37Z" }, +] + +[[package]] +name = "amo-server" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "celery" }, + { name = "fastapi" }, + { name = "google-api-python-client" }, + { name = "google-auth-httplib2" }, + { name = "google-auth-oauthlib" }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "redis" }, + { name = "sqlalchemy" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "black" }, + { name = "flake8" }, + { name = "isort" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.13.0" }, + { name = "black", marker = "extra == 'dev'", specifier = ">=23.9.0" }, + { name = "celery", specifier = ">=5.3.0" }, + { name = "fastapi", specifier = ">=0.104.0" }, + { name = "flake8", marker = "extra == 'dev'", specifier = ">=6.1.0" }, + { name = "google-api-python-client", specifier = ">=2.100.0" }, + { name = "google-auth-httplib2", specifier = ">=0.2.0" }, + { name = "google-auth-oauthlib", specifier = ">=1.1.0" }, + { name = "httpx", specifier = ">=0.25.0" }, + { name = "isort", marker = "extra == 'dev'", specifier = ">=5.12.0" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.6.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" }, + { name = "pydantic", specifier = ">=2.5.0" }, + { name = "pydantic-settings", specifier = ">=2.1.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "python-multipart", specifier = ">=0.0.6" }, + { name = "redis", specifier = ">=5.0.0" }, + { name = "sqlalchemy", specifier = ">=2.0.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "amqp" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/fc/ec94a357dfc6683d8c86f8b4cfa5416a4c36b28052ec8260c77aca96a443/amqp-5.3.1.tar.gz", hash = "sha256:cddc00c725449522023bad949f70fff7b48f0b1ade74d170a6f10ab044739432", size = 129013, upload-time = "2024-11-12T19:55:44.051Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/99/fc813cd978842c26c82534010ea849eee9ab3a13ea2b74e95cb9c99e747b/amqp-5.3.1-py3-none-any.whl", hash = "sha256:43b3319e1b4e7d1251833a93d672b4af1e40f3d632d479b98661a95f117880a2", size = 50944, upload-time = "2024-11-12T19:55:41.782Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "sniffio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" }, +] + +[[package]] +name = "billiard" +version = "4.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/58/1546c970afcd2a2428b1bfafecf2371d8951cc34b46701bea73f4280989e/billiard-4.2.1.tar.gz", hash = "sha256:12b641b0c539073fc8d3f5b8b7be998956665c4233c7c1fcd66a7e677c4fb36f", size = 155031, upload-time = "2024-09-21T13:40:22.491Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/da/43b15f28fe5f9e027b41c539abc5469052e9d48fd75f8ff094ba2a0ae767/billiard-4.2.1-py3-none-any.whl", hash = "sha256:40b59a4ac8806ba2c2369ea98d876bc6108b051c227baffd928c644d15d8f3cb", size = 86766, upload-time = "2024-09-21T13:40:20.188Z" }, +] + +[[package]] +name = "black" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "mypy-extensions" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/49/26a7b0f3f35da4b5a65f081943b7bcd22d7002f5f0fb8098ec1ff21cb6ef/black-25.1.0.tar.gz", hash = "sha256:33496d5cd1222ad73391352b4ae8da15253c5de89b93a80b3e2c8d9a19ec2666", size = 649449, upload-time = "2025-01-29T04:15:40.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/71/3fe4741df7adf015ad8dfa082dd36c94ca86bb21f25608eb247b4afb15b2/black-25.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4b60580e829091e6f9238c848ea6750efed72140b91b048770b64e74fe04908b", size = 1650988, upload-time = "2025-01-29T05:37:16.707Z" }, + { url = "https://files.pythonhosted.org/packages/13/f3/89aac8a83d73937ccd39bbe8fc6ac8860c11cfa0af5b1c96d081facac844/black-25.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e2978f6df243b155ef5fa7e558a43037c3079093ed5d10fd84c43900f2d8ecc", size = 1453985, upload-time = "2025-01-29T05:37:18.273Z" }, + { url = "https://files.pythonhosted.org/packages/6f/22/b99efca33f1f3a1d2552c714b1e1b5ae92efac6c43e790ad539a163d1754/black-25.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b48735872ec535027d979e8dcb20bf4f70b5ac75a8ea99f127c106a7d7aba9f", size = 1783816, upload-time = "2025-01-29T04:18:33.823Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/a27c3ad3822b6f2e0e00d63d58ff6299a99a5b3aee69fa77cd4b0076b261/black-25.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:ea0213189960bda9cf99be5b8c8ce66bb054af5e9e861249cd23471bd7b0b3ba", size = 1440860, upload-time = "2025-01-29T04:19:12.944Z" }, + { url = "https://files.pythonhosted.org/packages/98/87/0edf98916640efa5d0696e1abb0a8357b52e69e82322628f25bf14d263d1/black-25.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f0b18a02996a836cc9c9c78e5babec10930862827b1b724ddfe98ccf2f2fe4f", size = 1650673, upload-time = "2025-01-29T05:37:20.574Z" }, + { url = "https://files.pythonhosted.org/packages/52/e5/f7bf17207cf87fa6e9b676576749c6b6ed0d70f179a3d812c997870291c3/black-25.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:afebb7098bfbc70037a053b91ae8437c3857482d3a690fefc03e9ff7aa9a5fd3", size = 1453190, upload-time = "2025-01-29T05:37:22.106Z" }, + { url = "https://files.pythonhosted.org/packages/e3/ee/adda3d46d4a9120772fae6de454c8495603c37c4c3b9c60f25b1ab6401fe/black-25.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:030b9759066a4ee5e5aca28c3c77f9c64789cdd4de8ac1df642c40b708be6171", size = 1782926, upload-time = "2025-01-29T04:18:58.564Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/94eb5f45dcb997d2082f097a3944cfc7fe87e071907f677e80788a2d7b7a/black-25.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:a22f402b410566e2d1c950708c77ebf5ebd5d0d88a6a2e87c86d9fb48afa0d18", size = 1442613, upload-time = "2025-01-29T04:19:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646, upload-time = "2025-01-29T04:15:38.082Z" }, +] + +[[package]] +name = "cachetools" +version = "5.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/81/3747dad6b14fa2cf53fcf10548cf5aea6913e96fab41a3c198676f8948a5/cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4", size = 28380, upload-time = "2025-02-20T21:01:19.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/76/20fa66124dbe6be5cafeb312ece67de6b61dd91a0247d1ea13db4ebb33c2/cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a", size = 10080, upload-time = "2025-02-20T21:01:16.647Z" }, +] + +[[package]] +name = "celery" +version = "5.5.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "billiard" }, + { name = "click" }, + { name = "click-didyoumean" }, + { name = "click-plugins" }, + { name = "click-repl" }, + { name = "kombu" }, + { name = "python-dateutil" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/7d/6c289f407d219ba36d8b384b42489ebdd0c84ce9c413875a8aae0c85f35b/celery-5.5.3.tar.gz", hash = "sha256:6c972ae7968c2b5281227f01c3a3f984037d21c5129d07bf3550cc2afc6b10a5", size = 1667144, upload-time = "2025-06-01T11:08:12.563Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/af/0dcccc7fdcdf170f9a1585e5e96b6fb0ba1749ef6be8c89a6202284759bd/celery-5.5.3-py3-none-any.whl", hash = "sha256:0b5761a07057acee94694464ca482416b959568904c9dfa41ce8413a7d65d525", size = 438775, upload-time = "2025-06-01T11:08:09.94Z" }, +] + +[[package]] +name = "certifi" +version = "2025.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, +] + +[[package]] +name = "cfgv" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/5e/14c94999e418d9b87682734589404a25854d5f5d0408df68bc15b6ff54bb/charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1", size = 205655, upload-time = "2025-08-09T07:56:08.475Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a8/c6ec5d389672521f644505a257f50544c074cf5fc292d5390331cd6fc9c3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884", size = 146223, upload-time = "2025-08-09T07:56:09.708Z" }, + { url = "https://files.pythonhosted.org/packages/fc/eb/a2ffb08547f4e1e5415fb69eb7db25932c52a52bed371429648db4d84fb1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018", size = 159366, upload-time = "2025-08-09T07:56:11.326Z" }, + { url = "https://files.pythonhosted.org/packages/82/10/0fd19f20c624b278dddaf83b8464dcddc2456cb4b02bb902a6da126b87a1/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392", size = 157104, upload-time = "2025-08-09T07:56:13.014Z" }, + { url = "https://files.pythonhosted.org/packages/16/ab/0233c3231af734f5dfcf0844aa9582d5a1466c985bbed6cedab85af9bfe3/charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f", size = 151830, upload-time = "2025-08-09T07:56:14.428Z" }, + { url = "https://files.pythonhosted.org/packages/ae/02/e29e22b4e02839a0e4a06557b1999d0a47db3567e82989b5bb21f3fbbd9f/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154", size = 148854, upload-time = "2025-08-09T07:56:16.051Z" }, + { url = "https://files.pythonhosted.org/packages/05/6b/e2539a0a4be302b481e8cafb5af8792da8093b486885a1ae4d15d452bcec/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491", size = 160670, upload-time = "2025-08-09T07:56:17.314Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/883ee5676a2ef217a40ce0bffcc3d0dfbf9e64cbcfbdf822c52981c3304b/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93", size = 158501, upload-time = "2025-08-09T07:56:18.641Z" }, + { url = "https://files.pythonhosted.org/packages/c1/35/6525b21aa0db614cf8b5792d232021dca3df7f90a1944db934efa5d20bb1/charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f", size = 153173, upload-time = "2025-08-09T07:56:20.289Z" }, + { url = "https://files.pythonhosted.org/packages/50/ee/f4704bad8201de513fdc8aac1cabc87e38c5818c93857140e06e772b5892/charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37", size = 99822, upload-time = "2025-08-09T07:56:21.551Z" }, + { url = "https://files.pythonhosted.org/packages/39/f5/3b3836ca6064d0992c58c7561c6b6eee1b3892e9665d650c803bd5614522/charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc", size = 107543, upload-time = "2025-08-09T07:56:23.115Z" }, + { url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" }, + { url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" }, + { url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" }, + { url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" }, + { url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/8e/91/b5a06ad970ddc7a0e513112d40113e834638f4ca1120eb727a249fb2715e/charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15", size = 204342, upload-time = "2025-08-09T07:56:38.687Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ec/1edc30a377f0a02689342f214455c3f6c2fbedd896a1d2f856c002fc3062/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db", size = 145995, upload-time = "2025-08-09T07:56:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/17/e5/5e67ab85e6d22b04641acb5399c8684f4d37caf7558a53859f0283a650e9/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d", size = 158640, upload-time = "2025-08-09T07:56:41.311Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e5/38421987f6c697ee3722981289d554957c4be652f963d71c5e46a262e135/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096", size = 156636, upload-time = "2025-08-09T07:56:43.195Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e4/5a075de8daa3ec0745a9a3b54467e0c2967daaaf2cec04c845f73493e9a1/charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa", size = 150939, upload-time = "2025-08-09T07:56:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/02/f7/3611b32318b30974131db62b4043f335861d4d9b49adc6d57c1149cc49d4/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049", size = 148580, upload-time = "2025-08-09T07:56:46.684Z" }, + { url = "https://files.pythonhosted.org/packages/7e/61/19b36f4bd67f2793ab6a99b979b4e4f3d8fc754cbdffb805335df4337126/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0", size = 159870, upload-time = "2025-08-09T07:56:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/06/57/84722eefdd338c04cf3030ada66889298eaedf3e7a30a624201e0cbe424a/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92", size = 157797, upload-time = "2025-08-09T07:56:49.756Z" }, + { url = "https://files.pythonhosted.org/packages/72/2a/aff5dd112b2f14bcc3462c312dce5445806bfc8ab3a7328555da95330e4b/charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16", size = 152224, upload-time = "2025-08-09T07:56:51.369Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8c/9839225320046ed279c6e839d51f028342eb77c91c89b8ef2549f951f3ec/charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce", size = 100086, upload-time = "2025-08-09T07:56:52.722Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7a/36fbcf646e41f710ce0a563c1c9a343c6edf9be80786edeb15b6f62e17db/charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c", size = 107400, upload-time = "2025-08-09T07:56:55.172Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, +] + +[[package]] +name = "click-didyoumean" +version = "0.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/ce/217289b77c590ea1e7c24242d9ddd6e249e52c795ff10fac2c50062c48cb/click_didyoumean-0.3.1.tar.gz", hash = "sha256:4f82fdff0dbe64ef8ab2279bd6aa3f6a99c3b28c05aa09cbfc07c9d7fbb5a463", size = 3089, upload-time = "2024-03-24T08:22:07.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/5b/974430b5ffdb7a4f1941d13d83c64a0395114503cc357c6b9ae4ce5047ed/click_didyoumean-0.3.1-py3-none-any.whl", hash = "sha256:5c4bb6007cfea5f2fd6583a2fb6701a22a41eb98957e63d0fac41c10e7c3117c", size = 3631, upload-time = "2024-03-24T08:22:06.356Z" }, +] + +[[package]] +name = "click-plugins" +version = "1.1.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/a4/34847b59150da33690a36da3681d6bbc2ec14ee9a846bc30a6746e5984e4/click_plugins-1.1.1.2.tar.gz", hash = "sha256:d7af3984a99d243c131aa1a828331e7630f4a88a9741fd05c927b204bcf92261", size = 8343, upload-time = "2025-06-25T00:47:37.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/9a/2abecb28ae875e39c8cad711eb1186d8d14eab564705325e77e4e6ab9ae5/click_plugins-1.1.1.2-py2.py3-none-any.whl", hash = "sha256:008d65743833ffc1f5417bf0e78e8d2c23aab04d9745ba817bd3e71b0feb6aa6", size = 11051, upload-time = "2025-06-25T00:47:36.731Z" }, +] + +[[package]] +name = "click-repl" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/a2/57f4ac79838cfae6912f997b4d1a64a858fb0c86d7fcaae6f7b58d267fca/click-repl-0.3.0.tar.gz", hash = "sha256:17849c23dba3d667247dc4defe1757fff98694e90fe37474f3feebb69ced26a9", size = 10449, upload-time = "2023-06-15T12:43:51.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/40/9d857001228658f0d59e97ebd4c346fe73e138c6de1bce61dc568a57c7f8/click_repl-0.3.0-py3-none-any.whl", hash = "sha256:fb7e06deb8da8de86180a33a9da97ac316751c094c6899382da7feeeeb51b812", size = 10289, upload-time = "2023-06-15T12:43:48.626Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.10.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/70/025b179c993f019105b79575ac6edb5e084fb0f0e63f15cdebef4e454fb5/coverage-7.10.6.tar.gz", hash = "sha256:f644a3ae5933a552a29dbb9aa2f90c677a875f80ebea028e5a52a4f429044b90", size = 823736, upload-time = "2025-08-29T15:35:16.668Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/06/263f3305c97ad78aab066d116b52250dd316e74fcc20c197b61e07eb391a/coverage-7.10.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5b2dd6059938063a2c9fee1af729d4f2af28fd1a545e9b7652861f0d752ebcea", size = 217324, upload-time = "2025-08-29T15:33:29.06Z" }, + { url = "https://files.pythonhosted.org/packages/e9/60/1e1ded9a4fe80d843d7d53b3e395c1db3ff32d6c301e501f393b2e6c1c1f/coverage-7.10.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:388d80e56191bf846c485c14ae2bc8898aa3124d9d35903fef7d907780477634", size = 217560, upload-time = "2025-08-29T15:33:30.748Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/52136173c14e26dfed8b106ed725811bb53c30b896d04d28d74cb64318b3/coverage-7.10.6-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:90cb5b1a4670662719591aa92d0095bb41714970c0b065b02a2610172dbf0af6", size = 249053, upload-time = "2025-08-29T15:33:32.041Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1d/ae25a7dc58fcce8b172d42ffe5313fc267afe61c97fa872b80ee72d9515a/coverage-7.10.6-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:961834e2f2b863a0e14260a9a273aff07ff7818ab6e66d2addf5628590c628f9", size = 251802, upload-time = "2025-08-29T15:33:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7a/1f561d47743710fe996957ed7c124b421320f150f1d38523d8d9102d3e2a/coverage-7.10.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf9a19f5012dab774628491659646335b1928cfc931bf8d97b0d5918dd58033c", size = 252935, upload-time = "2025-08-29T15:33:34.909Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ad/8b97cd5d28aecdfde792dcbf646bac141167a5cacae2cd775998b45fabb5/coverage-7.10.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:99c4283e2a0e147b9c9cc6bc9c96124de9419d6044837e9799763a0e29a7321a", size = 250855, upload-time = "2025-08-29T15:33:36.922Z" }, + { url = "https://files.pythonhosted.org/packages/33/6a/95c32b558d9a61858ff9d79580d3877df3eb5bc9eed0941b1f187c89e143/coverage-7.10.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:282b1b20f45df57cc508c1e033403f02283adfb67d4c9c35a90281d81e5c52c5", size = 248974, upload-time = "2025-08-29T15:33:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/8ce95dee640a38e760d5b747c10913e7a06554704d60b41e73fdea6a1ffd/coverage-7.10.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8cdbe264f11afd69841bd8c0d83ca10b5b32853263ee62e6ac6a0ab63895f972", size = 250409, upload-time = "2025-08-29T15:33:39.447Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/7a55b0bdde78a98e2eb2356771fd2dcddb96579e8342bb52aa5bc52e96f0/coverage-7.10.6-cp312-cp312-win32.whl", hash = "sha256:a517feaf3a0a3eca1ee985d8373135cfdedfbba3882a5eab4362bda7c7cf518d", size = 219724, upload-time = "2025-08-29T15:33:41.172Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/32b185b8b8e327802c9efce3d3108d2fe2d9d31f153a0f7ecfd59c773705/coverage-7.10.6-cp312-cp312-win_amd64.whl", hash = "sha256:856986eadf41f52b214176d894a7de05331117f6035a28ac0016c0f63d887629", size = 220536, upload-time = "2025-08-29T15:33:42.524Z" }, + { url = "https://files.pythonhosted.org/packages/08/3a/d5d8dc703e4998038c3099eaf77adddb00536a3cec08c8dcd556a36a3eb4/coverage-7.10.6-cp312-cp312-win_arm64.whl", hash = "sha256:acf36b8268785aad739443fa2780c16260ee3fa09d12b3a70f772ef100939d80", size = 219171, upload-time = "2025-08-29T15:33:43.974Z" }, + { url = "https://files.pythonhosted.org/packages/bd/e7/917e5953ea29a28c1057729c1d5af9084ab6d9c66217523fd0e10f14d8f6/coverage-7.10.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ffea0575345e9ee0144dfe5701aa17f3ba546f8c3bb48db62ae101afb740e7d6", size = 217351, upload-time = "2025-08-29T15:33:45.438Z" }, + { url = "https://files.pythonhosted.org/packages/eb/86/2e161b93a4f11d0ea93f9bebb6a53f113d5d6e416d7561ca41bb0a29996b/coverage-7.10.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:95d91d7317cde40a1c249d6b7382750b7e6d86fad9d8eaf4fa3f8f44cf171e80", size = 217600, upload-time = "2025-08-29T15:33:47.269Z" }, + { url = "https://files.pythonhosted.org/packages/0e/66/d03348fdd8df262b3a7fb4ee5727e6e4936e39e2f3a842e803196946f200/coverage-7.10.6-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e23dd5408fe71a356b41baa82892772a4cefcf758f2ca3383d2aa39e1b7a003", size = 248600, upload-time = "2025-08-29T15:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/508420fb47d09d904d962f123221bc249f64b5e56aa93d5f5f7603be475f/coverage-7.10.6-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f3f56e4cb573755e96a16501a98bf211f100463d70275759e73f3cbc00d4f27", size = 251206, upload-time = "2025-08-29T15:33:50.697Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1f/9020135734184f439da85c70ea78194c2730e56c2d18aee6e8ff1719d50d/coverage-7.10.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:db4a1d897bbbe7339946ffa2fe60c10cc81c43fab8b062d3fcb84188688174a4", size = 252478, upload-time = "2025-08-29T15:33:52.303Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a4/3d228f3942bb5a2051fde28c136eea23a761177dc4ff4ef54533164ce255/coverage-7.10.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d8fd7879082953c156d5b13c74aa6cca37f6a6f4747b39538504c3f9c63d043d", size = 250637, upload-time = "2025-08-29T15:33:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/36/e3/293dce8cdb9a83de971637afc59b7190faad60603b40e32635cbd15fbf61/coverage-7.10.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:28395ca3f71cd103b8c116333fa9db867f3a3e1ad6a084aa3725ae002b6583bc", size = 248529, upload-time = "2025-08-29T15:33:55.022Z" }, + { url = "https://files.pythonhosted.org/packages/90/26/64eecfa214e80dd1d101e420cab2901827de0e49631d666543d0e53cf597/coverage-7.10.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:61c950fc33d29c91b9e18540e1aed7d9f6787cc870a3e4032493bbbe641d12fc", size = 250143, upload-time = "2025-08-29T15:33:56.386Z" }, + { url = "https://files.pythonhosted.org/packages/3e/70/bd80588338f65ea5b0d97e424b820fb4068b9cfb9597fbd91963086e004b/coverage-7.10.6-cp313-cp313-win32.whl", hash = "sha256:160c00a5e6b6bdf4e5984b0ef21fc860bc94416c41b7df4d63f536d17c38902e", size = 219770, upload-time = "2025-08-29T15:33:58.063Z" }, + { url = "https://files.pythonhosted.org/packages/a7/14/0b831122305abcc1060c008f6c97bbdc0a913ab47d65070a01dc50293c2b/coverage-7.10.6-cp313-cp313-win_amd64.whl", hash = "sha256:628055297f3e2aa181464c3808402887643405573eb3d9de060d81531fa79d32", size = 220566, upload-time = "2025-08-29T15:33:59.766Z" }, + { url = "https://files.pythonhosted.org/packages/83/c6/81a83778c1f83f1a4a168ed6673eeedc205afb562d8500175292ca64b94e/coverage-7.10.6-cp313-cp313-win_arm64.whl", hash = "sha256:df4ec1f8540b0bcbe26ca7dd0f541847cc8a108b35596f9f91f59f0c060bfdd2", size = 219195, upload-time = "2025-08-29T15:34:01.191Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1c/ccccf4bf116f9517275fa85047495515add43e41dfe8e0bef6e333c6b344/coverage-7.10.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:c9a8b7a34a4de3ed987f636f71881cd3b8339f61118b1aa311fbda12741bff0b", size = 218059, upload-time = "2025-08-29T15:34:02.91Z" }, + { url = "https://files.pythonhosted.org/packages/92/97/8a3ceff833d27c7492af4f39d5da6761e9ff624831db9e9f25b3886ddbca/coverage-7.10.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dd5af36092430c2b075cee966719898f2ae87b636cefb85a653f1d0ba5d5393", size = 218287, upload-time = "2025-08-29T15:34:05.106Z" }, + { url = "https://files.pythonhosted.org/packages/92/d8/50b4a32580cf41ff0423777a2791aaf3269ab60c840b62009aec12d3970d/coverage-7.10.6-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0353b0f0850d49ada66fdd7d0c7cdb0f86b900bb9e367024fd14a60cecc1e27", size = 259625, upload-time = "2025-08-29T15:34:06.575Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7e/6a7df5a6fb440a0179d94a348eb6616ed4745e7df26bf2a02bc4db72c421/coverage-7.10.6-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d6b9ae13d5d3e8aeca9ca94198aa7b3ebbc5acfada557d724f2a1f03d2c0b0df", size = 261801, upload-time = "2025-08-29T15:34:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/3a/4c/a270a414f4ed5d196b9d3d67922968e768cd971d1b251e1b4f75e9362f75/coverage-7.10.6-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:675824a363cc05781b1527b39dc2587b8984965834a748177ee3c37b64ffeafb", size = 264027, upload-time = "2025-08-29T15:34:09.806Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8b/3210d663d594926c12f373c5370bf1e7c5c3a427519a8afa65b561b9a55c/coverage-7.10.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:692d70ea725f471a547c305f0d0fc6a73480c62fb0da726370c088ab21aed282", size = 261576, upload-time = "2025-08-29T15:34:11.585Z" }, + { url = "https://files.pythonhosted.org/packages/72/d0/e1961eff67e9e1dba3fc5eb7a4caf726b35a5b03776892da8d79ec895775/coverage-7.10.6-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:851430a9a361c7a8484a36126d1d0ff8d529d97385eacc8dfdc9bfc8c2d2cbe4", size = 259341, upload-time = "2025-08-29T15:34:13.159Z" }, + { url = "https://files.pythonhosted.org/packages/3a/06/d6478d152cd189b33eac691cba27a40704990ba95de49771285f34a5861e/coverage-7.10.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d9369a23186d189b2fc95cc08b8160ba242057e887d766864f7adf3c46b2df21", size = 260468, upload-time = "2025-08-29T15:34:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/ed/73/737440247c914a332f0b47f7598535b29965bf305e19bbc22d4c39615d2b/coverage-7.10.6-cp313-cp313t-win32.whl", hash = "sha256:92be86fcb125e9bda0da7806afd29a3fd33fdf58fba5d60318399adf40bf37d0", size = 220429, upload-time = "2025-08-29T15:34:16.394Z" }, + { url = "https://files.pythonhosted.org/packages/bd/76/b92d3214740f2357ef4a27c75a526eb6c28f79c402e9f20a922c295c05e2/coverage-7.10.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6b3039e2ca459a70c79523d39347d83b73f2f06af5624905eba7ec34d64d80b5", size = 221493, upload-time = "2025-08-29T15:34:17.835Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8e/6dcb29c599c8a1f654ec6cb68d76644fe635513af16e932d2d4ad1e5ac6e/coverage-7.10.6-cp313-cp313t-win_arm64.whl", hash = "sha256:3fb99d0786fe17b228eab663d16bee2288e8724d26a199c29325aac4b0319b9b", size = 219757, upload-time = "2025-08-29T15:34:19.248Z" }, + { url = "https://files.pythonhosted.org/packages/d3/aa/76cf0b5ec00619ef208da4689281d48b57f2c7fde883d14bf9441b74d59f/coverage-7.10.6-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6008a021907be8c4c02f37cdc3ffb258493bdebfeaf9a839f9e71dfdc47b018e", size = 217331, upload-time = "2025-08-29T15:34:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/65/91/8e41b8c7c505d398d7730206f3cbb4a875a35ca1041efc518051bfce0f6b/coverage-7.10.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5e75e37f23eb144e78940b40395b42f2321951206a4f50e23cfd6e8a198d3ceb", size = 217607, upload-time = "2025-08-29T15:34:22.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/7f/f718e732a423d442e6616580a951b8d1ec3575ea48bcd0e2228386805e79/coverage-7.10.6-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0f7cb359a448e043c576f0da00aa8bfd796a01b06aa610ca453d4dde09cc1034", size = 248663, upload-time = "2025-08-29T15:34:24.425Z" }, + { url = "https://files.pythonhosted.org/packages/e6/52/c1106120e6d801ac03e12b5285e971e758e925b6f82ee9b86db3aa10045d/coverage-7.10.6-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c68018e4fc4e14b5668f1353b41ccf4bc83ba355f0e1b3836861c6f042d89ac1", size = 251197, upload-time = "2025-08-29T15:34:25.906Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ec/3a8645b1bb40e36acde9c0609f08942852a4af91a937fe2c129a38f2d3f5/coverage-7.10.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cd4b2b0707fc55afa160cd5fc33b27ccbf75ca11d81f4ec9863d5793fc6df56a", size = 252551, upload-time = "2025-08-29T15:34:27.337Z" }, + { url = "https://files.pythonhosted.org/packages/a1/70/09ecb68eeb1155b28a1d16525fd3a9b65fbe75337311a99830df935d62b6/coverage-7.10.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cec13817a651f8804a86e4f79d815b3b28472c910e099e4d5a0e8a3b6a1d4cb", size = 250553, upload-time = "2025-08-29T15:34:29.065Z" }, + { url = "https://files.pythonhosted.org/packages/c6/80/47df374b893fa812e953b5bc93dcb1427a7b3d7a1a7d2db33043d17f74b9/coverage-7.10.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f2a6a8e06bbda06f78739f40bfb56c45d14eb8249d0f0ea6d4b3d48e1f7c695d", size = 248486, upload-time = "2025-08-29T15:34:30.897Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/9f98640979ecee1b0d1a7164b589de720ddf8100d1747d9bbdb84be0c0fb/coverage-7.10.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:081b98395ced0d9bcf60ada7661a0b75f36b78b9d7e39ea0790bb4ed8da14747", size = 249981, upload-time = "2025-08-29T15:34:32.365Z" }, + { url = "https://files.pythonhosted.org/packages/1f/55/eeb6603371e6629037f47bd25bef300387257ed53a3c5fdb159b7ac8c651/coverage-7.10.6-cp314-cp314-win32.whl", hash = "sha256:6937347c5d7d069ee776b2bf4e1212f912a9f1f141a429c475e6089462fcecc5", size = 220054, upload-time = "2025-08-29T15:34:34.124Z" }, + { url = "https://files.pythonhosted.org/packages/15/d1/a0912b7611bc35412e919a2cd59ae98e7ea3b475e562668040a43fb27897/coverage-7.10.6-cp314-cp314-win_amd64.whl", hash = "sha256:adec1d980fa07e60b6ef865f9e5410ba760e4e1d26f60f7e5772c73b9a5b0713", size = 220851, upload-time = "2025-08-29T15:34:35.651Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2d/11880bb8ef80a45338e0b3e0725e4c2d73ffbb4822c29d987078224fd6a5/coverage-7.10.6-cp314-cp314-win_arm64.whl", hash = "sha256:a80f7aef9535442bdcf562e5a0d5a5538ce8abe6bb209cfbf170c462ac2c2a32", size = 219429, upload-time = "2025-08-29T15:34:37.16Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/1f00caad775c03a700146f55536ecd097a881ff08d310a58b353a1421be0/coverage-7.10.6-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:0de434f4fbbe5af4fa7989521c655c8c779afb61c53ab561b64dcee6149e4c65", size = 218080, upload-time = "2025-08-29T15:34:38.919Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c4/b1c5d2bd7cc412cbeb035e257fd06ed4e3e139ac871d16a07434e145d18d/coverage-7.10.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6e31b8155150c57e5ac43ccd289d079eb3f825187d7c66e755a055d2c85794c6", size = 218293, upload-time = "2025-08-29T15:34:40.425Z" }, + { url = "https://files.pythonhosted.org/packages/3f/07/4468d37c94724bf6ec354e4ec2f205fda194343e3e85fd2e59cec57e6a54/coverage-7.10.6-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:98cede73eb83c31e2118ae8d379c12e3e42736903a8afcca92a7218e1f2903b0", size = 259800, upload-time = "2025-08-29T15:34:41.996Z" }, + { url = "https://files.pythonhosted.org/packages/82/d8/f8fb351be5fee31690cd8da768fd62f1cfab33c31d9f7baba6cd8960f6b8/coverage-7.10.6-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f863c08f4ff6b64fa8045b1e3da480f5374779ef187f07b82e0538c68cb4ff8e", size = 261965, upload-time = "2025-08-29T15:34:43.61Z" }, + { url = "https://files.pythonhosted.org/packages/e8/70/65d4d7cfc75c5c6eb2fed3ee5cdf420fd8ae09c4808723a89a81d5b1b9c3/coverage-7.10.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b38261034fda87be356f2c3f42221fdb4171c3ce7658066ae449241485390d5", size = 264220, upload-time = "2025-08-29T15:34:45.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/3c/069df106d19024324cde10e4ec379fe2fb978017d25e97ebee23002fbadf/coverage-7.10.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e93b1476b79eae849dc3872faeb0bf7948fd9ea34869590bc16a2a00b9c82a7", size = 261660, upload-time = "2025-08-29T15:34:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/2974d53904080c5dc91af798b3a54a4ccb99a45595cc0dcec6eb9616a57d/coverage-7.10.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ff8a991f70f4c0cf53088abf1e3886edcc87d53004c7bb94e78650b4d3dac3b5", size = 259417, upload-time = "2025-08-29T15:34:48.779Z" }, + { url = "https://files.pythonhosted.org/packages/30/38/9616a6b49c686394b318974d7f6e08f38b8af2270ce7488e879888d1e5db/coverage-7.10.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ac765b026c9f33044419cbba1da913cfb82cca1b60598ac1c7a5ed6aac4621a0", size = 260567, upload-time = "2025-08-29T15:34:50.718Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/3ed2d6312b371a8cf804abf4e14895b70e4c3491c6e53536d63fd0958a8d/coverage-7.10.6-cp314-cp314t-win32.whl", hash = "sha256:441c357d55f4936875636ef2cfb3bee36e466dcf50df9afbd398ce79dba1ebb7", size = 220831, upload-time = "2025-08-29T15:34:52.653Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e5/d38d0cb830abede2adb8b147770d2a3d0e7fecc7228245b9b1ae6c24930a/coverage-7.10.6-cp314-cp314t-win_amd64.whl", hash = "sha256:073711de3181b2e204e4870ac83a7c4853115b42e9cd4d145f2231e12d670930", size = 221950, upload-time = "2025-08-29T15:34:54.212Z" }, + { url = "https://files.pythonhosted.org/packages/f4/51/e48e550f6279349895b0ffcd6d2a690e3131ba3a7f4eafccc141966d4dea/coverage-7.10.6-cp314-cp314t-win_arm64.whl", hash = "sha256:137921f2bac5559334ba66122b753db6dc5d1cf01eb7b64eb412bb0d064ef35b", size = 219969, upload-time = "2025-08-29T15:34:55.83Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/50db5379b615854b5cf89146f8f5bd1d5a9693d7f3a987e269693521c404/coverage-7.10.6-py3-none-any.whl", hash = "sha256:92c4ecf6bf11b2e85fd4d8204814dc26e6a19f0c9d938c207c5cb0eadfcabbe3", size = 208986, upload-time = "2025-08-29T15:35:14.506Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "fastapi" +version = "0.116.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, +] + +[[package]] +name = "filelock" +version = "3.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" }, +] + +[[package]] +name = "flake8" +version = "7.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mccabe" }, + { name = "pycodestyle" }, + { name = "pyflakes" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/af/fbfe3c4b5a657d79e5c47a2827a362f9e1b763336a52f926126aa6dc7123/flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872", size = 48326, upload-time = "2025-06-20T19:31:35.838Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/56/13ab06b4f93ca7cac71078fbe37fcea175d3216f31f85c3168a6bbd0bb9a/flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e", size = 57922, upload-time = "2025-06-20T19:31:34.425Z" }, +] + +[[package]] +name = "google-api-core" +version = "2.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "googleapis-common-protos" }, + { name = "proto-plus" }, + { name = "protobuf" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/21/e9d043e88222317afdbdb567165fdbc3b0aad90064c7e0c9eb0ad9955ad8/google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8", size = 165443, upload-time = "2025-06-12T20:52:20.439Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/4b/ead00905132820b623732b175d66354e9d3e69fcf2a5dcdab780664e7896/google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7", size = 160807, upload-time = "2025-06-12T20:52:19.334Z" }, +] + +[[package]] +name = "google-api-python-client" +version = "2.181.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-api-core" }, + { name = "google-auth" }, + { name = "google-auth-httplib2" }, + { name = "httplib2" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/96/5561a5d7e37781c880ca90975a70d61940ec1648b2b12e991311a9e39f83/google_api_python_client-2.181.0.tar.gz", hash = "sha256:d7060962a274a16a2c6f8fb4b1569324dbff11bfbca8eb050b88ead1dd32261c", size = 13545438, upload-time = "2025-09-02T15:41:33.852Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/03/72b7acf374a2cde9255df161686f00d8370117ac33e2bdd8fdadfe30272a/google_api_python_client-2.181.0-py3-none-any.whl", hash = "sha256:348730e3ece46434a01415f3d516d7a0885c8e624ce799f50f2d4d86c2475fb7", size = 14111793, upload-time = "2025-09-02T15:41:31.322Z" }, +] + +[[package]] +name = "google-auth" +version = "2.40.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "pyasn1-modules" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/9b/e92ef23b84fa10a64ce4831390b7a4c2e53c0132568d99d4ae61d04c8855/google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77", size = 281029, upload-time = "2025-06-04T18:04:57.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/63/b19553b658a1692443c62bd07e5868adaa0ad746a0751ba62c59568cd45b/google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca", size = 216137, upload-time = "2025-06-04T18:04:55.573Z" }, +] + +[[package]] +name = "google-auth-httplib2" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "httplib2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/be/217a598a818567b28e859ff087f347475c807a5649296fb5a817c58dacef/google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05", size = 10842, upload-time = "2023-12-12T17:40:30.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/8a/fe34d2f3f9470a27b01c9e76226965863f153d5fbe276f83608562e49c04/google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d", size = 9253, upload-time = "2023-12-12T17:40:13.055Z" }, +] + +[[package]] +name = "google-auth-oauthlib" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "google-auth" }, + { name = "requests-oauthlib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/87/e10bf24f7bcffc1421b84d6f9c3377c30ec305d082cd737ddaa6d8f77f7c/google_auth_oauthlib-1.2.2.tar.gz", hash = "sha256:11046fb8d3348b296302dd939ace8af0a724042e8029c1b872d87fabc9f41684", size = 20955, upload-time = "2025-04-22T16:40:29.172Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/84/40ee070be95771acd2f4418981edb834979424565c3eec3cd88b6aa09d24/google_auth_oauthlib-1.2.2-py3-none-any.whl", hash = "sha256:fd619506f4b3908b5df17b65f39ca8d66ea56986e5472eb5978fd8f3786f00a2", size = 19072, upload-time = "2025-04-22T16:40:28.174Z" }, +] + +[[package]] +name = "googleapis-common-protos" +version = "1.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/24/33db22342cf4a2ea27c9955e6713140fedd51e8b141b5ce5260897020f1a/googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257", size = 145903, upload-time = "2025-04-14T10:17:02.924Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/f1/62a193f0227cf15a920390abe675f386dec35f7ae3ffe6da582d3ade42c7/googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8", size = 294530, upload-time = "2025-04-14T10:17:01.271Z" }, +] + +[[package]] +name = "greenlet" +version = "3.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/03/b8/704d753a5a45507a7aab61f18db9509302ed3d0a27ac7e0359ec2905b1a6/greenlet-3.2.4.tar.gz", hash = "sha256:0dca0d95ff849f9a364385f36ab49f50065d76964944638be9691e1832e9f86d", size = 188260, upload-time = "2025-08-07T13:24:33.51Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/69/9b804adb5fd0671f367781560eb5eb586c4d495277c93bde4307b9e28068/greenlet-3.2.4-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:3b67ca49f54cede0186854a008109d6ee71f66bd57bb36abd6d0a0267b540cdd", size = 274079, upload-time = "2025-08-07T13:15:45.033Z" }, + { url = "https://files.pythonhosted.org/packages/46/e9/d2a80c99f19a153eff70bc451ab78615583b8dac0754cfb942223d2c1a0d/greenlet-3.2.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddf9164e7a5b08e9d22511526865780a576f19ddd00d62f8a665949327fde8bb", size = 640997, upload-time = "2025-08-07T13:42:56.234Z" }, + { url = "https://files.pythonhosted.org/packages/3b/16/035dcfcc48715ccd345f3a93183267167cdd162ad123cd93067d86f27ce4/greenlet-3.2.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f28588772bb5fb869a8eb331374ec06f24a83a9c25bfa1f38b6993afe9c1e968", size = 655185, upload-time = "2025-08-07T13:45:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/31/da/0386695eef69ffae1ad726881571dfe28b41970173947e7c558d9998de0f/greenlet-3.2.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5c9320971821a7cb77cfab8d956fa8e39cd07ca44b6070db358ceb7f8797c8c9", size = 649926, upload-time = "2025-08-07T13:53:15.251Z" }, + { url = "https://files.pythonhosted.org/packages/68/88/69bf19fd4dc19981928ceacbc5fd4bb6bc2215d53199e367832e98d1d8fe/greenlet-3.2.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c60a6d84229b271d44b70fb6e5fa23781abb5d742af7b808ae3f6efd7c9c60f6", size = 651839, upload-time = "2025-08-07T13:18:30.281Z" }, + { url = "https://files.pythonhosted.org/packages/19/0d/6660d55f7373b2ff8152401a83e02084956da23ae58cddbfb0b330978fe9/greenlet-3.2.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b3812d8d0c9579967815af437d96623f45c0f2ae5f04e366de62a12d83a8fb0", size = 607586, upload-time = "2025-08-07T13:18:28.544Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1a/c953fdedd22d81ee4629afbb38d2f9d71e37d23caace44775a3a969147d4/greenlet-3.2.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:abbf57b5a870d30c4675928c37278493044d7c14378350b3aa5d484fa65575f0", size = 1123281, upload-time = "2025-08-07T13:42:39.858Z" }, + { url = "https://files.pythonhosted.org/packages/3f/c7/12381b18e21aef2c6bd3a636da1088b888b97b7a0362fac2e4de92405f97/greenlet-3.2.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:20fb936b4652b6e307b8f347665e2c615540d4b42b3b4c8a321d8286da7e520f", size = 1151142, upload-time = "2025-08-07T13:18:22.981Z" }, + { url = "https://files.pythonhosted.org/packages/e9/08/b0814846b79399e585f974bbeebf5580fbe59e258ea7be64d9dfb253c84f/greenlet-3.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7d4e128405eea3814a12cc2605e0e6aedb4035bf32697f72deca74de4105e02", size = 299899, upload-time = "2025-08-07T13:38:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/49/e8/58c7f85958bda41dafea50497cbd59738c5c43dbbea5ee83d651234398f4/greenlet-3.2.4-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:1a921e542453fe531144e91e1feedf12e07351b1cf6c9e8a3325ea600a715a31", size = 272814, upload-time = "2025-08-07T13:15:50.011Z" }, + { url = "https://files.pythonhosted.org/packages/62/dd/b9f59862e9e257a16e4e610480cfffd29e3fae018a68c2332090b53aac3d/greenlet-3.2.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cd3c8e693bff0fff6ba55f140bf390fa92c994083f838fece0f63be121334945", size = 641073, upload-time = "2025-08-07T13:42:57.23Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0b/bc13f787394920b23073ca3b6c4a7a21396301ed75a655bcb47196b50e6e/greenlet-3.2.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:710638eb93b1fa52823aa91bf75326f9ecdfd5e0466f00789246a5280f4ba0fc", size = 655191, upload-time = "2025-08-07T13:45:29.752Z" }, + { url = "https://files.pythonhosted.org/packages/f2/d6/6adde57d1345a8d0f14d31e4ab9c23cfe8e2cd39c3baf7674b4b0338d266/greenlet-3.2.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c5111ccdc9c88f423426df3fd1811bfc40ed66264d35aa373420a34377efc98a", size = 649516, upload-time = "2025-08-07T13:53:16.314Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/3a3328a788d4a473889a2d403199932be55b1b0060f4ddd96ee7cdfcad10/greenlet-3.2.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76383238584e9711e20ebe14db6c88ddcedc1829a9ad31a584389463b5aa504", size = 652169, upload-time = "2025-08-07T13:18:32.861Z" }, + { url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" }, + { url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" }, + { url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" }, + { url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" }, + { url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" }, + { url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" }, + { url = "https://files.pythonhosted.org/packages/c0/aa/687d6b12ffb505a4447567d1f3abea23bd20e73a5bed63871178e0831b7a/greenlet-3.2.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c17b6b34111ea72fc5a4e4beec9711d2226285f0386ea83477cbb97c30a3f3a5", size = 699218, upload-time = "2025-08-07T13:45:30.969Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" }, + { url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" }, + { url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httplib2" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/75/1d10a90b3411f707c10c226fa918cf4f5e0578113caa223369130f702b6b/httplib2-0.30.0.tar.gz", hash = "sha256:d5b23c11fcf8e57e00ff91b7008656af0f6242c8886fd97065c97509e4e548c5", size = 249764, upload-time = "2025-08-29T18:58:36.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/7c/f35bd530a35654ef3ff81f5e102572b8b620361659e090beb85a73a3bcc9/httplib2-0.30.0-py3-none-any.whl", hash = "sha256:d10443a2bdfe0ea5dbb17e016726146d48b574208dafd41e854cf34e7d78842c", size = 91101, upload-time = "2025-08-29T18:58:33.224Z" }, +] + +[[package]] +name = "httptools" +version = "0.6.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload-time = "2024-10-16T19:44:30.175Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload-time = "2024-10-16T19:44:31.786Z" }, + { url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload-time = "2024-10-16T19:44:32.825Z" }, + { url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload-time = "2024-10-16T19:44:33.974Z" }, + { url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload-time = "2024-10-16T19:44:35.111Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload-time = "2024-10-16T19:44:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload-time = "2024-10-16T19:44:37.357Z" }, + { url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" }, + { url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" }, + { url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" }, + { url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" }, + { url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/c4/62963f25a678f6a050fb0505a65e9e726996171e6dbe1547f79619eefb15/identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a", size = 99283, upload-time = "2025-09-06T19:30:52.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e", size = 99172, upload-time = "2025-09-06T19:30:51.759Z" }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, +] + +[[package]] +name = "isort" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/21/1e2a441f74a653a144224d7d21afe8f4169e6c7c20bb13aec3a2dc3815e0/isort-6.0.1.tar.gz", hash = "sha256:1cb5df28dfbc742e490c5e41bad6da41b805b0a8be7bc93cd0fb2a8a890ac450", size = 821955, upload-time = "2025-02-26T21:13:16.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/11/114d0a5f4dabbdcedc1125dee0888514c3c3b16d3e9facad87ed96fad97c/isort-6.0.1-py3-none-any.whl", hash = "sha256:2dc5d7f65c9678d94c88dfc29161a320eec67328bc97aad576874cb4be1e9615", size = 94186, upload-time = "2025-02-26T21:13:14.911Z" }, +] + +[[package]] +name = "kombu" +version = "5.5.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "amqp" }, + { name = "packaging" }, + { name = "tzdata" }, + { name = "vine" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/d3/5ff936d8319ac86b9c409f1501b07c426e6ad41966fedace9ef1b966e23f/kombu-5.5.4.tar.gz", hash = "sha256:886600168275ebeada93b888e831352fe578168342f0d1d5833d88ba0d847363", size = 461992, upload-time = "2025-06-01T10:19:22.281Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/70/a07dcf4f62598c8ad579df241af55ced65bed76e42e45d3c368a6d82dbc1/kombu-5.5.4-py3-none-any.whl", hash = "sha256:a12ed0557c238897d8e518f1d1fdf84bd1516c5e305af2dacd85c2015115feb8", size = 210034, upload-time = "2025-06-01T10:19:20.436Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mypy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570, upload-time = "2025-07-31T07:54:19.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/a2/7034d0d61af8098ec47902108553122baa0f438df8a713be860f7407c9e6/mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb", size = 11086295, upload-time = "2025-07-31T07:53:28.124Z" }, + { url = "https://files.pythonhosted.org/packages/14/1f/19e7e44b594d4b12f6ba8064dbe136505cec813549ca3e5191e40b1d3cc2/mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403", size = 10112355, upload-time = "2025-07-31T07:53:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/5b/69/baa33927e29e6b4c55d798a9d44db5d394072eef2bdc18c3e2048c9ed1e9/mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056", size = 11875285, upload-time = "2025-07-31T07:53:55.293Z" }, + { url = "https://files.pythonhosted.org/packages/90/13/f3a89c76b0a41e19490b01e7069713a30949d9a6c147289ee1521bcea245/mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341", size = 12737895, upload-time = "2025-07-31T07:53:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/23/a1/c4ee79ac484241301564072e6476c5a5be2590bc2e7bfd28220033d2ef8f/mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb", size = 12931025, upload-time = "2025-07-31T07:54:17.125Z" }, + { url = "https://files.pythonhosted.org/packages/89/b8/7409477be7919a0608900e6320b155c72caab4fef46427c5cc75f85edadd/mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19", size = 9584664, upload-time = "2025-07-31T07:54:12.842Z" }, + { url = "https://files.pythonhosted.org/packages/5b/82/aec2fc9b9b149f372850291827537a508d6c4d3664b1750a324b91f71355/mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7", size = 11075338, upload-time = "2025-07-31T07:53:38.873Z" }, + { url = "https://files.pythonhosted.org/packages/07/ac/ee93fbde9d2242657128af8c86f5d917cd2887584cf948a8e3663d0cd737/mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81", size = 10113066, upload-time = "2025-07-31T07:54:14.707Z" }, + { url = "https://files.pythonhosted.org/packages/5a/68/946a1e0be93f17f7caa56c45844ec691ca153ee8b62f21eddda336a2d203/mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6", size = 11875473, upload-time = "2025-07-31T07:53:14.504Z" }, + { url = "https://files.pythonhosted.org/packages/9f/0f/478b4dce1cb4f43cf0f0d00fba3030b21ca04a01b74d1cd272a528cf446f/mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849", size = 12744296, upload-time = "2025-07-31T07:53:03.896Z" }, + { url = "https://files.pythonhosted.org/packages/ca/70/afa5850176379d1b303f992a828de95fc14487429a7139a4e0bdd17a8279/mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14", size = 12914657, upload-time = "2025-07-31T07:54:08.576Z" }, + { url = "https://files.pythonhosted.org/packages/53/f9/4a83e1c856a3d9c8f6edaa4749a4864ee98486e9b9dbfbc93842891029c2/mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a", size = 9593320, upload-time = "2025-07-31T07:53:01.341Z" }, + { url = "https://files.pythonhosted.org/packages/38/56/79c2fac86da57c7d8c48622a05873eaab40b905096c33597462713f5af90/mypy-1.17.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:15a83369400454c41ed3a118e0cc58bd8123921a602f385cb6d6ea5df050c733", size = 11040037, upload-time = "2025-07-31T07:54:10.942Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c3/adabe6ff53638e3cad19e3547268482408323b1e68bf082c9119000cd049/mypy-1.17.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:55b918670f692fc9fba55c3298d8a3beae295c5cded0a55dccdc5bbead814acd", size = 10131550, upload-time = "2025-07-31T07:53:41.307Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c5/2e234c22c3bdeb23a7817af57a58865a39753bde52c74e2c661ee0cfc640/mypy-1.17.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62761474061feef6f720149d7ba876122007ddc64adff5ba6f374fda35a018a0", size = 11872963, upload-time = "2025-07-31T07:53:16.878Z" }, + { url = "https://files.pythonhosted.org/packages/ab/26/c13c130f35ca8caa5f2ceab68a247775648fdcd6c9a18f158825f2bc2410/mypy-1.17.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c49562d3d908fd49ed0938e5423daed8d407774a479b595b143a3d7f87cdae6a", size = 12710189, upload-time = "2025-07-31T07:54:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/82/df/c7d79d09f6de8383fe800521d066d877e54d30b4fb94281c262be2df84ef/mypy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:397fba5d7616a5bc60b45c7ed204717eaddc38f826e3645402c426057ead9a91", size = 12900322, upload-time = "2025-07-31T07:53:10.551Z" }, + { url = "https://files.pythonhosted.org/packages/b8/98/3d5a48978b4f708c55ae832619addc66d677f6dc59f3ebad71bae8285ca6/mypy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:9d6b20b97d373f41617bd0708fd46aa656059af57f2ef72aa8c7d6a2b73b74ed", size = 9751879, upload-time = "2025-07-31T07:52:56.683Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411, upload-time = "2025-07-31T07:53:24.664Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "proto-plus" +version = "1.26.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "protobuf" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/ac/87285f15f7cce6d4a008f33f1757fb5a13611ea8914eb58c3d0d26243468/proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012", size = 56142, upload-time = "2025-03-10T15:54:38.843Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/6d/280c4c2ce28b1593a19ad5239c8b826871fc6ec275c21afc8e1820108039/proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66", size = 50163, upload-time = "2025-03-10T15:54:37.335Z" }, +] + +[[package]] +name = "protobuf" +version = "6.32.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/df/fb4a8eeea482eca989b51cffd274aac2ee24e825f0bf3cbce5281fa1567b/protobuf-6.32.0.tar.gz", hash = "sha256:a81439049127067fc49ec1d36e25c6ee1d1a2b7be930675f919258d03c04e7d2", size = 440614, upload-time = "2025-08-14T21:21:25.015Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/18/df8c87da2e47f4f1dcc5153a81cd6bca4e429803f4069a299e236e4dd510/protobuf-6.32.0-cp310-abi3-win32.whl", hash = "sha256:84f9e3c1ff6fb0308dbacb0950d8aa90694b0d0ee68e75719cb044b7078fe741", size = 424409, upload-time = "2025-08-14T21:21:12.366Z" }, + { url = "https://files.pythonhosted.org/packages/e1/59/0a820b7310f8139bd8d5a9388e6a38e1786d179d6f33998448609296c229/protobuf-6.32.0-cp310-abi3-win_amd64.whl", hash = "sha256:a8bdbb2f009cfc22a36d031f22a625a38b615b5e19e558a7b756b3279723e68e", size = 435735, upload-time = "2025-08-14T21:21:15.046Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5b/0d421533c59c789e9c9894683efac582c06246bf24bb26b753b149bd88e4/protobuf-6.32.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:d52691e5bee6c860fff9a1c86ad26a13afbeb4b168cd4445c922b7e2cf85aaf0", size = 426449, upload-time = "2025-08-14T21:21:16.687Z" }, + { url = "https://files.pythonhosted.org/packages/ec/7b/607764ebe6c7a23dcee06e054fd1de3d5841b7648a90fd6def9a3bb58c5e/protobuf-6.32.0-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:501fe6372fd1c8ea2a30b4d9be8f87955a64d6be9c88a973996cef5ef6f0abf1", size = 322869, upload-time = "2025-08-14T21:21:18.282Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/2e730bd1c25392fc32e3268e02446f0d77cb51a2c3a8486b1798e34d5805/protobuf-6.32.0-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:75a2aab2bd1aeb1f5dc7c5f33bcb11d82ea8c055c9becbb41c26a8c43fd7092c", size = 322009, upload-time = "2025-08-14T21:21:19.893Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f2/80ffc4677aac1bc3519b26bc7f7f5de7fce0ee2f7e36e59e27d8beb32dd1/protobuf-6.32.0-py3-none-any.whl", hash = "sha256:ba377e5b67b908c8f3072a57b63e2c6a4cbd18aea4ed98d2584350dbf46f2783", size = 169287, upload-time = "2025-08-14T21:21:23.515Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "pycodestyle" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/e0/abfd2a0d2efe47670df87f3e3a0e2edda42f055053c85361f19c0e2c1ca8/pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783", size = 39472, upload-time = "2025-06-20T18:49:48.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/27/a58ddaf8c588a3ef080db9d0b7e0b97215cee3a45df74f3a94dbbf5c893a/pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d", size = 31594, upload-time = "2025-06-20T18:49:47.491Z" }, +] + +[[package]] +name = "pydantic" +version = "2.11.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.33.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, +] + +[[package]] +name = "pyflakes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/dc/fd034dc20b4b264b3d015808458391acbf9df40b1e54750ef175d39180b1/pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58", size = 64669, upload-time = "2025-06-20T18:45:27.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/2f/81d580a0fb83baeb066698975cb14a618bdbed7720678566f1b046a95fe8/pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f", size = 63551, upload-time = "2025-06-20T18:45:26.937Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608, upload-time = "2025-03-25T05:01:28.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120, upload-time = "2025-03-25T05:01:24.908Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/4c/f883ab8f0daad69f47efdf95f55a66b51a8b939c430dadce0611508d9e99/pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2", size = 70398, upload-time = "2025-09-06T15:40:14.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749", size = 25115, upload-time = "2025-09-06T15:40:12.44Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/87/f44d7c9f274c7ee665a29b885ec97089ec5dc034c7f3fafa03da9e39a09e/python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13", size = 37158, upload-time = "2024-12-16T19:45:46.972Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "redis" +version = "6.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/d6/e8b92798a5bd67d659d51a18170e91c16ac3b59738d91894651ee255ed49/redis-6.4.0.tar.gz", hash = "sha256:b01bc7282b8444e28ec36b261df5375183bb47a07eb9c603f284e89cbc5ef010", size = 4647399, upload-time = "2025-08-07T08:10:11.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/02/89e2ed7e85db6c93dfa9e8f691c5087df4e3551ab39081a4d7c6d1f90e05/redis-6.4.0-py3-none-any.whl", hash = "sha256:f0544fa9604264e9464cdf4814e7d4830f74b165d52f2a330a760a88dd248b7f", size = 279847, upload-time = "2025-08-07T08:10:09.84Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.43" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/bc/d59b5d97d27229b0e009bd9098cd81af71c2fa5549c580a0a67b9bed0496/sqlalchemy-2.0.43.tar.gz", hash = "sha256:788bfcef6787a7764169cfe9859fe425bf44559619e1d9f56f5bddf2ebf6f417", size = 9762949, upload-time = "2025-08-11T14:24:58.438Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/db/20c78f1081446095450bdc6ee6cc10045fce67a8e003a5876b6eaafc5cc4/sqlalchemy-2.0.43-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:20d81fc2736509d7a2bd33292e489b056cbae543661bb7de7ce9f1c0cd6e7f24", size = 2134891, upload-time = "2025-08-11T15:51:13.019Z" }, + { url = "https://files.pythonhosted.org/packages/45/0a/3d89034ae62b200b4396f0f95319f7d86e9945ee64d2343dcad857150fa2/sqlalchemy-2.0.43-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b9fc27650ff5a2c9d490c13c14906b918b0de1f8fcbb4c992712d8caf40e83", size = 2123061, upload-time = "2025-08-11T15:51:14.319Z" }, + { url = "https://files.pythonhosted.org/packages/cb/10/2711f7ff1805919221ad5bee205971254845c069ee2e7036847103ca1e4c/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6772e3ca8a43a65a37c88e2f3e2adfd511b0b1da37ef11ed78dea16aeae85bd9", size = 3320384, upload-time = "2025-08-11T15:52:35.088Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0e/3d155e264d2ed2778484006ef04647bc63f55b3e2d12e6a4f787747b5900/sqlalchemy-2.0.43-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a113da919c25f7f641ffbd07fbc9077abd4b3b75097c888ab818f962707eb48", size = 3329648, upload-time = "2025-08-11T15:56:34.153Z" }, + { url = "https://files.pythonhosted.org/packages/5b/81/635100fb19725c931622c673900da5efb1595c96ff5b441e07e3dd61f2be/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4286a1139f14b7d70141c67a8ae1582fc2b69105f1b09d9573494eb4bb4b2687", size = 3258030, upload-time = "2025-08-11T15:52:36.933Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ed/a99302716d62b4965fded12520c1cbb189f99b17a6d8cf77611d21442e47/sqlalchemy-2.0.43-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:529064085be2f4d8a6e5fab12d36ad44f1909a18848fcfbdb59cc6d4bbe48efe", size = 3294469, upload-time = "2025-08-11T15:56:35.553Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a2/3a11b06715149bf3310b55a98b5c1e84a42cfb949a7b800bc75cb4e33abc/sqlalchemy-2.0.43-cp312-cp312-win32.whl", hash = "sha256:b535d35dea8bbb8195e7e2b40059e2253acb2b7579b73c1b432a35363694641d", size = 2098906, upload-time = "2025-08-11T15:55:00.645Z" }, + { url = "https://files.pythonhosted.org/packages/bc/09/405c915a974814b90aa591280623adc6ad6b322f61fd5cff80aeaef216c9/sqlalchemy-2.0.43-cp312-cp312-win_amd64.whl", hash = "sha256:1c6d85327ca688dbae7e2b06d7d84cfe4f3fffa5b5f9e21bb6ce9d0e1a0e0e0a", size = 2126260, upload-time = "2025-08-11T15:55:02.965Z" }, + { url = "https://files.pythonhosted.org/packages/41/1c/a7260bd47a6fae7e03768bf66451437b36451143f36b285522b865987ced/sqlalchemy-2.0.43-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e7c08f57f75a2bb62d7ee80a89686a5e5669f199235c6d1dac75cd59374091c3", size = 2130598, upload-time = "2025-08-11T15:51:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/8e/84/8a337454e82388283830b3586ad7847aa9c76fdd4f1df09cdd1f94591873/sqlalchemy-2.0.43-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:14111d22c29efad445cd5021a70a8b42f7d9152d8ba7f73304c4d82460946aaa", size = 2118415, upload-time = "2025-08-11T15:51:17.256Z" }, + { url = "https://files.pythonhosted.org/packages/cf/ff/22ab2328148492c4d71899d62a0e65370ea66c877aea017a244a35733685/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21b27b56eb2f82653168cefe6cb8e970cdaf4f3a6cb2c5e3c3c1cf3158968ff9", size = 3248707, upload-time = "2025-08-11T15:52:38.444Z" }, + { url = "https://files.pythonhosted.org/packages/dc/29/11ae2c2b981de60187f7cbc84277d9d21f101093d1b2e945c63774477aba/sqlalchemy-2.0.43-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c5a9da957c56e43d72126a3f5845603da00e0293720b03bde0aacffcf2dc04f", size = 3253602, upload-time = "2025-08-11T15:56:37.348Z" }, + { url = "https://files.pythonhosted.org/packages/b8/61/987b6c23b12c56d2be451bc70900f67dd7d989d52b1ee64f239cf19aec69/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d79f9fdc9584ec83d1b3c75e9f4595c49017f5594fee1a2217117647225d738", size = 3183248, upload-time = "2025-08-11T15:52:39.865Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/29d216002d4593c2ce1c0ec2cec46dda77bfbcd221e24caa6e85eff53d89/sqlalchemy-2.0.43-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9df7126fd9db49e3a5a3999442cc67e9ee8971f3cb9644250107d7296cb2a164", size = 3219363, upload-time = "2025-08-11T15:56:39.11Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e4/bd78b01919c524f190b4905d47e7630bf4130b9f48fd971ae1c6225b6f6a/sqlalchemy-2.0.43-cp313-cp313-win32.whl", hash = "sha256:7f1ac7828857fcedb0361b48b9ac4821469f7694089d15550bbcf9ab22564a1d", size = 2096718, upload-time = "2025-08-11T15:55:05.349Z" }, + { url = "https://files.pythonhosted.org/packages/ac/a5/ca2f07a2a201f9497de1928f787926613db6307992fe5cda97624eb07c2f/sqlalchemy-2.0.43-cp313-cp313-win_amd64.whl", hash = "sha256:971ba928fcde01869361f504fcff3b7143b47d30de188b11c6357c0505824197", size = 2123200, upload-time = "2025-08-11T15:55:07.932Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d9/13bdde6521f322861fab67473cec4b1cc8999f3871953531cf61945fad92/sqlalchemy-2.0.43-py3-none-any.whl", hash = "sha256:1681c21dd2ccee222c2fe0bef671d1aef7c504087c9c4e800371cfcc8ac966fc", size = 1924759, upload-time = "2025-08-11T15:39:53.024Z" }, +] + +[[package]] +name = "starlette" +version = "0.47.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, +] + +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "uritemplate" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/60/f174043244c5306c9988380d2cb10009f91563fc4b31293d27e17201af56/uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e", size = 33267, upload-time = "2025-06-02T15:12:06.318Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/99/3ae339466c9183ea5b8ae87b34c0b897eda475d2aec2307cae60e5cd4f29/uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686", size = 11488, upload-time = "2025-06-02T15:12:03.405Z" }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.35.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" }, + { url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" }, + { url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" }, + { url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, +] + +[[package]] +name = "vine" +version = "5.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/e4/d07b5f29d283596b9727dd5275ccbceb63c44a1a82aa9e4bfd20426762ac/vine-5.1.0.tar.gz", hash = "sha256:8b62e981d35c41049211cf62a0a1242d8c1ee9bd15bb196ce38aefd6799e61e0", size = 48980, upload-time = "2023-11-05T08:46:53.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/b8/858957045a38a4079203a33aaa7d23ea9269ca7761c8a074af3524fbb240/watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179", size = 402339, upload-time = "2025-06-15T19:05:24.516Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/98b222cca751ba68e88521fabd79a4fab64005fc5976ea49b53fa205d1fa/watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5", size = 394409, upload-time = "2025-06-15T19:05:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/50/dee79968566c03190677c26f7f47960aff738d32087087bdf63a5473e7df/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297", size = 450939, upload-time = "2025-06-15T19:05:26.494Z" }, + { url = "https://files.pythonhosted.org/packages/40/45/a7b56fb129700f3cfe2594a01aa38d033b92a33dddce86c8dfdfc1247b72/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0", size = 457270, upload-time = "2025-06-15T19:05:27.466Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c8/fa5ef9476b1d02dc6b5e258f515fcaaecf559037edf8b6feffcbc097c4b8/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e", size = 483370, upload-time = "2025-06-15T19:05:28.548Z" }, + { url = "https://files.pythonhosted.org/packages/98/68/42cfcdd6533ec94f0a7aab83f759ec11280f70b11bfba0b0f885e298f9bd/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee", size = 598654, upload-time = "2025-06-15T19:05:29.997Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/b2a1544224118cc28df7e59008a929e711f9c68ce7d554e171b2dc531352/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd", size = 478667, upload-time = "2025-06-15T19:05:31.172Z" }, + { url = "https://files.pythonhosted.org/packages/8c/77/e3362fe308358dc9f8588102481e599c83e1b91c2ae843780a7ded939a35/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f", size = 452213, upload-time = "2025-06-15T19:05:32.299Z" }, + { url = "https://files.pythonhosted.org/packages/6e/17/c8f1a36540c9a1558d4faf08e909399e8133599fa359bf52ec8fcee5be6f/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4", size = 626718, upload-time = "2025-06-15T19:05:33.415Z" }, + { url = "https://files.pythonhosted.org/packages/26/45/fb599be38b4bd38032643783d7496a26a6f9ae05dea1a42e58229a20ac13/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f", size = 623098, upload-time = "2025-06-15T19:05:34.534Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/fdf40e038475498e160cd167333c946e45d8563ae4dd65caf757e9ffe6b4/watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd", size = 279209, upload-time = "2025-06-15T19:05:35.577Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d3/3ae9d5124ec75143bdf088d436cba39812122edc47709cd2caafeac3266f/watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47", size = 292786, upload-time = "2025-06-15T19:05:36.559Z" }, + { url = "https://files.pythonhosted.org/packages/26/2f/7dd4fc8b5f2b34b545e19629b4a018bfb1de23b3a496766a2c1165ca890d/watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6", size = 284343, upload-time = "2025-06-15T19:05:37.5Z" }, + { url = "https://files.pythonhosted.org/packages/d3/42/fae874df96595556a9089ade83be34a2e04f0f11eb53a8dbf8a8a5e562b4/watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30", size = 402004, upload-time = "2025-06-15T19:05:38.499Z" }, + { url = "https://files.pythonhosted.org/packages/fa/55/a77e533e59c3003d9803c09c44c3651224067cbe7fb5d574ddbaa31e11ca/watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a", size = 393671, upload-time = "2025-06-15T19:05:39.52Z" }, + { url = "https://files.pythonhosted.org/packages/05/68/b0afb3f79c8e832e6571022611adbdc36e35a44e14f129ba09709aa4bb7a/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc", size = 449772, upload-time = "2025-06-15T19:05:40.897Z" }, + { url = "https://files.pythonhosted.org/packages/ff/05/46dd1f6879bc40e1e74c6c39a1b9ab9e790bf1f5a2fe6c08b463d9a807f4/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b", size = 456789, upload-time = "2025-06-15T19:05:42.045Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ca/0eeb2c06227ca7f12e50a47a3679df0cd1ba487ea19cf844a905920f8e95/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895", size = 482551, upload-time = "2025-06-15T19:05:43.781Z" }, + { url = "https://files.pythonhosted.org/packages/31/47/2cecbd8694095647406645f822781008cc524320466ea393f55fe70eed3b/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a", size = 597420, upload-time = "2025-06-15T19:05:45.244Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7e/82abc4240e0806846548559d70f0b1a6dfdca75c1b4f9fa62b504ae9b083/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b", size = 477950, upload-time = "2025-06-15T19:05:46.332Z" }, + { url = "https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c", size = 451706, upload-time = "2025-06-15T19:05:47.459Z" }, + { url = "https://files.pythonhosted.org/packages/81/b5/5516cf46b033192d544102ea07c65b6f770f10ed1d0a6d388f5d3874f6e4/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b", size = 625814, upload-time = "2025-06-15T19:05:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/0c/dd/7c1331f902f30669ac3e754680b6edb9a0dd06dea5438e61128111fadd2c/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb", size = 622820, upload-time = "2025-06-15T19:05:50.088Z" }, + { url = "https://files.pythonhosted.org/packages/1b/14/36d7a8e27cd128d7b1009e7715a7c02f6c131be9d4ce1e5c3b73d0e342d8/watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9", size = 279194, upload-time = "2025-06-15T19:05:51.186Z" }, + { url = "https://files.pythonhosted.org/packages/25/41/2dd88054b849aa546dbeef5696019c58f8e0774f4d1c42123273304cdb2e/watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7", size = 292349, upload-time = "2025-06-15T19:05:52.201Z" }, + { url = "https://files.pythonhosted.org/packages/c8/cf/421d659de88285eb13941cf11a81f875c176f76a6d99342599be88e08d03/watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5", size = 283836, upload-time = "2025-06-15T19:05:53.265Z" }, + { url = "https://files.pythonhosted.org/packages/45/10/6faf6858d527e3599cc50ec9fcae73590fbddc1420bd4fdccfebffeedbc6/watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1", size = 400343, upload-time = "2025-06-15T19:05:54.252Z" }, + { url = "https://files.pythonhosted.org/packages/03/20/5cb7d3966f5e8c718006d0e97dfe379a82f16fecd3caa7810f634412047a/watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339", size = 392916, upload-time = "2025-06-15T19:05:55.264Z" }, + { url = "https://files.pythonhosted.org/packages/8c/07/d8f1176328fa9e9581b6f120b017e286d2a2d22ae3f554efd9515c8e1b49/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633", size = 449582, upload-time = "2025-06-15T19:05:56.317Z" }, + { url = "https://files.pythonhosted.org/packages/66/e8/80a14a453cf6038e81d072a86c05276692a1826471fef91df7537dba8b46/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011", size = 456752, upload-time = "2025-06-15T19:05:57.359Z" }, + { url = "https://files.pythonhosted.org/packages/5a/25/0853b3fe0e3c2f5af9ea60eb2e781eade939760239a72c2d38fc4cc335f6/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670", size = 481436, upload-time = "2025-06-15T19:05:58.447Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/4af0056c258b861fbb29dcb36258de1e2b857be4a9509e6298abcf31e5c9/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf", size = 596016, upload-time = "2025-06-15T19:05:59.59Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fa/95d604b58aa375e781daf350897aaaa089cff59d84147e9ccff2447c8294/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4", size = 476727, upload-time = "2025-06-15T19:06:01.086Z" }, + { url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864, upload-time = "2025-06-15T19:06:02.144Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626, upload-time = "2025-06-15T19:06:03.578Z" }, + { url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744, upload-time = "2025-06-15T19:06:05.066Z" }, + { url = "https://files.pythonhosted.org/packages/2c/00/70f75c47f05dea6fd30df90f047765f6fc2d6eb8b5a3921379b0b04defa2/watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297", size = 402114, upload-time = "2025-06-15T19:06:06.186Z" }, + { url = "https://files.pythonhosted.org/packages/53/03/acd69c48db4a1ed1de26b349d94077cca2238ff98fd64393f3e97484cae6/watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018", size = 393879, upload-time = "2025-06-15T19:06:07.369Z" }, + { url = "https://files.pythonhosted.org/packages/2f/c8/a9a2a6f9c8baa4eceae5887fecd421e1b7ce86802bcfc8b6a942e2add834/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0", size = 450026, upload-time = "2025-06-15T19:06:08.476Z" }, + { url = "https://files.pythonhosted.org/packages/fe/51/d572260d98388e6e2b967425c985e07d47ee6f62e6455cefb46a6e06eda5/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12", size = 457917, upload-time = "2025-06-15T19:06:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/c6/2d/4258e52917bf9f12909b6ec314ff9636276f3542f9d3807d143f27309104/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb", size = 483602, upload-time = "2025-06-15T19:06:11.088Z" }, + { url = "https://files.pythonhosted.org/packages/84/99/bee17a5f341a4345fe7b7972a475809af9e528deba056f8963d61ea49f75/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77", size = 596758, upload-time = "2025-06-15T19:06:12.197Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/e4bec1d59b25b89d2b0716b41b461ed655a9a53c60dc78ad5771fda5b3e6/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92", size = 477601, upload-time = "2025-06-15T19:06:13.391Z" }, + { url = "https://files.pythonhosted.org/packages/1f/fa/a514292956f4a9ce3c567ec0c13cce427c158e9f272062685a8a727d08fc/watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e", size = 451936, upload-time = "2025-06-15T19:06:14.656Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/c3bf927ec3bbeb4566984eba8dd7a8eb69569400f5509904545576741f88/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b", size = 626243, upload-time = "2025-06-15T19:06:16.232Z" }, + { url = "https://files.pythonhosted.org/packages/e6/65/6e12c042f1a68c556802a84d54bb06d35577c81e29fba14019562479159c/watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259", size = 623073, upload-time = "2025-06-15T19:06:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/7f79d9bf57329e7cbb0a6fd4c7bd7d0cee1e4a8ef0041459f5409da3506c/watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f", size = 400872, upload-time = "2025-06-15T19:06:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/df/d5/3f7bf9912798e9e6c516094db6b8932df53b223660c781ee37607030b6d3/watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e", size = 392877, upload-time = "2025-06-15T19:06:19.55Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c5/54ec7601a2798604e01c75294770dbee8150e81c6e471445d7601610b495/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa", size = 449645, upload-time = "2025-06-15T19:06:20.66Z" }, + { url = "https://files.pythonhosted.org/packages/0a/04/c2f44afc3b2fce21ca0b7802cbd37ed90a29874f96069ed30a36dfe57c2b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8", size = 457424, upload-time = "2025-06-15T19:06:21.712Z" }, + { url = "https://files.pythonhosted.org/packages/9f/b0/eec32cb6c14d248095261a04f290636da3df3119d4040ef91a4a50b29fa5/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f", size = 481584, upload-time = "2025-06-15T19:06:22.777Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/ca4bb71c68a937d7145aa25709e4f5d68eb7698a25ce266e84b55d591bbd/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e", size = 596675, upload-time = "2025-06-15T19:06:24.226Z" }, + { url = "https://files.pythonhosted.org/packages/a1/dd/b0e4b7fb5acf783816bc950180a6cd7c6c1d2cf7e9372c0ea634e722712b/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb", size = 477363, upload-time = "2025-06-15T19:06:25.42Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/088825b75489cb5b6a761a4542645718893d395d8c530b38734f19da44d2/watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147", size = 452240, upload-time = "2025-06-15T19:06:26.552Z" }, + { url = "https://files.pythonhosted.org/packages/10/8c/22b074814970eeef43b7c44df98c3e9667c1f7bf5b83e0ff0201b0bd43f9/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8", size = 625607, upload-time = "2025-06-15T19:06:27.606Z" }, + { url = "https://files.pythonhosted.org/packages/32/fa/a4f5c2046385492b2273213ef815bf71a0d4c1943b784fb904e184e30201/watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db", size = 623315, upload-time = "2025-06-15T19:06:29.076Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301, upload-time = "2024-01-06T02:10:57.829Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166, upload-time = "2024-01-06T02:10:55.763Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] diff --git a/workers/__init__.py b/workers/__init__.py new file mode 100644 index 0000000..e69de29