Compare commits

..

22 Commits

Author SHA1 Message Date
Elisiário Couto
31382cab7a Fix notification channels returning null by connecting API to real implementations
- Update notification service to handle both old (api-key/chat-id) and new (token/chat_id) config formats
- Connect API service to actual Discord/Telegram notification implementations from CLI codebase
- Fix API routes to properly detect configured services using correct config keys
- Telegram notifications now work correctly, Discord properly shows as not configured
2025-09-09 16:10:08 +01:00
Elisiário Couto
223fd06072 fix: use account status for balance records instead of hardcoded 'active'
- Modified sync service to pass account status to balance persistence
- Updated database service to use account_status from balance data
- Fixed 8 balance records that had incorrect 'active' status
- All balance records now have consistent 'READY' status matching accounts
- Future balance records will inherit correct status from account data
2025-09-09 15:47:37 +01:00
Elisiário Couto
4315a6f747 fix: merge account details into balance data to prevent unknown/N/A values
- Modified sync service to include institution_id and iban in balance persistence
- Fixed data flow issue where balance records were missing account metadata
- Prevents future balance records from having 'unknown' bank or 'N/A' IBAN
- Successfully fixed 8 existing records with one-off script
2025-09-09 15:32:32 +01:00
Elisiário Couto
01744d56f0 feat: add automatic balance timestamp migration mechanism
- Add migration system to convert Unix timestamps to datetime strings
- Integrate migration into FastAPI lifespan for automatic startup execution
- Update balance persistence to use consistent ISO datetime format
- Fix mixed timestamp types causing API parsing issues
- Add comprehensive error handling and progress logging
- Successfully migrated 7522 balance records to consistent format
2025-09-09 15:22:22 +01:00
Elisiário Couto
cb9aa4540f Fix frontend health check to properly detect API unavailability
- Add isError to useQuery destructuring to handle network errors
- Improve health check query function to throw on HTTP errors
- Update status display logic to show 'Disconnected' when API is unreachable
- Ensure proper error handling for both network failures and HTTP status errors
2025-09-09 15:07:40 +01:00
Elisiário Couto
0315ba4bc6 feat: implement dynamic API connection status
- Move health endpoint from /health to /api/v1/health
- Update frontend Dashboard to show real connection status
- Add health check query that refreshes every 30 seconds
- Display connected/disconnected status with appropriate icons
- Show loading state while checking connection
2025-09-08 23:58:38 +01:00
Elisiário Couto
476a28d1f4 fix: resolve 404 balances endpoint and currency formatting errors
- Add missing /api/v1/balances endpoint to backend
- Update frontend Account type to match backend AccountDetails model
- Add currency validation with EUR fallback in formatCurrency function
- Update AccountsOverview, TransactionsList, and Dashboard components
- Fix balance calculations to use balances array structure
- All pre-commit checks pass
2025-09-08 23:45:31 +01:00
Elisiário Couto
95a5b27e04 Add hide_missing_ids filter to transaction queries
- Add hide_missing_ids parameter to database functions to filter out transactions without internalTransactionId
- Update API routes to support the new filter parameter
- Update unit tests to include the new parameter
- Add opencode.json configuration file
2025-09-08 23:38:14 +01:00
Elisiário Couto
2c7ecfb912 Create temporary database for testing instead of using configured database
- Add temp_db_path fixture to create temporary database file for tests
- Add mock_db_path fixture to mock Path.home() for database path resolution
- Update all account API tests to use temporary database
- Ensure test database is properly cleaned up after tests
- Prevent test data from polluting the actual configured database
- All 94 tests still pass with temporary database setup
2025-09-08 19:51:19 +01:00
Elisiário Couto
12052ff96f Remove GoCardless fallback from /accounts endpoints
- Remove GoCardless API calls from /api/v1/accounts and /api/v1/accounts/{account_id}
- Accounts endpoints now rely exclusively on database data
- Return 404 for accounts not found in database
- Update tests to mock database service instead of GoCardless API
- Remove unused GoCardless imports from transactions routes
- Preserve GoCardless usage in sync process and /banks endpoints
- Fix code formatting and remove unused imports
2025-09-08 19:37:49 +01:00
Elisiário Couto
da456b4c80 Fix api in lib folder. 2025-09-08 19:08:30 +01:00
Elisiário Couto
a52a6274c0 Claude experiments 2025-09-08 18:48:45 +01:00
Elisiário Couto
1bc259bd3e fix(cli): Show transactions without internal ID when using --full. 2025-09-03 23:40:54 +01:00
Elisiário Couto
fad426aee8 fix: Make internal transcation ID optional. 2025-09-03 23:21:11 +01:00
Elisiário Couto
7f59e634a1 feat: Implement database-first architecture to minimize GoCardless API calls
- Updated SQLite database to use ~/.config/leggen/leggen.db path
- Added comprehensive SQLite read functions with filtering and pagination
- Implemented async database service with SQLite integration
- Modified API routes to read transactions/balances from database instead of GoCardless
- Added performance indexes for transactions and balances tables
- Created comprehensive test suites for new functionality (94 tests total)
- Reduced GoCardless API calls by ~80-90% for typical usage patterns

This implements the database-first architecture where:
- Sync operations still call GoCardless APIs to populate local database
- Account details continue using GoCardless for real-time data
- Transaction and balance queries read from local SQLite database
- Bank management operations continue using GoCardless APIs

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 23:21:11 +01:00
Elisiário Couto
011c792e89 feat: Add mypy to pre-commit. 2025-09-03 23:21:11 +01:00
Elisiário Couto
4e4c80be48 chore: Implement code review suggestions and format code. 2025-09-03 23:21:11 +01:00
Elisiário Couto
4c10307e12 refactor: Remove MongoDB support, simplify to SQLite-only architecture
- Remove pymongo dependency from pyproject.toml and update lock file
- Delete leggen/database/mongo.py implementation file
- Simplify DatabaseService to SQLite-only operations with default enabled
- Update CLI database utilities to remove MongoDB logic and imports
- Update documentation and configuration examples to reflect SQLite-only approach
- Update test fixtures and configuration tests for simplified database setup
- Change SQLite default from false to true for better user experience

This simplification reduces complexity, removes external database dependencies,
and focuses on the robust built-in SQLite solution. All 46 tests passing.

Benefits:
- Simpler architecture with single database solution
- Reduced dependencies (removed pymongo and dnspython)
- Cleaner configuration with less complexity
- Easier maintenance with fewer code paths

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 23:21:11 +01:00
Elisiário Couto
de67ce9ec0 feat: Add comprehensive test suite with 46 passing tests
- Add pytest configuration in pyproject.toml with markers and async support
- Create shared test fixtures in tests/conftest.py for config, auth, and sample data
- Implement unit tests for all major components:
  * Configuration management (11 tests) - TOML loading/saving, singleton pattern
  * FastAPI API endpoints (12 tests) - Banks, accounts, transactions with mocks
  * CLI API client (11 tests) - HTTP client integration and error handling
  * Background scheduler (12 tests) - APScheduler job management and async ops

- Fix GoCardless API authentication mocking by adding token endpoints
- Resolve TOML file writing issues (binary vs text mode for tomli_w)
- Add comprehensive testing documentation to README
- Update code structure documentation to include test organization

Testing framework includes:
- respx for HTTP request mocking
- pytest-asyncio for async test support
- pytest-mock for advanced mocking capabilities
- requests-mock for CLI HTTP client testing
- Realistic test data fixtures for banks, accounts, and transactions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 23:21:11 +01:00
Elisiário Couto
d99b1c73db docs: Update README for new web architecture
Major README overhaul to reflect the transformation to web-ready architecture:

New Content:
- Web architecture description with FastAPI backend (leggend) and CLI
- Enhanced feature list with API & integration capabilities
- Quick start guide with Docker Compose and local development options
- Comprehensive usage examples for both API service and CLI
- Complete API endpoint documentation
- Development setup and code structure explanation

Key Improvements:
- Updated installation instructions with uv and Docker options
- Added leggend service commands with --reload flag
- Enhanced CLI examples with new options (--wait, --force, --full)
- API endpoint documentation with all major routes
- Configuration examples with scheduler and notification settings
- Development workflow and contribution guidelines

The README now accurately represents the current v0.6.11 capabilities
and provides clear guidance for both users and developers.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 23:21:11 +01:00
Elisiário Couto
69e381fffe fix: Implement proper GoCardless authentication and add dev features
Authentication Fixes:
- Implement proper async GoCardless token management in leggend service
- Add automatic token refresh and creation for expired/missing tokens
- Unify auth.json storage path between CLI and API (~/.config/leggen/)
- Fix 401 Unauthorized errors when accessing GoCardless API

Development Enhancements:
- Add --reload flag to leggend for automatic file watching and restart
- Add --host and --port options for flexible service binding
- Include both leggend/ and leggen/ directories in reload watching
- Improve development workflow with hot reloading

Configuration Consistency:
- Standardize config path to ~/.config/leggen/config.toml for both CLI and API
- Ensure auth.json is stored in same location as main config
- Add httpx dependency for async HTTP requests in leggend service

Verified working: leggen status command successfully authenticates
and retrieves bank/account data via leggend API service.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 23:21:11 +01:00
Elisiário Couto
f6ebb98de3 feat: Transform to web architecture with FastAPI backend
This major update transforms leggen from CLI-only to a web-ready
architecture while maintaining full CLI compatibility.

New Features:
- FastAPI backend service (leggend) with comprehensive REST API
- Background job scheduler with configurable cron (replaces Ofelia)
- All CLI commands refactored to use API endpoints
- Docker configuration updated for new services
- API client with health checks and error handling

API Endpoints:
- /api/v1/banks/* - Bank connections and institutions
- /api/v1/accounts/* - Account management and balances
- /api/v1/transactions/* - Transaction retrieval with filtering
- /api/v1/sync/* - Manual sync and scheduler configuration
- /api/v1/notifications/* - Notification settings management

CLI Enhancements:
- New --api-url option and LEGGEND_API_URL environment variable
- Enhanced sync command with --wait and --force options
- Improved transactions command with --full and --limit options
- Automatic fallback and health checking

Breaking Changes:
- compose.yml structure updated (leggend service added)
- Ofelia scheduler removed (internal scheduler used instead)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-03 23:21:11 +01:00
47 changed files with 992 additions and 1620 deletions

View File

@@ -1,5 +1,3 @@
.git/
data/
docker-compose.dev.yml
frontend/node_modules/
.venv/

View File

@@ -42,94 +42,44 @@ jobs:
- name: Publish package
run: uv publish
push-docker-backend:
push-docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: elisiariocouto
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta backend
id: meta-backend
uses: docker/metadata-action@v5
with:
flavor: |
latest=false
# list of Docker images to use as base name for tags
images: |
elisiariocouto/leggen
ghcr.io/elisiariocouto/leggen
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push backend
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
push-docker-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: elisiariocouto
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
flavor: |
latest=false
# list of Docker images to use as base name for tags
images: |
elisiariocouto/leggen
ghcr.io/elisiariocouto/leggen
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=tag,suffix=-frontend
type=semver,pattern={{version}},suffix=-frontend
type=semver,pattern={{major}}.{{minor}},suffix=-frontend
type=raw,value=latest-frontend
- name: Build and push frontend
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: elisiariocouto
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
# list of Docker images to use as base name for tags
images: |
elisiariocouto/leggen
ghcr.io/elisiariocouto/leggen
# generate Docker tags based on the following events/attributes
tags: |
type=ref,event=tag
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -1,42 +0,0 @@
# Agent Guidelines for Leggen
## Build/Lint/Test Commands
### Frontend (React/TypeScript)
- **Dev server**: `cd frontend && npm run dev`
- **Build**: `cd frontend && npm run build`
- **Lint**: `cd frontend && npm run lint`
### Backend (Python)
- **Lint**: `uv run ruff check .`
- **Format**: `uv run ruff format .`
- **Type check**: `uv run mypy leggen leggend --check-untyped-defs`
- **All checks**: `uv run pre-commit run --all-files`
- **Run all tests**: `uv run pytest`
- **Run single test**: `uv run pytest tests/unit/test_api_accounts.py::TestAccountsAPI::test_get_all_accounts_success -v`
- **Run tests by marker**: `uv run pytest -m "api"` or `uv run pytest -m "unit"`
## Code Style Guidelines
### Python
- **Imports**: Standard library → Third-party → Local (blank lines between groups)
- **Naming**: snake_case for variables/functions, PascalCase for classes
- **Types**: Use type hints for all function parameters and return values
- **Error handling**: Use specific exceptions, loguru for logging
- **Path handling**: Use `pathlib.Path` instead of `os.path`
- **CLI**: Use Click framework with proper option decorators
### TypeScript/React
- **Imports**: React hooks first, then third-party, then local components/types
- **Naming**: PascalCase for components, camelCase for variables/functions
- **Types**: Use `import type` for type-only imports, define interfaces/types
- **Styling**: Tailwind CSS with `clsx` utility for conditional classes
- **Icons**: lucide-react with consistent naming
- **Data fetching**: @tanstack/react-query with proper error handling
- **Components**: Functional components with hooks, proper TypeScript typing
### General
- **Formatting**: ruff for Python, ESLint for TypeScript
- **Commits**: Use conventional commits, run pre-commit hooks before pushing
- Avoid including specific numbers, counts, or data-dependent information that may become outdated
- **Security**: Never log sensitive data, use environment variables for secrets

View File

@@ -1,154 +1,4 @@
## 2025.9.2 (2025/09/10)
### Bug Fixes
- **ci:** Prevent duplicate Docker tags in GitHub Actions ([53e08e8e](https://github.com/elisiariocouto/leggen/commit/53e08e8e4b909b4895b5a447cfbce515893d31a5))
### Features
- **docker:** Add Docker containerization for React frontend ([84fe79b3](https://github.com/elisiariocouto/leggen/commit/84fe79b37b4f154fa0758f8d037cdba0d166dd3b))
## 2025.9.2 (2025/09/10)
### Bug Fixes
- **ci:** Prevent duplicate Docker tags in GitHub Actions ([53e08e8e](https://github.com/elisiariocouto/leggen/commit/53e08e8e4b909b4895b5a447cfbce515893d31a5))
### Features
- **docker:** Add Docker containerization for React frontend ([84fe79b3](https://github.com/elisiariocouto/leggen/commit/84fe79b37b4f154fa0758f8d037cdba0d166dd3b))
## 2025.9.1 (2025/09/09)
### Bug Fixes
- Handle duplicate transactionId values in migration ([8fabaf7b](https://github.com/elisiariocouto/leggen/commit/8fabaf7b86fde921c61266568ecb0403d3102671))
### Miscellaneous Tasks
- Improve AGENTS.md. ([3270dc45](https://github.com/elisiariocouto/leggen/commit/3270dc4585e6b33d55aef0deecd849753d36fa74))
### Refactor
- Remove unused hide_missing_ids functionality ([8006e5e1](https://github.com/elisiariocouto/leggen/commit/8006e5e1f6373aae39d3c38068d694e142bc85a5))
## 2025.9.1 (2025/09/09)
### Bug Fixes
- Handle duplicate transactionId values in migration ([8fabaf7b](https://github.com/elisiariocouto/leggen/commit/8fabaf7b86fde921c61266568ecb0403d3102671))
### Miscellaneous Tasks
- Improve AGENTS.md. ([3270dc45](https://github.com/elisiariocouto/leggen/commit/3270dc4585e6b33d55aef0deecd849753d36fa74))
### Refactor
- Remove unused hide_missing_ids functionality ([8006e5e1](https://github.com/elisiariocouto/leggen/commit/8006e5e1f6373aae39d3c38068d694e142bc85a5))
## 2025.9.0 (2025/09/09)
### Bug Fixes
- **cli:** Show transactions without internal ID when using --full. ([46f3f5c4](https://github.com/elisiariocouto/leggen/commit/46f3f5c4984224c3f4b421e1a06dcf44d4f211e0))
- Do not install development dependencies. ([73d6bd32](https://github.com/elisiariocouto/leggen/commit/73d6bd32dbc59608ef1472dc65d9e18450f00896))
- Implement proper GoCardless authentication and add dev features ([f0fee4fd](https://github.com/elisiariocouto/leggen/commit/f0fee4fd82e1c788614d73fcd0075f5e16976650))
- Make internal transcation ID optional. ([6bce7eb6](https://github.com/elisiariocouto/leggen/commit/6bce7eb6be5f9a5286eb27e777fbf83a6b1c5f8d))
- Resolve 404 balances endpoint and currency formatting errors ([417b7753](https://github.com/elisiariocouto/leggen/commit/417b77539fc275493d55efb29f92abcea666b210))
- Merge account details into balance data to prevent unknown/N/A values ([eaaea6e4](https://github.com/elisiariocouto/leggen/commit/eaaea6e4598e9c81997573e19f4ef1c58ebe320f))
- Use account status for balance records instead of hardcoded 'active' ([541cb262](https://github.com/elisiariocouto/leggen/commit/541cb262ee5783eedf2b154c148c28ec89845da5))
### Documentation
- Update README for new web architecture ([4018b263](https://github.com/elisiariocouto/leggen/commit/4018b263f27c2b59af31428d7a0878280a291c85))
### Features
- Transform to web architecture with FastAPI backend ([91f53b35](https://github.com/elisiariocouto/leggen/commit/91f53b35b18740869ee9cebfac394db2e12db099))
- Add comprehensive test suite with 46 passing tests ([34e793c7](https://github.com/elisiariocouto/leggen/commit/34e793c75c8df1e57ea240b92ccf0843a80c2a14))
- Add mypy to pre-commit. ([ec8ef834](https://github.com/elisiariocouto/leggen/commit/ec8ef8346add878f3ff4e8ed928b952d9b5dd584))
- Implement database-first architecture to minimize GoCardless API calls ([155c3055](https://github.com/elisiariocouto/leggen/commit/155c30559f4cacd76ef01e50ec29ee436d3f9d56))
- Implement dynamic API connection status ([cb2e70e4](https://github.com/elisiariocouto/leggen/commit/cb2e70e42d1122e9c2e5420b095aeb1e55454c24))
- Add automatic balance timestamp migration mechanism ([34501f5f](https://github.com/elisiariocouto/leggen/commit/34501f5f0d3b3dff68364b60be77bfb99071b269))
- Improve notification filters configuration format ([2191fe90](https://github.com/elisiariocouto/leggen/commit/2191fe906659f4fd22c25b6cb9fbb95c03472f00))
- Add notifications view and update branding ([abf39abe](https://github.com/elisiariocouto/leggen/commit/abf39abe74b75d8cb980109fbcbdd940066cc90b))
- Make API URL configurable and improve code quality ([37949a4e](https://github.com/elisiariocouto/leggen/commit/37949a4e1f25a2656f6abef75ba942f7b205c130))
- Change versioning scheme to calver. ([f2e05484](https://github.com/elisiariocouto/leggen/commit/f2e05484dc688409b6db6bd16858b066d3a16976))
### Miscellaneous Tasks
- Implement code review suggestions and format code. ([de3da84d](https://github.com/elisiariocouto/leggen/commit/de3da84dffd83e0b232cf76836935a66eb704aee))
### Refactor
- Remove MongoDB support, simplify to SQLite-only architecture ([47164e85](https://github.com/elisiariocouto/leggen/commit/47164e854600dfcac482449769b1d2e55c842570))
- Remove unused amount_threshold and keywords from notification filters ([95709978](https://github.com/elisiariocouto/leggen/commit/957099786cb0e48c9ffbda11b3172ec9fae9ac37))
## 2025.9.0 (2025/09/09)
### Bug Fixes
- **cli:** Show transactions without internal ID when using --full. ([46f3f5c4](https://github.com/elisiariocouto/leggen/commit/46f3f5c4984224c3f4b421e1a06dcf44d4f211e0))
- Do not install development dependencies. ([73d6bd32](https://github.com/elisiariocouto/leggen/commit/73d6bd32dbc59608ef1472dc65d9e18450f00896))
- Implement proper GoCardless authentication and add dev features ([f0fee4fd](https://github.com/elisiariocouto/leggen/commit/f0fee4fd82e1c788614d73fcd0075f5e16976650))
- Make internal transcation ID optional. ([6bce7eb6](https://github.com/elisiariocouto/leggen/commit/6bce7eb6be5f9a5286eb27e777fbf83a6b1c5f8d))
- Resolve 404 balances endpoint and currency formatting errors ([417b7753](https://github.com/elisiariocouto/leggen/commit/417b77539fc275493d55efb29f92abcea666b210))
- Merge account details into balance data to prevent unknown/N/A values ([eaaea6e4](https://github.com/elisiariocouto/leggen/commit/eaaea6e4598e9c81997573e19f4ef1c58ebe320f))
- Use account status for balance records instead of hardcoded 'active' ([541cb262](https://github.com/elisiariocouto/leggen/commit/541cb262ee5783eedf2b154c148c28ec89845da5))
### Documentation
- Update README for new web architecture ([4018b263](https://github.com/elisiariocouto/leggen/commit/4018b263f27c2b59af31428d7a0878280a291c85))
### Features
- Transform to web architecture with FastAPI backend ([91f53b35](https://github.com/elisiariocouto/leggen/commit/91f53b35b18740869ee9cebfac394db2e12db099))
- Add comprehensive test suite with 46 passing tests ([34e793c7](https://github.com/elisiariocouto/leggen/commit/34e793c75c8df1e57ea240b92ccf0843a80c2a14))
- Add mypy to pre-commit. ([ec8ef834](https://github.com/elisiariocouto/leggen/commit/ec8ef8346add878f3ff4e8ed928b952d9b5dd584))
- Implement database-first architecture to minimize GoCardless API calls ([155c3055](https://github.com/elisiariocouto/leggen/commit/155c30559f4cacd76ef01e50ec29ee436d3f9d56))
- Implement dynamic API connection status ([cb2e70e4](https://github.com/elisiariocouto/leggen/commit/cb2e70e42d1122e9c2e5420b095aeb1e55454c24))
- Add automatic balance timestamp migration mechanism ([34501f5f](https://github.com/elisiariocouto/leggen/commit/34501f5f0d3b3dff68364b60be77bfb99071b269))
- Improve notification filters configuration format ([2191fe90](https://github.com/elisiariocouto/leggen/commit/2191fe906659f4fd22c25b6cb9fbb95c03472f00))
- Add notifications view and update branding ([abf39abe](https://github.com/elisiariocouto/leggen/commit/abf39abe74b75d8cb980109fbcbdd940066cc90b))
- Make API URL configurable and improve code quality ([37949a4e](https://github.com/elisiariocouto/leggen/commit/37949a4e1f25a2656f6abef75ba942f7b205c130))
- Change versioning scheme to calver. ([f2e05484](https://github.com/elisiariocouto/leggen/commit/f2e05484dc688409b6db6bd16858b066d3a16976))
### Miscellaneous Tasks
- Implement code review suggestions and format code. ([de3da84d](https://github.com/elisiariocouto/leggen/commit/de3da84dffd83e0b232cf76836935a66eb704aee))
### Refactor
- Remove MongoDB support, simplify to SQLite-only architecture ([47164e85](https://github.com/elisiariocouto/leggen/commit/47164e854600dfcac482449769b1d2e55c842570))
- Remove unused amount_threshold and keywords from notification filters ([95709978](https://github.com/elisiariocouto/leggen/commit/957099786cb0e48c9ffbda11b3172ec9fae9ac37))
## 0.6.11 (2025/02/23)
### Bug Fixes

69
CLAUDE.md Normal file
View File

@@ -0,0 +1,69 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Leggen is an Open Banking CLI tool built in Python that connects to banks using the GoCardless Open Banking API. It allows users to sync bank transactions to SQLite/MongoDB databases, visualize data with NocoDB, and send notifications based on transaction filters.
## Development Commands
- **Install dependencies**: `uv sync` (uses uv package manager)
- **Run locally**: `uv run leggen --help`
- **Lint code**: `ruff check` and `ruff format` (configured in pyproject.toml)
- **Build Docker image**: `docker build -t leggen .`
- **Run with Docker Compose**: `docker compose up -d`
## Architecture
### Core Structure
- `leggen/main.py` - Main CLI entry point using Click framework with custom command loading
- `leggen/commands/` - CLI command implementations (balances, sync, transactions, etc.)
- `leggen/utils/` - Core utilities for authentication, database operations, network requests, and notifications
- `leggen/database/` - Database adapters for SQLite and MongoDB
- `leggen/notifications/` - Discord and Telegram notification handlers
### Key Components
**Configuration System**:
- Uses TOML configuration files (default: `~/.config/leggen/config.toml`)
- Configuration loaded via `leggen/utils/config.py`
- Supports GoCardless API credentials, database settings, and notification configurations
**Authentication & API**:
- GoCardless Open Banking API integration in `leggen/utils/gocardless.py`
- Token-based authentication via `leggen/utils/auth.py`
- Network utilities in `leggen/utils/network.py`
**Database Operations**:
- Dual database support: SQLite (`database/sqlite.py`) and MongoDB (`database/mongo.py`)
- Transaction persistence and balance tracking via `utils/database.py`
- Data storage patterns follow bank account and transaction models
**Command Architecture**:
- Dynamic command loading system in `main.py` with support for command groups
- Commands organized as modules with individual click decorators
- Bank management commands grouped under `commands/bank/`
### Data Flow
1. Configuration loaded from TOML file
2. GoCardless API authentication and bank requisition management
3. Account and transaction data retrieval from banks
4. Data persistence to configured databases (SQLite/MongoDB)
5. Optional notifications sent via Discord/Telegram based on filters
6. Data visualization available through NocoDB integration
## Docker & Deployment
The project uses multi-stage Docker builds with uv for dependency management. The compose.yml includes:
- Main leggen service with sync scheduling via Ofelia
- NocoDB for data visualization
- Optional MongoDB with mongo-express admin interface
## Configuration Requirements
All operations require a valid `config.toml` file with GoCardless API credentials. The configuration structure includes sections for:
- `[gocardless]` - API credentials and endpoint
- `[database]` - Storage backend selection
- `[notifications]` - Discord/Telegram webhook settings
- `[filters]` - Transaction matching patterns for notifications

View File

@@ -6,28 +6,25 @@ WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=uv.lock,target=uv.lock \
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-editable
uv sync --frozen --no-install-project --no-editable
COPY . /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-editable --no-group dev
uv sync --frozen --no-editable --no-group dev
FROM python:3.13-alpine
LABEL org.opencontainers.image.source="https://github.com/elisiariocouto/leggen"
LABEL org.opencontainers.image.authors="Elisiário Couto <elisiario@couto.io>"
LABEL org.opencontainers.image.licenses="MIT"
LABEL org.opencontainers.image.title="Leggend API"
LABEL org.opencontainers.image.description="Open Banking API for Leggen"
LABEL org.opencontainers.image.title="leggen"
LABEL org.opencontainers.image.description="An Open Banking CLI"
LABEL org.opencontainers.image.url="https://github.com/elisiariocouto/leggen"
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder --chown=app:app /app/.venv /app/.venv
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s CMD wget -q --spider http://127.0.0.1:8000/api/v1/health || exit 1
CMD ["/app/.venv/bin/leggend"]
ENTRYPOINT ["/app/.venv/bin/leggen"]

91
PROJECT.md Normal file
View File

@@ -0,0 +1,91 @@
# Leggen Web Transformation Project
## Overview
Transform leggen from CLI-only to web application with FastAPI backend (`leggend`) and SvelteKit frontend (`leggen-web`).
## Progress Tracking
### ✅ Phase 1: FastAPI Backend (`leggend`)
#### 1.1 Core Structure
- [x] Create directory structure (`leggend/`, `api/`, `services/`, etc.)
- [x] Add FastAPI dependencies to pyproject.toml
- [x] Create configuration management system
- [x] Set up FastAPI main application
- [x] Create Pydantic models for API responses
#### 1.2 API Endpoints
- [x] Banks API (`/api/v1/banks/`)
- [x] `GET /institutions` - List available banks
- [x] `POST /connect` - Connect to bank
- [x] `GET /status` - Bank connection status
- [x] Accounts API (`/api/v1/accounts/`)
- [x] `GET /` - List all accounts
- [x] `GET /{id}/balances` - Account balances
- [x] `GET /{id}/transactions` - Account transactions
- [x] Sync API (`/api/v1/sync/`)
- [x] `POST /` - Trigger manual sync
- [x] `GET /status` - Sync status
- [x] Notifications API (`/api/v1/notifications/`)
- [x] `GET/POST/PUT /settings` - Manage notification settings
#### 1.3 Background Jobs
- [x] Implement APScheduler for sync scheduling
- [x] Replace Ofelia with internal Python scheduler
- [x] Migrate existing sync logic from CLI
### ⏳ Phase 2: SvelteKit Frontend (`leggen-web`)
#### 2.1 Project Setup
- [ ] Create SvelteKit project structure
- [ ] Set up API client for backend communication
- [ ] Design component architecture
#### 2.2 UI Components
- [ ] Dashboard with account overview
- [ ] Bank connection wizard
- [ ] Transaction history and filtering
- [ ] Settings management
- [ ] Real-time sync status
### ✅ Phase 3: CLI Refactoring
#### 3.1 API Client Integration
- [x] Create HTTP client for FastAPI calls
- [x] Refactor existing commands to use APIs
- [x] Maintain CLI user experience
- [x] Add API URL configuration option
### ✅ Phase 4: Docker & Deployment
#### 4.1 Container Setup
- [x] Create Dockerfile for `leggend` service
- [x] Update docker-compose.yml with `leggend` service
- [x] Remove Ofelia dependency (scheduler now internal)
- [ ] Create Dockerfile for `leggen-web` (deferred - not implementing web UI yet)
## Current Status
**Active Phase**: Phase 2 - CLI Integration Complete
**Last Updated**: 2025-09-01
**Completion**: ~80% (FastAPI backend and CLI refactoring complete)
## Next Steps (Future Enhancements)
1. Implement SvelteKit web frontend
2. Add real-time WebSocket support for sync status
3. Implement user authentication and multi-user support
4. Add more comprehensive error handling and logging
5. Implement database migrations for schema changes
## Recent Achievements
- ✅ Complete FastAPI backend with all major endpoints
- ✅ Configurable background job scheduler (replaces Ofelia)
- ✅ CLI successfully refactored to use API endpoints
- ✅ Docker configuration updated for new architecture
- ✅ Maintained backward compatibility and user experience
## Architecture Decisions
- **FastAPI**: For high-performance async API backend
- **APScheduler**: For internal job scheduling (replacing Ofelia)
- **SvelteKit**: For modern, reactive frontend
- **Existing Logic**: Reuse all business logic from current CLI commands
- **Configuration**: Centralize in `leggend` service, maintain TOML compatibility

104
README.md
View File

@@ -2,7 +2,9 @@
An Open Banking CLI and API service for managing bank connections and transactions.
This tool provides **FastAPI backend service** (`leggend`), a **React Web Interface** and a **command-line interface** (`leggen`) to connect to banks using the GoCardless Open Banking API.
This tool provides both a **FastAPI backend service** (`leggend`) and a **command-line interface** (`leggen`) to connect to banks using the GoCardless Open Banking API.
**New in v0.6.11**: Web-ready architecture with FastAPI backend, enhanced CLI, and background job scheduling.
Having your bank data accessible through both CLI and REST API gives you the power to backup, analyze, create reports, and integrate with other applications.
@@ -16,11 +18,8 @@ Having your bank data accessible through both CLI and REST API gives you the pow
### 📦 Storage
- [SQLite](https://www.sqlite.org): for storing transactions, simple and easy to use
### Frontend
- [React](https://reactjs.org/): Modern web interface with TypeScript
- [Vite](https://vitejs.dev/): Fast build tool and development server
- [Tailwind CSS](https://tailwindcss.com/): Utility-first CSS framework
- [TanStack Query](https://tanstack.com/query): Powerful data synchronization for React
### 📊 Visualization
- [NocoDB](https://github.com/nocodb/nocodb): for visualizing and querying transactions, a simple and easy to use interface for SQLite
## ✨ Features
@@ -40,6 +39,8 @@ Having your bank data accessible through both CLI and REST API gives you the pow
### 📡 API & Integration
- **REST API**: Complete FastAPI backend with comprehensive endpoints
- **CLI Interface**: Enhanced command-line tools with new options
- **Health Checks**: Service monitoring and dependency management
- **Auto-reload**: Development mode with file watching
### 🔔 Notifications & Monitoring
- Discord and Telegram notifications for filtered transactions
@@ -47,6 +48,12 @@ Having your bank data accessible through both CLI and REST API gives you the pow
- Account expiry notifications and status alerts
- Comprehensive logging and error handling
### 📊 Visualization & Analysis
- NocoDB integration for visual data exploration
- Transaction statistics and reporting
- Account balance tracking over time
- Export capabilities for further analysis
## 🚀 Quick Start
### Prerequisites
@@ -56,7 +63,7 @@ Having your bank data accessible through both CLI and REST API gives you the pow
### Installation Options
#### Option 1: Docker Compose (Recommended)
The easiest way to get started is with Docker Compose, which includes both the React frontend and FastAPI backend:
The easiest way to get started is with Docker Compose:
```bash
# Clone the repository
@@ -64,41 +71,13 @@ git clone https://github.com/elisiariocouto/leggen.git
cd leggen
# Create your configuration
mkdir -p data && cp config.example.toml data/config.toml
# Edit data/config.toml with your GoCardless credentials
mkdir -p leggen && cp config.example.toml leggen/config.toml
# Edit leggen/config.toml with your GoCardless credentials
# Start all services (frontend + backend)
# Start all services
docker compose up -d
# Access the web interface at http://localhost:3000
# API is available at http://localhost:8000
```
#### Production Deployment
For production deployment using published Docker images:
```bash
# Clone the repository
git clone https://github.com/elisiariocouto/leggen.git
cd leggen
# Create your configuration
mkdir -p data && cp config.example.toml data/config.toml
# Edit data/config.toml with your GoCardless credentials
# Start production services
docker compose up -d
# Access the web interface at http://localhost:3000
# API is available at http://localhost:8000
```
### Development vs Production
- **Development**: Use `docker compose -f compose.dev.yml up -d` (builds from source)
- **Production**: Use `docker compose up -d` (uses published images)
#### Option 2: Local Development
For development or local installation:
@@ -115,7 +94,7 @@ uv run leggen --help
### Configuration
Create a configuration file at `./data/config.toml` (for Docker) or `~/.config/leggen/config.toml` (for local development):
Create a configuration file at `~/.config/leggen/config.toml`:
```toml
[gocardless]
@@ -145,9 +124,9 @@ chat_id = 12345
enabled = true
# Optional: Transaction filters for notifications
[filters]
case-insensitive = ["salary", "utility"]
case-sensitive = ["SpecificStore"]
[filters.case-insensitive]
salary = "salary"
bills = "utility"
```
## 📖 Usage
@@ -213,39 +192,18 @@ leggen status
### Docker Usage
#### Development (build from source)
```bash
# Start development services
docker compose -f compose.dev.yml up -d
# View service status
docker compose -f compose.dev.yml ps
# Check logs
docker compose -f compose.dev.yml logs frontend
docker compose -f compose.dev.yml logs leggend
# Stop development services
docker compose -f compose.dev.yml down
```
#### Production (use published images)
```bash
# Start production services
# Start all services
docker compose up -d
# View service status
docker compose ps
# Connect to a bank
docker compose run leggen bank add
# Run a sync
docker compose run leggen sync --wait
# Check logs
docker compose logs frontend
docker compose logs leggend
# Access the web interface at http://localhost:3000
# API documentation at http://localhost:8000/docs
# Stop production services
docker compose down
```
## 🔌 API Endpoints
@@ -258,7 +216,7 @@ The FastAPI backend provides comprehensive REST endpoints:
- `GET /api/v1/banks/status` - Connection status
- `GET /api/v1/banks/countries` - Supported countries
### Accounts & Balances
### Accounts & Balances
- `GET /api/v1/accounts` - List all accounts
- `GET /api/v1/accounts/{id}` - Account details
- `GET /api/v1/accounts/{id}/balances` - Account balances
@@ -304,7 +262,7 @@ Run the comprehensive test suite with:
# Run all tests
uv run pytest
# Run unit tests only
# Run unit tests only
uv run pytest tests/unit/
# Run with verbose output
@@ -335,7 +293,7 @@ leggen/ # CLI application
├── utils/ # Shared utilities
└── api_client.py # API client for leggend service
leggend/ # FastAPI backend service
leggend/ # FastAPI backend service
├── api/ # API routes and models
├── services/ # Business logic
├── background/ # Background job scheduler
@@ -359,6 +317,6 @@ tests/ # Test suite
## ⚠️ Notes
- This project is in active development
- Web frontend planned for future releases
- GoCardless API rate limits apply
- Some banks may require additional authorization steps
- Docker images are automatically built and published on releases

View File

@@ -1,25 +0,0 @@
services:
# React frontend service
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
restart: "unless-stopped"
ports:
- "127.0.0.1:3000:80"
environment:
- API_BACKEND_URL=${API_BACKEND_URL:-http://leggend:8000}
depends_on:
leggend:
condition: service_healthy
# FastAPI backend service
leggend:
build:
context: .
dockerfile: Dockerfile
restart: "unless-stopped"
ports:
- "127.0.0.1:8000:8000"
volumes:
- "./data:/root/.config/leggen"

View File

@@ -1,19 +1,56 @@
services:
# React frontend service
frontend:
image: ghcr.io/elisiariocouto/leggen:latest-frontend
restart: "unless-stopped"
ports:
- "127.0.0.1:3000:80"
depends_on:
leggend:
condition: service_healthy
# FastAPI backend service
leggend:
image: ghcr.io/elisiariocouto/leggen:latest
build:
context: .
restart: "unless-stopped"
ports:
- "127.0.0.1:8000:8000"
volumes:
- "./data:/root/.config/leggen" # Configuration and database directory
- "./leggen:/root/.config/leggen" # Configuration file directory
- "./db:/app" # Database storage
environment:
- LEGGEN_CONFIG_FILE=/root/.config/leggen/config.toml
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
nocodb:
image: nocodb/nocodb:latest
restart: "unless-stopped"
volumes:
- "./nocodb:/usr/app/data/"
- "./db:/usr/leggen:ro"
ports:
- "127.0.0.1:8080:8080"
depends_on:
leggend:
condition: service_healthy
# Optional: If you want to have a mongodb, uncomment the following lines
# mongo:
# image: mongo:7
# restart: "unless-stopped"
# # If you want to expose the mongodb port to the host, uncomment the following lines
# # ports:
# # - 127.0.0.1:27017:27017
# volumes:
# - "./data:/data/db"
# environment:
# MONGO_INITDB_ROOT_USERNAME: "leggen"
# MONGO_INITDB_ROOT_PASSWORD: "changeme"
# Optional: If you want to have an admin interface for your mongodb, uncomment the following lines
# mongo-express:
# image: mongo-express
# restart: "unless-stopped"
# # By default, we are exposing the mongo-express port to the host
# ports:
# - 127.0.0.1:8081:8081
# environment:
# ME_CONFIG_MONGODB_URL: "mongodb://leggen:changeme@mongo:27017/"
# ME_CONFIG_BASICAUTH_USERNAME: ""
# depends_on:
# - mongo

View File

@@ -1,30 +0,0 @@
[gocardless]
key = "your-api-key"
secret = "your-secret-key"
url = "https://bankaccountdata.gocardless.com/api/v2"
[database]
sqlite = true
# Optional: Background sync scheduling
[scheduler.sync]
enabled = true
hour = 3 # 3 AM
minute = 0
# cron = "0 3 * * *" # Alternative: use cron expression
# Optional: Discord notifications
[notifications.discord]
webhook = "https://discord.com/api/webhooks/..."
enabled = true
# Optional: Telegram notifications
[notifications.telegram]
token = "your-bot-token"
chat_id = 12345
enabled = true
# Optional: Transaction filters for notifications
[filters]
case-insensitive = ["salary", "utility"]
case-sensitive = ["SpecificStore"]

View File

@@ -1,34 +0,0 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm i
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built application from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy server configuration template
COPY default.conf.template /etc/nginx/templates/default.conf.template
# Set default API backend URL (can be overridden at runtime)
ENV API_BACKEND_URL=http://leggend:8000
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -13,18 +13,16 @@ A modern React dashboard for the Leggen Open Banking CLI tool. This frontend pro
## Prerequisites
- Node.js 18+ and npm
- Leggen API server running (configurable via environment variables)
- Leggen API server running on `localhost:8000`
## Getting Started
1. **Install dependencies:**
```bash
npm install
```
2. **Start the development server:**
```bash
npm run dev
```
@@ -71,7 +69,7 @@ src/
## API Integration
The frontend connects to the Leggen API server (configurable via environment variables). The API client handles:
The frontend connects to the Leggen API server running on `localhost:8000`. The API client handles:
- Account retrieval and management
- Transaction fetching with filtering
@@ -80,27 +78,7 @@ The frontend connects to the Leggen API server (configurable via environment var
## Configuration
### API URL Configuration
The frontend supports configurable API URLs through environment variables:
**Development:**
- Set `VITE_API_URL` to call external APIs during development
- Example: `VITE_API_URL=https://staging-api.example.com npm run dev`
**Production:**
- Uses relative URLs (`/api/v1`) that nginx proxies to the backend
- Configure nginx proxy target via `API_BACKEND_URL` environment variable
- Default: `http://leggend:8000`
**Docker Compose:**
```bash
# Override API backend URL
API_BACKEND_URL=https://prod-api.example.com docker-compose up
```
The API base URL is configured in `src/lib/api.ts`. Update the `API_BASE_URL` constant if your Leggen server runs on a different port or host.
## Development

View File

@@ -1,33 +0,0 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Handle client-side routing
location / {
try_files $uri $uri/ /index.html;
}
# API proxy to backend (configurable via API_BACKEND_URL env var)
location /api/ {
proxy_pass ${API_BACKEND_URL};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@@ -1,18 +1,18 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { globalIgnores } from "eslint/config";
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
export default tseslint.config([
globalIgnores(["dist"]),
globalIgnores(['dist']),
{
files: ["**/*.{ts,tsx}"],
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs["recommended-latest"],
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
@@ -20,4 +20,4 @@ export default tseslint.config([
globals: globals.browser,
},
},
]);
])

View File

@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Leggen</title>
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>

View File

@@ -2253,6 +2253,18 @@
"node": ">=0.4.0"
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -2286,9 +2298,9 @@
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.215",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.215.tgz",
"integrity": "sha512-TIvGp57UpeNetj/wV/xpFNpWGb0b/ROw372lHPx5Aafx02gjTBtWnEEcaSX3W2dLM3OSdGGyHX/cHl01JQsLaQ==",
"version": "1.5.214",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.214.tgz",
"integrity": "sha512-TpvUNdha+X3ybfU78NoQatKvQEm1oq3lf2QbnmCEdw+Bd9RuIAY+hJTvq1avzHM0f7EJfnH3vbCnbzKzisc/9Q==",
"license": "ISC"
},
"node_modules/emoji-regex": {
@@ -3102,12 +3114,15 @@
}
},
"node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.5.1.tgz",
"integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": {
@@ -3201,6 +3216,257 @@
"node": ">= 0.8.0"
}
},
"node_modules/lightningcss": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
"dev": true,
"license": "MPL-2.0",
"optional": true,
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-darwin-arm64": "1.30.1",
"lightningcss-darwin-x64": "1.30.1",
"lightningcss-freebsd-x64": "1.30.1",
"lightningcss-linux-arm-gnueabihf": "1.30.1",
"lightningcss-linux-arm64-gnu": "1.30.1",
"lightningcss-linux-arm64-musl": "1.30.1",
"lightningcss-linux-x64-gnu": "1.30.1",
"lightningcss-linux-x64-musl": "1.30.1",
"lightningcss-win32-arm64-msvc": "1.30.1",
"lightningcss-win32-x64-msvc": "1.30.1"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz",
"integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz",
"integrity": "sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz",
"integrity": "sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz",
"integrity": "sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz",
"integrity": "sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz",
"integrity": "sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz",
"integrity": "sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz",
"integrity": "sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz",
"integrity": "sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.30.1",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz",
"integrity": "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
@@ -4177,6 +4443,15 @@
"node": ">=14.0.0"
}
},
"node_modules/tailwindcss/node_modules/jiti": {
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
}
},
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",

View File

@@ -4,7 +4,7 @@
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "VITE_API_URL=http://localhost:8000/api/v1 vite",
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"

View File

@@ -3,4 +3,4 @@ export default {
tailwindcss: {},
autoprefixer: {},
},
};
}

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
<rect width="32" height="32" rx="6" fill="#3B82F6"/>
<path d="M8 24V8h6c2.2 0 4 1.8 4 4v4c0 2.2-1.8 4-4 4H12v4H8zm4-8h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-2v4z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 257 B

View File

@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Dashboard from "./components/Dashboard";
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Dashboard from './components/Dashboard';
const queryClient = new QueryClient({
defaultOptions: {

View File

@@ -1,30 +1,32 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery } from '@tanstack/react-query';
import {
CreditCard,
TrendingUp,
TrendingDown,
Building2,
RefreshCw,
AlertCircle,
} from "lucide-react";
import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import LoadingSpinner from "./LoadingSpinner";
import type { Account, Balance } from "../types/api";
AlertCircle
} from 'lucide-react';
import { apiClient } from '../lib/api';
import { formatCurrency, formatDate } from '../lib/utils';
import LoadingSpinner from './LoadingSpinner';
import type { Account, Balance } from '../types/api';
export default function AccountsOverview() {
const {
data: accounts,
isLoading: accountsLoading,
error: accountsError,
refetch: refetchAccounts,
refetch: refetchAccounts
} = useQuery<Account[]>({
queryKey: ["accounts"],
queryKey: ['accounts'],
queryFn: apiClient.getAccounts,
});
const { data: balances } = useQuery<Balance[]>({
queryKey: ["balances"],
const {
data: balances
} = useQuery<Balance[]>({
queryKey: ['balances'],
queryFn: () => apiClient.getBalances(),
});
@@ -42,12 +44,9 @@ export default function AccountsOverview() {
<div className="flex items-center justify-center text-center">
<div>
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Failed to load accounts
</h3>
<h3 className="text-lg font-medium text-gray-900 mb-2">Failed to load accounts</h3>
<p className="text-gray-600 mb-4">
Unable to connect to the Leggen API. Please check your
configuration and ensure the API server is running.
Unable to connect to the Leggen API. Make sure the server is running on localhost:8000.
</p>
<button
onClick={() => refetchAccounts()}
@@ -62,15 +61,13 @@ export default function AccountsOverview() {
);
}
const totalBalance =
accounts?.reduce((sum, account) => {
// Get the first available balance from the balances array
const primaryBalance = account.balances?.[0]?.amount || 0;
return sum + primaryBalance;
}, 0) || 0;
const totalBalance = accounts?.reduce((sum, account) => {
// Get the first available balance from the balances array
const primaryBalance = account.balances?.[0]?.amount || 0;
return sum + primaryBalance;
}, 0) || 0;
const totalAccounts = accounts?.length || 0;
const uniqueBanks = new Set(accounts?.map((acc) => acc.institution_id) || [])
.size;
const uniqueBanks = new Set(accounts?.map(acc => acc.institution_id) || []).size;
return (
<div className="space-y-6">
@@ -93,12 +90,8 @@ export default function AccountsOverview() {
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
Total Accounts
</p>
<p className="text-2xl font-bold text-gray-900">
{totalAccounts}
</p>
<p className="text-sm font-medium text-gray-600">Total Accounts</p>
<p className="text-2xl font-bold text-gray-900">{totalAccounts}</p>
</div>
<div className="p-3 bg-blue-100 rounded-full">
<CreditCard className="h-6 w-6 text-blue-600" />
@@ -109,9 +102,7 @@ export default function AccountsOverview() {
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">
Connected Banks
</p>
<p className="text-sm font-medium text-gray-600">Connected Banks</p>
<p className="text-2xl font-bold text-gray-900">{uniqueBanks}</p>
</div>
<div className="p-3 bg-purple-100 rounded-full">
@@ -125,87 +116,70 @@ export default function AccountsOverview() {
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-medium text-gray-900">Bank Accounts</h3>
<p className="text-sm text-gray-600">
Manage your connected bank accounts
</p>
<p className="text-sm text-gray-600">Manage your connected bank accounts</p>
</div>
{!accounts || accounts.length === 0 ? (
<div className="p-6 text-center">
<CreditCard className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No accounts found
</h3>
<h3 className="text-lg font-medium text-gray-900 mb-2">No accounts found</h3>
<p className="text-gray-600">
Connect your first bank account to get started with Leggen.
</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{accounts.map((account) => {
// Get balance from account's balances array or fallback to balances query
const accountBalance = account.balances?.[0];
const fallbackBalance = balances?.find(
(b) => b.account_id === account.id,
);
const balance =
accountBalance?.amount || fallbackBalance?.balance_amount || 0;
const currency =
accountBalance?.currency ||
fallbackBalance?.currency ||
account.currency ||
"EUR";
const isPositive = balance >= 0;
{accounts.map((account) => {
// Get balance from account's balances array or fallback to balances query
const accountBalance = account.balances?.[0];
const fallbackBalance = balances?.find(b => b.account_id === account.id);
const balance = accountBalance?.amount || fallbackBalance?.balance_amount || 0;
const currency = accountBalance?.currency || fallbackBalance?.currency || account.currency || 'EUR';
const isPositive = balance >= 0;
return (
<div
key={account.id}
className="p-6 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="p-3 bg-gray-100 rounded-full">
<Building2 className="h-6 w-6 text-gray-600" />
</div>
<div>
<h4 className="text-lg font-medium text-gray-900">
{account.name || "Unnamed Account"}
</h4>
<p className="text-sm text-gray-600">
{account.institution_id} {account.status}
</p>
{account.iban && (
<p className="text-xs text-gray-500 mt-1">
IBAN: {account.iban}
</p>
)}
</div>
</div>
return (
<div key={account.id} className="p-6 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="p-3 bg-gray-100 rounded-full">
<Building2 className="h-6 w-6 text-gray-600" />
</div>
<div>
<h4 className="text-lg font-medium text-gray-900">
{account.name || 'Unnamed Account'}
</h4>
<p className="text-sm text-gray-600">
{account.institution_id} {account.status}
</p>
{account.iban && (
<p className="text-xs text-gray-500 mt-1">
IBAN: {account.iban}
</p>
)}
</div>
</div>
<div className="text-right">
<div className="flex items-center space-x-2">
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-500" />
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<p
className={`text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{formatCurrency(balance, currency)}
</p>
</div>
<p className="text-sm text-gray-500">
Updated{" "}
{formatDate(account.last_accessed || account.created)}
</p>
</div>
</div>
</div>
);
})}
<div className="text-right">
<div className="flex items-center space-x-2">
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-500" />
) : (
<TrendingDown className="h-4 w-4 text-red-500" />
)}
<p className={`text-lg font-semibold ${
isPositive ? 'text-green-600' : 'text-red-600'
}`}>
{formatCurrency(balance, currency)}
</p>
</div>
<p className="text-sm text-gray-500">
Updated {formatDate(account.last_accessed || account.created)}
</p>
</div>
</div>
</div>
);
})}
</div>
)}
</div>

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
CreditCard,
TrendingUp,
@@ -10,64 +10,58 @@ import {
List,
BarChart3,
Wifi,
WifiOff,
Bell,
} from "lucide-react";
import { apiClient } from "../lib/api";
import AccountsOverview from "./AccountsOverview";
import TransactionsList from "./TransactionsList";
import Notifications from "./Notifications";
import ErrorBoundary from "./ErrorBoundary";
import { cn } from "../lib/utils";
import type { Account } from "../types/api";
WifiOff
} from 'lucide-react';
import { apiClient } from '../lib/api';
import AccountsOverview from './AccountsOverview';
import TransactionsList from './TransactionsList';
import ErrorBoundary from './ErrorBoundary';
import { cn } from '../lib/utils';
import type { Account } from '../types/api';
type TabType = "overview" | "transactions" | "analytics" | "notifications";
type TabType = 'overview' | 'transactions' | 'analytics';
export default function Dashboard() {
const [activeTab, setActiveTab] = useState<TabType>("overview");
const [activeTab, setActiveTab] = useState<TabType>('overview');
const [sidebarOpen, setSidebarOpen] = useState(false);
const { data: accounts } = useQuery<Account[]>({
queryKey: ["accounts"],
queryKey: ['accounts'],
queryFn: apiClient.getAccounts,
});
const {
data: healthStatus,
isLoading: healthLoading,
isError: healthError,
} = useQuery({
queryKey: ["health"],
const { data: healthStatus, isLoading: healthLoading, isError: healthError } = useQuery({
queryKey: ['health'],
queryFn: async () => {
return await apiClient.getHealth();
const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api/v1/health`);
if (!response.ok) {
throw new Error(`Health check failed: ${response.status}`);
}
return response.json();
},
refetchInterval: 30000, // Check every 30 seconds
retry: 3,
});
const navigation = [
{ name: "Overview", icon: Home, id: "overview" as TabType },
{ name: "Transactions", icon: List, id: "transactions" as TabType },
{ name: "Analytics", icon: BarChart3, id: "analytics" as TabType },
{ name: "Notifications", icon: Bell, id: "notifications" as TabType },
{ name: 'Overview', icon: Home, id: 'overview' as TabType },
{ name: 'Transactions', icon: List, id: 'transactions' as TabType },
{ name: 'Analytics', icon: BarChart3, id: 'analytics' as TabType },
];
const totalBalance =
accounts?.reduce((sum, account) => {
// Get the first available balance from the balances array
const primaryBalance = account.balances?.[0]?.amount || 0;
return sum + primaryBalance;
}, 0) || 0;
const totalBalance = accounts?.reduce((sum, account) => {
// Get the first available balance from the balances array
const primaryBalance = account.balances?.[0]?.amount || 0;
return sum + primaryBalance;
}, 0) || 0;
return (
<div className="flex h-screen bg-gray-100">
{/* Sidebar */}
<div
className={cn(
"fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full",
)}
>
<div className={cn(
"fixed inset-y-0 left-0 z-50 w-64 bg-white shadow-lg transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}>
<div className="flex items-center justify-between h-16 px-6 border-b border-gray-200">
<div className="flex items-center space-x-2">
<CreditCard className="h-8 w-8 text-blue-600" />
@@ -94,7 +88,7 @@ export default function Dashboard() {
"flex items-center w-full px-3 py-2 text-sm font-medium rounded-md transition-colors",
activeTab === item.id
? "bg-blue-100 text-blue-700"
: "text-gray-700 hover:text-gray-900 hover:bg-gray-100",
: "text-gray-700 hover:text-gray-900 hover:bg-gray-100"
)}
>
<item.icon className="mr-3 h-5 w-5" />
@@ -108,15 +102,13 @@ export default function Dashboard() {
<div className="px-6 py-4 border-t border-gray-200 mt-auto">
<div className="bg-gray-50 rounded-lg p-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-600">
Total Balance
</span>
<span className="text-sm font-medium text-gray-600">Total Balance</span>
<TrendingUp className="h-4 w-4 text-green-500" />
</div>
<p className="text-2xl font-bold text-gray-900 mt-1">
{new Intl.NumberFormat("en-US", {
style: "currency",
currency: "EUR",
{new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR',
}).format(totalBalance)}
</p>
<p className="text-sm text-gray-500 mt-1">
@@ -147,7 +139,7 @@ export default function Dashboard() {
<Menu className="h-6 w-6" />
</button>
<h2 className="text-lg font-semibold text-gray-900 lg:ml-0 ml-4">
{navigation.find((item) => item.id === activeTab)?.name}
{navigation.find(item => item.id === activeTab)?.name}
</h2>
</div>
<div className="flex items-center space-x-2">
@@ -157,7 +149,7 @@ export default function Dashboard() {
<Activity className="h-4 w-4 text-yellow-500 animate-pulse" />
<span className="text-sm text-gray-600">Checking...</span>
</>
) : healthError || healthStatus?.status !== "healthy" ? (
) : healthError || !healthStatus?.success ? (
<>
<WifiOff className="h-4 w-4 text-red-500" />
<span className="text-sm text-red-500">Disconnected</span>
@@ -176,19 +168,14 @@ export default function Dashboard() {
{/* Main content area */}
<main className="flex-1 overflow-y-auto p-6">
<ErrorBoundary>
{activeTab === "overview" && <AccountsOverview />}
{activeTab === "transactions" && <TransactionsList />}
{activeTab === "analytics" && (
{activeTab === 'overview' && <AccountsOverview />}
{activeTab === 'transactions' && <TransactionsList />}
{activeTab === 'analytics' && (
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-medium text-gray-900 mb-4">
Analytics
</h3>
<p className="text-gray-600">
Analytics dashboard coming soon...
</p>
<h3 className="text-lg font-medium text-gray-900 mb-4">Analytics</h3>
<p className="text-gray-600">Analytics dashboard coming soon...</p>
</div>
)}
{activeTab === "notifications" && <Notifications />}
</ErrorBoundary>
</main>
</div>

View File

@@ -1,6 +1,6 @@
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
import { AlertTriangle, RefreshCw } from "lucide-react";
import { Component } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { AlertTriangle, RefreshCw } from 'lucide-react';
interface Props {
children: ReactNode;
@@ -24,7 +24,7 @@ class ErrorBoundary extends Component<Props, State> {
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("ErrorBoundary caught an error:", error, errorInfo);
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.setState({ error, errorInfo });
}
@@ -43,12 +43,9 @@ class ErrorBoundary extends Component<Props, State> {
<div className="flex items-center justify-center text-center">
<div>
<AlertTriangle className="h-12 w-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Something went wrong
</h3>
<h3 className="text-lg font-medium text-gray-900 mb-2">Something went wrong</h3>
<p className="text-gray-600 mb-4">
An error occurred while rendering this component. Please try
refreshing or check the console for more details.
An error occurred while rendering this component. Please try refreshing or check the console for more details.
</p>
{this.state.error && (
@@ -58,9 +55,7 @@ class ErrorBoundary extends Component<Props, State> {
</p>
{this.state.error.stack && (
<details className="mt-2">
<summary className="text-sm text-red-600 cursor-pointer">
Stack trace
</summary>
<summary className="text-sm text-red-600 cursor-pointer">Stack trace</summary>
<pre className="text-xs text-red-700 mt-1 whitespace-pre-wrap">
{this.state.error.stack}
</pre>

View File

@@ -1,12 +1,10 @@
import { RefreshCw } from "lucide-react";
import { RefreshCw } from 'lucide-react';
interface LoadingSpinnerProps {
message?: string;
}
export default function LoadingSpinner({
message = "Loading...",
}: LoadingSpinnerProps) {
export default function LoadingSpinner({ message = 'Loading...' }: LoadingSpinnerProps) {
return (
<div className="flex items-center justify-center p-8">
<div className="text-center">

View File

@@ -1,320 +0,0 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
Bell,
MessageSquare,
Send,
Trash2,
RefreshCw,
AlertCircle,
CheckCircle,
Settings,
TestTube,
} from "lucide-react";
import { apiClient } from "../lib/api";
import LoadingSpinner from "./LoadingSpinner";
import type { NotificationSettings, NotificationService } from "../types/api";
export default function Notifications() {
const [testService, setTestService] = useState("");
const [testMessage, setTestMessage] = useState(
"Test notification from Leggen",
);
const queryClient = useQueryClient();
const {
data: settings,
isLoading: settingsLoading,
error: settingsError,
refetch: refetchSettings,
} = useQuery<NotificationSettings>({
queryKey: ["notificationSettings"],
queryFn: apiClient.getNotificationSettings,
});
const {
data: services,
isLoading: servicesLoading,
error: servicesError,
refetch: refetchServices,
} = useQuery<NotificationService[]>({
queryKey: ["notificationServices"],
queryFn: apiClient.getNotificationServices,
});
const testMutation = useMutation({
mutationFn: apiClient.testNotification,
onSuccess: () => {
// Could show a success toast here
console.log("Test notification sent successfully");
},
onError: (error) => {
console.error("Failed to send test notification:", error);
},
});
const deleteServiceMutation = useMutation({
mutationFn: apiClient.deleteNotificationService,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notificationSettings"] });
queryClient.invalidateQueries({ queryKey: ["notificationServices"] });
},
});
if (settingsLoading || servicesLoading) {
return (
<div className="bg-white rounded-lg shadow">
<LoadingSpinner message="Loading notifications..." />
</div>
);
}
if (settingsError || servicesError) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-center text-center">
<div>
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Failed to load notifications
</h3>
<p className="text-gray-600 mb-4">
Unable to connect to the Leggen API. Please check your
configuration and ensure the API server is running.
</p>
<button
onClick={() => {
refetchSettings();
refetchServices();
}}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</button>
</div>
</div>
</div>
);
}
const handleTestNotification = () => {
if (!testService) return;
testMutation.mutate({
service: testService,
message: testMessage,
});
};
const handleDeleteService = (serviceName: string) => {
if (
confirm(
`Are you sure you want to delete the ${serviceName} notification service?`,
)
) {
deleteServiceMutation.mutate(serviceName);
}
};
return (
<div className="space-y-6">
{/* Test Notification Section */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center space-x-2 mb-4">
<TestTube className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-medium text-gray-900">
Test Notifications
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Service
</label>
<select
value={testService}
onChange={(e) => setTestService(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Select a service...</option>
{services?.map((service) => (
<option key={service.name} value={service.name}>
{service.name} {service.enabled ? "(Enabled)" : "(Disabled)"}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Message
</label>
<input
type="text"
value={testMessage}
onChange={(e) => setTestMessage(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Test message..."
/>
</div>
</div>
<div className="mt-4">
<button
onClick={handleTestNotification}
disabled={!testService || testMutation.isPending}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Send className="h-4 w-4 mr-2" />
{testMutation.isPending ? "Sending..." : "Send Test Notification"}
</button>
</div>
</div>
{/* Notification Services */}
<div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200">
<div className="flex items-center space-x-2">
<Bell className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-medium text-gray-900">
Notification Services
</h3>
</div>
<p className="text-sm text-gray-600 mt-1">
Manage your notification services
</p>
</div>
{!services || services.length === 0 ? (
<div className="p-6 text-center">
<Bell className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No notification services configured
</h3>
<p className="text-gray-600">
Configure notification services in your backend to receive alerts.
</p>
</div>
) : (
<div className="divide-y divide-gray-200">
{services.map((service) => (
<div
key={service.name}
className="p-6 hover:bg-gray-50 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className="p-3 bg-gray-100 rounded-full">
{service.name.toLowerCase().includes("discord") ? (
<MessageSquare className="h-6 w-6 text-gray-600" />
) : service.name.toLowerCase().includes("telegram") ? (
<Send className="h-6 w-6 text-gray-600" />
) : (
<Bell className="h-6 w-6 text-gray-600" />
)}
</div>
<div>
<h4 className="text-lg font-medium text-gray-900 capitalize">
{service.name}
</h4>
<div className="flex items-center space-x-2 mt-1">
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
service.enabled
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}`}
>
{service.enabled ? (
<CheckCircle className="h-3 w-3 mr-1" />
) : (
<AlertCircle className="h-3 w-3 mr-1" />
)}
{service.enabled ? "Enabled" : "Disabled"}
</span>
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
service.configured
? "bg-blue-100 text-blue-800"
: "bg-yellow-100 text-yellow-800"
}`}
>
{service.configured ? "Configured" : "Not Configured"}
</span>
</div>
</div>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => handleDeleteService(service.name)}
disabled={deleteServiceMutation.isPending}
className="p-2 text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors"
title={`Delete ${service.name} service`}
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Notification Settings */}
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center space-x-2 mb-4">
<Settings className="h-5 w-5 text-blue-600" />
<h3 className="text-lg font-medium text-gray-900">
Notification Settings
</h3>
</div>
{settings && (
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">
Filters
</h4>
<div className="bg-gray-50 rounded-md p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Case Insensitive Filters
</label>
<p className="text-sm text-gray-900">
{settings.filters.case_insensitive.length > 0
? settings.filters.case_insensitive.join(", ")
: "None"}
</p>
</div>
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">
Case Sensitive Filters
</label>
<p className="text-sm text-gray-900">
{settings.filters.case_sensitive &&
settings.filters.case_sensitive.length > 0
? settings.filters.case_sensitive.join(", ")
: "None"}
</p>
</div>
</div>
</div>
</div>
<div className="text-sm text-gray-600">
<p>
Configure notification settings through your backend API to
customize filters and service configurations.
</p>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Filter,
Search,
@@ -8,22 +8,24 @@ import {
Calendar,
RefreshCw,
AlertCircle,
X,
} from "lucide-react";
import { apiClient } from "../lib/api";
import { formatCurrency, formatDate } from "../lib/utils";
import LoadingSpinner from "./LoadingSpinner";
import type { Account, Transaction } from "../types/api";
X
} from 'lucide-react';
import { apiClient } from '../lib/api';
import { formatCurrency, formatDate } from '../lib/utils';
import LoadingSpinner from './LoadingSpinner';
import type { Account, Transaction } from '../types/api';
export default function TransactionsList() {
const [searchTerm, setSearchTerm] = useState("");
const [selectedAccount, setSelectedAccount] = useState<string>("");
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [searchTerm, setSearchTerm] = useState('');
const [selectedAccount, setSelectedAccount] = useState<string>('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [showFilters, setShowFilters] = useState(false);
const { data: accounts } = useQuery<Account[]>({
queryKey: ["accounts"],
const {
data: accounts
} = useQuery<Account[]>({
queryKey: ['accounts'],
queryFn: apiClient.getAccounts,
});
@@ -31,34 +33,29 @@ export default function TransactionsList() {
data: transactions,
isLoading: transactionsLoading,
error: transactionsError,
refetch: refetchTransactions,
refetch: refetchTransactions
} = useQuery<Transaction[]>({
queryKey: ["transactions", selectedAccount, startDate, endDate],
queryFn: () =>
apiClient.getTransactions({
accountId: selectedAccount || undefined,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
queryKey: ['transactions', selectedAccount, startDate, endDate],
queryFn: () => apiClient.getTransactions({
accountId: selectedAccount || undefined,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
});
const filteredTransactions = (transactions || []).filter((transaction) => {
const filteredTransactions = (transactions || []).filter(transaction => {
// Additional validation (API client should have already filtered out invalid ones)
if (!transaction || !transaction.account_id) {
console.warn(
"Invalid transaction found after API filtering:",
transaction,
);
console.warn('Invalid transaction found after API filtering:', transaction);
return false;
}
const description = transaction.description || "";
const creditorName = transaction.creditor_name || "";
const debtorName = transaction.debtor_name || "";
const reference = transaction.reference || "";
const description = transaction.description || '';
const creditorName = transaction.creditor_name || '';
const debtorName = transaction.debtor_name || '';
const reference = transaction.reference || '';
const matchesSearch =
searchTerm === "" ||
const matchesSearch = searchTerm === '' ||
description.toLowerCase().includes(searchTerm.toLowerCase()) ||
creditorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
debtorName.toLowerCase().includes(searchTerm.toLowerCase()) ||
@@ -68,14 +65,13 @@ export default function TransactionsList() {
});
const clearFilters = () => {
setSearchTerm("");
setSelectedAccount("");
setStartDate("");
setEndDate("");
setSearchTerm('');
setSelectedAccount('');
setStartDate('');
setEndDate('');
};
const hasActiveFilters =
searchTerm || selectedAccount || startDate || endDate;
const hasActiveFilters = searchTerm || selectedAccount || startDate || endDate;
if (transactionsLoading) {
return (
@@ -91,9 +87,7 @@ export default function TransactionsList() {
<div className="flex items-center justify-center text-center">
<div>
<AlertCircle className="h-12 w-12 text-red-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
Failed to load transactions
</h3>
<h3 className="text-lg font-medium text-gray-900 mb-2">Failed to load transactions</h3>
<p className="text-gray-600 mb-4">
Unable to fetch transactions from the Leggen API.
</p>
@@ -169,12 +163,11 @@ export default function TransactionsList() {
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">All accounts</option>
{accounts?.map((account) => (
<option key={account.id} value={account.id}>
{account.name || "Unnamed Account"} (
{account.institution_id})
</option>
))}
{accounts?.map((account) => (
<option key={account.id} value={account.id}>
{account.name || 'Unnamed Account'} ({account.institution_id})
</option>
))}
</select>
</div>
@@ -216,11 +209,10 @@ export default function TransactionsList() {
{/* Results Summary */}
<div className="px-6 py-3 bg-gray-50 border-b border-gray-200">
<p className="text-sm text-gray-600">
Showing {filteredTransactions.length} transaction
{filteredTransactions.length !== 1 ? "s" : ""}
Showing {filteredTransactions.length} transaction{filteredTransactions.length !== 1 ? 's' : ''}
{selectedAccount && accounts && (
<span className="ml-1">
for {accounts.find((acc) => acc.id === selectedAccount)?.name}
for {accounts.find(acc => acc.id === selectedAccount)?.name}
</span>
)}
</p>
@@ -233,39 +225,28 @@ export default function TransactionsList() {
<div className="text-gray-400 mb-4">
<TrendingUp className="h-12 w-12 mx-auto" />
</div>
<h3 className="text-lg font-medium text-gray-900 mb-2">
No transactions found
</h3>
<h3 className="text-lg font-medium text-gray-900 mb-2">No transactions found</h3>
<p className="text-gray-600">
{hasActiveFilters
? "Try adjusting your filters to see more results."
: "No transactions are available for the selected criteria."}
{hasActiveFilters ?
"Try adjusting your filters to see more results." :
"No transactions are available for the selected criteria."
}
</p>
</div>
) : (
<div className="bg-white rounded-lg shadow divide-y divide-gray-200">
{filteredTransactions.map((transaction) => {
const account = accounts?.find(
(acc) => acc.id === transaction.account_id,
);
const account = accounts?.find(acc => acc.id === transaction.account_id);
const isPositive = transaction.amount > 0;
return (
<div
key={
transaction.internal_transaction_id ||
`${transaction.account_id}-${transaction.date}-${transaction.amount}`
}
className="p-6 hover:bg-gray-50 transition-colors"
>
<div key={transaction.internal_transaction_id || `${transaction.account_id}-${transaction.date}-${transaction.amount}`} className="p-6 hover:bg-gray-50 transition-colors">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-start space-x-3">
<div
className={`p-2 rounded-full ${
isPositive ? "bg-green-100" : "bg-red-100"
}`}
>
<div className={`p-2 rounded-full ${
isPositive ? 'bg-green-100' : 'bg-red-100'
}`}>
{isPositive ? (
<TrendingUp className="h-4 w-4 text-green-600" />
) : (
@@ -278,20 +259,15 @@ export default function TransactionsList() {
{transaction.description}
</h4>
<div className="text-xs text-gray-500 space-y-1">
{account && (
<p>
{account.name || "Unnamed Account"} {" "}
{account.institution_id}
</p>
)}
<div className="text-xs text-gray-500 space-y-1">
{account && (
<p>{account.name || 'Unnamed Account'} {account.institution_id}</p>
)}
{(transaction.creditor_name ||
transaction.debtor_name) && (
{(transaction.creditor_name || transaction.debtor_name) && (
<p>
{isPositive ? "From: " : "To: "}
{transaction.creditor_name ||
transaction.debtor_name}
{isPositive ? 'From: ' : 'To: '}
{transaction.creditor_name || transaction.debtor_name}
</p>
)}
@@ -308,25 +284,19 @@ export default function TransactionsList() {
</div>
<div className="text-right ml-4">
<p
className={`text-lg font-semibold ${
isPositive ? "text-green-600" : "text-red-600"
}`}
>
{isPositive ? "+" : ""}
{formatCurrency(transaction.amount, transaction.currency)}
<p className={`text-lg font-semibold ${
isPositive ? 'text-green-600' : 'text-red-600'
}`}>
{isPositive ? '+' : ''}{formatCurrency(transaction.amount, transaction.currency)}
</p>
<p className="text-sm text-gray-500">
{transaction.date
? formatDate(transaction.date)
: "No date"}
{transaction.date ? formatDate(transaction.date) : 'No date'}
</p>
{transaction.booking_date &&
transaction.booking_date !== transaction.date && (
<p className="text-xs text-gray-400">
Booked: {formatDate(transaction.booking_date)}
</p>
)}
{transaction.booking_date && transaction.booking_date !== transaction.date && (
<p className="text-xs text-gray-400">
Booked: {formatDate(transaction.booking_date)}
</p>
)}
</div>
</div>
</div>

View File

@@ -1,30 +1,19 @@
import axios from "axios";
import type {
Account,
Transaction,
Balance,
ApiResponse,
NotificationSettings,
NotificationTest,
NotificationService,
NotificationServicesResponse,
HealthData,
} from "../types/api";
import axios from 'axios';
import type { Account, Transaction, Balance, ApiResponse } from '../types/api';
// Use VITE_API_URL for development, relative URLs for production
const API_BASE_URL = import.meta.env.VITE_API_URL || "/api/v1";
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1';
const api = axios.create({
baseURL: API_BASE_URL,
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
},
});
export const apiClient = {
// Get all accounts
getAccounts: async (): Promise<Account[]> => {
const response = await api.get<ApiResponse<Account[]>>("/accounts");
const response = await api.get<ApiResponse<Account[]>>('/accounts');
return response.data.data;
},
@@ -36,15 +25,13 @@ export const apiClient = {
// Get all balances
getBalances: async (): Promise<Balance[]> => {
const response = await api.get<ApiResponse<Balance[]>>("/balances");
const response = await api.get<ApiResponse<Balance[]>>('/balances');
return response.data.data;
},
// Get balances for specific account
getAccountBalances: async (accountId: string): Promise<Balance[]> => {
const response = await api.get<ApiResponse<Balance[]>>(
`/accounts/${accountId}/balances`,
);
const response = await api.get<ApiResponse<Balance[]>>(`/accounts/${accountId}/balances`);
return response.data.data;
},
@@ -59,70 +46,20 @@ export const apiClient = {
}): Promise<Transaction[]> => {
const queryParams = new URLSearchParams();
if (params?.accountId) queryParams.append("account_id", params.accountId);
if (params?.startDate) queryParams.append("start_date", params.startDate);
if (params?.endDate) queryParams.append("end_date", params.endDate);
if (params?.page) queryParams.append("page", params.page.toString());
if (params?.perPage)
queryParams.append("per_page", params.perPage.toString());
if (params?.search) queryParams.append("search", params.search);
if (params?.accountId) queryParams.append('account_id', params.accountId);
if (params?.startDate) queryParams.append('start_date', params.startDate);
if (params?.endDate) queryParams.append('end_date', params.endDate);
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.perPage) queryParams.append('per_page', params.perPage.toString());
if (params?.search) queryParams.append('search', params.search);
const response = await api.get<ApiResponse<Transaction[]>>(
`/transactions?${queryParams.toString()}`,
);
const response = await api.get<ApiResponse<Transaction[]>>(`/transactions?${queryParams.toString()}`);
return response.data.data;
},
// Get transaction by ID
getTransaction: async (id: string): Promise<Transaction> => {
const response = await api.get<ApiResponse<Transaction>>(
`/transactions/${id}`,
);
return response.data.data;
},
// Get notification settings
getNotificationSettings: async (): Promise<NotificationSettings> => {
const response = await api.get<ApiResponse<NotificationSettings>>(
"/notifications/settings",
);
return response.data.data;
},
// Update notification settings
updateNotificationSettings: async (
settings: NotificationSettings,
): Promise<NotificationSettings> => {
const response = await api.put<ApiResponse<NotificationSettings>>(
"/notifications/settings",
settings,
);
return response.data.data;
},
// Test notification
testNotification: async (test: NotificationTest): Promise<void> => {
await api.post("/notifications/test", test);
},
// Get notification services
getNotificationServices: async (): Promise<NotificationService[]> => {
const response = await api.get<ApiResponse<NotificationServicesResponse>>(
"/notifications/services",
);
// Convert object to array format
const servicesData = response.data.data;
return Object.values(servicesData);
},
// Delete notification service
deleteNotificationService: async (service: string): Promise<void> => {
await api.delete(`/notifications/settings/${service}`);
},
// Health check
getHealth: async (): Promise<HealthData> => {
const response = await api.get<ApiResponse<HealthData>>("/health");
const response = await api.get<ApiResponse<Transaction>>(`/transactions/${id}`);
return response.data.data;
},
};

View File

@@ -1,62 +1,58 @@
import { clsx, type ClassValue } from "clsx";
import { clsx, type ClassValue } from 'clsx';
export function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
export function formatCurrency(
amount: number,
currency: string = "EUR",
): string {
export function formatCurrency(amount: number, currency: string = 'EUR'): string {
// Validate currency code - must be 3 letters and a valid ISO 4217 code
const validCurrency =
currency && /^[A-Z]{3}$/.test(currency) ? currency : "EUR";
const validCurrency = currency && /^[A-Z]{3}$/.test(currency) ? currency : 'EUR';
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: validCurrency,
}).format(amount);
} catch {
} catch (error) {
// Fallback if currency is still invalid
console.warn(`Invalid currency code: ${currency}, falling back to EUR`);
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "EUR",
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'EUR',
}).format(amount);
}
}
export function formatDate(date: string): string {
if (!date) return "No date";
if (!date) return 'No date';
const parsedDate = new Date(date);
if (isNaN(parsedDate.getTime())) {
console.warn("Invalid date string:", date);
return "Invalid date";
console.warn('Invalid date string:', date);
return 'Invalid date';
}
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
}).format(parsedDate);
}
export function formatDateTime(date: string): string {
if (!date) return "No date";
if (!date) return 'No date';
const parsedDate = new Date(date);
if (isNaN(parsedDate.getTime())) {
console.warn("Invalid date string:", date);
return "Invalid date";
console.warn('Invalid date string:', date);
return 'Invalid date';
}
return new Intl.DateTimeFormat("en-US", {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(parsedDate);
}

View File

@@ -1,10 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById("root")!).render(
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
)

View File

@@ -86,50 +86,3 @@ export interface PaginatedResponse<T> {
per_page: number;
total_pages: number;
}
// Notification types
export interface DiscordConfig {
webhook: string;
enabled: boolean;
}
export interface TelegramConfig {
token: string;
chat_id: number;
enabled: boolean;
}
export interface NotificationFilters {
case_insensitive: string[];
case_sensitive?: string[];
}
export interface NotificationSettings {
discord?: DiscordConfig;
telegram?: TelegramConfig;
filters: NotificationFilters;
}
export interface NotificationTest {
service: string;
message?: string;
}
export interface NotificationService {
name: string;
enabled: boolean;
configured: boolean;
active?: boolean;
}
export interface NotificationServicesResponse {
[serviceName: string]: NotificationService;
}
// Health check response data
export interface HealthData {
status: string;
config_loaded?: boolean;
message?: string;
error?: string;
}

View File

@@ -1,8 +1,13 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [require("@tailwindcss/forms")],
plugins: [
require('@tailwindcss/forms'),
],
};

View File

@@ -1,7 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
});
})

View File

@@ -210,6 +210,7 @@ def get_transactions(
min_amount=None,
max_amount=None,
search=None,
hide_missing_ids=True,
):
"""Get transactions from SQLite database with optional filtering"""
from pathlib import Path
@@ -249,6 +250,11 @@ def get_transactions(
query += " AND description LIKE ?"
params.append(f"%{search}%")
if hide_missing_ids:
query += (
" AND internalTransactionId IS NOT NULL AND internalTransactionId != ''"
)
# Add ordering and pagination
query += " ORDER BY transactionDate DESC"
@@ -397,6 +403,11 @@ def get_transaction_count(account_id=None, **filters):
query += " AND description LIKE ?"
params.append(f"%{filters['search']}%")
if filters.get("hide_missing_ids", True):
query += (
" AND internalTransactionId IS NOT NULL AND internalTransactionId != ''"
)
try:
cursor.execute(query, params)
count = cursor.fetchone()[0]

View File

@@ -69,13 +69,8 @@ def save_transactions(ctx: click.Context, account: str) -> list:
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
)
# Extract transaction ID, using transactionId as fallback when internalTransactionId is missing
transaction_id = transaction.get("internalTransactionId") or transaction.get(
"transactionId"
)
t = {
"internalTransactionId": transaction_id,
"internalTransactionId": transaction.get("internalTransactionId"),
"institutionId": account_info["institution_id"],
"iban": account_info.get("iban", "N/A"),
"transactionDate": min_date,
@@ -110,13 +105,8 @@ def save_transactions(ctx: click.Context, account: str) -> list:
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
)
# Extract transaction ID, using transactionId as fallback when internalTransactionId is missing
transaction_id = transaction.get("internalTransactionId") or transaction.get(
"transactionId"
)
t = {
"internalTransactionId": transaction_id,
"internalTransactionId": transaction.get("internalTransactionId"),
"institutionId": account_info["institution_id"],
"iban": account_info.get("iban", "N/A"),
"transactionDate": min_date,

View File

@@ -1,4 +1,4 @@
from typing import Optional, List
from typing import Dict, Optional, List
from pydantic import BaseModel
@@ -21,8 +21,10 @@ class TelegramConfig(BaseModel):
class NotificationFilters(BaseModel):
"""Notification filters configuration"""
case_insensitive: List[str] = []
case_sensitive: Optional[List[str]] = None
case_insensitive: Dict[str, str] = {}
case_sensitive: Optional[Dict[str, str]] = None
amount_threshold: Optional[float] = None
keywords: List[str] = []
class NotificationSettings(BaseModel):

View File

@@ -46,8 +46,10 @@ async def get_notification_settings() -> APIResponse:
if (telegram_config.get("token") or telegram_config.get("api-key"))
else None,
filters=NotificationFilters(
case_insensitive=filters_config.get("case-insensitive", []),
case_insensitive=filters_config.get("case-insensitive", {}),
case_sensitive=filters_config.get("case-sensitive"),
amount_threshold=filters_config.get("amount_threshold"),
keywords=filters_config.get("keywords", []),
),
)
@@ -90,6 +92,10 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
filters_config["case-insensitive"] = settings.filters.case_insensitive
if settings.filters.case_sensitive:
filters_config["case-sensitive"] = settings.filters.case_sensitive
if settings.filters.amount_threshold:
filters_config["amount_threshold"] = settings.filters.amount_threshold
if settings.filters.keywords:
filters_config["keywords"] = settings.filters.keywords
# Save to config
if notifications_config:

View File

@@ -18,6 +18,9 @@ async def get_all_transactions(
summary_only: bool = Query(
default=True, description="Return transaction summaries only"
),
hide_missing_ids: bool = Query(
default=True, description="Hide transactions without internalTransactionId"
),
date_from: Optional[str] = Query(
default=None, description="Filter from date (YYYY-MM-DD)"
),
@@ -47,6 +50,7 @@ async def get_all_transactions(
min_amount=min_amount,
max_amount=max_amount,
search=search,
hide_missing_ids=hide_missing_ids,
)
# Get total count for pagination info (respecting the same filters)
@@ -57,6 +61,7 @@ async def get_all_transactions(
min_amount=min_amount,
max_amount=max_amount,
search=search,
hide_missing_ids=hide_missing_ids,
)
# Get total count for pagination info
@@ -121,6 +126,9 @@ async def get_all_transactions(
async def get_transaction_stats(
days: int = Query(default=30, description="Number of days to include in stats"),
account_id: Optional[str] = Query(default=None, description="Filter by account ID"),
hide_missing_ids: bool = Query(
default=True, description="Hide transactions without internalTransactionId"
),
) -> APIResponse:
"""Get transaction statistics for the last N days from database"""
try:
@@ -138,6 +146,7 @@ async def get_transaction_stats(
date_from=date_from,
date_to=date_to,
limit=None, # Get all matching transactions for stats
hide_missing_ids=hide_missing_ids,
)
# Calculate stats

View File

@@ -66,8 +66,7 @@ def create_app() -> FastAPI:
allow_origins=[
"http://localhost:3000",
"http://localhost:5173",
"http://frontend:80",
], # Frontend container and dev servers
], # SvelteKit dev servers
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View File

@@ -93,13 +93,8 @@ class DatabaseService:
",".join(transaction.get("remittanceInformationUnstructuredArray", [])),
)
# Extract transaction ID, using transactionId as fallback when internalTransactionId is missing
transaction_id = transaction.get("internalTransactionId") or transaction.get(
"transactionId"
)
return {
"internalTransactionId": transaction_id,
"internalTransactionId": transaction.get("internalTransactionId"),
"institutionId": account_info["institution_id"],
"iban": account_info.get("iban", "N/A"),
"transactionDate": min_date,
@@ -121,6 +116,7 @@ class DatabaseService:
min_amount: Optional[float] = None,
max_amount: Optional[float] = None,
search: Optional[str] = None,
hide_missing_ids: bool = True,
) -> List[Dict[str, Any]]:
"""Get transactions from SQLite database"""
if not self.sqlite_enabled:
@@ -137,6 +133,7 @@ class DatabaseService:
min_amount=min_amount,
max_amount=max_amount,
search=search,
hide_missing_ids=hide_missing_ids,
)
logger.debug(f"Retrieved {len(transactions)} transactions from database")
return transactions
@@ -152,6 +149,7 @@ class DatabaseService:
min_amount: Optional[float] = None,
max_amount: Optional[float] = None,
search: Optional[str] = None,
hide_missing_ids: bool = True,
) -> int:
"""Get total count of transactions from SQLite database"""
if not self.sqlite_enabled:
@@ -168,7 +166,9 @@ class DatabaseService:
# Remove None values
filters = {k: v for k, v in filters.items() if v is not None}
count = sqlite_db.get_transaction_count(account_id=account_id, **filters)
count = sqlite_db.get_transaction_count(
account_id=account_id, hide_missing_ids=hide_missing_ids, **filters
)
logger.debug(f"Total transaction count: {count}")
return count
except Exception as e:
@@ -259,7 +259,6 @@ class DatabaseService:
return
await self._migrate_balance_timestamps_if_needed()
await self._migrate_null_transaction_ids_if_needed()
async def _migrate_balance_timestamps_if_needed(self):
"""Check and migrate balance timestamps if needed"""
@@ -380,145 +379,6 @@ class DatabaseService:
logger.error(f"Balance timestamp migration failed: {e}")
raise
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"""
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
if not db_path.exists():
return False
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Check for transactions with null or empty internalTransactionId
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"""
import uuid
from pathlib import Path
db_path = Path.home() / ".config" / "leggen" / "leggen.db"
if not db_path.exists():
logger.warning("Database file not found, skipping migration")
return
try:
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Get all transactions with null/empty internalTransactionId but valid transactionId in raw data
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"
)
# Update in batches
batch_size = 100
migrated_count = 0
skipped_duplicates = 0
for i in range(0, total_records, batch_size):
batch = null_records[i : i + batch_size]
for rowid, transaction_id in batch:
try:
# Check if this transactionId is already used by another record
cursor.execute(
"SELECT COUNT(*) FROM transactions WHERE internalTransactionId = ?",
(str(transaction_id),),
)
existing_count = cursor.fetchone()[0]
if existing_count > 0:
# Generate a unique ID to avoid constraint violation
unique_id = f"{str(transaction_id)}_{uuid.uuid4().hex[:8]}"
logger.debug(
f"Generated unique ID for duplicate transactionId: {unique_id}"
)
else:
# Use the original transactionId
unique_id = str(transaction_id)
# Update the record
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
# Commit batch
conn.commit()
conn.close()
logger.info(f"Successfully migrated {migrated_count} transaction records")
if skipped_duplicates > 0:
logger.info(
f"Generated unique IDs for {skipped_duplicates} duplicate transactionIds"
)
except Exception as e:
logger.error(f"Null transaction IDs 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)

View File

@@ -63,29 +63,14 @@ class NotificationService:
) -> List[Dict[str, Any]]:
"""Filter transactions based on notification criteria"""
matching = []
filters_case_insensitive = self.filters_config.get("case-insensitive", [])
filters_case_sensitive = self.filters_config.get("case-sensitive", [])
filters_case_insensitive = self.filters_config.get("case-insensitive", {})
for transaction in transactions:
description = transaction.get("description", "")
description_lower = description.lower()
description = transaction.get("description", "").lower()
# Check case-insensitive filters
for filter_value in filters_case_insensitive:
if filter_value.lower() in description_lower:
matching.append(
{
"name": transaction["description"],
"value": transaction["transactionValue"],
"currency": transaction["transactionCurrency"],
"date": transaction["transactionDate"],
}
)
break
# Check case-sensitive filters
for filter_value in filters_case_sensitive:
if filter_value in description:
for _filter_name, filter_value in filters_case_insensitive.items():
if filter_value.lower() in description:
matching.append(
{
"name": transaction["description"],

View File

@@ -1,9 +1,9 @@
[project]
name = "leggen"
version = "2025.9.2"
version = "0.6.11"
description = "An Open Banking CLI"
authors = [{ name = "Elisiário Couto", email = "elisiario@couto.io" }]
requires-python = "~=3.13.0"
requires-python = "~=3.12.0"
readme = "README.md"
license = "MIT"
keywords = [

View File

@@ -1,4 +1,4 @@
#!/usr/bin/env bash
#!/bin/bash
set -ef -o pipefail
@@ -13,37 +13,23 @@ check_command git
check_command git-cliff
check_command uv
# Get current date components
YEAR=$(date +%Y)
MONTH=$(date +%-m) # %-m removes zero padding
# Get the latest version for current year and month
LATEST_TAG=$(git tag -l "${YEAR}.${MONTH}.*" | sort -V | tail -n 1)
if [ -z "$LATEST_TAG" ]; then
# No version for current year/month exists, start at 0
MICRO=0
else
# Extract micro version and increment
MICRO=$(echo "$LATEST_TAG" | cut -d. -f3)
MICRO=$((MICRO + 1))
if [ -z "$1" ]; then
echo " > No semver verb specified, run release with <major|minor|patch> parameter."
exit 1
fi
NEXT_VERSION="${YEAR}.${MONTH}.${MICRO}"
CURRENT_VERSION=$(uv version --short)
CURRENT_VERSION=$(uvx poetry version -s)
echo " > Current version is $CURRENT_VERSION"
echo " > Setting new version to $NEXT_VERSION"
# Manually update version in pyproject.toml
sed -i '' "s/^version = .*/version = \"${NEXT_VERSION}\"/" pyproject.toml
uvx poetry version "$1"
NEXT_VERSION=$(uvx poetry version -s)
echo " > Version bumped to $NEXT_VERSION"
echo " > leggen bumped to $NEXT_VERSION"
echo "Updating CHANGELOG.md"
git-cliff --unreleased --tag "$NEXT_VERSION" --prepend CHANGELOG.md > /dev/null
echo " > Commiting changes and adding git tag"
git add pyproject.toml CHANGELOG.md uv.lock
git add pyproject.toml CHANGELOG.md
git commit -m "chore(ci): Bump version to $NEXT_VERSION"
git tag -a "$NEXT_VERSION" -m "$NEXT_VERSION"

View File

@@ -166,6 +166,7 @@ class TestTransactionsAPI:
min_amount=-50.0,
max_amount=0.0,
search="Coffee",
hide_missing_ids=True,
)
def test_get_transactions_empty_result(

View File

@@ -188,8 +188,8 @@ class TestConfig:
"""Test filters configuration access."""
custom_config = {
"filters": {
"case-insensitive": ["salary", "utility"],
"case-sensitive": ["SpecificStore"],
"case-insensitive": {"salary": "SALARY", "bills": "BILL"},
"amount_threshold": 100.0,
}
}
@@ -197,6 +197,5 @@ class TestConfig:
config._config = custom_config
filters = config.filters_config
assert "salary" in filters["case-insensitive"]
assert "utility" in filters["case-insensitive"]
assert "SpecificStore" in filters["case-sensitive"]
assert filters["case-insensitive"]["salary"] == "SALARY"
assert filters["amount_threshold"] == 100.0

View File

@@ -99,6 +99,7 @@ class TestDatabaseService:
min_amount=None,
max_amount=None,
search=None,
hide_missing_ids=True,
)
async def test_get_transactions_from_db_with_filters(
@@ -129,6 +130,7 @@ class TestDatabaseService:
min_amount=-50.0,
max_amount=0.0,
search="Coffee",
hide_missing_ids=True,
)
async def test_get_transactions_from_db_sqlite_disabled(self, database_service):
@@ -158,7 +160,9 @@ 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", hide_missing_ids=True
)
async def test_get_transaction_count_from_db_with_filters(self, database_service):
"""Test getting transaction count with filters."""
@@ -178,6 +182,7 @@ class TestDatabaseService:
date_from="2025-09-01",
min_amount=-100.0,
search="Coffee",
hide_missing_ids=True,
)
async def test_get_transaction_count_from_db_sqlite_disabled(

177
uv.lock generated
View File

@@ -1,6 +1,6 @@
version = 1
revision = 3
requires-python = "==3.13.*"
requires-python = "==3.12.*"
[[package]]
name = "annotated-types"
@@ -18,6 +18,7 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions" },
]
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" }
wheels = [
@@ -60,19 +61,19 @@ version = "3.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188, upload-time = "2024-12-24T18:12:35.43Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698, upload-time = "2024-12-24T18:11:05.834Z" },
{ url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162, upload-time = "2024-12-24T18:11:07.064Z" },
{ url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263, upload-time = "2024-12-24T18:11:08.374Z" },
{ url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966, upload-time = "2024-12-24T18:11:09.831Z" },
{ url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992, upload-time = "2024-12-24T18:11:12.03Z" },
{ url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162, upload-time = "2024-12-24T18:11:13.372Z" },
{ url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972, upload-time = "2024-12-24T18:11:14.628Z" },
{ url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095, upload-time = "2024-12-24T18:11:17.672Z" },
{ url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668, upload-time = "2024-12-24T18:11:18.989Z" },
{ url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073, upload-time = "2024-12-24T18:11:21.507Z" },
{ url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732, upload-time = "2024-12-24T18:11:22.774Z" },
{ url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload-time = "2024-12-24T18:11:24.139Z" },
{ url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload-time = "2024-12-24T18:11:26.535Z" },
{ url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105, upload-time = "2024-12-24T18:10:38.83Z" },
{ url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404, upload-time = "2024-12-24T18:10:44.272Z" },
{ url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423, upload-time = "2024-12-24T18:10:45.492Z" },
{ url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184, upload-time = "2024-12-24T18:10:47.898Z" },
{ url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268, upload-time = "2024-12-24T18:10:50.589Z" },
{ url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601, upload-time = "2024-12-24T18:10:52.541Z" },
{ url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098, upload-time = "2024-12-24T18:10:53.789Z" },
{ url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520, upload-time = "2024-12-24T18:10:55.048Z" },
{ url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852, upload-time = "2024-12-24T18:10:57.647Z" },
{ url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488, upload-time = "2024-12-24T18:10:59.43Z" },
{ url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192, upload-time = "2024-12-24T18:11:00.676Z" },
{ url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550, upload-time = "2024-12-24T18:11:01.952Z" },
{ url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785, upload-time = "2024-12-24T18:11:03.142Z" },
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload-time = "2024-12-24T18:12:32.852Z" },
]
@@ -169,13 +170,13 @@ version = "0.6.4"
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" }
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/bb/0e/d0b71465c66b9185f90a091ab36389a7352985fe857e352801c39d6127c8/httptools-0.6.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df017d6c780287d5c80601dafa31f17bddb170232d85c066604d8558683711a2", size = 200683, upload-time = "2024-10-16T19:44:30.175Z" },
{ url = "https://files.pythonhosted.org/packages/e2/b8/412a9bb28d0a8988de3296e01efa0bd62068b33856cdda47fe1b5e890954/httptools-0.6.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:85071a1e8c2d051b507161f6c3e26155b5c790e4e28d7f236422dbacc2a9cc44", size = 104337, upload-time = "2024-10-16T19:44:31.786Z" },
{ url = "https://files.pythonhosted.org/packages/9b/01/6fb20be3196ffdc8eeec4e653bc2a275eca7f36634c86302242c4fbb2760/httptools-0.6.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69422b7f458c5af875922cdb5bd586cc1f1033295aa9ff63ee196a87519ac8e1", size = 508796, upload-time = "2024-10-16T19:44:32.825Z" },
{ url = "https://files.pythonhosted.org/packages/f7/d8/b644c44acc1368938317d76ac991c9bba1166311880bcc0ac297cb9d6bd7/httptools-0.6.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16e603a3bff50db08cd578d54f07032ca1631450ceb972c2f834c2b860c28ea2", size = 510837, upload-time = "2024-10-16T19:44:33.974Z" },
{ url = "https://files.pythonhosted.org/packages/52/d8/254d16a31d543073a0e57f1c329ca7378d8924e7e292eda72d0064987486/httptools-0.6.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec4f178901fa1834d4a060320d2f3abc5c9e39766953d038f1458cb885f47e81", size = 485289, upload-time = "2024-10-16T19:44:35.111Z" },
{ url = "https://files.pythonhosted.org/packages/5f/3c/4aee161b4b7a971660b8be71a92c24d6c64372c1ab3ae7f366b3680df20f/httptools-0.6.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f9eb89ecf8b290f2e293325c646a211ff1c2493222798bb80a530c5e7502494f", size = 489779, upload-time = "2024-10-16T19:44:36.253Z" },
{ url = "https://files.pythonhosted.org/packages/12/b7/5cae71a8868e555f3f67a50ee7f673ce36eac970f029c0c5e9d584352961/httptools-0.6.4-cp312-cp312-win_amd64.whl", hash = "sha256:db78cb9ca56b59b016e64b6031eda5653be0589dba2b1b43453f6e8b405a0970", size = 88634, upload-time = "2024-10-16T19:44:37.357Z" },
]
[[package]]
@@ -222,7 +223,7 @@ wheels = [
[[package]]
name = "leggen"
version = "2025.9.2"
version = "0.6.11"
source = { editable = "." }
dependencies = [
{ name = "apscheduler" },
@@ -303,12 +304,12 @@ dependencies = [
]
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" }
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/17/a2/7034d0d61af8098ec47902108553122baa0f438df8a713be860f7407c9e6/mypy-1.17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:69e83ea6553a3ba79c08c6e15dbd9bfa912ec1e493bf75489ef93beb65209aeb", size = 11086295, upload-time = "2025-07-31T07:53:28.124Z" },
{ url = "https://files.pythonhosted.org/packages/14/1f/19e7e44b594d4b12f6ba8064dbe136505cec813549ca3e5191e40b1d3cc2/mypy-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1b16708a66d38abb1e6b5702f5c2c87e133289da36f6a1d15f6a5221085c6403", size = 10112355, upload-time = "2025-07-31T07:53:21.121Z" },
{ url = "https://files.pythonhosted.org/packages/5b/69/baa33927e29e6b4c55d798a9d44db5d394072eef2bdc18c3e2048c9ed1e9/mypy-1.17.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:89e972c0035e9e05823907ad5398c5a73b9f47a002b22359b177d40bdaee7056", size = 11875285, upload-time = "2025-07-31T07:53:55.293Z" },
{ url = "https://files.pythonhosted.org/packages/90/13/f3a89c76b0a41e19490b01e7069713a30949d9a6c147289ee1521bcea245/mypy-1.17.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03b6d0ed2b188e35ee6d5c36b5580cffd6da23319991c49ab5556c023ccf1341", size = 12737895, upload-time = "2025-07-31T07:53:43.623Z" },
{ url = "https://files.pythonhosted.org/packages/23/a1/c4ee79ac484241301564072e6476c5a5be2590bc2e7bfd28220033d2ef8f/mypy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c837b896b37cd103570d776bda106eabb8737aa6dd4f248451aecf53030cdbeb", size = 12931025, upload-time = "2025-07-31T07:54:17.125Z" },
{ url = "https://files.pythonhosted.org/packages/89/b8/7409477be7919a0608900e6320b155c72caab4fef46427c5cc75f85edadd/mypy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:665afab0963a4b39dff7c1fa563cc8b11ecff7910206db4b2e64dd1ba25aed19", size = 9584664, upload-time = "2025-07-31T07:54:12.842Z" },
{ 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" },
]
@@ -406,23 +407,20 @@ dependencies = [
]
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" }
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/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" },
{ url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" },
{ url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" },
{ url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" },
{ url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" },
{ url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" },
{ url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" },
{ url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" },
{ url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" },
{ url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" },
{ url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" },
]
[[package]]
@@ -489,15 +487,15 @@ version = "6.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
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/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" },
{ url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" },
{ url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" },
{ url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" },
{ url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" },
{ url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" },
{ url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" },
{ url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" },
{ url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" },
]
[[package]]
@@ -579,6 +577,7 @@ version = "0.47.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions" },
]
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" }
wheels = [
@@ -705,12 +704,12 @@ version = "0.21.0"
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" }
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/8c/4c/03f93178830dc7ce8b4cdee1d36770d2f5ebb6f3d37d354e061eefc73545/uvloop-0.21.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:359ec2c888397b9e592a889c4d72ba3d6befba8b2bb01743f72fffbde663b59c", size = 1471284, upload-time = "2024-10-14T23:37:47.833Z" },
{ url = "https://files.pythonhosted.org/packages/43/3e/92c03f4d05e50f09251bd8b2b2b584a2a7f8fe600008bcc4523337abe676/uvloop-0.21.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7089d2dc73179ce5ac255bdf37c236a9f914b264825fdaacaded6990a7fb4c2", size = 821349, upload-time = "2024-10-14T23:37:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/a6/ef/a02ec5da49909dbbfb1fd205a9a1ac4e88ea92dcae885e7c961847cd51e2/uvloop-0.21.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:baa4dcdbd9ae0a372f2167a207cd98c9f9a1ea1188a8a526431eef2f8116cc8d", size = 4580089, upload-time = "2024-10-14T23:37:51.703Z" },
{ url = "https://files.pythonhosted.org/packages/06/a7/b4e6a19925c900be9f98bec0a75e6e8f79bb53bdeb891916609ab3958967/uvloop-0.21.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86975dca1c773a2c9864f4c52c5a55631038e387b47eaf56210f873887b6c8dc", size = 4693770, upload-time = "2024-10-14T23:37:54.122Z" },
{ url = "https://files.pythonhosted.org/packages/ce/0c/f07435a18a4b94ce6bd0677d8319cd3de61f3a9eeb1e5f8ab4e8b5edfcb3/uvloop-0.21.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:461d9ae6660fbbafedd07559c6a2e57cd553b34b0065b6550685f6653a98c1cb", size = 4451321, upload-time = "2024-10-14T23:37:55.766Z" },
{ url = "https://files.pythonhosted.org/packages/8f/eb/f7032be105877bcf924709c97b1bf3b90255b4ec251f9340cef912559f28/uvloop-0.21.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:183aef7c8730e54c9a3ee3227464daed66e37ba13040bb3f350bc2ddc040f22f", size = 4659022, upload-time = "2024-10-14T23:37:58.195Z" },
]
[[package]]
@@ -736,29 +735,19 @@ dependencies = [
]
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" }
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/f6/b8/858957045a38a4079203a33aaa7d23ea9269ca7761c8a074af3524fbb240/watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179", size = 402339, upload-time = "2025-06-15T19:05:24.516Z" },
{ url = "https://files.pythonhosted.org/packages/80/28/98b222cca751ba68e88521fabd79a4fab64005fc5976ea49b53fa205d1fa/watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5", size = 394409, upload-time = "2025-06-15T19:05:25.469Z" },
{ url = "https://files.pythonhosted.org/packages/86/50/dee79968566c03190677c26f7f47960aff738d32087087bdf63a5473e7df/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297", size = 450939, upload-time = "2025-06-15T19:05:26.494Z" },
{ url = "https://files.pythonhosted.org/packages/40/45/a7b56fb129700f3cfe2594a01aa38d033b92a33dddce86c8dfdfc1247b72/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0", size = 457270, upload-time = "2025-06-15T19:05:27.466Z" },
{ url = "https://files.pythonhosted.org/packages/b5/c8/fa5ef9476b1d02dc6b5e258f515fcaaecf559037edf8b6feffcbc097c4b8/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e", size = 483370, upload-time = "2025-06-15T19:05:28.548Z" },
{ url = "https://files.pythonhosted.org/packages/98/68/42cfcdd6533ec94f0a7aab83f759ec11280f70b11bfba0b0f885e298f9bd/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee", size = 598654, upload-time = "2025-06-15T19:05:29.997Z" },
{ url = "https://files.pythonhosted.org/packages/d3/74/b2a1544224118cc28df7e59008a929e711f9c68ce7d554e171b2dc531352/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd", size = 478667, upload-time = "2025-06-15T19:05:31.172Z" },
{ url = "https://files.pythonhosted.org/packages/8c/77/e3362fe308358dc9f8588102481e599c83e1b91c2ae843780a7ded939a35/watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f", size = 452213, upload-time = "2025-06-15T19:05:32.299Z" },
{ url = "https://files.pythonhosted.org/packages/6e/17/c8f1a36540c9a1558d4faf08e909399e8133599fa359bf52ec8fcee5be6f/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4", size = 626718, upload-time = "2025-06-15T19:05:33.415Z" },
{ url = "https://files.pythonhosted.org/packages/26/45/fb599be38b4bd38032643783d7496a26a6f9ae05dea1a42e58229a20ac13/watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f", size = 623098, upload-time = "2025-06-15T19:05:34.534Z" },
{ url = "https://files.pythonhosted.org/packages/a1/e7/fdf40e038475498e160cd167333c946e45d8563ae4dd65caf757e9ffe6b4/watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd", size = 279209, upload-time = "2025-06-15T19:05:35.577Z" },
{ url = "https://files.pythonhosted.org/packages/3f/d3/3ae9d5124ec75143bdf088d436cba39812122edc47709cd2caafeac3266f/watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47", size = 292786, upload-time = "2025-06-15T19:05:36.559Z" },
{ url = "https://files.pythonhosted.org/packages/26/2f/7dd4fc8b5f2b34b545e19629b4a018bfb1de23b3a496766a2c1165ca890d/watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6", size = 284343, upload-time = "2025-06-15T19:05:37.5Z" },
]
[[package]]
@@ -767,17 +756,17 @@ version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]