Compare commits

...

27 Commits

Author SHA1 Message Date
Elisiário Couto
9e9b1cf15f refactor(api): Update all modified files with dependency injection changes. 2025-12-10 00:18:53 +00:00
Elisiário Couto
9dc6357905 refactor(api): Remove DatabaseService layer and implement dependency injection.
- Remove DatabaseService abstraction layer from API routes
- Implement FastAPI dependency injection for repositories
- Create leggen/api/dependencies.py with factory functions
- Update routes to use AccountRepo, BalanceRepo, TransactionRepo directly
- Refactor SyncService to use repositories instead of DatabaseService
- Deprecate DatabaseService with warnings for backward compatibility
- Update all tests to use FastAPI dependency overrides pattern
- Fix mypy type errors in routes

Benefits:
- Simpler architecture with one less abstraction layer
- More explicit dependencies via function signatures
- Better testability with FastAPI's app.dependency_overrides
- Clearer separation of concerns

All 114 tests passing, mypy clean.
2025-12-10 00:18:53 +00:00
Elisiário Couto
5f87991076 refactor(api): Split DatabaseService into repository pattern.
Split the monolithic DatabaseService (1,492 lines) into focused repository
modules using the repository pattern for better maintainability and
separation of concerns.

Changes:
- Create new repositories/ directory with 5 focused repositories:
  - TransactionRepository: transaction data operations (264 lines)
  - AccountRepository: account data operations (128 lines)
  - BalanceRepository: balance data operations (107 lines)
  - MigrationRepository: all database migrations (629 lines)
  - SyncRepository: sync operation tracking (132 lines)
  - BaseRepository: shared database connection logic (28 lines)

- Refactor DatabaseService into a clean facade (287 lines):
  - Delegates data access to repositories
  - Maintains public API (no breaking changes)
  - Keeps data processors in service layer
  - Preserves require_sqlite decorator

- Update tests to mock repository methods instead of private methods
- Fix test references to internal methods (_persist_*, _get_*)

Benefits:
- Clear separation of concerns (one repository per domain)
- Easier maintenance (changes isolated to specific repositories)
- Better testability (repositories can be mocked individually)
- Improved code organization (from 1 file to 7 focused files)

All 114 tests passing.
2025-12-08 23:21:55 +00:00
Elisiário Couto
267db8ac63 refactor(api): Improve database connection management and reduce boilerplate.
- Add context manager for database connections with proper cleanup
- Add @require_sqlite decorator to eliminate duplicate checks
- Refactor 9 core CRUD methods to use managed connections
- Reduce code by 50 lines while improving resource management
- All 114 tests passing
2025-12-08 22:54:57 +00:00
Elisiário Couto
7007043521 Reformat. 2025-12-08 22:05:04 +00:00
Elisiário Couto
fbb3eb9e64 refactor: Consolidate service layer with dedicated data processors.
Introduces a DataProcessor layer to separate transformation logic from orchestration and persistence concerns:

- Created data_processors/ directory with AccountEnricher, BalanceTransformer, AnalyticsProcessor, and moved TransactionProcessor
- Refactored SyncService to pure orchestrator, removing account/balance enrichment logic
- Refactored DatabaseService to pure CRUD, removing analytics and transformation logic
- Extracted 90+ lines of analytics SQL from DatabaseService to AnalyticsProcessor
- Extracted 80+ lines of balance transformation logic to BalanceTransformer
- Maintained backward compatibility - all 109 tests pass
- No API contract changes

This improves code clarity, testability, and maintainability while maintaining the existing API surface.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2025-12-08 22:04:00 +00:00
Elisiário Couto
3d5994bf30 Fix lint issues. 2025-12-08 21:59:54 +00:00
Elisiário Couto
edbc1cb39e Update frontend/src/components/TransactionsTable.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-08 21:59:54 +00:00
Elisiário Couto
504f78aa85 Update frontend/src/components/filters/FilterBar.tsx
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-08 21:59:54 +00:00
Elisiário Couto
cbbc316537 chore(ci): Fix workflow permissions. 2025-12-08 21:59:54 +00:00
Elisiário Couto
18ee52bdff fix(frontend): Prevent full transactions page reload on search. 2025-12-08 21:59:54 +00:00
Elisiário Couto
07edfeaf25 fix(frontend): Blur balances in transactions page cards. 2025-12-08 21:59:54 +00:00
copilot-swe-agent[bot]
c8b161e7f2 refactor(frontend): Address code review feedback on focus and currency handling.
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-12-08 21:59:54 +00:00
copilot-swe-agent[bot]
2c85722fd0 feat(frontend): Fix search focus issue and add transaction statistics.
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-12-08 21:59:54 +00:00
copilot-swe-agent[bot]
88037f328d fix: Address code review feedback on notification error handling.
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-12-07 19:05:28 +00:00
copilot-swe-agent[bot]
d58894d07c refactor: Replace magic numbers with named constants.
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-12-07 19:05:28 +00:00
copilot-swe-agent[bot]
1a2ec45f89 feat: Add sync error and account expiry notifications.
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-12-07 19:05:28 +00:00
Elisiário Couto
5de9badfde fix(frontend): Blur balances in Account Management page. 2025-12-07 12:00:23 +00:00
Elisiário Couto
159cba508e fix: Resolve all lint warnings and type errors across frontend and backend.
Frontend:
- Memoize pagination object in TransactionsTable to prevent unnecessary re-renders and fix exhaustive-deps warning
- Add optional success and message fields to backup API response types for proper error handling

Backend:
- Add TypedDict for transaction type configuration to improve type safety in generate_sample_db
- Fix unpacking of amount_range with explicit float type hints
- Add explicit type hints for descriptions dictionary and specific_descriptions variable
- Fix sync endpoint return types: get_sync_status returns SyncStatus and sync_now returns SyncResult
- Fix transactions endpoint data type declaration to properly support Union types in PaginatedResponse

All checks now pass:
- Frontend: npm lint and npm build ✓
- Backend: mypy type checking ✓
- Backend: ruff lint on modified files ✓

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2025-12-07 12:00:23 +00:00
copilot-swe-agent[bot]
966440006a fix(frontend): Remove unused import in TransactionDistribution
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-12-07 12:00:23 +00:00
copilot-swe-agent[bot]
a592b827aa feat(frontend): Add balance visibility toggle with blur effect
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-12-07 12:00:23 +00:00
Elisiário Couto
fabea404ef refactor: Remove API response wrapper pattern.
Replace wrapped responses {success, data, message} with direct data returns
following REST best practices. Simplifies 41 endpoints across 7 route files
and updates all 109 tests. Also fixes test config setup to not require
user home directory config file.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2025-12-07 00:54:51 +00:00
Elisiário Couto
a75365d805 chore: Update dependencies. 2025-11-23 23:10:23 +00:00
Elisiário Couto
31abe68b2a chore: Merge sample data scripts. 2025-11-23 23:08:57 +00:00
Elisiário Couto
5eecc72219 chore(ci): Bump version to 2025.11.0 2025-11-22 21:02:01 +00:00
Elisiário Couto
b1b348badb fix: Fallback to internal_transaction_id when bank transactions do not have transaction_id. 2025-11-22 21:00:45 +00:00
copilot-swe-agent[bot]
d2bc179d59 fix(frontend): Apply iOS safe area insets to body element instead of individual components.
Co-authored-by: elisiariocouto <818914+elisiariocouto@users.noreply.github.com>
2025-10-16 00:17:21 +01:00
63 changed files with 7132 additions and 4516 deletions

View File

@@ -6,6 +6,9 @@ on:
pull_request:
branches: ["main", "dev"]
permissions:
contents: read
jobs:
test-python:
name: Test Python

View File

@@ -5,6 +5,11 @@ on:
tags:
- "**"
permissions:
contents: write
packages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
@@ -44,6 +49,9 @@ jobs:
push-docker-backend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -90,6 +98,9 @@ jobs:
push-docker-frontend:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -137,6 +148,8 @@ jobs:
create-github-release:
name: Create GitHub Release
runs-on: ubuntu-latest
permissions:
contents: write
needs: [build, publish-to-pypi, push-docker-backend, push-docker-frontend]
steps:
- name: Checkout

View File

@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: "v0.13.0"
rev: "v0.14.6"
hooks:
- id: ruff-check
- id: ruff-format

View File

@@ -1,4 +1,13 @@
## 2025.11.0 (2025/11/22)
### Bug Fixes
- **frontend:** Apply iOS safe area insets to body element instead of individual components. ([d2bc179d](https://github.com/elisiariocouto/leggen/commit/d2bc179d5937172a01ebbfffd35e7617f0ac32af))
- Fallback to internal_transaction_id when bank transactions do not have transaction_id. ([b1b348ba](https://github.com/elisiariocouto/leggen/commit/b1b348badb5d1ea9c01ef9ecab1003252165468c))
## 2025.10.2 (2025/10/06)
### Bug Fixes

127
REFACTORING_SUMMARY.md Normal file
View File

@@ -0,0 +1,127 @@
# Backend Refactoring Summary
## What Was Accomplished ✅
### 1. Removed DatabaseService Layer from Production Code
- **Removed**: The `DatabaseService` class is no longer used in production API routes
- **Replaced with**: Direct repository usage via FastAPI dependency injection
- **Files changed**:
- `leggen/api/routes/accounts.py` - Now uses `AccountRepo`, `BalanceRepo`, `TransactionRepo`, `AnalyticsProc`
- `leggen/api/routes/transactions.py` - Now uses `TransactionRepo`, `AnalyticsProc`
- `leggen/services/sync_service.py` - Now uses repositories directly
- `leggen/commands/server.py` - Now uses `MigrationRepository` directly
### 2. Created Dependency Injection System
- **New file**: `leggen/api/dependencies.py`
- **Provides**: Centralized dependency injection setup for FastAPI
- **Includes**: Factory functions for all repositories and data processors
- **Type annotations**: `AccountRepo`, `BalanceRepo`, `TransactionRepo`, etc.
### 3. Simplified Code Architecture
- **Before**: Routes → DatabaseService → Repositories
- **After**: Routes → Repositories (via DI)
- **Benefits**:
- One less layer of indirection
- Clearer dependencies
- Easier to test with FastAPI's `app.dependency_overrides`
- Better separation of concerns
### 4. Maintained Backward Compatibility
- **DatabaseService** is kept but deprecated for test compatibility
- Added deprecation warning when instantiated
- Tests continue to work without immediate changes required
## Code Statistics
- **Lines removed from API layer**: ~16 imports of DatabaseService
- **New dependency injection file**: 80 lines
- **Files refactored**: 4 main files
## Benefits Achieved
1. **Cleaner Architecture**: Removed unnecessary abstraction layer
2. **Better Testability**: FastAPI dependency overrides are cleaner than mocking
3. **More Explicit Dependencies**: Function signatures show exactly what's needed
4. **Easier to Maintain**: Less indirection makes code easier to follow
5. **Performance**: Slightly fewer object instantiations per request
## What Still Needs Work
### Tests Need Updating
The test files still patch `database_service` which no longer exists in routes:
```python
# Old test pattern (needs updating):
patch("leggen.api.routes.accounts.database_service.get_accounts_from_db")
# New pattern (should use):
app.dependency_overrides[get_account_repository] = lambda: mock_repo
```
**Files needing test updates**:
- `tests/unit/test_api_accounts.py` (7 tests failing)
- `tests/unit/test_api_transactions.py` (10 tests failing)
- `tests/unit/test_analytics_fix.py` (2 tests failing)
### Test Update Strategy
**Option 1 - Quick Fix (Recommended for now)**:
Keep `DatabaseService` and have routes import it again temporarily, update tests at leisure.
**Option 2 - Proper Fix**:
Update all tests to use FastAPI dependency overrides pattern:
```python
def test_get_accounts(fastapi_app, api_client, mock_account_repo):
mock_account_repo.get_accounts.return_value = [...]
fastapi_app.dependency_overrides[get_account_repository] = lambda: mock_account_repo
response = api_client.get("/api/v1/accounts")
fastapi_app.dependency_overrides.clear()
```
## Migration Path Forward
1.**Phase 1**: Refactor production code (DONE)
2. 🔄 **Phase 2**: Update tests to use dependency overrides (TODO)
3. 🔄 **Phase 3**: Remove deprecated `DatabaseService` completely (TODO)
4. 🔄 **Phase 4**: Consider extracting analytics logic to separate service (TODO)
## How to Use the New System
### In API Routes
```python
from leggen.api.dependencies import AccountRepo, BalanceRepo
@router.get("/accounts")
async def get_accounts(
account_repo: AccountRepo, # Injected automatically
balance_repo: BalanceRepo, # Injected automatically
) -> List[AccountDetails]:
accounts = account_repo.get_accounts()
# ...
```
### In Tests (Future Pattern)
```python
def test_endpoint(fastapi_app, api_client):
mock_repo = MagicMock()
mock_repo.get_accounts.return_value = [...]
fastapi_app.dependency_overrides[get_account_repository] = lambda: mock_repo
response = api_client.get("/api/v1/accounts")
# assertions...
```
## Conclusion
The refactoring successfully simplified the backend architecture by:
- Eliminating the DatabaseService middleman layer
- Introducing proper dependency injection
- Making dependencies more explicit and testable
- Maintaining backward compatibility for a smooth transition
**Next steps**: Update test files to use the new dependency injection pattern, then remove the deprecated `DatabaseService` class entirely.

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,7 @@ import {
import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import AccountsSkeleton from "./AccountsSkeleton";
import { BlurredValue } from "./ui/blurred-value";
import type { Account, Balance } from "../types/api";
// Helper function to get status indicator color and styles
@@ -158,7 +159,7 @@ export default function AccountsOverview() {
Total Balance
</p>
<p className="text-2xl font-bold text-foreground">
{formatCurrency(totalBalance)}
<BlurredValue>{formatCurrency(totalBalance)}</BlurredValue>
</p>
</div>
<div className="p-3 bg-green-100 dark:bg-green-900/20 rounded-full">
@@ -369,7 +370,9 @@ export default function AccountsOverview() {
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(balance, currency)}
<BlurredValue>
{formatCurrency(balance, currency)}
</BlurredValue>
</p>
</div>
</div>

View File

@@ -16,6 +16,7 @@ import { apiClient } from "../lib/api";
import { formatCurrency } from "../lib/utils";
import { useState } from "react";
import type { Account } from "../types/api";
import { BlurredValue } from "./ui/blurred-value";
import {
Sidebar,
SidebarContent,
@@ -61,7 +62,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
};
return (
<Sidebar collapsible="icon" className="pt-safe-top pl-safe-left" {...props}>
<Sidebar collapsible="icon" {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
@@ -130,7 +131,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<div className="px-3 pb-2">
<p className="text-xl font-bold text-foreground">
{formatCurrency(totalBalance)}
<BlurredValue>{formatCurrency(totalBalance)}</BlurredValue>
</p>
<p className="text-sm text-muted-foreground">
{accounts?.length || 0} accounts
@@ -163,7 +164,9 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
"Unnamed Account"}
</p>
<p className="text-xs font-semibold text-foreground">
{formatCurrency(primaryBalance, currency)}
<BlurredValue>
{formatCurrency(primaryBalance, currency)}
</BlurredValue>
</p>
</div>
</div>

View File

@@ -34,6 +34,7 @@ import { Button } from "./ui/button";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Label } from "./ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import { BlurredValue } from "./ui/blurred-value";
import AccountsSkeleton from "./AccountsSkeleton";
import NotificationFiltersDrawer from "./NotificationFiltersDrawer";
import DiscordConfigDrawer from "./DiscordConfigDrawer";
@@ -491,13 +492,13 @@ export default function Settings() {
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<p
<BlurredValue
className={`text-base sm:text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(balance, currency)}
</p>
</BlurredValue>
</div>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import { Activity, Wifi, WifiOff } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../lib/api";
import { ThemeToggle } from "./ui/theme-toggle";
import { BalanceToggle } from "./ui/balance-toggle";
import { Separator } from "./ui/separator";
import { SidebarTrigger } from "./ui/sidebar";
@@ -31,7 +32,7 @@ export function SiteHeader() {
});
return (
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear pt-safe-top">
<header className="flex h-16 shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear">
<div className="flex w-full items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
@@ -77,6 +78,7 @@ export function SiteHeader() {
</>
)}
</div>
<BalanceToggle />
<ThemeToggle />
</div>
</div>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
useReactTable,
@@ -31,7 +31,8 @@ import { DataTablePagination } from "./ui/data-table-pagination";
import { Card } from "./ui/card";
import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
import type { Account, Transaction, ApiResponse } from "../types/api";
import { BlurredValue } from "./ui/blurred-value";
import type { Account, Transaction, PaginatedResponse } from "../types/api";
export default function TransactionsTable() {
// Filter state consolidated into a single object
@@ -102,7 +103,7 @@ export default function TransactionsTable() {
isLoading: transactionsLoading,
error: transactionsError,
refetch: refetchTransactions,
} = useQuery<ApiResponse<Transaction[]>>({
} = useQuery<PaginatedResponse<Transaction>>({
queryKey: [
"transactions",
filterState.selectedAccount,
@@ -122,10 +123,52 @@ export default function TransactionsTable() {
search: debouncedSearchTerm || undefined,
summaryOnly: false,
}),
placeholderData: (previousData) => previousData,
});
const transactions = transactionsResponse?.data || [];
const pagination = transactionsResponse?.pagination;
const transactions = useMemo(
() => transactionsResponse?.data || [],
[transactionsResponse],
);
const pagination = useMemo(
() =>
transactionsResponse
? {
page: transactionsResponse.page,
total_pages: transactionsResponse.total_pages,
per_page: transactionsResponse.per_page,
total: transactionsResponse.total,
has_next: transactionsResponse.has_next,
has_prev: transactionsResponse.has_prev,
}
: undefined,
[transactionsResponse],
);
// Calculate stats from current page transactions, memoized for performance
const stats = useMemo(() => {
const totalIncome = transactions
.filter((t: Transaction) => t.transaction_value > 0)
.reduce((sum: number, t: Transaction) => sum + t.transaction_value, 0);
const totalExpenses = Math.abs(
transactions
.filter((t: Transaction) => t.transaction_value < 0)
.reduce((sum: number, t: Transaction) => sum + t.transaction_value, 0)
);
// Get currency from first transaction, fallback to EUR
const displayCurrency = transactions.length > 0 ? transactions[0].transaction_currency : "EUR";
return {
totalCount: pagination?.total || 0,
pageCount: transactions.length,
totalIncome,
totalExpenses,
netChange: totalIncome - totalExpenses,
displayCurrency,
};
}, [transactions, pagination]);
// Check if search is currently debouncing
const isSearchLoading = filterState.searchTerm !== debouncedSearchTerm;
@@ -221,11 +264,13 @@ export default function TransactionsTable() {
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{isPositive ? "+" : ""}
{formatCurrency(
transaction.transaction_value,
transaction.transaction_currency,
)}
<BlurredValue>
{isPositive ? "+" : ""}
{formatCurrency(
transaction.transaction_value,
transaction.transaction_currency,
)}
</BlurredValue>
</p>
</div>
);
@@ -350,6 +395,78 @@ export default function TransactionsTable() {
isSearchLoading={isSearchLoading}
/>
{/* Transaction Statistics */}
{transactions.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Showing
</p>
<p className="text-2xl font-bold text-foreground mt-1">
{stats.pageCount}
</p>
<p className="text-xs text-muted-foreground mt-1">
of {stats.totalCount} total
</p>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Income
</p>
<BlurredValue className="text-2xl font-bold text-green-600 mt-1 block">
+{formatCurrency(stats.totalIncome, stats.displayCurrency)}
</BlurredValue>
</div>
<TrendingUp className="h-8 w-8 text-green-600 opacity-50" />
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Expenses
</p>
<BlurredValue className="text-2xl font-bold text-red-600 mt-1 block">
-{formatCurrency(stats.totalExpenses, stats.displayCurrency)}
</BlurredValue>
</div>
<TrendingDown className="h-8 w-8 text-red-600 opacity-50" />
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wider">
Net Change
</p>
<BlurredValue
className={`text-2xl font-bold mt-1 block ${
stats.netChange >= 0 ? "text-green-600" : "text-red-600"
}`}
>
{stats.netChange >= 0 ? "+" : ""}
{formatCurrency(stats.netChange, stats.displayCurrency)}
</BlurredValue>
</div>
{stats.netChange >= 0 ? (
<TrendingUp className="h-8 w-8 text-green-600 opacity-50" />
) : (
<TrendingDown className="h-8 w-8 text-red-600 opacity-50" />
)}
</div>
</Card>
</div>
)}
{/* Responsive Table/Cards */}
<Card>
{/* Desktop Table View (hidden on mobile) */}
@@ -525,11 +642,13 @@ export default function TransactionsTable() {
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{isPositive ? "+" : ""}
{formatCurrency(
transaction.transaction_value,
transaction.transaction_currency,
)}
<BlurredValue>
{isPositive ? "+" : ""}
{formatCurrency(
transaction.transaction_value,
transaction.transaction_currency,
)}
</BlurredValue>
</p>
<Button
onClick={() => handleViewRaw(transaction)}

View File

@@ -8,6 +8,8 @@ import {
ResponsiveContainer,
Legend,
} from "recharts";
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
import { cn } from "../../lib/utils";
import type { Balance, Account } from "../../types/api";
interface BalanceChartProps {
@@ -42,6 +44,8 @@ export default function BalanceChart({
accounts,
className,
}: BalanceChartProps) {
const { isBalanceVisible } = useBalanceVisibility();
// Create a lookup map for account info
const accountMap = accounts.reduce(
(map, account) => {
@@ -149,7 +153,7 @@ export default function BalanceChart({
<h3 className="text-lg font-medium text-foreground mb-4">
Balance Progress Over Time
</h3>
<div className="h-80">
<div className={cn("h-80", !isBalanceVisible && "blur-md select-none")}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={finalData}>
<CartesianGrid strokeDasharray="3 3" />

View File

@@ -8,6 +8,8 @@ import {
ResponsiveContainer,
} from "recharts";
import { useQuery } from "@tanstack/react-query";
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
import { cn } from "../../lib/utils";
import apiClient from "../../lib/api";
interface MonthlyTrendsProps {
@@ -29,6 +31,8 @@ export default function MonthlyTrends({
className,
days = 365,
}: MonthlyTrendsProps) {
const { isBalanceVisible } = useBalanceVisibility();
// Get pre-calculated monthly stats from the new endpoint
const { data: monthlyData, isLoading } = useQuery({
queryKey: ["monthly-stats", days],
@@ -103,7 +107,7 @@ export default function MonthlyTrends({
<h3 className="text-lg font-medium text-foreground mb-4">
{getTitle(days)}
</h3>
<div className="h-80">
<div className={cn("h-80", !isBalanceVisible && "blur-md select-none")}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={displayData}

View File

@@ -1,5 +1,6 @@
import type { LucideIcon } from "lucide-react";
import { Card, CardContent } from "../ui/card";
import { BlurredValue } from "../ui/blurred-value";
import { cn } from "../../lib/utils";
interface StatCardProps {
@@ -13,6 +14,7 @@ interface StatCardProps {
};
className?: string;
iconColor?: "green" | "blue" | "red" | "purple" | "orange" | "default";
shouldBlur?: boolean;
}
export default function StatCard({
@@ -23,6 +25,7 @@ export default function StatCard({
trend,
className,
iconColor = "default",
shouldBlur = false,
}: StatCardProps) {
return (
<Card className={cn(className)}>
@@ -31,7 +34,9 @@ export default function StatCard({
<div>
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<div className="flex items-baseline">
<p className="text-2xl font-bold text-foreground">{value}</p>
<p className="text-2xl font-bold text-foreground">
{shouldBlur ? <BlurredValue>{value}</BlurredValue> : value}
</p>
{trend && (
<div
className={cn(

View File

@@ -6,6 +6,7 @@ import {
Tooltip,
Legend,
} from "recharts";
import { BlurredValue } from "../ui/blurred-value";
import type { Account } from "../../types/api";
interface TransactionDistributionProps {
@@ -85,7 +86,8 @@ export default function TransactionDistribution({
<div className="bg-card p-3 border rounded shadow-lg">
<p className="font-medium text-foreground">{data.name}</p>
<p className="text-primary">
Balance: {data.value.toLocaleString()}
Balance:{" "}
<BlurredValue>{data.value.toLocaleString()}</BlurredValue>
</p>
<p className="text-muted-foreground">{percentage}% of total</p>
</div>
@@ -138,7 +140,7 @@ export default function TransactionDistribution({
<span className="text-foreground">{item.name}</span>
</div>
<span className="font-medium text-foreground">
{item.value.toLocaleString()}
<BlurredValue>{item.value.toLocaleString()}</BlurredValue>
</span>
</div>
))}

View File

@@ -1,3 +1,4 @@
import { useRef, useEffect } from "react";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
@@ -30,6 +31,21 @@ export function FilterBar({
isSearchLoading = false,
className,
}: FilterBarProps) {
const searchInputRef = useRef<HTMLInputElement>(null);
const cursorPositionRef = useRef<number | null>(null);
// Maintain focus and cursor position on search input during re-renders
useEffect(() => {
const currentInput = searchInputRef.current;
if (!currentInput) return;
// Restore focus and cursor position after data fetches complete
if (cursorPositionRef.current !== null && document.activeElement !== currentInput) {
currentInput.focus();
currentInput.setSelectionRange(cursorPositionRef.current, cursorPositionRef.current);
}
}, [isSearchLoading]);
const hasActiveFilters =
filterState.searchTerm ||
filterState.selectedAccount ||
@@ -61,9 +77,19 @@ export function FilterBar({
<div className="relative w-[200px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder="Search transactions..."
value={filterState.searchTerm}
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
onChange={(e) => {
cursorPositionRef.current = e.target.selectionStart;
onFilterChange("searchTerm", e.target.value);
}}
onFocus={() => {
cursorPositionRef.current = searchInputRef.current?.selectionStart ?? null;
}}
onBlur={() => {
cursorPositionRef.current = null;
}}
className="pl-9 pr-8 bg-background"
/>
{isSearchLoading && (
@@ -99,9 +125,19 @@ export function FilterBar({
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
ref={searchInputRef}
placeholder="Search..."
value={filterState.searchTerm}
onChange={(e) => onFilterChange("searchTerm", e.target.value)}
onChange={(e) => {
cursorPositionRef.current = e.target.selectionStart;
onFilterChange("searchTerm", e.target.value);
}}
onFocus={() => {
cursorPositionRef.current = searchInputRef.current?.selectionStart ?? null;
}}
onBlur={() => {
cursorPositionRef.current = null;
}}
className="pl-9 pr-8 bg-background w-full"
/>
{isSearchLoading && (

View File

@@ -0,0 +1,26 @@
import { Eye, EyeOff } from "lucide-react";
import { Button } from "./button";
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
export function BalanceToggle() {
const { isBalanceVisible, toggleBalanceVisibility } = useBalanceVisibility();
return (
<Button
variant="outline"
size="icon"
onClick={toggleBalanceVisibility}
className="h-8 w-8"
title={isBalanceVisible ? "Hide balances" : "Show balances"}
>
{isBalanceVisible ? (
<Eye className="h-4 w-4" />
) : (
<EyeOff className="h-4 w-4" />
)}
<span className="sr-only">
{isBalanceVisible ? "Hide balances" : "Show balances"}
</span>
</Button>
);
}

View File

@@ -0,0 +1,23 @@
import React from "react";
import { useBalanceVisibility } from "../../contexts/BalanceVisibilityContext";
import { cn } from "../../lib/utils";
interface BlurredValueProps {
children: React.ReactNode;
className?: string;
}
export function BlurredValue({ children, className }: BlurredValueProps) {
const { isBalanceVisible } = useBalanceVisibility();
return (
<span
className={cn(
isBalanceVisible ? "" : "blur-md select-none",
className,
)}
>
{children}
</span>
);
}

View File

@@ -0,0 +1,48 @@
import React, { createContext, useContext, useEffect, useState } from "react";
interface BalanceVisibilityContextType {
isBalanceVisible: boolean;
toggleBalanceVisibility: () => void;
}
const BalanceVisibilityContext = createContext<
BalanceVisibilityContextType | undefined
>(undefined);
export function BalanceVisibilityProvider({
children,
}: {
children: React.ReactNode;
}) {
const [isBalanceVisible, setIsBalanceVisible] = useState<boolean>(() => {
const stored = localStorage.getItem("balanceVisible");
// Default to true (visible) if not set
return stored === null ? true : stored === "true";
});
useEffect(() => {
localStorage.setItem("balanceVisible", String(isBalanceVisible));
}, [isBalanceVisible]);
const toggleBalanceVisibility = () => {
setIsBalanceVisible((prev) => !prev);
};
return (
<BalanceVisibilityContext.Provider
value={{ isBalanceVisible, toggleBalanceVisibility }}
>
{children}
</BalanceVisibilityContext.Provider>
);
}
export function useBalanceVisibility() {
const context = useContext(BalanceVisibilityContext);
if (context === undefined) {
throw new Error(
"useBalanceVisibility must be used within a BalanceVisibilityProvider",
);
}
return context;
}

View File

@@ -86,5 +86,9 @@
}
body {
@apply bg-background text-foreground;
padding-top: var(--safe-area-inset-top);
padding-bottom: var(--safe-area-inset-bottom);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
}

View File

@@ -4,7 +4,7 @@ import type {
Transaction,
AnalyticsTransaction,
Balance,
ApiResponse,
PaginatedResponse,
NotificationSettings,
NotificationTest,
NotificationService,
@@ -36,14 +36,14 @@ const api = axios.create({
export const apiClient = {
// Get all accounts
getAccounts: async (): Promise<Account[]> => {
const response = await api.get<ApiResponse<Account[]>>("/accounts");
return response.data.data;
const response = await api.get<Account[]>("/accounts");
return response.data;
},
// Get account by ID
getAccount: async (id: string): Promise<Account> => {
const response = await api.get<ApiResponse<Account>>(`/accounts/${id}`);
return response.data.data;
const response = await api.get<Account>(`/accounts/${id}`);
return response.data;
},
// Update account details
@@ -51,16 +51,17 @@ export const apiClient = {
id: string,
updates: AccountUpdate,
): Promise<{ id: string; display_name?: string }> => {
const response = await api.put<
ApiResponse<{ id: string; display_name?: string }>
>(`/accounts/${id}`, updates);
return response.data.data;
const response = await api.put<{ id: string; display_name?: string }>(
`/accounts/${id}`,
updates,
);
return response.data;
},
// Get all balances
getBalances: async (): Promise<Balance[]> => {
const response = await api.get<ApiResponse<Balance[]>>("/balances");
return response.data.data;
const response = await api.get<Balance[]>("/balances");
return response.data;
},
// Get historical balances for balance progression chart
@@ -72,18 +73,18 @@ export const apiClient = {
if (days) queryParams.append("days", days.toString());
if (accountId) queryParams.append("account_id", accountId);
const response = await api.get<ApiResponse<Balance[]>>(
const response = await api.get<Balance[]>(
`/balances/history?${queryParams.toString()}`,
);
return response.data.data;
return response.data;
},
// Get balances for specific account
getAccountBalances: async (accountId: string): Promise<Balance[]> => {
const response = await api.get<ApiResponse<Balance[]>>(
const response = await api.get<Balance[]>(
`/accounts/${accountId}/balances`,
);
return response.data.data;
return response.data;
},
// Get transactions with optional filters
@@ -97,7 +98,7 @@ export const apiClient = {
summaryOnly?: boolean;
minAmount?: number;
maxAmount?: number;
}): Promise<ApiResponse<Transaction[]>> => {
}): Promise<PaginatedResponse<Transaction>> => {
const queryParams = new URLSearchParams();
if (params?.accountId) queryParams.append("account_id", params.accountId);
@@ -117,7 +118,7 @@ export const apiClient = {
queryParams.append("max_amount", params.maxAmount.toString());
}
const response = await api.get<ApiResponse<Transaction[]>>(
const response = await api.get<PaginatedResponse<Transaction>>(
`/transactions?${queryParams.toString()}`,
);
return response.data;
@@ -125,29 +126,27 @@ export const apiClient = {
// Get transaction by ID
getTransaction: async (id: string): Promise<Transaction> => {
const response = await api.get<ApiResponse<Transaction>>(
`/transactions/${id}`,
);
return response.data.data;
const response = await api.get<Transaction>(`/transactions/${id}`);
return response.data;
},
// Get notification settings
getNotificationSettings: async (): Promise<NotificationSettings> => {
const response = await api.get<ApiResponse<NotificationSettings>>(
const response = await api.get<NotificationSettings>(
"/notifications/settings",
);
return response.data.data;
return response.data;
},
// Update notification settings
updateNotificationSettings: async (
settings: NotificationSettings,
): Promise<NotificationSettings> => {
const response = await api.put<ApiResponse<NotificationSettings>>(
const response = await api.put<NotificationSettings>(
"/notifications/settings",
settings,
);
return response.data.data;
return response.data;
},
// Test notification
@@ -157,11 +156,11 @@ export const apiClient = {
// Get notification services
getNotificationServices: async (): Promise<NotificationService[]> => {
const response = await api.get<ApiResponse<NotificationServicesResponse>>(
const response = await api.get<NotificationServicesResponse>(
"/notifications/services",
);
// Convert object to array format
const servicesData = response.data.data;
const servicesData = response.data;
return Object.values(servicesData);
},
@@ -172,8 +171,8 @@ export const apiClient = {
// Health check
getHealth: async (): Promise<HealthData> => {
const response = await api.get<ApiResponse<HealthData>>("/health");
return response.data.data;
const response = await api.get<HealthData>("/health");
return response.data;
},
// Analytics endpoints
@@ -181,10 +180,10 @@ export const apiClient = {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<TransactionStats>>(
const response = await api.get<TransactionStats>(
`/transactions/stats?${queryParams.toString()}`,
);
return response.data.data;
return response.data;
},
// Get all transactions for analytics (no pagination)
@@ -194,10 +193,10 @@ export const apiClient = {
const queryParams = new URLSearchParams();
if (days) queryParams.append("days", days.toString());
const response = await api.get<ApiResponse<AnalyticsTransaction[]>>(
const response = await api.get<AnalyticsTransaction[]>(
`/transactions/analytics?${queryParams.toString()}`,
);
return response.data.data;
return response.data;
},
// Get monthly transaction statistics (pre-calculated)
@@ -215,16 +214,14 @@ export const apiClient = {
if (days) queryParams.append("days", days.toString());
const response = await api.get<
ApiResponse<
Array<{
month: string;
income: number;
expenses: number;
net: number;
}>
>
Array<{
month: string;
income: number;
expenses: number;
net: number;
}>
>(`/transactions/monthly-stats?${queryParams.toString()}`);
return response.data.data;
return response.data;
},
// Get sync operations history
@@ -232,24 +229,23 @@ export const apiClient = {
limit: number = 50,
offset: number = 0,
): Promise<SyncOperationsResponse> => {
const response = await api.get<ApiResponse<SyncOperationsResponse>>(
const response = await api.get<SyncOperationsResponse>(
`/sync/operations?limit=${limit}&offset=${offset}`,
);
return response.data.data;
return response.data;
},
// Bank management endpoints
getBankInstitutions: async (country: string): Promise<BankInstitution[]> => {
const response = await api.get<ApiResponse<BankInstitution[]>>(
const response = await api.get<BankInstitution[]>(
`/banks/institutions?country=${country}`,
);
return response.data.data;
return response.data;
},
getBankConnectionsStatus: async (): Promise<BankConnectionStatus[]> => {
const response =
await api.get<ApiResponse<BankConnectionStatus[]>>("/banks/status");
return response.data.data;
const response = await api.get<BankConnectionStatus[]>("/banks/status");
return response.data;
},
createBankConnection: async (
@@ -260,14 +256,11 @@ export const apiClient = {
const finalRedirectUrl =
redirectUrl || `${window.location.origin}/bank-connected`;
const response = await api.post<ApiResponse<BankRequisition>>(
"/banks/connect",
{
institution_id: institutionId,
redirect_url: finalRedirectUrl,
},
);
return response.data.data;
const response = await api.post<BankRequisition>("/banks/connect", {
institution_id: institutionId,
redirect_url: finalRedirectUrl,
});
return response.data;
},
deleteBankConnection: async (requisitionId: string): Promise<void> => {
@@ -275,39 +268,56 @@ export const apiClient = {
},
getSupportedCountries: async (): Promise<Country[]> => {
const response = await api.get<ApiResponse<Country[]>>("/banks/countries");
return response.data.data;
const response = await api.get<Country[]>("/banks/countries");
return response.data;
},
// Backup endpoints
getBackupSettings: async (): Promise<BackupSettings> => {
const response =
await api.get<ApiResponse<BackupSettings>>("/backup/settings");
return response.data.data;
const response = await api.get<BackupSettings>("/backup/settings");
return response.data;
},
updateBackupSettings: async (
settings: BackupSettings,
): Promise<BackupSettings> => {
const response = await api.put<ApiResponse<BackupSettings>>(
const response = await api.put<BackupSettings>(
"/backup/settings",
settings,
);
return response.data.data;
return response.data;
},
testBackupConnection: async (test: BackupTest): Promise<ApiResponse<{ connected?: boolean }>> => {
const response = await api.post<ApiResponse<{ connected?: boolean }>>("/backup/test", test);
testBackupConnection: async (
test: BackupTest,
): Promise<{ connected?: boolean; success?: boolean; message?: string }> => {
const response = await api.post<{
connected?: boolean;
success?: boolean;
message?: string;
}>("/backup/test", test);
return response.data;
},
listBackups: async (): Promise<BackupInfo[]> => {
const response = await api.get<ApiResponse<BackupInfo[]>>("/backup/list");
return response.data.data;
const response = await api.get<BackupInfo[]>("/backup/list");
return response.data;
},
performBackupOperation: async (operation: BackupOperation): Promise<ApiResponse<{ operation: string; completed: boolean }>> => {
const response = await api.post<ApiResponse<{ operation: string; completed: boolean }>>("/backup/operation", operation);
performBackupOperation: async (
operation: BackupOperation,
): Promise<{
operation: string;
completed: boolean;
success?: boolean;
message?: string;
}> => {
const response = await api.post<{
operation: string;
completed: boolean;
success?: boolean;
message?: string;
}>("/backup/operation", operation);
return response.data;
},
};

View File

@@ -3,6 +3,7 @@ import { createRoot } from "react-dom/client";
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ThemeProvider } from "./contexts/ThemeContext";
import { BalanceVisibilityProvider } from "./contexts/BalanceVisibilityContext";
import "./index.css";
import { routeTree } from "./routeTree.gen";
import { registerSW } from "virtual:pwa-register";
@@ -73,7 +74,9 @@ createRoot(document.getElementById("root")!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<RouterProvider router={router} />
<BalanceVisibilityProvider>
<RouterProvider router={router} />
</BalanceVisibilityProvider>
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,

View File

@@ -88,6 +88,7 @@ function AnalyticsDashboard() {
subtitle="Inflows this period"
icon={TrendingUp}
iconColor="green"
shouldBlur={true}
/>
<StatCard
title="Total Expenses"
@@ -95,6 +96,7 @@ function AnalyticsDashboard() {
subtitle="Outflows this period"
icon={TrendingDown}
iconColor="red"
shouldBlur={true}
/>
</div>
@@ -106,6 +108,7 @@ function AnalyticsDashboard() {
subtitle="Income minus expenses"
icon={CreditCard}
iconColor={(stats?.net_change || 0) >= 0 ? "green" : "red"}
shouldBlur={true}
/>
<StatCard
title="Average Transaction"
@@ -113,6 +116,7 @@ function AnalyticsDashboard() {
subtitle="Per transaction"
icon={Activity}
iconColor="purple"
shouldBlur={true}
/>
<StatCard
title="Active Accounts"

View File

@@ -133,26 +133,14 @@ export interface Bank {
logo_url?: string;
}
export interface ApiResponse<T> {
data: T;
message?: string;
success: boolean;
pagination?: {
total: number;
page: number;
per_page: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
};
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
per_page: number;
total_pages: number;
has_next: boolean;
has_prev: boolean;
}
// Notification types

View File

@@ -0,0 +1,75 @@
"""FastAPI dependency injection setup for repositories and services."""
from typing import Annotated
from fastapi import Depends
from leggen.repositories import (
AccountRepository,
BalanceRepository,
MigrationRepository,
SyncRepository,
TransactionRepository,
)
from leggen.services.data_processors import (
AnalyticsProcessor,
BalanceTransformer,
TransactionProcessor,
)
from leggen.utils.config import config
def get_account_repository() -> AccountRepository:
"""Get account repository instance."""
return AccountRepository()
def get_balance_repository() -> BalanceRepository:
"""Get balance repository instance."""
return BalanceRepository()
def get_transaction_repository() -> TransactionRepository:
"""Get transaction repository instance."""
return TransactionRepository()
def get_sync_repository() -> SyncRepository:
"""Get sync repository instance."""
return SyncRepository()
def get_migration_repository() -> MigrationRepository:
"""Get migration repository instance."""
return MigrationRepository()
def get_transaction_processor() -> TransactionProcessor:
"""Get transaction processor instance."""
return TransactionProcessor()
def get_balance_transformer() -> BalanceTransformer:
"""Get balance transformer instance."""
return BalanceTransformer()
def get_analytics_processor() -> AnalyticsProcessor:
"""Get analytics processor instance."""
return AnalyticsProcessor()
def is_sqlite_enabled() -> bool:
"""Check if SQLite is enabled in configuration."""
return config.database_config.get("sqlite", True)
# Type annotations for dependency injection
AccountRepo = Annotated[AccountRepository, Depends(get_account_repository)]
BalanceRepo = Annotated[BalanceRepository, Depends(get_balance_repository)]
TransactionRepo = Annotated[TransactionRepository, Depends(get_transaction_repository)]
SyncRepo = Annotated[SyncRepository, Depends(get_sync_repository)]
MigrationRepo = Annotated[MigrationRepository, Depends(get_migration_repository)]
TransactionProc = Annotated[TransactionProcessor, Depends(get_transaction_processor)]
BalanceTransform = Annotated[BalanceTransformer, Depends(get_balance_transformer)]
AnalyticsProc = Annotated[AnalyticsProcessor, Depends(get_analytics_processor)]

View File

@@ -1,29 +1,17 @@
from typing import Any, Dict, Optional
from typing import Generic, List, TypeVar
from pydantic import BaseModel
class APIResponse(BaseModel):
"""Base API response model"""
success: bool = True
message: Optional[str] = None
data: Optional[Any] = None
T = TypeVar("T")
class ErrorResponse(BaseModel):
"""Error response model"""
success: bool = False
message: str
error_code: Optional[str] = None
details: Optional[Dict[str, Any]] = None
class PaginatedResponse(BaseModel):
class PaginatedResponse(BaseModel, Generic[T]):
"""Paginated response model"""
success: bool = True
data: list
pagination: Dict[str, Any]
message: Optional[str] = None
data: List[T]
total: int
page: int
per_page: int
total_pages: int
has_next: bool
has_prev: bool

View File

@@ -3,6 +3,12 @@ from typing import List, Optional, Union
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from leggen.api.dependencies import (
AccountRepo,
AnalyticsProc,
BalanceRepo,
TransactionRepo,
)
from leggen.api.models.accounts import (
AccountBalance,
AccountDetails,
@@ -10,29 +16,27 @@ from leggen.api.models.accounts import (
Transaction,
TransactionSummary,
)
from leggen.api.models.common import APIResponse
from leggen.services.database_service import DatabaseService
router = APIRouter()
database_service = DatabaseService()
@router.get("/accounts", response_model=APIResponse)
async def get_all_accounts() -> APIResponse:
@router.get("/accounts")
async def get_all_accounts(
account_repo: AccountRepo,
balance_repo: BalanceRepo,
) -> List[AccountDetails]:
"""Get all connected accounts from database"""
try:
accounts = []
# Get all account details from database
db_accounts = await database_service.get_accounts_from_db()
db_accounts = account_repo.get_accounts()
# Process accounts found in database
for db_account in db_accounts:
try:
# Get latest balances from database for this account
balances_data = await database_service.get_balances_from_db(
db_account["id"]
)
balances_data = balance_repo.get_balances(db_account["id"])
# Process balances
balances = []
@@ -68,11 +72,7 @@ async def get_all_accounts() -> APIResponse:
)
continue
return APIResponse(
success=True,
data=accounts,
message=f"Retrieved {len(accounts)} accounts from database",
)
return accounts
except Exception as e:
logger.error(f"Failed to get accounts: {e}")
@@ -81,12 +81,16 @@ async def get_all_accounts() -> APIResponse:
) from e
@router.get("/accounts/{account_id}", response_model=APIResponse)
async def get_account_details(account_id: str) -> APIResponse:
@router.get("/accounts/{account_id}")
async def get_account_details(
account_id: str,
account_repo: AccountRepo,
balance_repo: BalanceRepo,
) -> AccountDetails:
"""Get details for a specific account from database"""
try:
# Get account details from database
db_account = await database_service.get_account_details_from_db(account_id)
db_account = account_repo.get_account(account_id)
if not db_account:
raise HTTPException(
@@ -94,7 +98,7 @@ async def get_account_details(account_id: str) -> APIResponse:
)
# Get latest balances from database for this account
balances_data = await database_service.get_balances_from_db(account_id)
balances_data = balance_repo.get_balances(account_id)
# Process balances
balances = []
@@ -122,11 +126,7 @@ async def get_account_details(account_id: str) -> APIResponse:
balances=balances,
)
return APIResponse(
success=True,
data=account,
message=f"Account details retrieved from database for {account_id}",
)
return account
except HTTPException:
raise
@@ -137,12 +137,15 @@ async def get_account_details(account_id: str) -> APIResponse:
) from e
@router.get("/accounts/{account_id}/balances", response_model=APIResponse)
async def get_account_balances(account_id: str) -> APIResponse:
@router.get("/accounts/{account_id}/balances")
async def get_account_balances(
account_id: str,
balance_repo: BalanceRepo,
) -> List[AccountBalance]:
"""Get balances for a specific account from database"""
try:
# Get balances from database instead of GoCardless API
db_balances = await database_service.get_balances_from_db(account_id=account_id)
db_balances = balance_repo.get_balances(account_id=account_id)
balances = []
for balance in db_balances:
@@ -155,11 +158,7 @@ async def get_account_balances(account_id: str) -> APIResponse:
)
)
return APIResponse(
success=True,
data=balances,
message=f"Retrieved {len(balances)} balances for account {account_id}",
)
return balances
except Exception as e:
logger.error(
@@ -170,20 +169,21 @@ async def get_account_balances(account_id: str) -> APIResponse:
) from e
@router.get("/balances", response_model=APIResponse)
async def get_all_balances() -> APIResponse:
@router.get("/balances")
async def get_all_balances(
account_repo: AccountRepo,
balance_repo: BalanceRepo,
) -> List[dict]:
"""Get all balances from all accounts in database"""
try:
# Get all accounts first to iterate through them
db_accounts = await database_service.get_accounts_from_db()
db_accounts = account_repo.get_accounts()
all_balances = []
for db_account in db_accounts:
try:
# Get balances for this account
db_balances = await database_service.get_balances_from_db(
account_id=db_account["id"]
)
db_balances = balance_repo.get_balances(account_id=db_account["id"])
# Process balances and add account info
for balance in db_balances:
@@ -207,11 +207,7 @@ async def get_all_balances() -> APIResponse:
)
continue
return APIResponse(
success=True,
data=all_balances,
message=f"Retrieved {len(all_balances)} balances from {len(db_accounts)} accounts",
)
return all_balances
except Exception as e:
logger.error(f"Failed to get all balances: {e}")
@@ -220,27 +216,27 @@ async def get_all_balances() -> APIResponse:
) from e
@router.get("/balances/history", response_model=APIResponse)
@router.get("/balances/history")
async def get_historical_balances(
analytics_proc: AnalyticsProc,
days: Optional[int] = Query(
default=365, le=1095, ge=1, description="Number of days of history to retrieve"
),
account_id: Optional[str] = Query(
default=None, description="Filter by specific account ID"
),
) -> APIResponse:
) -> List[dict]:
"""Get historical balance progression calculated from transaction history"""
try:
from leggen.utils.paths import path_manager
# Get historical balances from database
historical_balances = await database_service.get_historical_balances_from_db(
account_id=account_id, days=days or 365
db_path = path_manager.get_database_path()
historical_balances = analytics_proc.calculate_historical_balances(
db_path, account_id=account_id, days=days or 365
)
return APIResponse(
success=True,
data=historical_balances,
message=f"Retrieved {len(historical_balances)} historical balance points over {days} days",
)
return historical_balances
except Exception as e:
logger.error(f"Failed to get historical balances: {e}")
@@ -249,27 +245,23 @@ async def get_historical_balances(
) from e
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
@router.get("/accounts/{account_id}/transactions")
async def get_account_transactions(
account_id: str,
transaction_repo: TransactionRepo,
limit: Optional[int] = Query(default=100, le=500),
offset: Optional[int] = Query(default=0, ge=0),
summary_only: bool = Query(
default=False, description="Return transaction summaries only"
),
) -> APIResponse:
) -> Union[List[TransactionSummary], List[Transaction]]:
"""Get transactions for a specific account from database"""
try:
# Get transactions from database instead of GoCardless API
db_transactions = await database_service.get_transactions_from_db(
db_transactions = transaction_repo.get_transactions(
account_id=account_id,
limit=limit,
offset=offset,
)
# Get total count for pagination info
total_transactions = await database_service.get_transaction_count_from_db(
account_id=account_id,
offset=offset or 0,
)
data: Union[List[TransactionSummary], List[Transaction]]
@@ -308,12 +300,7 @@ async def get_account_transactions(
for txn in db_transactions
]
actual_offset = offset or 0
return APIResponse(
success=True,
data=data,
message=f"Retrieved {len(data)} transactions (showing {actual_offset + 1}-{actual_offset + len(data)} of {total_transactions})",
)
return data
except Exception as e:
logger.error(
@@ -324,14 +311,16 @@ async def get_account_transactions(
) from e
@router.put("/accounts/{account_id}", response_model=APIResponse)
@router.put("/accounts/{account_id}")
async def update_account_details(
account_id: str, update_data: AccountUpdate
) -> APIResponse:
account_id: str,
update_data: AccountUpdate,
account_repo: AccountRepo,
) -> dict:
"""Update account details (currently only display_name)"""
try:
# Get current account details
current_account = await database_service.get_account_details_from_db(account_id)
current_account = account_repo.get_account(account_id)
if not current_account:
raise HTTPException(
@@ -344,13 +333,9 @@ async def update_account_details(
updated_account_data["display_name"] = update_data.display_name
# Persist updated account details
await database_service.persist_account_details(updated_account_data)
account_repo.persist(updated_account_data)
return APIResponse(
success=True,
data={"id": account_id, "display_name": update_data.display_name},
message=f"Account {account_id} display name updated successfully",
)
return {"id": account_id, "display_name": update_data.display_name}
except HTTPException:
raise

View File

@@ -9,7 +9,6 @@ from leggen.api.models.backup import (
BackupTest,
S3Config,
)
from leggen.api.models.common import APIResponse
from leggen.models.config import S3BackupConfig
from leggen.services.backup_service import BackupService
from leggen.utils.config import config
@@ -18,8 +17,8 @@ from leggen.utils.paths import path_manager
router = APIRouter()
@router.get("/backup/settings", response_model=APIResponse)
async def get_backup_settings() -> APIResponse:
@router.get("/backup/settings")
async def get_backup_settings() -> BackupSettings:
"""Get current backup settings."""
try:
backup_config = config.backup_config
@@ -41,11 +40,7 @@ async def get_backup_settings() -> APIResponse:
else None,
)
return APIResponse(
success=True,
data=settings,
message="Backup settings retrieved successfully",
)
return settings
except Exception as e:
logger.error(f"Failed to get backup settings: {e}")
@@ -54,8 +49,8 @@ async def get_backup_settings() -> APIResponse:
) from e
@router.put("/backup/settings", response_model=APIResponse)
async def update_backup_settings(settings: BackupSettings) -> APIResponse:
@router.put("/backup/settings")
async def update_backup_settings(settings: BackupSettings) -> dict:
"""Update backup settings."""
try:
# First test the connection if S3 config is provided
@@ -99,11 +94,7 @@ async def update_backup_settings(settings: BackupSettings) -> APIResponse:
if backup_config:
config.update_section("backup", backup_config)
return APIResponse(
success=True,
data={"updated": True},
message="Backup settings updated successfully",
)
return {"updated": True}
except HTTPException:
raise
@@ -114,8 +105,8 @@ async def update_backup_settings(settings: BackupSettings) -> APIResponse:
) from e
@router.post("/backup/test", response_model=APIResponse)
async def test_backup_connection(test_request: BackupTest) -> APIResponse:
@router.post("/backup/test")
async def test_backup_connection(test_request: BackupTest) -> dict:
"""Test backup connection."""
try:
if test_request.service != "s3":
@@ -137,17 +128,10 @@ async def test_backup_connection(test_request: BackupTest) -> APIResponse:
backup_service = BackupService()
success = await backup_service.test_connection(s3_config)
if success:
return APIResponse(
success=True,
data={"connected": True},
message="S3 connection test successful",
)
else:
return APIResponse(
success=False,
message="S3 connection test failed",
)
if not success:
raise HTTPException(status_code=400, detail="S3 connection test failed")
return {"connected": True}
except HTTPException:
raise
@@ -158,18 +142,14 @@ async def test_backup_connection(test_request: BackupTest) -> APIResponse:
) from e
@router.get("/backup/list", response_model=APIResponse)
async def list_backups() -> APIResponse:
@router.get("/backup/list")
async def list_backups() -> list:
"""List available backups."""
try:
backup_config = config.backup_config.get("s3", {})
if not backup_config.get("bucket_name"):
return APIResponse(
success=True,
data=[],
message="No S3 backup configuration found",
)
return []
# Convert config to model
s3_config = S3BackupConfig(**backup_config)
@@ -177,11 +157,7 @@ async def list_backups() -> APIResponse:
backups = await backup_service.list_backups()
return APIResponse(
success=True,
data=backups,
message=f"Found {len(backups)} backups",
)
return backups
except Exception as e:
logger.error(f"Failed to list backups: {e}")
@@ -190,8 +166,8 @@ async def list_backups() -> APIResponse:
) from e
@router.post("/backup/operation", response_model=APIResponse)
async def backup_operation(operation_request: BackupOperation) -> APIResponse:
@router.post("/backup/operation")
async def backup_operation(operation_request: BackupOperation) -> dict:
"""Perform backup operation (backup or restore)."""
try:
backup_config = config.backup_config.get("s3", {})
@@ -214,17 +190,10 @@ async def backup_operation(operation_request: BackupOperation) -> APIResponse:
database_path = path_manager.get_database_path()
success = await backup_service.backup_database(database_path)
if success:
return APIResponse(
success=True,
data={"operation": "backup", "completed": True},
message="Database backup completed successfully",
)
else:
return APIResponse(
success=False,
message="Database backup failed",
)
if not success:
raise HTTPException(status_code=500, detail="Database backup failed")
return {"operation": "backup", "completed": True}
elif operation_request.operation == "restore":
if not operation_request.backup_key:
@@ -239,17 +208,10 @@ async def backup_operation(operation_request: BackupOperation) -> APIResponse:
operation_request.backup_key, database_path
)
if success:
return APIResponse(
success=True,
data={"operation": "restore", "completed": True},
message="Database restore completed successfully",
)
else:
return APIResponse(
success=False,
message="Database restore failed",
)
if not success:
raise HTTPException(status_code=500, detail="Database restore failed")
return {"operation": "restore", "completed": True}
else:
raise HTTPException(
status_code=400, detail="Invalid operation. Use 'backup' or 'restore'"

View File

@@ -8,7 +8,6 @@ from leggen.api.models.banks import (
BankInstitution,
BankRequisition,
)
from leggen.api.models.common import APIResponse
from leggen.services.gocardless_service import GoCardlessService
from leggen.utils.gocardless import REQUISITION_STATUS
@@ -16,10 +15,10 @@ router = APIRouter()
gocardless_service = GoCardlessService()
@router.get("/banks/institutions", response_model=APIResponse)
@router.get("/banks/institutions")
async def get_bank_institutions(
country: str = Query(default="PT", description="Country code (e.g., PT, ES, FR)"),
) -> APIResponse:
) -> list[BankInstitution]:
"""Get available bank institutions for a country"""
try:
institutions_response = await gocardless_service.get_institutions(country)
@@ -41,11 +40,7 @@ async def get_bank_institutions(
for inst in institutions_data
]
return APIResponse(
success=True,
data=institutions,
message=f"Found {len(institutions)} institutions for {country}",
)
return institutions
except Exception as e:
logger.error(f"Failed to get institutions for {country}: {e}")
@@ -54,8 +49,8 @@ async def get_bank_institutions(
) from e
@router.post("/banks/connect", response_model=APIResponse)
async def connect_to_bank(request: BankConnectionRequest) -> APIResponse:
@router.post("/banks/connect")
async def connect_to_bank(request: BankConnectionRequest) -> BankRequisition:
"""Create a connection to a bank (requisition)"""
try:
redirect_url = request.redirect_url or "http://localhost:8000/"
@@ -72,11 +67,7 @@ async def connect_to_bank(request: BankConnectionRequest) -> APIResponse:
accounts=requisition_data.get("accounts", []),
)
return APIResponse(
success=True,
data=requisition,
message="Bank connection created. Please visit the link to authorize.",
)
return requisition
except Exception as e:
logger.error(f"Failed to connect to bank {request.institution_id}: {e}")
@@ -85,8 +76,8 @@ async def connect_to_bank(request: BankConnectionRequest) -> APIResponse:
) from e
@router.get("/banks/status", response_model=APIResponse)
async def get_bank_connections_status() -> APIResponse:
@router.get("/banks/status")
async def get_bank_connections_status() -> list[BankConnectionStatus]:
"""Get status of all bank connections"""
try:
requisitions_data = await gocardless_service.get_requisitions()
@@ -110,11 +101,7 @@ async def get_bank_connections_status() -> APIResponse:
)
)
return APIResponse(
success=True,
data=connections,
message=f"Found {len(connections)} bank connections",
)
return connections
except Exception as e:
logger.error(f"Failed to get bank connection status: {e}")
@@ -123,8 +110,8 @@ async def get_bank_connections_status() -> APIResponse:
) from e
@router.delete("/banks/connections/{requisition_id}", response_model=APIResponse)
async def delete_bank_connection(requisition_id: str) -> APIResponse:
@router.delete("/banks/connections/{requisition_id}")
async def delete_bank_connection(requisition_id: str) -> dict:
"""Delete a bank connection"""
try:
# Delete the requisition from GoCardless
@@ -134,10 +121,7 @@ async def delete_bank_connection(requisition_id: str) -> APIResponse:
# We should check if the operation was actually successful
logger.info(f"GoCardless delete response for {requisition_id}: {result}")
return APIResponse(
success=True,
message=f"Bank connection {requisition_id} deleted successfully",
)
return {"deleted": requisition_id}
except httpx.HTTPStatusError as http_err:
logger.error(
@@ -164,8 +148,8 @@ async def delete_bank_connection(requisition_id: str) -> APIResponse:
) from e
@router.get("/banks/countries", response_model=APIResponse)
async def get_supported_countries() -> APIResponse:
@router.get("/banks/countries")
async def get_supported_countries() -> list[dict]:
"""Get list of supported countries"""
countries = [
{"code": "AT", "name": "Austria"},
@@ -201,8 +185,4 @@ async def get_supported_countries() -> APIResponse:
{"code": "GB", "name": "United Kingdom"},
]
return APIResponse(
success=True,
data=countries,
message="Supported countries retrieved successfully",
)
return countries

View File

@@ -3,7 +3,6 @@ from typing import Any, Dict
from fastapi import APIRouter, HTTPException
from loguru import logger
from leggen.api.models.common import APIResponse
from leggen.api.models.notifications import (
DiscordConfig,
NotificationFilters,
@@ -18,8 +17,8 @@ router = APIRouter()
notification_service = NotificationService()
@router.get("/notifications/settings", response_model=APIResponse)
async def get_notification_settings() -> APIResponse:
@router.get("/notifications/settings")
async def get_notification_settings() -> NotificationSettings:
"""Get current notification settings"""
try:
notifications_config = config.notifications_config
@@ -49,11 +48,7 @@ async def get_notification_settings() -> APIResponse:
),
)
return APIResponse(
success=True,
data=settings,
message="Notification settings retrieved successfully",
)
return settings
except Exception as e:
logger.error(f"Failed to get notification settings: {e}")
@@ -62,8 +57,8 @@ async def get_notification_settings() -> APIResponse:
) from e
@router.put("/notifications/settings", response_model=APIResponse)
async def update_notification_settings(settings: NotificationSettings) -> APIResponse:
@router.put("/notifications/settings")
async def update_notification_settings(settings: NotificationSettings) -> dict:
"""Update notification settings"""
try:
# Update notifications config
@@ -95,11 +90,7 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
if filters_config:
config.update_section("filters", filters_config)
return APIResponse(
success=True,
data={"updated": True},
message="Notification settings updated successfully",
)
return {"updated": True}
except Exception as e:
logger.error(f"Failed to update notification settings: {e}")
@@ -108,26 +99,24 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
) from e
@router.post("/notifications/test", response_model=APIResponse)
async def test_notification(test_request: NotificationTest) -> APIResponse:
@router.post("/notifications/test")
async def test_notification(test_request: NotificationTest) -> dict:
"""Send a test notification"""
try:
success = await notification_service.send_test_notification(
test_request.service, test_request.message
)
if success:
return APIResponse(
success=True,
data={"sent": True},
message=f"Test notification sent to {test_request.service} successfully",
)
else:
return APIResponse(
success=False,
message=f"Failed to send test notification to {test_request.service}",
if not success:
raise HTTPException(
status_code=400,
detail=f"Failed to send test notification to {test_request.service}",
)
return {"sent": True}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to send test notification: {e}")
raise HTTPException(
@@ -135,8 +124,8 @@ async def test_notification(test_request: NotificationTest) -> APIResponse:
) from e
@router.get("/notifications/services", response_model=APIResponse)
async def get_notification_services() -> APIResponse:
@router.get("/notifications/services")
async def get_notification_services() -> dict:
"""Get available notification services and their status"""
try:
notifications_config = config.notifications_config
@@ -164,11 +153,7 @@ async def get_notification_services() -> APIResponse:
},
}
return APIResponse(
success=True,
data=services,
message="Notification services status retrieved successfully",
)
return services
except Exception as e:
logger.error(f"Failed to get notification services: {e}")
@@ -177,8 +162,8 @@ async def get_notification_services() -> APIResponse:
) from e
@router.delete("/notifications/settings/{service}", response_model=APIResponse)
async def delete_notification_service(service: str) -> APIResponse:
@router.delete("/notifications/settings/{service}")
async def delete_notification_service(service: str) -> dict:
"""Delete/disable a notification service"""
try:
if service not in ["discord", "telegram"]:
@@ -191,12 +176,10 @@ async def delete_notification_service(service: str) -> APIResponse:
del notifications_config[service]
config.update_section("notifications", notifications_config)
return APIResponse(
success=True,
data={"deleted": service},
message=f"{service.capitalize()} notification service deleted successfully",
)
return {"deleted": service}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to delete notification service {service}: {e}")
raise HTTPException(

View File

@@ -3,8 +3,7 @@ from typing import Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException
from loguru import logger
from leggen.api.models.common import APIResponse
from leggen.api.models.sync import SchedulerConfig, SyncRequest
from leggen.api.models.sync import SchedulerConfig, SyncRequest, SyncResult, SyncStatus
from leggen.background.scheduler import scheduler
from leggen.services.sync_service import SyncService
from leggen.utils.config import config
@@ -13,8 +12,8 @@ router = APIRouter()
sync_service = SyncService()
@router.get("/sync/status", response_model=APIResponse)
async def get_sync_status() -> APIResponse:
@router.get("/sync/status")
async def get_sync_status() -> SyncStatus:
"""Get current sync status"""
try:
status = await sync_service.get_sync_status()
@@ -24,9 +23,7 @@ async def get_sync_status() -> APIResponse:
if next_sync_time:
status.next_sync = next_sync_time
return APIResponse(
success=True, data=status, message="Sync status retrieved successfully"
)
return status
except Exception as e:
logger.error(f"Failed to get sync status: {e}")
@@ -35,18 +32,18 @@ async def get_sync_status() -> APIResponse:
) from e
@router.post("/sync", response_model=APIResponse)
@router.post("/sync")
async def trigger_sync(
background_tasks: BackgroundTasks, sync_request: Optional[SyncRequest] = None
) -> APIResponse:
) -> dict:
"""Trigger a manual sync operation"""
try:
# Check if sync is already running
status = await sync_service.get_sync_status()
if status.is_running and not (sync_request and sync_request.force):
return APIResponse(
success=False,
message="Sync is already running. Use 'force: true' to override.",
raise HTTPException(
status_code=409,
detail="Sync is already running. Use 'force: true' to override.",
)
# Determine what to sync
@@ -58,9 +55,6 @@ async def trigger_sync(
sync_request.force if sync_request else False,
"api", # trigger_type
)
message = (
f"Started sync for {len(sync_request.account_ids)} specific accounts"
)
else:
# Sync all accounts in background
background_tasks.add_task(
@@ -68,17 +62,14 @@ async def trigger_sync(
sync_request.force if sync_request else False,
"api", # trigger_type
)
message = "Started sync for all accounts"
return APIResponse(
success=True,
data={
"sync_started": True,
"force": sync_request.force if sync_request else False,
},
message=message,
)
return {
"sync_started": True,
"force": sync_request.force if sync_request else False,
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to trigger sync: {e}")
raise HTTPException(
@@ -86,8 +77,8 @@ async def trigger_sync(
) from e
@router.post("/sync/now", response_model=APIResponse)
async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse:
@router.post("/sync/now")
async def sync_now(sync_request: Optional[SyncRequest] = None) -> SyncResult:
"""Run sync synchronously and return results (slower, for testing)"""
try:
if sync_request and sync_request.account_ids:
@@ -99,13 +90,7 @@ async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse:
sync_request.force if sync_request else False, "api"
)
return APIResponse(
success=result.success,
data=result,
message="Sync completed"
if result.success
else f"Sync failed with {len(result.errors)} errors",
)
return result
except Exception as e:
logger.error(f"Failed to run sync: {e}")
@@ -114,8 +99,8 @@ async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse:
) from e
@router.get("/sync/scheduler", response_model=APIResponse)
async def get_scheduler_config() -> APIResponse:
@router.get("/sync/scheduler")
async def get_scheduler_config() -> dict:
"""Get current scheduler configuration"""
try:
scheduler_config = config.scheduler_config
@@ -131,11 +116,7 @@ async def get_scheduler_config() -> APIResponse:
else False,
}
return APIResponse(
success=True,
data=response_data,
message="Scheduler configuration retrieved successfully",
)
return response_data
except Exception as e:
logger.error(f"Failed to get scheduler config: {e}")
@@ -144,8 +125,8 @@ async def get_scheduler_config() -> APIResponse:
) from e
@router.put("/sync/scheduler", response_model=APIResponse)
async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIResponse:
@router.put("/sync/scheduler")
async def update_scheduler_config(scheduler_config: SchedulerConfig) -> dict:
"""Update scheduler configuration"""
try:
# Validate cron expression if provided
@@ -168,12 +149,10 @@ async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIRespo
# Reschedule the job
scheduler.reschedule_sync(schedule_data)
return APIResponse(
success=True,
data=schedule_data,
message="Scheduler configuration updated successfully",
)
return schedule_data
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to update scheduler config: {e}")
raise HTTPException(
@@ -181,15 +160,15 @@ async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIRespo
) from e
@router.post("/sync/scheduler/start", response_model=APIResponse)
async def start_scheduler() -> APIResponse:
@router.post("/sync/scheduler/start")
async def start_scheduler() -> dict:
"""Start the background scheduler"""
try:
if not scheduler.scheduler.running:
scheduler.start()
return APIResponse(success=True, message="Scheduler started successfully")
return {"started": True}
else:
return APIResponse(success=True, message="Scheduler is already running")
return {"started": False, "message": "Scheduler is already running"}
except Exception as e:
logger.error(f"Failed to start scheduler: {e}")
@@ -198,15 +177,15 @@ async def start_scheduler() -> APIResponse:
) from e
@router.post("/sync/scheduler/stop", response_model=APIResponse)
async def stop_scheduler() -> APIResponse:
@router.post("/sync/scheduler/stop")
async def stop_scheduler() -> dict:
"""Stop the background scheduler"""
try:
if scheduler.scheduler.running:
scheduler.shutdown()
return APIResponse(success=True, message="Scheduler stopped successfully")
return {"stopped": True}
else:
return APIResponse(success=True, message="Scheduler is already stopped")
return {"stopped": False, "message": "Scheduler is already stopped"}
except Exception as e:
logger.error(f"Failed to stop scheduler: {e}")
@@ -215,19 +194,16 @@ async def stop_scheduler() -> APIResponse:
) from e
@router.get("/sync/operations", response_model=APIResponse)
async def get_sync_operations(limit: int = 50, offset: int = 0) -> APIResponse:
@router.get("/sync/operations")
async def get_sync_operations(limit: int = 50, offset: int = 0) -> dict:
"""Get sync operations history"""
try:
operations = await sync_service.database.get_sync_operations(
limit=limit, offset=offset
)
from leggen.repositories import SyncRepository
return APIResponse(
success=True,
data={"operations": operations, "count": len(operations)},
message="Sync operations retrieved successfully",
)
sync_repo = SyncRepository()
operations = sync_repo.get_operations(limit=limit, offset=offset)
return {"operations": operations, "count": len(operations)}
except Exception as e:
logger.error(f"Failed to get sync operations: {e}")

View File

@@ -4,16 +4,16 @@ from typing import List, Optional, Union
from fastapi import APIRouter, HTTPException, Query
from loguru import logger
from leggen.api.dependencies import AnalyticsProc, TransactionRepo
from leggen.api.models.accounts import Transaction, TransactionSummary
from leggen.api.models.common import APIResponse, PaginatedResponse
from leggen.services.database_service import DatabaseService
from leggen.api.models.common import PaginatedResponse
router = APIRouter()
database_service = DatabaseService()
@router.get("/transactions", response_model=PaginatedResponse)
@router.get("/transactions")
async def get_all_transactions(
transaction_repo: TransactionRepo,
page: int = Query(default=1, ge=1, description="Page number (1-based)"),
per_page: int = Query(default=50, le=500, description="Items per page"),
summary_only: bool = Query(
@@ -35,7 +35,7 @@ async def get_all_transactions(
default=None, description="Search in transaction descriptions"
),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> PaginatedResponse:
) -> PaginatedResponse[Union[TransactionSummary, Transaction]]:
"""Get all transactions from database with filtering options"""
try:
# Calculate offset from page and per_page
@@ -43,7 +43,7 @@ async def get_all_transactions(
limit = per_page
# Get transactions from database instead of GoCardless API
db_transactions = await database_service.get_transactions_from_db(
db_transactions = transaction_repo.get_transactions(
account_id=account_id,
limit=limit,
offset=offset,
@@ -55,7 +55,7 @@ async def get_all_transactions(
)
# Get total count for pagination info (respecting the same filters)
total_transactions = await database_service.get_transaction_count_from_db(
total_transactions = transaction_repo.get_count(
account_id=account_id,
date_from=date_from,
date_to=date_to,
@@ -64,11 +64,9 @@ async def get_all_transactions(
search=search,
)
data: Union[List[TransactionSummary], List[Transaction]]
if summary_only:
# Return simplified transaction summaries
data = [
data: list[TransactionSummary | Transaction] = [
TransactionSummary(
transaction_id=txn["transactionId"], # NEW: stable bank-provided ID
internal_transaction_id=txn.get("internalTransactionId"),
@@ -103,16 +101,13 @@ async def get_all_transactions(
total_pages = (total_transactions + per_page - 1) // per_page
return PaginatedResponse(
success=True,
data=data,
pagination={
"total": total_transactions,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1,
},
total=total_transactions,
page=page,
per_page=per_page,
total_pages=total_pages,
has_next=page < total_pages,
has_prev=page > 1,
)
except Exception as e:
@@ -122,11 +117,12 @@ async def get_all_transactions(
) from e
@router.get("/transactions/stats", response_model=APIResponse)
@router.get("/transactions/stats")
async def get_transaction_stats(
transaction_repo: TransactionRepo,
days: int = Query(default=30, description="Number of days to include in stats"),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> APIResponse:
) -> dict:
"""Get transaction statistics for the last N days from database"""
try:
# Date range for stats
@@ -138,7 +134,7 @@ async def get_transaction_stats(
date_to = end_date.isoformat()
# Get transactions from database
recent_transactions = await database_service.get_transactions_from_db(
recent_transactions = transaction_repo.get_transactions(
account_id=account_id,
date_from=date_from,
date_to=date_to,
@@ -192,11 +188,7 @@ async def get_transaction_stats(
"accounts_included": unique_accounts,
}
return APIResponse(
success=True,
data=stats,
message=f"Transaction statistics for last {days} days",
)
return stats
except Exception as e:
logger.error(f"Failed to get transaction stats from database: {e}")
@@ -205,11 +197,12 @@ async def get_transaction_stats(
) from e
@router.get("/transactions/analytics", response_model=APIResponse)
@router.get("/transactions/analytics")
async def get_transactions_for_analytics(
transaction_repo: TransactionRepo,
days: int = Query(default=365, description="Number of days to include"),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> APIResponse:
) -> List[dict]:
"""Get all transactions for analytics (no pagination) for the last N days"""
try:
# Date range for analytics
@@ -221,7 +214,7 @@ async def get_transactions_for_analytics(
date_to = end_date.isoformat()
# Get ALL transactions from database (no limit for analytics)
transactions = await database_service.get_transactions_from_db(
transactions = transaction_repo.get_transactions(
account_id=account_id,
date_from=date_from,
date_to=date_to,
@@ -242,11 +235,7 @@ async def get_transactions_for_analytics(
for txn in transactions
]
return APIResponse(
success=True,
data=transaction_summaries,
message=f"Retrieved {len(transaction_summaries)} transactions for analytics",
)
return transaction_summaries
except Exception as e:
logger.error(f"Failed to get transactions for analytics: {e}")
@@ -255,13 +244,16 @@ async def get_transactions_for_analytics(
) from e
@router.get("/transactions/monthly-stats", response_model=APIResponse)
@router.get("/transactions/monthly-stats")
async def get_monthly_transaction_stats(
analytics_proc: AnalyticsProc,
days: int = Query(default=365, description="Number of days to include"),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
) -> APIResponse:
) -> List[dict]:
"""Get monthly transaction statistics aggregated by the database"""
try:
from leggen.utils.paths import path_manager
# Date range for monthly stats
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
@@ -271,17 +263,12 @@ async def get_monthly_transaction_stats(
date_to = end_date.isoformat()
# Get monthly aggregated stats from database
monthly_stats = await database_service.get_monthly_transaction_stats_from_db(
account_id=account_id,
date_from=date_from,
date_to=date_to,
db_path = path_manager.get_database_path()
monthly_stats = analytics_proc.calculate_monthly_stats(
db_path, account_id=account_id, date_from=date_from, date_to=date_to
)
return APIResponse(
success=True,
data=monthly_stats,
message=f"Retrieved monthly stats for last {days} days",
)
return monthly_stats
except Exception as e:
logger.error(f"Failed to get monthly transaction stats: {e}")

View File

@@ -1,10 +1,489 @@
"""Generate sample database command."""
import json
import random
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, TypedDict
import click
class TransactionType(TypedDict):
"""Type definition for transaction type configuration."""
description: str
amount_range: tuple[float, float]
frequency: float
class SampleDataGenerator:
"""Generates realistic sample data for testing Leggen."""
def __init__(self, db_path: Path):
self.db_path = db_path
self.institutions = [
{
"id": "REVOLUT_REVOLT21",
"name": "Revolut",
"bic": "REVOLT21",
"country": "LT",
},
{
"id": "BANCOBPI_BBPIPTPL",
"name": "Banco BPI",
"bic": "BBPIPTPL",
"country": "PT",
},
{
"id": "MONZO_MONZGB2L",
"name": "Monzo Bank",
"bic": "MONZGB2L",
"country": "GB",
},
{
"id": "NUBANK_NUPBBR25",
"name": "Nu Pagamentos",
"bic": "NUPBBR25",
"country": "BR",
},
]
self.transaction_types: list[TransactionType] = [
{
"description": "Grocery Store",
"amount_range": (-150, -20),
"frequency": 0.3,
},
{"description": "Coffee Shop", "amount_range": (-15, -3), "frequency": 0.2},
{
"description": "Gas Station",
"amount_range": (-80, -30),
"frequency": 0.1,
},
{
"description": "Online Shopping",
"amount_range": (-200, -25),
"frequency": 0.15,
},
{
"description": "Restaurant",
"amount_range": (-60, -15),
"frequency": 0.15,
},
{"description": "Salary", "amount_range": (2500, 5000), "frequency": 0.02},
{
"description": "ATM Withdrawal",
"amount_range": (-200, -20),
"frequency": 0.05,
},
{
"description": "Transfer to Savings",
"amount_range": (-1000, -100),
"frequency": 0.03,
},
]
def ensure_database_dir(self):
"""Ensure database directory exists."""
self.db_path.parent.mkdir(parents=True, exist_ok=True)
def create_tables(self):
"""Create database tables."""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
# Create accounts table
cursor.execute("""
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
institution_id TEXT,
status TEXT,
iban TEXT,
name TEXT,
currency TEXT,
created DATETIME,
last_accessed DATETIME,
last_updated DATETIME,
display_name TEXT
)
""")
# Create transactions table with composite primary key
cursor.execute("""
CREATE TABLE IF NOT EXISTS transactions (
accountId TEXT NOT NULL,
transactionId TEXT NOT NULL,
internalTransactionId TEXT,
institutionId TEXT,
iban TEXT,
transactionDate DATETIME,
description TEXT,
transactionValue REAL,
transactionCurrency TEXT,
transactionStatus TEXT,
rawTransaction JSON,
PRIMARY KEY (accountId, transactionId)
)
""")
# Create balances table
cursor.execute("""
CREATE TABLE IF NOT EXISTS balances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT,
bank TEXT,
status TEXT,
iban TEXT,
amount REAL,
currency TEXT,
type TEXT,
timestamp DATETIME
)
""")
# Create indexes
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_transactions_internal_id ON transactions(internalTransactionId)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transactionDate)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_transactions_account_date ON transactions(accountId, transactionDate)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_transactions_amount ON transactions(transactionValue)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_balances_account_id ON balances(account_id)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_balances_timestamp ON balances(timestamp)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_balances_account_type_timestamp ON balances(account_id, type, timestamp)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_accounts_institution_id ON accounts(institution_id)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status)"
)
conn.commit()
conn.close()
def generate_iban(self, country_code: str) -> str:
"""Generate a realistic IBAN for the given country."""
ibans = {
"LT": lambda: f"LT{random.randint(10, 99)}{random.randint(10000, 99999)}{random.randint(10000000, 99999999)}",
"PT": lambda: f"PT{random.randint(10, 99)}{random.randint(1000, 9999)}{random.randint(1000, 9999)}{random.randint(10000000000, 99999999999)}",
"GB": lambda: f"GB{random.randint(10, 99)}MONZ{random.randint(100000, 999999)}{random.randint(100000, 999999)}",
"BR": lambda: f"BR{random.randint(10, 99)}{random.randint(10000000, 99999999)}{random.randint(1000, 9999)}{random.randint(10000000, 99999999)}",
}
return ibans.get(
country_code,
lambda: f"{country_code}{random.randint(1000000000000000, 9999999999999999)}",
)()
def generate_accounts(self, num_accounts: int = 3) -> list[dict[str, Any]]:
"""Generate sample accounts."""
accounts = []
base_date = datetime.now() - timedelta(days=90)
for i in range(num_accounts):
institution = random.choice(self.institutions)
account_id = f"account-{i + 1:03d}-{random.randint(1000, 9999)}"
account = {
"id": account_id,
"institution_id": institution["id"],
"status": "READY",
"iban": self.generate_iban(institution["country"]),
"name": f"Personal Account {i + 1}",
"currency": "EUR",
"created": (
base_date + timedelta(days=random.randint(0, 30))
).isoformat(),
"last_accessed": (
datetime.now() - timedelta(hours=random.randint(1, 48))
).isoformat(),
"last_updated": datetime.now().isoformat(),
}
accounts.append(account)
return accounts
def generate_transactions(
self, accounts: list[dict[str, Any]], num_transactions_per_account: int = 50
) -> list[dict[str, Any]]:
"""Generate sample transactions for accounts."""
transactions = []
base_date = datetime.now() - timedelta(days=60)
for account in accounts:
account_transactions = []
current_balance = random.uniform(500, 3000)
for i in range(num_transactions_per_account):
# Choose transaction type based on frequency weights
transaction_type = random.choices(
self.transaction_types,
weights=[t["frequency"] for t in self.transaction_types],
)[0]
# Generate transaction amount
min_amount: float
max_amount: float
min_amount, max_amount = transaction_type["amount_range"]
amount = round(random.uniform(min_amount, max_amount), 2)
# Generate transaction date (more recent transactions are more likely)
days_ago = random.choices(
range(60), weights=[1.5 ** (60 - d) for d in range(60)]
)[0]
transaction_date = base_date + timedelta(
days=days_ago,
hours=random.randint(6, 22),
minutes=random.randint(0, 59),
)
# Generate transaction IDs
transaction_id = f"bank-txn-{account['id']}-{i + 1:04d}"
internal_transaction_id = f"int-txn-{random.randint(100000, 999999)}"
# Create realistic descriptions
descriptions: dict[str, list[str]] = {
"Grocery Store": [
"TESCO",
"SAINSBURY'S",
"LIDL",
"ALDI",
"WALMART",
"CARREFOUR",
],
"Coffee Shop": [
"STARBUCKS",
"COSTA COFFEE",
"PRET A MANGER",
"LOCAL CAFE",
],
"Gas Station": ["BP", "SHELL", "ESSO", "GALP", "PETROBRAS"],
"Online Shopping": ["AMAZON", "EBAY", "ZALANDO", "ASOS", "APPLE"],
"Restaurant": [
"PIZZA HUT",
"MCDONALD'S",
"BURGER KING",
"LOCAL RESTAURANT",
],
"Salary": ["MONTHLY SALARY", "PAYROLL DEPOSIT", "SALARY PAYMENT"],
"ATM Withdrawal": ["ATM WITHDRAWAL", "CASH WITHDRAWAL"],
"Transfer to Savings": ["SAVINGS TRANSFER", "INVESTMENT TRANSFER"],
}
specific_descriptions: list[str] = descriptions.get(
transaction_type["description"], [transaction_type["description"]]
)
description = random.choice(specific_descriptions)
# Create raw transaction (simplified GoCardless format)
raw_transaction = {
"transactionId": transaction_id,
"bookingDate": transaction_date.strftime("%Y-%m-%d"),
"valueDate": transaction_date.strftime("%Y-%m-%d"),
"transactionAmount": {
"amount": str(amount),
"currency": account["currency"],
},
"remittanceInformationUnstructured": description,
"bankTransactionCode": "PMNT" if amount < 0 else "RCDT",
}
# Determine status (most are booked, some recent ones might be pending)
status = (
"pending" if days_ago < 2 and random.random() < 0.1 else "booked"
)
transaction = {
"accountId": account["id"],
"transactionId": transaction_id,
"internalTransactionId": internal_transaction_id,
"institutionId": account["institution_id"],
"iban": account["iban"],
"transactionDate": transaction_date.isoformat(),
"description": description,
"transactionValue": amount,
"transactionCurrency": account["currency"],
"transactionStatus": status,
"rawTransaction": raw_transaction,
}
account_transactions.append(transaction)
current_balance += amount
# Sort transactions by date for realistic ordering
account_transactions.sort(key=lambda x: x["transactionDate"])
transactions.extend(account_transactions)
return transactions
def generate_balances(self, accounts: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Generate sample balances for accounts."""
balances = []
for account in accounts:
# Calculate balance from transactions (simplified)
base_balance = random.uniform(500, 2000)
balance_types = ["interimAvailable", "closingBooked", "authorised"]
for balance_type in balance_types:
# Add some variation to balance types
variation = (
random.uniform(-50, 50) if balance_type != "interimAvailable" else 0
)
balance_amount = base_balance + variation
balance = {
"account_id": account["id"],
"bank": account["institution_id"],
"status": account["status"],
"iban": account["iban"],
"amount": round(balance_amount, 2),
"currency": account["currency"],
"type": balance_type,
"timestamp": datetime.now().isoformat(),
}
balances.append(balance)
return balances
def insert_data(
self,
accounts: list[dict[str, Any]],
transactions: list[dict[str, Any]],
balances: list[dict[str, Any]],
):
"""Insert generated data into the database."""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
# Insert accounts
for account in accounts:
cursor.execute(
"""
INSERT OR REPLACE INTO accounts
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated, display_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
account["id"],
account["institution_id"],
account["status"],
account["iban"],
account["name"],
account["currency"],
account["created"],
account["last_accessed"],
account["last_updated"],
None, # display_name is initially None for sample data
),
)
# Insert transactions
for transaction in transactions:
cursor.execute(
"""
INSERT OR REPLACE INTO transactions
(accountId, transactionId, internalTransactionId, institutionId, iban,
transactionDate, description, transactionValue, transactionCurrency,
transactionStatus, rawTransaction)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
transaction["accountId"],
transaction["transactionId"],
transaction["internalTransactionId"],
transaction["institutionId"],
transaction["iban"],
transaction["transactionDate"],
transaction["description"],
transaction["transactionValue"],
transaction["transactionCurrency"],
transaction["transactionStatus"],
json.dumps(transaction["rawTransaction"]),
),
)
# Insert balances
for balance in balances:
cursor.execute(
"""
INSERT INTO balances
(account_id, bank, status, iban, amount, currency, type, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
balance["account_id"],
balance["bank"],
balance["status"],
balance["iban"],
balance["amount"],
balance["currency"],
balance["type"],
balance["timestamp"],
),
)
conn.commit()
conn.close()
def generate_sample_database(
self, num_accounts: int = 3, num_transactions_per_account: int = 50
):
"""Generate complete sample database."""
click.echo(f"🗄️ Creating sample database at: {self.db_path}")
self.ensure_database_dir()
self.create_tables()
click.echo(f"👥 Generating {num_accounts} sample accounts...")
accounts = self.generate_accounts(num_accounts)
click.echo(
f"💳 Generating {num_transactions_per_account} transactions per account..."
)
transactions = self.generate_transactions(
accounts, num_transactions_per_account
)
click.echo("💰 Generating account balances...")
balances = self.generate_balances(accounts)
click.echo("💾 Inserting data into database...")
self.insert_data(accounts, transactions, balances)
# Print summary
click.echo("\n✅ Sample database created successfully!")
click.echo("📊 Summary:")
click.echo(f" - Accounts: {len(accounts)}")
click.echo(f" - Transactions: {len(transactions)}")
click.echo(f" - Balances: {len(balances)}")
click.echo(f" - Database: {self.db_path}")
# Show account details
click.echo("\n📋 Sample accounts:")
for account in accounts:
institution_name = next(
inst["name"]
for inst in self.institutions
if inst["id"] == account["institution_id"]
)
click.echo(f" - {account['id']} ({institution_name}) - {account['iban']}")
@click.command()
@click.option(
"--database",
@@ -30,39 +509,45 @@ import click
)
@click.pass_context
def generate_sample_db(
ctx: click.Context, database: Path, accounts: int, transactions: int, force: bool
ctx: click.Context,
database: Path | None,
accounts: int,
transactions: int,
force: bool,
):
"""Generate a sample database with realistic financial data for testing."""
import os
# Import here to avoid circular imports
import subprocess
import sys
from pathlib import Path as PathlibPath
# Get the script path
script_path = (
PathlibPath(__file__).parent.parent.parent / "scripts" / "generate_sample_db.py"
)
# Build command arguments
cmd = [sys.executable, str(script_path)]
from leggen.utils.paths import path_manager
# Determine database path
if database:
cmd.extend(["--database", str(database)])
db_path = database
else:
# Use development database by default to avoid overwriting production data
env_path = os.environ.get("LEGGEN_DATABASE_PATH")
if env_path:
db_path = Path(env_path)
else:
# Default to development database in config directory
db_path = path_manager.get_config_dir() / "leggen-dev.db"
cmd.extend(["--accounts", str(accounts)])
cmd.extend(["--transactions", str(transactions)])
# Check if database exists and ask for confirmation
if db_path.exists() and not force:
click.echo(f"⚠️ Database already exists: {db_path}")
if not click.confirm("Do you want to overwrite it?"):
click.echo("Aborted.")
ctx.exit(0)
if force:
cmd.append("--force")
# Generate the sample database
generator = SampleDataGenerator(db_path)
generator.generate_sample_database(accounts, transactions)
# Execute the script
try:
subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as e:
click.echo(f"Error generating sample database: {e}")
ctx.exit(1)
# Export the command
generate_sample_db = generate_sample_db
# Show usage instructions
click.echo("\n🚀 Usage instructions:")
click.echo("To use this sample database with leggen commands:")
click.echo(f" export LEGGEN_DATABASE_PATH={db_path}")
click.echo(" leggen transactions")
click.echo("")
click.echo("To use this sample database with leggen server:")
click.echo(f" leggen server --database {db_path}")

View File

@@ -28,10 +28,10 @@ async def lifespan(app: FastAPI):
# Run database migrations
try:
from leggen.services.database_service import DatabaseService
from leggen.api.dependencies import get_migration_repository
db_service = DatabaseService()
await db_service.run_migrations_if_needed()
migrations = get_migration_repository()
await migrations.run_all_migrations()
logger.info("Database migrations completed")
except Exception as e:
logger.error(f"Database migration failed: {e}")
@@ -89,8 +89,6 @@ def create_app() -> FastAPI:
async def health():
"""Health check endpoint for API connectivity"""
try:
from leggen.api.models.common import APIResponse
config_loaded = config._config is not None
# Get version dynamically
@@ -99,25 +97,17 @@ def create_app() -> FastAPI:
except metadata.PackageNotFoundError:
version = "dev"
return APIResponse(
success=True,
data={
"status": "healthy",
"config_loaded": config_loaded,
"version": version,
"message": "API is running and responsive",
},
message="Health check successful",
)
return {
"status": "healthy",
"config_loaded": config_loaded,
"version": version,
}
except Exception as e:
logger.error(f"Health check failed: {e}")
from leggen.api.models.common import APIResponse
return APIResponse(
success=False,
data={"status": "unhealthy", "error": str(e)},
message="Health check failed",
)
return {
"status": "unhealthy",
"error": str(e),
}
return app

View File

@@ -61,17 +61,13 @@ def send_sync_failure_notification(ctx: click.Context, notification: dict):
info("Sending sync failure notification to Discord")
webhook = DiscordWebhook(url=ctx.obj["notifications"]["discord"]["webhook"])
# Determine color and title based on failure type
if notification.get("type") == "sync_final_failure":
color = "ff0000" # Red for final failure
title = "🚨 Sync Final Failure"
description = (
f"Sync failed permanently after {notification['retry_count']} attempts"
)
else:
color = "ffaa00" # Orange for retry
title = "⚠️ Sync Failure"
description = f"Sync failed (attempt {notification['retry_count']}/{notification['max_retries']}). Will retry automatically..."
color = "ffaa00" # Orange for sync failure
title = "⚠️ Sync Failure"
# Build description with account info if available
description = "Account sync failed"
if notification.get("account_id"):
description = f"Account {notification['account_id']} sync failed"
embed = DiscordEmbed(
title=title,

View File

@@ -87,19 +87,14 @@ def send_sync_failure_notification(ctx: click.Context, notification: dict):
bot_url = f"https://api.telegram.org/bot{token}/sendMessage"
info("Sending sync failure notification to Telegram")
message = "*🚨 [Leggen](https://github.com/elisiariocouto/leggen)*\n"
message = "*⚠️ [Leggen](https://github.com/elisiariocouto/leggen)*\n"
message += "*Sync Failed*\n\n"
message += escape_markdown(f"Error: {notification['error']}\n")
if notification.get("type") == "sync_final_failure":
message += escape_markdown(
f"❌ Final failure after {notification['retry_count']} attempts\n"
)
else:
message += escape_markdown(
f"🔄 Attempt {notification['retry_count']}/{notification['max_retries']}\n"
)
message += escape_markdown("Will retry automatically...\n")
# Add account info if available
if notification.get("account_id"):
message += escape_markdown(f"Account: {notification['account_id']}\n")
message += escape_markdown(f"Error: {notification['error']}\n")
res = requests.post(
bot_url,

View File

@@ -0,0 +1,13 @@
from leggen.repositories.account_repository import AccountRepository
from leggen.repositories.balance_repository import BalanceRepository
from leggen.repositories.migration_repository import MigrationRepository
from leggen.repositories.sync_repository import SyncRepository
from leggen.repositories.transaction_repository import TransactionRepository
__all__ = [
"AccountRepository",
"BalanceRepository",
"MigrationRepository",
"SyncRepository",
"TransactionRepository",
]

View File

@@ -0,0 +1,128 @@
from typing import Any, Dict, List, Optional
from leggen.repositories.base_repository import BaseRepository
class AccountRepository(BaseRepository):
"""Repository for account data operations"""
def create_table(self):
"""Create accounts table with indexes"""
with self._get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
institution_id TEXT,
status TEXT,
iban TEXT,
name TEXT,
currency TEXT,
created DATETIME,
last_accessed DATETIME,
last_updated DATETIME,
display_name TEXT,
logo TEXT
)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_accounts_institution_id
ON accounts(institution_id)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_accounts_status
ON accounts(status)"""
)
conn.commit()
def persist(self, account_data: Dict[str, Any]) -> Dict[str, Any]:
"""Persist account details to database"""
self.create_table()
with self._get_db_connection() as conn:
cursor = conn.cursor()
# Check if account exists and preserve display_name
cursor.execute(
"SELECT display_name FROM accounts WHERE id = ?", (account_data["id"],)
)
existing_row = cursor.fetchone()
existing_display_name = existing_row[0] if existing_row else None
# Use existing display_name if not provided in account_data
display_name = account_data.get("display_name", existing_display_name)
cursor.execute(
"""INSERT OR REPLACE INTO accounts (
id,
institution_id,
status,
iban,
name,
currency,
created,
last_accessed,
last_updated,
display_name,
logo
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
account_data["id"],
account_data["institution_id"],
account_data["status"],
account_data.get("iban"),
account_data.get("name"),
account_data.get("currency"),
account_data["created"],
account_data.get("last_accessed"),
account_data.get("last_updated", account_data["created"]),
display_name,
account_data.get("logo"),
),
)
conn.commit()
return account_data
def get_accounts(
self, account_ids: Optional[List[str]] = None
) -> List[Dict[str, Any]]:
"""Get account details from database"""
if not self._db_exists():
return []
with self._get_db_connection(row_factory=True) as conn:
cursor = conn.cursor()
query = "SELECT * FROM accounts"
params = []
if account_ids:
placeholders = ",".join("?" * len(account_ids))
query += f" WHERE id IN ({placeholders})"
params.extend(account_ids)
query += " ORDER BY created DESC"
cursor.execute(query, params)
rows = cursor.fetchall()
return [dict(row) for row in rows]
def get_account(self, account_id: str) -> Optional[Dict[str, Any]]:
"""Get specific account details from database"""
if not self._db_exists():
return None
with self._get_db_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
row = cursor.fetchone()
if row:
return dict(row)
return None

View File

@@ -0,0 +1,107 @@
import sqlite3
from typing import Any, Dict, List, Optional
from loguru import logger
from leggen.repositories.base_repository import BaseRepository
class BalanceRepository(BaseRepository):
"""Repository for balance data operations"""
def create_table(self):
"""Create balances table with indexes"""
with self._get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""CREATE TABLE IF NOT EXISTS balances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT,
bank TEXT,
status TEXT,
iban TEXT,
amount REAL,
currency TEXT,
type TEXT,
timestamp DATETIME
)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_balances_account_id
ON balances(account_id)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_balances_timestamp
ON balances(timestamp)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_balances_account_type_timestamp
ON balances(account_id, type, timestamp)"""
)
conn.commit()
def persist(self, account_id: str, balance_rows: List[tuple]) -> None:
"""Persist balance rows to database"""
try:
self.create_table()
with self._get_db_connection() as conn:
cursor = conn.cursor()
for row in balance_rows:
try:
cursor.execute(
"""INSERT INTO balances (
account_id,
bank,
status,
iban,
amount,
currency,
type,
timestamp
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
row,
)
except sqlite3.IntegrityError:
logger.warning(f"Skipped duplicate balance for {account_id}")
conn.commit()
logger.info(f"Persisted balances for account {account_id}")
except Exception as e:
logger.error(f"Failed to persist balances: {e}")
raise
def get_balances(self, account_id: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get latest balances from database"""
if not self._db_exists():
return []
with self._get_db_connection(row_factory=True) as conn:
cursor = conn.cursor()
# Get latest balance for each account_id and type combination
query = """
SELECT * FROM balances b1
WHERE b1.timestamp = (
SELECT MAX(b2.timestamp)
FROM balances b2
WHERE b2.account_id = b1.account_id AND b2.type = b1.type
)
"""
params = []
if account_id:
query += " AND b1.account_id = ?"
params.append(account_id)
query += " ORDER BY b1.account_id, b1.type"
cursor.execute(query, params)
rows = cursor.fetchall()
return [dict(row) for row in rows]

View File

@@ -0,0 +1,28 @@
import sqlite3
from contextlib import contextmanager
from leggen.utils.paths import path_manager
class BaseRepository:
"""Base repository with shared database connection logic"""
@contextmanager
def _get_db_connection(self, row_factory: bool = False):
"""Context manager for database connections with proper cleanup"""
db_path = path_manager.get_database_path()
conn = sqlite3.connect(str(db_path))
if row_factory:
conn.row_factory = sqlite3.Row
try:
yield conn
except Exception as e:
conn.rollback()
raise e
finally:
conn.close()
def _db_exists(self) -> bool:
"""Check if database file exists"""
db_path = path_manager.get_database_path()
return db_path.exists()

View File

@@ -0,0 +1,626 @@
import sqlite3
import uuid
from datetime import datetime
from loguru import logger
from leggen.repositories.base_repository import BaseRepository
from leggen.utils.paths import path_manager
class MigrationRepository(BaseRepository):
"""Repository for database migrations"""
async def run_all_migrations(self):
"""Run all necessary database migrations"""
await self.migrate_balance_timestamps_if_needed()
await self.migrate_null_transaction_ids_if_needed()
await self.migrate_to_composite_key_if_needed()
await self.migrate_add_display_name_if_needed()
await self.migrate_add_sync_operations_if_needed()
await self.migrate_add_logo_if_needed()
# Balance timestamp migration methods
async def migrate_balance_timestamps_if_needed(self):
"""Check and migrate balance timestamps if needed"""
try:
if await self._check_balance_timestamp_migration_needed():
logger.info("Balance timestamp migration needed, starting...")
await self._migrate_balance_timestamps()
logger.info("Balance timestamp migration completed")
else:
logger.info("Balance timestamps are already consistent")
except Exception as e:
logger.error(f"Balance timestamp migration failed: {e}")
raise
async def _check_balance_timestamp_migration_needed(self) -> bool:
"""Check if balance timestamps need migration"""
db_path = path_manager.get_database_path()
if not db_path.exists():
return False
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute("""
SELECT typeof(timestamp) as type, COUNT(*) as count
FROM balances
GROUP BY typeof(timestamp)
""")
types = cursor.fetchall()
conn.close()
type_names = [row[0] for row in types]
return "real" in type_names and "text" in type_names
except Exception as e:
logger.error(f"Failed to check migration status: {e}")
return False
async def _migrate_balance_timestamps(self):
"""Convert all Unix timestamps to datetime strings"""
db_path = path_manager.get_database_path()
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute("""
SELECT id, timestamp
FROM balances
WHERE typeof(timestamp) = 'real'
ORDER BY id
""")
unix_records = cursor.fetchall()
total_records = len(unix_records)
if total_records == 0:
logger.info("No Unix timestamps found to migrate")
conn.close()
return
logger.info(
f"Migrating {total_records} balance records from Unix to datetime format"
)
batch_size = 100
migrated_count = 0
for i in range(0, total_records, batch_size):
batch = unix_records[i : i + batch_size]
for record_id, unix_timestamp in batch:
try:
dt_string = self._unix_to_datetime_string(float(unix_timestamp))
cursor.execute(
"""
UPDATE balances
SET timestamp = ?
WHERE id = ?
""",
(dt_string, record_id),
)
migrated_count += 1
if migrated_count % 100 == 0:
logger.info(
f"Migrated {migrated_count}/{total_records} balance records"
)
except Exception as e:
logger.error(f"Failed to migrate record {record_id}: {e}")
continue
conn.commit()
conn.close()
logger.info(f"Successfully migrated {migrated_count} balance records")
except Exception as e:
logger.error(f"Balance timestamp migration failed: {e}")
raise
def _unix_to_datetime_string(self, unix_timestamp: float) -> str:
"""Convert Unix timestamp to datetime string"""
dt = datetime.fromtimestamp(unix_timestamp)
return dt.isoformat()
# Null transaction IDs migration methods
async def migrate_null_transaction_ids_if_needed(self):
"""Check and migrate null transaction IDs if needed"""
try:
if await self._check_null_transaction_ids_migration_needed():
logger.info("Null transaction IDs migration needed, starting...")
await self._migrate_null_transaction_ids()
logger.info("Null transaction IDs migration completed")
else:
logger.info("No null transaction IDs found to migrate")
except Exception as e:
logger.error(f"Null transaction IDs migration failed: {e}")
raise
async def _check_null_transaction_ids_migration_needed(self) -> bool:
"""Check if null transaction IDs need migration"""
db_path = path_manager.get_database_path()
if not db_path.exists():
return False
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute("""
SELECT COUNT(*)
FROM transactions
WHERE (internalTransactionId IS NULL OR internalTransactionId = '')
AND json_extract(rawTransaction, '$.transactionId') IS NOT NULL
""")
count = cursor.fetchone()[0]
conn.close()
return count > 0
except Exception as e:
logger.error(f"Failed to check null transaction IDs migration status: {e}")
return False
async def _migrate_null_transaction_ids(self):
"""Populate null internalTransactionId fields using transactionId from raw data"""
db_path = path_manager.get_database_path()
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute("""
SELECT rowid, json_extract(rawTransaction, '$.transactionId') as transactionId
FROM transactions
WHERE (internalTransactionId IS NULL OR internalTransactionId = '')
AND json_extract(rawTransaction, '$.transactionId') IS NOT NULL
ORDER BY rowid
""")
null_records = cursor.fetchall()
total_records = len(null_records)
if total_records == 0:
logger.info("No null transaction IDs found to migrate")
conn.close()
return
logger.info(
f"Migrating {total_records} transaction records with null internalTransactionId"
)
batch_size = 100
migrated_count = 0
for i in range(0, total_records, batch_size):
batch = null_records[i : i + batch_size]
for rowid, transaction_id in batch:
try:
cursor.execute(
"SELECT COUNT(*) FROM transactions WHERE internalTransactionId = ?",
(str(transaction_id),),
)
existing_count = cursor.fetchone()[0]
if existing_count > 0:
unique_id = f"{str(transaction_id)}_{uuid.uuid4().hex[:8]}"
logger.debug(
f"Generated unique ID for duplicate transactionId: {unique_id}"
)
else:
unique_id = str(transaction_id)
cursor.execute(
"""
UPDATE transactions
SET internalTransactionId = ?
WHERE rowid = ?
""",
(unique_id, rowid),
)
migrated_count += 1
if migrated_count % 100 == 0:
logger.info(
f"Migrated {migrated_count}/{total_records} transaction records"
)
except Exception as e:
logger.error(f"Failed to migrate record {rowid}: {e}")
continue
conn.commit()
conn.close()
logger.info(f"Successfully migrated {migrated_count} transaction records")
except Exception as e:
logger.error(f"Null transaction IDs migration failed: {e}")
raise
# Composite key migration methods
async def migrate_to_composite_key_if_needed(self):
"""Check and migrate to composite primary key if needed"""
try:
if await self._check_composite_key_migration_needed():
logger.info("Composite key migration needed, starting...")
await self._migrate_to_composite_key()
logger.info("Composite key migration completed")
else:
logger.info("Composite key migration not needed")
except Exception as e:
logger.error(f"Composite key migration failed: {e}")
raise
async def _check_composite_key_migration_needed(self) -> bool:
"""Check if composite key migration is needed"""
db_path = path_manager.get_database_path()
if not db_path.exists():
return False
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='transactions'"
)
if not cursor.fetchone():
conn.close()
return False
cursor.execute("PRAGMA table_info(transactions)")
columns = cursor.fetchall()
internal_transaction_id_is_pk = any(
col[1] == "internalTransactionId" and col[5] == 1 for col in columns
)
has_composite_key = any(
col[1] in ["accountId", "transactionId"] and col[5] == 1
for col in columns
)
conn.close()
return internal_transaction_id_is_pk or not has_composite_key
except Exception as e:
logger.error(f"Failed to check composite key migration status: {e}")
return False
async def _migrate_to_composite_key(self):
"""Migrate transactions table to use composite primary key (accountId, transactionId)"""
db_path = path_manager.get_database_path()
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
logger.info("Starting composite key migration...")
logger.info("Creating temporary table with composite primary key...")
cursor.execute("DROP TABLE IF EXISTS transactions_temp")
cursor.execute("""
CREATE TABLE transactions_temp (
accountId TEXT NOT NULL,
transactionId TEXT NOT NULL,
internalTransactionId TEXT,
institutionId TEXT,
iban TEXT,
transactionDate DATETIME,
description TEXT,
transactionValue REAL,
transactionCurrency TEXT,
transactionStatus TEXT,
rawTransaction JSON,
PRIMARY KEY (accountId, transactionId)
)
""")
logger.info("Inserting deduplicated data...")
cursor.execute("""
INSERT INTO transactions_temp
SELECT
accountId,
json_extract(rawTransaction, '$.transactionId') as transactionId,
internalTransactionId,
institutionId,
iban,
transactionDate,
description,
transactionValue,
transactionCurrency,
transactionStatus,
rawTransaction
FROM (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY accountId, json_extract(rawTransaction, '$.transactionId')
ORDER BY transactionDate DESC
) as rn
FROM transactions
WHERE json_extract(rawTransaction, '$.transactionId') IS NOT NULL
)
WHERE rn = 1
""")
rows_migrated = cursor.rowcount
logger.info(f"Migrated {rows_migrated} unique transactions")
logger.info("Replacing old table...")
cursor.execute("DROP TABLE transactions")
cursor.execute("ALTER TABLE transactions_temp RENAME TO transactions")
logger.info("Recreating indexes...")
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_transactions_internal_id
ON transactions(internalTransactionId)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_transactions_date
ON transactions(transactionDate)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_transactions_account_date
ON transactions(accountId, transactionDate)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_transactions_amount
ON transactions(transactionValue)"""
)
conn.commit()
conn.close()
logger.info("Composite key migration completed successfully")
except Exception as e:
logger.error(f"Composite key migration failed: {e}")
raise
# Display name migration methods
async def migrate_add_display_name_if_needed(self):
"""Check and add display_name column if needed"""
try:
if await self._check_display_name_migration_needed():
logger.info("Display name column migration needed, starting...")
await self._migrate_add_display_name()
logger.info("Display name column migration completed")
else:
logger.info("Display name column already exists")
except Exception as e:
logger.error(f"Display name column migration failed: {e}")
raise
async def _check_display_name_migration_needed(self) -> bool:
"""Check if display_name column needs to be added"""
db_path = path_manager.get_database_path()
if not db_path.exists():
return False
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
)
if not cursor.fetchone():
conn.close()
return False
cursor.execute("PRAGMA table_info(accounts)")
columns = cursor.fetchall()
has_display_name = any(col[1] == "display_name" for col in columns)
conn.close()
return not has_display_name
except Exception as e:
logger.error(f"Failed to check display_name migration status: {e}")
return False
async def _migrate_add_display_name(self):
"""Add display_name column to accounts table"""
db_path = path_manager.get_database_path()
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
logger.info("Adding display_name column to accounts table...")
cursor.execute("""
ALTER TABLE accounts
ADD COLUMN display_name TEXT
""")
conn.commit()
conn.close()
logger.info("Display name column migration completed successfully")
except Exception as e:
logger.error(f"Display name column migration failed: {e}")
raise
# Sync operations migration methods
async def migrate_add_sync_operations_if_needed(self):
"""Check and add sync_operations table if needed"""
try:
if await self._check_sync_operations_migration_needed():
logger.info("Sync operations table migration needed, starting...")
await self._migrate_add_sync_operations()
logger.info("Sync operations table migration completed")
else:
logger.info("Sync operations table already exists")
except Exception as e:
logger.error(f"Sync operations table migration failed: {e}")
raise
async def _check_sync_operations_migration_needed(self) -> bool:
"""Check if sync_operations table needs to be created"""
db_path = path_manager.get_database_path()
if not db_path.exists():
return False
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='sync_operations'"
)
table_exists = cursor.fetchone() is not None
conn.close()
return not table_exists
except Exception as e:
logger.error(f"Failed to check sync_operations migration status: {e}")
return False
async def _migrate_add_sync_operations(self):
"""Add sync_operations table"""
db_path = path_manager.get_database_path()
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
logger.info("Creating sync_operations table...")
cursor.execute("""
CREATE TABLE sync_operations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at DATETIME NOT NULL,
completed_at DATETIME,
success BOOLEAN,
accounts_processed INTEGER DEFAULT 0,
transactions_added INTEGER DEFAULT 0,
transactions_updated INTEGER DEFAULT 0,
balances_updated INTEGER DEFAULT 0,
duration_seconds REAL,
errors TEXT,
logs TEXT,
trigger_type TEXT DEFAULT 'manual'
)
""")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_sync_operations_started_at ON sync_operations(started_at)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_sync_operations_success ON sync_operations(success)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_sync_operations_trigger_type ON sync_operations(trigger_type)"
)
conn.commit()
conn.close()
logger.info("Sync operations table migration completed successfully")
except Exception as e:
logger.error(f"Sync operations table migration failed: {e}")
raise
# Logo migration methods
async def migrate_add_logo_if_needed(self):
"""Check and add logo column to accounts table if needed"""
try:
if await self._check_logo_migration_needed():
logger.info("Logo column migration needed, starting...")
await self._migrate_add_logo()
logger.info("Logo column migration completed")
else:
logger.info("Logo column already exists")
except Exception as e:
logger.error(f"Logo column migration failed: {e}")
raise
async def _check_logo_migration_needed(self) -> bool:
"""Check if logo column needs to be added to accounts table"""
db_path = path_manager.get_database_path()
if not db_path.exists():
return False
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'"
)
if not cursor.fetchone():
conn.close()
return False
cursor.execute("PRAGMA table_info(accounts)")
columns = cursor.fetchall()
has_logo = any(col[1] == "logo" for col in columns)
conn.close()
return not has_logo
except Exception as e:
logger.error(f"Failed to check logo migration status: {e}")
return False
async def _migrate_add_logo(self):
"""Add logo column to accounts table"""
db_path = path_manager.get_database_path()
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
logger.info("Adding logo column to accounts table...")
cursor.execute("""
ALTER TABLE accounts
ADD COLUMN logo TEXT
""")
conn.commit()
conn.close()
logger.info("Logo column migration completed successfully")
except Exception as e:
logger.error(f"Logo column migration failed: {e}")
raise

View File

@@ -0,0 +1,132 @@
import json
import sqlite3
from typing import Any, Dict, List
from loguru import logger
from leggen.repositories.base_repository import BaseRepository
from leggen.utils.paths import path_manager
class SyncRepository(BaseRepository):
"""Repository for sync operation data"""
def create_table(self):
"""Create sync_operations table with indexes"""
with self._get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS sync_operations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at DATETIME NOT NULL,
completed_at DATETIME,
success BOOLEAN,
accounts_processed INTEGER DEFAULT 0,
transactions_added INTEGER DEFAULT 0,
transactions_updated INTEGER DEFAULT 0,
balances_updated INTEGER DEFAULT 0,
duration_seconds REAL,
errors TEXT,
logs TEXT,
trigger_type TEXT DEFAULT 'manual'
)
""")
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_sync_operations_started_at ON sync_operations(started_at)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_sync_operations_success ON sync_operations(success)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_sync_operations_trigger_type ON sync_operations(trigger_type)"
)
conn.commit()
def persist(self, sync_operation: Dict[str, Any]) -> int:
"""Persist sync operation to database and return the ID"""
try:
self.create_table()
db_path = path_manager.get_database_path()
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute(
"""INSERT INTO sync_operations (
started_at, completed_at, success, accounts_processed,
transactions_added, transactions_updated, balances_updated,
duration_seconds, errors, logs, trigger_type
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
sync_operation.get("started_at"),
sync_operation.get("completed_at"),
sync_operation.get("success"),
sync_operation.get("accounts_processed", 0),
sync_operation.get("transactions_added", 0),
sync_operation.get("transactions_updated", 0),
sync_operation.get("balances_updated", 0),
sync_operation.get("duration_seconds"),
json.dumps(sync_operation.get("errors", [])),
json.dumps(sync_operation.get("logs", [])),
sync_operation.get("trigger_type", "manual"),
),
)
operation_id = cursor.lastrowid
if operation_id is None:
raise ValueError("Failed to get operation ID after insert")
conn.commit()
conn.close()
logger.debug(f"Persisted sync operation with ID: {operation_id}")
return operation_id
except Exception as e:
logger.error(f"Failed to persist sync operation: {e}")
raise
def get_operations(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
"""Get sync operations from database"""
try:
db_path = path_manager.get_database_path()
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute(
"""SELECT id, started_at, completed_at, success, accounts_processed,
transactions_added, transactions_updated, balances_updated,
duration_seconds, errors, logs, trigger_type
FROM sync_operations
ORDER BY started_at DESC
LIMIT ? OFFSET ?""",
(limit, offset),
)
operations = []
for row in cursor.fetchall():
operation = {
"id": row[0],
"started_at": row[1],
"completed_at": row[2],
"success": bool(row[3]) if row[3] is not None else None,
"accounts_processed": row[4],
"transactions_added": row[5],
"transactions_updated": row[6],
"balances_updated": row[7],
"duration_seconds": row[8],
"errors": json.loads(row[9]) if row[9] else [],
"logs": json.loads(row[10]) if row[10] else [],
"trigger_type": row[11],
}
operations.append(operation)
conn.close()
return operations
except Exception as e:
logger.error(f"Failed to get sync operations: {e}")
return []

View File

@@ -0,0 +1,264 @@
import json
import sqlite3
from typing import Any, Dict, List, Optional, Union
from loguru import logger
from leggen.repositories.base_repository import BaseRepository
class TransactionRepository(BaseRepository):
"""Repository for transaction data operations"""
def create_table(self):
"""Create transactions table with indexes"""
with self._get_db_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"""CREATE TABLE IF NOT EXISTS transactions (
accountId TEXT NOT NULL,
transactionId TEXT NOT NULL,
internalTransactionId TEXT,
institutionId TEXT,
iban TEXT,
transactionDate DATETIME,
description TEXT,
transactionValue REAL,
transactionCurrency TEXT,
transactionStatus TEXT,
rawTransaction JSON,
PRIMARY KEY (accountId, transactionId)
)"""
)
# Create indexes for better performance
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_transactions_internal_id
ON transactions(internalTransactionId)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_transactions_date
ON transactions(transactionDate)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_transactions_account_date
ON transactions(accountId, transactionDate)"""
)
cursor.execute(
"""CREATE INDEX IF NOT EXISTS idx_transactions_amount
ON transactions(transactionValue)"""
)
conn.commit()
def persist(
self, account_id: str, transactions: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Persist transactions to database, return new ones"""
try:
self.create_table()
with self._get_db_connection() as conn:
cursor = conn.cursor()
insert_sql = """INSERT OR REPLACE INTO transactions (
accountId,
transactionId,
internalTransactionId,
institutionId,
iban,
transactionDate,
description,
transactionValue,
transactionCurrency,
transactionStatus,
rawTransaction
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"""
new_transactions = []
for transaction in transactions:
try:
# Check if transaction already exists
cursor.execute(
"""SELECT COUNT(*) FROM transactions
WHERE accountId = ? AND transactionId = ?""",
(transaction["accountId"], transaction["transactionId"]),
)
exists = cursor.fetchone()[0] > 0
cursor.execute(
insert_sql,
(
transaction["accountId"],
transaction["transactionId"],
transaction.get("internalTransactionId"),
transaction["institutionId"],
transaction["iban"],
transaction["transactionDate"],
transaction["description"],
transaction["transactionValue"],
transaction["transactionCurrency"],
transaction["transactionStatus"],
json.dumps(transaction["rawTransaction"]),
),
)
if not exists:
new_transactions.append(transaction)
except sqlite3.IntegrityError as e:
logger.warning(
f"Failed to insert transaction {transaction.get('transactionId')}: {e}"
)
continue
conn.commit()
logger.info(
f"Persisted {len(new_transactions)} new transactions for account {account_id}"
)
return new_transactions
except Exception as e:
logger.error(f"Failed to persist transactions: {e}")
raise
def get_transactions(
self,
account_id: Optional[str] = None,
limit: Optional[int] = 100,
offset: int = 0,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
min_amount: Optional[float] = None,
max_amount: Optional[float] = None,
search: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Get transactions with optional filtering"""
if not self._db_exists():
return []
with self._get_db_connection(row_factory=True) as conn:
cursor = conn.cursor()
query = "SELECT * FROM transactions WHERE 1=1"
params: List[Union[str, int, float]] = []
if account_id:
query += " AND accountId = ?"
params.append(account_id)
if date_from:
query += " AND transactionDate >= ?"
params.append(date_from)
if date_to:
query += " AND transactionDate <= ?"
params.append(date_to)
if min_amount is not None:
query += " AND transactionValue >= ?"
params.append(min_amount)
if max_amount is not None:
query += " AND transactionValue <= ?"
params.append(max_amount)
if search:
query += " AND description LIKE ?"
params.append(f"%{search}%")
query += " ORDER BY transactionDate DESC"
if limit:
query += " LIMIT ?"
params.append(limit)
if offset:
query += " OFFSET ?"
params.append(offset)
cursor.execute(query, params)
rows = cursor.fetchall()
transactions = []
for row in rows:
transaction = dict(row)
if transaction["rawTransaction"]:
transaction["rawTransaction"] = json.loads(
transaction["rawTransaction"]
)
transactions.append(transaction)
return transactions
def get_count(
self,
account_id: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
min_amount: Optional[float] = None,
max_amount: Optional[float] = None,
search: Optional[str] = None,
) -> int:
"""Get total count of transactions matching filters"""
if not self._db_exists():
return 0
with self._get_db_connection() as conn:
cursor = conn.cursor()
query = "SELECT COUNT(*) FROM transactions WHERE 1=1"
params: List[Union[str, float]] = []
if account_id:
query += " AND accountId = ?"
params.append(account_id)
if date_from:
query += " AND transactionDate >= ?"
params.append(date_from)
if date_to:
query += " AND transactionDate <= ?"
params.append(date_to)
if min_amount is not None:
query += " AND transactionValue >= ?"
params.append(min_amount)
if max_amount is not None:
query += " AND transactionValue <= ?"
params.append(max_amount)
if search:
query += " AND description LIKE ?"
params.append(f"%{search}%")
cursor.execute(query, params)
return cursor.fetchone()[0]
def get_account_summary(self, account_id: str) -> Optional[Dict[str, Any]]:
"""Get basic account info from transactions table"""
if not self._db_exists():
return None
with self._get_db_connection(row_factory=True) as conn:
cursor = conn.cursor()
cursor.execute(
"""
SELECT DISTINCT accountId, institutionId, iban
FROM transactions
WHERE accountId = ?
ORDER BY transactionDate DESC
LIMIT 1
""",
(account_id,),
)
row = cursor.fetchone()
if row:
return dict(row)
return None

View File

@@ -0,0 +1,13 @@
"""Data processing layer for all transformation logic."""
from leggen.services.data_processors.account_enricher import AccountEnricher
from leggen.services.data_processors.analytics_processor import AnalyticsProcessor
from leggen.services.data_processors.balance_transformer import BalanceTransformer
from leggen.services.data_processors.transaction_processor import TransactionProcessor
__all__ = [
"AccountEnricher",
"AnalyticsProcessor",
"BalanceTransformer",
"TransactionProcessor",
]

View File

@@ -0,0 +1,71 @@
"""Account enrichment processor for adding currency, logos, and metadata."""
from typing import Any, Dict
from loguru import logger
from leggen.services.gocardless_service import GoCardlessService
class AccountEnricher:
"""Enriches account details with currency and institution information."""
def __init__(self):
self.gocardless = GoCardlessService()
async def enrich_account_details(
self,
account_details: Dict[str, Any],
balances: Dict[str, Any],
) -> Dict[str, Any]:
"""
Enrich account details with currency from balances and institution logo.
Args:
account_details: Raw account details from GoCardless
balances: Balance data containing currency information
Returns:
Enriched account details with currency and logo added
"""
enriched_account = account_details.copy()
# Extract currency from first balance
currency = self._extract_currency_from_balances(balances)
if currency:
enriched_account["currency"] = currency
# Fetch and add institution logo
institution_id = enriched_account.get("institution_id")
if institution_id:
logo = await self._fetch_institution_logo(institution_id)
if logo:
enriched_account["logo"] = logo
return enriched_account
def _extract_currency_from_balances(self, balances: Dict[str, Any]) -> str | None:
"""Extract currency from the first balance in the balances data."""
balances_list = balances.get("balances", [])
if not balances_list:
return None
first_balance = balances_list[0]
balance_amount = first_balance.get("balanceAmount", {})
return balance_amount.get("currency")
async def _fetch_institution_logo(self, institution_id: str) -> str | None:
"""Fetch institution logo from GoCardless API."""
try:
institution_details = await self.gocardless.get_institution_details(
institution_id
)
logo = institution_details.get("logo", "")
if logo:
logger.info(f"Fetched logo for institution {institution_id}: {logo}")
return logo
except Exception as e:
logger.warning(
f"Failed to fetch institution details for {institution_id}: {e}"
)
return None

View File

@@ -0,0 +1,201 @@
"""Analytics processor for calculating historical balances and statistics."""
import sqlite3
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List, Optional
from loguru import logger
class AnalyticsProcessor:
"""Calculates historical balances and transaction statistics from database data."""
def calculate_historical_balances(
self,
db_path: Path,
account_id: Optional[str] = None,
days: int = 365,
) -> List[Dict[str, Any]]:
"""
Generate historical balance progression based on transaction history.
Uses current balances and subtracts future transactions to calculate
balance at each historical point in time.
Args:
db_path: Path to SQLite database
account_id: Optional account ID to filter by
days: Number of days to look back (default 365)
Returns:
List of historical balance data points
"""
if not db_path.exists():
return []
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
try:
cutoff_date = (datetime.now() - timedelta(days=days)).date().isoformat()
today_date = datetime.now().date().isoformat()
# Single SQL query to generate historical balances using window functions
query = """
WITH RECURSIVE date_series AS (
-- Generate weekly dates from cutoff_date to today
SELECT date(?) as ref_date
UNION ALL
SELECT date(ref_date, '+7 days')
FROM date_series
WHERE ref_date < date(?)
),
current_balances AS (
-- Get current balance for each account/type
SELECT account_id, type, amount, currency
FROM balances b1
WHERE b1.timestamp = (
SELECT MAX(b2.timestamp)
FROM balances b2
WHERE b2.account_id = b1.account_id AND b2.type = b1.type
)
{account_filter}
AND b1.type = 'closingBooked' -- Focus on closingBooked for charts
),
historical_points AS (
-- Calculate balance at each weekly point by subtracting future transactions
SELECT
cb.account_id,
cb.type as balance_type,
cb.currency,
ds.ref_date,
cb.amount - COALESCE(
(SELECT SUM(t.transactionValue)
FROM transactions t
WHERE t.accountId = cb.account_id
AND date(t.transactionDate) > ds.ref_date), 0
) as balance_amount
FROM current_balances cb
CROSS JOIN date_series ds
)
SELECT
account_id || '_' || balance_type || '_' || ref_date as id,
account_id,
balance_amount,
balance_type,
currency,
ref_date as reference_date
FROM historical_points
ORDER BY account_id, ref_date
"""
# Build parameters and account filter
params = [cutoff_date, today_date]
if account_id:
account_filter = "AND b1.account_id = ?"
params.append(account_id)
else:
account_filter = ""
# Format the query with conditional filter
formatted_query = query.format(account_filter=account_filter)
cursor.execute(formatted_query, params)
rows = cursor.fetchall()
conn.close()
return [dict(row) for row in rows]
except Exception as e:
conn.close()
logger.error(f"Failed to calculate historical balances: {e}")
raise
def calculate_monthly_stats(
self,
db_path: Path,
account_id: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
Calculate monthly transaction statistics aggregated from database.
Sums transactions by month and calculates income, expenses, and net values.
Args:
db_path: Path to SQLite database
account_id: Optional account ID to filter by
date_from: Optional start date (ISO format)
date_to: Optional end date (ISO format)
Returns:
List of monthly statistics with income, expenses, and net totals
"""
if not db_path.exists():
return []
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
try:
# SQL query to aggregate transactions by month
query = """
SELECT
strftime('%Y-%m', transactionDate) as month,
COALESCE(SUM(CASE WHEN transactionValue > 0 THEN transactionValue ELSE 0 END), 0) as income,
COALESCE(SUM(CASE WHEN transactionValue < 0 THEN ABS(transactionValue) ELSE 0 END), 0) as expenses,
COALESCE(SUM(transactionValue), 0) as net
FROM transactions
WHERE 1=1
"""
params = []
if account_id:
query += " AND accountId = ?"
params.append(account_id)
if date_from:
query += " AND transactionDate >= ?"
params.append(date_from)
if date_to:
query += " AND transactionDate <= ?"
params.append(date_to)
query += """
GROUP BY strftime('%Y-%m', transactionDate)
ORDER BY month ASC
"""
cursor.execute(query, params)
rows = cursor.fetchall()
# Convert to desired format with proper month display
monthly_stats = []
for row in rows:
# Convert YYYY-MM to display format like "Mar 2024"
year, month_num = row["month"].split("-")
month_date = datetime.strptime(f"{year}-{month_num}-01", "%Y-%m-%d")
display_month = month_date.strftime("%b %Y")
monthly_stats.append(
{
"month": display_month,
"income": round(row["income"], 2),
"expenses": round(row["expenses"], 2),
"net": round(row["net"], 2),
}
)
conn.close()
return monthly_stats
except Exception as e:
conn.close()
logger.error(f"Failed to calculate monthly stats: {e}")
raise

View File

@@ -0,0 +1,69 @@
"""Balance data transformation processor for format conversions."""
from datetime import datetime
from typing import Any, Dict, List, Tuple
class BalanceTransformer:
"""Transforms balance data between GoCardless and internal database formats."""
def merge_account_metadata_into_balances(
self,
balances: Dict[str, Any],
account_details: Dict[str, Any],
) -> Dict[str, Any]:
"""
Merge account metadata into balance data for proper persistence.
This adds institution_id, iban, and account_status to the balances
so they can be persisted alongside the balance data.
Args:
balances: Raw balance data from GoCardless
account_details: Enriched account details containing metadata
Returns:
Balance data with account metadata merged in
"""
balances_with_metadata = balances.copy()
balances_with_metadata["institution_id"] = account_details.get("institution_id")
balances_with_metadata["iban"] = account_details.get("iban")
balances_with_metadata["account_status"] = account_details.get("status")
return balances_with_metadata
def transform_to_database_format(
self,
account_id: str,
balance_data: Dict[str, Any],
) -> List[Tuple[Any, ...]]:
"""
Transform GoCardless balance format to database row format.
Converts nested GoCardless balance structure into flat tuples
ready for SQLite insertion.
Args:
account_id: The account ID
balance_data: Balance data with merged account metadata
Returns:
List of tuples in database row format (account_id, bank, status, ...)
"""
rows = []
for balance in balance_data.get("balances", []):
balance_amount = balance.get("balanceAmount", {})
row = (
account_id,
balance_data.get("institution_id", "unknown"),
balance_data.get("account_status"),
balance_data.get("iban", "N/A"),
float(balance_amount.get("amount", 0)),
balance_amount.get("currency"),
balance.get("balanceType"),
datetime.now().isoformat(),
)
rows.append(row)
return rows

View File

@@ -70,7 +70,10 @@ class TransactionProcessor:
internal_transaction_id = transaction.get("internalTransactionId")
if not transaction_id:
raise ValueError("Transaction missing required transactionId field")
if internal_transaction_id:
transaction_id = internal_transaction_id
else:
raise ValueError("Transaction missing required transactionId field")
return {
"accountId": account_id,

File diff suppressed because it is too large Load Diff

View File

@@ -52,11 +52,17 @@ class NotificationService:
async def send_expiry_notification(self, notification_data: Dict[str, Any]) -> None:
"""Send notification about account expiry"""
if self._is_discord_enabled():
await self._send_discord_expiry(notification_data)
try:
if self._is_discord_enabled():
await self._send_discord_expiry(notification_data)
except Exception as e:
logger.error(f"Failed to send Discord expiry notification: {e}")
if self._is_telegram_enabled():
await self._send_telegram_expiry(notification_data)
try:
if self._is_telegram_enabled():
await self._send_telegram_expiry(notification_data)
except Exception as e:
logger.error(f"Failed to send Telegram expiry notification: {e}")
def _filter_transactions(
self, transactions: List[Dict[str, Any]]
@@ -262,7 +268,6 @@ class NotificationService:
logger.info(f"Sent Discord expiry notification: {notification_data}")
except Exception as e:
logger.error(f"Failed to send Discord expiry notification: {e}")
raise
async def _send_telegram_expiry(self, notification_data: Dict[str, Any]) -> None:
"""Send Telegram expiry notification"""
@@ -288,17 +293,22 @@ class NotificationService:
logger.info(f"Sent Telegram expiry notification: {notification_data}")
except Exception as e:
logger.error(f"Failed to send Telegram expiry notification: {e}")
raise
async def send_sync_failure_notification(
self, notification_data: Dict[str, Any]
) -> None:
"""Send notification about sync failure"""
if self._is_discord_enabled():
await self._send_discord_sync_failure(notification_data)
try:
if self._is_discord_enabled():
await self._send_discord_sync_failure(notification_data)
except Exception as e:
logger.error(f"Failed to send Discord sync failure notification: {e}")
if self._is_telegram_enabled():
await self._send_telegram_sync_failure(notification_data)
try:
if self._is_telegram_enabled():
await self._send_telegram_sync_failure(notification_data)
except Exception as e:
logger.error(f"Failed to send Telegram sync failure notification: {e}")
async def _send_discord_sync_failure(
self, notification_data: Dict[str, Any]
@@ -326,7 +336,6 @@ class NotificationService:
logger.info(f"Sent Discord sync failure notification: {notification_data}")
except Exception as e:
logger.error(f"Failed to send Discord sync failure notification: {e}")
raise
async def _send_telegram_sync_failure(
self, notification_data: Dict[str, Any]
@@ -354,4 +363,3 @@ class NotificationService:
logger.info(f"Sent Telegram sync failure notification: {notification_data}")
except Exception as e:
logger.error(f"Failed to send Telegram sync failure notification: {e}")
raise

View File

@@ -4,18 +4,41 @@ from typing import List
from loguru import logger
from leggen.api.models.sync import SyncResult, SyncStatus
from leggen.services.database_service import DatabaseService
from leggen.repositories import (
AccountRepository,
BalanceRepository,
SyncRepository,
TransactionRepository,
)
from leggen.services.data_processors import (
AccountEnricher,
BalanceTransformer,
TransactionProcessor,
)
from leggen.services.gocardless_service import GoCardlessService
from leggen.services.notification_service import NotificationService
# Constants for notification
EXPIRED_DAYS_LEFT = 0
class SyncService:
def __init__(self):
self.gocardless = GoCardlessService()
self.database = DatabaseService()
self.notifications = NotificationService()
# Repositories
self.accounts = AccountRepository()
self.balances = BalanceRepository()
self.transactions = TransactionRepository()
self.sync = SyncRepository()
# Data processors
self.account_enricher = AccountEnricher()
self.balance_transformer = BalanceTransformer()
self.transaction_processor = TransactionProcessor()
self._sync_status = SyncStatus(is_running=False)
self._institution_logos = {} # Cache for institution logos
async def get_sync_status(self) -> SyncStatus:
"""Get current sync status"""
@@ -67,6 +90,9 @@ class SyncService:
self._sync_status.total_accounts = len(all_accounts)
logs.append(f"Found {len(all_accounts)} accounts to sync")
# Check for expired or expiring requisitions
await self._check_requisition_expiry(requisitions.get("results", []))
# Process each account
for account_id in all_accounts:
try:
@@ -78,72 +104,44 @@ class SyncService:
# Get balances to extract currency information
balances = await self.gocardless.get_account_balances(account_id)
# Enrich account details with currency and institution logo
# Enrich and persist account details
if account_details and balances:
enriched_account_details = account_details.copy()
# Extract currency from first balance
balances_list = balances.get("balances", [])
if balances_list:
first_balance = balances_list[0]
balance_amount = first_balance.get("balanceAmount", {})
currency = balance_amount.get("currency")
if currency:
enriched_account_details["currency"] = currency
# Get institution details to fetch logo
institution_id = enriched_account_details.get("institution_id")
if institution_id:
try:
institution_details = (
await self.gocardless.get_institution_details(
institution_id
)
)
enriched_account_details["logo"] = (
institution_details.get("logo", "")
)
logger.info(
f"Fetched logo for institution {institution_id}: {enriched_account_details.get('logo', 'No logo')}"
)
except Exception as e:
logger.warning(
f"Failed to fetch institution details for {institution_id}: {e}"
)
# Enrich account with currency and institution logo
enriched_account_details = (
await self.account_enricher.enrich_account_details(
account_details, balances
)
)
# Persist enriched account details to database
await self.database.persist_account_details(
enriched_account_details
)
self.accounts.persist(enriched_account_details)
# Merge account details into balances data for proper persistence
balances_with_account_info = balances.copy()
balances_with_account_info["institution_id"] = (
enriched_account_details.get("institution_id")
# Merge account metadata into balances for persistence
balances_with_account_info = self.balance_transformer.merge_account_metadata_into_balances(
balances, enriched_account_details
)
balances_with_account_info["iban"] = (
enriched_account_details.get("iban")
)
balances_with_account_info["account_status"] = (
enriched_account_details.get("status")
)
await self.database.persist_balance(
account_id, balances_with_account_info
balance_rows = (
self.balance_transformer.transform_to_database_format(
account_id, balances_with_account_info
)
)
self.balances.persist(account_id, balance_rows)
balances_updated += len(balances.get("balances", []))
elif account_details:
# Fallback: persist account details without currency if balances failed
await self.database.persist_account_details(account_details)
self.accounts.persist(account_details)
# Get and save transactions
transactions = await self.gocardless.get_account_transactions(
account_id
)
if transactions:
processed_transactions = self.database.process_transactions(
account_id, account_details, transactions
processed_transactions = (
self.transaction_processor.process_transactions(
account_id, account_details, transactions
)
)
new_transactions = await self.database.persist_transactions(
new_transactions = self.transactions.persist(
account_id, processed_transactions
)
transactions_added += len(new_transactions)
@@ -166,6 +164,15 @@ class SyncService:
logger.error(error_msg)
logs.append(error_msg)
# Send notification for account sync failure
await self.notifications.send_sync_failure_notification(
{
"account_id": account_id,
"error": error_msg,
"type": "account_sync_failure",
}
)
end_time = datetime.now()
duration = (end_time - start_time).total_seconds()
@@ -188,9 +195,7 @@ class SyncService:
# Persist sync operation to database
try:
operation_id = await self.database.persist_sync_operation(
sync_operation
)
operation_id = self.sync.persist(sync_operation)
logger.debug(f"Saved sync operation with ID: {operation_id}")
except Exception as e:
logger.error(f"Failed to persist sync operation: {e}")
@@ -239,9 +244,7 @@ class SyncService:
)
try:
operation_id = await self.database.persist_sync_operation(
sync_operation
)
operation_id = self.sync.persist(sync_operation)
logger.debug(f"Saved failed sync operation with ID: {operation_id}")
except Exception as persist_error:
logger.error(
@@ -252,6 +255,31 @@ class SyncService:
finally:
self._sync_status.is_running = False
async def _check_requisition_expiry(self, requisitions: List[dict]) -> None:
"""Check requisitions for expiry and send notifications.
Args:
requisitions: List of requisition dictionaries to check
"""
for req in requisitions:
requisition_id = req.get("id", "unknown")
institution_id = req.get("institution_id", "unknown")
status = req.get("status", "")
# Check if requisition is expired
if status == "EX":
logger.warning(
f"Requisition {requisition_id} for {institution_id} has expired"
)
await self.notifications.send_expiry_notification(
{
"bank": institution_id,
"requisition_id": requisition_id,
"status": "expired",
"days_left": EXPIRED_DAYS_LEFT,
}
)
async def sync_specific_accounts(
self, account_ids: List[str], force: bool = False, trigger_type: str = "manual"
) -> SyncResult:

View File

@@ -1,6 +1,6 @@
[project]
name = "leggen"
version = "2025.10.2"
version = "2025.11.0"
description = "An Open Banking CLI"
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
requires-python = "~=3.13.0"

View File

@@ -1,548 +0,0 @@
#!/usr/bin/env python3
"""Sample database generator for Leggen testing and development."""
import json
import random
import sqlite3
import sys
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Dict, List
import click
# Add the project root to the Python path
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
# Import after path setup - this is necessary for the script to work
from leggen.utils.paths import path_manager # noqa: E402
class SampleDataGenerator:
"""Generates realistic sample data for testing Leggen."""
def __init__(self, db_path: Path):
self.db_path = db_path
self.institutions = [
{
"id": "REVOLUT_REVOLT21",
"name": "Revolut",
"bic": "REVOLT21",
"country": "LT",
},
{
"id": "BANCOBPI_BBPIPTPL",
"name": "Banco BPI",
"bic": "BBPIPTPL",
"country": "PT",
},
{
"id": "MONZO_MONZGB2L",
"name": "Monzo Bank",
"bic": "MONZGB2L",
"country": "GB",
},
{
"id": "NUBANK_NUPBBR25",
"name": "Nu Pagamentos",
"bic": "NUPBBR25",
"country": "BR",
},
]
self.transaction_types = [
{
"description": "Grocery Store",
"amount_range": (-150, -20),
"frequency": 0.3,
},
{"description": "Coffee Shop", "amount_range": (-15, -3), "frequency": 0.2},
{
"description": "Gas Station",
"amount_range": (-80, -30),
"frequency": 0.1,
},
{
"description": "Online Shopping",
"amount_range": (-200, -25),
"frequency": 0.15,
},
{
"description": "Restaurant",
"amount_range": (-60, -15),
"frequency": 0.15,
},
{"description": "Salary", "amount_range": (2500, 5000), "frequency": 0.02},
{
"description": "ATM Withdrawal",
"amount_range": (-200, -20),
"frequency": 0.05,
},
{
"description": "Transfer to Savings",
"amount_range": (-1000, -100),
"frequency": 0.03,
},
]
def ensure_database_dir(self):
"""Ensure database directory exists."""
self.db_path.parent.mkdir(parents=True, exist_ok=True)
def create_tables(self):
"""Create database tables."""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
# Create accounts table
cursor.execute("""
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
institution_id TEXT,
status TEXT,
iban TEXT,
name TEXT,
currency TEXT,
created DATETIME,
last_accessed DATETIME,
last_updated DATETIME,
display_name TEXT
)
""")
# Create transactions table with composite primary key
cursor.execute("""
CREATE TABLE IF NOT EXISTS transactions (
accountId TEXT NOT NULL,
transactionId TEXT NOT NULL,
internalTransactionId TEXT,
institutionId TEXT,
iban TEXT,
transactionDate DATETIME,
description TEXT,
transactionValue REAL,
transactionCurrency TEXT,
transactionStatus TEXT,
rawTransaction JSON,
PRIMARY KEY (accountId, transactionId)
)
""")
# Create balances table
cursor.execute("""
CREATE TABLE IF NOT EXISTS balances (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT,
bank TEXT,
status TEXT,
iban TEXT,
amount REAL,
currency TEXT,
type TEXT,
timestamp DATETIME
)
""")
# Create indexes
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_transactions_internal_id ON transactions(internalTransactionId)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(transactionDate)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_transactions_account_date ON transactions(accountId, transactionDate)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_transactions_amount ON transactions(transactionValue)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_balances_account_id ON balances(account_id)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_balances_timestamp ON balances(timestamp)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_balances_account_type_timestamp ON balances(account_id, type, timestamp)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_accounts_institution_id ON accounts(institution_id)"
)
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_accounts_status ON accounts(status)"
)
conn.commit()
conn.close()
def generate_iban(self, country_code: str) -> str:
"""Generate a realistic IBAN for the given country."""
ibans = {
"LT": lambda: f"LT{random.randint(10, 99)}{random.randint(10000, 99999)}{random.randint(10000000, 99999999)}",
"PT": lambda: f"PT{random.randint(10, 99)}{random.randint(1000, 9999)}{random.randint(1000, 9999)}{random.randint(10000000000, 99999999999)}",
"GB": lambda: f"GB{random.randint(10, 99)}MONZ{random.randint(100000, 999999)}{random.randint(100000, 999999)}",
"BR": lambda: f"BR{random.randint(10, 99)}{random.randint(10000000, 99999999)}{random.randint(1000, 9999)}{random.randint(10000000, 99999999)}",
}
return ibans.get(
country_code,
lambda: f"{country_code}{random.randint(1000000000000000, 9999999999999999)}",
)()
def generate_accounts(self, num_accounts: int = 3) -> List[Dict[str, Any]]:
"""Generate sample accounts."""
accounts = []
base_date = datetime.now() - timedelta(days=90)
for i in range(num_accounts):
institution = random.choice(self.institutions)
account_id = f"account-{i + 1:03d}-{random.randint(1000, 9999)}"
account = {
"id": account_id,
"institution_id": institution["id"],
"status": "READY",
"iban": self.generate_iban(institution["country"]),
"name": f"Personal Account {i + 1}",
"currency": "EUR",
"created": (
base_date + timedelta(days=random.randint(0, 30))
).isoformat(),
"last_accessed": (
datetime.now() - timedelta(hours=random.randint(1, 48))
).isoformat(),
"last_updated": datetime.now().isoformat(),
}
accounts.append(account)
return accounts
def generate_transactions(
self, accounts: List[Dict[str, Any]], num_transactions_per_account: int = 50
) -> List[Dict[str, Any]]:
"""Generate sample transactions for accounts."""
transactions = []
base_date = datetime.now() - timedelta(days=60)
for account in accounts:
account_transactions = []
current_balance = random.uniform(500, 3000)
for i in range(num_transactions_per_account):
# Choose transaction type based on frequency weights
transaction_type = random.choices(
self.transaction_types,
weights=[t["frequency"] for t in self.transaction_types],
)[0]
# Generate transaction amount
min_amount, max_amount = transaction_type["amount_range"]
amount = round(random.uniform(min_amount, max_amount), 2)
# Generate transaction date (more recent transactions are more likely)
days_ago = random.choices(
range(60), weights=[1.5 ** (60 - d) for d in range(60)]
)[0]
transaction_date = base_date + timedelta(
days=days_ago,
hours=random.randint(6, 22),
minutes=random.randint(0, 59),
)
# Generate transaction IDs
transaction_id = f"bank-txn-{account['id']}-{i + 1:04d}"
internal_transaction_id = f"int-txn-{random.randint(100000, 999999)}"
# Create realistic descriptions
descriptions = {
"Grocery Store": [
"TESCO",
"SAINSBURY'S",
"LIDL",
"ALDI",
"WALMART",
"CARREFOUR",
],
"Coffee Shop": [
"STARBUCKS",
"COSTA COFFEE",
"PRET A MANGER",
"LOCAL CAFE",
],
"Gas Station": ["BP", "SHELL", "ESSO", "GALP", "PETROBRAS"],
"Online Shopping": ["AMAZON", "EBAY", "ZALANDO", "ASOS", "APPLE"],
"Restaurant": [
"PIZZA HUT",
"MCDONALD'S",
"BURGER KING",
"LOCAL RESTAURANT",
],
"Salary": ["MONTHLY SALARY", "PAYROLL DEPOSIT", "SALARY PAYMENT"],
"ATM Withdrawal": ["ATM WITHDRAWAL", "CASH WITHDRAWAL"],
"Transfer to Savings": ["SAVINGS TRANSFER", "INVESTMENT TRANSFER"],
}
specific_descriptions = descriptions.get(
transaction_type["description"], [transaction_type["description"]]
)
description = random.choice(specific_descriptions)
# Create raw transaction (simplified GoCardless format)
raw_transaction = {
"transactionId": transaction_id,
"bookingDate": transaction_date.strftime("%Y-%m-%d"),
"valueDate": transaction_date.strftime("%Y-%m-%d"),
"transactionAmount": {
"amount": str(amount),
"currency": account["currency"],
},
"remittanceInformationUnstructured": description,
"bankTransactionCode": "PMNT" if amount < 0 else "RCDT",
}
# Determine status (most are booked, some recent ones might be pending)
status = (
"pending" if days_ago < 2 and random.random() < 0.1 else "booked"
)
transaction = {
"accountId": account["id"],
"transactionId": transaction_id,
"internalTransactionId": internal_transaction_id,
"institutionId": account["institution_id"],
"iban": account["iban"],
"transactionDate": transaction_date.isoformat(),
"description": description,
"transactionValue": amount,
"transactionCurrency": account["currency"],
"transactionStatus": status,
"rawTransaction": raw_transaction,
}
account_transactions.append(transaction)
current_balance += amount
# Sort transactions by date for realistic ordering
account_transactions.sort(key=lambda x: x["transactionDate"])
transactions.extend(account_transactions)
return transactions
def generate_balances(self, accounts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Generate sample balances for accounts."""
balances = []
for account in accounts:
# Calculate balance from transactions (simplified)
base_balance = random.uniform(500, 2000)
balance_types = ["interimAvailable", "closingBooked", "authorised"]
for balance_type in balance_types:
# Add some variation to balance types
variation = (
random.uniform(-50, 50) if balance_type != "interimAvailable" else 0
)
balance_amount = base_balance + variation
balance = {
"account_id": account["id"],
"bank": account["institution_id"],
"status": account["status"],
"iban": account["iban"],
"amount": round(balance_amount, 2),
"currency": account["currency"],
"type": balance_type,
"timestamp": datetime.now().isoformat(),
}
balances.append(balance)
return balances
def insert_data(
self,
accounts: List[Dict[str, Any]],
transactions: List[Dict[str, Any]],
balances: List[Dict[str, Any]],
):
"""Insert generated data into the database."""
conn = sqlite3.connect(str(self.db_path))
cursor = conn.cursor()
# Insert accounts
for account in accounts:
cursor.execute(
"""
INSERT OR REPLACE INTO accounts
(id, institution_id, status, iban, name, currency, created, last_accessed, last_updated, display_name)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
account["id"],
account["institution_id"],
account["status"],
account["iban"],
account["name"],
account["currency"],
account["created"],
account["last_accessed"],
account["last_updated"],
None, # display_name is initially None for sample data
),
)
# Insert transactions
for transaction in transactions:
cursor.execute(
"""
INSERT OR REPLACE INTO transactions
(accountId, transactionId, internalTransactionId, institutionId, iban,
transactionDate, description, transactionValue, transactionCurrency,
transactionStatus, rawTransaction)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
transaction["accountId"],
transaction["transactionId"],
transaction["internalTransactionId"],
transaction["institutionId"],
transaction["iban"],
transaction["transactionDate"],
transaction["description"],
transaction["transactionValue"],
transaction["transactionCurrency"],
transaction["transactionStatus"],
json.dumps(transaction["rawTransaction"]),
),
)
# Insert balances
for balance in balances:
cursor.execute(
"""
INSERT INTO balances
(account_id, bank, status, iban, amount, currency, type, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
balance["account_id"],
balance["bank"],
balance["status"],
balance["iban"],
balance["amount"],
balance["currency"],
balance["type"],
balance["timestamp"],
),
)
conn.commit()
conn.close()
def generate_sample_database(
self, num_accounts: int = 3, num_transactions_per_account: int = 50
):
"""Generate complete sample database."""
click.echo(f"🗄️ Creating sample database at: {self.db_path}")
self.ensure_database_dir()
self.create_tables()
click.echo(f"👥 Generating {num_accounts} sample accounts...")
accounts = self.generate_accounts(num_accounts)
click.echo(
f"💳 Generating {num_transactions_per_account} transactions per account..."
)
transactions = self.generate_transactions(
accounts, num_transactions_per_account
)
click.echo("💰 Generating account balances...")
balances = self.generate_balances(accounts)
click.echo("💾 Inserting data into database...")
self.insert_data(accounts, transactions, balances)
# Print summary
click.echo("\n✅ Sample database created successfully!")
click.echo("📊 Summary:")
click.echo(f" - Accounts: {len(accounts)}")
click.echo(f" - Transactions: {len(transactions)}")
click.echo(f" - Balances: {len(balances)}")
click.echo(f" - Database: {self.db_path}")
# Show account details
click.echo("\n📋 Sample accounts:")
for account in accounts:
institution_name = next(
inst["name"]
for inst in self.institutions
if inst["id"] == account["institution_id"]
)
click.echo(f" - {account['id']} ({institution_name}) - {account['iban']}")
@click.command()
@click.option(
"--database",
type=click.Path(path_type=Path),
help="Path to database file (default: uses LEGGEN_DATABASE_PATH or ~/.config/leggen/leggen-dev.db)",
)
@click.option(
"--accounts",
type=int,
default=3,
help="Number of sample accounts to generate (default: 3)",
)
@click.option(
"--transactions",
type=int,
default=50,
help="Number of transactions per account (default: 50)",
)
@click.option(
"--force",
is_flag=True,
help="Overwrite existing database without confirmation",
)
def main(database: Path, accounts: int, transactions: int, force: bool):
"""Generate a sample database with realistic financial data for testing Leggen."""
# Determine database path
if database:
db_path = database
else:
# Use development database by default to avoid overwriting production data
import os
env_path = os.environ.get("LEGGEN_DATABASE_PATH")
if env_path:
db_path = Path(env_path)
else:
# Default to development database in config directory
db_path = path_manager.get_config_dir() / "leggen-dev.db"
# Check if database exists and ask for confirmation
if db_path.exists() and not force:
click.echo(f"⚠️ Database already exists: {db_path}")
if not click.confirm("Do you want to overwrite it?"):
click.echo("Aborted.")
return
# Generate the sample database
generator = SampleDataGenerator(db_path)
generator.generate_sample_database(accounts, transactions)
# Show usage instructions
click.echo("\n🚀 Usage instructions:")
click.echo("To use this sample database with leggen commands:")
click.echo(f" export LEGGEN_DATABASE_PATH={db_path}")
click.echo(" leggen transactions")
click.echo("")
click.echo("To use this sample database with leggen server:")
click.echo(f" leggen server --database {db_path}")
if __name__ == "__main__":
main()

View File

@@ -1,16 +1,53 @@
"""Pytest configuration and shared fixtures."""
import json
import os
import shutil
import tempfile
from pathlib import Path
from unittest.mock import patch
import pytest
import tomli_w
from fastapi.testclient import TestClient
from leggen.commands.server import create_app
from leggen.utils.config import Config
# Create test config before any imports that might load it
_test_config_dir = tempfile.mkdtemp(prefix="leggen_test_")
_test_config_path = Path(_test_config_dir) / "config.toml"
# Create minimal test config
_config_data = {
"gocardless": {
"key": "test-key",
"secret": "test-secret",
"url": "https://bankaccountdata.gocardless.com/api/v2",
},
"database": {"sqlite": True},
"scheduler": {"sync": {"enabled": True, "hour": 3, "minute": 0}},
}
with open(_test_config_path, "wb") as f:
tomli_w.dump(_config_data, f)
# Set environment variables to point to test config BEFORE importing the app
os.environ["LEGGEN_CONFIG_FILE"] = str(_test_config_path)
def pytest_configure(config):
"""Pytest hook called before test collection."""
# Ensure test config is set
os.environ["LEGGEN_CONFIG_FILE"] = str(_test_config_path)
def pytest_unconfigure(config):
"""Pytest hook called after all tests."""
# Cleanup test config directory
if Path(_test_config_dir).exists():
shutil.rmtree(_test_config_dir)
@pytest.fixture
def temp_config_dir():
@@ -73,9 +110,14 @@ def mock_auth_token(temp_config_dir):
@pytest.fixture
def fastapi_app():
def fastapi_app(mock_db_path):
"""Create FastAPI test application."""
return create_app()
# Patch the database path for the app
with patch(
"leggen.utils.paths.path_manager.get_database_path", return_value=mock_db_path
):
app = create_app()
yield app
@pytest.fixture
@@ -84,6 +126,38 @@ def api_client(fastapi_app):
return TestClient(fastapi_app)
@pytest.fixture
def mock_account_repo():
"""Create mock AccountRepository for testing."""
from unittest.mock import MagicMock
return MagicMock()
@pytest.fixture
def mock_balance_repo():
"""Create mock BalanceRepository for testing."""
from unittest.mock import MagicMock
return MagicMock()
@pytest.fixture
def mock_transaction_repo():
"""Create mock TransactionRepository for testing."""
from unittest.mock import MagicMock
return MagicMock()
@pytest.fixture
def mock_analytics_proc():
"""Create mock AnalyticsProcessor for testing."""
from unittest.mock import MagicMock
return MagicMock()
@pytest.fixture
def mock_db_path(temp_db_path):
"""Mock the database path to use temporary database for testing."""

View File

@@ -1,13 +1,13 @@
"""Tests for analytics fixes to ensure all transactions are used in statistics."""
from datetime import datetime, timedelta
from unittest.mock import AsyncMock, Mock, patch
from unittest.mock import Mock
import pytest
from fastapi.testclient import TestClient
from leggen.api.dependencies import get_transaction_repository
from leggen.commands.server import create_app
from leggen.services.database_service import DatabaseService
class TestAnalyticsFix:
@@ -19,11 +19,11 @@ class TestAnalyticsFix:
return TestClient(app)
@pytest.fixture
def mock_database_service(self):
return Mock(spec=DatabaseService)
def mock_transaction_repo(self):
return Mock()
@pytest.mark.asyncio
async def test_transaction_stats_uses_all_transactions(self, mock_database_service):
async def test_transaction_stats_uses_all_transactions(self, mock_transaction_repo):
"""Test that transaction stats endpoint uses all transactions (not limited to 100)"""
# Mock data for 600 transactions (simulating the issue)
mock_transactions = []
@@ -42,54 +42,50 @@ class TestAnalyticsFix:
}
)
mock_database_service.get_transactions_from_db = AsyncMock(
return_value=mock_transactions
mock_transaction_repo.get_transactions.return_value = mock_transactions
app = create_app()
app.dependency_overrides[get_transaction_repository] = (
lambda: mock_transaction_repo
)
client = TestClient(app)
response = client.get("/api/v1/transactions/stats?days=365")
assert response.status_code == 200
data = response.json()
# Verify that limit=None was passed to get all transactions
mock_transaction_repo.get_transactions.assert_called_once()
call_args = mock_transaction_repo.get_transactions.call_args
assert call_args.kwargs.get("limit") is None, (
"Stats endpoint should pass limit=None to get all transactions"
)
# Test that the endpoint calls get_transactions_from_db with limit=None
with patch(
"leggen.api.routes.transactions.database_service", mock_database_service
):
app = create_app()
client = TestClient(app)
# Verify that the response contains stats for all 600 transactions
stats = data
assert stats["total_transactions"] == 600, (
"Should process all 600 transactions, not just 100"
)
response = client.get("/api/v1/transactions/stats?days=365")
# Verify calculations are correct for all transactions
expected_income = sum(
txn["transactionValue"]
for txn in mock_transactions
if txn["transactionValue"] > 0
)
expected_expenses = sum(
abs(txn["transactionValue"])
for txn in mock_transactions
if txn["transactionValue"] < 0
)
assert response.status_code == 200
data = response.json()
# Verify that limit=None was passed to get all transactions
mock_database_service.get_transactions_from_db.assert_called_once()
call_args = mock_database_service.get_transactions_from_db.call_args
assert call_args.kwargs.get("limit") is None, (
"Stats endpoint should pass limit=None to get all transactions"
)
# Verify that the response contains stats for all 600 transactions
assert data["success"] is True
stats = data["data"]
assert stats["total_transactions"] == 600, (
"Should process all 600 transactions, not just 100"
)
# Verify calculations are correct for all transactions
expected_income = sum(
txn["transactionValue"]
for txn in mock_transactions
if txn["transactionValue"] > 0
)
expected_expenses = sum(
abs(txn["transactionValue"])
for txn in mock_transactions
if txn["transactionValue"] < 0
)
assert stats["total_income"] == expected_income
assert stats["total_expenses"] == expected_expenses
assert stats["total_income"] == expected_income
assert stats["total_expenses"] == expected_expenses
@pytest.mark.asyncio
async def test_analytics_endpoint_returns_all_transactions(
self, mock_database_service
self, mock_transaction_repo
):
"""Test that the new analytics endpoint returns all transactions without pagination"""
# Mock data for 600 transactions
@@ -109,31 +105,28 @@ class TestAnalyticsFix:
}
)
mock_database_service.get_transactions_from_db = AsyncMock(
return_value=mock_transactions
mock_transaction_repo.get_transactions.return_value = mock_transactions
app = create_app()
app.dependency_overrides[get_transaction_repository] = (
lambda: mock_transaction_repo
)
client = TestClient(app)
response = client.get("/api/v1/transactions/analytics?days=365")
assert response.status_code == 200
data = response.json()
# Verify that limit=None was passed to get all transactions
mock_transaction_repo.get_transactions.assert_called_once()
call_args = mock_transaction_repo.get_transactions.call_args
assert call_args.kwargs.get("limit") is None, (
"Analytics endpoint should pass limit=None"
)
with patch(
"leggen.api.routes.transactions.database_service", mock_database_service
):
app = create_app()
client = TestClient(app)
response = client.get("/api/v1/transactions/analytics?days=365")
assert response.status_code == 200
data = response.json()
# Verify that limit=None was passed to get all transactions
mock_database_service.get_transactions_from_db.assert_called_once()
call_args = mock_database_service.get_transactions_from_db.call_args
assert call_args.kwargs.get("limit") is None, (
"Analytics endpoint should pass limit=None"
)
# Verify that all 600 transactions are returned
assert data["success"] is True
transactions_data = data["data"]
assert len(transactions_data) == 600, (
"Analytics endpoint should return all 600 transactions"
)
# Verify that all 600 transactions are returned
transactions_data = data
assert len(transactions_data) == 600, (
"Analytics endpoint should return all 600 transactions"
)

View File

@@ -4,6 +4,12 @@ from unittest.mock import patch
import pytest
from leggen.api.dependencies import (
get_account_repository,
get_balance_repository,
get_transaction_repository,
)
@pytest.mark.api
class TestAccountsAPI:
@@ -11,11 +17,14 @@ class TestAccountsAPI:
def test_get_all_accounts_success(
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
sample_account_data,
mock_db_path,
mock_account_repo,
mock_balance_repo,
):
"""Test successful retrieval of all accounts from database."""
mock_accounts = [
@@ -45,24 +54,25 @@ class TestAccountsAPI:
}
]
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.accounts.database_service.get_accounts_from_db",
return_value=mock_accounts,
),
patch(
"leggen.api.routes.accounts.database_service.get_balances_from_db",
return_value=mock_balances,
),
):
mock_account_repo.get_accounts.return_value = mock_accounts
mock_balance_repo.get_balances.return_value = mock_balances
fastapi_app.dependency_overrides[get_account_repository] = (
lambda: mock_account_repo
)
fastapi_app.dependency_overrides[get_balance_repository] = (
lambda: mock_balance_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/accounts")
fastapi_app.dependency_overrides.clear()
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 1
account = data["data"][0]
assert len(data) == 1
account = data[0]
assert account["id"] == "test-account-123"
assert account["institution_id"] == "REVOLUT_REVOLT21"
assert len(account["balances"]) == 1
@@ -70,11 +80,14 @@ class TestAccountsAPI:
def test_get_account_details_success(
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
sample_account_data,
mock_db_path,
mock_account_repo,
mock_balance_repo,
):
"""Test successful retrieval of specific account details from database."""
mock_account = {
@@ -102,29 +115,35 @@ class TestAccountsAPI:
}
]
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
return_value=mock_account,
),
patch(
"leggen.api.routes.accounts.database_service.get_balances_from_db",
return_value=mock_balances,
),
):
mock_account_repo.get_account.return_value = mock_account
mock_balance_repo.get_balances.return_value = mock_balances
fastapi_app.dependency_overrides[get_account_repository] = (
lambda: mock_account_repo
)
fastapi_app.dependency_overrides[get_balance_repository] = (
lambda: mock_balance_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/accounts/test-account-123")
fastapi_app.dependency_overrides.clear()
assert response.status_code == 200
data = response.json()
assert data["success"] is True
account = data["data"]
assert account["id"] == "test-account-123"
assert account["iban"] == "LT313250081177977789"
assert len(account["balances"]) == 1
assert data["id"] == "test-account-123"
assert data["iban"] == "LT313250081177977789"
assert len(data["balances"]) == 1
def test_get_account_balances_success(
self, api_client, mock_config, mock_auth_token, mock_db_path
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
mock_db_path,
mock_balance_repo,
):
"""Test successful retrieval of account balances from database."""
mock_balances = [
@@ -152,31 +171,34 @@ class TestAccountsAPI:
},
]
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.accounts.database_service.get_balances_from_db",
return_value=mock_balances,
),
):
mock_balance_repo.get_balances.return_value = mock_balances
fastapi_app.dependency_overrides[get_balance_repository] = (
lambda: mock_balance_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/accounts/test-account-123/balances")
fastapi_app.dependency_overrides.clear()
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
assert data["data"][0]["amount"] == 1000.00
assert data["data"][0]["currency"] == "EUR"
assert data["data"][0]["balance_type"] == "interimAvailable"
assert len(data) == 2
assert data[0]["amount"] == 1000.00
assert data[0]["currency"] == "EUR"
assert data[0]["balance_type"] == "interimAvailable"
def test_get_account_transactions_success(
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
sample_account_data,
sample_transaction_data,
mock_db_path,
mock_transaction_repo,
):
"""Test successful retrieval of account transactions from database."""
mock_transactions = [
@@ -195,27 +217,24 @@ class TestAccountsAPI:
}
]
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.accounts.database_service.get_transactions_from_db",
return_value=mock_transactions,
),
patch(
"leggen.api.routes.accounts.database_service.get_transaction_count_from_db",
return_value=1,
),
):
mock_transaction_repo.get_transactions.return_value = mock_transactions
fastapi_app.dependency_overrides[get_transaction_repository] = (
lambda: mock_transaction_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get(
"/api/v1/accounts/test-account-123/transactions?summary_only=true"
)
fastapi_app.dependency_overrides.clear()
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 1
assert len(data) == 1
transaction = data["data"][0]
transaction = data[0]
assert transaction["internal_transaction_id"] == "txn-123"
assert transaction["amount"] == -10.50
assert transaction["currency"] == "EUR"
@@ -223,12 +242,14 @@ class TestAccountsAPI:
def test_get_account_transactions_full_details(
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
sample_account_data,
sample_transaction_data,
mock_db_path,
mock_transaction_repo,
):
"""Test retrieval of full transaction details from database."""
mock_transactions = [
@@ -247,49 +268,60 @@ class TestAccountsAPI:
}
]
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.accounts.database_service.get_transactions_from_db",
return_value=mock_transactions,
),
patch(
"leggen.api.routes.accounts.database_service.get_transaction_count_from_db",
return_value=1,
),
):
mock_transaction_repo.get_transactions.return_value = mock_transactions
fastapi_app.dependency_overrides[get_transaction_repository] = (
lambda: mock_transaction_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get(
"/api/v1/accounts/test-account-123/transactions?summary_only=false"
)
fastapi_app.dependency_overrides.clear()
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 1
assert len(data) == 1
transaction = data["data"][0]
transaction = data[0]
assert transaction["internal_transaction_id"] == "txn-123"
assert transaction["institution_id"] == "REVOLUT_REVOLT21"
assert transaction["iban"] == "LT313250081177977789"
assert "raw_transaction" in transaction
def test_get_account_not_found(
self, api_client, mock_config, mock_auth_token, mock_db_path
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
mock_db_path,
mock_account_repo,
):
"""Test handling of non-existent account."""
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
return_value=None,
),
):
mock_account_repo.get_account.return_value = None
fastapi_app.dependency_overrides[get_account_repository] = (
lambda: mock_account_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/accounts/nonexistent")
fastapi_app.dependency_overrides.clear()
assert response.status_code == 404
def test_update_account_display_name_success(
self, api_client, mock_config, mock_auth_token, mock_db_path
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
mock_db_path,
mock_account_repo,
):
"""Test successful update of account display name."""
mock_account = {
@@ -303,42 +335,48 @@ class TestAccountsAPI:
"last_accessed": "2025-09-01T09:30:00Z",
}
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
return_value=mock_account,
),
patch(
"leggen.api.routes.accounts.database_service.persist_account_details",
return_value=None,
),
):
mock_account_repo.get_account.return_value = mock_account
mock_account_repo.persist.return_value = mock_account
fastapi_app.dependency_overrides[get_account_repository] = (
lambda: mock_account_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.put(
"/api/v1/accounts/test-account-123",
json={"display_name": "My Custom Account Name"},
)
fastapi_app.dependency_overrides.clear()
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["id"] == "test-account-123"
assert data["data"]["display_name"] == "My Custom Account Name"
assert data["id"] == "test-account-123"
assert data["display_name"] == "My Custom Account Name"
def test_update_account_not_found(
self, api_client, mock_config, mock_auth_token, mock_db_path
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
mock_db_path,
mock_account_repo,
):
"""Test updating non-existent account."""
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.accounts.database_service.get_account_details_from_db",
return_value=None,
),
):
mock_account_repo.get_account.return_value = None
fastapi_app.dependency_overrides[get_account_repository] = (
lambda: mock_account_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.put(
"/api/v1/accounts/nonexistent",
json={"display_name": "New Name"},
)
fastapi_app.dependency_overrides.clear()
assert response.status_code == 404

View File

@@ -19,8 +19,7 @@ class TestBackupAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["s3"] is None
assert data["s3"] is None
def test_get_backup_settings_with_s3_config(self, api_client, mock_config):
"""Test getting backup settings with S3 configuration."""
@@ -42,10 +41,9 @@ class TestBackupAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["s3"] is not None
assert data["s3"] is not None
s3_config = data["data"]["s3"]
s3_config = data["s3"]
assert s3_config["access_key_id"] == "***" # Masked
assert s3_config["secret_access_key"] == "***" # Masked
assert s3_config["bucket_name"] == "test-bucket"
@@ -77,8 +75,7 @@ class TestBackupAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["updated"] is True
assert data["updated"] is True
# Verify connection test was called
mock_test_connection.assert_called_once()
@@ -132,8 +129,7 @@ class TestBackupAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["connected"] is True
assert data["connected"] is True
# Verify connection test was called
mock_test_connection.assert_called_once()
@@ -158,9 +154,9 @@ class TestBackupAPI:
response = api_client.post("/api/v1/backup/test", json=request_data)
assert response.status_code == 200
assert response.status_code == 400
data = response.json()
assert data["success"] is False
assert "S3 connection test failed" in data["detail"]
def test_test_backup_connection_invalid_service(self, api_client):
"""Test backup connection test with invalid service."""
@@ -214,12 +210,8 @@ class TestBackupAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
assert (
data["data"][0]["key"]
== "leggen_backups/database_backup_20250101_120000.db"
)
assert len(data) == 2
assert data[0]["key"] == "leggen_backups/database_backup_20250101_120000.db"
def test_list_backups_no_config(self, api_client, mock_config):
"""Test backup listing with no configuration."""
@@ -230,8 +222,7 @@ class TestBackupAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"] == []
assert data == []
@patch("leggen.services.backup_service.BackupService.backup_database")
@patch("leggen.utils.paths.path_manager.get_database_path")
@@ -261,9 +252,8 @@ class TestBackupAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["operation"] == "backup"
assert data["data"]["completed"] is True
assert data["operation"] == "backup"
assert data["completed"] is True
# Verify backup was called
mock_backup_db.assert_called_once()

View File

@@ -33,10 +33,9 @@ class TestBanksAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
assert data["data"][0]["id"] == "REVOLUT_REVOLT21"
assert data["data"][1]["id"] == "BANCOBPI_BBPIPTPL"
assert len(data) == 2
assert data[0]["id"] == "REVOLUT_REVOLT21"
assert data[1]["id"] == "BANCOBPI_BBPIPTPL"
@respx.mock
def test_get_institutions_invalid_country(self, api_client, mock_config):
@@ -92,9 +91,8 @@ class TestBanksAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["data"]["id"] == "req-123"
assert data["data"]["institution_id"] == "REVOLUT_REVOLT21"
assert data["id"] == "req-123"
assert data["institution_id"] == "REVOLUT_REVOLT21"
@respx.mock
def test_get_bank_status_success(self, api_client, mock_config, mock_auth_token):
@@ -128,10 +126,9 @@ class TestBanksAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 1
assert data["data"][0]["bank_id"] == "REVOLUT_REVOLT21"
assert data["data"][0]["status_display"] == "LINKED"
assert len(data) == 1
assert data[0]["bank_id"] == "REVOLUT_REVOLT21"
assert data[0]["status_display"] == "LINKED"
def test_get_supported_countries(self, api_client):
"""Test supported countries endpoint."""
@@ -139,11 +136,10 @@ class TestBanksAPI:
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) > 0
assert len(data) > 0
# Check some expected countries
country_codes = [country["code"] for country in data["data"]]
country_codes = [country["code"] for country in data]
assert "PT" in country_codes
assert "GB" in country_codes
assert "DE" in country_codes

View File

@@ -5,13 +5,20 @@ from unittest.mock import patch
import pytest
from leggen.api.dependencies import get_transaction_repository
@pytest.mark.api
class TestTransactionsAPI:
"""Test transaction-related API endpoints."""
def test_get_all_transactions_success(
self, api_client, mock_config, mock_auth_token
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
mock_transaction_repo,
):
"""Test successful retrieval of all transactions from database."""
mock_transactions = [
@@ -43,22 +50,19 @@ class TestTransactionsAPI:
},
]
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=mock_transactions,
),
patch(
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
return_value=2,
),
):
mock_transaction_repo.get_transactions.return_value = mock_transactions
mock_transaction_repo.get_count.return_value = len(mock_transactions)
fastapi_app.dependency_overrides[get_transaction_repository] = (
lambda: mock_transaction_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/transactions?summary_only=true")
fastapi_app.dependency_overrides.clear()
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 2
# Check first transaction summary
@@ -71,7 +75,12 @@ class TestTransactionsAPI:
assert transaction["account_id"] == "test-account-123"
def test_get_all_transactions_full_details(
self, api_client, mock_config, mock_auth_token
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
mock_transaction_repo,
):
"""Test retrieval of full transaction details from database."""
mock_transactions = [
@@ -90,22 +99,19 @@ class TestTransactionsAPI:
}
]
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=mock_transactions,
),
patch(
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
return_value=1,
),
):
mock_transaction_repo.get_transactions.return_value = mock_transactions
mock_transaction_repo.get_count.return_value = len(mock_transactions)
fastapi_app.dependency_overrides[get_transaction_repository] = (
lambda: mock_transaction_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/transactions?summary_only=false")
fastapi_app.dependency_overrides.clear()
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 1
transaction = data["data"][0]
@@ -116,7 +122,12 @@ class TestTransactionsAPI:
assert "raw_transaction" in transaction
def test_get_transactions_with_filters(
self, api_client, mock_config, mock_auth_token
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
mock_transaction_repo,
):
"""Test getting transactions with various filters."""
mock_transactions = [
@@ -135,17 +146,14 @@ class TestTransactionsAPI:
}
]
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=mock_transactions,
) as mock_get_transactions,
patch(
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
return_value=1,
),
):
mock_transaction_repo.get_transactions.return_value = mock_transactions
mock_transaction_repo.get_count.return_value = 1
fastapi_app.dependency_overrides[get_transaction_repository] = (
lambda: mock_transaction_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get(
"/api/v1/transactions?"
"account_id=test-account-123&"
@@ -158,12 +166,12 @@ class TestTransactionsAPI:
"per_page=10"
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
fastapi_app.dependency_overrides.clear()
# Verify the database service was called with correct filters
mock_get_transactions.assert_called_once_with(
assert response.status_code == 200
# Verify the repository was called with correct filters
mock_transaction_repo.get_transactions.assert_called_once_with(
account_id="test-account-123",
limit=10,
offset=10, # (page-1) * per_page = (2-1) * 10 = 10
@@ -175,48 +183,65 @@ class TestTransactionsAPI:
)
def test_get_transactions_empty_result(
self, api_client, mock_config, mock_auth_token
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
mock_transaction_repo,
):
"""Test getting transactions when database returns empty result."""
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=[],
),
patch(
"leggen.api.routes.transactions.database_service.get_transaction_count_from_db",
return_value=0,
),
):
mock_transaction_repo.get_transactions.return_value = []
mock_transaction_repo.get_count.return_value = 0
fastapi_app.dependency_overrides[get_transaction_repository] = (
lambda: mock_transaction_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/transactions")
fastapi_app.dependency_overrides.clear()
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert len(data["data"]) == 0
assert data["pagination"]["total"] == 0
assert data["pagination"]["page"] == 1
assert data["pagination"]["total_pages"] == 0
assert data["total"] == 0
assert data["page"] == 1
assert data["total_pages"] == 0
def test_get_transactions_database_error(
self, api_client, mock_config, mock_auth_token
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
mock_transaction_repo,
):
"""Test handling database error when getting transactions."""
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
side_effect=Exception("Database connection failed"),
),
):
mock_transaction_repo.get_transactions.side_effect = Exception(
"Database connection failed"
)
fastapi_app.dependency_overrides[get_transaction_repository] = (
lambda: mock_transaction_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/transactions")
fastapi_app.dependency_overrides.clear()
assert response.status_code == 500
assert "Failed to get transactions" in response.json()["detail"]
def test_get_transaction_stats_success(
self, api_client, mock_config, mock_auth_token
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
mock_transaction_repo,
):
"""Test successful retrieval of transaction statistics from database."""
mock_transactions = [
@@ -243,35 +268,39 @@ class TestTransactionsAPI:
},
]
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=mock_transactions,
),
):
mock_transaction_repo.get_transactions.return_value = mock_transactions
fastapi_app.dependency_overrides[get_transaction_repository] = (
lambda: mock_transaction_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/transactions/stats?days=30")
fastapi_app.dependency_overrides.clear()
assert response.status_code == 200
data = response.json()
assert data["success"] is True
stats = data["data"]
assert stats["period_days"] == 30
assert stats["total_transactions"] == 3
assert stats["booked_transactions"] == 2
assert stats["pending_transactions"] == 1
assert stats["total_income"] == 100.00
assert stats["total_expenses"] == 35.80 # abs(-10.50) + abs(-25.30)
assert stats["net_change"] == 64.20 # 100.00 - 35.80
assert stats["accounts_included"] == 2 # Two unique account IDs
assert data["period_days"] == 30
assert data["total_transactions"] == 3
assert data["booked_transactions"] == 2
assert data["pending_transactions"] == 1
assert data["total_income"] == 100.00
assert data["total_expenses"] == 35.80 # abs(-10.50) + abs(-25.30)
assert data["net_change"] == 64.20 # 100.00 - 35.80
assert data["accounts_included"] == 2 # Two unique account IDs
# Average transaction: ((-10.50) + 100.00 + (-25.30)) / 3 = 64.20 / 3 = 21.4
expected_avg = round(64.20 / 3, 2)
assert stats["average_transaction"] == expected_avg
assert data["average_transaction"] == expected_avg
def test_get_transaction_stats_with_account_filter(
self, api_client, mock_config, mock_auth_token
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
mock_transaction_repo,
):
"""Test getting transaction stats filtered by account."""
mock_transactions = [
@@ -284,67 +313,88 @@ class TestTransactionsAPI:
}
]
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=mock_transactions,
) as mock_get_transactions,
):
mock_transaction_repo.get_transactions.return_value = mock_transactions
fastapi_app.dependency_overrides[get_transaction_repository] = (
lambda: mock_transaction_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get(
"/api/v1/transactions/stats?account_id=test-account-123"
)
fastapi_app.dependency_overrides.clear()
assert response.status_code == 200
# Verify the database service was called with account filter
mock_get_transactions.assert_called_once()
call_kwargs = mock_get_transactions.call_args.kwargs
# Verify the repository was called with account filter
mock_transaction_repo.get_transactions.assert_called_once()
call_kwargs = mock_transaction_repo.get_transactions.call_args.kwargs
assert call_kwargs["account_id"] == "test-account-123"
def test_get_transaction_stats_empty_result(
self, api_client, mock_config, mock_auth_token
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
mock_transaction_repo,
):
"""Test getting stats when no transactions match criteria."""
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=[],
),
):
mock_transaction_repo.get_transactions.return_value = []
fastapi_app.dependency_overrides[get_transaction_repository] = (
lambda: mock_transaction_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/transactions/stats")
fastapi_app.dependency_overrides.clear()
assert response.status_code == 200
data = response.json()
assert data["success"] is True
stats = data["data"]
assert stats["total_transactions"] == 0
assert stats["total_income"] == 0.0
assert stats["total_expenses"] == 0.0
assert stats["net_change"] == 0.0
assert stats["average_transaction"] == 0 # Division by zero handled
assert stats["accounts_included"] == 0
assert data["total_transactions"] == 0
assert data["total_income"] == 0.0
assert data["total_expenses"] == 0.0
assert data["net_change"] == 0.0
assert data["average_transaction"] == 0 # Division by zero handled
assert data["accounts_included"] == 0
def test_get_transaction_stats_database_error(
self, api_client, mock_config, mock_auth_token
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
mock_transaction_repo,
):
"""Test handling database error when getting stats."""
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
side_effect=Exception("Database connection failed"),
),
):
mock_transaction_repo.get_transactions.side_effect = Exception(
"Database connection failed"
)
fastapi_app.dependency_overrides[get_transaction_repository] = (
lambda: mock_transaction_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/transactions/stats")
fastapi_app.dependency_overrides.clear()
assert response.status_code == 500
assert "Failed to get transaction stats" in response.json()["detail"]
def test_get_transaction_stats_custom_period(
self, api_client, mock_config, mock_auth_token
self,
fastapi_app,
api_client,
mock_config,
mock_auth_token,
mock_transaction_repo,
):
"""Test getting transaction stats for custom time period."""
mock_transactions = [
@@ -357,21 +407,23 @@ class TestTransactionsAPI:
}
]
with (
patch("leggen.utils.config.config", mock_config),
patch(
"leggen.api.routes.transactions.database_service.get_transactions_from_db",
return_value=mock_transactions,
) as mock_get_transactions,
):
mock_transaction_repo.get_transactions.return_value = mock_transactions
fastapi_app.dependency_overrides[get_transaction_repository] = (
lambda: mock_transaction_repo
)
with patch("leggen.utils.config.config", mock_config):
response = api_client.get("/api/v1/transactions/stats?days=7")
fastapi_app.dependency_overrides.clear()
assert response.status_code == 200
data = response.json()
assert data["data"]["period_days"] == 7
assert data["period_days"] == 7
# Verify the date range was calculated correctly for 7 days
mock_get_transactions.assert_called_once()
call_kwargs = mock_get_transactions.call_args.kwargs
mock_transaction_repo.get_transactions.assert_called_once()
call_kwargs = mock_transaction_repo.get_transactions.call_args.kwargs
assert "date_from" in call_kwargs
assert "date_to" in call_kwargs

View File

@@ -120,12 +120,10 @@ class TestConfigurablePaths:
"iban": "TEST_IBAN",
}
# Use the internal balance persistence method since the test needs direct database access
# Use the public balance persistence method
import asyncio
asyncio.run(
database_service._persist_balance_sqlite("test-account", balance_data)
)
asyncio.run(database_service.persist_balance("test-account", balance_data))
# Retrieve balances
balances = asyncio.run(

View File

@@ -85,7 +85,7 @@ class TestDatabaseService:
):
"""Test successful retrieval of transactions from database."""
with patch.object(
database_service, "_get_transactions"
database_service.transactions, "get_transactions"
) as mock_get_transactions:
mock_get_transactions.return_value = sample_transactions_db_format
@@ -111,7 +111,7 @@ class TestDatabaseService:
):
"""Test retrieving transactions with filters."""
with patch.object(
database_service, "_get_transactions"
database_service.transactions, "get_transactions"
) as mock_get_transactions:
mock_get_transactions.return_value = sample_transactions_db_format
@@ -149,7 +149,7 @@ class TestDatabaseService:
async def test_get_transactions_from_db_error(self, database_service):
"""Test handling error when getting transactions."""
with patch.object(
database_service, "_get_transactions"
database_service.transactions, "get_transactions"
) as mock_get_transactions:
mock_get_transactions.side_effect = Exception("Database error")
@@ -159,7 +159,7 @@ class TestDatabaseService:
async def test_get_transaction_count_from_db_success(self, database_service):
"""Test successful retrieval of transaction count."""
with patch.object(database_service, "_get_transaction_count") as mock_get_count:
with patch.object(database_service.transactions, "get_count") as mock_get_count:
mock_get_count.return_value = 42
result = await database_service.get_transaction_count_from_db(
@@ -167,11 +167,18 @@ class TestDatabaseService:
)
assert result == 42
mock_get_count.assert_called_once_with(account_id="test-account-123")
mock_get_count.assert_called_once_with(
account_id="test-account-123",
date_from=None,
date_to=None,
min_amount=None,
max_amount=None,
search=None,
)
async def test_get_transaction_count_from_db_with_filters(self, database_service):
"""Test getting transaction count with filters."""
with patch.object(database_service, "_get_transaction_count") as mock_get_count:
with patch.object(database_service.transactions, "get_count") as mock_get_count:
mock_get_count.return_value = 15
result = await database_service.get_transaction_count_from_db(
@@ -185,7 +192,9 @@ class TestDatabaseService:
mock_get_count.assert_called_once_with(
account_id="test-account-123",
date_from="2025-09-01",
date_to=None,
min_amount=-100.0,
max_amount=None,
search="Coffee",
)
@@ -201,7 +210,7 @@ class TestDatabaseService:
async def test_get_transaction_count_from_db_error(self, database_service):
"""Test handling error when getting count."""
with patch.object(database_service, "_get_transaction_count") as mock_get_count:
with patch.object(database_service.transactions, "get_count") as mock_get_count:
mock_get_count.side_effect = Exception("Database error")
result = await database_service.get_transaction_count_from_db()
@@ -212,7 +221,9 @@ class TestDatabaseService:
self, database_service, sample_balances_db_format
):
"""Test successful retrieval of balances from database."""
with patch.object(database_service, "_get_balances") as mock_get_balances:
with patch.object(
database_service.balances, "get_balances"
) as mock_get_balances:
mock_get_balances.return_value = sample_balances_db_format
result = await database_service.get_balances_from_db(
@@ -234,7 +245,9 @@ class TestDatabaseService:
async def test_get_balances_from_db_error(self, database_service):
"""Test handling error when getting balances."""
with patch.object(database_service, "_get_balances") as mock_get_balances:
with patch.object(
database_service.balances, "get_balances"
) as mock_get_balances:
mock_get_balances.side_effect = Exception("Database error")
result = await database_service.get_balances_from_db()
@@ -249,7 +262,9 @@ class TestDatabaseService:
"iban": "LT313250081177977789",
}
with patch.object(database_service, "_get_account_summary") as mock_get_summary:
with patch.object(
database_service.transactions, "get_account_summary"
) as mock_get_summary:
mock_get_summary.return_value = mock_summary
result = await database_service.get_account_summary_from_db(
@@ -269,7 +284,9 @@ class TestDatabaseService:
async def test_get_account_summary_from_db_error(self, database_service):
"""Test handling error when getting summary."""
with patch.object(database_service, "_get_account_summary") as mock_get_summary:
with patch.object(
database_service.transactions, "get_account_summary"
) as mock_get_summary:
mock_get_summary.side_effect = Exception("Database error")
result = await database_service.get_account_summary_from_db(
@@ -291,87 +308,87 @@ class TestDatabaseService:
],
}
with patch("sqlite3.connect") as mock_connect:
mock_conn = mock_connect.return_value
mock_cursor = mock_conn.cursor.return_value
with (
patch.object(database_service.balances, "persist") as mock_persist,
patch.object(
database_service.balance_transformer, "transform_to_database_format"
) as mock_transform,
):
mock_transform.return_value = [
(
"test-account-123",
"REVOLUT_REVOLT21",
"active",
"LT313250081177977789",
1000.0,
"EUR",
"interimAvailable",
"2025-09-01T10:00:00",
)
]
await database_service._persist_balance_sqlite(
"test-account-123", balance_data
)
await database_service.persist_balance("test-account-123", balance_data)
# Verify database operations
mock_connect.assert_called()
mock_cursor.execute.assert_called() # Table creation and insert
mock_conn.commit.assert_called_once()
mock_conn.close.assert_called_once()
# Verify transformation and persistence were called
mock_transform.assert_called_once_with("test-account-123", balance_data)
mock_persist.assert_called_once()
async def test_persist_balance_sqlite_error(self, database_service):
"""Test handling error during balance persistence."""
balance_data = {"balances": []}
with patch("sqlite3.connect") as mock_connect:
mock_connect.side_effect = Exception("Database error")
with (
patch.object(database_service.balances, "persist") as mock_persist,
patch.object(
database_service.balance_transformer, "transform_to_database_format"
) as mock_transform,
):
mock_persist.side_effect = Exception("Database error")
mock_transform.return_value = []
with pytest.raises(Exception, match="Database error"):
await database_service._persist_balance_sqlite(
"test-account-123", balance_data
)
await database_service.persist_balance("test-account-123", balance_data)
async def test_persist_transactions_sqlite_success(
self, database_service, sample_transactions_db_format
):
"""Test successful transaction persistence."""
with patch("sqlite3.connect") as mock_connect:
mock_conn = mock_connect.return_value
mock_cursor = mock_conn.cursor.return_value
# Mock fetchone to return (0,) indicating transaction doesn't exist yet
mock_cursor.fetchone.return_value = (0,)
with patch.object(database_service.transactions, "persist") as mock_persist:
mock_persist.return_value = sample_transactions_db_format
result = await database_service._persist_transactions_sqlite(
result = await database_service.persist_transactions(
"test-account-123", sample_transactions_db_format
)
# Should return the transactions (assuming no duplicates)
assert len(result) >= 0 # Could be empty if all are duplicates
# Verify database operations
mock_connect.assert_called()
mock_cursor.execute.assert_called()
mock_conn.commit.assert_called_once()
mock_conn.close.assert_called_once()
# Should return the new transactions
assert len(result) == 2
mock_persist.assert_called_once_with(
"test-account-123", sample_transactions_db_format
)
async def test_persist_transactions_sqlite_duplicate_detection(
self, database_service, sample_transactions_db_format
):
"""Test that existing transactions are not returned as new."""
with patch("sqlite3.connect") as mock_connect:
mock_conn = mock_connect.return_value
mock_cursor = mock_conn.cursor.return_value
# Mock fetchone to return (1,) indicating transaction already exists
mock_cursor.fetchone.return_value = (1,)
with patch.object(database_service.transactions, "persist") as mock_persist:
# Return empty list indicating all were duplicates
mock_persist.return_value = []
result = await database_service._persist_transactions_sqlite(
result = await database_service.persist_transactions(
"test-account-123", sample_transactions_db_format
)
# Should return empty list since all transactions already exist
assert len(result) == 0
# Verify database operations still happened (INSERT OR REPLACE executed)
mock_connect.assert_called()
mock_cursor.execute.assert_called()
mock_conn.commit.assert_called_once()
mock_conn.close.assert_called_once()
mock_persist.assert_called_once()
async def test_persist_transactions_sqlite_error(self, database_service):
"""Test handling error during transaction persistence."""
with patch("sqlite3.connect") as mock_connect:
mock_connect.side_effect = Exception("Database error")
with patch.object(database_service.transactions, "persist") as mock_persist:
mock_persist.side_effect = Exception("Database error")
with pytest.raises(Exception, match="Database error"):
await database_service._persist_transactions_sqlite(
"test-account-123", []
)
await database_service.persist_transactions("test-account-123", [])
async def test_process_transactions_booked_and_pending(self, database_service):
"""Test processing transactions with both booked and pending."""

View File

@@ -0,0 +1,244 @@
"""Tests for sync service notification functionality."""
from unittest.mock import patch
import pytest
from leggen.services.sync_service import SyncService
@pytest.mark.unit
class TestSyncNotifications:
"""Test sync service notification functionality."""
@pytest.mark.asyncio
async def test_sync_failure_sends_notification(self):
"""Test that sync failures trigger notifications."""
sync_service = SyncService()
# Mock the dependencies
with (
patch.object(
sync_service.gocardless, "get_requisitions"
) as mock_get_requisitions,
patch.object(
sync_service.gocardless, "get_account_details"
) as mock_get_details,
patch.object(
sync_service.notifications, "send_sync_failure_notification"
) as mock_send_notification,
patch.object(sync_service.sync, "persist", return_value=1),
):
# Setup: One requisition with one account that will fail
mock_get_requisitions.return_value = {
"results": [
{
"id": "req-123",
"institution_id": "TEST_BANK",
"status": "LN",
"accounts": ["account-1"],
}
]
}
# Make account details fail
mock_get_details.side_effect = Exception("API Error")
# Execute: Run sync which should fail for the account
await sync_service.sync_all_accounts()
# Assert: Notification should be sent for the failed account
mock_send_notification.assert_called_once()
call_args = mock_send_notification.call_args[0][0]
assert call_args["account_id"] == "account-1"
assert "API Error" in call_args["error"]
assert call_args["type"] == "account_sync_failure"
@pytest.mark.asyncio
async def test_expired_requisition_sends_notification(self):
"""Test that expired requisitions trigger expiry notifications."""
sync_service = SyncService()
# Mock the dependencies
with (
patch.object(
sync_service.gocardless, "get_requisitions"
) as mock_get_requisitions,
patch.object(
sync_service.notifications, "send_expiry_notification"
) as mock_send_expiry,
patch.object(sync_service.sync, "persist", return_value=1),
):
# Setup: One expired requisition
mock_get_requisitions.return_value = {
"results": [
{
"id": "req-expired",
"institution_id": "EXPIRED_BANK",
"status": "EX",
"accounts": [],
}
]
}
# Execute: Run sync
await sync_service.sync_all_accounts()
# Assert: Expiry notification should be sent
mock_send_expiry.assert_called_once()
call_args = mock_send_expiry.call_args[0][0]
assert call_args["requisition_id"] == "req-expired"
assert call_args["bank"] == "EXPIRED_BANK"
assert call_args["status"] == "expired"
assert call_args["days_left"] == 0
@pytest.mark.asyncio
async def test_multiple_failures_send_multiple_notifications(self):
"""Test that multiple account failures send multiple notifications."""
sync_service = SyncService()
# Mock the dependencies
with (
patch.object(
sync_service.gocardless, "get_requisitions"
) as mock_get_requisitions,
patch.object(
sync_service.gocardless, "get_account_details"
) as mock_get_details,
patch.object(
sync_service.notifications, "send_sync_failure_notification"
) as mock_send_notification,
patch.object(sync_service.sync, "persist", return_value=1),
):
# Setup: One requisition with two accounts that will fail
mock_get_requisitions.return_value = {
"results": [
{
"id": "req-123",
"institution_id": "TEST_BANK",
"status": "LN",
"accounts": ["account-1", "account-2"],
}
]
}
# Make all account details fail
mock_get_details.side_effect = Exception("API Error")
# Execute: Run sync
await sync_service.sync_all_accounts()
# Assert: Two notifications should be sent
assert mock_send_notification.call_count == 2
@pytest.mark.asyncio
async def test_successful_sync_no_failure_notification(self):
"""Test that successful syncs don't send failure notifications."""
sync_service = SyncService()
# Mock the dependencies
with (
patch.object(
sync_service.gocardless, "get_requisitions"
) as mock_get_requisitions,
patch.object(
sync_service.gocardless, "get_account_details"
) as mock_get_details,
patch.object(
sync_service.gocardless, "get_account_balances"
) as mock_get_balances,
patch.object(
sync_service.gocardless, "get_account_transactions"
) as mock_get_transactions,
patch.object(
sync_service.notifications, "send_sync_failure_notification"
) as mock_send_notification,
patch.object(sync_service.notifications, "send_transaction_notifications"),
patch.object(sync_service.accounts, "persist"),
patch.object(sync_service.balances, "persist"),
patch.object(
sync_service.transaction_processor,
"process_transactions",
return_value=[],
),
patch.object(sync_service.transactions, "persist", return_value=[]),
patch.object(sync_service.sync, "persist", return_value=1),
):
# Setup: One requisition with one account that succeeds
mock_get_requisitions.return_value = {
"results": [
{
"id": "req-123",
"institution_id": "TEST_BANK",
"status": "LN",
"accounts": ["account-1"],
}
]
}
mock_get_details.return_value = {
"id": "account-1",
"institution_id": "TEST_BANK",
"status": "READY",
"iban": "TEST123",
}
mock_get_balances.return_value = {
"balances": [{"balanceAmount": {"amount": "100", "currency": "EUR"}}]
}
mock_get_transactions.return_value = {"transactions": {"booked": []}}
# Execute: Run sync
await sync_service.sync_all_accounts()
# Assert: No failure notification should be sent
mock_send_notification.assert_not_called()
@pytest.mark.asyncio
async def test_notification_failure_does_not_stop_sync(self):
"""Test that notification failures don't stop the sync process."""
sync_service = SyncService()
# Mock the dependencies
with (
patch.object(
sync_service.gocardless, "get_requisitions"
) as mock_get_requisitions,
patch.object(
sync_service.gocardless, "get_account_details"
) as mock_get_details,
patch.object(
sync_service.notifications, "_send_discord_sync_failure"
) as mock_discord_notification,
patch.object(
sync_service.notifications, "_send_telegram_sync_failure"
) as mock_telegram_notification,
patch.object(sync_service.sync, "persist", return_value=1),
):
# Setup: One requisition with one account that will fail
mock_get_requisitions.return_value = {
"results": [
{
"id": "req-123",
"institution_id": "TEST_BANK",
"status": "LN",
"accounts": ["account-1"],
}
]
}
# Make account details fail
mock_get_details.side_effect = Exception("API Error")
# Make both notification methods fail
mock_discord_notification.side_effect = Exception("Discord Error")
mock_telegram_notification.side_effect = Exception("Telegram Error")
# Execute: Run sync - should not raise exception from notification
result = await sync_service.sync_all_accounts()
# The sync should complete with errors but not crash from notifications
assert result.success is False
assert len(result.errors) > 0
assert "API Error" in result.errors[0]

395
uv.lock generated
View File

@@ -2,6 +2,15 @@ version = 1
revision = 3
requires-python = "==3.13.*"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -13,105 +22,110 @@ wheels = [
[[package]]
name = "anyio"
version = "4.10.0"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f1/b4/636b3b65173d3ce9a38ef5f0522789614e590dab6a8d505340a4efe4c567/anyio-4.10.0.tar.gz", hash = "sha256:3f3fae35c96039744587aa5b8371e7e8e603c0702999535961dd336026973ba6", size = 213252, upload-time = "2025-08-04T08:54:26.451Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/12/e5e0282d673bb9746bacfb6e2dba8719989d3660cdb2ea79aee9a9651afb/anyio-4.10.0-py3-none-any.whl", hash = "sha256:60e474ac86736bbfd6f210f7a61218939c318f43f9972497381f1c5e930ed3d1", size = 107213, upload-time = "2025-08-04T08:54:24.882Z" },
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
]
[[package]]
name = "apscheduler"
version = "3.11.0"
version = "3.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzlocal" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347, upload-time = "2024-11-24T19:39:26.463Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/81/192db4f8471de5bc1f0d098783decffb1e6e69c4f8b4bc6711094691950b/apscheduler-3.11.1.tar.gz", hash = "sha256:0db77af6400c84d1747fe98a04b8b58f0080c77d11d338c4f507a9752880f221", size = 108044, upload-time = "2025-10-31T18:55:42.819Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" },
{ url = "https://files.pythonhosted.org/packages/58/9f/d3c76f76c73fcc959d28e9def45b8b1cc3d7722660c5003b19c1022fd7f4/apscheduler-3.11.1-py3-none-any.whl", hash = "sha256:6162cb5683cb09923654fa9bdd3130c4be4bfda6ad8990971c9597ecd52965d2", size = 64278, upload-time = "2025-10-31T18:55:41.186Z" },
]
[[package]]
name = "boto3"
version = "1.40.36"
version = "1.41.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8d/21/7bc857b155e8264c92b6fa8e0860a67dc01a19cbe6ba4342500299f2ae5b/boto3-1.40.36.tar.gz", hash = "sha256:bfc1f3d5c4f5d12b8458406b8972f8794ac57e2da1ee441469e143bc0440a5c3", size = 111552, upload-time = "2025-09-22T19:26:17.357Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/81/2600e83ddd7cb1dac43d28fd39774434afcda0d85d730402192b1a9266a3/boto3-1.41.2.tar.gz", hash = "sha256:7054fbc61cadab383f40ea6d725013ba6c8f569641dddb14c0055e790280ad6c", size = 111593, upload-time = "2025-11-21T20:32:08.622Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/4c/428b728d5cf9003f83f735d10dd522945ab20c7d67e6c987909f29be12a0/boto3-1.40.36-py3-none-any.whl", hash = "sha256:d7c1fe033f491f560cd26022a9dcf28baf877ae854f33bc64fffd0df3b9c98be", size = 139345, upload-time = "2025-09-22T19:26:15.194Z" },
{ url = "https://files.pythonhosted.org/packages/48/41/1ed7fdc3f124c1cf2df78e605588fa78a182410b832f5b71944a69436171/boto3-1.41.2-py3-none-any.whl", hash = "sha256:edcde82fdae4201aa690e3683f8e5b1a846cf1bbf79d03db4fa8a2f6f46dba9c", size = 139343, upload-time = "2025-11-21T20:32:07.147Z" },
]
[[package]]
name = "botocore"
version = "1.40.36"
version = "1.41.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4b/30/75fdc75933d3bc1c8dd7fbaee771438328b518936906b411075b1eacac93/botocore-1.40.36.tar.gz", hash = "sha256:93386a8dc54173267ddfc6cd8636c9171e021f7c032aa1df3af7de816e3df616", size = 14349583, upload-time = "2025-09-22T19:26:05.957Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c5/0b/6eb5dc752b240dd0b76d7e3257ae25b70683896d1789e7bfb78fba7c7c99/botocore-1.41.2.tar.gz", hash = "sha256:49a3e8f4c1a1759a687941fef8b36efd7bafcf63c1ef74aa75d6497eb4887c9c", size = 14660558, upload-time = "2025-11-21T20:31:58.785Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/51/95c0324ac20b5bbafad4c89dd610c8e0dd6cbadbb2c8ca66dc95ccde98b8/botocore-1.40.36-py3-none-any.whl", hash = "sha256:d6edf75875e4013cb7078875a1d6c289afb4cc6675d99d80700c692d8d8e0b72", size = 14020478, upload-time = "2025-09-22T19:26:02.054Z" },
{ url = "https://files.pythonhosted.org/packages/77/4d/516ee2157c0686fbe48ca8b94dffc17a0c35040d4626761d74b1a43215c8/botocore-1.41.2-py3-none-any.whl", hash = "sha256:154052dfaa7292212f01c8fab822c76cd10a15a7e164e4c45e4634eb40214b90", size = 14324839, upload-time = "2025-11-21T20:31:56.236Z" },
]
[[package]]
name = "certifi"
version = "2025.8.3"
version = "2025.11.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" },
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
]
[[package]]
name = "cfgv"
version = "3.4.0"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249, upload-time = "2023-08-12T20:38:16.269Z" },
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.3"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14", size = 122371, upload-time = "2025-08-09T07:57:28.46Z" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/65/ca/2135ac97709b400c7654b4b764daf5c5567c2da45a30cdd20f9eefe2d658/charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe", size = 205326, upload-time = "2025-08-09T07:56:24.721Z" },
{ url = "https://files.pythonhosted.org/packages/71/11/98a04c3c97dd34e49c7d247083af03645ca3730809a5509443f3c37f7c99/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8", size = 146008, upload-time = "2025-08-09T07:56:26.004Z" },
{ url = "https://files.pythonhosted.org/packages/60/f5/4659a4cb3c4ec146bec80c32d8bb16033752574c20b1252ee842a95d1a1e/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9", size = 159196, upload-time = "2025-08-09T07:56:27.25Z" },
{ url = "https://files.pythonhosted.org/packages/86/9e/f552f7a00611f168b9a5865a1414179b2c6de8235a4fa40189f6f79a1753/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31", size = 156819, upload-time = "2025-08-09T07:56:28.515Z" },
{ url = "https://files.pythonhosted.org/packages/7e/95/42aa2156235cbc8fa61208aded06ef46111c4d3f0de233107b3f38631803/charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f", size = 151350, upload-time = "2025-08-09T07:56:29.716Z" },
{ url = "https://files.pythonhosted.org/packages/c2/a9/3865b02c56f300a6f94fc631ef54f0a8a29da74fb45a773dfd3dcd380af7/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927", size = 148644, upload-time = "2025-08-09T07:56:30.984Z" },
{ url = "https://files.pythonhosted.org/packages/77/d9/cbcf1a2a5c7d7856f11e7ac2d782aec12bdfea60d104e60e0aa1c97849dc/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9", size = 160468, upload-time = "2025-08-09T07:56:32.252Z" },
{ url = "https://files.pythonhosted.org/packages/f6/42/6f45efee8697b89fda4d50580f292b8f7f9306cb2971d4b53f8914e4d890/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5", size = 158187, upload-time = "2025-08-09T07:56:33.481Z" },
{ url = "https://files.pythonhosted.org/packages/70/99/f1c3bdcfaa9c45b3ce96f70b14f070411366fa19549c1d4832c935d8e2c3/charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc", size = 152699, upload-time = "2025-08-09T07:56:34.739Z" },
{ url = "https://files.pythonhosted.org/packages/a3/ad/b0081f2f99a4b194bcbb1934ef3b12aa4d9702ced80a37026b7607c72e58/charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce", size = 99580, upload-time = "2025-08-09T07:56:35.981Z" },
{ url = "https://files.pythonhosted.org/packages/9a/8f/ae790790c7b64f925e5c953b924aaa42a243fb778fed9e41f147b2a5715a/charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef", size = 107366, upload-time = "2025-08-09T07:56:37.339Z" },
{ url = "https://files.pythonhosted.org/packages/8a/1f/f041989e93b001bc4e44bb1669ccdcf54d3f00e628229a85b08d330615c5/charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a", size = 53175, upload-time = "2025-08-09T07:57:26.864Z" },
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "click"
version = "8.2.1"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" },
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
@@ -146,25 +160,26 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.116.1"
version = "0.121.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" }
sdist = { url = "https://files.pythonhosted.org/packages/80/f0/086c442c6516195786131b8ca70488c6ef11d2f2e33c9a893576b2b0d3f7/fastapi-0.121.3.tar.gz", hash = "sha256:0055bc24fe53e56a40e9e0ad1ae2baa81622c406e548e501e717634e2dfbc40b", size = 344501, upload-time = "2025-11-19T16:53:39.243Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" },
{ url = "https://files.pythonhosted.org/packages/98/b6/4f620d7720fc0a754c8c1b7501d73777f6ba43b57c8ab99671f4d7441eb8/fastapi-0.121.3-py3-none-any.whl", hash = "sha256:0c78fc87587fcd910ca1bbf5bc8ba37b80e119b388a7206b39f0ecc95ebf53e9", size = 109801, upload-time = "2025-11-19T16:53:37.918Z" },
]
[[package]]
name = "filelock"
version = "3.19.1"
version = "3.20.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" }
sdist = { url = "https://files.pythonhosted.org/packages/58/46/0028a82567109b5ef6e4d2a1f04a583fb513e6cf9527fcdd09afd817deeb/filelock-3.20.0.tar.gz", hash = "sha256:711e943b4ec6be42e1d4e6690b48dc175c822967466bb31c0c293f34334c13f4", size = 18922, upload-time = "2025-10-08T18:03:50.056Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" },
{ url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054, upload-time = "2025-10-08T18:03:48.35Z" },
]
[[package]]
@@ -191,17 +206,17 @@ wheels = [
[[package]]
name = "httptools"
version = "0.6.4"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a7/9a/ce5e1f7e131522e6d3426e8e7a490b3a01f39a6696602e1c4f33f9e94277/httptools-0.6.4.tar.gz", hash = "sha256:4e93eee4add6493b59a5c514da98c939b244fce4a0d8879cd3f466562f4b7d5c", size = 240639, upload-time = "2024-10-16T19:45:08.902Z" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/a3/9fe9ad23fd35f7de6b91eeb60848986058bd8b5a5c1e256f5860a160cc3e/httptools-0.6.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ade273d7e767d5fae13fa637f4d53b6e961fb7fd93c7797562663f0171c26660", size = 197214, upload-time = "2024-10-16T19:44:38.738Z" },
{ url = "https://files.pythonhosted.org/packages/ea/d9/82d5e68bab783b632023f2fa31db20bebb4e89dfc4d2293945fd68484ee4/httptools-0.6.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:856f4bc0478ae143bad54a4242fccb1f3f86a6e1be5548fecfd4102061b3a083", size = 102431, upload-time = "2024-10-16T19:44:39.818Z" },
{ url = "https://files.pythonhosted.org/packages/96/c1/cb499655cbdbfb57b577734fde02f6fa0bbc3fe9fb4d87b742b512908dff/httptools-0.6.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:322d20ea9cdd1fa98bd6a74b77e2ec5b818abdc3d36695ab402a0de8ef2865a3", size = 473121, upload-time = "2024-10-16T19:44:41.189Z" },
{ url = "https://files.pythonhosted.org/packages/af/71/ee32fd358f8a3bb199b03261f10921716990808a675d8160b5383487a317/httptools-0.6.4-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4d87b29bd4486c0093fc64dea80231f7c7f7eb4dc70ae394d70a495ab8436071", size = 473805, upload-time = "2024-10-16T19:44:42.384Z" },
{ url = "https://files.pythonhosted.org/packages/8a/0a/0d4df132bfca1507114198b766f1737d57580c9ad1cf93c1ff673e3387be/httptools-0.6.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:342dd6946aa6bda4b8f18c734576106b8a31f2fe31492881a9a160ec84ff4bd5", size = 448858, upload-time = "2024-10-16T19:44:43.959Z" },
{ url = "https://files.pythonhosted.org/packages/1e/6a/787004fdef2cabea27bad1073bf6a33f2437b4dbd3b6fb4a9d71172b1c7c/httptools-0.6.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b36913ba52008249223042dca46e69967985fb4051951f94357ea681e1f5dc0", size = 452042, upload-time = "2024-10-16T19:44:45.071Z" },
{ url = "https://files.pythonhosted.org/packages/4d/dc/7decab5c404d1d2cdc1bb330b1bf70e83d6af0396fd4fc76fc60c0d522bf/httptools-0.6.4-cp313-cp313-win_amd64.whl", hash = "sha256:28908df1b9bb8187393d5b5db91435ccc9c8e891657f9cbb42a2541b44c82fc8", size = 87682, upload-time = "2024-10-16T19:44:46.46Z" },
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
]
[[package]]
@@ -221,29 +236,29 @@ wheels = [
[[package]]
name = "identify"
version = "2.6.14"
version = "2.6.15"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/c4/62963f25a678f6a050fb0505a65e9e726996171e6dbe1547f79619eefb15/identify-2.6.14.tar.gz", hash = "sha256:663494103b4f717cb26921c52f8751363dc89db64364cd836a9bf1535f53cd6a", size = 99283, upload-time = "2025-09-06T19:30:52.938Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/ae/2ad30f4652712c82f1c23423d79136fbce338932ad166d70c1efb86a5998/identify-2.6.14-py2.py3-none-any.whl", hash = "sha256:11a073da82212c6646b1f39bb20d4483bfb9543bd5566fec60053c4bb309bf2e", size = 99172, upload-time = "2025-09-06T19:30:51.759Z" },
{ url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" },
]
[[package]]
name = "idna"
version = "3.10"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.1.0"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
@@ -257,7 +272,7 @@ wheels = [
[[package]]
name = "leggen"
version = "2025.10.2"
version = "2025.11.0"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },
@@ -333,22 +348,22 @@ wheels = [
[[package]]
name = "mypy"
version = "1.17.1"
version = "1.18.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570, upload-time = "2025-07-31T07:54:19.204Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/82/aec2fc9b9b149f372850291827537a508d6c4d3664b1750a324b91f71355/mypy-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:93378d3203a5c0800c6b6d850ad2f19f7a3cdf1a3701d3416dbf128805c6a6a7", size = 11075338, upload-time = "2025-07-31T07:53:38.873Z" },
{ url = "https://files.pythonhosted.org/packages/07/ac/ee93fbde9d2242657128af8c86f5d917cd2887584cf948a8e3663d0cd737/mypy-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:15d54056f7fe7a826d897789f53dd6377ec2ea8ba6f776dc83c2902b899fee81", size = 10113066, upload-time = "2025-07-31T07:54:14.707Z" },
{ url = "https://files.pythonhosted.org/packages/5a/68/946a1e0be93f17f7caa56c45844ec691ca153ee8b62f21eddda336a2d203/mypy-1.17.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:209a58fed9987eccc20f2ca94afe7257a8f46eb5df1fb69958650973230f91e6", size = 11875473, upload-time = "2025-07-31T07:53:14.504Z" },
{ url = "https://files.pythonhosted.org/packages/9f/0f/478b4dce1cb4f43cf0f0d00fba3030b21ca04a01b74d1cd272a528cf446f/mypy-1.17.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:099b9a5da47de9e2cb5165e581f158e854d9e19d2e96b6698c0d64de911dd849", size = 12744296, upload-time = "2025-07-31T07:53:03.896Z" },
{ url = "https://files.pythonhosted.org/packages/ca/70/afa5850176379d1b303f992a828de95fc14487429a7139a4e0bdd17a8279/mypy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ffadfbe6994d724c5a1bb6123a7d27dd68fc9c059561cd33b664a79578e14", size = 12914657, upload-time = "2025-07-31T07:54:08.576Z" },
{ url = "https://files.pythonhosted.org/packages/53/f9/4a83e1c856a3d9c8f6edaa4749a4864ee98486e9b9dbfbc93842891029c2/mypy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:9a2b7d9180aed171f033c9f2fc6c204c1245cf60b0cb61cf2e7acc24eea78e0a", size = 9593320, upload-time = "2025-07-31T07:53:01.341Z" },
{ url = "https://files.pythonhosted.org/packages/1d/f3/8fcd2af0f5b806f6cf463efaffd3c9548a28f84220493ecd38d127b6b66d/mypy-1.17.1-py3-none-any.whl", hash = "sha256:a9f52c0351c21fe24c21d8c0eb1f62967b262d6729393397b6f443c3b773c3b9", size = 2283411, upload-time = "2025-07-31T07:53:24.664Z" },
{ url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" },
{ url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" },
{ url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" },
{ url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" },
{ url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" },
{ url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" },
{ url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" },
]
[[package]]
@@ -389,11 +404,11 @@ wheels = [
[[package]]
name = "platformdirs"
version = "4.4.0"
version = "4.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" }
sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/4b/2028861e724d3bd36227adfa20d3fd24c3fc6d52032f4a93c133be5d17ce/platformdirs-4.4.0-py3-none-any.whl", hash = "sha256:abd01743f24e5287cd7a5db3752faf1a2d65353f38ec26d98e25a6db65958c85", size = 18654, upload-time = "2025-08-26T14:32:02.735Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
]
[[package]]
@@ -407,7 +422,7 @@ wheels = [
[[package]]
name = "pre-commit"
version = "4.3.0"
version = "4.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
@@ -416,14 +431,14 @@ dependencies = [
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792, upload-time = "2025-08-09T18:56:14.651Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f4/9b/6a4ffb4ed980519da959e1cf3122fc6cb41211daa58dbae1c73c0e519a37/pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b", size = 198428, upload-time = "2025-11-22T21:02:42.304Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965, upload-time = "2025-08-09T18:56:13.192Z" },
{ url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429, upload-time = "2025-11-22T21:02:40.836Z" },
]
[[package]]
name = "pydantic"
version = "2.11.7"
version = "2.12.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
@@ -431,37 +446,34 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" }
sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" },
{ url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" },
]
[[package]]
name = "pydantic-core"
version = "2.33.2"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" }
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" },
{ url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" },
{ url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" },
{ url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" },
{ url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" },
{ url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" },
{ url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" },
{ url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" },
{ url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" },
{ url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" },
{ url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" },
{ url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" },
{ url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" },
{ url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" },
{ url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" },
{ url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" },
{ url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" },
{ url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" },
{ url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" },
{ url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" },
{ url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" },
{ url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" },
{ url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" },
{ url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" },
{ url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" },
{ url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" },
{ url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" },
{ url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" },
]
[[package]]
@@ -475,7 +487,7 @@ wheels = [
[[package]]
name = "pytest"
version = "8.4.2"
version = "9.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
@@ -484,33 +496,33 @@ dependencies = [
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
sdist = { url = "https://files.pythonhosted.org/packages/07/56/f013048ac4bc4c1d9be45afd4ab209ea62822fb1598f40687e6bf45dcea4/pytest-9.0.1.tar.gz", hash = "sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8", size = 1564125, upload-time = "2025-11-12T13:05:09.333Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
{ url = "https://files.pythonhosted.org/packages/0b/8b/6300fb80f858cda1c51ffa17075df5d846757081d11ab4aa35cef9e6258b/pytest-9.0.1-py3-none-any.whl", hash = "sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad", size = 373668, upload-time = "2025-11-12T13:05:07.379Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.1.0"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4e/51/f8794af39eeb870e87a8c8068642fc07bce0c854d6865d7dd0f2a9d338c2/pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea", size = 46652, upload-time = "2025-07-16T04:29:26.393Z" }
sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/9d/bf86eddabf8c6c9cb1ea9a869d6873b46f105a5d292d3a6f7071f5b07935/pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf", size = 15157, upload-time = "2025-07-16T04:29:24.929Z" },
{ url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" },
]
[[package]]
name = "pytest-mock"
version = "3.15.0"
version = "3.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/99/3323ee5c16b3637b4d941c362182d3e749c11e400bea31018c42219f3a98/pytest_mock-3.15.0.tar.gz", hash = "sha256:ab896bd190316b9d5d87b277569dfcdf718b2d049a2ccff5f7aca279c002a1cf", size = 33838, upload-time = "2025-09-04T20:57:48.679Z" }
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/b3/7fefc43fb706380144bcd293cc6e446e6f637ddfa8b83f48d1734156b529/pytest_mock-3.15.0-py3-none-any.whl", hash = "sha256:ef2219485fb1bd256b00e7ad7466ce26729b30eadfc7cbcdb4fa9a92ca68db6f", size = 10050, upload-time = "2025-09-04T20:57:47.274Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
]
[[package]]
@@ -527,28 +539,29 @@ wheels = [
[[package]]
name = "python-dotenv"
version = "1.1.1"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" },
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.2"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
{ url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
{ url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
{ url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
{ url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
{ url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
{ url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
{ url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
{ url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
]
[[package]]
@@ -592,40 +605,40 @@ wheels = [
[[package]]
name = "ruff"
version = "0.13.0"
version = "0.14.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6e/1a/1f4b722862840295bcaba8c9e5261572347509548faaa99b2d57ee7bfe6a/ruff-0.13.0.tar.gz", hash = "sha256:5b4b1ee7eb35afae128ab94459b13b2baaed282b1fb0f472a73c82c996c8ae60", size = 5372863, upload-time = "2025-09-10T16:25:37.917Z" }
sdist = { url = "https://files.pythonhosted.org/packages/52/f0/62b5a1a723fe183650109407fa56abb433b00aa1c0b9ba555f9c4efec2c6/ruff-0.14.6.tar.gz", hash = "sha256:6f0c742ca6a7783a736b867a263b9a7a80a45ce9bee391eeda296895f1b4e1cc", size = 5669501, upload-time = "2025-11-21T14:26:17.903Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/fe/6f87b419dbe166fd30a991390221f14c5b68946f389ea07913e1719741e0/ruff-0.13.0-py3-none-linux_armv6l.whl", hash = "sha256:137f3d65d58ee828ae136a12d1dc33d992773d8f7644bc6b82714570f31b2004", size = 12187826, upload-time = "2025-09-10T16:24:39.5Z" },
{ url = "https://files.pythonhosted.org/packages/e4/25/c92296b1fc36d2499e12b74a3fdb230f77af7bdf048fad7b0a62e94ed56a/ruff-0.13.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21ae48151b66e71fd111b7d79f9ad358814ed58c339631450c66a4be33cc28b9", size = 12933428, upload-time = "2025-09-10T16:24:43.866Z" },
{ url = "https://files.pythonhosted.org/packages/44/cf/40bc7221a949470307d9c35b4ef5810c294e6cfa3caafb57d882731a9f42/ruff-0.13.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64de45f4ca5441209e41742d527944635a05a6e7c05798904f39c85bafa819e3", size = 12095543, upload-time = "2025-09-10T16:24:46.638Z" },
{ url = "https://files.pythonhosted.org/packages/f1/03/8b5ff2a211efb68c63a1d03d157e924997ada87d01bebffbd13a0f3fcdeb/ruff-0.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2c653ae9b9d46e0ef62fc6fbf5b979bda20a0b1d2b22f8f7eb0cde9f4963b8", size = 12312489, upload-time = "2025-09-10T16:24:49.556Z" },
{ url = "https://files.pythonhosted.org/packages/37/fc/2336ef6d5e9c8d8ea8305c5f91e767d795cd4fc171a6d97ef38a5302dadc/ruff-0.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cec632534332062bc9eb5884a267b689085a1afea9801bf94e3ba7498a2d207", size = 11991631, upload-time = "2025-09-10T16:24:53.439Z" },
{ url = "https://files.pythonhosted.org/packages/39/7f/f6d574d100fca83d32637d7f5541bea2f5e473c40020bbc7fc4a4d5b7294/ruff-0.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd628101d9f7d122e120ac7c17e0a0f468b19bc925501dbe03c1cb7f5415b24", size = 13720602, upload-time = "2025-09-10T16:24:56.392Z" },
{ url = "https://files.pythonhosted.org/packages/fd/c8/a8a5b81d8729b5d1f663348d11e2a9d65a7a9bd3c399763b1a51c72be1ce/ruff-0.13.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afe37db8e1466acb173bb2a39ca92df00570e0fd7c94c72d87b51b21bb63efea", size = 14697751, upload-time = "2025-09-10T16:24:59.89Z" },
{ url = "https://files.pythonhosted.org/packages/57/f5/183ec292272ce7ec5e882aea74937f7288e88ecb500198b832c24debc6d3/ruff-0.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f96a8d90bb258d7d3358b372905fe7333aaacf6c39e2408b9f8ba181f4b6ef2", size = 14095317, upload-time = "2025-09-10T16:25:03.025Z" },
{ url = "https://files.pythonhosted.org/packages/9f/8d/7f9771c971724701af7926c14dab31754e7b303d127b0d3f01116faef456/ruff-0.13.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b5e3d883e4f924c5298e3f2ee0f3085819c14f68d1e5b6715597681433f153", size = 13144418, upload-time = "2025-09-10T16:25:06.272Z" },
{ url = "https://files.pythonhosted.org/packages/a8/a6/7985ad1778e60922d4bef546688cd8a25822c58873e9ff30189cfe5dc4ab/ruff-0.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03447f3d18479df3d24917a92d768a89f873a7181a064858ea90a804a7538991", size = 13370843, upload-time = "2025-09-10T16:25:09.965Z" },
{ url = "https://files.pythonhosted.org/packages/64/1c/bafdd5a7a05a50cc51d9f5711da704942d8dd62df3d8c70c311e98ce9f8a/ruff-0.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fbc6b1934eb1c0033da427c805e27d164bb713f8e273a024a7e86176d7f462cf", size = 13321891, upload-time = "2025-09-10T16:25:12.969Z" },
{ url = "https://files.pythonhosted.org/packages/bc/3e/7817f989cb9725ef7e8d2cee74186bf90555279e119de50c750c4b7a72fe/ruff-0.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8ab6a3e03665d39d4a25ee199d207a488724f022db0e1fe4002968abdb8001b", size = 12119119, upload-time = "2025-09-10T16:25:16.621Z" },
{ url = "https://files.pythonhosted.org/packages/58/07/9df080742e8d1080e60c426dce6e96a8faf9a371e2ce22eef662e3839c95/ruff-0.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2a5c62f8ccc6dd2fe259917482de7275cecc86141ee10432727c4816235bc41", size = 11961594, upload-time = "2025-09-10T16:25:19.49Z" },
{ url = "https://files.pythonhosted.org/packages/6a/f4/ae1185349197d26a2316840cb4d6c3fba61d4ac36ed728bf0228b222d71f/ruff-0.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7b85ca27aeeb1ab421bc787009831cffe6048faae08ad80867edab9f2760945", size = 12933377, upload-time = "2025-09-10T16:25:22.371Z" },
{ url = "https://files.pythonhosted.org/packages/b6/39/e776c10a3b349fc8209a905bfb327831d7516f6058339a613a8d2aaecacd/ruff-0.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:79ea0c44a3032af768cabfd9616e44c24303af49d633b43e3a5096e009ebe823", size = 13418555, upload-time = "2025-09-10T16:25:25.681Z" },
{ url = "https://files.pythonhosted.org/packages/46/09/dca8df3d48e8b3f4202bf20b1658898e74b6442ac835bfe2c1816d926697/ruff-0.13.0-py3-none-win32.whl", hash = "sha256:4e473e8f0e6a04e4113f2e1de12a5039579892329ecc49958424e5568ef4f768", size = 12141613, upload-time = "2025-09-10T16:25:28.664Z" },
{ url = "https://files.pythonhosted.org/packages/61/21/0647eb71ed99b888ad50e44d8ec65d7148babc0e242d531a499a0bbcda5f/ruff-0.13.0-py3-none-win_amd64.whl", hash = "sha256:48e5c25c7a3713eea9ce755995767f4dcd1b0b9599b638b12946e892123d1efb", size = 13258250, upload-time = "2025-09-10T16:25:31.773Z" },
{ url = "https://files.pythonhosted.org/packages/e1/a3/03216a6a86c706df54422612981fb0f9041dbb452c3401501d4a22b942c9/ruff-0.13.0-py3-none-win_arm64.whl", hash = "sha256:ab80525317b1e1d38614addec8ac954f1b3e662de9d59114ecbf771d00cf613e", size = 12312357, upload-time = "2025-09-10T16:25:35.595Z" },
{ url = "https://files.pythonhosted.org/packages/67/d2/7dd544116d107fffb24a0064d41a5d2ed1c9d6372d142f9ba108c8e39207/ruff-0.14.6-py3-none-linux_armv6l.whl", hash = "sha256:d724ac2f1c240dbd01a2ae98db5d1d9a5e1d9e96eba999d1c48e30062df578a3", size = 13326119, upload-time = "2025-11-21T14:25:24.2Z" },
{ url = "https://files.pythonhosted.org/packages/36/6a/ad66d0a3315d6327ed6b01f759d83df3c4d5f86c30462121024361137b6a/ruff-0.14.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9f7539ea257aa4d07b7ce87aed580e485c40143f2473ff2f2b75aee003186004", size = 13526007, upload-time = "2025-11-21T14:25:26.906Z" },
{ url = "https://files.pythonhosted.org/packages/a3/9d/dae6db96df28e0a15dea8e986ee393af70fc97fd57669808728080529c37/ruff-0.14.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7f6007e55b90a2a7e93083ba48a9f23c3158c433591c33ee2e99a49b889c6332", size = 12676572, upload-time = "2025-11-21T14:25:29.826Z" },
{ url = "https://files.pythonhosted.org/packages/76/a4/f319e87759949062cfee1b26245048e92e2acce900ad3a909285f9db1859/ruff-0.14.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a8e7b9d73d8728b68f632aa8e824ef041d068d231d8dbc7808532d3629a6bef", size = 13140745, upload-time = "2025-11-21T14:25:32.788Z" },
{ url = "https://files.pythonhosted.org/packages/95/d3/248c1efc71a0a8ed4e8e10b4b2266845d7dfc7a0ab64354afe049eaa1310/ruff-0.14.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d50d45d4553a3ebcbd33e7c5e0fe6ca4aafd9a9122492de357205c2c48f00775", size = 13076486, upload-time = "2025-11-21T14:25:35.601Z" },
{ url = "https://files.pythonhosted.org/packages/a5/19/b68d4563fe50eba4b8c92aa842149bb56dd24d198389c0ed12e7faff4f7d/ruff-0.14.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:118548dd121f8a21bfa8ab2c5b80e5b4aed67ead4b7567790962554f38e598ce", size = 13727563, upload-time = "2025-11-21T14:25:38.514Z" },
{ url = "https://files.pythonhosted.org/packages/47/ac/943169436832d4b0e867235abbdb57ce3a82367b47e0280fa7b4eabb7593/ruff-0.14.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:57256efafbfefcb8748df9d1d766062f62b20150691021f8ab79e2d919f7c11f", size = 15199755, upload-time = "2025-11-21T14:25:41.516Z" },
{ url = "https://files.pythonhosted.org/packages/c9/b9/288bb2399860a36d4bb0541cb66cce3c0f4156aaff009dc8499be0c24bf2/ruff-0.14.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff18134841e5c68f8e5df1999a64429a02d5549036b394fafbe410f886e1989d", size = 14850608, upload-time = "2025-11-21T14:25:44.428Z" },
{ url = "https://files.pythonhosted.org/packages/ee/b1/a0d549dd4364e240f37e7d2907e97ee80587480d98c7799d2d8dc7a2f605/ruff-0.14.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c4b7ec1e66a105d5c27bd57fa93203637d66a26d10ca9809dc7fc18ec58440", size = 14118754, upload-time = "2025-11-21T14:25:47.214Z" },
{ url = "https://files.pythonhosted.org/packages/13/ac/9b9fe63716af8bdfddfacd0882bc1586f29985d3b988b3c62ddce2e202c3/ruff-0.14.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167843a6f78680746d7e226f255d920aeed5e4ad9c03258094a2d49d3028b105", size = 13949214, upload-time = "2025-11-21T14:25:50.002Z" },
{ url = "https://files.pythonhosted.org/packages/12/27/4dad6c6a77fede9560b7df6802b1b697e97e49ceabe1f12baf3ea20862e9/ruff-0.14.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:16a33af621c9c523b1ae006b1b99b159bf5ac7e4b1f20b85b2572455018e0821", size = 14106112, upload-time = "2025-11-21T14:25:52.841Z" },
{ url = "https://files.pythonhosted.org/packages/6a/db/23e322d7177873eaedea59a7932ca5084ec5b7e20cb30f341ab594130a71/ruff-0.14.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1432ab6e1ae2dc565a7eea707d3b03a0c234ef401482a6f1621bc1f427c2ff55", size = 13035010, upload-time = "2025-11-21T14:25:55.536Z" },
{ url = "https://files.pythonhosted.org/packages/a8/9c/20e21d4d69dbb35e6a1df7691e02f363423658a20a2afacf2a2c011800dc/ruff-0.14.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c55cfbbe7abb61eb914bfd20683d14cdfb38a6d56c6c66efa55ec6570ee4e71", size = 13054082, upload-time = "2025-11-21T14:25:58.625Z" },
{ url = "https://files.pythonhosted.org/packages/66/25/906ee6a0464c3125c8d673c589771a974965c2be1a1e28b5c3b96cb6ef88/ruff-0.14.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:efea3c0f21901a685fff4befda6d61a1bf4cb43de16da87e8226a281d614350b", size = 13303354, upload-time = "2025-11-21T14:26:01.816Z" },
{ url = "https://files.pythonhosted.org/packages/4c/58/60577569e198d56922b7ead07b465f559002b7b11d53f40937e95067ca1c/ruff-0.14.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:344d97172576d75dc6afc0e9243376dbe1668559c72de1864439c4fc95f78185", size = 14054487, upload-time = "2025-11-21T14:26:05.058Z" },
{ url = "https://files.pythonhosted.org/packages/67/0b/8e4e0639e4cc12547f41cb771b0b44ec8225b6b6a93393176d75fe6f7d40/ruff-0.14.6-py3-none-win32.whl", hash = "sha256:00169c0c8b85396516fdd9ce3446c7ca20c2a8f90a77aa945ba6b8f2bfe99e85", size = 13013361, upload-time = "2025-11-21T14:26:08.152Z" },
{ url = "https://files.pythonhosted.org/packages/fb/02/82240553b77fd1341f80ebb3eaae43ba011c7a91b4224a9f317d8e6591af/ruff-0.14.6-py3-none-win_amd64.whl", hash = "sha256:390e6480c5e3659f8a4c8d6a0373027820419ac14fa0d2713bd8e6c3e125b8b9", size = 14432087, upload-time = "2025-11-21T14:26:10.891Z" },
{ url = "https://files.pythonhosted.org/packages/a5/1f/93f9b0fad9470e4c829a5bb678da4012f0c710d09331b860ee555216f4ea/ruff-0.14.6-py3-none-win_arm64.whl", hash = "sha256:d43c81fbeae52cfa8728d8766bbf46ee4298c888072105815b392da70ca836b2", size = 13520930, upload-time = "2025-11-21T14:26:13.951Z" },
]
[[package]]
name = "s3transfer"
version = "0.14.0"
version = "0.15.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/62/74/8d69dcb7a9efe8baa2046891735e5dfe433ad558ae23d9e3c14c633d1d58/s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125", size = 151547, upload-time = "2025-09-09T19:23:31.089Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/bb/940d6af975948c1cc18f44545ffb219d3c35d78ec972b42ae229e8e37e08/s3transfer-0.15.0.tar.gz", hash = "sha256:d36fac8d0e3603eff9b5bfa4282c7ce6feb0301a633566153cbd0b93d11d8379", size = 152185, upload-time = "2025-11-20T20:28:56.327Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/48/f0/ae7ca09223a81a1d890b2557186ea015f6e0502e9b8cb8e1813f1d8cfa4e/s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456", size = 85712, upload-time = "2025-09-09T19:23:30.041Z" },
{ url = "https://files.pythonhosted.org/packages/5f/e1/5ef25f52973aa12a19cf4e1375d00932d7fb354ffd310487ba7d44225c1a/s3transfer-0.15.0-py3-none-any.whl", hash = "sha256:6f8bf5caa31a0865c4081186689db1b2534cef721d104eb26101de4b9d6a5852", size = 85984, upload-time = "2025-11-20T20:28:55.046Z" },
]
[[package]]
@@ -648,14 +661,14 @@ wheels = [
[[package]]
name = "starlette"
version = "0.47.3"
version = "0.50.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/15/b9/cc3017f9a9c9b6e27c5106cc10cc7904653c3eec0729793aec10479dd669/starlette-0.47.3.tar.gz", hash = "sha256:6bc94f839cc176c4858894f1f8908f0ab79dfec1a6b8402f6da9be26ebea52e9", size = 2584144, upload-time = "2025-08-24T13:36:42.122Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/fd/901cfa59aaa5b30a99e16876f11abe38b59a1a2c51ffb3d7142bb6089069/starlette-0.47.3-py3-none-any.whl", hash = "sha256:89c0778ca62a76b826101e7c709e70680a1699ca7da6b44d38eb0a7e61fe4b51", size = 72991, upload-time = "2025-08-24T13:36:40.887Z" },
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
]
[[package]]
@@ -678,14 +691,14 @@ wheels = [
[[package]]
name = "types-requests"
version = "2.32.4.20250809"
version = "2.32.4.20250913"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ed/b0/9355adb86ec84d057fea765e4c49cce592aaf3d5117ce5609a95a7fc3dac/types_requests-2.32.4.20250809.tar.gz", hash = "sha256:d8060de1c8ee599311f56ff58010fb4902f462a1470802cf9f6ed27bc46c4df3", size = 23027, upload-time = "2025-08-09T03:17:10.664Z" }
sdist = { url = "https://files.pythonhosted.org/packages/36/27/489922f4505975b11de2b5ad07b4fe1dca0bca9be81a703f26c5f3acfce5/types_requests-2.32.4.20250913.tar.gz", hash = "sha256:abd6d4f9ce3a9383f269775a9835a4c24e5cd6b9f647d64f88aa4613c33def5d", size = 23113, upload-time = "2025-09-13T02:40:02.309Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/6f/ec0012be842b1d888d46884ac5558fd62aeae1f0ec4f7a581433d890d4b5/types_requests-2.32.4.20250809-py3-none-any.whl", hash = "sha256:f73d1832fb519ece02c85b1f09d5f0dd3108938e7d47e7f94bbfa18a6782b163", size = 20644, upload-time = "2025-08-09T03:17:09.716Z" },
{ url = "https://files.pythonhosted.org/packages/2a/20/9a227ea57c1285986c4cf78400d0a91615d25b24e257fd9e2969606bdfae/types_requests-2.32.4.20250913-py3-none-any.whl", hash = "sha256:78c9c1fffebbe0fa487a418e0fa5252017e9c60d1a2da394077f1780f655d7e1", size = 20658, upload-time = "2025-09-13T02:40:01.115Z" },
]
[[package]]
@@ -708,14 +721,14 @@ wheels = [
[[package]]
name = "typing-inspection"
version = "0.4.1"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" }
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" },
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
@@ -750,15 +763,15 @@ wheels = [
[[package]]
name = "uvicorn"
version = "0.35.0"
version = "0.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" },
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
]
[package.optional-dependencies]
@@ -774,64 +787,64 @@ standard = [
[[package]]
name = "uvloop"
version = "0.21.0"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" },
{ url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" },
{ url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" },
{ url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" },
{ url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" },
{ url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
]
[[package]]
name = "virtualenv"
version = "20.34.0"
version = "20.35.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/14/37fcdba2808a6c615681cd216fecae00413c9dab44fb2e57805ecf3eaee3/virtualenv-20.34.0.tar.gz", hash = "sha256:44815b2c9dee7ed86e387b842a84f20b93f7f417f95886ca1996a72a4138eb1a", size = 6003808, upload-time = "2025-08-13T14:24:07.464Z" }
sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/06/04c8e804f813cf972e3262f3f8584c232de64f0cde9f703b46cf53a45090/virtualenv-20.34.0-py3-none-any.whl", hash = "sha256:341f5afa7eee943e4984a9207c025feedd768baff6753cd660c857ceb3e36026", size = 5983279, upload-time = "2025-08-13T14:24:05.111Z" },
{ url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.0"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2a/9a/d451fcc97d029f5812e898fd30a53fd8c15c7bbd058fd75cfc6beb9bd761/watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575", size = 94406, upload-time = "2025-06-15T19:06:59.42Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/42/fae874df96595556a9089ade83be34a2e04f0f11eb53a8dbf8a8a5e562b4/watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30", size = 402004, upload-time = "2025-06-15T19:05:38.499Z" },
{ url = "https://files.pythonhosted.org/packages/fa/55/a77e533e59c3003d9803c09c44c3651224067cbe7fb5d574ddbaa31e11ca/watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a", size = 393671, upload-time = "2025-06-15T19:05:39.52Z" },
{ url = "https://files.pythonhosted.org/packages/05/68/b0afb3f79c8e832e6571022611adbdc36e35a44e14f129ba09709aa4bb7a/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc", size = 449772, upload-time = "2025-06-15T19:05:40.897Z" },
{ url = "https://files.pythonhosted.org/packages/ff/05/46dd1f6879bc40e1e74c6c39a1b9ab9e790bf1f5a2fe6c08b463d9a807f4/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b", size = 456789, upload-time = "2025-06-15T19:05:42.045Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ca/0eeb2c06227ca7f12e50a47a3679df0cd1ba487ea19cf844a905920f8e95/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895", size = 482551, upload-time = "2025-06-15T19:05:43.781Z" },
{ url = "https://files.pythonhosted.org/packages/31/47/2cecbd8694095647406645f822781008cc524320466ea393f55fe70eed3b/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a", size = 597420, upload-time = "2025-06-15T19:05:45.244Z" },
{ url = "https://files.pythonhosted.org/packages/d9/7e/82abc4240e0806846548559d70f0b1a6dfdca75c1b4f9fa62b504ae9b083/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b", size = 477950, upload-time = "2025-06-15T19:05:46.332Z" },
{ url = "https://files.pythonhosted.org/packages/25/0d/4d564798a49bf5482a4fa9416dea6b6c0733a3b5700cb8a5a503c4b15853/watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c", size = 451706, upload-time = "2025-06-15T19:05:47.459Z" },
{ url = "https://files.pythonhosted.org/packages/81/b5/5516cf46b033192d544102ea07c65b6f770f10ed1d0a6d388f5d3874f6e4/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b", size = 625814, upload-time = "2025-06-15T19:05:48.654Z" },
{ url = "https://files.pythonhosted.org/packages/0c/dd/7c1331f902f30669ac3e754680b6edb9a0dd06dea5438e61128111fadd2c/watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb", size = 622820, upload-time = "2025-06-15T19:05:50.088Z" },
{ url = "https://files.pythonhosted.org/packages/1b/14/36d7a8e27cd128d7b1009e7715a7c02f6c131be9d4ce1e5c3b73d0e342d8/watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9", size = 279194, upload-time = "2025-06-15T19:05:51.186Z" },
{ url = "https://files.pythonhosted.org/packages/25/41/2dd88054b849aa546dbeef5696019c58f8e0774f4d1c42123273304cdb2e/watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7", size = 292349, upload-time = "2025-06-15T19:05:52.201Z" },
{ url = "https://files.pythonhosted.org/packages/c8/cf/421d659de88285eb13941cf11a81f875c176f76a6d99342599be88e08d03/watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5", size = 283836, upload-time = "2025-06-15T19:05:53.265Z" },
{ url = "https://files.pythonhosted.org/packages/45/10/6faf6858d527e3599cc50ec9fcae73590fbddc1420bd4fdccfebffeedbc6/watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1", size = 400343, upload-time = "2025-06-15T19:05:54.252Z" },
{ url = "https://files.pythonhosted.org/packages/03/20/5cb7d3966f5e8c718006d0e97dfe379a82f16fecd3caa7810f634412047a/watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339", size = 392916, upload-time = "2025-06-15T19:05:55.264Z" },
{ url = "https://files.pythonhosted.org/packages/8c/07/d8f1176328fa9e9581b6f120b017e286d2a2d22ae3f554efd9515c8e1b49/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633", size = 449582, upload-time = "2025-06-15T19:05:56.317Z" },
{ url = "https://files.pythonhosted.org/packages/66/e8/80a14a453cf6038e81d072a86c05276692a1826471fef91df7537dba8b46/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011", size = 456752, upload-time = "2025-06-15T19:05:57.359Z" },
{ url = "https://files.pythonhosted.org/packages/5a/25/0853b3fe0e3c2f5af9ea60eb2e781eade939760239a72c2d38fc4cc335f6/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670", size = 481436, upload-time = "2025-06-15T19:05:58.447Z" },
{ url = "https://files.pythonhosted.org/packages/fe/9e/4af0056c258b861fbb29dcb36258de1e2b857be4a9509e6298abcf31e5c9/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf", size = 596016, upload-time = "2025-06-15T19:05:59.59Z" },
{ url = "https://files.pythonhosted.org/packages/c5/fa/95d604b58aa375e781daf350897aaaa089cff59d84147e9ccff2447c8294/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4", size = 476727, upload-time = "2025-06-15T19:06:01.086Z" },
{ url = "https://files.pythonhosted.org/packages/65/95/fe479b2664f19be4cf5ceeb21be05afd491d95f142e72d26a42f41b7c4f8/watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20", size = 451864, upload-time = "2025-06-15T19:06:02.144Z" },
{ url = "https://files.pythonhosted.org/packages/d3/8a/3c4af14b93a15ce55901cd7a92e1a4701910f1768c78fb30f61d2b79785b/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef", size = 625626, upload-time = "2025-06-15T19:06:03.578Z" },
{ url = "https://files.pythonhosted.org/packages/da/f5/cf6aa047d4d9e128f4b7cde615236a915673775ef171ff85971d698f3c2c/watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb", size = 622744, upload-time = "2025-06-15T19:06:05.066Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
]
[[package]]