Compare commits

...

29 Commits

Author SHA1 Message Date
a55eccf8e1 docs: Added technical video 2026-07-03 17:23:22 +05:30
573d8d0d86 docs: Fix typo 2026-07-03 16:50:37 +05:30
8100323af5 docs: Added demo curl commands for testing 2026-07-03 16:48:02 +05:30
1902cd2d03 refactor: Concurrent batch processing 2026-07-03 13:07:41 +05:30
61d78af5c1 docs: Added draw.io link 2026-07-03 11:26:39 +05:30
c9ec297a5f feat: Implemented observability layer and benchmarking 2026-07-03 11:20:23 +05:30
9713a12591 tests: Add tests for llm summary 2026-07-03 09:21:49 +05:30
a3f2db0760 switch to gemini-flash-lite-2.5 2026-07-03 09:03:17 +05:30
9c7c7b286e docs: Update readme 2026-07-03 08:56:57 +05:30
28ea349d51 docs: Added github shields 2026-07-03 08:43:31 +05:30
9e1a5f83d4 ci: Added build, Code coverage and security workflows 2026-07-03 08:38:38 +05:30
8488e3bdd0 feat: Add API endpoint for managing categories 2026-07-03 08:33:48 +05:30
be6c95c497 feat: Add resilience hardening for csv parser 2026-07-03 08:04:14 +05:30
a305bba66d feat: Add google-genai & structured output 2026-07-03 08:02:28 +05:30
b4049630ed fix: clear conflicting local root created dirs 2026-06-30 19:40:28 +05:30
f3727af697 refactor: Add JobSummary and Transaction models, client to get currency exchange rate 2026-06-29 20:57:06 +05:30
2e556173d4 docs: Added misc comments 2026-06-29 17:52:25 +05:30
40881b4773 test: Update tests for jobs endpoint 2026-06-29 17:52:08 +05:30
d980558bee feat: Setup celery worker 2026-06-29 17:51:15 +05:30
0cc2b5fe57 feat: Setup CORS middleware and healthcheck 2026-06-29 17:49:37 +05:30
c00f27aa30 feat: Add job model 2026-06-29 15:15:11 +05:30
265c6704a7 define db session connector 2026-06-29 15:14:22 +05:30
0b20fd3263 feat: Implement API endpoints for jobs 2026-06-29 15:12:30 +05:30
fa48a39277 ci: Update pyproject.toml to include ruff 2026-06-29 15:09:59 +05:30
bcf99c66cd ci: Add linter, minor lint fixes 2026-06-29 15:07:28 +05:30
22b5cd8ced setup dependencies and fix test_job_repositories missing deps 2026-06-29 15:00:48 +05:30
f63cd4774c Added basic exceptions 2026-06-29 14:58:38 +05:30
a2a25d7bfb add test config and basic tests for csv_parser 2026-06-29 14:56:54 +05:30
47b2db8a15 feat: Setup app lifecycle, job service and basic csv parser 2026-06-29 14:48:41 +05:30
49 changed files with 4116 additions and 15 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.venv
.git
.pytest_cache
__pycache__
*.pyc
.env
*.log

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

26
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Lint
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
lint:
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 Ruff Check
run: uv run --group dev ruff check .
- name: Run Ruff Format Check
run: uv run --group dev ruff format --check .

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

2
.gitignore vendored
View File

@@ -6,6 +6,8 @@ dist/
wheels/
*.egg-info
.env
# Virtual environments
.venv

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),
)

84
app/api/v1/jobs.py Normal file
View File

@@ -0,0 +1,84 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, File, UploadFile, Query, HTTPException, status
from app.core.dependencies import get_job_service
from app.core.exceptions import JobNotFoundException, InvalidCSVException
from app.schemas.job import JobResponse, JobStatusResponse, JobResultResponse
from app.services.job_service import JobService
router = APIRouter(prefix="/jobs", tags=["jobs"])
@router.post("/upload", response_model=JobResponse, status_code=status.HTTP_201_CREATED)
async def upload_csv(
file: UploadFile = File(...),
service: JobService = Depends(get_job_service),
):
"""
Accepts a CSV file upload. Validates it, creates a job record in the database
with status=pending, enqueues the processing task, and returns the job_id immediately.
"""
if not file.filename.endswith(".csv"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Only CSV files are supported.",
)
content = await file.read()
try:
job = await service.upload_csv(filename=file.filename, content=content)
return job
except InvalidCSVException as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e),
)
@router.get("/{job_id}/status", response_model=JobStatusResponse)
async def get_job_status(
job_id: str,
service: JobService = Depends(get_job_service),
):
"""
Returns the current status of the job: pending, processing, completed, or failed.
If completed, also includes a summary field with high-level stats.
"""
try:
job = await service.get_job_status(job_id)
return job
except JobNotFoundException as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
@router.get("/{job_id}/results", response_model=JobResultResponse)
async def get_job_result(
job_id: str,
service: JobService = Depends(get_job_service),
):
"""
Returns the full structured output: cleaned transactions list, flagged anomalies,
per-category spend breakdown, and the LLM-generated narrative summary.
"""
try:
job = await service.get_job_results(job_id)
return job
except JobNotFoundException as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e),
)
@router.get("", response_model=List[JobResponse])
async def get_all_jobs(
status: Optional[str] = Query(None, description="Filter jobs by status"),
service: JobService = Depends(get_job_service),
):
"""
Lists all jobs with their status, filename, row count, and created_at timestamp.
Supports filtering via ?status= query parameter.
"""
return await service.list_jobs(status=status)

View File

@@ -0,0 +1,74 @@
import httpx
import redis
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
self.cache_key = "usd_to_inr_rate"
self.cache_ttl = 86400 # 24 hours cache duration
try:
self.redis_client = redis.Redis(
host=settings.REDIS_HOST,
port=settings.REDIS_PORT,
db=settings.REDIS_DB,
decode_responses=True,
)
except Exception as e:
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.
Checks Redis cache first. If cache is empty/offline, fetches from a free public API
and updates the cache. Falls back to a hardcoded default rate if both fail.
"""
# read from cache
if self.redis_client:
try:
cached_rate = self.redis_client.get(self.cache_key)
if cached_rate:
logger.info("Retrieved exchange rate from Redis cache.")
return float(cached_rate)
except Exception as e:
logger.warning(f"Failed to read exchange rate from Redis: {e}")
# fetching from Exchange Rate API
try:
logger.info("Fetching live exchange rate from API...")
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get("https://open.er-api.com/v6/latest/USD")
if response.status_code == 200:
data = response.json()
rates = data.get("rates", {})
inr_rate = rates.get("INR")
if inr_rate:
inr_rate = float(inr_rate)
logger.info(
f"Successfully fetched live exchange rate: {inr_rate}"
)
# Cache the rate in Redis
if self.redis_client:
try:
self.redis_client.set(
self.cache_key, str(inr_rate), ex=self.cache_ttl
)
except Exception as ce:
logger.warning(
f"Failed to cache exchange rate in Redis: {ce}"
)
return inr_rate
except Exception as e:
logger.error(
f"Error fetching live exchange rate: {e}. Falling back to default rate {self.default_rate}"
)
# 3. Fallback
return self.default_rate

51
app/core/config.py Normal file
View File

@@ -0,0 +1,51 @@
from typing import Optional
from pydantic import Field, computed_field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
# General
PROJECT_NAME: str = "Alemno Payments API"
API_V1_STR: str = "/api/v1"
# PostgreSQL
POSTGRES_USER: str = Field(default="postgres")
POSTGRES_PASSWORD: str = Field(default="postgres")
POSTGRES_HOST: str = Field(default="localhost")
POSTGRES_PORT: int = Field(default=5432)
POSTGRES_DB: str = Field(default="alemno_payments")
# Redis
REDIS_HOST: str = Field(default="localhost")
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:
return (
f"postgresql+asyncpg://"
f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}@"
f"{self.POSTGRES_HOST}:{self.POSTGRES_PORT}/"
f"{self.POSTGRES_DB}"
)
@computed_field
@property
def CELERY_BROKER_URL(self) -> str:
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
@computed_field
@property
def CELERY_RESULT_BACKEND(self) -> str:
return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}"
settings = Settings()

21
app/core/dependencies.py Normal file
View File

@@ -0,0 +1,21 @@
from fastapi import Depends
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:
"""Dependency to retrieve JobRepository"""
return JobRepository(db)
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)

28
app/core/exceptions.py Normal file
View File

@@ -0,0 +1,28 @@
class AlemnoException(Exception):
"""Base exception for Alemno Payments Application"""
pass
class JobNotFoundException(AlemnoException):
"""Raised when a job is not found"""
def __init__(self, job_id: str):
self.job_id = job_id
super().__init__(f"Job with ID {job_id} not found")
class InvalidCSVException(AlemnoException):
"""Raised when the uploaded CSV is invalid"""
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.")

11
app/core/logging.py Normal file
View File

@@ -0,0 +1,11 @@
import logging
import sys
# Configure logging format
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
handlers=[logging.StreamHandler(sys.stdout)],
)
logger = logging.getLogger("alemno_payments")

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

@@ -1,4 +1,7 @@
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"]
__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)

56
app/db/models/job.py Normal file
View File

@@ -0,0 +1,56 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import List, Optional, TYPE_CHECKING
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
if TYPE_CHECKING:
from app.db.models.transaction import Transaction
from app.db.models.job_summary import JobSummary
class Job(Base):
__tablename__ = "jobs"
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
index=True,
)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(
String(50),
default="pending",
index=True,
)
row_count_raw: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
row_count_clean: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
completed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
error_message: Mapped[Optional[str]] = mapped_column(
String(500),
nullable=True,
)
# Relationships
transactions: Mapped[List["Transaction"]] = relationship(
"Transaction",
back_populates="job",
cascade="all, delete-orphan",
)
summary: Mapped[Optional["JobSummary"]] = relationship(
"JobSummary",
back_populates="job",
cascade="all, delete-orphan",
uselist=False,
)

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
import uuid
from typing import Any, Dict, Optional, TYPE_CHECKING
from sqlalchemy import Float, ForeignKey, Integer, JSON, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
if TYPE_CHECKING:
from app.db.models.job import Job
class JobSummary(Base):
__tablename__ = "job_summaries"
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
index=True,
)
job_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("jobs.id", ondelete="CASCADE"),
nullable=False,
unique=True,
index=True,
)
total_spend_inr: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
total_spend_usd: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
top_merchants: Mapped[Dict[str, Any]] = mapped_column(
JSON, nullable=False, default=dict
)
anomaly_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
narrative: Mapped[Optional[str]] = mapped_column(String(2000), nullable=True)
risk_level: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
# Relationships
job: Mapped["Job"] = relationship("Job", back_populates="summary")

View File

@@ -0,0 +1,43 @@
from __future__ import annotations
import uuid
from datetime import date
from typing import Optional, TYPE_CHECKING
from sqlalchemy import Boolean, Date, Float, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.base import Base
if TYPE_CHECKING:
from app.db.models.job import Job
class Transaction(Base):
__tablename__ = "transactions"
id: Mapped[str] = mapped_column(
String(36),
primary_key=True,
default=lambda: str(uuid.uuid4()),
index=True,
)
job_id: Mapped[str] = mapped_column(
String(36),
ForeignKey("jobs.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
txn_id: Mapped[str] = mapped_column(String(100), nullable=False)
date: Mapped[date] = mapped_column(Date, nullable=False)
merchant: Mapped[str] = mapped_column(String(255), nullable=False)
amount: Mapped[float] = mapped_column(Float, nullable=False)
currency: Mapped[str] = mapped_column(String(10), nullable=False, default="INR")
status: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
account_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
is_anomaly: Mapped[bool] = mapped_column(Boolean, default=False)
anomaly_reason: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
llm_category: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
llm_raw_response: Mapped[Optional[str]] = mapped_column(String(1000), nullable=True)
llm_failed: Mapped[Optional[bool]] = mapped_column(Boolean, default=False)
# Relationships
job: Mapped["Job"] = relationship("Job", back_populates="transactions")

32
app/db/session.py Normal file
View File

@@ -0,0 +1,32 @@
from typing import AsyncGenerator
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.core.config import settings
# Create async database engine
engine = create_async_engine(
settings.DATABASE_URL,
echo=False,
future=True,
pool_size=10,
max_overflow=20,
)
# Async session factory
AsyncSessionLocal = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""Dependency for obtaining an asynchronous database session"""
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()

View File

@@ -1,6 +1,66 @@
def main():
print("Hello from alemno-payments!")
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
if __name__ == "__main__":
main()
@asynccontextmanager
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()
app = FastAPI(
title=settings.PROJECT_NAME,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
lifespan=lifespan,
)
# CORS Middleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 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
@app.get("/health", tags=["health"])
def health_check():
return {"status": "ok", "project": settings.PROJECT_NAME}

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()

View File

@@ -1,7 +1,10 @@
from typing import List, Optional, Any, Dict
from typing import List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.db.models.job import Job
from app.db.models.transaction import Transaction
from app.db.models.job_summary import JobSummary
class JobRepository:
@@ -9,18 +12,25 @@ class JobRepository:
self.db = db
async def create(self, filename: str) -> Job:
"""Create a new job record with status='pending'"""
job = Job(filename=filename, status="pending")
self.db.add(job)
await self.db.flush()
return job
async def get_by_id(self, job_id: str) -> Optional[Job]:
query = select(Job).where(Job.id == job_id)
"""Fetch a job record by its ID, eagerly loading summary and transactions"""
query = (
select(Job)
.options(selectinload(Job.summary), selectinload(Job.transactions))
.where(Job.id == job_id)
)
result = await self.db.execute(query)
return result.scalar_one_or_none()
async def list_all(self, status: Optional[str] = None) -> List[Job]:
query = select(Job)
"""List all job records, optionally filtered by status, eagerly loading summary"""
query = select(Job).options(selectinload(Job.summary))
if status:
query = query.where(Job.status == status)
query = query.order_by(Job.created_at.desc())
@@ -28,8 +38,20 @@ class JobRepository:
return list(result.scalars().all())
async def update(self, job: Job, **kwargs) -> Job:
"""Update job fields dynamically"""
for key, value in kwargs.items():
if hasattr(job, key):
setattr(job, key, value)
await self.db.flush()
return job
async def add_transactions(self, transactions: List[Transaction]) -> None:
"""Add multiple transaction records in bulk"""
self.db.add_all(transactions)
await self.db.flush()
async def add_summary(self, summary: JobSummary) -> JobSummary:
"""Add job summary record"""
self.db.add(summary)
await self.db.flush()
return summary

View File

@@ -3,6 +3,8 @@ from app.schemas.job import (
JobResponse,
JobStatusResponse,
JobResultResponse,
TransactionResponse,
JobSummaryResponse,
)
__all__ = [
@@ -10,4 +12,6 @@ __all__ = [
"JobResponse",
"JobStatusResponse",
"JobResultResponse",
"TransactionResponse",
"JobSummaryResponse",
]

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

@@ -1,8 +1,41 @@
from datetime import datetime
from datetime import date, datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict
class TransactionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
job_id: str
txn_id: str
date: date
merchant: str
amount: float
currency: str
status: Optional[str] = None
category: Optional[str] = None
account_id: Optional[str] = None
is_anomaly: bool
anomaly_reason: Optional[str] = None
llm_category: Optional[str] = None
llm_raw_response: Optional[str] = None
llm_failed: Optional[bool] = False
class JobSummaryResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: str
job_id: str
total_spend_inr: float
total_spend_usd: float
top_merchants: Dict[str, Any]
anomaly_count: int
narrative: Optional[str] = None
risk_level: Optional[str] = None
class JobBase(BaseModel):
filename: str
@@ -16,9 +49,11 @@ class JobResponse(JobBase):
id: str
status: str
row_count: Optional[int] = None
row_count_raw: Optional[int] = None
row_count_clean: Optional[int] = None
created_at: datetime
updated_at: datetime
completed_at: Optional[datetime] = None
error_message: Optional[str] = None
class JobStatusResponse(BaseModel):
@@ -26,7 +61,7 @@ class JobStatusResponse(BaseModel):
id: str
status: str
summary: Optional[Dict[str, Any]] = None
summary: Optional[JobSummaryResponse] = None
class JobResultResponse(BaseModel):
@@ -34,4 +69,5 @@ class JobResultResponse(BaseModel):
id: str
status: str
results: Optional[Dict[str, Any]] = None
summary: Optional[JobSummaryResponse] = None
transactions: List[TransactionResponse] = []

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

@@ -0,0 +1,42 @@
from typing import List, Optional
from app.repositories.job_repository import JobRepository
from app.core.exceptions import JobNotFoundException
from app.db.models.job import Job
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)
job = await self.repo.create(filename=filename)
process_transaction_job.delay(job.id, parsed_transactions)
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)

134
app/utils/csv_parser.py Normal file
View File

@@ -0,0 +1,134 @@
import csv
import io
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.
Supports flexible mapping of common synonyms for headers.
Returns a list of dicts. Raises InvalidCSVException if the file is invalid.
"""
try:
decoded = content.decode("utf-8")
except UnicodeDecodeError as e:
raise InvalidCSVException("CSV file must be UTF-8 encoded text") from e
csv_file = io.StringIO(decoded)
reader = csv.DictReader(csv_file)
if not reader.fieldnames:
raise InvalidCSVException("CSV file is empty or missing headers")
header_mapping = {}
for actual_header in reader.fieldnames:
if not actual_header:
continue
cleaned = actual_header.strip().lower()
# Map synonyms
if cleaned in {"txn_id", "transaction_id", "id"}:
header_mapping["txn_id"] = actual_header
elif cleaned == "date":
header_mapping["date"] = actual_header
elif cleaned in {"merchant", "description"}:
header_mapping["merchant"] = actual_header
elif cleaned == "amount":
header_mapping["amount"] = actual_header
elif cleaned == "currency":
header_mapping["currency"] = actual_header
elif cleaned == "category":
header_mapping["category"] = actual_header
elif cleaned in {"account_id", "account"}:
header_mapping["account_id"] = actual_header
# Verify all required columns are present in mapping
missing = REQUIRED_COLUMNS - set(header_mapping.keys())
if missing:
raise InvalidCSVException(f"Missing required CSV columns: {', '.join(missing)}")
parsed_rows = []
for line_num, row in enumerate(reader, start=2):
txn_id = row.get(header_mapping["txn_id"])
date_str = row.get(header_mapping["date"])
amount_str = row.get(header_mapping["amount"])
merchant = row.get(header_mapping["merchant"])
# Optional columns
currency = (
row.get(header_mapping.get("currency", ""))
if "currency" in header_mapping
else "INR"
)
category = (
row.get(header_mapping.get("category", ""))
if "category" in header_mapping
else None
)
account_id = (
row.get(header_mapping.get("account_id", ""))
if "account_id" in header_mapping
else None
)
if not all([date_str, amount_str, merchant]):
raise InvalidCSVException(
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(cleaned_amount_str)
except ValueError as e:
raise InvalidCSVException(
f"Row {line_num}: Invalid amount '{amount_str}'. Must be a number."
) from e
# Validate date (supporting multiple formats)
parsed_date = None
for fmt in ("%Y-%m-%d", "%m/%d/%Y", "%d-%m-%Y", "%Y/%m/%d"):
try:
parsed_date = datetime.strptime(date_str.strip(), fmt).date()
break
except ValueError:
continue
if not parsed_date:
raise InvalidCSVException(
f"Row {line_num}: Invalid date format '{date_str}'. Expected YYYY-MM-DD or MM/DD/YYYY"
)
parsed_rows.append(
{
"txn_id": txn_id.strip(),
"date": parsed_date.isoformat(),
"amount": amount,
"merchant": merchant.strip(),
"currency": currency.strip() if currency else "INR",
"category": category.strip() if category else None,
"account_id": account_id.strip() if account_id else None,
}
)
if not parsed_rows:
raise InvalidCSVException("CSV file contains no data rows")
return parsed_rows

699
app/worker.py Normal file
View File

@@ -0,0 +1,699 @@
import asyncio
import json
import os
import statistics
from datetime import datetime, date
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
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",
broker=settings.CELERY_BROKER_URL,
backend=settings.CELERY_RESULT_BACKEND,
)
# Optional configurations
celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="UTC",
enable_utc=True,
)
def run_async(coro):
"""Helper to run async functions synchronously in Celery worker context"""
return asyncio.run(coro)
@celery_app.task(name="tasks.process_transaction_job")
def process_transaction_job(job_id: str, transactions: List[Dict[str, Any]]):
"""
Background job to clean transactions, detect anomalies, calculate spend breakdown,
generate narrative summary, and persist results to Transaction and JobSummary tables.
"""
logger.info(f"Starting Celery task to process job_id: {job_id}")
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]]):
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
# 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
merchant_spends: Dict[str, float] = {}
anomaly_count = 0
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"]
# Parse date string to datetime.date object
parsed_date = (
date.fromisoformat(raw_date)
if isinstance(raw_date, str)
else raw_date
)
merchant = t["merchant"].strip()
amount = float(t["amount"])
currency = t.get("currency", "INR").strip().upper()
category = t.get("category") or ""
account_id = t.get("account_id")
# Spend calculation and currency conversion
if currency == "USD":
amt_usd = amount
amt_inr = amount * USD_TO_INR
else:
amt_inr = amount
amt_usd = amount / USD_TO_INR
total_spend_inr += amt_inr
total_spend_usd += amt_usd
# Aggregate merchant spend (in USD for standardization)
merchant_spends[merchant] = merchant_spends.get(merchant, 0.0) + amt_usd
# 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:
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
# 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,
txn_id=txn_id,
date=parsed_date,
merchant=merchant,
amount=amount,
currency=currency,
status="cleaned",
category=category,
account_id=account_id,
is_anomaly=is_anomaly,
anomaly_reason=anomaly_reason,
llm_category=llm_category,
llm_raw_response=llm_raw_response,
llm_failed=llm_failed,
)
db_transactions.append(db_txn)
with benchmark("Save Cleaned Transactions to DB"):
await repo.add_transactions(db_transactions)
# 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}")
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"
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
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()
logger.info(f"Job {job_id} processing completed successfully.")
except Exception as e:
logger.exception(f"Error processing job {job_id}")
await repo.update(
job,
status="failed",
error_message=str(e),
completed_at=datetime.utcnow(),
)
await db.commit()
raise 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

@@ -6,6 +6,26 @@ services:
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"
volumes:
- redis_data:/data
web:
build: .
container_name: alemno_web
@@ -13,8 +33,46 @@ services:
- "3000:3000"
volumes:
- .:/app
- /app/.venv
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
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
worker:
build: .
container_name: alemno_worker
volumes:
- .:/app
- /app/.venv
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
command: celery -A app.worker.celery_app worker --loglevel=info
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
volumes:
postgres_data:
redis_data:

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

@@ -2,4 +2,3 @@ import uvicorn
if __name__ == "__main__":
uvicorn.run("app.app:app", host="0.0.0.0", port=3000, reload=True)

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",
@@ -22,3 +23,9 @@ dependencies = [
[tool.pytest.ini_options]
pythonpath = ["."]
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"]

149
tests/api/test_jobs_api.py Normal file
View File

@@ -0,0 +1,149 @@
import io
import pytest
from httpx import AsyncClient
from app.worker import celery_app
# Mark all tests as async
pytestmark = pytest.mark.asyncio
# Set celery to run tasks eagerly (synchronously) during API testing
celery_app.conf.task_always_eager = True
async def test_upload_csv_success(client: AsyncClient):
csv_content = (
"txn_id,date,amount,merchant,currency,category,account\n"
"TX1001,2026-06-25,120.50,Weekly grocery shop,INR,Groceries,ACC01\n"
"TX1002,06/26/2026,6000.00,New Laptop,USD,Electronics,ACC02\n" # Anomaly (> $5,000 USD equivalent)
)
files = {
"file": (
"transactions.csv",
io.BytesIO(csv_content.encode("utf-8")),
"text/csv",
)
}
# 1. Post upload CSV
response = await client.post("/api/v1/jobs/upload", files=files)
assert response.status_code == 201
data = response.json()
assert "id" in data
assert data["filename"] == "transactions.csv"
assert data["status"] == "pending" # Initial state returned by router immediately
job_id = data["id"]
# Since Celery is in eager mode, the task should have completed immediately.
# 2. Get Job Status
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["id"] == job_id
assert status_data["status"] == "completed"
summary = status_data["summary"]
assert summary is not None
assert summary["anomaly_count"] == 1
assert (
summary["total_spend_usd"] > 6000.0
) # TX1002 is $6,000, TX1001 is INR 120.50 ($1.45)
assert "New Laptop" in summary["top_merchants"]
assert summary["risk_level"] == "Medium" # 1 anomaly
# 3. Get Job Results
results_response = await client.get(f"/api/v1/jobs/{job_id}/results")
assert results_response.status_code == 200
results_data = results_response.json()
assert results_data["id"] == job_id
assert results_data["status"] == "completed"
transactions_list = results_data["transactions"]
assert len(transactions_list) == 2
# Assert TX1002 is flagged as an anomaly
laptop_txn = next(t for t in transactions_list if t["txn_id"] == "TX1002")
assert laptop_txn["is_anomaly"] is True
assert "High value transaction" in laptop_txn["anomaly_reason"]
assert laptop_txn["llm_category"] == "Electronics"
assert laptop_txn["llm_failed"] is False
grocery_txn = next(t for t in transactions_list if t["txn_id"] == "TX1001")
assert grocery_txn["is_anomaly"] is False
assert grocery_txn["currency"] == "INR"
async def test_upload_invalid_csv(client: AsyncClient):
# Invalid CSV structure (missing required column: merchant)
csv_content = "txn_id,date,amount\nTX1001,2026-06-25,120.50\n"
files = {
"file": (
"transactions.csv",
io.BytesIO(csv_content.encode("utf-8")),
"text/csv",
)
}
response = await client.post("/api/v1/jobs/upload", files=files)
assert response.status_code == 400
assert "Missing required CSV columns" in response.json()["detail"]
async def test_get_nonexistent_job(client: AsyncClient):
response = await client.get("/api/v1/jobs/nonexistent-id-xyz/status")
assert response.status_code == 404
assert "not found" in response.json()["detail"]
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"]

120
tests/conftest.py Normal file
View File

@@ -0,0 +1,120 @@
import asyncio
from typing import AsyncGenerator
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.core.config import settings
from app.core.dependencies import get_db
from app.db.base import Base
from app.main import app
# Use a test database suffix or a separate database for testing
TEST_DATABASE_URL = settings.DATABASE_URL.replace(
settings.POSTGRES_DB, f"test_{settings.POSTGRES_DB}"
)
# Create async engine for test db
test_engine = create_async_engine(
TEST_DATABASE_URL,
echo=False,
future=True,
)
TestAsyncSessionLocal = async_sessionmaker(
bind=test_engine,
class_=AsyncSession,
expire_on_commit=False,
)
# Global flag to track if test DB is accessible
_db_offline = False
@pytest.fixture(scope="session", autouse=True)
def initialize_test_db():
"""Create test tables and clean them up after all tests finish."""
global _db_offline
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
async def setup_db():
async with test_engine.begin() as conn:
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)
try:
loop.run_until_complete(setup_db())
except Exception as e:
_db_offline = True
print(f"\nSkipping DB initialization (Postgres is likely offline): {e}\n")
yield
if not _db_offline:
try:
loop.run_until_complete(teardown_db())
except Exception:
pass
@pytest.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
"""Provide a transactional test database session."""
if _db_offline:
pytest.skip("PostgreSQL database is offline")
return
async with TestAsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
@pytest.fixture
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
"""Provide an HTTPX AsyncClient for FastAPI endpoint testing with db overrides."""
# Override get_db dependency to use the test session
async def override_get_db() -> AsyncGenerator[AsyncSession, None]:
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test",
) as client:
yield client
app.dependency_overrides.clear()

View File

@@ -0,0 +1,84 @@
from datetime import date
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from app.repositories.job_repository import JobRepository
from app.db.models.transaction import Transaction
from app.db.models.job_summary import JobSummary
# Set up asyncio marker
pytestmark = pytest.mark.asyncio
async def test_create_and_get_job(db_session: AsyncSession):
repo = JobRepository(db_session)
# Create job
job = await repo.create(filename="test_transactions.csv")
assert job.id is not None
assert job.status == "pending"
assert job.filename == "test_transactions.csv"
# Retrieve job
fetched_job = await repo.get_by_id(job.id)
assert fetched_job is not None
assert fetched_job.id == job.id
assert fetched_job.filename == "test_transactions.csv"
async def test_update_job_and_add_relations(db_session: AsyncSession):
repo = JobRepository(db_session)
job = await repo.create(filename="update_test.csv")
# Update status and count
updated = await repo.update(
job, status="completed", row_count_raw=10, row_count_clean=8
)
assert updated.status == "completed"
assert updated.row_count_raw == 10
assert updated.row_count_clean == 8
# Create & add summary
summary = JobSummary(
job_id=job.id,
total_spend_inr=15000.0,
total_spend_usd=180.72,
top_merchants={"Merchant A": 100.0},
anomaly_count=1,
narrative="Good",
risk_level="Low",
)
await repo.add_summary(summary)
# Create & add transaction
txn = Transaction(
job_id=job.id,
txn_id="TX001",
date=date(2026, 6, 25),
merchant="Merchant A",
amount=100.0,
currency="USD",
status="cleaned",
is_anomaly=False,
)
await repo.add_transactions([txn])
# Retrieve to verify persistence
fetched = await repo.get_by_id(job.id)
assert fetched.status == "completed"
assert fetched.row_count_raw == 10
assert fetched.row_count_clean == 8
assert fetched.summary is not None
assert fetched.summary.total_spend_usd == 180.72
assert len(fetched.transactions) == 1
assert fetched.transactions[0].txn_id == "TX001"
async def test_list_jobs(db_session: AsyncSession):
repo = JobRepository(db_session)
await repo.create(filename="list_1.csv")
await repo.create(filename="list_2.csv")
jobs = await repo.list_all()
# At least the ones we created in this test session
assert len(jobs) >= 2

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

@@ -0,0 +1,66 @@
import pytest
from app.core.exceptions import InvalidCSVException
from app.utils.csv_parser import parse_and_validate_csv
def test_parse_valid_csv():
valid_csv = (
"transaction_id,date,amount,category,description,currency,account\n"
"TX1001,2026-06-25,120.50,Groceries,Weekly grocery shop,INR,ACC01\n"
"TX1002,06/26/2026,15.00,Coffee,Morning latte,USD,ACC02\n"
)
result = parse_and_validate_csv(valid_csv.encode("utf-8"))
assert len(result) == 2
assert result[0]["txn_id"] == "TX1001"
assert result[0]["amount"] == 120.50
assert result[0]["date"] == "2026-06-25"
assert result[0]["merchant"] == "Weekly grocery shop"
assert result[0]["currency"] == "INR"
assert result[0]["account_id"] == "ACC01"
assert result[1]["txn_id"] == "TX1002"
assert result[1]["amount"] == 15.00
assert result[1]["date"] == "2026-06-26"
assert result[1]["merchant"] == "Morning latte"
assert result[1]["currency"] == "USD"
assert result[1]["account_id"] == "ACC02"
def test_parse_missing_headers():
invalid_csv = (
"txn_id,amount,category,description\n"
"TX1001,120.50,Groceries,Weekly grocery shop\n"
)
with pytest.raises(InvalidCSVException) as exc_info:
parse_and_validate_csv(invalid_csv.encode("utf-8"))
assert "Missing required CSV columns" in str(exc_info.value)
def test_parse_invalid_amount():
invalid_csv = (
"txn_id,date,amount,merchant\nTX1001,2026-06-25,abc,Weekly grocery shop\n"
)
with pytest.raises(InvalidCSVException) as exc_info:
parse_and_validate_csv(invalid_csv.encode("utf-8"))
assert "Invalid amount" in str(exc_info.value)
def test_parse_invalid_date():
invalid_csv = (
"txn_id,date,amount,merchant\nTX1001,invalid-date,120.50,Weekly grocery shop\n"
)
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,12 @@
import pytest
from app.clients.exchange_rate_client import ExchangeRateClient
pytestmark = pytest.mark.asyncio
async def test_get_usd_to_inr_rate():
client = ExchangeRateClient()
rate = await client.get_usd_to_inr_rate()
assert isinstance(rate, float)
# The rate should be positive (either the fetched rate or the default fallback of 83.0)
assert rate > 0.0

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

363
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" },
@@ -21,11 +22,18 @@ dependencies = [
{ name = "uvicorn" },
]
[package.dev-dependencies]
dev = [
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
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" },
@@ -37,6 +45,12 @@ requires-dist = [
{ name = "uvicorn", specifier = ">=0.49.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "pytest-cov", specifier = ">=6.0.0" },
{ name = "ruff", specifier = ">=0.15.20" },
]
[[package]]
name = "amqp"
version = "5.3.1"
@@ -141,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"
@@ -199,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"
@@ -215,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"
@@ -354,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"
@@ -461,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"
@@ -500,6 +769,46 @@ 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"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" },
{ url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" },
{ url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" },
{ url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" },
{ url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" },
{ url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" },
{ url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" },
{ url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" },
{ url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" },
{ url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" },
{ url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" },
{ url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" },
{ url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" },
{ url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" },
{ url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" },
]
[[package]]
name = "six"
version = "1.17.0"
@@ -509,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"
@@ -548,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"
@@ -590,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"
@@ -620,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" },
]