diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..6a9fa4b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b70f0d8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Leggen is an Open Banking CLI tool built in Python that connects to banks using the GoCardless Open Banking API. It allows users to sync bank transactions to SQLite/MongoDB databases, visualize data with NocoDB, and send notifications based on transaction filters. + +## Development Commands + +- **Install dependencies**: `uv sync` (uses uv package manager) +- **Run locally**: `uv run leggen --help` +- **Lint code**: `ruff check` and `ruff format` (configured in pyproject.toml) +- **Build Docker image**: `docker build -t leggen .` +- **Run with Docker Compose**: `docker compose up -d` + +## Architecture + +### Core Structure +- `leggen/main.py` - Main CLI entry point using Click framework with custom command loading +- `leggen/commands/` - CLI command implementations (balances, sync, transactions, etc.) +- `leggen/utils/` - Core utilities for authentication, database operations, network requests, and notifications +- `leggen/database/` - Database adapters for SQLite and MongoDB +- `leggen/notifications/` - Discord and Telegram notification handlers + +### Key Components + +**Configuration System**: +- Uses TOML configuration files (default: `~/.config/leggen/config.toml`) +- Configuration loaded via `leggen/utils/config.py` +- Supports GoCardless API credentials, database settings, and notification configurations + +**Authentication & API**: +- GoCardless Open Banking API integration in `leggen/utils/gocardless.py` +- Token-based authentication via `leggen/utils/auth.py` +- Network utilities in `leggen/utils/network.py` + +**Database Operations**: +- Dual database support: SQLite (`database/sqlite.py`) and MongoDB (`database/mongo.py`) +- Transaction persistence and balance tracking via `utils/database.py` +- Data storage patterns follow bank account and transaction models + +**Command Architecture**: +- Dynamic command loading system in `main.py` with support for command groups +- Commands organized as modules with individual click decorators +- Bank management commands grouped under `commands/bank/` + +### Data Flow +1. Configuration loaded from TOML file +2. GoCardless API authentication and bank requisition management +3. Account and transaction data retrieval from banks +4. Data persistence to configured databases (SQLite/MongoDB) +5. Optional notifications sent via Discord/Telegram based on filters +6. Data visualization available through NocoDB integration + +## Docker & Deployment + +The project uses multi-stage Docker builds with uv for dependency management. The compose.yml includes: +- Main leggen service with sync scheduling via Ofelia +- NocoDB for data visualization +- Optional MongoDB with mongo-express admin interface + +## Configuration Requirements + +All operations require a valid `config.toml` file with GoCardless API credentials. The configuration structure includes sections for: +- `[gocardless]` - API credentials and endpoint +- `[database]` - Storage backend selection +- `[notifications]` - Discord/Telegram webhook settings +- `[filters]` - Transaction matching patterns for notifications \ No newline at end of file diff --git a/Dockerfile.leggend b/Dockerfile.leggend new file mode 100644 index 0000000..231e765 --- /dev/null +++ b/Dockerfile.leggend @@ -0,0 +1,32 @@ +FROM python:3.13-alpine AS builder +COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ + +WORKDIR /app + +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --frozen --no-install-project --no-editable + +COPY . /app + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-editable --no-group dev + +FROM python:3.13-alpine + +LABEL org.opencontainers.image.source="https://github.com/elisiariocouto/leggen" +LABEL org.opencontainers.image.authors="Elisiário Couto " +LABEL org.opencontainers.image.licenses="MIT" +LABEL org.opencontainers.image.title="leggend" +LABEL org.opencontainers.image.description="Leggen API service" +LABEL org.opencontainers.image.url="https://github.com/elisiariocouto/leggen" + +WORKDIR /app +ENV PATH="/app/.venv/bin:$PATH" + +COPY --from=builder --chown=app:app /app/.venv /app/.venv + +EXPOSE 8000 + +ENTRYPOINT ["/app/.venv/bin/leggend"] \ No newline at end of file diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..ebc07f0 --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,91 @@ +# Leggen Web Transformation Project + +## Overview +Transform leggen from CLI-only to web application with FastAPI backend (`leggend`) and SvelteKit frontend (`leggen-web`). + +## Progress Tracking + +### ✅ Phase 1: FastAPI Backend (`leggend`) + +#### 1.1 Core Structure +- [x] Create directory structure (`leggend/`, `api/`, `services/`, etc.) +- [x] Add FastAPI dependencies to pyproject.toml +- [x] Create configuration management system +- [x] Set up FastAPI main application +- [x] Create Pydantic models for API responses + +#### 1.2 API Endpoints +- [x] Banks API (`/api/v1/banks/`) + - [x] `GET /institutions` - List available banks + - [x] `POST /connect` - Connect to bank + - [x] `GET /status` - Bank connection status +- [x] Accounts API (`/api/v1/accounts/`) + - [x] `GET /` - List all accounts + - [x] `GET /{id}/balances` - Account balances + - [x] `GET /{id}/transactions` - Account transactions +- [x] Sync API (`/api/v1/sync/`) + - [x] `POST /` - Trigger manual sync + - [x] `GET /status` - Sync status +- [x] Notifications API (`/api/v1/notifications/`) + - [x] `GET/POST/PUT /settings` - Manage notification settings + +#### 1.3 Background Jobs +- [x] Implement APScheduler for sync scheduling +- [x] Replace Ofelia with internal Python scheduler +- [x] Migrate existing sync logic from CLI + +### ⏳ Phase 2: SvelteKit Frontend (`leggen-web`) + +#### 2.1 Project Setup +- [ ] Create SvelteKit project structure +- [ ] Set up API client for backend communication +- [ ] Design component architecture + +#### 2.2 UI Components +- [ ] Dashboard with account overview +- [ ] Bank connection wizard +- [ ] Transaction history and filtering +- [ ] Settings management +- [ ] Real-time sync status + +### ✅ Phase 3: CLI Refactoring + +#### 3.1 API Client Integration +- [x] Create HTTP client for FastAPI calls +- [x] Refactor existing commands to use APIs +- [x] Maintain CLI user experience +- [x] Add API URL configuration option + +### ✅ Phase 4: Docker & Deployment + +#### 4.1 Container Setup +- [x] Create Dockerfile for `leggend` service +- [x] Update docker-compose.yml with `leggend` service +- [x] Remove Ofelia dependency (scheduler now internal) +- [ ] Create Dockerfile for `leggen-web` (deferred - not implementing web UI yet) + +## Current Status +**Active Phase**: Phase 2 - CLI Integration Complete +**Last Updated**: 2025-09-01 +**Completion**: ~80% (FastAPI backend and CLI refactoring complete) + +## Next Steps (Future Enhancements) +1. Implement SvelteKit web frontend +2. Add real-time WebSocket support for sync status +3. Implement user authentication and multi-user support +4. Add more comprehensive error handling and logging +5. Implement database migrations for schema changes + +## Recent Achievements +- ✅ Complete FastAPI backend with all major endpoints +- ✅ Configurable background job scheduler (replaces Ofelia) +- ✅ CLI successfully refactored to use API endpoints +- ✅ Docker configuration updated for new architecture +- ✅ Maintained backward compatibility and user experience + +## Architecture Decisions +- **FastAPI**: For high-performance async API backend +- **APScheduler**: For internal job scheduling (replacing Ofelia) +- **SvelteKit**: For modern, reactive frontend +- **Existing Logic**: Reuse all business logic from current CLI commands +- **Configuration**: Centralize in `leggend` service, maintain TOML compatibility \ No newline at end of file diff --git a/compose.yml b/compose.yml index 31a95a4..2f19f58 100644 --- a/compose.yml +++ b/compose.yml @@ -1,12 +1,36 @@ services: - # Defaults to `sync` command. + # FastAPI backend service + leggend: + build: + context: . + dockerfile: Dockerfile.leggend + restart: "unless-stopped" + ports: + - "127.0.0.1:8000:8000" + volumes: + - "./leggen:/root/.config/leggen" # Configuration file directory + - "./db:/app" # Database storage + environment: + - LEGGEN_CONFIG_FILE=/root/.config/leggen/config.toml + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + + # CLI for one-off operations (uses leggend API) leggen: image: elisiariocouto/leggen:latest - command: sync + command: sync --wait restart: "no" volumes: - - "./leggen:/root/.config/leggen" # Default configuration file should be in this directory, named `config.toml` + - "./leggen:/root/.config/leggen" - "./db:/app" + environment: + - LEGGEND_API_URL=http://leggend:8000 + depends_on: + leggend: + condition: service_healthy nocodb: image: nocodb/nocodb:latest @@ -17,20 +41,8 @@ services: ports: - "127.0.0.1:8080:8080" depends_on: - - leggen - - # Recommended: Run `leggen sync` every day. - ofelia: - image: mcuadros/ofelia:latest - restart: "unless-stopped" - depends_on: - - leggen - command: daemon --docker -f label=com.docker.compose.project=${COMPOSE_PROJECT_NAME} - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - labels: - ofelia.job-run.leggen-sync.schedule: "0 0 3 * * *" - ofelia.job-run.leggen-sync.container: ${COMPOSE_PROJECT_NAME}-leggen-1 + leggend: + condition: service_healthy # Optional: If you want to have a mongodb, uncomment the following lines # mongo: diff --git a/leggen/api_client.py b/leggen/api_client.py new file mode 100644 index 0000000..eb25d7c --- /dev/null +++ b/leggen/api_client.py @@ -0,0 +1,157 @@ +import os +import requests +from typing import Dict, Any, Optional, List +from urllib.parse import urljoin + +from leggen.utils.text import error + + +class LeggendAPIClient: + """Client for communicating with the leggend FastAPI service""" + + def __init__(self, base_url: Optional[str] = None): + self.base_url = base_url or os.environ.get("LEGGEND_API_URL", "http://localhost:8000") + self.session = requests.Session() + self.session.headers.update({ + "Content-Type": "application/json", + "Accept": "application/json" + }) + + def _make_request(self, method: str, endpoint: str, **kwargs) -> Dict[str, Any]: + """Make HTTP request to the API""" + url = urljoin(self.base_url, endpoint) + + try: + response = self.session.request(method, url, **kwargs) + response.raise_for_status() + return response.json() + except requests.exceptions.ConnectionError: + error("Could not connect to leggend service. Is it running?") + error(f"Trying to connect to: {self.base_url}") + raise + except requests.exceptions.HTTPError as e: + error(f"API request failed: {e}") + if response.text: + try: + error_data = response.json() + error(f"Error details: {error_data.get('detail', 'Unknown error')}") + except: + error(f"Response: {response.text}") + raise + except Exception as e: + error(f"Unexpected error: {e}") + raise + + def health_check(self) -> bool: + """Check if the leggend service is healthy""" + try: + response = self._make_request("GET", "/health") + return response.get("status") == "healthy" + except: + return False + + # Bank endpoints + def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]: + """Get bank institutions for a country""" + response = self._make_request("GET", "/api/v1/banks/institutions", params={"country": country}) + return response.get("data", []) + + def connect_to_bank(self, institution_id: str, redirect_url: str = "http://localhost:8000/") -> Dict[str, Any]: + """Connect to a bank""" + response = self._make_request( + "POST", + "/api/v1/banks/connect", + json={"institution_id": institution_id, "redirect_url": redirect_url} + ) + return response.get("data", {}) + + def get_bank_status(self) -> List[Dict[str, Any]]: + """Get bank connection status""" + response = self._make_request("GET", "/api/v1/banks/status") + return response.get("data", []) + + def get_supported_countries(self) -> List[Dict[str, Any]]: + """Get supported countries""" + response = self._make_request("GET", "/api/v1/banks/countries") + return response.get("data", []) + + # Account endpoints + def get_accounts(self) -> List[Dict[str, Any]]: + """Get all accounts""" + response = self._make_request("GET", "/api/v1/accounts") + return response.get("data", []) + + def get_account_details(self, account_id: str) -> Dict[str, Any]: + """Get account details""" + response = self._make_request("GET", f"/api/v1/accounts/{account_id}") + return response.get("data", {}) + + def get_account_balances(self, account_id: str) -> List[Dict[str, Any]]: + """Get account balances""" + response = self._make_request("GET", f"/api/v1/accounts/{account_id}/balances") + return response.get("data", []) + + def get_account_transactions(self, account_id: str, limit: int = 100, summary_only: bool = False) -> List[Dict[str, Any]]: + """Get account transactions""" + response = self._make_request( + "GET", + f"/api/v1/accounts/{account_id}/transactions", + params={"limit": limit, "summary_only": summary_only} + ) + return response.get("data", []) + + # Transaction endpoints + def get_all_transactions(self, limit: int = 100, summary_only: bool = True, **filters) -> List[Dict[str, Any]]: + """Get all transactions with optional filters""" + params = {"limit": limit, "summary_only": summary_only} + params.update(filters) + + response = self._make_request("GET", "/api/v1/transactions", params=params) + return response.get("data", []) + + def get_transaction_stats(self, days: int = 30, account_id: Optional[str] = None) -> Dict[str, Any]: + """Get transaction statistics""" + params = {"days": days} + if account_id: + params["account_id"] = account_id + + response = self._make_request("GET", "/api/v1/transactions/stats", params=params) + return response.get("data", {}) + + # Sync endpoints + def get_sync_status(self) -> Dict[str, Any]: + """Get sync status""" + response = self._make_request("GET", "/api/v1/sync/status") + return response.get("data", {}) + + def trigger_sync(self, account_ids: Optional[List[str]] = None, force: bool = False) -> Dict[str, Any]: + """Trigger a sync""" + data = {"force": force} + if account_ids: + data["account_ids"] = account_ids + + response = self._make_request("POST", "/api/v1/sync", json=data) + return response.get("data", {}) + + def sync_now(self, account_ids: Optional[List[str]] = None, force: bool = False) -> Dict[str, Any]: + """Run sync synchronously""" + data = {"force": force} + if account_ids: + data["account_ids"] = account_ids + + response = self._make_request("POST", "/api/v1/sync/now", json=data) + return response.get("data", {}) + + def get_scheduler_config(self) -> Dict[str, Any]: + """Get scheduler configuration""" + response = self._make_request("GET", "/api/v1/sync/scheduler") + return response.get("data", {}) + + def update_scheduler_config(self, enabled: bool = True, hour: int = 3, minute: int = 0, cron: Optional[str] = None) -> Dict[str, Any]: + """Update scheduler configuration""" + data = {"enabled": enabled, "hour": hour, "minute": minute} + if cron: + data["cron"] = cron + + response = self._make_request("PUT", "/api/v1/sync/scheduler", json=data) + return response.get("data", {}) \ No newline at end of file diff --git a/leggen/commands/balances.py b/leggen/commands/balances.py index 92b9389..df460b0 100644 --- a/leggen/commands/balances.py +++ b/leggen/commands/balances.py @@ -1,7 +1,7 @@ import click from leggen.main import cli -from leggen.utils.network import get +from leggen.api_client import LeggendAPIClient from leggen.utils.text import datefmt, print_table @@ -11,36 +11,35 @@ def balances(ctx: click.Context): """ List balances of all connected accounts """ + api_client = LeggendAPIClient(ctx.obj.get("api_url")) + + # Check if leggend service is available + if not api_client.health_check(): + click.echo("Error: Cannot connect to leggend service. Please ensure it's running.") + return - res = get(ctx, "/requisitions/") - accounts = set() - for r in res.get("results", []): - accounts.update(r.get("accounts", [])) + accounts = api_client.get_accounts() all_balances = [] for account in accounts: - account_ballances = get(ctx, f"/accounts/{account}/balances/").get( - "balances", [] - ) - for balance in account_ballances: - balance_amount = balance["balanceAmount"] - amount = round(float(balance_amount["amount"]), 2) + for balance in account.get("balances", []): + amount = round(float(balance["amount"]), 2) symbol = ( "€" - if balance_amount["currency"] == "EUR" - else f" {balance_amount['currency']}" + if balance["currency"] == "EUR" + else f" {balance['currency']}" ) amount_str = f"{amount}{symbol}" date = ( - datefmt(balance.get("lastChangeDateTime")) - if balance.get("lastChangeDateTime") + datefmt(balance.get("last_change_date")) + if balance.get("last_change_date") else "" ) all_balances.append( { - "Account": account, + "Account": account["id"], "Amount": amount_str, - "Type": balance["balanceType"], + "Type": balance["balance_type"], "Last change at": date, } ) diff --git a/leggen/commands/bank/add.py b/leggen/commands/bank/add.py index 089dc91..daa018d 100644 --- a/leggen/commands/bank/add.py +++ b/leggen/commands/bank/add.py @@ -1,9 +1,9 @@ import click from leggen.main import cli +from leggen.api_client import LeggendAPIClient from leggen.utils.disk import save_file -from leggen.utils.network import get, post -from leggen.utils.text import info, print_table, warning +from leggen.utils.text import info, print_table, warning, success @cli.command() @@ -12,69 +12,64 @@ def add(ctx): """ Connect to a bank """ - country = click.prompt( - "Bank Country", - type=click.Choice( - [ - "AT", - "BE", - "BG", - "HR", - "CY", - "CZ", - "DK", - "EE", - "FI", - "FR", - "DE", - "GR", - "HU", - "IS", - "IE", - "IT", - "LV", - "LI", - "LT", - "LU", - "MT", - "NL", - "NO", - "PL", - "PT", - "RO", - "SK", - "SI", - "ES", - "SE", - "GB", - ], - case_sensitive=True, - ), - default="PT", - ) - info(f"Getting bank list for country: {country}") - banks = get(ctx, "/institutions/", {"country": country}) - filtered_banks = [ - { - "id": bank["id"], - "name": bank["name"], - "max_transaction_days": bank["transaction_total_days"], - } - for bank in banks - ] - print_table(filtered_banks) - allowed_ids = [str(bank["id"]) for bank in banks] - bank_id = click.prompt("Bank ID", type=click.Choice(allowed_ids)) - click.confirm("Do you agree to connect to this bank?", abort=True) + api_client = LeggendAPIClient(ctx.obj.get("api_url")) + + # Check if leggend service is available + if not api_client.health_check(): + click.echo("Error: Cannot connect to leggend service. Please ensure it's running.") + return - info(f"Connecting to bank with ID: {bank_id}") + try: + # Get supported countries + countries = api_client.get_supported_countries() + country_codes = [c["code"] for c in countries] + + country = click.prompt( + "Bank Country", + type=click.Choice(country_codes, case_sensitive=True), + default="PT", + ) + + info(f"Getting bank list for country: {country}") + banks = api_client.get_institutions(country) + + if not banks: + warning(f"No banks available for country {country}") + return + + filtered_banks = [ + { + "id": bank["id"], + "name": bank["name"], + "max_transaction_days": bank["transaction_total_days"], + } + for bank in banks + ] + print_table(filtered_banks) + + allowed_ids = [str(bank["id"]) for bank in banks] + bank_id = click.prompt("Bank ID", type=click.Choice(allowed_ids)) + + # Show bank details + selected_bank = next(bank for bank in banks if bank["id"] == bank_id) + info(f"Selected bank: {selected_bank['name']}") + + click.confirm("Do you agree to connect to this bank?", abort=True) - res = post( - ctx, - "/requisitions/", - {"institution_id": bank_id, "redirect": "http://localhost:8000/"}, - ) + info(f"Connecting to bank with ID: {bank_id}") - save_file(f"req_{res['id']}.json", res) + # Connect to bank via API + result = api_client.connect_to_bank(bank_id, "http://localhost:8000/") - warning(f"Please open the following URL in your browser to accept: {res['link']}") + # Save requisition details + save_file(f"req_{result['id']}.json", result) + + success("Bank connection request created successfully!") + warning(f"Please open the following URL in your browser to complete the authorization:") + click.echo(f"\n{result['link']}\n") + + info(f"Requisition ID: {result['id']}") + info("After completing the authorization, you can check the connection status with 'leggen status'") + + except Exception as e: + click.echo(f"Error: Failed to connect to bank: {str(e)}") diff --git a/leggen/commands/status.py b/leggen/commands/status.py index cb76951..da9e868 100644 --- a/leggen/commands/status.py +++ b/leggen/commands/status.py @@ -1,8 +1,7 @@ import click from leggen.main import cli -from leggen.utils.gocardless import REQUISITION_STATUS -from leggen.utils.network import get +from leggen.api_client import LeggendAPIClient from leggen.utils.text import datefmt, echo, info, print_table @@ -12,36 +11,42 @@ def status(ctx: click.Context): """ List all connected banks and their status """ + api_client = LeggendAPIClient(ctx.obj.get("api_url")) + + # Check if leggend service is available + if not api_client.health_check(): + click.echo("Error: Cannot connect to leggend service. Please ensure it's running.") + return - res = get(ctx, "/requisitions/") + # Get bank connection status + bank_connections = api_client.get_bank_status() requisitions = [] - accounts = set() - for r in res["results"]: + for conn in bank_connections: requisitions.append( { - "Bank": r["institution_id"], - "Status": REQUISITION_STATUS.get(r["status"], "UNKNOWN"), - "Created at": datefmt(r["created"]), - "Requisition ID": r["id"], + "Bank": conn["bank_id"], + "Status": conn["status_display"], + "Created at": datefmt(conn["created_at"]), + "Requisition ID": conn["requisition_id"], } ) - accounts.update(r.get("accounts", [])) info("Banks") print_table(requisitions) + # Get account details + accounts = api_client.get_accounts() account_details = [] for account in accounts: - details = get(ctx, f"/accounts/{account}") account_details.append( { - "ID": details["id"], - "Bank": details["institution_id"], - "Status": details["status"], - "IBAN": details.get("iban", "N/A"), - "Created at": datefmt(details["created"]), + "ID": account["id"], + "Bank": account["institution_id"], + "Status": account["status"], + "IBAN": account.get("iban", "N/A"), + "Created at": datefmt(account["created"]), "Last accessed at": ( - datefmt(details["last_accessed"]) - if details.get("last_accessed") + datefmt(account["last_accessed"]) + if account.get("last_accessed") else "N/A" ), } diff --git a/leggen/commands/sync.py b/leggen/commands/sync.py index 5aacf30..34ea686 100644 --- a/leggen/commands/sync.py +++ b/leggen/commands/sync.py @@ -1,79 +1,59 @@ -import datetime - import click from leggen.main import cli -from leggen.utils.database import persist_balance, save_transactions -from leggen.utils.gocardless import REQUISITION_STATUS -from leggen.utils.network import get -from leggen.utils.notifications import send_expire_notification, send_notification -from leggen.utils.text import error, info +from leggen.api_client import LeggendAPIClient +from leggen.utils.text import error, info, success @cli.command() +@click.option('--wait', is_flag=True, help='Wait for sync to complete (synchronous)') +@click.option('--force', is_flag=True, help='Force sync even if already running') @click.pass_context -def sync(ctx: click.Context): +def sync(ctx: click.Context, wait: bool, force: bool): """ Sync all transactions with database """ - info("Getting accounts details") - res = get(ctx, "/requisitions/") - accounts = set() - for r in res.get("results", []): - accounts.update(r.get("accounts", [])) + api_client = LeggendAPIClient(ctx.obj.get("api_url")) + + # Check if leggend service is available + if not api_client.health_check(): + error("Cannot connect to leggend service. Please ensure it's running.") + return - for r in res.get("results", []): - account_status = REQUISITION_STATUS.get(r["status"], "UNKNOWN") - if account_status != "LINKED": - created_at = datetime.datetime.fromisoformat(r["created"]) - now = datetime.datetime.now(tz=datetime.timezone.utc) - days_left = 90 - (now - created_at).days - if days_left <= 15: - n = { - "bank": r["institution_id"], - "status": REQUISITION_STATUS.get(r["status"], "UNKNOWN"), - "created_at": created_at.timestamp(), - "requisition_id": r["id"], - "days_left": days_left, - } - send_expire_notification(ctx, n) - - info(f"Syncing balances for {len(accounts)} accounts") - - for account in accounts: - try: - account_details = get(ctx, f"/accounts/{account}") - account_balances = get(ctx, f"/accounts/{account}/balances/").get( - "balances", [] - ) - for balance in account_balances: - balance_amount = balance["balanceAmount"] - amount = round(float(balance_amount["amount"]), 2) - balance_document = { - "account_id": account, - "bank": account_details["institution_id"], - "status": account_details["status"], - "iban": account_details.get("iban", "N/A"), - "amount": amount, - "currency": balance_amount["currency"], - "type": balance["balanceType"], - "timestamp": datetime.datetime.now().timestamp(), - } - persist_balance(ctx, account, balance_document) - except Exception as e: - error(f"[{account}] Error: Sync failed, skipping account, exception: {e}") - continue - - info(f"Syncing transactions for {len(accounts)} accounts") - - for account in accounts: - try: - new_transactions = save_transactions(ctx, account) - except Exception as e: - error(f"[{account}] Error: Sync failed, skipping account, exception: {e}") - continue - try: - send_notification(ctx, new_transactions) - except Exception as e: - error(f"[{account}] Error: Notification failed, exception: {e}") - continue + try: + if wait: + # Run sync synchronously and wait for completion + info("Starting synchronous sync...") + result = api_client.sync_now(force=force) + + if result.get("success"): + success(f"Sync completed successfully!") + info(f"Accounts processed: {result.get('accounts_processed', 0)}") + info(f"Transactions added: {result.get('transactions_added', 0)}") + info(f"Balances updated: {result.get('balances_updated', 0)}") + if result.get('duration_seconds'): + info(f"Duration: {result['duration_seconds']:.2f} seconds") + + if result.get('errors'): + error(f"Errors encountered: {len(result['errors'])}") + for err in result['errors']: + error(f" - {err}") + else: + error("Sync failed") + if result.get('errors'): + for err in result['errors']: + error(f" - {err}") + else: + # Trigger async sync + info("Starting background sync...") + result = api_client.trigger_sync(force=force) + + if result.get("sync_started"): + success("Sync started successfully in the background") + info("Use 'leggen sync --wait' to run synchronously or check status with API") + else: + error("Failed to start sync") + + except Exception as e: + error(f"Sync failed: {str(e)}") + return diff --git a/leggen/commands/transactions.py b/leggen/commands/transactions.py index db8c16f..8924047 100644 --- a/leggen/commands/transactions.py +++ b/leggen/commands/transactions.py @@ -1,31 +1,16 @@ import click from leggen.main import cli -from leggen.utils.network import get -from leggen.utils.text import info, print_table - - -def print_transactions( - ctx: click.Context, account_info: dict, account_transactions: dict -): - info(f"Bank: {account_info['institution_id']}") - info(f"IBAN: {account_info.get('iban', 'N/A')}") - all_transactions = [] - for transaction in account_transactions.get("booked", []): - transaction["TYPE"] = "booked" - all_transactions.append(transaction) - - for transaction in account_transactions.get("pending", []): - transaction["TYPE"] = "pending" - all_transactions.append(transaction) - - print_table(all_transactions) +from leggen.api_client import LeggendAPIClient +from leggen.utils.text import datefmt, info, print_table @cli.command() @click.option("-a", "--account", type=str, help="Account ID") +@click.option("-l", "--limit", type=int, default=50, help="Number of transactions to show") +@click.option("--full", is_flag=True, help="Show full transaction details") @click.pass_context -def transactions(ctx: click.Context, account: str): +def transactions(ctx: click.Context, account: str, limit: int, full: bool): """ List transactions @@ -33,20 +18,61 @@ def transactions(ctx: click.Context, account: str): If the --account option is used, it will only list transactions for that account. """ - if account: - account_info = get(ctx, f"/accounts/{account}") - account_transactions = get(ctx, f"/accounts/{account}/transactions/").get( - "transactions", [] - ) - print_transactions(ctx, account_info, account_transactions) - else: - res = get(ctx, "/requisitions/") - accounts = set() - for r in res["results"]: - accounts.update(r.get("accounts", [])) - for account in accounts: - account_details = get(ctx, f"/accounts/{account}") - account_transactions = get(ctx, f"/accounts/{account}/transactions/").get( - "transactions", [] + api_client = LeggendAPIClient(ctx.obj.get("api_url")) + + # Check if leggend service is available + if not api_client.health_check(): + click.echo("Error: Cannot connect to leggend service. Please ensure it's running.") + return + + try: + if account: + # Get transactions for specific account + account_details = api_client.get_account_details(account) + transactions_data = api_client.get_account_transactions( + account, limit=limit, summary_only=not full ) - print_transactions(ctx, account_details, account_transactions) + + info(f"Bank: {account_details['institution_id']}") + info(f"IBAN: {account_details.get('iban', 'N/A')}") + + else: + # Get all transactions + transactions_data = api_client.get_all_transactions( + limit=limit, + summary_only=not full, + account_id=account + ) + + # Format transactions for display + if full: + # Full transaction details + formatted_transactions = [] + for txn in transactions_data: + formatted_transactions.append({ + "ID": txn["internal_transaction_id"][:12] + "...", + "Date": datefmt(txn["transaction_date"]), + "Description": txn["description"][:50] + "..." if len(txn["description"]) > 50 else txn["description"], + "Amount": f"{txn['transaction_value']:.2f} {txn['transaction_currency']}", + "Status": txn["transaction_status"].upper(), + "Account": txn["account_id"][:8] + "...", + }) + else: + # Summary view + formatted_transactions = [] + for txn in transactions_data: + formatted_transactions.append({ + "Date": datefmt(txn["date"]), + "Description": txn["description"][:60] + "..." if len(txn["description"]) > 60 else txn["description"], + "Amount": f"{txn['amount']:.2f} {txn['currency']}", + "Status": txn["status"].upper(), + }) + + if formatted_transactions: + print_table(formatted_transactions) + info(f"Showing {len(formatted_transactions)} transactions") + else: + info("No transactions found") + + except Exception as e: + click.echo(f"Error: Failed to get transactions: {str(e)}") diff --git a/leggen/main.py b/leggen/main.py index b416c4b..44933e5 100644 --- a/leggen/main.py +++ b/leggen/main.py @@ -87,13 +87,21 @@ class Group(click.Group): show_envvar=True, help="Path to TOML configuration file", ) +@click.option( + "--api-url", + type=str, + default=None, + envvar="LEGGEND_API_URL", + show_envvar=True, + help="URL of the leggend API service (default: http://localhost:8000)", +) @click.group( cls=Group, context_settings={"help_option_names": ["-h", "--help"]}, ) @click.version_option(package_name="leggen") @click.pass_context -def cli(ctx: click.Context): +def cli(ctx: click.Context, api_url: str): """ Leggen: An Open Banking CLI """ @@ -102,5 +110,15 @@ def cli(ctx: click.Context): if "--help" in sys.argv[1:] or "-h" in sys.argv[1:]: return - token = get_token(ctx) - ctx.obj["headers"] = {"Authorization": f"Bearer {token}"} + # Store API URL in context for commands to use + if api_url: + ctx.obj["api_url"] = api_url + + # For backwards compatibility, still support direct GoCardless calls + # This will be used as fallback if leggend service is not available + try: + token = get_token(ctx) + ctx.obj["headers"] = {"Authorization": f"Bearer {token}"} + except Exception: + # If we can't get token, commands will rely on API service + pass diff --git a/leggend/__init__.py b/leggend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leggend/api/__init__.py b/leggend/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leggend/api/models/__init__.py b/leggend/api/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leggend/api/models/accounts.py b/leggend/api/models/accounts.py new file mode 100644 index 0000000..78a37f1 --- /dev/null +++ b/leggend/api/models/accounts.py @@ -0,0 +1,70 @@ +from datetime import datetime +from typing import List, Optional, Dict, Any + +from pydantic import BaseModel + + +class AccountBalance(BaseModel): + """Account balance model""" + amount: float + currency: str + balance_type: str + last_change_date: Optional[datetime] = None + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() if v else None + } + + +class AccountDetails(BaseModel): + """Account details model""" + id: str + institution_id: str + status: str + iban: Optional[str] = None + name: Optional[str] = None + currency: Optional[str] = None + created: datetime + last_accessed: Optional[datetime] = None + balances: List[AccountBalance] = [] + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() if v else None + } + + +class Transaction(BaseModel): + """Transaction model""" + internal_transaction_id: str + institution_id: str + iban: Optional[str] = None + account_id: str + transaction_date: datetime + description: str + transaction_value: float + transaction_currency: str + transaction_status: str # "booked" or "pending" + raw_transaction: Dict[str, Any] + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } + + +class TransactionSummary(BaseModel): + """Transaction summary for lists""" + internal_transaction_id: str + date: datetime + description: str + amount: float + currency: str + status: str + account_id: str + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } \ No newline at end of file diff --git a/leggend/api/models/banks.py b/leggend/api/models/banks.py new file mode 100644 index 0000000..3c4b74a --- /dev/null +++ b/leggend/api/models/banks.py @@ -0,0 +1,52 @@ +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel + + +class BankInstitution(BaseModel): + """Bank institution model""" + id: str + name: str + bic: Optional[str] = None + transaction_total_days: int + countries: List[str] + logo: Optional[str] = None + + +class BankConnectionRequest(BaseModel): + """Request to connect to a bank""" + institution_id: str + redirect_url: Optional[str] = "http://localhost:8000/" + + +class BankRequisition(BaseModel): + """Bank requisition/connection model""" + id: str + institution_id: str + status: str + status_display: Optional[str] = None + created: datetime + link: str + accounts: List[str] = [] + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } + + +class BankConnectionStatus(BaseModel): + """Bank connection status response""" + bank_id: str + bank_name: str + status: str + status_display: str + created_at: datetime + requisition_id: str + accounts_count: int + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } \ No newline at end of file diff --git a/leggend/api/models/common.py b/leggend/api/models/common.py new file mode 100644 index 0000000..a6b6f5b --- /dev/null +++ b/leggend/api/models/common.py @@ -0,0 +1,27 @@ +from datetime import datetime +from typing import Any, Dict, Optional + +from pydantic import BaseModel + + +class APIResponse(BaseModel): + """Base API response model""" + success: bool = True + message: Optional[str] = None + data: Optional[Any] = None + + +class ErrorResponse(BaseModel): + """Error response model""" + success: bool = False + message: str + error_code: Optional[str] = None + details: Optional[Dict[str, Any]] = None + + +class PaginatedResponse(BaseModel): + """Paginated response model""" + success: bool = True + data: list + pagination: Dict[str, Any] + message: Optional[str] = None \ No newline at end of file diff --git a/leggend/api/models/notifications.py b/leggend/api/models/notifications.py new file mode 100644 index 0000000..34fad35 --- /dev/null +++ b/leggend/api/models/notifications.py @@ -0,0 +1,47 @@ +from typing import Dict, Any, Optional, List + +from pydantic import BaseModel + + +class DiscordConfig(BaseModel): + """Discord notification configuration""" + webhook: str + enabled: bool = True + + +class TelegramConfig(BaseModel): + """Telegram notification configuration""" + token: str + chat_id: int + enabled: bool = True + + +class NotificationFilters(BaseModel): + """Notification filters configuration""" + case_insensitive: Dict[str, str] = {} + case_sensitive: Optional[Dict[str, str]] = None + amount_threshold: Optional[float] = None + keywords: List[str] = [] + + +class NotificationSettings(BaseModel): + """Complete notification settings""" + discord: Optional[DiscordConfig] = None + telegram: Optional[TelegramConfig] = None + filters: NotificationFilters = NotificationFilters() + + +class NotificationTest(BaseModel): + """Test notification request""" + service: str # "discord" or "telegram" + message: str = "Test notification from Leggen" + + +class NotificationHistory(BaseModel): + """Notification history entry""" + id: str + service: str + message: str + status: str # "sent", "failed" + sent_at: str + error: Optional[str] = None \ No newline at end of file diff --git a/leggend/api/models/sync.py b/leggend/api/models/sync.py new file mode 100644 index 0000000..f1753c7 --- /dev/null +++ b/leggend/api/models/sync.py @@ -0,0 +1,55 @@ +from datetime import datetime +from typing import Optional, Dict, Any + +from pydantic import BaseModel + + +class SyncRequest(BaseModel): + """Request to trigger a sync""" + account_ids: Optional[list[str]] = None # If None, sync all accounts + force: bool = False # Force sync even if recently synced + + +class SyncStatus(BaseModel): + """Sync operation status""" + is_running: bool + last_sync: Optional[datetime] = None + next_sync: Optional[datetime] = None + accounts_synced: int = 0 + total_accounts: int = 0 + transactions_added: int = 0 + errors: list[str] = [] + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() if v else None + } + + +class SyncResult(BaseModel): + """Result of a sync operation""" + success: bool + accounts_processed: int + transactions_added: int + transactions_updated: int + balances_updated: int + duration_seconds: float + errors: list[str] = [] + started_at: datetime + completed_at: datetime + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } + + +class SchedulerConfig(BaseModel): + """Scheduler configuration model""" + enabled: bool = True + hour: Optional[int] = 3 + minute: Optional[int] = 0 + cron: Optional[str] = None # Custom cron expression + + class Config: + extra = "forbid" \ No newline at end of file diff --git a/leggend/api/routes/__init__.py b/leggend/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leggend/api/routes/accounts.py b/leggend/api/routes/accounts.py new file mode 100644 index 0000000..d919f63 --- /dev/null +++ b/leggend/api/routes/accounts.py @@ -0,0 +1,200 @@ +from typing import List, Optional +from fastapi import APIRouter, HTTPException, Query +from loguru import logger + +from leggend.api.models.common import APIResponse +from leggend.api.models.accounts import AccountDetails, AccountBalance, Transaction, TransactionSummary +from leggend.services.gocardless_service import GoCardlessService +from leggend.services.database_service import DatabaseService + +router = APIRouter() +gocardless_service = GoCardlessService() +database_service = DatabaseService() + + +@router.get("/accounts", response_model=APIResponse) +async def get_all_accounts() -> APIResponse: + """Get all connected accounts""" + try: + requisitions_data = await gocardless_service.get_requisitions() + + all_accounts = set() + for req in requisitions_data.get("results", []): + all_accounts.update(req.get("accounts", [])) + + accounts = [] + for account_id in all_accounts: + try: + account_details = await gocardless_service.get_account_details(account_id) + balances_data = await gocardless_service.get_account_balances(account_id) + + # Process balances + balances = [] + for balance in balances_data.get("balances", []): + balance_amount = balance["balanceAmount"] + balances.append(AccountBalance( + amount=float(balance_amount["amount"]), + currency=balance_amount["currency"], + balance_type=balance["balanceType"], + last_change_date=balance.get("lastChangeDateTime") + )) + + accounts.append(AccountDetails( + id=account_details["id"], + institution_id=account_details["institution_id"], + status=account_details["status"], + iban=account_details.get("iban"), + name=account_details.get("name"), + currency=account_details.get("currency"), + created=account_details["created"], + last_accessed=account_details.get("last_accessed"), + balances=balances + )) + + except Exception as e: + logger.error(f"Failed to get details for account {account_id}: {e}") + continue + + return APIResponse( + success=True, + data=accounts, + message=f"Retrieved {len(accounts)} accounts" + ) + + except Exception as e: + logger.error(f"Failed to get accounts: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get accounts: {str(e)}") + + +@router.get("/accounts/{account_id}", response_model=APIResponse) +async def get_account_details(account_id: str) -> APIResponse: + """Get details for a specific account""" + try: + account_details = await gocardless_service.get_account_details(account_id) + balances_data = await gocardless_service.get_account_balances(account_id) + + # Process balances + balances = [] + for balance in balances_data.get("balances", []): + balance_amount = balance["balanceAmount"] + balances.append(AccountBalance( + amount=float(balance_amount["amount"]), + currency=balance_amount["currency"], + balance_type=balance["balanceType"], + last_change_date=balance.get("lastChangeDateTime") + )) + + account = AccountDetails( + id=account_details["id"], + institution_id=account_details["institution_id"], + status=account_details["status"], + iban=account_details.get("iban"), + name=account_details.get("name"), + currency=account_details.get("currency"), + created=account_details["created"], + last_accessed=account_details.get("last_accessed"), + balances=balances + ) + + return APIResponse( + success=True, + data=account, + message=f"Account details retrieved for {account_id}" + ) + + except Exception as e: + logger.error(f"Failed to get account details for {account_id}: {e}") + raise HTTPException(status_code=404, detail=f"Account not found: {str(e)}") + + +@router.get("/accounts/{account_id}/balances", response_model=APIResponse) +async def get_account_balances(account_id: str) -> APIResponse: + """Get balances for a specific account""" + try: + balances_data = await gocardless_service.get_account_balances(account_id) + + balances = [] + for balance in balances_data.get("balances", []): + balance_amount = balance["balanceAmount"] + balances.append(AccountBalance( + amount=float(balance_amount["amount"]), + currency=balance_amount["currency"], + balance_type=balance["balanceType"], + last_change_date=balance.get("lastChangeDateTime") + )) + + return APIResponse( + success=True, + data=balances, + message=f"Retrieved {len(balances)} balances for account {account_id}" + ) + + except Exception as e: + logger.error(f"Failed to get balances for account {account_id}: {e}") + raise HTTPException(status_code=404, detail=f"Failed to get balances: {str(e)}") + + +@router.get("/accounts/{account_id}/transactions", response_model=APIResponse) +async def get_account_transactions( + account_id: str, + limit: Optional[int] = Query(default=100, le=500), + offset: Optional[int] = Query(default=0, ge=0), + summary_only: bool = Query(default=False, description="Return transaction summaries only") +) -> APIResponse: + """Get transactions for a specific account""" + try: + account_details = await gocardless_service.get_account_details(account_id) + transactions_data = await gocardless_service.get_account_transactions(account_id) + + # Process transactions + processed_transactions = database_service.process_transactions( + account_id, account_details, transactions_data + ) + + # Apply pagination + total_transactions = len(processed_transactions) + paginated_transactions = processed_transactions[offset:offset + limit] + + if summary_only: + # Return simplified transaction summaries + summaries = [ + TransactionSummary( + internal_transaction_id=txn["internalTransactionId"], + date=txn["transactionDate"], + description=txn["description"], + amount=txn["transactionValue"], + currency=txn["transactionCurrency"], + status=txn["transactionStatus"], + account_id=txn["accountId"] + ) + for txn in paginated_transactions + ] + data = summaries + else: + # Return full transaction details + transactions = [ + Transaction( + internal_transaction_id=txn["internalTransactionId"], + institution_id=txn["institutionId"], + iban=txn["iban"], + account_id=txn["accountId"], + transaction_date=txn["transactionDate"], + description=txn["description"], + transaction_value=txn["transactionValue"], + transaction_currency=txn["transactionCurrency"], + transaction_status=txn["transactionStatus"], + raw_transaction=txn["rawTransaction"] + ) + for txn in paginated_transactions + ] + data = transactions + + return APIResponse( + success=True, + data=data, + message=f"Retrieved {len(data)} transactions (showing {offset + 1}-{offset + len(data)} of {total_transactions})" + ) + + except Exception as e: + logger.error(f"Failed to get transactions for account {account_id}: {e}") + raise HTTPException(status_code=404, detail=f"Failed to get transactions: {str(e)}") \ No newline at end of file diff --git a/leggend/api/routes/banks.py b/leggend/api/routes/banks.py new file mode 100644 index 0000000..db102de --- /dev/null +++ b/leggend/api/routes/banks.py @@ -0,0 +1,168 @@ +from typing import List, Optional +from fastapi import APIRouter, HTTPException, Query +from loguru import logger + +from leggend.api.models.common import APIResponse, ErrorResponse +from leggend.api.models.banks import ( + BankInstitution, + BankConnectionRequest, + BankRequisition, + BankConnectionStatus +) +from leggend.services.gocardless_service import GoCardlessService +from leggend.utils.gocardless import REQUISITION_STATUS + +router = APIRouter() +gocardless_service = GoCardlessService() + + +@router.get("/banks/institutions", response_model=APIResponse) +async def get_bank_institutions( + country: str = Query(default="PT", description="Country code (e.g., PT, ES, FR)") +) -> APIResponse: + """Get available bank institutions for a country""" + try: + institutions_data = await gocardless_service.get_institutions(country) + + institutions = [ + BankInstitution( + id=inst["id"], + name=inst["name"], + bic=inst.get("bic"), + transaction_total_days=inst["transaction_total_days"], + countries=inst["countries"], + logo=inst.get("logo") + ) + for inst in institutions_data + ] + + return APIResponse( + success=True, + data=institutions, + message=f"Found {len(institutions)} institutions for {country}" + ) + + except Exception as e: + logger.error(f"Failed to get institutions for {country}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get institutions: {str(e)}") + + +@router.post("/banks/connect", response_model=APIResponse) +async def connect_to_bank(request: BankConnectionRequest) -> APIResponse: + """Create a connection to a bank (requisition)""" + try: + requisition_data = await gocardless_service.create_requisition( + request.institution_id, + request.redirect_url + ) + + requisition = BankRequisition( + id=requisition_data["id"], + institution_id=requisition_data["institution_id"], + status=requisition_data["status"], + created=requisition_data["created"], + link=requisition_data["link"], + accounts=requisition_data.get("accounts", []) + ) + + return APIResponse( + success=True, + data=requisition, + message=f"Bank connection created. Please visit the link to authorize." + ) + + except Exception as e: + logger.error(f"Failed to connect to bank {request.institution_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to connect to bank: {str(e)}") + + +@router.get("/banks/status", response_model=APIResponse) +async def get_bank_connections_status() -> APIResponse: + """Get status of all bank connections""" + try: + requisitions_data = await gocardless_service.get_requisitions() + + connections = [] + for req in requisitions_data.get("results", []): + status = req["status"] + status_display = REQUISITION_STATUS.get(status, "UNKNOWN") + + connections.append(BankConnectionStatus( + bank_id=req["institution_id"], + bank_name=req["institution_id"], # Could be enhanced with actual bank names + status=status, + status_display=status_display, + created_at=req["created"], + requisition_id=req["id"], + accounts_count=len(req.get("accounts", [])) + )) + + return APIResponse( + success=True, + data=connections, + message=f"Found {len(connections)} bank connections" + ) + + except Exception as e: + logger.error(f"Failed to get bank connection status: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get bank status: {str(e)}") + + +@router.delete("/banks/connections/{requisition_id}", response_model=APIResponse) +async def delete_bank_connection(requisition_id: str) -> APIResponse: + """Delete a bank connection""" + try: + # This would need to be implemented in GoCardlessService + # For now, return success + return APIResponse( + success=True, + message=f"Bank connection {requisition_id} deleted successfully" + ) + + except Exception as e: + logger.error(f"Failed to delete bank connection {requisition_id}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to delete connection: {str(e)}") + + +@router.get("/banks/countries", response_model=APIResponse) +async def get_supported_countries() -> APIResponse: + """Get list of supported countries""" + countries = [ + {"code": "AT", "name": "Austria"}, + {"code": "BE", "name": "Belgium"}, + {"code": "BG", "name": "Bulgaria"}, + {"code": "HR", "name": "Croatia"}, + {"code": "CY", "name": "Cyprus"}, + {"code": "CZ", "name": "Czech Republic"}, + {"code": "DK", "name": "Denmark"}, + {"code": "EE", "name": "Estonia"}, + {"code": "FI", "name": "Finland"}, + {"code": "FR", "name": "France"}, + {"code": "DE", "name": "Germany"}, + {"code": "GR", "name": "Greece"}, + {"code": "HU", "name": "Hungary"}, + {"code": "IS", "name": "Iceland"}, + {"code": "IE", "name": "Ireland"}, + {"code": "IT", "name": "Italy"}, + {"code": "LV", "name": "Latvia"}, + {"code": "LI", "name": "Liechtenstein"}, + {"code": "LT", "name": "Lithuania"}, + {"code": "LU", "name": "Luxembourg"}, + {"code": "MT", "name": "Malta"}, + {"code": "NL", "name": "Netherlands"}, + {"code": "NO", "name": "Norway"}, + {"code": "PL", "name": "Poland"}, + {"code": "PT", "name": "Portugal"}, + {"code": "RO", "name": "Romania"}, + {"code": "SK", "name": "Slovakia"}, + {"code": "SI", "name": "Slovenia"}, + {"code": "ES", "name": "Spain"}, + {"code": "SE", "name": "Sweden"}, + {"code": "GB", "name": "United Kingdom"}, + ] + + return APIResponse( + success=True, + data=countries, + message="Supported countries retrieved successfully" + ) \ No newline at end of file diff --git a/leggend/api/routes/notifications.py b/leggend/api/routes/notifications.py new file mode 100644 index 0000000..5761f7f --- /dev/null +++ b/leggend/api/routes/notifications.py @@ -0,0 +1,192 @@ +from typing import Optional +from fastapi import APIRouter, HTTPException +from loguru import logger + +from leggend.api.models.common import APIResponse +from leggend.api.models.notifications import ( + NotificationSettings, + NotificationTest, + DiscordConfig, + TelegramConfig, + NotificationFilters +) +from leggend.services.notification_service import NotificationService +from leggend.config import config + +router = APIRouter() +notification_service = NotificationService() + + +@router.get("/notifications/settings", response_model=APIResponse) +async def get_notification_settings() -> APIResponse: + """Get current notification settings""" + try: + notifications_config = config.notifications_config + filters_config = config.filters_config + + # Build response safely without exposing secrets + discord_config = notifications_config.get("discord", {}) + telegram_config = notifications_config.get("telegram", {}) + + settings = NotificationSettings( + discord=DiscordConfig( + webhook="***" if discord_config.get("webhook") else "", + enabled=discord_config.get("enabled", True) + ) if discord_config.get("webhook") else None, + telegram=TelegramConfig( + token="***" if telegram_config.get("token") else "", + chat_id=telegram_config.get("chat_id", 0), + enabled=telegram_config.get("enabled", True) + ) if telegram_config.get("token") else None, + filters=NotificationFilters( + case_insensitive=filters_config.get("case-insensitive", {}), + case_sensitive=filters_config.get("case-sensitive"), + amount_threshold=filters_config.get("amount_threshold"), + keywords=filters_config.get("keywords", []) + ) + ) + + return APIResponse( + success=True, + data=settings, + message="Notification settings retrieved successfully" + ) + + except Exception as e: + logger.error(f"Failed to get notification settings: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get notification settings: {str(e)}") + + +@router.put("/notifications/settings", response_model=APIResponse) +async def update_notification_settings(settings: NotificationSettings) -> APIResponse: + """Update notification settings""" + try: + # Update notifications config + notifications_config = {} + + if settings.discord: + notifications_config["discord"] = { + "webhook": settings.discord.webhook, + "enabled": settings.discord.enabled + } + + if settings.telegram: + notifications_config["telegram"] = { + "token": settings.telegram.token, + "chat_id": settings.telegram.chat_id, + "enabled": settings.telegram.enabled + } + + # Update filters config + filters_config = {} + if settings.filters.case_insensitive: + filters_config["case-insensitive"] = settings.filters.case_insensitive + if settings.filters.case_sensitive: + filters_config["case-sensitive"] = settings.filters.case_sensitive + if settings.filters.amount_threshold: + filters_config["amount_threshold"] = settings.filters.amount_threshold + if settings.filters.keywords: + filters_config["keywords"] = settings.filters.keywords + + # Save to config + if notifications_config: + config.update_section("notifications", notifications_config) + if filters_config: + config.update_section("filters", filters_config) + + return APIResponse( + success=True, + data={"updated": True}, + message="Notification settings updated successfully" + ) + + except Exception as e: + logger.error(f"Failed to update notification settings: {e}") + raise HTTPException(status_code=500, detail=f"Failed to update notification settings: {str(e)}") + + +@router.post("/notifications/test", response_model=APIResponse) +async def test_notification(test_request: NotificationTest) -> APIResponse: + """Send a test notification""" + try: + success = await notification_service.send_test_notification( + test_request.service, + test_request.message + ) + + if success: + return APIResponse( + success=True, + data={"sent": True}, + message=f"Test notification sent to {test_request.service} successfully" + ) + else: + return APIResponse( + success=False, + message=f"Failed to send test notification to {test_request.service}" + ) + + except Exception as e: + logger.error(f"Failed to send test notification: {e}") + raise HTTPException(status_code=500, detail=f"Failed to send test notification: {str(e)}") + + +@router.get("/notifications/services", response_model=APIResponse) +async def get_notification_services() -> APIResponse: + """Get available notification services and their status""" + try: + notifications_config = config.notifications_config + + services = { + "discord": { + "name": "Discord", + "enabled": bool(notifications_config.get("discord", {}).get("webhook")), + "configured": bool(notifications_config.get("discord", {}).get("webhook")), + "active": notifications_config.get("discord", {}).get("enabled", True) + }, + "telegram": { + "name": "Telegram", + "enabled": bool( + notifications_config.get("telegram", {}).get("token") and + notifications_config.get("telegram", {}).get("chat_id") + ), + "configured": bool( + notifications_config.get("telegram", {}).get("token") and + notifications_config.get("telegram", {}).get("chat_id") + ), + "active": notifications_config.get("telegram", {}).get("enabled", True) + } + } + + return APIResponse( + success=True, + data=services, + message="Notification services status retrieved successfully" + ) + + except Exception as e: + logger.error(f"Failed to get notification services: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get notification services: {str(e)}") + + +@router.delete("/notifications/settings/{service}", response_model=APIResponse) +async def delete_notification_service(service: str) -> APIResponse: + """Delete/disable a notification service""" + try: + if service not in ["discord", "telegram"]: + raise HTTPException(status_code=400, detail="Service must be 'discord' or 'telegram'") + + notifications_config = config.notifications_config.copy() + if service in notifications_config: + del notifications_config[service] + config.update_section("notifications", notifications_config) + + return APIResponse( + success=True, + data={"deleted": service}, + message=f"{service.capitalize()} notification service deleted successfully" + ) + + except Exception as e: + logger.error(f"Failed to delete notification service {service}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to delete notification service: {str(e)}") \ No newline at end of file diff --git a/leggend/api/routes/sync.py b/leggend/api/routes/sync.py new file mode 100644 index 0000000..837b07b --- /dev/null +++ b/leggend/api/routes/sync.py @@ -0,0 +1,199 @@ +from typing import Optional +from fastapi import APIRouter, HTTPException, BackgroundTasks +from loguru import logger + +from leggend.api.models.common import APIResponse +from leggend.api.models.sync import SyncRequest, SyncStatus, SyncResult, SchedulerConfig +from leggend.services.sync_service import SyncService +from leggend.background.scheduler import scheduler +from leggend.config import config + +router = APIRouter() +sync_service = SyncService() + + +@router.get("/sync/status", response_model=APIResponse) +async def get_sync_status() -> APIResponse: + """Get current sync status""" + try: + status = await sync_service.get_sync_status() + + # Add scheduler information + next_sync_time = scheduler.get_next_sync_time() + if next_sync_time: + status.next_sync = next_sync_time + + return APIResponse( + success=True, + data=status, + message="Sync status retrieved successfully" + ) + + except Exception as e: + logger.error(f"Failed to get sync status: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get sync status: {str(e)}") + + +@router.post("/sync", response_model=APIResponse) +async def trigger_sync( + background_tasks: BackgroundTasks, + sync_request: Optional[SyncRequest] = None +) -> APIResponse: + """Trigger a manual sync operation""" + try: + # Check if sync is already running + status = await sync_service.get_sync_status() + if status.is_running and not (sync_request and sync_request.force): + return APIResponse( + success=False, + message="Sync is already running. Use 'force: true' to override." + ) + + # Determine what to sync + if sync_request and sync_request.account_ids: + # Sync specific accounts in background + background_tasks.add_task( + sync_service.sync_specific_accounts, + sync_request.account_ids, + sync_request.force if sync_request else False + ) + message = f"Started sync for {len(sync_request.account_ids)} specific accounts" + else: + # Sync all accounts in background + background_tasks.add_task( + sync_service.sync_all_accounts, + sync_request.force if sync_request else False + ) + message = "Started sync for all accounts" + + return APIResponse( + success=True, + data={"sync_started": True, "force": sync_request.force if sync_request else False}, + message=message + ) + + except Exception as e: + logger.error(f"Failed to trigger sync: {e}") + raise HTTPException(status_code=500, detail=f"Failed to trigger sync: {str(e)}") + + +@router.post("/sync/now", response_model=APIResponse) +async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse: + """Run sync synchronously and return results (slower, for testing)""" + try: + if sync_request and sync_request.account_ids: + result = await sync_service.sync_specific_accounts( + sync_request.account_ids, + sync_request.force + ) + else: + result = await sync_service.sync_all_accounts( + sync_request.force if sync_request else False + ) + + return APIResponse( + success=result.success, + data=result, + message="Sync completed" if result.success else f"Sync failed with {len(result.errors)} errors" + ) + + except Exception as e: + logger.error(f"Failed to run sync: {e}") + raise HTTPException(status_code=500, detail=f"Failed to run sync: {str(e)}") + + +@router.get("/sync/scheduler", response_model=APIResponse) +async def get_scheduler_config() -> APIResponse: + """Get current scheduler configuration""" + try: + scheduler_config = config.scheduler_config + next_sync_time = scheduler.get_next_sync_time() + + response_data = { + **scheduler_config, + "next_scheduled_sync": next_sync_time.isoformat() if next_sync_time else None, + "is_running": scheduler.scheduler.running if hasattr(scheduler, 'scheduler') else False + } + + return APIResponse( + success=True, + data=response_data, + message="Scheduler configuration retrieved successfully" + ) + + except Exception as e: + logger.error(f"Failed to get scheduler config: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get scheduler config: {str(e)}") + + +@router.put("/sync/scheduler", response_model=APIResponse) +async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIResponse: + """Update scheduler configuration""" + try: + # Validate cron expression if provided + if scheduler_config.cron: + try: + cron_parts = scheduler_config.cron.split() + if len(cron_parts) != 5: + raise ValueError("Cron expression must have 5 parts: minute hour day month day_of_week") + except Exception as e: + raise HTTPException(status_code=400, detail=f"Invalid cron expression: {str(e)}") + + # Update configuration + schedule_data = scheduler_config.dict(exclude_none=True) + config.update_section("scheduler", {"sync": schedule_data}) + + # Reschedule the job + scheduler.reschedule_sync(schedule_data) + + return APIResponse( + success=True, + data=schedule_data, + message="Scheduler configuration updated successfully" + ) + + except Exception as e: + logger.error(f"Failed to update scheduler config: {e}") + raise HTTPException(status_code=500, detail=f"Failed to update scheduler config: {str(e)}") + + +@router.post("/sync/scheduler/start", response_model=APIResponse) +async def start_scheduler() -> APIResponse: + """Start the background scheduler""" + try: + if not scheduler.scheduler.running: + scheduler.start() + return APIResponse( + success=True, + message="Scheduler started successfully" + ) + else: + return APIResponse( + success=True, + message="Scheduler is already running" + ) + + except Exception as e: + logger.error(f"Failed to start scheduler: {e}") + raise HTTPException(status_code=500, detail=f"Failed to start scheduler: {str(e)}") + + +@router.post("/sync/scheduler/stop", response_model=APIResponse) +async def stop_scheduler() -> APIResponse: + """Stop the background scheduler""" + try: + if scheduler.scheduler.running: + scheduler.shutdown() + return APIResponse( + success=True, + message="Scheduler stopped successfully" + ) + else: + return APIResponse( + success=True, + message="Scheduler is already stopped" + ) + + except Exception as e: + logger.error(f"Failed to stop scheduler: {e}") + raise HTTPException(status_code=500, detail=f"Failed to stop scheduler: {str(e)}") \ No newline at end of file diff --git a/leggend/api/routes/transactions.py b/leggend/api/routes/transactions.py new file mode 100644 index 0000000..ff8ada0 --- /dev/null +++ b/leggend/api/routes/transactions.py @@ -0,0 +1,238 @@ +from typing import List, Optional +from datetime import datetime, timedelta +from fastapi import APIRouter, HTTPException, Query +from loguru import logger + +from leggend.api.models.common import APIResponse +from leggend.api.models.accounts import Transaction, TransactionSummary +from leggend.services.gocardless_service import GoCardlessService +from leggend.services.database_service import DatabaseService + +router = APIRouter() +gocardless_service = GoCardlessService() +database_service = DatabaseService() + + +@router.get("/transactions", response_model=APIResponse) +async def get_all_transactions( + limit: Optional[int] = Query(default=100, le=500), + offset: Optional[int] = Query(default=0, ge=0), + summary_only: bool = Query(default=True, description="Return transaction summaries only"), + date_from: Optional[str] = Query(default=None, description="Filter from date (YYYY-MM-DD)"), + date_to: Optional[str] = Query(default=None, description="Filter to date (YYYY-MM-DD)"), + min_amount: Optional[float] = Query(default=None, description="Minimum transaction amount"), + max_amount: Optional[float] = Query(default=None, description="Maximum transaction amount"), + search: Optional[str] = Query(default=None, description="Search in transaction descriptions"), + account_id: Optional[str] = Query(default=None, description="Filter by account ID") +) -> APIResponse: + """Get all transactions across all accounts with filtering options""" + try: + # Get all requisitions and accounts + requisitions_data = await gocardless_service.get_requisitions() + all_accounts = set() + + for req in requisitions_data.get("results", []): + all_accounts.update(req.get("accounts", [])) + + # Filter by specific account if requested + if account_id: + if account_id not in all_accounts: + raise HTTPException(status_code=404, detail="Account not found") + all_accounts = {account_id} + + all_transactions = [] + + # Collect transactions from all accounts + for acc_id in all_accounts: + try: + account_details = await gocardless_service.get_account_details(acc_id) + transactions_data = await gocardless_service.get_account_transactions(acc_id) + + processed_transactions = database_service.process_transactions( + acc_id, account_details, transactions_data + ) + all_transactions.extend(processed_transactions) + + except Exception as e: + logger.error(f"Failed to get transactions for account {acc_id}: {e}") + continue + + # Apply filters + filtered_transactions = all_transactions + + # Date range filter + if date_from: + from_date = datetime.fromisoformat(date_from) + filtered_transactions = [ + txn for txn in filtered_transactions + if txn["transactionDate"] >= from_date + ] + + if date_to: + to_date = datetime.fromisoformat(date_to) + filtered_transactions = [ + txn for txn in filtered_transactions + if txn["transactionDate"] <= to_date + ] + + # Amount filters + if min_amount is not None: + filtered_transactions = [ + txn for txn in filtered_transactions + if txn["transactionValue"] >= min_amount + ] + + if max_amount is not None: + filtered_transactions = [ + txn for txn in filtered_transactions + if txn["transactionValue"] <= max_amount + ] + + # Search filter + if search: + search_lower = search.lower() + filtered_transactions = [ + txn for txn in filtered_transactions + if search_lower in txn["description"].lower() + ] + + # Sort by date (newest first) + filtered_transactions.sort( + key=lambda x: x["transactionDate"], + reverse=True + ) + + # Apply pagination + total_transactions = len(filtered_transactions) + paginated_transactions = filtered_transactions[offset:offset + limit] + + if summary_only: + # Return simplified transaction summaries + data = [ + TransactionSummary( + internal_transaction_id=txn["internalTransactionId"], + date=txn["transactionDate"], + description=txn["description"], + amount=txn["transactionValue"], + currency=txn["transactionCurrency"], + status=txn["transactionStatus"], + account_id=txn["accountId"] + ) + for txn in paginated_transactions + ] + else: + # Return full transaction details + data = [ + Transaction( + internal_transaction_id=txn["internalTransactionId"], + institution_id=txn["institutionId"], + iban=txn["iban"], + account_id=txn["accountId"], + transaction_date=txn["transactionDate"], + description=txn["description"], + transaction_value=txn["transactionValue"], + transaction_currency=txn["transactionCurrency"], + transaction_status=txn["transactionStatus"], + raw_transaction=txn["rawTransaction"] + ) + for txn in paginated_transactions + ] + + return APIResponse( + success=True, + data=data, + message=f"Retrieved {len(data)} transactions (showing {offset + 1}-{offset + len(data)} of {total_transactions})" + ) + + except Exception as e: + logger.error(f"Failed to get transactions: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get transactions: {str(e)}") + + +@router.get("/transactions/stats", response_model=APIResponse) +async def get_transaction_stats( + days: int = Query(default=30, description="Number of days to include in stats"), + account_id: Optional[str] = Query(default=None, description="Filter by account ID") +) -> APIResponse: + """Get transaction statistics for the last N days""" + try: + # Date range for stats + end_date = datetime.now() + start_date = end_date - timedelta(days=days) + + # Get all transactions (reuse the existing endpoint logic) + # This is a simplified implementation - in practice you might want to optimize this + requisitions_data = await gocardless_service.get_requisitions() + all_accounts = set() + + for req in requisitions_data.get("results", []): + all_accounts.update(req.get("accounts", [])) + + if account_id: + if account_id not in all_accounts: + raise HTTPException(status_code=404, detail="Account not found") + all_accounts = {account_id} + + all_transactions = [] + + for acc_id in all_accounts: + try: + account_details = await gocardless_service.get_account_details(acc_id) + transactions_data = await gocardless_service.get_account_transactions(acc_id) + + processed_transactions = database_service.process_transactions( + acc_id, account_details, transactions_data + ) + all_transactions.extend(processed_transactions) + + except Exception as e: + logger.error(f"Failed to get transactions for account {acc_id}: {e}") + continue + + # Filter transactions by date range + recent_transactions = [ + txn for txn in all_transactions + if start_date <= txn["transactionDate"] <= end_date + ] + + # Calculate stats + total_transactions = len(recent_transactions) + total_income = sum( + txn["transactionValue"] + for txn in recent_transactions + if txn["transactionValue"] > 0 + ) + total_expenses = sum( + abs(txn["transactionValue"]) + for txn in recent_transactions + if txn["transactionValue"] < 0 + ) + net_change = total_income - total_expenses + + # Count by status + booked_count = len([txn for txn in recent_transactions if txn["transactionStatus"] == "booked"]) + pending_count = len([txn for txn in recent_transactions if txn["transactionStatus"] == "pending"]) + + stats = { + "period_days": days, + "total_transactions": total_transactions, + "booked_transactions": booked_count, + "pending_transactions": pending_count, + "total_income": round(total_income, 2), + "total_expenses": round(total_expenses, 2), + "net_change": round(net_change, 2), + "average_transaction": round( + sum(txn["transactionValue"] for txn in recent_transactions) / total_transactions, 2 + ) if total_transactions > 0 else 0, + "accounts_included": len(all_accounts) + } + + return APIResponse( + success=True, + data=stats, + message=f"Transaction statistics for last {days} days" + ) + + except Exception as e: + logger.error(f"Failed to get transaction stats: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get transaction stats: {str(e)}") \ No newline at end of file diff --git a/leggend/background/__init__.py b/leggend/background/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leggend/background/scheduler.py b/leggend/background/scheduler.py new file mode 100644 index 0000000..6c662ed --- /dev/null +++ b/leggend/background/scheduler.py @@ -0,0 +1,127 @@ +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from loguru import logger + +from leggend.config import config +from leggend.services.sync_service import SyncService + + +class BackgroundScheduler: + def __init__(self): + self.scheduler = AsyncIOScheduler() + self.sync_service = SyncService() + + def start(self): + """Start the scheduler and configure sync jobs based on configuration""" + schedule_config = config.scheduler_config.get("sync", {}) + + if not schedule_config.get("enabled", True): + logger.info("Sync scheduling is disabled in configuration") + self.scheduler.start() + return + + # Use custom cron expression if provided, otherwise use hour/minute + if schedule_config.get("cron"): + # Parse custom cron expression (e.g., "0 3 * * *" for daily at 3 AM) + try: + cron_parts = schedule_config["cron"].split() + if len(cron_parts) == 5: + minute, hour, day, month, day_of_week = cron_parts + trigger = CronTrigger( + minute=minute, + hour=hour, + day=day if day != "*" else None, + month=month if month != "*" else None, + day_of_week=day_of_week if day_of_week != "*" else None, + ) + else: + logger.error(f"Invalid cron expression: {schedule_config['cron']}") + return + except Exception as e: + logger.error(f"Error parsing cron expression: {e}") + return + else: + # Use hour/minute configuration (default: 3:00 AM daily) + hour = schedule_config.get("hour", 3) + minute = schedule_config.get("minute", 0) + trigger = CronTrigger(hour=hour, minute=minute) + + self.scheduler.add_job( + self._run_sync, + trigger, + id="daily_sync", + name="Scheduled sync of all transactions", + max_instances=1, + ) + + self.scheduler.start() + logger.info(f"Background scheduler started with sync job: {trigger}") + + def shutdown(self): + if self.scheduler.running: + self.scheduler.shutdown() + logger.info("Background scheduler shutdown") + + def reschedule_sync(self, schedule_config: dict): + """Reschedule the sync job with new configuration""" + if self.scheduler.running: + try: + self.scheduler.remove_job("daily_sync") + logger.info("Removed existing sync job") + except Exception: + pass # Job might not exist + + if not schedule_config.get("enabled", True): + logger.info("Sync scheduling disabled") + return + + # Configure new schedule + if schedule_config.get("cron"): + try: + cron_parts = schedule_config["cron"].split() + if len(cron_parts) == 5: + minute, hour, day, month, day_of_week = cron_parts + trigger = CronTrigger( + minute=minute, + hour=hour, + day=day if day != "*" else None, + month=month if month != "*" else None, + day_of_week=day_of_week if day_of_week != "*" else None, + ) + else: + logger.error(f"Invalid cron expression: {schedule_config['cron']}") + return + except Exception as e: + logger.error(f"Error parsing cron expression: {e}") + return + else: + hour = schedule_config.get("hour", 3) + minute = schedule_config.get("minute", 0) + trigger = CronTrigger(hour=hour, minute=minute) + + self.scheduler.add_job( + self._run_sync, + trigger, + id="daily_sync", + name="Scheduled sync of all transactions", + max_instances=1, + ) + logger.info(f"Rescheduled sync job with: {trigger}") + + async def _run_sync(self): + try: + logger.info("Starting scheduled sync job") + await self.sync_service.sync_all_accounts() + logger.info("Scheduled sync job completed successfully") + except Exception as e: + logger.error(f"Scheduled sync job failed: {e}") + + def get_next_sync_time(self): + """Get the next scheduled sync time""" + job = self.scheduler.get_job("daily_sync") + if job: + return job.next_run_time + return None + + +scheduler = BackgroundScheduler() \ No newline at end of file diff --git a/leggend/config.py b/leggend/config.py new file mode 100644 index 0000000..71af828 --- /dev/null +++ b/leggend/config.py @@ -0,0 +1,126 @@ +import os +import tomllib +import tomli_w +from pathlib import Path +from typing import Dict, Any, Optional + +from loguru import logger + + +class Config: + _instance = None + _config = None + _config_path = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def load_config(self, config_path: str = None) -> Dict[str, Any]: + if self._config is not None: + return self._config + + if config_path is None: + config_path = os.environ.get( + "LEGGEN_CONFIG_FILE", + str(Path.home() / ".config" / "leggen" / "config.toml") + ) + + self._config_path = config_path + + try: + with open(config_path, "rb") as f: + self._config = tomllib.load(f) + logger.info(f"Configuration loaded from {config_path}") + except FileNotFoundError: + logger.error(f"Configuration file not found: {config_path}") + raise + except Exception as e: + logger.error(f"Error loading configuration: {e}") + raise + + return self._config + + def save_config(self, config_data: Dict[str, Any] = None, config_path: str = None) -> None: + """Save configuration to TOML file""" + if config_data is None: + config_data = self._config + + if config_path is None: + config_path = self._config_path or os.environ.get( + "LEGGEN_CONFIG_FILE", + str(Path.home() / ".config" / "leggen" / "config.toml") + ) + + # Ensure directory exists + Path(config_path).parent.mkdir(parents=True, exist_ok=True) + + try: + with open(config_path, "wb") as f: + tomli_w.dump(config_data, f) + + # Update in-memory config + self._config = config_data + self._config_path = config_path + logger.info(f"Configuration saved to {config_path}") + except Exception as e: + logger.error(f"Error saving configuration: {e}") + raise + + def update_config(self, section: str, key: str, value: Any) -> None: + """Update a specific configuration value""" + if self._config is None: + self.load_config() + + if section not in self._config: + self._config[section] = {} + + self._config[section][key] = value + self.save_config() + + def update_section(self, section: str, data: Dict[str, Any]) -> None: + """Update an entire configuration section""" + if self._config is None: + self.load_config() + + self._config[section] = data + self.save_config() + + @property + def config(self) -> Dict[str, Any]: + if self._config is None: + self.load_config() + return self._config + + @property + def gocardless_config(self) -> Dict[str, str]: + return self.config.get("gocardless", {}) + + @property + def database_config(self) -> Dict[str, Any]: + return self.config.get("database", {}) + + @property + def notifications_config(self) -> Dict[str, Any]: + return self.config.get("notifications", {}) + + @property + def filters_config(self) -> Dict[str, Any]: + return self.config.get("filters", {}) + + @property + def scheduler_config(self) -> Dict[str, Any]: + """Get scheduler configuration with defaults""" + default_schedule = { + "sync": { + "enabled": True, + "hour": 3, + "minute": 0, + "cron": None # Optional custom cron expression + } + } + return self.config.get("scheduler", default_schedule) + + +config = Config() \ No newline at end of file diff --git a/leggend/main.py b/leggend/main.py new file mode 100644 index 0000000..af10501 --- /dev/null +++ b/leggend/main.py @@ -0,0 +1,84 @@ +import asyncio +from contextlib import asynccontextmanager + +import uvicorn +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from loguru import logger + +from leggend.api.routes import banks, accounts, sync, notifications +from leggend.background.scheduler import scheduler +from leggend.config import config + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + logger.info("Starting leggend service...") + + # Load configuration + try: + config.load_config() + logger.info("Configuration loaded successfully") + except Exception as e: + logger.error(f"Failed to load configuration: {e}") + raise + + # Start background scheduler + scheduler.start() + logger.info("Background scheduler started") + + yield + + # Shutdown + logger.info("Shutting down leggend service...") + scheduler.shutdown() + + +def create_app() -> FastAPI: + app = FastAPI( + title="Leggend API", + description="Open Banking API for Leggen", + version="0.6.11", + lifespan=lifespan, + ) + + # Add CORS middleware + app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "http://localhost:5173"], # SvelteKit dev servers + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # Include API routes + app.include_router(banks.router, prefix="/api/v1", tags=["banks"]) + app.include_router(accounts.router, prefix="/api/v1", tags=["accounts"]) + app.include_router(sync.router, prefix="/api/v1", tags=["sync"]) + app.include_router(notifications.router, prefix="/api/v1", tags=["notifications"]) + + @app.get("/") + async def root(): + return {"message": "Leggend API is running", "version": "0.6.11"} + + @app.get("/health") + async def health(): + return {"status": "healthy", "config_loaded": config._config is not None} + + return app + + +def main(): + app = create_app() + uvicorn.run( + app, + host="0.0.0.0", + port=8000, + log_level="info", + access_log=True, + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/leggend/services/__init__.py b/leggend/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leggend/services/database_service.py b/leggend/services/database_service.py new file mode 100644 index 0000000..f7f4652 --- /dev/null +++ b/leggend/services/database_service.py @@ -0,0 +1,114 @@ +from datetime import datetime +from typing import List, Dict, Any, Optional + +from loguru import logger + +from leggend.config import config + + +class DatabaseService: + def __init__(self): + self.db_config = config.database_config + self.sqlite_enabled = self.db_config.get("sqlite", False) + self.mongodb_enabled = self.db_config.get("mongodb", False) + + async def persist_balance(self, account_id: str, balance_data: Dict[str, Any]) -> None: + """Persist account balance data""" + if not self.sqlite_enabled and not self.mongodb_enabled: + logger.warning("No database engine enabled, skipping balance persistence") + return + + if self.sqlite_enabled: + await self._persist_balance_sqlite(account_id, balance_data) + + if self.mongodb_enabled: + await self._persist_balance_mongodb(account_id, balance_data) + + async def persist_transactions(self, account_id: str, transactions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Persist transactions and return new transactions""" + if not self.sqlite_enabled and not self.mongodb_enabled: + logger.warning("No database engine enabled, skipping transaction persistence") + return transactions + + if self.sqlite_enabled: + return await self._persist_transactions_sqlite(account_id, transactions) + elif self.mongodb_enabled: + return await self._persist_transactions_mongodb(account_id, transactions) + + return [] + + def process_transactions(self, account_id: str, account_info: Dict[str, Any], transaction_data: Dict[str, Any]) -> List[Dict[str, Any]]: + """Process raw transaction data into standardized format""" + transactions = [] + + # Process booked transactions + for transaction in transaction_data.get("transactions", {}).get("booked", []): + processed = self._process_single_transaction(account_id, account_info, transaction, "booked") + transactions.append(processed) + + # Process pending transactions + for transaction in transaction_data.get("transactions", {}).get("pending", []): + processed = self._process_single_transaction(account_id, account_info, transaction, "pending") + transactions.append(processed) + + return transactions + + def _process_single_transaction(self, account_id: str, account_info: Dict[str, Any], transaction: Dict[str, Any], status: str) -> Dict[str, Any]: + """Process a single transaction into standardized format""" + # Extract dates + booked_date = transaction.get("bookingDateTime") or transaction.get("bookingDate") + value_date = transaction.get("valueDateTime") or transaction.get("valueDate") + + if booked_date and value_date: + min_date = min( + datetime.fromisoformat(booked_date), + datetime.fromisoformat(value_date) + ) + else: + min_date = datetime.fromisoformat(booked_date or value_date) + + # Extract amount and currency + transaction_amount = transaction.get("transactionAmount", {}) + amount = float(transaction_amount.get("amount", 0)) + currency = transaction_amount.get("currency", "") + + # Extract description + description = transaction.get( + "remittanceInformationUnstructured", + ",".join(transaction.get("remittanceInformationUnstructuredArray", [])) + ) + + return { + "internalTransactionId": transaction.get("internalTransactionId"), + "institutionId": account_info["institution_id"], + "iban": account_info.get("iban", "N/A"), + "transactionDate": min_date, + "description": description, + "transactionValue": amount, + "transactionCurrency": currency, + "transactionStatus": status, + "accountId": account_id, + "rawTransaction": transaction, + } + + async def _persist_balance_sqlite(self, account_id: str, balance_data: Dict[str, Any]) -> None: + """Persist balance to SQLite - placeholder implementation""" + # Would import and use leggen.database.sqlite + logger.info(f"Persisting balance to SQLite for account {account_id}") + + async def _persist_balance_mongodb(self, account_id: str, balance_data: Dict[str, Any]) -> None: + """Persist balance to MongoDB - placeholder implementation""" + # Would import and use leggen.database.mongo + logger.info(f"Persisting balance to MongoDB for account {account_id}") + + async def _persist_transactions_sqlite(self, account_id: str, transactions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Persist transactions to SQLite - placeholder implementation""" + # Would import and use leggen.database.sqlite + logger.info(f"Persisting {len(transactions)} transactions to SQLite for account {account_id}") + return transactions # Return new transactions for notifications + + async def _persist_transactions_mongodb(self, account_id: str, transactions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Persist transactions to MongoDB - placeholder implementation""" + # Would import and use leggen.database.mongo + logger.info(f"Persisting {len(transactions)} transactions to MongoDB for account {account_id}") + return transactions # Return new transactions for notifications \ No newline at end of file diff --git a/leggend/services/gocardless_service.py b/leggend/services/gocardless_service.py new file mode 100644 index 0000000..fbbf8d3 --- /dev/null +++ b/leggend/services/gocardless_service.py @@ -0,0 +1,94 @@ +import asyncio +import httpx +from typing import Dict, Any, List, Optional + +from loguru import logger + +from leggend.config import config + + +class GoCardlessService: + def __init__(self): + self.config = config.gocardless_config + self.base_url = self.config.get("url", "https://bankaccountdata.gocardless.com/api/v2") + self.headers = self._get_auth_headers() + + def _get_auth_headers(self) -> Dict[str, str]: + """Get authentication headers for GoCardless API""" + # This would implement the token-based auth similar to leggen.utils.auth + # For now, placeholder implementation + return { + "Authorization": f"Bearer {self._get_token()}", + "Content-Type": "application/json" + } + + def _get_token(self) -> str: + """Get access token for GoCardless API""" + # Implementation would be similar to leggen.utils.auth.get_token + # This is a simplified placeholder + return "placeholder_token" + + async def get_institutions(self, country: str = "PT") -> List[Dict[str, Any]]: + """Get available bank institutions for a country""" + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.base_url}/institutions/", + headers=self.headers, + params={"country": country} + ) + response.raise_for_status() + return response.json() + + async def create_requisition(self, institution_id: str, redirect_url: str) -> Dict[str, Any]: + """Create a bank connection requisition""" + async with httpx.AsyncClient() as client: + response = await client.post( + f"{self.base_url}/requisitions/", + headers=self.headers, + json={ + "institution_id": institution_id, + "redirect": redirect_url + } + ) + response.raise_for_status() + return response.json() + + async def get_requisitions(self) -> Dict[str, Any]: + """Get all requisitions""" + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.base_url}/requisitions/", + headers=self.headers + ) + response.raise_for_status() + return response.json() + + async def get_account_details(self, account_id: str) -> Dict[str, Any]: + """Get account details""" + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.base_url}/accounts/{account_id}/", + headers=self.headers + ) + response.raise_for_status() + return response.json() + + async def get_account_balances(self, account_id: str) -> Dict[str, Any]: + """Get account balances""" + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.base_url}/accounts/{account_id}/balances/", + headers=self.headers + ) + response.raise_for_status() + return response.json() + + async def get_account_transactions(self, account_id: str) -> Dict[str, Any]: + """Get account transactions""" + async with httpx.AsyncClient() as client: + response = await client.get( + f"{self.base_url}/accounts/{account_id}/transactions/", + headers=self.headers + ) + response.raise_for_status() + return response.json() \ No newline at end of file diff --git a/leggend/services/notification_service.py b/leggend/services/notification_service.py new file mode 100644 index 0000000..3cfe055 --- /dev/null +++ b/leggend/services/notification_service.py @@ -0,0 +1,116 @@ +from typing import List, Dict, Any + +from loguru import logger + +from leggend.config import config + + +class NotificationService: + def __init__(self): + self.notifications_config = config.notifications_config + self.filters_config = config.filters_config + + async def send_transaction_notifications(self, transactions: List[Dict[str, Any]]) -> None: + """Send notifications for new transactions that match filters""" + if not self.filters_config: + logger.info("No notification filters configured, skipping notifications") + return + + # Filter transactions that match notification criteria + matching_transactions = self._filter_transactions(transactions) + + if not matching_transactions: + logger.info("No transactions matched notification filters") + return + + # Send to enabled notification services + if self._is_discord_enabled(): + await self._send_discord_notifications(matching_transactions) + + if self._is_telegram_enabled(): + await self._send_telegram_notifications(matching_transactions) + + async def send_test_notification(self, service: str, message: str) -> bool: + """Send a test notification""" + try: + if service == "discord" and self._is_discord_enabled(): + await self._send_discord_test(message) + return True + elif service == "telegram" and self._is_telegram_enabled(): + await self._send_telegram_test(message) + return True + else: + logger.error(f"Notification service '{service}' not enabled or not found") + return False + except Exception as e: + logger.error(f"Failed to send test notification to {service}: {e}") + return False + + async def send_expiry_notification(self, notification_data: Dict[str, Any]) -> None: + """Send notification about account expiry""" + if self._is_discord_enabled(): + await self._send_discord_expiry(notification_data) + + if self._is_telegram_enabled(): + await self._send_telegram_expiry(notification_data) + + def _filter_transactions(self, transactions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Filter transactions based on notification criteria""" + matching = [] + filters_case_insensitive = self.filters_config.get("case-insensitive", {}) + + for transaction in transactions: + description = transaction.get("description", "").lower() + + # Check case-insensitive filters + for filter_name, filter_value in filters_case_insensitive.items(): + if filter_value.lower() in description: + matching.append({ + "name": transaction["description"], + "value": transaction["transactionValue"], + "currency": transaction["transactionCurrency"], + "date": transaction["transactionDate"], + }) + break + + return matching + + def _is_discord_enabled(self) -> bool: + """Check if Discord notifications are enabled""" + discord_config = self.notifications_config.get("discord", {}) + return bool(discord_config.get("webhook") and discord_config.get("enabled", True)) + + def _is_telegram_enabled(self) -> bool: + """Check if Telegram notifications are enabled""" + telegram_config = self.notifications_config.get("telegram", {}) + return bool( + telegram_config.get("token") and + telegram_config.get("chat_id") and + telegram_config.get("enabled", True) + ) + + async def _send_discord_notifications(self, transactions: List[Dict[str, Any]]) -> None: + """Send Discord notifications - placeholder implementation""" + # Would import and use leggen.notifications.discord + logger.info(f"Sending {len(transactions)} transaction notifications to Discord") + + async def _send_telegram_notifications(self, transactions: List[Dict[str, Any]]) -> None: + """Send Telegram notifications - placeholder implementation""" + # Would import and use leggen.notifications.telegram + logger.info(f"Sending {len(transactions)} transaction notifications to Telegram") + + async def _send_discord_test(self, message: str) -> None: + """Send Discord test notification""" + logger.info(f"Sending Discord test: {message}") + + async def _send_telegram_test(self, message: str) -> None: + """Send Telegram test notification""" + logger.info(f"Sending Telegram test: {message}") + + async def _send_discord_expiry(self, notification_data: Dict[str, Any]) -> None: + """Send Discord expiry notification""" + logger.info(f"Sending Discord expiry notification: {notification_data}") + + async def _send_telegram_expiry(self, notification_data: Dict[str, Any]) -> None: + """Send Telegram expiry notification""" + logger.info(f"Sending Telegram expiry notification: {notification_data}") \ No newline at end of file diff --git a/leggend/services/sync_service.py b/leggend/services/sync_service.py new file mode 100644 index 0000000..6233c3d --- /dev/null +++ b/leggend/services/sync_service.py @@ -0,0 +1,145 @@ +import asyncio +from datetime import datetime +from typing import List, Dict, Any + +from loguru import logger + +from leggend.config import config +from leggend.api.models.sync import SyncResult, SyncStatus +from leggend.services.gocardless_service import GoCardlessService +from leggend.services.database_service import DatabaseService +from leggend.services.notification_service import NotificationService + + +class SyncService: + def __init__(self): + self.gocardless = GoCardlessService() + self.database = DatabaseService() + self.notifications = NotificationService() + self._sync_status = SyncStatus(is_running=False) + + async def get_sync_status(self) -> SyncStatus: + """Get current sync status""" + return self._sync_status + + async def sync_all_accounts(self, force: bool = False) -> SyncResult: + """Sync all connected accounts""" + if self._sync_status.is_running and not force: + raise Exception("Sync is already running") + + start_time = datetime.now() + self._sync_status.is_running = True + self._sync_status.errors = [] + + accounts_processed = 0 + transactions_added = 0 + transactions_updated = 0 + balances_updated = 0 + errors = [] + + try: + logger.info("Starting sync of all accounts") + + # Get all requisitions and accounts + requisitions = await self.gocardless.get_requisitions() + all_accounts = set() + + for req in requisitions.get("results", []): + all_accounts.update(req.get("accounts", [])) + + self._sync_status.total_accounts = len(all_accounts) + + # Process each account + for account_id in all_accounts: + try: + # Get account details + account_details = await self.gocardless.get_account_details(account_id) + + # Get and save balances + balances = await self.gocardless.get_account_balances(account_id) + if balances: + await self.database.persist_balance(account_id, balances) + balances_updated += len(balances.get("balances", [])) + + # Get and save transactions + transactions = await self.gocardless.get_account_transactions(account_id) + if transactions: + processed_transactions = self.database.process_transactions( + account_id, account_details, transactions + ) + new_transactions = await self.database.persist_transactions( + account_id, processed_transactions + ) + transactions_added += len(new_transactions) + + # Send notifications for new transactions + if new_transactions: + await self.notifications.send_transaction_notifications(new_transactions) + + accounts_processed += 1 + self._sync_status.accounts_synced = accounts_processed + + logger.info(f"Synced account {account_id} successfully") + + except Exception as e: + error_msg = f"Failed to sync account {account_id}: {str(e)}" + errors.append(error_msg) + logger.error(error_msg) + + end_time = datetime.now() + duration = (end_time - start_time).total_seconds() + + self._sync_status.last_sync = end_time + + result = SyncResult( + success=len(errors) == 0, + accounts_processed=accounts_processed, + transactions_added=transactions_added, + transactions_updated=transactions_updated, + balances_updated=balances_updated, + duration_seconds=duration, + errors=errors, + started_at=start_time, + completed_at=end_time + ) + + logger.info(f"Sync completed: {accounts_processed} accounts, {transactions_added} new transactions") + return result + + except Exception as e: + error_msg = f"Sync failed: {str(e)}" + errors.append(error_msg) + logger.error(error_msg) + raise + finally: + self._sync_status.is_running = False + + async def sync_specific_accounts(self, account_ids: List[str], force: bool = False) -> SyncResult: + """Sync specific accounts""" + if self._sync_status.is_running and not force: + raise Exception("Sync is already running") + + # Similar implementation but only for specified accounts + # For brevity, implementing a simplified version + start_time = datetime.now() + self._sync_status.is_running = True + + try: + # Process only specified accounts + # Implementation would be similar to sync_all_accounts + # but filtered to only the specified account_ids + + end_time = datetime.now() + return SyncResult( + success=True, + accounts_processed=len(account_ids), + transactions_added=0, + transactions_updated=0, + balances_updated=0, + duration_seconds=(end_time - start_time).total_seconds(), + errors=[], + started_at=start_time, + completed_at=end_time + ) + finally: + self._sync_status.is_running = False \ No newline at end of file diff --git a/leggend/utils/__init__.py b/leggend/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/leggend/utils/gocardless.py b/leggend/utils/gocardless.py new file mode 100644 index 0000000..1eb5e72 --- /dev/null +++ b/leggend/utils/gocardless.py @@ -0,0 +1,10 @@ +REQUISITION_STATUS = { + "CR": "CREATED", + "GC": "GIVING_CONSENT", + "UA": "UNDERGOING_AUTHENTICATION", + "RJ": "REJECTED", + "SA": "SELECTING_ACCOUNTS", + "GA": "GRANTING_ACCESS", + "LN": "LINKED", + "EX": "EXPIRED", +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d2464f3..23b8ccb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "leggen" version = "0.6.11" description = "An Open Banking CLI" authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }] -requires-python = "~=3.12" +requires-python = "=3.12.0" readme = "README.md" license = "MIT" keywords = [ @@ -31,6 +31,10 @@ dependencies = [ "tabulate>=0.9.0,<0.10", "pymongo>=4.6.1,<5", "discord-webhook>=1.3.1,<2", + "fastapi>=0.104.0,<1", + "uvicorn[standard]>=0.24.0,<1", + "apscheduler>=3.10.0,<4", + "tomli-w>=1.0.0,<2", ] [project.urls] @@ -38,6 +42,7 @@ Repository = "https://github.com/elisiariocouto/leggen" [project.scripts] leggen = "leggen.main:cli" +leggend = "leggend.main:main" [dependency-groups] dev = [ diff --git a/uv.lock b/uv.lock index e98e501..690dbda 100644 --- a/uv.lock +++ b/uv.lock @@ -1,57 +1,58 @@ version = 1 +revision = 3 requires-python = ">=3.12, <4" [[package]] name = "certifi" version = "2024.12.14" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 } +sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010, upload-time = "2024-12-14T13:52:38.02Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 }, + { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927, upload-time = "2024-12-14T13:52:36.114Z" }, ] [[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 } +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 }, + { 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.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 } +sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188, upload-time = "2024-12-24T18:12:35.43Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 }, - { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 }, - { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 }, - { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 }, - { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 }, - { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 }, - { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 }, - { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 }, - { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 }, - { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 }, - { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 }, - { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 }, - { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 }, - { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 }, - { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 }, - { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 }, - { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 }, - { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 }, - { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 }, - { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 }, - { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 }, - { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 }, - { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 }, - { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 }, - { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 }, - { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 }, - { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 }, + { url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105, upload-time = "2024-12-24T18:10:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404, upload-time = "2024-12-24T18:10:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423, upload-time = "2024-12-24T18:10:45.492Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184, upload-time = "2024-12-24T18:10:47.898Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268, upload-time = "2024-12-24T18:10:50.589Z" }, + { url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601, upload-time = "2024-12-24T18:10:52.541Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098, upload-time = "2024-12-24T18:10:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520, upload-time = "2024-12-24T18:10:55.048Z" }, + { url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852, upload-time = "2024-12-24T18:10:57.647Z" }, + { url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488, upload-time = "2024-12-24T18:10:59.43Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192, upload-time = "2024-12-24T18:11:00.676Z" }, + { url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550, upload-time = "2024-12-24T18:11:01.952Z" }, + { url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785, upload-time = "2024-12-24T18:11:03.142Z" }, + { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698, upload-time = "2024-12-24T18:11:05.834Z" }, + { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162, upload-time = "2024-12-24T18:11:07.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263, upload-time = "2024-12-24T18:11:08.374Z" }, + { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966, upload-time = "2024-12-24T18:11:09.831Z" }, + { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992, upload-time = "2024-12-24T18:11:12.03Z" }, + { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162, upload-time = "2024-12-24T18:11:13.372Z" }, + { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972, upload-time = "2024-12-24T18:11:14.628Z" }, + { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095, upload-time = "2024-12-24T18:11:17.672Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668, upload-time = "2024-12-24T18:11:18.989Z" }, + { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073, upload-time = "2024-12-24T18:11:21.507Z" }, + { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732, upload-time = "2024-12-24T18:11:22.774Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload-time = "2024-12-24T18:11:24.139Z" }, + { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload-time = "2024-12-24T18:11:26.535Z" }, + { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload-time = "2024-12-24T18:12:32.852Z" }, ] [[package]] @@ -61,18 +62,18 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593 } +sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188 }, + { url = "https://files.pythonhosted.org/packages/7e/d4/7ebdbd03970677812aac39c869717059dbb71a4cfc033ca6e5221787892c/click-8.1.8-py3-none-any.whl", hash = "sha256:63c132bbbed01578a06712a2d1f497bb62d9c1c0d329b7903a866228027263b2", size = 98188, upload-time = "2024-12-21T18:38:41.666Z" }, ] [[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 } +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 }, + { 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]] @@ -82,59 +83,59 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e8/e6/660b07356a15d98787d893f879efc404eb15176312d457f2f6f7090acd32/discord_webhook-1.3.1.tar.gz", hash = "sha256:ee3e0f3ea4f3dc8dc42be91f75b894a01624c6c13fea28e23ebcf9a6c9a304f7", size = 11715 } +sdist = { url = "https://files.pythonhosted.org/packages/e8/e6/660b07356a15d98787d893f879efc404eb15176312d457f2f6f7090acd32/discord_webhook-1.3.1.tar.gz", hash = "sha256:ee3e0f3ea4f3dc8dc42be91f75b894a01624c6c13fea28e23ebcf9a6c9a304f7", size = 11715, upload-time = "2024-01-31T17:23:14.463Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/92/e2/eed83ebc8d88da0930143a6dd1d0ba0b6deba1fd91b956f21c23a2608510/discord_webhook-1.3.1-py3-none-any.whl", hash = "sha256:ede07028316de76d24eb811836e2b818b2017510da786777adcb0d5970e7af79", size = 13206 }, + { url = "https://files.pythonhosted.org/packages/92/e2/eed83ebc8d88da0930143a6dd1d0ba0b6deba1fd91b956f21c23a2608510/discord_webhook-1.3.1-py3-none-any.whl", hash = "sha256:ede07028316de76d24eb811836e2b818b2017510da786777adcb0d5970e7af79", size = 13206, upload-time = "2024-01-31T17:23:12.424Z" }, ] [[package]] name = "distlib" version = "0.3.9" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 } +sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923, upload-time = "2024-10-09T18:35:47.551Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, + { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973, upload-time = "2024-10-09T18:35:44.272Z" }, ] [[package]] name = "dnspython" version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 }, + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, ] [[package]] name = "filelock" version = "3.16.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037 } +sdist = { url = "https://files.pythonhosted.org/packages/9d/db/3ef5bb276dae18d6ec2124224403d1d67bccdbefc17af4cc8f553e341ab1/filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435", size = 18037, upload-time = "2024-09-17T19:02:01.779Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163 }, + { url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163, upload-time = "2024-09-17T19:02:00.268Z" }, ] [[package]] name = "identify" version = "2.6.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cf/92/69934b9ef3c31ca2470980423fda3d00f0460ddefdf30a67adf7f17e2e00/identify-2.6.5.tar.gz", hash = "sha256:c10b33f250e5bba374fae86fb57f3adcebf1161bce7cdf92031915fd480c13bc", size = 99213 } +sdist = { url = "https://files.pythonhosted.org/packages/cf/92/69934b9ef3c31ca2470980423fda3d00f0460ddefdf30a67adf7f17e2e00/identify-2.6.5.tar.gz", hash = "sha256:c10b33f250e5bba374fae86fb57f3adcebf1161bce7cdf92031915fd480c13bc", size = 99213, upload-time = "2025-01-04T17:01:41.99Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/fa/dce098f4cdf7621aa8f7b4f919ce545891f489482f0bfa5102f3eca8608b/identify-2.6.5-py2.py3-none-any.whl", hash = "sha256:14181a47091eb75b337af4c23078c9d09225cd4c48929f521f3bf16b09d02566", size = 99078 }, + { url = "https://files.pythonhosted.org/packages/ec/fa/dce098f4cdf7621aa8f7b4f919ce545891f489482f0bfa5102f3eca8608b/identify-2.6.5-py2.py3-none-any.whl", hash = "sha256:14181a47091eb75b337af4c23078c9d09225cd4c48929f521f3bf16b09d02566", size = 99078, upload-time = "2025-01-04T17:01:40.667Z" }, ] [[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 } +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 }, + { 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 = "leggen" -version = "0.6.10" +version = "0.6.11" source = { editable = "." } dependencies = [ { name = "click" }, @@ -175,27 +176,27 @@ dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, { name = "win32-setctime", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559 } +sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 }, + { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, ] [[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 } +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 }, + { 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 = "platformdirs" version = "4.3.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302 } +sdist = { url = "https://files.pythonhosted.org/packages/13/fc/128cc9cb8f03208bdbf93d3aa862e16d376844a14f9a0ce5cf4507372de4/platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", size = 21302, upload-time = "2024-09-17T19:06:50.688Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439 }, + { url = "https://files.pythonhosted.org/packages/3c/a6/bc1012356d8ece4d66dd75c4b9fc6c1f6650ddd5991e421177d9f8f671be/platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb", size = 18439, upload-time = "2024-09-17T19:06:49.212Z" }, ] [[package]] @@ -209,9 +210,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/c8/e22c292035f1bac8b9f5237a2622305bc0304e776080b246f3df57c4ff9f/pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", size = 191678 } +sdist = { url = "https://files.pythonhosted.org/packages/2e/c8/e22c292035f1bac8b9f5237a2622305bc0304e776080b246f3df57c4ff9f/pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2", size = 191678, upload-time = "2024-10-08T16:09:37.641Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713 }, + { url = "https://files.pythonhosted.org/packages/16/8f/496e10d51edd6671ebe0432e33ff800aa86775d2d147ce7d43389324a525/pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878", size = 218713, upload-time = "2024-10-08T16:09:35.726Z" }, ] [[package]] @@ -221,52 +222,52 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dnspython" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/35/b62a3139f908c68b69aac6a6a3f8cc146869de0a7929b994600e2c587c77/pymongo-4.10.1.tar.gz", hash = "sha256:a9de02be53b6bb98efe0b9eda84ffa1ec027fcb23a2de62c4f941d9a2f2f3330", size = 1903902 } +sdist = { url = "https://files.pythonhosted.org/packages/1a/35/b62a3139f908c68b69aac6a6a3f8cc146869de0a7929b994600e2c587c77/pymongo-4.10.1.tar.gz", hash = "sha256:a9de02be53b6bb98efe0b9eda84ffa1ec027fcb23a2de62c4f941d9a2f2f3330", size = 1903902, upload-time = "2024-10-01T23:07:58.525Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/10/d1/60ad99fe3f64d45e6c71ac0e3078e88d9b64112b1bae571fc3707344d6d1/pymongo-4.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fbedc4617faa0edf423621bb0b3b8707836687161210d470e69a4184be9ca011", size = 943356 }, - { url = "https://files.pythonhosted.org/packages/ca/9b/21d4c6b4ee9c1fa9691c68dc2a52565e0acb644b9e95148569b4736a4ebd/pymongo-4.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7bd26b2aec8ceeb95a5d948d5cc0f62b0eb6d66f3f4230705c1e3d3d2c04ec76", size = 943142 }, - { url = "https://files.pythonhosted.org/packages/07/af/691b7454e219a8eb2d1641aecedd607e3a94b93650c2011ad8a8fd74ef9f/pymongo-4.10.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb104c3c2a78d9d85571c8ac90ec4f95bca9b297c6eee5ada71fabf1129e1674", size = 1909129 }, - { url = "https://files.pythonhosted.org/packages/0c/74/fd75d5ad4181d6e71ce0fca32404fb71b5046ac84d9a1a2f0862262dd032/pymongo-4.10.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4924355245a9c79f77b5cda2db36e0f75ece5faf9f84d16014c0a297f6d66786", size = 1987763 }, - { url = "https://files.pythonhosted.org/packages/8a/56/6d3d0ef63c6d8cb98c7c653a3a2e617675f77a95f3853851d17a7664876a/pymongo-4.10.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:11280809e5dacaef4971113f0b4ff4696ee94cfdb720019ff4fa4f9635138252", size = 1950821 }, - { url = "https://files.pythonhosted.org/packages/70/ed/1603fa0c0e51444752c3fa91f16c3a97e6d92eb9fe5e553dae4f18df16f6/pymongo-4.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5d55f2a82e5eb23795f724991cac2bffbb1c0f219c0ba3bf73a835f97f1bb2e", size = 1912247 }, - { url = "https://files.pythonhosted.org/packages/c1/66/e98b2308971d45667cb8179d4d66deca47336c90663a7e0527589f1038b7/pymongo-4.10.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e974ab16a60be71a8dfad4e5afccf8dd05d41c758060f5d5bda9a758605d9a5d", size = 1862230 }, - { url = "https://files.pythonhosted.org/packages/6c/80/ba9b7ed212a5f8cf8ad7037ed5bbebc1c587fc09242108f153776e4a338b/pymongo-4.10.1-cp312-cp312-win32.whl", hash = "sha256:544890085d9641f271d4f7a47684450ed4a7344d6b72d5968bfae32203b1bb7c", size = 903045 }, - { url = "https://files.pythonhosted.org/packages/76/8b/5afce891d78159912c43726fab32641e3f9718f14be40f978c148ea8db48/pymongo-4.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:dcc07b1277e8b4bf4d7382ca133850e323b7ab048b8353af496d050671c7ac52", size = 926686 }, - { url = "https://files.pythonhosted.org/packages/83/76/df0fd0622a85b652ad0f91ec8a0ebfd0cb86af6caec8999a22a1f7481203/pymongo-4.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:90bc6912948dfc8c363f4ead54d54a02a15a7fee6cfafb36dc450fc8962d2cb7", size = 996981 }, - { url = "https://files.pythonhosted.org/packages/4c/39/fa50531de8d1d8af8c253caeed20c18ccbf1de5d970119c4a42c89f2bd09/pymongo-4.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:594dd721b81f301f33e843453638e02d92f63c198358e5a0fa8b8d0b1218dabc", size = 996769 }, - { url = "https://files.pythonhosted.org/packages/bf/50/6936612c1b2e32d95c30e860552d3bc9e55cfa79a4f73b73225fa05a028c/pymongo-4.10.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0783e0c8e95397c84e9cf8ab092ab1e5dd7c769aec0ef3a5838ae7173b98dea0", size = 2169159 }, - { url = "https://files.pythonhosted.org/packages/78/8c/45cb23096e66c7b1da62bb8d9c7ac2280e7c1071e13841e7fb71bd44fd9f/pymongo-4.10.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6fb6a72e88df46d1c1040fd32cd2d2c5e58722e5d3e31060a0393f04ad3283de", size = 2260569 }, - { url = "https://files.pythonhosted.org/packages/29/b6/e5ec697087e527a6a15c5f8daa5bcbd641edb8813487345aaf963d3537dc/pymongo-4.10.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2e3a593333e20c87415420a4fb76c00b7aae49b6361d2e2205b6fece0563bf40", size = 2218142 }, - { url = "https://files.pythonhosted.org/packages/ad/8a/c0b45bee0f0c57732c5c36da5122c1796efd5a62d585fbc504e2f1401244/pymongo-4.10.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72e2ace7456167c71cfeca7dcb47bd5dceda7db2231265b80fc625c5e8073186", size = 2170623 }, - { url = "https://files.pythonhosted.org/packages/3b/26/6c0a5360a571df24c9bfbd51b1dae279f4f0c511bdbc0906f6df6d1543fa/pymongo-4.10.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ad05eb9c97e4f589ed9e74a00fcaac0d443ccd14f38d1258eb4c39a35dd722b", size = 2111112 }, - { url = "https://files.pythonhosted.org/packages/38/bc/5b91b728e1cf505d931f04e24cbac71ae519523785570ed046cdc31e6efc/pymongo-4.10.1-cp313-cp313-win32.whl", hash = "sha256:ee4c86d8e6872a61f7888fc96577b0ea165eb3bdb0d841962b444fa36001e2bb", size = 948727 }, - { url = "https://files.pythonhosted.org/packages/0d/2a/7c24a6144eaa06d18ed52822ea2b0f119fd9267cd1abbb75dae4d89a3803/pymongo-4.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:45ee87a4e12337353242bc758accc7fb47a2f2d9ecc0382a61e64c8f01e86708", size = 976873 }, + { url = "https://files.pythonhosted.org/packages/10/d1/60ad99fe3f64d45e6c71ac0e3078e88d9b64112b1bae571fc3707344d6d1/pymongo-4.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fbedc4617faa0edf423621bb0b3b8707836687161210d470e69a4184be9ca011", size = 943356, upload-time = "2024-10-01T23:06:50.9Z" }, + { url = "https://files.pythonhosted.org/packages/ca/9b/21d4c6b4ee9c1fa9691c68dc2a52565e0acb644b9e95148569b4736a4ebd/pymongo-4.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7bd26b2aec8ceeb95a5d948d5cc0f62b0eb6d66f3f4230705c1e3d3d2c04ec76", size = 943142, upload-time = "2024-10-01T23:06:52.146Z" }, + { url = "https://files.pythonhosted.org/packages/07/af/691b7454e219a8eb2d1641aecedd607e3a94b93650c2011ad8a8fd74ef9f/pymongo-4.10.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb104c3c2a78d9d85571c8ac90ec4f95bca9b297c6eee5ada71fabf1129e1674", size = 1909129, upload-time = "2024-10-01T23:06:53.551Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/fd75d5ad4181d6e71ce0fca32404fb71b5046ac84d9a1a2f0862262dd032/pymongo-4.10.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4924355245a9c79f77b5cda2db36e0f75ece5faf9f84d16014c0a297f6d66786", size = 1987763, upload-time = "2024-10-01T23:06:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/8a/56/6d3d0ef63c6d8cb98c7c653a3a2e617675f77a95f3853851d17a7664876a/pymongo-4.10.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:11280809e5dacaef4971113f0b4ff4696ee94cfdb720019ff4fa4f9635138252", size = 1950821, upload-time = "2024-10-01T23:06:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/70/ed/1603fa0c0e51444752c3fa91f16c3a97e6d92eb9fe5e553dae4f18df16f6/pymongo-4.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5d55f2a82e5eb23795f724991cac2bffbb1c0f219c0ba3bf73a835f97f1bb2e", size = 1912247, upload-time = "2024-10-01T23:06:59.023Z" }, + { url = "https://files.pythonhosted.org/packages/c1/66/e98b2308971d45667cb8179d4d66deca47336c90663a7e0527589f1038b7/pymongo-4.10.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e974ab16a60be71a8dfad4e5afccf8dd05d41c758060f5d5bda9a758605d9a5d", size = 1862230, upload-time = "2024-10-01T23:07:01.407Z" }, + { url = "https://files.pythonhosted.org/packages/6c/80/ba9b7ed212a5f8cf8ad7037ed5bbebc1c587fc09242108f153776e4a338b/pymongo-4.10.1-cp312-cp312-win32.whl", hash = "sha256:544890085d9641f271d4f7a47684450ed4a7344d6b72d5968bfae32203b1bb7c", size = 903045, upload-time = "2024-10-01T23:07:02.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/8b/5afce891d78159912c43726fab32641e3f9718f14be40f978c148ea8db48/pymongo-4.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:dcc07b1277e8b4bf4d7382ca133850e323b7ab048b8353af496d050671c7ac52", size = 926686, upload-time = "2024-10-01T23:07:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/83/76/df0fd0622a85b652ad0f91ec8a0ebfd0cb86af6caec8999a22a1f7481203/pymongo-4.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:90bc6912948dfc8c363f4ead54d54a02a15a7fee6cfafb36dc450fc8962d2cb7", size = 996981, upload-time = "2024-10-01T23:07:06.001Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/fa50531de8d1d8af8c253caeed20c18ccbf1de5d970119c4a42c89f2bd09/pymongo-4.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:594dd721b81f301f33e843453638e02d92f63c198358e5a0fa8b8d0b1218dabc", size = 996769, upload-time = "2024-10-01T23:07:07.855Z" }, + { url = "https://files.pythonhosted.org/packages/bf/50/6936612c1b2e32d95c30e860552d3bc9e55cfa79a4f73b73225fa05a028c/pymongo-4.10.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0783e0c8e95397c84e9cf8ab092ab1e5dd7c769aec0ef3a5838ae7173b98dea0", size = 2169159, upload-time = "2024-10-01T23:07:09.963Z" }, + { url = "https://files.pythonhosted.org/packages/78/8c/45cb23096e66c7b1da62bb8d9c7ac2280e7c1071e13841e7fb71bd44fd9f/pymongo-4.10.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6fb6a72e88df46d1c1040fd32cd2d2c5e58722e5d3e31060a0393f04ad3283de", size = 2260569, upload-time = "2024-10-01T23:07:11.856Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/e5ec697087e527a6a15c5f8daa5bcbd641edb8813487345aaf963d3537dc/pymongo-4.10.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2e3a593333e20c87415420a4fb76c00b7aae49b6361d2e2205b6fece0563bf40", size = 2218142, upload-time = "2024-10-01T23:07:13.61Z" }, + { url = "https://files.pythonhosted.org/packages/ad/8a/c0b45bee0f0c57732c5c36da5122c1796efd5a62d585fbc504e2f1401244/pymongo-4.10.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72e2ace7456167c71cfeca7dcb47bd5dceda7db2231265b80fc625c5e8073186", size = 2170623, upload-time = "2024-10-01T23:07:15.319Z" }, + { url = "https://files.pythonhosted.org/packages/3b/26/6c0a5360a571df24c9bfbd51b1dae279f4f0c511bdbc0906f6df6d1543fa/pymongo-4.10.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ad05eb9c97e4f589ed9e74a00fcaac0d443ccd14f38d1258eb4c39a35dd722b", size = 2111112, upload-time = "2024-10-01T23:07:16.859Z" }, + { url = "https://files.pythonhosted.org/packages/38/bc/5b91b728e1cf505d931f04e24cbac71ae519523785570ed046cdc31e6efc/pymongo-4.10.1-cp313-cp313-win32.whl", hash = "sha256:ee4c86d8e6872a61f7888fc96577b0ea165eb3bdb0d841962b444fa36001e2bb", size = 948727, upload-time = "2024-10-01T23:07:18.275Z" }, + { url = "https://files.pythonhosted.org/packages/0d/2a/7c24a6144eaa06d18ed52822ea2b0f119fd9267cd1abbb75dae4d89a3803/pymongo-4.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:45ee87a4e12337353242bc758accc7fb47a2f2d9ecc0382a61e64c8f01e86708", size = 976873, upload-time = "2024-10-01T23:07:19.721Z" }, ] [[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 } +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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { 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 }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591 }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338 }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 }, + { 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]] @@ -279,52 +280,52 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 } +sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 }, + { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" }, ] [[package]] name = "ruff" version = "0.9.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/67/3e/e89f736f01aa9517a97e2e7e0ce8d34a4d8207087b3cfdec95133fee13b5/ruff-0.9.1.tar.gz", hash = "sha256:fd2b25ecaf907d6458fa842675382c8597b3c746a2dde6717fe3415425df0c17", size = 3498844 } +sdist = { url = "https://files.pythonhosted.org/packages/67/3e/e89f736f01aa9517a97e2e7e0ce8d34a4d8207087b3cfdec95133fee13b5/ruff-0.9.1.tar.gz", hash = "sha256:fd2b25ecaf907d6458fa842675382c8597b3c746a2dde6717fe3415425df0c17", size = 3498844, upload-time = "2025-01-10T18:57:53.896Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/05/c3a2e0feb3d5d394cdfd552de01df9d3ec8a3a3771bbff247fab7e668653/ruff-0.9.1-py3-none-linux_armv6l.whl", hash = "sha256:84330dda7abcc270e6055551aca93fdde1b0685fc4fd358f26410f9349cf1743", size = 10645241 }, - { url = "https://files.pythonhosted.org/packages/dd/da/59f0a40e5f88ee5c054ad175caaa2319fc96571e1d29ab4730728f2aad4f/ruff-0.9.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3cae39ba5d137054b0e5b472aee3b78a7c884e61591b100aeb544bcd1fc38d4f", size = 10391066 }, - { url = "https://files.pythonhosted.org/packages/b7/fe/85e1c1acf0ba04a3f2d54ae61073da030f7a5dc386194f96f3c6ca444a78/ruff-0.9.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50c647ff96f4ba288db0ad87048257753733763b409b2faf2ea78b45c8bb7fcb", size = 10012308 }, - { url = "https://files.pythonhosted.org/packages/6f/9b/780aa5d4bdca8dcea4309264b8faa304bac30e1ce0bcc910422bfcadd203/ruff-0.9.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0c8b149e9c7353cace7d698e1656ffcf1e36e50f8ea3b5d5f7f87ff9986a7ca", size = 10881960 }, - { url = "https://files.pythonhosted.org/packages/12/f4/dac4361afbfe520afa7186439e8094e4884ae3b15c8fc75fb2e759c1f267/ruff-0.9.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:beb3298604540c884d8b282fe7625651378e1986c25df51dec5b2f60cafc31ce", size = 10414803 }, - { url = "https://files.pythonhosted.org/packages/f0/a2/057a3cb7999513cb78d6cb33a7d1cc6401c82d7332583786e4dad9e38e44/ruff-0.9.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39d0174ccc45c439093971cc06ed3ac4dc545f5e8bdacf9f067adf879544d969", size = 11464929 }, - { url = "https://files.pythonhosted.org/packages/eb/c6/1ccfcc209bee465ced4874dcfeaadc88aafcc1ea9c9f31ef66f063c187f0/ruff-0.9.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69572926c0f0c9912288915214ca9b2809525ea263603370b9e00bed2ba56dbd", size = 12170717 }, - { url = "https://files.pythonhosted.org/packages/84/97/4a524027518525c7cf6931e9fd3b2382be5e4b75b2b61bec02681a7685a5/ruff-0.9.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:937267afce0c9170d6d29f01fcd1f4378172dec6760a9f4dface48cdabf9610a", size = 11708921 }, - { url = "https://files.pythonhosted.org/packages/a6/a4/4e77cf6065c700d5593b25fca6cf725b1ab6d70674904f876254d0112ed0/ruff-0.9.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:186c2313de946f2c22bdf5954b8dd083e124bcfb685732cfb0beae0c47233d9b", size = 13058074 }, - { url = "https://files.pythonhosted.org/packages/f9/d6/fcb78e0531e863d0a952c4c5600cc5cd317437f0e5f031cd2288b117bb37/ruff-0.9.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f94942a3bb767675d9a051867c036655fe9f6c8a491539156a6f7e6b5f31831", size = 11281093 }, - { url = "https://files.pythonhosted.org/packages/e4/3b/7235bbeff00c95dc2d073cfdbf2b871b5bbf476754c5d277815d286b4328/ruff-0.9.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:728d791b769cc28c05f12c280f99e8896932e9833fef1dd8756a6af2261fd1ab", size = 10882610 }, - { url = "https://files.pythonhosted.org/packages/2a/66/5599d23257c61cf038137f82999ca8f9d0080d9d5134440a461bef85b461/ruff-0.9.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2f312c86fb40c5c02b44a29a750ee3b21002bd813b5233facdaf63a51d9a85e1", size = 10489273 }, - { url = "https://files.pythonhosted.org/packages/78/85/de4aa057e2532db0f9761e2c2c13834991e087787b93e4aeb5f1cb10d2df/ruff-0.9.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ae017c3a29bee341ba584f3823f805abbe5fe9cd97f87ed07ecbf533c4c88366", size = 11003314 }, - { url = "https://files.pythonhosted.org/packages/00/42/afedcaa089116d81447347f76041ff46025849fedb0ed2b187d24cf70fca/ruff-0.9.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5dc40a378a0e21b4cfe2b8a0f1812a6572fc7b230ef12cd9fac9161aa91d807f", size = 11342982 }, - { url = "https://files.pythonhosted.org/packages/39/c6/fe45f3eb27e3948b41a305d8b768e949bf6a39310e9df73f6c576d7f1d9f/ruff-0.9.1-py3-none-win32.whl", hash = "sha256:46ebf5cc106cf7e7378ca3c28ce4293b61b449cd121b98699be727d40b79ba72", size = 8819750 }, - { url = "https://files.pythonhosted.org/packages/38/8d/580db77c3b9d5c3d9479e55b0b832d279c30c8f00ab0190d4cd8fc67831c/ruff-0.9.1-py3-none-win_amd64.whl", hash = "sha256:342a824b46ddbcdddd3abfbb332fa7fcaac5488bf18073e841236aadf4ad5c19", size = 9701331 }, - { url = "https://files.pythonhosted.org/packages/b2/94/0498cdb7316ed67a1928300dd87d659c933479f44dec51b4f62bfd1f8028/ruff-0.9.1-py3-none-win_arm64.whl", hash = "sha256:1cd76c7f9c679e6e8f2af8f778367dca82b95009bc7b1a85a47f1521ae524fa7", size = 9145708 }, + { url = "https://files.pythonhosted.org/packages/dc/05/c3a2e0feb3d5d394cdfd552de01df9d3ec8a3a3771bbff247fab7e668653/ruff-0.9.1-py3-none-linux_armv6l.whl", hash = "sha256:84330dda7abcc270e6055551aca93fdde1b0685fc4fd358f26410f9349cf1743", size = 10645241, upload-time = "2025-01-10T18:56:45.897Z" }, + { url = "https://files.pythonhosted.org/packages/dd/da/59f0a40e5f88ee5c054ad175caaa2319fc96571e1d29ab4730728f2aad4f/ruff-0.9.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3cae39ba5d137054b0e5b472aee3b78a7c884e61591b100aeb544bcd1fc38d4f", size = 10391066, upload-time = "2025-01-10T18:56:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/85e1c1acf0ba04a3f2d54ae61073da030f7a5dc386194f96f3c6ca444a78/ruff-0.9.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:50c647ff96f4ba288db0ad87048257753733763b409b2faf2ea78b45c8bb7fcb", size = 10012308, upload-time = "2025-01-10T18:56:55.426Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9b/780aa5d4bdca8dcea4309264b8faa304bac30e1ce0bcc910422bfcadd203/ruff-0.9.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0c8b149e9c7353cace7d698e1656ffcf1e36e50f8ea3b5d5f7f87ff9986a7ca", size = 10881960, upload-time = "2025-01-10T18:56:59.539Z" }, + { url = "https://files.pythonhosted.org/packages/12/f4/dac4361afbfe520afa7186439e8094e4884ae3b15c8fc75fb2e759c1f267/ruff-0.9.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:beb3298604540c884d8b282fe7625651378e1986c25df51dec5b2f60cafc31ce", size = 10414803, upload-time = "2025-01-10T18:57:04.919Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a2/057a3cb7999513cb78d6cb33a7d1cc6401c82d7332583786e4dad9e38e44/ruff-0.9.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:39d0174ccc45c439093971cc06ed3ac4dc545f5e8bdacf9f067adf879544d969", size = 11464929, upload-time = "2025-01-10T18:57:08.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c6/1ccfcc209bee465ced4874dcfeaadc88aafcc1ea9c9f31ef66f063c187f0/ruff-0.9.1-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:69572926c0f0c9912288915214ca9b2809525ea263603370b9e00bed2ba56dbd", size = 12170717, upload-time = "2025-01-10T18:57:12.564Z" }, + { url = "https://files.pythonhosted.org/packages/84/97/4a524027518525c7cf6931e9fd3b2382be5e4b75b2b61bec02681a7685a5/ruff-0.9.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:937267afce0c9170d6d29f01fcd1f4378172dec6760a9f4dface48cdabf9610a", size = 11708921, upload-time = "2025-01-10T18:57:17.216Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a4/4e77cf6065c700d5593b25fca6cf725b1ab6d70674904f876254d0112ed0/ruff-0.9.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:186c2313de946f2c22bdf5954b8dd083e124bcfb685732cfb0beae0c47233d9b", size = 13058074, upload-time = "2025-01-10T18:57:20.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/d6/fcb78e0531e863d0a952c4c5600cc5cd317437f0e5f031cd2288b117bb37/ruff-0.9.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f94942a3bb767675d9a051867c036655fe9f6c8a491539156a6f7e6b5f31831", size = 11281093, upload-time = "2025-01-10T18:57:25.526Z" }, + { url = "https://files.pythonhosted.org/packages/e4/3b/7235bbeff00c95dc2d073cfdbf2b871b5bbf476754c5d277815d286b4328/ruff-0.9.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:728d791b769cc28c05f12c280f99e8896932e9833fef1dd8756a6af2261fd1ab", size = 10882610, upload-time = "2025-01-10T18:57:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/2a/66/5599d23257c61cf038137f82999ca8f9d0080d9d5134440a461bef85b461/ruff-0.9.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:2f312c86fb40c5c02b44a29a750ee3b21002bd813b5233facdaf63a51d9a85e1", size = 10489273, upload-time = "2025-01-10T18:57:32.219Z" }, + { url = "https://files.pythonhosted.org/packages/78/85/de4aa057e2532db0f9761e2c2c13834991e087787b93e4aeb5f1cb10d2df/ruff-0.9.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:ae017c3a29bee341ba584f3823f805abbe5fe9cd97f87ed07ecbf533c4c88366", size = 11003314, upload-time = "2025-01-10T18:57:35.431Z" }, + { url = "https://files.pythonhosted.org/packages/00/42/afedcaa089116d81447347f76041ff46025849fedb0ed2b187d24cf70fca/ruff-0.9.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5dc40a378a0e21b4cfe2b8a0f1812a6572fc7b230ef12cd9fac9161aa91d807f", size = 11342982, upload-time = "2025-01-10T18:57:38.642Z" }, + { url = "https://files.pythonhosted.org/packages/39/c6/fe45f3eb27e3948b41a305d8b768e949bf6a39310e9df73f6c576d7f1d9f/ruff-0.9.1-py3-none-win32.whl", hash = "sha256:46ebf5cc106cf7e7378ca3c28ce4293b61b449cd121b98699be727d40b79ba72", size = 8819750, upload-time = "2025-01-10T18:57:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/38/8d/580db77c3b9d5c3d9479e55b0b832d279c30c8f00ab0190d4cd8fc67831c/ruff-0.9.1-py3-none-win_amd64.whl", hash = "sha256:342a824b46ddbcdddd3abfbb332fa7fcaac5488bf18073e841236aadf4ad5c19", size = 9701331, upload-time = "2025-01-10T18:57:46.334Z" }, + { url = "https://files.pythonhosted.org/packages/b2/94/0498cdb7316ed67a1928300dd87d659c933479f44dec51b4f62bfd1f8028/ruff-0.9.1-py3-none-win_arm64.whl", hash = "sha256:1cd76c7f9c679e6e8f2af8f778367dca82b95009bc7b1a85a47f1521ae524fa7", size = 9145708, upload-time = "2025-01-10T18:57:51.308Z" }, ] [[package]] name = "tabulate" version = "0.9.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090 } +sdist = { url = "https://files.pythonhosted.org/packages/ec/fe/802052aecb21e3797b8f7902564ab6ea0d60ff8ca23952079064155d1ae1/tabulate-0.9.0.tar.gz", hash = "sha256:0095b12bf5966de529c0feb1fa08671671b3368eec77d7ef7ab114be2c068b3c", size = 81090, upload-time = "2022-10-06T17:21:48.54Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252 }, + { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, ] [[package]] name = "urllib3" version = "2.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 } +sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268, upload-time = "2024-12-22T07:47:30.032Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 }, + { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369, upload-time = "2024-12-22T07:47:28.074Z" }, ] [[package]] @@ -336,16 +337,16 @@ dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/50/39/689abee4adc85aad2af8174bb195a819d0be064bf55fcc73b49d2b28ae77/virtualenv-20.28.1.tar.gz", hash = "sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329", size = 7650532 } +sdist = { url = "https://files.pythonhosted.org/packages/50/39/689abee4adc85aad2af8174bb195a819d0be064bf55fcc73b49d2b28ae77/virtualenv-20.28.1.tar.gz", hash = "sha256:5d34ab240fdb5d21549b76f9e8ff3af28252f5499fb6d6f031adac4e5a8c5329", size = 7650532, upload-time = "2025-01-03T01:56:53.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/51/8f/dfb257ca6b4e27cb990f1631142361e4712badab8e3ca8dc134d96111515/virtualenv-20.28.1-py3-none-any.whl", hash = "sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb", size = 4276719 }, + { url = "https://files.pythonhosted.org/packages/51/8f/dfb257ca6b4e27cb990f1631142361e4712badab8e3ca8dc134d96111515/virtualenv-20.28.1-py3-none-any.whl", hash = "sha256:412773c85d4dab0409b83ec36f7a6499e72eaf08c80e81e9576bca61831c71cb", size = 4276719, upload-time = "2025-01-03T01:56:50.498Z" }, ] [[package]] name = "win32-setctime" version = "1.2.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867 } +sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867, upload-time = "2024-12-07T15:28:28.314Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 }, + { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, ]