Compare commits

..

14 Commits

34 changed files with 2826 additions and 78 deletions

View File

@@ -9,3 +9,6 @@ POSTGRES_DB=alemno_payments
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB=0
# Gemini API Config
GEMINI_API_KEY=

76
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,76 @@
name: Docker CI
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
env:
REGISTRY: ghcr.io
IMAGE_NAME_WEB: ghcr.io/${{ github.repository }}/web
IMAGE_NAME_WORKER: ghcr.io/${{ github.repository }}/worker
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Web
id: meta-web
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME_WEB }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,format=short
latest
- name: Build and Push Web Image
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta-web.outputs.tags }}
labels: ${{ steps.meta-web.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Extract metadata (tags, labels) for Worker
id: meta-worker
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_NAME_WORKER }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,format=short
latest
- name: Build and Push Worker Image
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta-worker.outputs.tags }}
labels: ${{ steps.meta-worker.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

57
.github/workflows/security.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
name: Security Scanning
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
bandit:
name: Run Bandit (SAST)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
python-version: "3.14"
- name: Run Bandit
run: uvx bandit -r app/ -ll -ii
pip-audit:
name: Dependency Audit
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
python-version: "3.14"
- name: Compile requirements
run: uv pip compile pyproject.toml -o requirements.txt
- name: Run pip-audit
run: uvx pip-audit -r requirements.txt
gitleaks:
name: Secret Scanning
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

60
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Run Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: alemno_test_db
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
python-version: "3.14"
- name: Run Tests with Coverage
env:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@localhost:5432/alemno_test_db
REDIS_URL: redis://localhost:6379/0
TESTING: "True"
run: |
uv run pytest --cov=app --cov-report=xml --cov-report=term-missing -v
- name: Upload Coverage Report
uses: actions/upload-artifact@v4
with:
name: code-coverage-report
path: coverage.xml

195
README.md
View File

@@ -1 +1,194 @@
# Alemno Payments
# Alemeno Payments
[![Python Version](https://img.shields.io/badge/python-3.14-blue?style=flat-square&logo=python)](file:///home/sortedcord/Projects/alemno_payments/pyproject.toml)
[![FastAPI](https://img.shields.io/badge/FastAPI-0.139-009688?style=flat-square&logo=fastapi)](file:///home/sortedcord/Projects/alemno_payments/app/app.py)
[![Test CI Status](https://github.com/sortedcord/alemno-payments/actions/workflows/tests.yml/badge.svg)](https://github.com/sortedcord/alemno-payments/actions/workflows/tests.yml)
[![Lint CI Status](https://github.com/sortedcord/alemno-payments/actions/workflows/lint.yml/badge.svg)](https://github.com/sortedcord/alemno-payments/actions/workflows/lint.yml)
[![Docker CI Status](https://github.com/sortedcord/alemno-payments/actions/workflows/docker.yml/badge.svg)](https://github.com/sortedcord/alemno-payments/actions/workflows/docker.yml)
[![Security CI Status](https://github.com/sortedcord/alemno-payments/actions/workflows/security.yml/badge.svg)](https://github.com/sortedcord/alemno-payments/actions/workflows/security.yml)
An asynchronous transactional data cleaning, validation, and LLM powered categorization pipeline.
## Features
- Reads multi format dates and handles missing transaction IDs by auto generating unique fallbacks, and parses messy amounts with currency signs and commas cleanly.
- Offloads validation, spend conversion, and analysis to a Celery background worker backed by Redis.
- Integrates with the Google GenAI SDK (`gemini-2.5-flash-lite` 75% cheaper than `gemini-3.5-flash-lite with no tangible decrease in performance) using structured output schemas to categorize transaction records into database- defined custom categories.
- Uses Redis caching with a 24-hour TTL to store live exchange rates, falling back to a hardcoded rate of `93.0` if offline.
- Statistical Anomaly Detection:
- Flags transactions exceeding 3 times the account's median spend as statistical outliers.
- Flags domestic transactions in USD currency.
- Flags high value transactions and transactions with fraud indicative keywords.
[![High Level Overview](./docs/assets/Overview.svg)](https://drive.google.com/file/d/1yk0-pXClrOp0ccTiaO7tbXPHZg4EH1eE/view?usp=sharing)
[Technical Video](https://www.youtube.com/watch?v=Xjfyf1ct-0U)
_Click on the image to view the [Draw.io](https://drive.google.com/file/d/1yk0-pXClrOp0ccTiaO7tbXPHZg4EH1eE/view?usp=sharing) file_
## Self Hosting & Deployment
We publish production-ready images to **GitHub Container Registry (GHCR)**. You can pull them directly or run them via Docker Compose.
Create a `docker-compose.yml` file to host the entire stack (or just clone the repo and directly run `docker compose up -d` :
```yaml
version: "3.8"
services:
db:
image: postgres:16-alpine
container_name: alemno_db
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: alemno_payments
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d alemno_payments"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: alemno_redis
ports:
- "6379:6379"
web:
image: ghcr.io/sortedcord/alemno-payments/web:latest
container_name: alemno_web
ports:
- "3000:3000"
environment:
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=alemno_payments
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_DB=0
- GEMINI_API_KEY=your-gemini-api-key
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
worker:
image: ghcr.io/sortedcord/alemno-payments/worker:latest
container_name: alemno_worker
environment:
- POSTGRES_HOST=db
- POSTGRES_PORT=5432
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=alemno_payments
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_DB=0
- GEMINI_API_KEY=your-gemini-api-key
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
volumes:
postgres_data:
```
Launch the stack:
```bash
docker compose up -d
```
## Testing the API with curl
All API endpoints are prefixed with `/api/v1`. Below are copy-pasteable `curl` commands using the sample [transactions.csv](file:///home/sortedcord/Projects/alemno_payments/transactions.csv) dataset.
### 1. Upload CSV Transaction Data
Submit a CSV file for asynchronous processing.
```bash
curl -X POST -F "file=@transactions.csv" http://localhost:3000/api/v1/jobs/upload
```
_Response Example:_
```json
{
"filename": "transactions.csv",
"id": "c0249af1-c52d-4d49-9305-3d1a10f29e03",
"status": "pending",
"row_count_raw": null,
"row_count_clean": null,
"created_at": "2026-07-03T06:29:13.123456Z",
"completed_at": null,
"error_message": null
}
```
### 2. Check Job Status
Check the status of a job using the job ID returned by the upload endpoint.
```bash
curl http://localhost:3000/api/v1/jobs/c0249af1-c52d-4d49-9305-3d1a10f29e03/status
```
_Response Example (while processing):_
```json
{
"id": "c0249af1-c52d-4d49-9305-3d1a10f29e03",
"status": "processing",
"summary": null
}
```
### 3. Get Full Job Results
Retrieve the high-level summary along with all categorized and parsed transactions once the job is completed.
```bash
curl http://localhost:3000/api/v1/jobs/c0249af1-c52d-4d49-9305-3d1a10f29e03/results
```
### 4. List All Jobs
List all historical processing jobs. You can optionally filter by status (e.g. `pending`, `processing`, `completed`, `failed`).
```bash
# List all jobs
curl http://localhost:3000/api/v1/jobs
# List completed jobs only
curl "http://localhost:3000/api/v1/jobs?status=completed"
```
### 5. List Categories
List all available transaction categories sorted alphabetically.
```bash
curl http://localhost:3000/api/v1/categories
```
### 6. Create Custom Category
Add a new custom category. Newly uploaded transactions will dynamically map to this category during classification if identified by the LLM.
```bash
curl -X POST \
-H "Content-Type: application/json" \
-d '{"name": "Subscription"}' \
http://localhost:3000/api/v1/categories
```

35
app/api/v1/categories.py Normal file
View File

@@ -0,0 +1,35 @@
from typing import List
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.dependencies import get_category_service
from app.core.exceptions import CategoryAlreadyExistsException
from app.schemas.category import CategoryCreate, CategoryResponse
from app.services.category_service import CategoryService
router = APIRouter(prefix="/categories", tags=["categories"])
@router.get("", response_model=List[CategoryResponse])
async def list_categories(
service: CategoryService = Depends(get_category_service),
):
"""
Returns the list of all available categories ordered alphabetically.
"""
return await service.list_categories()
@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_category(
category_in: CategoryCreate,
service: CategoryService = Depends(get_category_service),
):
"""
Creates a new custom category. The name must be unique.
"""
try:
return await service.create_category(name=category_in.name)
except CategoryAlreadyExistsException as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)

View File

@@ -4,6 +4,9 @@ from app.core.config import settings
from app.core.logging import logger
from app.core.observability import time_it
class ExchangeRateClient:
def __init__(self):
self.default_rate = 93.0
@@ -20,6 +23,7 @@ class ExchangeRateClient:
logger.warning(f"Could not connect to Redis for exchange rate caching: {e}")
self.redis_client = None
@time_it("Get USD to INR Exchange Rate")
async def get_usd_to_inr_rate(self) -> float:
"""
Retrieves the USD to INR conversion rate.

View File

@@ -1,3 +1,4 @@
from typing import Optional
from pydantic import Field, computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -23,6 +24,9 @@ class Settings(BaseSettings):
REDIS_PORT: int = Field(default=6379)
REDIS_DB: int = Field(default=0)
# Gemini
GEMINI_API_KEY: Optional[str] = Field(default=None)
@computed_field
@property
def DATABASE_URL(self) -> str:

View File

@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.repositories.job_repository import JobRepository
from app.services.job_service import JobService
from app.services.category_service import CategoryService
def get_job_repository(db: AsyncSession = Depends(get_db)) -> JobRepository:
@@ -13,3 +14,8 @@ def get_job_repository(db: AsyncSession = Depends(get_db)) -> JobRepository:
def get_job_service(repo: JobRepository = Depends(get_job_repository)) -> JobService:
"""Dependency to retrieve JobService"""
return JobService(repo)
def get_category_service(db: AsyncSession = Depends(get_db)) -> CategoryService:
"""Dependency to retrieve CategoryService"""
return CategoryService(db)

View File

@@ -18,3 +18,11 @@ class InvalidCSVException(AlemnoException):
def __init__(self, detail: str):
self.detail = detail
super().__init__(detail)
class CategoryAlreadyExistsException(AlemnoException):
"""Raised when a category already exists"""
def __init__(self, name: str):
self.name = name
super().__init__(f"Category '{name}' already exists.")

59
app/core/observability.py Normal file
View File

@@ -0,0 +1,59 @@
import functools
import inspect
import time
from contextlib import contextmanager
from typing import Optional
from app.core.logging import logger
@contextmanager
def benchmark(name: str):
"""
Context manager to benchmark a block of code.
Example:
with benchmark("Database save"):
await repo.save(data)
"""
start_time = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start_time
logger.info(f"[BENCHMARK] {name} completed in {elapsed:.4f} seconds")
def time_it(name: Optional[str] = None):
"""
Decorator to benchmark synchronous or asynchronous functions.
Example:
@time_it("Retrieve exchange rate")
async def get_rate():
...
"""
def decorator(func):
label = name or func.__name__
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return await func(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
logger.info(f"[BENCHMARK] {label} completed in {elapsed:.4f} seconds")
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
start = time.perf_counter()
try:
return func(*args, **kwargs)
finally:
elapsed = time.perf_counter() - start
logger.info(f"[BENCHMARK] {label} completed in {elapsed:.4f} seconds")
if inspect.iscoroutinefunction(func):
return async_wrapper
return sync_wrapper
return decorator

View File

@@ -2,5 +2,6 @@ from app.db.base import Base
from app.db.models.job import Job
from app.db.models.transaction import Transaction
from app.db.models.job_summary import JobSummary
from app.db.models.category import Category
__all__ = ["Base", "Job", "Transaction", "JobSummary"]
__all__ = ["Base", "Job", "Transaction", "JobSummary", "Category"]

10
app/db/models/category.py Normal file
View File

@@ -0,0 +1,10 @@
from sqlalchemy import Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base
class Category(Base):
__tablename__ = "categories"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)

View File

@@ -2,6 +2,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.v1.jobs import router as jobs_router
from app.api.v1.categories import router as categories_router
from app.core.config import settings
from app.db.base import Base
from app.db.session import engine
@@ -12,6 +13,28 @@ async def lifespan(app: FastAPI):
# Startup: Create tables if they don't exist
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Seed default categories if empty
from app.db.session import AsyncSessionLocal
from app.db.models.category import Category
from sqlalchemy import select
async with AsyncSessionLocal() as session:
result = await session.execute(select(Category))
if not result.scalars().first():
defaults = [
Category(name="Food"),
Category(name="Shopping"),
Category(name="Travel"),
Category(name="Transport"),
Category(name="Utilities"),
Category(name="Cash Withdrawal"),
Category(name="Entertainment"),
Category(name="Other"),
]
session.add_all(defaults)
await session.commit()
yield
# Shutdown: Clean up pool
await engine.dispose()
@@ -32,8 +55,9 @@ app.add_middleware(
allow_headers=["*"],
)
# Include API Router
# Include API Routers
app.include_router(jobs_router, prefix=settings.API_V1_STR)
app.include_router(categories_router, prefix=settings.API_V1_STR)
# Root healthcheck endpoint

View File

@@ -0,0 +1,28 @@
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.category import Category
class CategoryRepository:
def __init__(self, db: AsyncSession):
self.db = db
async def list_all(self) -> List[Category]:
"""List all category records ordered alphabetically by name"""
query = select(Category).order_by(Category.name.asc())
result = await self.db.execute(query)
return list(result.scalars().all())
async def create(self, name: str) -> Category:
"""Create a new category record"""
category = Category(name=name)
self.db.add(category)
await self.db.flush()
return category
async def get_by_name(self, name: str) -> Optional[Category]:
"""Fetch a category record by name (case-insensitive)"""
query = select(Category).where(Category.name.ilike(name))
result = await self.db.execute(query)
return result.scalar_one_or_none()

17
app/schemas/category.py Normal file
View File

@@ -0,0 +1,17 @@
from pydantic import BaseModel, Field, ConfigDict
class CategoryBase(BaseModel):
name: str = Field(
..., description="Unique category name", min_length=1, max_length=100
)
class CategoryCreate(CategoryBase):
pass
class CategoryResponse(CategoryBase):
id: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -0,0 +1,26 @@
from typing import List
from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories.category_repository import CategoryRepository
from app.db.models.category import Category
from app.core.exceptions import CategoryAlreadyExistsException
class CategoryService:
def __init__(self, db: AsyncSession):
self.db = db
self.repo = CategoryRepository(db)
async def list_categories(self) -> List[Category]:
"""Returns all categories alphabetical list"""
return await self.repo.list_all()
async def create_category(self, name: str) -> Category:
"""Creates a new category, validating uniqueness first"""
name_clean = name.strip()
existing = await self.repo.get_by_name(name_clean)
if existing:
raise CategoryAlreadyExistsException(name_clean)
category = await self.repo.create(name_clean)
await self.db.commit()
return category

View File

@@ -6,10 +6,14 @@ from app.utils.csv_parser import parse_and_validate_csv
from app.worker import process_transaction_job
from app.core.observability import time_it
class JobService:
def __init__(self, repo: JobRepository):
self.repo = repo
@time_it("Upload CSV Service")
async def upload_csv(self, filename: str, content: bytes) -> Job:
parsed_transactions = parse_and_validate_csv(content)
@@ -19,17 +23,20 @@ class JobService:
return job
@time_it("Get Job Status Service")
async def get_job_status(self, job_id: str) -> Job:
job = await self.repo.get_by_id(job_id)
if not job:
raise JobNotFoundException(job_id)
return job
@time_it("Get Job Results Service")
async def get_job_results(self, job_id: str) -> Job:
job = await self.repo.get_by_id(job_id)
if not job:
raise JobNotFoundException(job_id)
return job
@time_it("List Jobs Service")
async def list_jobs(self, status: Optional[str] = None) -> List[Job]:
return await self.repo.list_all(status=status)

View File

@@ -4,9 +4,12 @@ from datetime import datetime
from typing import Any, Dict, List
from app.core.exceptions import InvalidCSVException
from app.core.observability import time_it
REQUIRED_COLUMNS = {"txn_id", "date", "merchant", "amount"}
@time_it("Parse and Validate CSV")
def parse_and_validate_csv(content: bytes) -> List[Dict[str, Any]]:
"""
Parses CSV content from bytes, validates headers, and checks field types.
@@ -75,14 +78,25 @@ def parse_and_validate_csv(content: bytes) -> List[Dict[str, Any]]:
else None
)
if not all([txn_id, date_str, amount_str, merchant]):
if not all([date_str, amount_str, merchant]):
raise InvalidCSVException(
f"Row {line_num}: Empty values are not allowed in required fields"
f"Row {line_num}: Empty values are not allowed in required fields (date, amount, merchant)"
)
# Auto-generate txn_id if missing or empty
if not txn_id or not txn_id.strip():
import uuid
txn_id = f"GEN_{uuid.uuid4().hex[:8].upper()}"
else:
txn_id = txn_id.strip()
# Validate amount
cleaned_amount_str = (
amount_str.strip().replace("$", "").replace("", "").replace(",", "")
)
try:
amount = float(amount_str.strip())
amount = float(cleaned_amount_str)
except ValueError as e:
raise InvalidCSVException(
f"Row {line_num}: Invalid amount '{amount_str}'. Must be a number."

View File

@@ -1,7 +1,13 @@
import asyncio
import json
import os
import statistics
from datetime import datetime, date
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional
from sqlalchemy import select
from celery import Celery
from pydantic import BaseModel, Field, AliasChoices
from google import genai
from app.core.config import settings
from app.core.logging import logger
from app.db.session import AsyncSessionLocal
@@ -9,10 +15,215 @@ from app.repositories.job_repository import JobRepository
from app.db.models.transaction import Transaction
from app.db.models.job_summary import JobSummary
from app.clients.exchange_rate_client import ExchangeRateClient
from app.core.observability import time_it, benchmark
exchange_rate_client = ExchangeRateClient()
def clean_json_response(text: str) -> str:
"""Extracts the first JSON object or array from the text, handling markdown fences and conversational prefix/suffix."""
text = text.strip()
start_idx = -1
for i, char in enumerate(text):
if char in ("{", "["):
start_idx = i
break
end_idx = -1
for i in range(len(text) - 1, -1, -1):
if text[i] in ("}", "]"):
end_idx = i
break
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
return text[start_idx : end_idx + 1]
return text
class TransactionClassification(BaseModel):
txn_id: str = Field(
description="The unique transaction ID (txn_id) from the input."
)
category: str = Field(
description="Assigned category matching one of the allowed categories list."
)
class BatchClassificationResponse(BaseModel):
classifications: List[TransactionClassification]
@time_it("LLM Batch Classification Request")
async def classify_transactions_batch(
client: genai.Client,
batch_txns: List[Dict[str, Any]],
allowed_categories: List[str],
) -> Dict[str, Dict[str, Any]]:
"""
Calls the Gemini model to classify a batch of transactions.
Implements up to 3 retries (4 attempts total) with exponential backoff.
Returns a dictionary mapping txn_id to classification results.
"""
# Format the input for the LLM
prompt_data = []
for t in batch_txns:
prompt_data.append(
{
"txn_id": t["txn_id"],
"merchant": t["merchant"],
"amount": t["amount"],
"currency": t.get("currency", "INR"),
}
)
input_prompt = (
"Classify the following financial transactions. For each transaction, match its txn_id "
f"and assign one of the allowed categories: {', '.join(allowed_categories)} based on the merchant name and details:\n\n"
f"{json.dumps(prompt_data, indent=2)}"
)
for attempt in range(1, 5):
try:
logger.info(
f"Sending batch of {len(batch_txns)} transactions to LLM (Attempt {attempt})..."
)
loop = asyncio.get_running_loop()
# Wrap synchronous genai SDK call in executor
interaction = await loop.run_in_executor(
None,
lambda: client.interactions.create(
model="gemini-2.5-flash-lite",
input=input_prompt,
response_format={
"type": "text",
"mime_type": "application/json",
"schema": BatchClassificationResponse.model_json_schema(),
},
),
)
clean_text = clean_json_response(interaction.output_text)
resp_obj = BatchClassificationResponse.model_validate_json(clean_text)
results = {}
for item in resp_obj.classifications:
item_json = json.dumps(item.model_dump())
results[item.txn_id] = {
"category": item.category,
"raw_response": item_json,
"failed": False,
}
return results
except Exception as e:
logger.warning(f"LLM Classification attempt {attempt} failed: {e}")
if attempt == 4:
break
sleep_seconds = 2**attempt
await asyncio.sleep(sleep_seconds)
logger.error(
"LLM Classification completely failed after all retries. Falling back to default."
)
results = {}
for t in batch_txns:
results[t["txn_id"]] = {
"category": "Other",
"raw_response": None,
"failed": True,
}
return results
class JobNarrativeSummary(BaseModel):
total_spend_by_currency: Dict[str, float] = Field(
validation_alias=AliasChoices(
"total_spend_by_currency", "spend_by_currency", "total_spend"
),
description="Total spend grouped by original currency",
)
top_3_merchants: Dict[str, float] = Field(
validation_alias=AliasChoices("top_3_merchants", "top_merchants", "top_3"),
description="Top 3 merchants by spend volume (value in USD equivalent)",
)
anomaly_count: int = Field(
validation_alias=AliasChoices(
"anomaly_count", "total_anomalies", "anomalies", "anomaly_transactions"
),
description="Total number of anomaly transactions flagged in the dataset",
)
spending_narrative: str = Field(
validation_alias=AliasChoices("spending_narrative", "narrative", "summary"),
description="A cohesive 2-3 sentence spending narrative",
)
risk_level: str = Field(
validation_alias=AliasChoices("risk_level", "overall_risk_level", "risk"),
description="Overall risk level: Low, Medium, or High",
)
@time_it("LLM Job Narrative Summary Request")
async def generate_job_summary_llm(
client: genai.Client,
db_transactions: List[Transaction],
) -> Optional[JobNarrativeSummary]:
"""
Calls the Gemini model to produce a structured JSON summary:
total spend by currency, top 3 merchants, anomaly count, a 2-3 sentence spending narrative, and a risk level.
"""
# Build a simplified list of transactions for context
tx_data = [
{
"merchant": t.merchant,
"amount": t.amount,
"currency": t.currency,
"category": t.category,
"is_anomaly": t.is_anomaly,
"anomaly_reason": t.anomaly_reason,
}
for t in db_transactions
]
input_prompt = (
"Analyze the following list of processed financial transactions and generate a structured JSON summary. "
"The returned JSON MUST contain exactly the following keys:\n"
' - "total_spend_by_currency": dictionary mapping currency symbol/code to total amount spent (e.g. {"USD": 150.0, "INR": 25000.0})\n'
' - "top_3_merchants": dictionary mapping the top 3 merchants by spend to their total spend value in USD equivalent (assuming 83 INR per USD for conversion)\n'
' - "anomaly_count": integer showing the total count of flagged anomaly transactions (is_anomaly=True)\n'
' - "spending_narrative": string containing a cohesive 2-3 sentence narrative summarizing key spending insights and largest merchants\n'
' - "risk_level": string showing the overall risk level of the job: "Low", "Medium", or "High" (pick one based on the nature of anomalies)\n\n'
f"{json.dumps(tx_data, indent=2)}"
)
for attempt in range(1, 4):
try:
logger.info(
f"Calling LLM to generate job narrative summary (Attempt {attempt})..."
)
loop = asyncio.get_running_loop()
interaction = await loop.run_in_executor(
None,
lambda: client.interactions.create(
model="gemini-2.5-flash-lite",
input=input_prompt,
response_format={
"type": "text",
"mime_type": "application/json",
"schema": JobNarrativeSummary.model_json_schema(),
},
),
)
clean_text = clean_json_response(interaction.output_text)
resp_obj = JobNarrativeSummary.model_validate_json(clean_text)
return resp_obj
except Exception as e:
logger.warning(f"Failed to generate LLM summary (Attempt {attempt}): {e}")
if attempt == 3:
break
await asyncio.sleep(2)
return None
# Define Celery app
celery_app = Celery(
"alemno_worker",
@@ -32,7 +243,7 @@ celery_app.conf.update(
def run_async(coro):
"""Helper to run async functions synchronously in Celery worker context"""
return asyncio.get_event_loop().run_until_complete(coro)
return asyncio.run(coro)
@celery_app.task(name="tasks.process_transaction_job")
@@ -45,19 +256,26 @@ def process_transaction_job(job_id: str, transactions: List[Dict[str, Any]]):
return run_async(_process_job_async(job_id, transactions))
@time_it("Process Job Background Task")
async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
async with AsyncSessionLocal() as db:
repo = JobRepository(db)
job = await repo.get_by_id(job_id)
if not job:
logger.error(f"Job with ID {job_id} not found in database.")
return
from app.db.session import engine
with benchmark("Dispose Engine"):
await engine.dispose()
async with AsyncSessionLocal() as db:
with benchmark("Initial Job Setup"):
repo = JobRepository(db)
job = await repo.get_by_id(job_id)
if not job:
logger.error(f"Job with ID {job_id} not found in database.")
return
try:
# Update status to processing
await repo.update(job, status="processing")
await db.commit()
try:
db_transactions = []
total_spend_inr = 0.0
total_spend_usd = 0.0
@@ -66,6 +284,225 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
USD_TO_INR = await exchange_rate_client.get_usd_to_inr_rate()
with benchmark("Compute Account History Medians"):
# 1. Fetch existing transaction amounts and currencies for all accounts in this job to calculate median
account_ids = {
t.get("account_id") for t in transactions if t.get("account_id")
}
existing_amounts_inr = {}
if account_ids:
stmt = select(
Transaction.account_id, Transaction.amount, Transaction.currency
).where(Transaction.account_id.in_(account_ids))
res = await db.execute(stmt)
for acc_id, amt, curr in res.all():
if acc_id not in existing_amounts_inr:
existing_amounts_inr[acc_id] = []
# Standardize to INR for median calculation
amt_inr = amt * USD_TO_INR if curr.upper() == "USD" else amt
existing_amounts_inr[acc_id].append(amt_inr)
# Group incoming transaction amounts standardized to INR
incoming_amounts_inr = {}
for t in transactions:
acc_id = t.get("account_id")
if acc_id:
if acc_id not in incoming_amounts_inr:
incoming_amounts_inr[acc_id] = []
amt = float(t["amount"])
curr = t.get("currency", "INR").strip().upper()
amt_inr = amt * USD_TO_INR if curr == "USD" else amt
incoming_amounts_inr[acc_id].append(amt_inr)
# Precalculate median of (existing + incoming) transactions in INR
account_medians_inr = {}
for acc_id in account_ids:
all_amts = existing_amounts_inr.get(
acc_id, []
) + incoming_amounts_inr.get(acc_id, [])
if all_amts:
account_medians_inr[acc_id] = statistics.median(all_amts)
# 2. Call LLM to classify transactions without a category
# Find all uncategorized incoming transactions
uncategorized_txns = []
for t in transactions:
cat = t.get("category")
if not cat or cat.strip().lower() in {"", "general", "other", "none"}:
uncategorized_txns.append(t)
llm_results = {}
api_key = settings.GEMINI_API_KEY or os.environ.get("GEMINI_API_KEY")
if uncategorized_txns:
# Load allowed categories from the database categories table
from app.db.models.category import Category
try:
res_cats = await db.execute(select(Category.name))
allowed_categories = [r[0] for r in res_cats.all()]
except Exception as db_err:
logger.warning(f"Could not load categories from database: {db_err}")
allowed_categories = []
if not allowed_categories:
allowed_categories = [
"Food",
"Shopping",
"Travel",
"Transport",
"Utilities",
"Cash Withdrawal",
"Entertainment",
"Other",
]
if api_key:
logger.info(
f"Found {len(uncategorized_txns)} uncategorized transactions. Initializing GenAI client..."
)
try:
client = genai.Client(api_key=api_key)
# Split into batches of 50 and fire all concurrently
batch_size = 50
batches = [
uncategorized_txns[i : i + batch_size]
for i in range(0, len(uncategorized_txns), batch_size)
]
logger.info(
f"Dispatching {len(batches)} classification batch(es) concurrently..."
)
with benchmark(
"LLM Concurrent Batch Classification (all batches)"
):
batch_results_list = await asyncio.gather(
*[
classify_transactions_batch(
client, batch, allowed_categories
)
for batch in batches
],
return_exceptions=True,
)
for batch_result in batch_results_list:
if isinstance(batch_result, Exception):
logger.error(
f"A classification batch failed: {batch_result}"
)
else:
llm_results.update(batch_result)
except Exception as e:
logger.error(
f"Failed to initialize GenAI Client or classify: {e}"
)
# Fallback for all
for t in uncategorized_txns:
llm_results[t["txn_id"]] = {
"category": "Other",
"raw_response": None,
"failed": True,
}
else:
logger.warning(
"GEMINI_API_KEY is not set. Defaulting to local mock categorization."
)
# Mock categorization mapping based on merchant name keywords
for t in uncategorized_txns:
merchant_lower = t["merchant"].lower()
cat = (
"Other"
if "Other" in allowed_categories
else (
allowed_categories[0] if allowed_categories else "Other"
)
)
if (
any(
kw in merchant_lower
for kw in [
"swiggy",
"zomato",
"restaurant",
"cafe",
"food",
]
)
and "Food" in allowed_categories
):
cat = "Food"
elif (
any(
kw in merchant_lower
for kw in ["ola", "uber", "taxi", "metro", "train"]
)
and "Transport" in allowed_categories
):
cat = "Transport"
elif (
any(
kw in merchant_lower
for kw in [
"amazon",
"flipkart",
"shopping",
"store",
"supermarket",
]
)
and "Shopping" in allowed_categories
):
cat = "Shopping"
elif (
any(
kw in merchant_lower
for kw in ["air", "flight", "hotel", "travel", "irctc"]
)
and "Travel" in allowed_categories
):
cat = "Travel"
elif (
any(
kw in merchant_lower
for kw in [
"electric",
"power",
"water",
"bill",
"utility",
"utilities",
]
)
and "Utilities" in allowed_categories
):
cat = "Utilities"
elif (
any(
kw in merchant_lower
for kw in ["atm", "cash", "withdrawal"]
)
and "Cash Withdrawal" in allowed_categories
):
cat = "Cash Withdrawal"
elif (
any(
kw in merchant_lower
for kw in [
"netflix",
"spotify",
"movie",
"show",
"entertainment",
]
)
and "Entertainment" in allowed_categories
):
cat = "Entertainment"
llm_results[t["txn_id"]] = {
"category": cat,
"raw_response": f'{{"category": "{cat}", "confidence": 0.8, "status": "mocked_offline"}}',
"failed": False,
}
for t in transactions:
txn_id = t["txn_id"]
raw_date = t["date"]
@@ -78,7 +515,7 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
merchant = t["merchant"].strip()
amount = float(t["amount"])
currency = t.get("currency", "INR").strip().upper()
category = t.get("category", "General")
category = t.get("category") or ""
account_id = t.get("account_id")
# Spend calculation and currency conversion
@@ -95,28 +532,62 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
# Aggregate merchant spend (in USD for standardization)
merchant_spends[merchant] = merchant_spends.get(merchant, 0.0) + amt_usd
# Heuristic Anomaly detection
is_anomaly = False
anomaly_reason = None
# Anomaly detection checks
reasons = []
# A. 3x Median Outlier Check
if account_id and account_id in account_medians_inr:
median_inr = account_medians_inr[account_id]
if amt_inr > 3.0 * median_inr:
reasons.append(
"Statistical outlier (amount exceeds 3x account median)"
)
# B. Domestic Brand transacted in USD check
domestic_brands = {"swiggy", "ola", "irctc"}
merchant_lower = merchant.lower()
if currency == "USD" and any(
brand in merchant_lower for brand in domestic_brands
):
reasons.append("Domestic brand transacted in USD")
# C. High value transaction check
if amt_usd > 5000.0:
is_anomaly = True
anomaly_reason = "High value transaction (> $5,000 USD equivalent)"
else:
desc_lower = merchant.lower()
if any(
k in desc_lower
for k in ["suspend", "hack", "error", "unknown", "fraud"]
):
is_anomaly = True
anomaly_reason = "Suspicious merchant keyword"
reasons.append("High value transaction (> $5,000 USD equivalent)")
# D. Suspicious keyword check
desc_lower = merchant.lower()
if any(
k in desc_lower
for k in ["suspend", "hack", "error", "unknown", "fraud"]
):
reasons.append("Suspicious merchant keyword")
is_anomaly = len(reasons) > 0
anomaly_reason = "; ".join(reasons) if is_anomaly else None
if is_anomaly:
anomaly_count += 1
# Mock
llm_failed = False
llm_category = category.capitalize() if category else "General"
llm_raw_response = f'{{"category": "{llm_category}", "confidence": 0.95, "status": "processed"}}'
# Check if it was uncategorized initially and assign LLM category
cat_lower = category.strip().lower()
is_uncategorized = not cat_lower or cat_lower in {
"",
"general",
"other",
"none",
}
if is_uncategorized and txn_id in llm_results:
classification = llm_results[txn_id]
llm_category = classification["category"].strip()
llm_raw_response = classification["raw_response"]
llm_failed = classification["failed"]
category = llm_category
else:
llm_category = category.strip() if category else "Other"
llm_raw_response = None
llm_failed = False
db_txn = Transaction(
job_id=job_id,
@@ -136,55 +607,84 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
)
db_transactions.append(db_txn)
await repo.add_transactions(db_transactions)
with benchmark("Save Cleaned Transactions to DB"):
await repo.add_transactions(db_transactions)
top_merchant = (
max(merchant_spends, key=merchant_spends.get)
if merchant_spends
else "None"
)
risk_level = "Low"
if anomaly_count > 2:
risk_level = "High"
elif anomaly_count > 0:
risk_level = "Medium"
# Generate narrative and risk summary using LLM if api_key is available
llm_summary = None
if api_key:
try:
client = genai.Client(api_key=api_key)
llm_summary = await generate_job_summary_llm(
client, db_transactions
)
except Exception as e:
logger.error(f"Error in LLM summary generation: {e}")
narrative = (
f"Successfully parsed and clean-processed {len(transactions)} transaction records. "
f"Total spend is INR {total_spend_inr:,.2f} (${total_spend_usd:,.2f} USD). "
f"Top merchant by spend volume is '{top_merchant}' with total of ${merchant_spends.get(top_merchant, 0):,.2f} USD. "
f"Scan flagged {anomaly_count} potential anomalies, resulting in a overall risk level of '{risk_level}'."
)
if llm_summary:
# Normalize risk level value to match capitalized Low/Medium/High in our DB
risk_level_str = llm_summary.risk_level.strip().lower()
if risk_level_str == "high":
risk_level = "High"
elif risk_level_str == "medium":
risk_level = "Medium"
else:
risk_level = "Low"
# Build top merchants dict sorted by spend limit (keep top 5)
sorted_merchants = sorted(
merchant_spends.items(), key=lambda x: x[1], reverse=True
)[:5]
top_merchants_json = {
merchant: round(spend, 2) for merchant, spend in sorted_merchants
}
narrative = llm_summary.spending_narrative.strip()
top_merchants_json = {
merchant: round(spend, 2)
for merchant, spend in llm_summary.top_3_merchants.items()
}
anomaly_count = llm_summary.anomaly_count
else:
top_merchant = (
max(merchant_spends, key=merchant_spends.get)
if merchant_spends
else "None"
)
risk_level = "Low"
if anomaly_count > 2:
risk_level = "High"
elif anomaly_count > 0:
risk_level = "Medium"
narrative = (
f"Successfully parsed and clean-processed {len(transactions)} transaction records. "
f"Total spend is INR {total_spend_inr:,.2f} (${total_spend_usd:,.2f} USD). "
f"Top merchant by spend volume is '{top_merchant}' with total of ${merchant_spends.get(top_merchant, 0):,.2f} USD. "
f"Scan flagged {anomaly_count} potential anomalies, resulting in a overall risk level of '{risk_level}'."
)
sorted_merchants = sorted(
merchant_spends.items(), key=lambda x: x[1], reverse=True
)[:3]
top_merchants_json = {
merchant: round(spend, 2) for merchant, spend in sorted_merchants
}
# Create job summary
summary = JobSummary(
job_id=job_id,
total_spend_inr=round(total_spend_inr, 2),
total_spend_usd=round(total_spend_usd, 2),
top_merchants=top_merchants_json,
anomaly_count=anomaly_count,
narrative=narrative,
risk_level=risk_level,
)
await repo.add_summary(summary)
with benchmark("Save Summary and Complete Job"):
summary = JobSummary(
job_id=job_id,
total_spend_inr=round(total_spend_inr, 2),
total_spend_usd=round(total_spend_usd, 2),
top_merchants=top_merchants_json,
anomaly_count=anomaly_count,
narrative=narrative,
risk_level=risk_level,
)
await repo.add_summary(summary)
# Update job state
await repo.update(
job,
status="completed",
row_count_raw=len(transactions),
row_count_clean=len(db_transactions),
completed_at=datetime.utcnow(),
)
await db.commit()
# Update job state
await repo.update(
job,
status="completed",
row_count_raw=len(transactions),
row_count_clean=len(db_transactions),
completed_at=datetime.utcnow(),
)
await db.commit()
logger.info(f"Job {job_id} processing completed successfully.")
except Exception as e:

896
coverage.xml Normal file
View File

@@ -0,0 +1,896 @@
<?xml version="1.0" ?>
<coverage version="7.15.0" timestamp="1783047937028" lines-valid="711" lines-covered="402" line-rate="0.5654" branches-covered="0" branches-valid="0" branch-rate="0" complexity="0">
<!-- Generated by coverage.py: https://coverage.readthedocs.io/en/7.15.0 -->
<!-- Based on https://raw.githubusercontent.com/cobertura/web/master/htdocs/xml/coverage-04.dtd -->
<sources>
<source>/home/sortedcord/Projects/alemno_payments/app</source>
</sources>
<packages>
<package name="." line-rate="0.2851" branch-rate="0" complexity="0">
<classes>
<class name="app.py" filename="app.py" complexity="0" line-rate="0" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="0"/>
<line number="3" hits="0"/>
</lines>
</class>
<class name="main.py" filename="main.py" complexity="0" line-rate="0.5333" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="7" hits="1"/>
<line number="8" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="14" hits="0"/>
<line number="15" hits="0"/>
<line number="18" hits="0"/>
<line number="19" hits="0"/>
<line number="20" hits="0"/>
<line number="22" hits="0"/>
<line number="23" hits="0"/>
<line number="24" hits="0"/>
<line number="25" hits="0"/>
<line number="35" hits="0"/>
<line number="36" hits="0"/>
<line number="38" hits="0"/>
<line number="40" hits="0"/>
<line number="43" hits="1"/>
<line number="50" hits="1"/>
<line number="59" hits="1"/>
<line number="60" hits="1"/>
<line number="64" hits="1"/>
<line number="65" hits="1"/>
<line number="66" hits="0"/>
</lines>
</class>
<class name="worker.py" filename="worker.py" complexity="0" line-rate="0.2535" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="7" hits="1"/>
<line number="8" hits="1"/>
<line number="9" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="13" hits="1"/>
<line number="14" hits="1"/>
<line number="15" hits="1"/>
<line number="16" hits="1"/>
<line number="17" hits="1"/>
<line number="19" hits="1"/>
<line number="22" hits="1"/>
<line number="23" hits="1"/>
<line number="26" hits="1"/>
<line number="31" hits="1"/>
<line number="35" hits="1"/>
<line number="46" hits="1"/>
<line number="47" hits="1"/>
<line number="48" hits="1"/>
<line number="57" hits="1"/>
<line number="63" hits="1"/>
<line number="64" hits="1"/>
<line number="65" hits="1"/>
<line number="68" hits="1"/>
<line number="70" hits="1"/>
<line number="83" hits="1"/>
<line number="87" hits="1"/>
<line number="88" hits="1"/>
<line number="89" hits="1"/>
<line number="90" hits="1"/>
<line number="95" hits="1"/>
<line number="96" hits="1"/>
<line number="97" hits="1"/>
<line number="98" hits="1"/>
<line number="99" hits="1"/>
<line number="100" hits="1"/>
<line number="101" hits="1"/>
<line number="103" hits="1"/>
<line number="106" hits="1"/>
<line number="107" hits="1"/>
<line number="108" hits="1"/>
<line number="113" hits="1"/>
<line number="117" hits="1"/>
<line number="124" hits="1"/>
<line number="133" hits="1"/>
<line number="135" hits="0"/>
<line number="138" hits="1"/>
<line number="139" hits="1"/>
<line number="144" hits="0"/>
<line number="145" hits="0"/>
<line number="148" hits="1"/>
<line number="149" hits="0"/>
<line number="151" hits="0"/>
<line number="152" hits="0"/>
<line number="153" hits="0"/>
<line number="154" hits="0"/>
<line number="155" hits="0"/>
<line number="156" hits="0"/>
<line number="157" hits="0"/>
<line number="159" hits="0"/>
<line number="161" hits="0"/>
<line number="162" hits="0"/>
<line number="164" hits="0"/>
<line number="165" hits="0"/>
<line number="166" hits="0"/>
<line number="167" hits="0"/>
<line number="168" hits="0"/>
<line number="170" hits="0"/>
<line number="173" hits="0"/>
<line number="176" hits="0"/>
<line number="177" hits="0"/>
<line number="178" hits="0"/>
<line number="181" hits="0"/>
<line number="182" hits="0"/>
<line number="183" hits="0"/>
<line number="184" hits="0"/>
<line number="186" hits="0"/>
<line number="187" hits="0"/>
<line number="190" hits="0"/>
<line number="191" hits="0"/>
<line number="192" hits="0"/>
<line number="193" hits="0"/>
<line number="194" hits="0"/>
<line number="195" hits="0"/>
<line number="196" hits="0"/>
<line number="197" hits="0"/>
<line number="198" hits="0"/>
<line number="199" hits="0"/>
<line number="202" hits="0"/>
<line number="203" hits="0"/>
<line number="204" hits="0"/>
<line number="207" hits="0"/>
<line number="208" hits="0"/>
<line number="212" hits="0"/>
<line number="213" hits="0"/>
<line number="214" hits="0"/>
<line number="215" hits="0"/>
<line number="216" hits="0"/>
<line number="218" hits="0"/>
<line number="219" hits="0"/>
<line number="220" hits="0"/>
<line number="222" hits="0"/>
<line number="224" hits="0"/>
<line number="225" hits="0"/>
<line number="226" hits="0"/>
<line number="227" hits="0"/>
<line number="228" hits="0"/>
<line number="229" hits="0"/>
<line number="231" hits="0"/>
<line number="232" hits="0"/>
<line number="243" hits="0"/>
<line number="244" hits="0"/>
<line number="247" hits="0"/>
<line number="248" hits="0"/>
<line number="250" hits="0"/>
<line number="251" hits="0"/>
<line number="252" hits="0"/>
<line number="253" hits="0"/>
<line number="256" hits="0"/>
<line number="257" hits="0"/>
<line number="258" hits="0"/>
<line number="262" hits="0"/>
<line number="263" hits="0"/>
<line number="269" hits="0"/>
<line number="273" hits="0"/>
<line number="274" hits="0"/>
<line number="275" hits="0"/>
<line number="282" hits="0"/>
<line number="295" hits="0"/>
<line number="296" hits="0"/>
<line number="303" hits="0"/>
<line number="304" hits="0"/>
<line number="317" hits="0"/>
<line number="318" hits="0"/>
<line number="325" hits="0"/>
<line number="326" hits="0"/>
<line number="340" hits="0"/>
<line number="341" hits="0"/>
<line number="348" hits="0"/>
<line number="349" hits="0"/>
<line number="362" hits="0"/>
<line number="364" hits="0"/>
<line number="370" hits="0"/>
<line number="371" hits="0"/>
<line number="372" hits="0"/>
<line number="374" hits="0"/>
<line number="379" hits="0"/>
<line number="380" hits="0"/>
<line number="381" hits="0"/>
<line number="382" hits="0"/>
<line number="383" hits="0"/>
<line number="386" hits="0"/>
<line number="387" hits="0"/>
<line number="388" hits="0"/>
<line number="390" hits="0"/>
<line number="391" hits="0"/>
<line number="393" hits="0"/>
<line number="394" hits="0"/>
<line number="397" hits="0"/>
<line number="400" hits="0"/>
<line number="403" hits="0"/>
<line number="404" hits="0"/>
<line number="405" hits="0"/>
<line number="406" hits="0"/>
<line number="411" hits="0"/>
<line number="412" hits="0"/>
<line number="413" hits="0"/>
<line number="416" hits="0"/>
<line number="419" hits="0"/>
<line number="420" hits="0"/>
<line number="423" hits="0"/>
<line number="424" hits="0"/>
<line number="428" hits="0"/>
<line number="430" hits="0"/>
<line number="431" hits="0"/>
<line number="433" hits="0"/>
<line number="434" hits="0"/>
<line number="437" hits="0"/>
<line number="438" hits="0"/>
<line number="445" hits="0"/>
<line number="446" hits="0"/>
<line number="447" hits="0"/>
<line number="448" hits="0"/>
<line number="449" hits="0"/>
<line number="450" hits="0"/>
<line number="452" hits="0"/>
<line number="453" hits="0"/>
<line number="454" hits="0"/>
<line number="456" hits="0"/>
<line number="472" hits="0"/>
<line number="474" hits="0"/>
<line number="476" hits="0"/>
<line number="481" hits="0"/>
<line number="482" hits="0"/>
<line number="483" hits="0"/>
<line number="484" hits="0"/>
<line number="485" hits="0"/>
<line number="487" hits="0"/>
<line number="495" hits="0"/>
<line number="498" hits="0"/>
<line number="503" hits="0"/>
<line number="512" hits="0"/>
<line number="515" hits="0"/>
<line number="522" hits="0"/>
<line number="523" hits="0"/>
<line number="525" hits="0"/>
<line number="526" hits="0"/>
<line number="527" hits="0"/>
<line number="533" hits="0"/>
<line number="534" hits="0"/>
</lines>
</class>
</classes>
</package>
<package name="api.v1" line-rate="0.52" branch-rate="0" complexity="0">
<classes>
<class name="categories.py" filename="api/v1/categories.py" complexity="0" line-rate="0.6875" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="8" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="18" hits="0"/>
<line number="21" hits="1"/>
<line number="22" hits="1"/>
<line number="29" hits="0"/>
<line number="30" hits="0"/>
<line number="31" hits="0"/>
<line number="32" hits="0"/>
</lines>
</class>
<class name="jobs.py" filename="api/v1/jobs.py" complexity="0" line-rate="0.4412" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="8" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="20" hits="0"/>
<line number="21" hits="0"/>
<line number="26" hits="0"/>
<line number="27" hits="0"/>
<line number="28" hits="0"/>
<line number="29" hits="0"/>
<line number="30" hits="0"/>
<line number="31" hits="0"/>
<line number="37" hits="1"/>
<line number="38" hits="1"/>
<line number="46" hits="0"/>
<line number="47" hits="0"/>
<line number="48" hits="0"/>
<line number="49" hits="0"/>
<line number="50" hits="0"/>
<line number="56" hits="1"/>
<line number="57" hits="1"/>
<line number="65" hits="0"/>
<line number="66" hits="0"/>
<line number="67" hits="0"/>
<line number="68" hits="0"/>
<line number="69" hits="0"/>
<line number="75" hits="1"/>
<line number="76" hits="1"/>
<line number="84" hits="0"/>
</lines>
</class>
</classes>
</package>
<package name="clients" line-rate="0.4186" branch-rate="0" complexity="0">
<classes>
<class name="exchange_rate_client.py" filename="clients/exchange_rate_client.py" complexity="0" line-rate="0.4186" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="7" hits="1"/>
<line number="8" hits="1"/>
<line number="9" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="1"/>
<line number="13" hits="1"/>
<line number="19" hits="0"/>
<line number="20" hits="0"/>
<line number="21" hits="0"/>
<line number="23" hits="1"/>
<line number="30" hits="1"/>
<line number="31" hits="1"/>
<line number="32" hits="1"/>
<line number="33" hits="1"/>
<line number="34" hits="1"/>
<line number="35" hits="1"/>
<line number="36" hits="0"/>
<line number="37" hits="0"/>
<line number="40" hits="0"/>
<line number="41" hits="0"/>
<line number="42" hits="0"/>
<line number="43" hits="0"/>
<line number="44" hits="0"/>
<line number="45" hits="0"/>
<line number="46" hits="0"/>
<line number="47" hits="0"/>
<line number="48" hits="0"/>
<line number="49" hits="0"/>
<line number="50" hits="0"/>
<line number="54" hits="0"/>
<line number="55" hits="0"/>
<line number="56" hits="0"/>
<line number="59" hits="0"/>
<line number="60" hits="0"/>
<line number="63" hits="0"/>
<line number="64" hits="0"/>
<line number="65" hits="0"/>
<line number="70" hits="0"/>
</lines>
</class>
</classes>
</package>
<package name="core" line-rate="0.8814" branch-rate="0" complexity="0">
<classes>
<class name="config.py" filename="core/config.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="6" hits="1"/>
<line number="7" hits="1"/>
<line number="12" hits="1"/>
<line number="13" hits="1"/>
<line number="16" hits="1"/>
<line number="17" hits="1"/>
<line number="18" hits="1"/>
<line number="19" hits="1"/>
<line number="20" hits="1"/>
<line number="23" hits="1"/>
<line number="24" hits="1"/>
<line number="25" hits="1"/>
<line number="28" hits="1"/>
<line number="30" hits="1"/>
<line number="31" hits="1"/>
<line number="32" hits="1"/>
<line number="33" hits="1"/>
<line number="40" hits="1"/>
<line number="41" hits="1"/>
<line number="42" hits="1"/>
<line number="43" hits="1"/>
<line number="45" hits="1"/>
<line number="46" hits="1"/>
<line number="47" hits="1"/>
<line number="48" hits="1"/>
<line number="51" hits="1"/>
</lines>
</class>
<class name="dependencies.py" filename="core/dependencies.py" complexity="0" line-rate="0.75" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="9" hits="1"/>
<line number="11" hits="0"/>
<line number="14" hits="1"/>
<line number="16" hits="0"/>
<line number="19" hits="1"/>
<line number="21" hits="0"/>
</lines>
</class>
<class name="exceptions.py" filename="core/exceptions.py" complexity="0" line-rate="0.7143" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="4" hits="1"/>
<line number="7" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="0"/>
<line number="12" hits="0"/>
<line number="15" hits="1"/>
<line number="18" hits="1"/>
<line number="19" hits="1"/>
<line number="20" hits="1"/>
<line number="23" hits="1"/>
<line number="26" hits="1"/>
<line number="27" hits="0"/>
<line number="28" hits="0"/>
</lines>
</class>
<class name="logging.py" filename="core/logging.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="5" hits="1"/>
<line number="11" hits="1"/>
</lines>
</class>
</classes>
</package>
<package name="db" line-rate="0.5294" branch-rate="0" complexity="0">
<classes>
<class name="base.py" filename="db/base.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
</lines>
</class>
<class name="session.py" filename="db/session.py" complexity="0" line-rate="0.4286" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="6" hits="1"/>
<line number="15" hits="1"/>
<line number="22" hits="1"/>
<line number="24" hits="0"/>
<line number="25" hits="0"/>
<line number="26" hits="0"/>
<line number="27" hits="0"/>
<line number="28" hits="0"/>
<line number="29" hits="0"/>
<line number="30" hits="0"/>
<line number="32" hits="0"/>
</lines>
</class>
</classes>
</package>
<package name="db.models" line-rate="1" branch-rate="0" complexity="0">
<classes>
<class name="__init__.py" filename="db/models/__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="7" hits="1"/>
</lines>
</class>
<class name="category.py" filename="db/models/category.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="6" hits="1"/>
<line number="7" hits="1"/>
<line number="9" hits="1"/>
<line number="10" hits="1"/>
</lines>
</class>
<class name="job.py" filename="db/models/job.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="7" hits="1"/>
<line number="14" hits="1"/>
<line number="15" hits="1"/>
<line number="17" hits="1"/>
<line number="23" hits="1"/>
<line number="24" hits="1"/>
<line number="29" hits="1"/>
<line number="30" hits="1"/>
<line number="31" hits="1"/>
<line number="36" hits="1"/>
<line number="40" hits="1"/>
<line number="46" hits="1"/>
<line number="51" hits="1"/>
</lines>
</class>
<class name="job_summary.py" filename="db/models/job_summary.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="12" hits="1"/>
<line number="13" hits="1"/>
<line number="15" hits="1"/>
<line number="21" hits="1"/>
<line number="28" hits="1"/>
<line number="29" hits="1"/>
<line number="30" hits="1"/>
<line number="33" hits="1"/>
<line number="34" hits="1"/>
<line number="35" hits="1"/>
<line number="38" hits="1"/>
</lines>
</class>
<class name="transaction.py" filename="db/models/transaction.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="7" hits="1"/>
<line number="13" hits="1"/>
<line number="14" hits="1"/>
<line number="16" hits="1"/>
<line number="22" hits="1"/>
<line number="28" hits="1"/>
<line number="29" hits="1"/>
<line number="30" hits="1"/>
<line number="31" hits="1"/>
<line number="32" hits="1"/>
<line number="33" hits="1"/>
<line number="34" hits="1"/>
<line number="35" hits="1"/>
<line number="36" hits="1"/>
<line number="37" hits="1"/>
<line number="38" hits="1"/>
<line number="39" hits="1"/>
<line number="40" hits="1"/>
<line number="43" hits="1"/>
</lines>
</class>
</classes>
</package>
<package name="repositories" line-rate="0.4262" branch-rate="0" complexity="0">
<classes>
<class name="__init__.py" filename="repositories/__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="3" hits="1"/>
</lines>
</class>
<class name="category_repository.py" filename="repositories/category_repository.py" complexity="0" line-rate="0.45" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="7" hits="1"/>
<line number="8" hits="1"/>
<line number="9" hits="0"/>
<line number="11" hits="1"/>
<line number="13" hits="0"/>
<line number="14" hits="0"/>
<line number="15" hits="0"/>
<line number="17" hits="1"/>
<line number="19" hits="0"/>
<line number="20" hits="0"/>
<line number="21" hits="0"/>
<line number="22" hits="0"/>
<line number="24" hits="1"/>
<line number="26" hits="0"/>
<line number="27" hits="0"/>
<line number="28" hits="0"/>
</lines>
</class>
<class name="job_repository.py" filename="repositories/job_repository.py" complexity="0" line-rate="0.3846" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="7" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="12" hits="0"/>
<line number="14" hits="1"/>
<line number="16" hits="0"/>
<line number="17" hits="0"/>
<line number="18" hits="0"/>
<line number="19" hits="0"/>
<line number="21" hits="1"/>
<line number="23" hits="0"/>
<line number="28" hits="0"/>
<line number="29" hits="0"/>
<line number="31" hits="1"/>
<line number="33" hits="0"/>
<line number="34" hits="0"/>
<line number="35" hits="0"/>
<line number="36" hits="0"/>
<line number="37" hits="0"/>
<line number="38" hits="0"/>
<line number="40" hits="1"/>
<line number="42" hits="0"/>
<line number="43" hits="0"/>
<line number="44" hits="0"/>
<line number="45" hits="0"/>
<line number="46" hits="0"/>
<line number="48" hits="1"/>
<line number="50" hits="0"/>
<line number="51" hits="0"/>
<line number="53" hits="1"/>
<line number="55" hits="0"/>
<line number="56" hits="0"/>
<line number="57" hits="0"/>
</lines>
</class>
</classes>
</package>
<package name="schemas" line-rate="1" branch-rate="0" complexity="0">
<classes>
<class name="__init__.py" filename="schemas/__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="10" hits="1"/>
</lines>
</class>
<class name="category.py" filename="schemas/category.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="1"/>
<line number="14" hits="1"/>
<line number="17" hits="1"/>
</lines>
</class>
<class name="job.py" filename="schemas/job.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="6" hits="1"/>
<line number="7" hits="1"/>
<line number="16" hits="1"/>
<line number="17" hits="1"/>
<line number="18" hits="1"/>
<line number="20" hits="1"/>
<line number="21" hits="1"/>
<line number="22" hits="1"/>
<line number="23" hits="1"/>
<line number="26" hits="1"/>
<line number="27" hits="1"/>
<line number="35" hits="1"/>
<line number="36" hits="1"/>
<line number="39" hits="1"/>
<line number="43" hits="1"/>
<line number="44" hits="1"/>
<line number="47" hits="1"/>
<line number="48" hits="1"/>
<line number="52" hits="1"/>
<line number="53" hits="1"/>
<line number="55" hits="1"/>
<line number="56" hits="1"/>
<line number="59" hits="1"/>
<line number="60" hits="1"/>
<line number="64" hits="1"/>
<line number="67" hits="1"/>
<line number="68" hits="1"/>
<line number="72" hits="1"/>
<line number="73" hits="1"/>
</lines>
</class>
</classes>
</package>
<package name="services" line-rate="0.4894" branch-rate="0" complexity="0">
<classes>
<class name="__init__.py" filename="services/__init__.py" complexity="0" line-rate="1" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="3" hits="1"/>
</lines>
</class>
<class name="category_service.py" filename="services/category_service.py" complexity="0" line-rate="0.4737" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="8" hits="1"/>
<line number="9" hits="1"/>
<line number="10" hits="0"/>
<line number="11" hits="0"/>
<line number="13" hits="1"/>
<line number="15" hits="0"/>
<line number="17" hits="1"/>
<line number="19" hits="0"/>
<line number="20" hits="0"/>
<line number="21" hits="0"/>
<line number="22" hits="0"/>
<line number="24" hits="0"/>
<line number="25" hits="0"/>
<line number="26" hits="0"/>
</lines>
</class>
<class name="job_service.py" filename="services/job_service.py" complexity="0" line-rate="0.4615" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="6" hits="1"/>
<line number="9" hits="1"/>
<line number="10" hits="1"/>
<line number="11" hits="0"/>
<line number="13" hits="1"/>
<line number="14" hits="0"/>
<line number="16" hits="0"/>
<line number="18" hits="0"/>
<line number="20" hits="0"/>
<line number="22" hits="1"/>
<line number="23" hits="0"/>
<line number="24" hits="0"/>
<line number="25" hits="0"/>
<line number="26" hits="0"/>
<line number="28" hits="1"/>
<line number="29" hits="0"/>
<line number="30" hits="0"/>
<line number="31" hits="0"/>
<line number="32" hits="0"/>
<line number="34" hits="1"/>
<line number="35" hits="0"/>
</lines>
</class>
</classes>
</package>
<package name="utils" line-rate="0.8857" branch-rate="0" complexity="0">
<classes>
<class name="csv_parser.py" filename="utils/csv_parser.py" complexity="0" line-rate="0.8857" branch-rate="0">
<methods/>
<lines>
<line number="1" hits="1"/>
<line number="2" hits="1"/>
<line number="3" hits="1"/>
<line number="4" hits="1"/>
<line number="5" hits="1"/>
<line number="7" hits="1"/>
<line number="10" hits="1"/>
<line number="16" hits="1"/>
<line number="17" hits="1"/>
<line number="18" hits="0"/>
<line number="19" hits="0"/>
<line number="21" hits="1"/>
<line number="22" hits="1"/>
<line number="24" hits="1"/>
<line number="25" hits="0"/>
<line number="27" hits="1"/>
<line number="28" hits="1"/>
<line number="29" hits="1"/>
<line number="30" hits="0"/>
<line number="31" hits="1"/>
<line number="34" hits="1"/>
<line number="35" hits="1"/>
<line number="36" hits="1"/>
<line number="37" hits="1"/>
<line number="38" hits="1"/>
<line number="39" hits="1"/>
<line number="40" hits="1"/>
<line number="41" hits="1"/>
<line number="42" hits="1"/>
<line number="43" hits="1"/>
<line number="44" hits="1"/>
<line number="45" hits="1"/>
<line number="46" hits="1"/>
<line number="47" hits="1"/>
<line number="50" hits="1"/>
<line number="51" hits="1"/>
<line number="52" hits="1"/>
<line number="54" hits="1"/>
<line number="55" hits="1"/>
<line number="56" hits="1"/>
<line number="57" hits="1"/>
<line number="58" hits="1"/>
<line number="59" hits="1"/>
<line number="62" hits="1"/>
<line number="67" hits="1"/>
<line number="72" hits="1"/>
<line number="78" hits="1"/>
<line number="79" hits="0"/>
<line number="84" hits="1"/>
<line number="85" hits="0"/>
<line number="87" hits="0"/>
<line number="89" hits="1"/>
<line number="92" hits="1"/>
<line number="95" hits="1"/>
<line number="96" hits="1"/>
<line number="97" hits="1"/>
<line number="98" hits="1"/>
<line number="103" hits="1"/>
<line number="104" hits="1"/>
<line number="105" hits="1"/>
<line number="106" hits="1"/>
<line number="107" hits="1"/>
<line number="108" hits="1"/>
<line number="109" hits="1"/>
<line number="111" hits="1"/>
<line number="112" hits="1"/>
<line number="116" hits="1"/>
<line number="128" hits="1"/>
<line number="129" hits="0"/>
<line number="131" hits="1"/>
</lines>
</class>
</classes>
</package>
</packages>
</coverage>

View File

@@ -43,6 +43,7 @@ services:
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_DB=0
- GEMINI_API_KEY
depends_on:
db:
condition: service_healthy
@@ -64,6 +65,7 @@ services:
- REDIS_HOST=redis
- REDIS_PORT=6379
- REDIS_DB=0
- GEMINI_API_KEY
command: celery -A app.worker.celery_app worker --loglevel=info
depends_on:
db:

4
docs/assets/Overview.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 1.8 MiB

View File

View File

@@ -8,6 +8,7 @@ dependencies = [
"asyncpg>=0.31.0",
"celery>=5.6.3",
"fastapi>=0.138.1",
"google-genai>=2.10.0",
"httpx>=0.28.1",
"pydantic-settings>=2.14.2",
"pytest>=9.1.1",
@@ -26,4 +27,5 @@ asyncio_mode = "auto"
[dependency-groups]
dev = [
"ruff>=0.15.20",
"pytest-cov>=6.0.0",
]

View File

@@ -0,0 +1,32 @@
import pytest
from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
async def test_get_categories_seeded_defaults(client: AsyncClient):
# Retrieve categories and verify the default list is present (since lifespan seeds them)
response = await client.get("/api/v1/categories")
assert response.status_code == 200
categories = response.json()
assert len(categories) >= 8
names = [c["name"] for c in categories]
assert "Food" in names
assert "Shopping" in names
assert "Travel" in names
assert "Other" in names
async def test_create_and_duplicate_category(client: AsyncClient):
# 1. Create a new category
response = await client.post("/api/v1/categories", json={"name": "Medical"})
assert response.status_code == 201
data = response.json()
assert "id" in data
assert data["name"] == "Medical"
# 2. Try creating the same category (duplicate, case-insensitive check)
dup_response = await client.post("/api/v1/categories", json={"name": "medical"})
assert dup_response.status_code == 400
assert "already exists" in dup_response.json()["detail"]

View File

@@ -101,3 +101,49 @@ async def test_get_all_jobs(client: AsyncClient):
response = await client.get("/api/v1/jobs")
assert response.status_code == 200
assert isinstance(response.json(), list)
async def test_upload_csv_new_anomalies(client: AsyncClient):
# ACC03 has Cafe transactions: 10 INR, 12 INR, 100 INR (median 12 INR, 3x median = 36 INR)
# ACC03 also has a domestic merchant 'Ola' in USD
csv_content = (
"txn_id,date,amount,merchant,currency,category,account\n"
"TX3001,2026-06-25,10.00,Cafe,INR,Food,ACC03\n"
"TX3002,2026-06-26,12.00,Cafe,INR,Food,ACC03\n"
"TX3003,2026-06-27,100.00,Cafe,INR,Food,ACC03\n" # 3x median outlier (> 36)
"TX3004,2026-06-28,50.00,Ola cabs,USD,Travel,ACC03\n" # Domestic in USD
)
files = {
"file": (
"transactions_new_anomalies.csv",
io.BytesIO(csv_content.encode("utf-8")),
"text/csv",
)
}
response = await client.post("/api/v1/jobs/upload", files=files)
assert response.status_code == 201
job_id = response.json()["id"]
# Retrieve status and verify anomalies count
status_response = await client.get(f"/api/v1/jobs/{job_id}/status")
assert status_response.status_code == 200
status_data = status_response.json()
assert status_data["summary"]["anomaly_count"] == 2 # TX3003 and TX3004
# Retrieve full results and verify reasons
results_response = await client.get(f"/api/v1/jobs/{job_id}/results")
assert results_response.status_code == 200
results_data = results_response.json()
txns = results_data["transactions"]
# TX3003 outlier check
outlier_txn = next(t for t in txns if t["txn_id"] == "TX3003")
assert outlier_txn["is_anomaly"] is True
assert "Statistical outlier" in outlier_txn["anomaly_reason"]
# TX3004 domestic in USD check
domestic_txn = next(t for t in txns if t["txn_id"] == "TX3004")
assert domestic_txn["is_anomaly"] is True
assert "Domestic brand transacted in USD" in domestic_txn["anomaly_reason"]

View File

@@ -36,7 +36,7 @@ def initialize_test_db():
"""Create test tables and clean them up after all tests finish."""
global _db_offline
try:
loop = asyncio.get_event_loop_policy().get_event_loop()
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
@@ -46,6 +46,24 @@ def initialize_test_db():
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
# Seed categories in test DB
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.category import Category
async with AsyncSession(test_engine) as session:
defaults = [
Category(name="Food"),
Category(name="Shopping"),
Category(name="Travel"),
Category(name="Transport"),
Category(name="Utilities"),
Category(name="Cash Withdrawal"),
Category(name="Entertainment"),
Category(name="Other"),
]
session.add_all(defaults)
await session.commit()
async def teardown_db():
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)

View File

@@ -0,0 +1,53 @@
import pytest
import asyncio
from app.core.observability import benchmark, time_it
def test_benchmark_context_manager(caplog):
import logging
with caplog.at_level(logging.INFO):
with benchmark("Test Task"):
# simulate work
pass
assert any(
"[BENCHMARK] Test Task completed in" in record.message
for record in caplog.records
)
def test_time_it_decorator_sync(caplog):
import logging
@time_it("Sync Helper")
def my_sync_fn(x, y):
return x + y
with caplog.at_level(logging.INFO):
res = my_sync_fn(10, 20)
assert res == 30
assert any(
"[BENCHMARK] Sync Helper completed in" in record.message
for record in caplog.records
)
@pytest.mark.asyncio
async def test_time_it_decorator_async(caplog):
import logging
@time_it("Async Helper")
async def my_async_fn():
await asyncio.sleep(0.01)
return "done"
with caplog.at_level(logging.INFO):
res = await my_async_fn()
assert res == "done"
assert any(
"[BENCHMARK] Async Helper completed in" in record.message
for record in caplog.records
)

View File

@@ -52,3 +52,15 @@ def test_parse_invalid_date():
with pytest.raises(InvalidCSVException) as exc_info:
parse_and_validate_csv(invalid_csv.encode("utf-8"))
assert "Invalid date format" in str(exc_info.value)
def test_parse_amount_with_symbols():
csv_data = (
"txn_id,date,amount,merchant\n"
"TX1001,2026-06-25,$120.50,Weekly grocery shop\n"
'TX1002,2026-06-26,"₹1,500.00",Weekly grocery shop\n'
)
result = parse_and_validate_csv(csv_data.encode("utf-8"))
assert len(result) == 2
assert result[0]["amount"] == 120.50
assert result[1]["amount"] == 1500.00

View File

@@ -0,0 +1,48 @@
import pytest
from app.worker import classify_transactions_batch
from google import genai
from unittest.mock import MagicMock, patch
pytestmark = pytest.mark.asyncio
async def test_classify_transactions_batch_retry_success():
# Mock GenAI client and interaction creation
mock_client = MagicMock(spec=genai.Client)
mock_interaction = MagicMock()
mock_interaction.output_text = '{"classifications": [{"txn_id": "TX1", "category": "Food"}, {"txn_id": "TX2", "category": "Shopping"}]}'
mock_client.interactions.create.return_value = mock_interaction
batch_txns = [
{"txn_id": "TX1", "merchant": "Swiggy", "amount": 150.0},
{"txn_id": "TX2", "merchant": "Amazon", "amount": 1200.0},
]
results = await classify_transactions_batch(
mock_client, batch_txns, ["Food", "Shopping", "Other"]
)
assert len(results) == 2
assert results["TX1"]["category"] == "Food"
assert results["TX1"]["failed"] is False
assert results["TX2"]["category"] == "Shopping"
assert results["TX2"]["failed"] is False
async def test_classify_transactions_batch_retry_failure():
mock_client = MagicMock(spec=genai.Client)
# Simulate API call failure (e.g. invalid API key or connection error)
mock_client.interactions.create.side_effect = Exception("API Key Error")
batch_txns = [
{"txn_id": "TX1", "merchant": "Swiggy", "amount": 150.0},
]
# Patch sleep to make retry test run instantly
with patch("asyncio.sleep", return_value=None):
results = await classify_transactions_batch(
mock_client, batch_txns, ["Food", "Shopping", "Other"]
)
assert len(results) == 1
assert results["TX1"]["category"] == "Other"
assert results["TX1"]["failed"] is True

View File

@@ -0,0 +1,77 @@
import pytest
from app.worker import generate_job_summary_llm, clean_json_response
from app.db.models.transaction import Transaction
from google import genai
from unittest.mock import MagicMock, patch
def test_clean_json_response_markdown():
# Test stripping markdown code fences
dirty_json = '```json\n{"key": "value"}\n```'
assert clean_json_response(dirty_json) == '{"key": "value"}'
# Test stripping markdown code fences with conversational prefix
dirty_conversational = (
'Here is the data:\n```\n{"key": "value"}\n```\nHope it helps.'
)
assert clean_json_response(dirty_conversational) == '{"key": "value"}'
@pytest.mark.asyncio
async def test_generate_job_summary_llm_success():
# 1. Setup mock transactions
t1 = Transaction(
merchant="Zomato",
amount=100.0,
currency="USD",
category="Food",
is_anomaly=False,
)
t2 = Transaction(
merchant="Ola",
amount=2500.0,
currency="INR",
category="Transport",
is_anomaly=True,
anomaly_reason="Outlier",
)
tx_list = [t1, t2]
# 2. Mock client and return json
mock_client = MagicMock(spec=genai.Client)
mock_interaction = MagicMock()
mock_interaction.output_text = """
```json
{
"total_spend_by_currency": {"USD": 100.0, "INR": 2500.0},
"top_3_merchants": {"Ola": 30.12, "Zomato": 100.0},
"anomaly_count": 1,
"spending_narrative": "Cohesive two sentence summary of spendings. Mostly food and transit.",
"risk_level": "Medium"
}
```
"""
mock_client.interactions.create.return_value = mock_interaction
# 3. Call and assert
result = await generate_job_summary_llm(mock_client, tx_list)
assert result is not None
assert result.anomaly_count == 1
assert result.risk_level == "Medium"
assert result.total_spend_by_currency["USD"] == 100.0
assert result.total_spend_by_currency["INR"] == 2500.0
assert "Ola" in result.top_3_merchants
@pytest.mark.asyncio
async def test_generate_job_summary_llm_retry_failure():
mock_client = MagicMock(spec=genai.Client)
mock_client.interactions.create.side_effect = Exception("API Key Expired")
tx_list = [Transaction(merchant="Test", amount=1.0, currency="USD")]
# Patch sleep to execute instantly
with patch("asyncio.sleep", return_value=None):
result = await generate_job_summary_llm(mock_client, tx_list)
assert result is None

96
transactions.csv Normal file
View File

@@ -0,0 +1,96 @@
txn_id,date,merchant,amount,currency,status,category,account_id,notes
TXN1065,04-09-2024,Flipkart,10882.55,INR,SUCCESS,Shopping,ACC003,Refund expected
TXN1054,2024/02/05,Swiggy,$11325.79,INR,success,Food,ACC004,
TXN1021,17-02-2024,Zomato,2536.35,USD,SUCCESS,Food,ACC001,Verified
TXN1045,07-05-2024,Amazon,6874.1,INR,failed,Shopping,ACC004,SUSPICIOUS
TXN1076,14-08-2024,Flipkart,2763.26,INR,SUCCESS,Shopping,ACC004,Duplicate?
TXN1029,19-05-2024,Flipkart,9092.21,INR,FAILED,Shopping,ACC003,
TXN1004,04-07-2024,Jio Recharge,2924.71,INR,SUCCESS,Utilities,ACC001,Verified
TXN1002,2024/01/04,HDFC ATM,10487.18,INR,FAILED,Cash Withdrawal,ACC004,
TXN1000,23-11-2024,Dentist Appointment Fee,423.91,INR,failed,,ACC004,
TXN1073,16-05-2024,HDFC ATM,1117.58,INR,failed,Cash Withdrawal,ACC004,
TXN1019,26-11-2024,Amazon,956.16,INR,success,Shopping,ACC004,Refund expected
TXN1006,24-03-2024,IRCTC,5722.86,INR,FAILED,Travel,ACC001,Refund expected
TXN1014,2024/09/05,Amazon,14670.87,INR,failed,Shopping,ACC005,
TXN1009,11-03-2024,MakeMyTrip,7428.06,USD,success,Travel,ACC004,SUSPICIOUS
TXN1066,2024/06/03,Swiggy,10634.88,INR,FAILED,Food,ACC002,
TXN1071,09-10-2024,Amazon,2390.16,INR,SUCCESS,Shopping,ACC003,
TXN1044,16-03-2024,Swiggy,13395.26,INR,success,Food,ACC005,Verified
TXN1047,2024/09/23,IRCTC,9419.89,INR,FAILED,Travel,ACC005,
TXN1068,03-02-2024,Ola,7401.61,inr,failed,Transport,ACC001,Refund expected
TXN1077,23-02-2024,HDFC ATM,4980.56,INR,failed,,ACC001,Duplicate?
TXN1060,22-05-2024,IRCTC,3695.76,INR,SUCCESS,Travel,ACC003,Refund expected
TXN1009,11-03-2024,MakeMyTrip,7428.06,USD,success,Travel,ACC004,SUSPICIOUS
TXN1033,13-10-2024,Ola,14608.86,inr,success,Transport,ACC002,Verified
TXN1059,29-11-2024,Flipkart,9257.89,INR,failed,Shopping,ACC002,
TXN1034,19-06-2024,Ola,12043.42,inr,SUCCESS,Transport,ACC001,
TXN1062,27-07-2024,IRCTC,8349.88,INR,PENDING,Travel,ACC001,Duplicate?
TXN1042,20-09-2024,Jio Recharge,5061.06,INR,success,Utilities,ACC002,
TXN1023,2024/10/23,MakeMyTrip,961.32,USD,failed,Travel,ACC005,
TXN1011,16-05-2024,BookMyShow,1717.7,INR,FAILED,Entertainment,ACC002,SUSPICIOUS
,25-06-2024,Jio Recharge,4004.59,INR,PENDING,,ACC003,
TXN1020,22-05-2024,IRCTC,3784.61,INR,failed,,ACC005,
TXN1064,20-08-2024,Flipkart,4634.01,INR,failed,Shopping,ACC002,Refund expected
TXN1025,02-08-2024,Jio Recharge,8500.14,INR,FAILED,Utilities,ACC004,Refund expected
TXN1069,01-08-2024,Jio Recharge,8962.1,INR,PENDING,Utilities,ACC005,Verified
TXN1024,05-02-2024,Jio Recharge,1066.01,INR,failed,Utilities,ACC005,
TXN1005,21-11-2024,BookMyShow,2481.68,INR,SUCCESS,Entertainment,ACC002,SUSPICIOUS
TXN1018,30-08-2024,Ola,2896.63,inr,PENDING,Transport,ACC001,
TXN1032,06-03-2024,Swiggy,4658.46,INR,FAILED,Food,ACC002,
TXN1028,30-11-2024,MakeMyTrip,166.96,USD,success,Travel,ACC001,
TXN1070,13-11-2024,Swiggy,9163.98,INR,success,Food,ACC001,Refund expected
TXN1043,31-08-2024,Jio Recharge,12753.58,INR,success,Utilities,ACC004,Verified
TXN1079,12-09-2024,IRCTC,11411.86,INR,PENDING,Travel,ACC004,
TXN1008,25-11-2024,IRCTC,2185.93,INR,failed,Travel,ACC005,Verified
TXN1035,12-04-2024,IRCTC,5277.39,INR,success,Travel,ACC003,
TXN1072,06-02-2024,Zomato,13862.47,USD,success,Food,ACC005,Refund expected
TXN1039,15-11-2024,BookMyShow,9967.64,INR,PENDING,Entertainment,ACC003,SUSPICIOUS
TXN1079,12-09-2024,IRCTC,11411.86,INR,PENDING,Travel,ACC004,
TXN1038,07-04-2024,Flipkart,713.58,INR,success,Shopping,ACC001,
TXN1026,2024/06/02,IRCTC,4776.85,INR,failed,Travel,ACC001,SUSPICIOUS
TXN1040,03-08-2024,Ola,10175.9,inr,failed,Transport,ACC003,Duplicate?
TXN1058,2024/06/23,Swiggy,12751.16,INR,PENDING,Food,ACC004,Refund expected
,2024/04/21,Zomato,7605.06,USD,failed,Food,ACC004,SUSPICIOUS
TXN1022,20-08-2024,Flipkart,6373.96,INR,PENDING,Shopping,ACC003,Refund expected
TXN1075,16-02-2024,Zomato,14430.57,USD,FAILED,Food,ACC002,Verified
TXN1012,2024/11/16,Flipkart,12632.96,INR,success,Shopping,ACC005,
TXN1074,04-06-2024,Ola,14143.01,inr,SUCCESS,Transport,ACC003,SUSPICIOUS
TXN1063,20-12-2024,Zomato,4627.78,USD,PENDING,Food,ACC005,Duplicate?
TXN1013,07-09-2024,Swiggy,1722.42,INR,success,,ACC005,
TXN1057,03-06-2024,Jio Recharge,$12092.64,INR,failed,Utilities,ACC003,
TXN1010,14-07-2024,Jio Recharge,14942.01,INR,SUCCESS,Utilities,ACC001,
TXN1046,15-09-2024,Ola,12467.01,inr,PENDING,Transport,ACC002,
TXN1017,17-01-2024,BookMyShow,1109.32,INR,success,Entertainment,ACC005,SUSPICIOUS
TXN1050,02-08-2024,MakeMyTrip,11225.36,USD,success,Travel,ACC004,
TXN1078,2024/02/29,Ola,12980.41,inr,PENDING,Transport,ACC002,SUSPICIOUS
TXN1041,23-10-2024,Jio Recharge,9837.85,INR,PENDING,Utilities,ACC002,
TXN1035,12-04-2024,IRCTC,5277.39,INR,success,Travel,ACC003,
TXN1037,15-05-2024,Swiggy,1670.62,INR,failed,Food,ACC001,Verified
TXN2003,2024-07-15,IRCTC,193647.29,INR,SUCCESS,,ACC002,
TXN1048,2024/04/28,Ola,10424.55,inr,failed,Transport,ACC001,Refund expected
TXN1016,25-04-2024,Amazon,5104.38,INR,SUCCESS,Shopping,ACC001,Refund expected
TXN1053,13-07-2024,Flipkart,11292.55,INR,SUCCESS,Shopping,ACC001,Duplicate?
TXN1033,13-10-2024,Ola,14608.86,inr,success,Transport,ACC002,Verified
TXN1031,14-10-2024,Swiggy,1722.51,INR,SUCCESS,Food,ACC005,SUSPICIOUS
TXN1000_DUP,23-11-2024,Amazon,423.91,INR,failed,,ACC004,
TXN2002,2024-07-15,Ola,91185.1,INR,SUCCESS,,ACC001,
TXN1055,2024/04/21,IRCTC,10507.0,INR,SUCCESS,Travel,ACC002,Duplicate?
TXN1036,22-05-2024,BookMyShow,9640.15,INR,SUCCESS,Entertainment,ACC003,Refund expected
TXN2001,2024-07-15,Flipkart,146100.68,INR,SUCCESS,,ACC005,
,20-11-2024,Ola,12448.75,inr,FAILED,Transport,ACC004,SUSPICIOUS
TXN1007,10-06-2024,Ola,4052.73,inr,FAILED,Transport,ACC004,Verified
TXN1015,18-04-2024,MakeMyTrip,11341.21,USD,FAILED,Travel,ACC005,Verified
TXN2004,2024-07-15,IRCTC,191918.37,INR,SUCCESS,,ACC003,
TXN1067,08-09-2024,Amazon,13097.16,INR,success,Shopping,ACC003,
TXN1020,22-05-2024,IRCTC,3784.61,INR,failed,,ACC005,
TXN1042,20-09-2024,Jio Recharge,5061.06,INR,success,Utilities,ACC002,
TXN1016,25-04-2024,Amazon,5104.38,INR,SUCCESS,Shopping,ACC001,Refund expected
TXN1061,30-06-2024,Flipkart,5138.07,INR,SUCCESS,Shopping,ACC002,
,18-07-2024,Jio Recharge,14193.63,INR,PENDING,,ACC003,Duplicate?
TXN1027,15-05-2024,HDFC ATM,14002.23,INR,success,Cash Withdrawal,ACC005,
TXN1051,08-12-2024,Ola,10876.51,inr,failed,,ACC004,SUSPICIOUS
TXN1052,14-01-2024,Amazon,9659.11,INR,PENDING,Shopping,ACC004,Duplicate?
TXN1044,16-03-2024,Swiggy,13395.26,INR,success,Food,ACC005,Verified
TXN1029,19-05-2024,Flipkart,9092.21,INR,FAILED,Shopping,ACC003,
TXN2000,2024-07-15,Jio Recharge,175917.65,INR,SUCCESS,,ACC002,
TXN1056,14-01-2024,Flipkart,8658.16,INR,success,,ACC002,
1 txn_id date merchant amount currency status category account_id notes
2 TXN1065 04-09-2024 Flipkart 10882.55 INR SUCCESS Shopping ACC003 Refund expected
3 TXN1054 2024/02/05 Swiggy $11325.79 INR success Food ACC004
4 TXN1021 17-02-2024 Zomato 2536.35 USD SUCCESS Food ACC001 Verified
5 TXN1045 07-05-2024 Amazon 6874.1 INR failed Shopping ACC004 SUSPICIOUS
6 TXN1076 14-08-2024 Flipkart 2763.26 INR SUCCESS Shopping ACC004 Duplicate?
7 TXN1029 19-05-2024 Flipkart 9092.21 INR FAILED Shopping ACC003
8 TXN1004 04-07-2024 Jio Recharge 2924.71 INR SUCCESS Utilities ACC001 Verified
9 TXN1002 2024/01/04 HDFC ATM 10487.18 INR FAILED Cash Withdrawal ACC004
10 TXN1000 23-11-2024 Dentist Appointment Fee 423.91 INR failed ACC004
11 TXN1073 16-05-2024 HDFC ATM 1117.58 INR failed Cash Withdrawal ACC004
12 TXN1019 26-11-2024 Amazon 956.16 INR success Shopping ACC004 Refund expected
13 TXN1006 24-03-2024 IRCTC 5722.86 INR FAILED Travel ACC001 Refund expected
14 TXN1014 2024/09/05 Amazon 14670.87 INR failed Shopping ACC005
15 TXN1009 11-03-2024 MakeMyTrip 7428.06 USD success Travel ACC004 SUSPICIOUS
16 TXN1066 2024/06/03 Swiggy 10634.88 INR FAILED Food ACC002
17 TXN1071 09-10-2024 Amazon 2390.16 INR SUCCESS Shopping ACC003
18 TXN1044 16-03-2024 Swiggy 13395.26 INR success Food ACC005 Verified
19 TXN1047 2024/09/23 IRCTC 9419.89 INR FAILED Travel ACC005
20 TXN1068 03-02-2024 Ola 7401.61 inr failed Transport ACC001 Refund expected
21 TXN1077 23-02-2024 HDFC ATM 4980.56 INR failed ACC001 Duplicate?
22 TXN1060 22-05-2024 IRCTC 3695.76 INR SUCCESS Travel ACC003 Refund expected
23 TXN1009 11-03-2024 MakeMyTrip 7428.06 USD success Travel ACC004 SUSPICIOUS
24 TXN1033 13-10-2024 Ola 14608.86 inr success Transport ACC002 Verified
25 TXN1059 29-11-2024 Flipkart 9257.89 INR failed Shopping ACC002
26 TXN1034 19-06-2024 Ola 12043.42 inr SUCCESS Transport ACC001
27 TXN1062 27-07-2024 IRCTC 8349.88 INR PENDING Travel ACC001 Duplicate?
28 TXN1042 20-09-2024 Jio Recharge 5061.06 INR success Utilities ACC002
29 TXN1023 2024/10/23 MakeMyTrip 961.32 USD failed Travel ACC005
30 TXN1011 16-05-2024 BookMyShow 1717.7 INR FAILED Entertainment ACC002 SUSPICIOUS
31 25-06-2024 Jio Recharge 4004.59 INR PENDING ACC003
32 TXN1020 22-05-2024 IRCTC 3784.61 INR failed ACC005
33 TXN1064 20-08-2024 Flipkart 4634.01 INR failed Shopping ACC002 Refund expected
34 TXN1025 02-08-2024 Jio Recharge 8500.14 INR FAILED Utilities ACC004 Refund expected
35 TXN1069 01-08-2024 Jio Recharge 8962.1 INR PENDING Utilities ACC005 Verified
36 TXN1024 05-02-2024 Jio Recharge 1066.01 INR failed Utilities ACC005
37 TXN1005 21-11-2024 BookMyShow 2481.68 INR SUCCESS Entertainment ACC002 SUSPICIOUS
38 TXN1018 30-08-2024 Ola 2896.63 inr PENDING Transport ACC001
39 TXN1032 06-03-2024 Swiggy 4658.46 INR FAILED Food ACC002
40 TXN1028 30-11-2024 MakeMyTrip 166.96 USD success Travel ACC001
41 TXN1070 13-11-2024 Swiggy 9163.98 INR success Food ACC001 Refund expected
42 TXN1043 31-08-2024 Jio Recharge 12753.58 INR success Utilities ACC004 Verified
43 TXN1079 12-09-2024 IRCTC 11411.86 INR PENDING Travel ACC004
44 TXN1008 25-11-2024 IRCTC 2185.93 INR failed Travel ACC005 Verified
45 TXN1035 12-04-2024 IRCTC 5277.39 INR success Travel ACC003
46 TXN1072 06-02-2024 Zomato 13862.47 USD success Food ACC005 Refund expected
47 TXN1039 15-11-2024 BookMyShow 9967.64 INR PENDING Entertainment ACC003 SUSPICIOUS
48 TXN1079 12-09-2024 IRCTC 11411.86 INR PENDING Travel ACC004
49 TXN1038 07-04-2024 Flipkart 713.58 INR success Shopping ACC001
50 TXN1026 2024/06/02 IRCTC 4776.85 INR failed Travel ACC001 SUSPICIOUS
51 TXN1040 03-08-2024 Ola 10175.9 inr failed Transport ACC003 Duplicate?
52 TXN1058 2024/06/23 Swiggy 12751.16 INR PENDING Food ACC004 Refund expected
53 2024/04/21 Zomato 7605.06 USD failed Food ACC004 SUSPICIOUS
54 TXN1022 20-08-2024 Flipkart 6373.96 INR PENDING Shopping ACC003 Refund expected
55 TXN1075 16-02-2024 Zomato 14430.57 USD FAILED Food ACC002 Verified
56 TXN1012 2024/11/16 Flipkart 12632.96 INR success Shopping ACC005
57 TXN1074 04-06-2024 Ola 14143.01 inr SUCCESS Transport ACC003 SUSPICIOUS
58 TXN1063 20-12-2024 Zomato 4627.78 USD PENDING Food ACC005 Duplicate?
59 TXN1013 07-09-2024 Swiggy 1722.42 INR success ACC005
60 TXN1057 03-06-2024 Jio Recharge $12092.64 INR failed Utilities ACC003
61 TXN1010 14-07-2024 Jio Recharge 14942.01 INR SUCCESS Utilities ACC001
62 TXN1046 15-09-2024 Ola 12467.01 inr PENDING Transport ACC002
63 TXN1017 17-01-2024 BookMyShow 1109.32 INR success Entertainment ACC005 SUSPICIOUS
64 TXN1050 02-08-2024 MakeMyTrip 11225.36 USD success Travel ACC004
65 TXN1078 2024/02/29 Ola 12980.41 inr PENDING Transport ACC002 SUSPICIOUS
66 TXN1041 23-10-2024 Jio Recharge 9837.85 INR PENDING Utilities ACC002
67 TXN1035 12-04-2024 IRCTC 5277.39 INR success Travel ACC003
68 TXN1037 15-05-2024 Swiggy 1670.62 INR failed Food ACC001 Verified
69 TXN2003 2024-07-15 IRCTC 193647.29 INR SUCCESS ACC002
70 TXN1048 2024/04/28 Ola 10424.55 inr failed Transport ACC001 Refund expected
71 TXN1016 25-04-2024 Amazon 5104.38 INR SUCCESS Shopping ACC001 Refund expected
72 TXN1053 13-07-2024 Flipkart 11292.55 INR SUCCESS Shopping ACC001 Duplicate?
73 TXN1033 13-10-2024 Ola 14608.86 inr success Transport ACC002 Verified
74 TXN1031 14-10-2024 Swiggy 1722.51 INR SUCCESS Food ACC005 SUSPICIOUS
75 TXN1000_DUP 23-11-2024 Amazon 423.91 INR failed ACC004
76 TXN2002 2024-07-15 Ola 91185.1 INR SUCCESS ACC001
77 TXN1055 2024/04/21 IRCTC 10507.0 INR SUCCESS Travel ACC002 Duplicate?
78 TXN1036 22-05-2024 BookMyShow 9640.15 INR SUCCESS Entertainment ACC003 Refund expected
79 TXN2001 2024-07-15 Flipkart 146100.68 INR SUCCESS ACC005
80 20-11-2024 Ola 12448.75 inr FAILED Transport ACC004 SUSPICIOUS
81 TXN1007 10-06-2024 Ola 4052.73 inr FAILED Transport ACC004 Verified
82 TXN1015 18-04-2024 MakeMyTrip 11341.21 USD FAILED Travel ACC005 Verified
83 TXN2004 2024-07-15 IRCTC 191918.37 INR SUCCESS ACC003
84 TXN1067 08-09-2024 Amazon 13097.16 INR success Shopping ACC003
85 TXN1020 22-05-2024 IRCTC 3784.61 INR failed ACC005
86 TXN1042 20-09-2024 Jio Recharge 5061.06 INR success Utilities ACC002
87 TXN1016 25-04-2024 Amazon 5104.38 INR SUCCESS Shopping ACC001 Refund expected
88 TXN1061 30-06-2024 Flipkart 5138.07 INR SUCCESS Shopping ACC002
89 18-07-2024 Jio Recharge 14193.63 INR PENDING ACC003 Duplicate?
90 TXN1027 15-05-2024 HDFC ATM 14002.23 INR success Cash Withdrawal ACC005
91 TXN1051 08-12-2024 Ola 10876.51 inr failed ACC004 SUSPICIOUS
92 TXN1052 14-01-2024 Amazon 9659.11 INR PENDING Shopping ACC004 Duplicate?
93 TXN1044 16-03-2024 Swiggy 13395.26 INR success Food ACC005 Verified
94 TXN1029 19-05-2024 Flipkart 9092.21 INR FAILED Shopping ACC003
95 TXN2000 2024-07-15 Jio Recharge 175917.65 INR SUCCESS ACC002
96 TXN1056 14-01-2024 Flipkart 8658.16 INR success ACC002

332
uv.lock generated
View File

@@ -10,6 +10,7 @@ dependencies = [
{ name = "asyncpg" },
{ name = "celery" },
{ name = "fastapi" },
{ name = "google-genai" },
{ name = "httpx" },
{ name = "pydantic-settings" },
{ name = "pytest" },
@@ -23,6 +24,7 @@ dependencies = [
[package.dev-dependencies]
dev = [
{ name = "pytest-cov" },
{ name = "ruff" },
]
@@ -31,6 +33,7 @@ requires-dist = [
{ name = "asyncpg", specifier = ">=0.31.0" },
{ name = "celery", specifier = ">=5.6.3" },
{ name = "fastapi", specifier = ">=0.138.1" },
{ name = "google-genai", specifier = ">=2.10.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "pydantic-settings", specifier = ">=2.14.2" },
{ name = "pytest", specifier = ">=9.1.1" },
@@ -43,7 +46,10 @@ requires-dist = [
]
[package.metadata.requires-dev]
dev = [{ name = "ruff", specifier = ">=0.15.20" }]
dev = [
{ name = "pytest-cov", specifier = ">=6.0.0" },
{ name = "ruff", specifier = ">=0.15.20" },
]
[[package]]
name = "amqp"
@@ -149,6 +155,80 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
{ url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
{ url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
{ url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
{ url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
{ url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
{ url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
{ url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
{ url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
{ url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
{ url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
{ url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
{ url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
{ url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
{ url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
{ url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
{ url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
{ url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
{ url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
{ url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
{ url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
{ url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
{ url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
{ url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
{ url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
{ url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
]
[[package]]
name = "click"
version = "8.4.2"
@@ -207,6 +287,104 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" },
{ url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" },
{ url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" },
{ url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" },
{ url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" },
{ url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" },
{ url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" },
{ url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" },
{ url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" },
{ url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" },
{ url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" },
{ url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" },
{ url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" },
{ url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" },
{ url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" },
{ url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" },
{ url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" },
{ url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" },
{ url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" },
{ url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" },
{ url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" },
{ url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" },
{ url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" },
{ url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" },
{ url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" },
{ url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" },
{ url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" },
{ url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" },
{ url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" },
]
[[package]]
name = "cryptography"
version = "49.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
]
[[package]]
name = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "fastapi"
version = "0.138.1"
@@ -223,6 +401,45 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/a9/69a6924f645eb4dd8cd625bf255b3625990eb3e14e073438a53c405dcd3e/fastapi-0.138.1-py3-none-any.whl", hash = "sha256:b994cae7ba8b82c976a728b544244de31333fa5f7d261f9a1dffe526444cae23", size = 129182, upload-time = "2026-06-25T15:40:40.771Z" },
]
[[package]]
name = "google-auth"
version = "2.55.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyasn1-modules" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/6f/f3f4ac177c67bbee8fe8e88f2ab4f36af88c44a096e165c5217accf6e5d3/google_auth-2.55.1.tar.gz", hash = "sha256:fb2d9b730f2c9b8d326ec8d7222f21aef2ead15bf0513793d6442485d87af0a1", size = 349527, upload-time = "2026-06-25T23:39:27.182Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/1d/f6d3ca1ad0725f2e08a1c6915640748a52de2e66596160a4d53b010cccf0/google_auth-2.55.1-py3-none-any.whl", hash = "sha256:eada68dfd52b3b81191827601e2a0c3fa12540c818534b630ddc5355769c3995", size = 252349, upload-time = "2026-06-25T23:38:52.946Z" },
]
[package.optional-dependencies]
requests = [
{ name = "requests" },
]
[[package]]
name = "google-genai"
version = "2.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "google-auth", extra = ["requests"] },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "requests" },
{ name = "sniffio" },
{ name = "tenacity" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cd/fe/b796087493c3c55371aa58b9f264841ace5bfdf8c668cafa7afa33c44bec/google_genai-2.10.0.tar.gz", hash = "sha256:77912cd558cd7dfd5b75c25fd1c609e78d7954dde583331104022a46ea90f9ee", size = 600039, upload-time = "2026-06-24T01:33:18.157Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/39/00bcfd94de255d24249401efff4f48d77bf6066b46447e519fa193c0c299/google_genai-2.10.0-py3-none-any.whl", hash = "sha256:d5350311567ae660c24cbc1752aee4b3d660f89c0106d2dcd2a69978c35afe1e", size = 957974, upload-time = "2026-06-24T01:33:16.296Z" },
]
[[package]]
name = "greenlet"
version = "3.5.3"
@@ -362,6 +579,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
]
[[package]]
name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pydantic"
version = "2.13.4"
@@ -469,6 +716,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
@@ -508,6 +769,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/0a/c2345ebf1ebe70840ce3f6c6ee612f8fa749cfbd1b03069c53bf0c62aaad/redis-8.0.1-py3-none-any.whl", hash = "sha256:47daa35a058c23468d6437f17a8c76882cb316b838ef763036af99b96cedd743", size = 502406, upload-time = "2026-06-23T14:52:36.137Z" },
]
[[package]]
name = "requests"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
name = "ruff"
version = "0.15.20"
@@ -542,6 +818,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.51"
@@ -581,6 +866,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]
[[package]]
name = "tenacity"
version = "9.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
@@ -623,6 +917,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/42/28/fc144409c71569e928585f8f3c629d80d1ca3ef40175e9222f01588f98c9/tzlocal-5.4.3-py3-none-any.whl", hash = "sha256:24ce97bb58e2a973f7640ec2553ab4e6f6d5a0d0d1aa9dc43bca21d89e1feb82", size = 18039, upload-time = "2026-06-17T04:17:40.027Z" },
]
[[package]]
name = "urllib3"
version = "2.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
]
[[package]]
name = "uvicorn"
version = "0.49.0"
@@ -653,3 +956,30 @@ sdist = { url = "https://files.pythonhosted.org/packages/49/b4/51fe890511f0f242d
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/6e/95b0e537de1f4d4301f76f944642c6da50d1511cc7b3d64dc418a66c7509/wcwidth-0.8.1-py3-none-any.whl", hash = "sha256:f453740b1e4a4f3291faa37944c555d71056c4da08d59809b307ef4feba695c8", size = 323092, upload-time = "2026-06-08T05:57:21.413Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]