mirror of
https://github.com/sortedcord/alemno-payments.git
synced 2026-07-22 12:12:49 +05:30
Compare commits
29 Commits
22f300474f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| a55eccf8e1 | |||
| 573d8d0d86 | |||
| 8100323af5 | |||
| 1902cd2d03 | |||
| 61d78af5c1 | |||
| c9ec297a5f | |||
| 9713a12591 | |||
| a3f2db0760 | |||
| 9c7c7b286e | |||
| 28ea349d51 | |||
| 9e1a5f83d4 | |||
| 8488e3bdd0 | |||
| be6c95c497 | |||
| a305bba66d | |||
| b4049630ed | |||
| f3727af697 | |||
| 2e556173d4 | |||
| 40881b4773 | |||
| d980558bee | |||
| 0cc2b5fe57 | |||
| c00f27aa30 | |||
| 265c6704a7 | |||
| 0b20fd3263 | |||
| fa48a39277 | |||
| bcf99c66cd | |||
| 22b5cd8ced | |||
| f63cd4774c | |||
| a2a25d7bfb | |||
| 47b2db8a15 |
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.venv
|
||||
.git
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
*.log
|
||||
@@ -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
76
.github/workflows/docker.yml
vendored
Normal 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
26
.github/workflows/lint.yml
vendored
Normal 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
57
.github/workflows/security.yml
vendored
Normal 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
60
.github/workflows/tests.yml
vendored
Normal 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
2
.gitignore
vendored
@@ -6,6 +6,8 @@ dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
.env
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
|
||||
195
README.md
195
README.md
@@ -1 +1,194 @@
|
||||
# Alemno Payments
|
||||
# Alemeno Payments
|
||||
|
||||
[](file:///home/sortedcord/Projects/alemno_payments/pyproject.toml)
|
||||
[](file:///home/sortedcord/Projects/alemno_payments/app/app.py)
|
||||
[](https://github.com/sortedcord/alemno-payments/actions/workflows/tests.yml)
|
||||
[](https://github.com/sortedcord/alemno-payments/actions/workflows/lint.yml)
|
||||
[](https://github.com/sortedcord/alemno-payments/actions/workflows/docker.yml)
|
||||
[](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.
|
||||
|
||||
[](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
35
app/api/v1/categories.py
Normal 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
84
app/api/v1/jobs.py
Normal 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)
|
||||
74
app/clients/exchange_rate_client.py
Normal file
74
app/clients/exchange_rate_client.py
Normal 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
51
app/core/config.py
Normal 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
21
app/core/dependencies.py
Normal 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
28
app/core/exceptions.py
Normal 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
11
app/core/logging.py
Normal 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
59
app/core/observability.py
Normal 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
|
||||
@@ -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
10
app/db/models/category.py
Normal 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
56
app/db/models/job.py
Normal 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,
|
||||
)
|
||||
38
app/db/models/job_summary.py
Normal file
38
app/db/models/job_summary.py
Normal 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")
|
||||
43
app/db/models/transaction.py
Normal file
43
app/db/models/transaction.py
Normal 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
32
app/db/session.py
Normal 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()
|
||||
68
app/main.py
68
app/main.py
@@ -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}
|
||||
|
||||
28
app/repositories/category_repository.py
Normal file
28
app/repositories/category_repository.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
17
app/schemas/category.py
Normal 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)
|
||||
@@ -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] = []
|
||||
|
||||
26
app/services/category_service.py
Normal file
26
app/services/category_service.py
Normal 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
|
||||
42
app/services/job_service.py
Normal file
42
app/services/job_service.py
Normal 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
134
app/utils/csv_parser.py
Normal 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
699
app/worker.py
Normal 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
896
coverage.xml
Normal 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>
|
||||
@@ -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
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 |
0
docs/assets/overview_diagram.svg
Normal file
0
docs/assets/overview_diagram.svg
Normal file
1
main.py
1
main.py
@@ -2,4 +2,3 @@ import uvicorn
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run("app.app:app", host="0.0.0.0", port=3000, reload=True)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
32
tests/api/test_categories_api.py
Normal file
32
tests/api/test_categories_api.py
Normal 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
149
tests/api/test_jobs_api.py
Normal 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
120
tests/conftest.py
Normal 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()
|
||||
84
tests/repositories/test_job_repository.py
Normal file
84
tests/repositories/test_job_repository.py
Normal 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
|
||||
53
tests/services/test_benchmarker.py
Normal file
53
tests/services/test_benchmarker.py
Normal 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
|
||||
)
|
||||
66
tests/services/test_csv_parser.py
Normal file
66
tests/services/test_csv_parser.py
Normal 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
|
||||
12
tests/services/test_exchange_rate.py
Normal file
12
tests/services/test_exchange_rate.py
Normal 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
|
||||
48
tests/services/test_llm_classification.py
Normal file
48
tests/services/test_llm_classification.py
Normal 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
|
||||
77
tests/services/test_llm_summary.py
Normal file
77
tests/services/test_llm_summary.py
Normal 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
96
transactions.csv
Normal 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,
|
||||
|
363
uv.lock
generated
363
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user