feat: Add mypy to pre-commit.

This commit is contained in:
Elisiário Couto
2025-09-03 21:40:15 +01:00
committed by Elisiário Couto
parent de3da84dff
commit ec8ef8346a
34 changed files with 226 additions and 242 deletions

View File

@@ -4,9 +4,11 @@
"Bash(mkdir:*)", "Bash(mkdir:*)",
"Bash(uv sync:*)", "Bash(uv sync:*)",
"Bash(uv run pytest:*)", "Bash(uv run pytest:*)",
"Bash(git commit:*)" "Bash(git commit:*)",
"Bash(ruff check:*)",
"Bash(git add:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []
} }
} }

View File

@@ -1,13 +1,23 @@
repos: repos:
- repo: https://github.com/charliermarsh/ruff-pre-commit - repo: https://github.com/charliermarsh/ruff-pre-commit
rev: "v0.9.1" rev: "v0.12.11"
hooks: hooks:
- id: ruff - id: ruff
- id: ruff-format - id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v6.0.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
exclude: ".*\\.md$" exclude: ".*\\.md$"
- id: end-of-file-fixer - id: end-of-file-fixer
- id: check-added-large-files - id: check-added-large-files
- repo: local
hooks:
- id: mypy
name: Static type check with mypy
entry: uv run mypy leggen leggend --check-untyped-defs
files: "^leggen(d)?/.*"
language: "system"
types: ["python"]
always_run: true
pass_filenames: false

View File

@@ -66,4 +66,4 @@ All operations require a valid `config.toml` file with GoCardless API credential
- `[gocardless]` - API credentials and endpoint - `[gocardless]` - API credentials and endpoint
- `[database]` - Storage backend selection - `[database]` - Storage backend selection
- `[notifications]` - Discord/Telegram webhook settings - `[notifications]` - Discord/Telegram webhook settings
- `[filters]` - Transaction matching patterns for notifications - `[filters]` - Transaction matching patterns for notifications

View File

@@ -88,4 +88,4 @@ Transform leggen from CLI-only to web application with FastAPI backend (`leggend
- **APScheduler**: For internal job scheduling (replacing Ofelia) - **APScheduler**: For internal job scheduling (replacing Ofelia)
- **SvelteKit**: For modern, reactive frontend - **SvelteKit**: For modern, reactive frontend
- **Existing Logic**: Reuse all business logic from current CLI commands - **Existing Logic**: Reuse all business logic from current CLI commands
- **Configuration**: Centralize in `leggend` service, maintain TOML compatibility - **Configuration**: Centralize in `leggend` service, maintain TOML compatibility

0
leggen/__init__.py Normal file
View File

View File

@@ -1,6 +1,6 @@
import os import os
import requests import requests
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List, Union
from urllib.parse import urljoin from urllib.parse import urljoin
from leggen.utils.text import error from leggen.utils.text import error
@@ -9,9 +9,13 @@ from leggen.utils.text import error
class LeggendAPIClient: class LeggendAPIClient:
"""Client for communicating with the leggend FastAPI service""" """Client for communicating with the leggend FastAPI service"""
base_url: str
def __init__(self, base_url: Optional[str] = None): def __init__(self, base_url: Optional[str] = None):
self.base_url = base_url or os.environ.get( self.base_url = (
"LEGGEND_API_URL", "http://localhost:8000" base_url
or os.environ.get("LEGGEND_API_URL", "http://localhost:8000")
or "http://localhost:8000"
) )
self.session = requests.Session() self.session = requests.Session()
self.session.headers.update( self.session.headers.update(
@@ -36,7 +40,7 @@ class LeggendAPIClient:
try: try:
error_data = response.json() error_data = response.json()
error(f"Error details: {error_data.get('detail', 'Unknown error')}") error(f"Error details: {error_data.get('detail', 'Unknown error')}")
except: except Exception:
error(f"Response: {response.text}") error(f"Response: {response.text}")
raise raise
except Exception as e: except Exception as e:
@@ -48,7 +52,7 @@ class LeggendAPIClient:
try: try:
response = self._make_request("GET", "/health") response = self._make_request("GET", "/health")
return response.get("status") == "healthy" return response.get("status") == "healthy"
except: except Exception:
return False return False
# Bank endpoints # Bank endpoints
@@ -122,7 +126,7 @@ class LeggendAPIClient:
self, days: int = 30, account_id: Optional[str] = None self, days: int = 30, account_id: Optional[str] = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Get transaction statistics""" """Get transaction statistics"""
params = {"days": days} params: Dict[str, Union[int, str]] = {"days": days}
if account_id: if account_id:
params["account_id"] = account_id params["account_id"] = account_id
@@ -141,7 +145,7 @@ class LeggendAPIClient:
self, account_ids: Optional[List[str]] = None, force: bool = False self, account_ids: Optional[List[str]] = None, force: bool = False
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Trigger a sync""" """Trigger a sync"""
data = {"force": force} data: Dict[str, Union[bool, List[str]]] = {"force": force}
if account_ids: if account_ids:
data["account_ids"] = account_ids data["account_ids"] = account_ids
@@ -152,7 +156,7 @@ class LeggendAPIClient:
self, account_ids: Optional[List[str]] = None, force: bool = False self, account_ids: Optional[List[str]] = None, force: bool = False
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Run sync synchronously""" """Run sync synchronously"""
data = {"force": force} data: Dict[str, Union[bool, List[str]]] = {"force": force}
if account_ids: if account_ids:
data["account_ids"] = account_ids data["account_ids"] = account_ids
@@ -172,7 +176,11 @@ class LeggendAPIClient:
cron: Optional[str] = None, cron: Optional[str] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Update scheduler configuration""" """Update scheduler configuration"""
data = {"enabled": enabled, "hour": hour, "minute": minute} data: Dict[str, Union[bool, int, str]] = {
"enabled": enabled,
"hour": hour,
"minute": minute,
}
if cron: if cron:
data["cron"] = cron data["cron"] = cron

View File

@@ -68,7 +68,7 @@ def add(ctx):
success("Bank connection request created successfully!") success("Bank connection request created successfully!")
warning( warning(
f"Please open the following URL in your browser to complete the authorization:" "Please open the following URL in your browser to complete the authorization:"
) )
click.echo(f"\n{result['link']}\n") click.echo(f"\n{result['link']}\n")

View File

@@ -1,7 +1,6 @@
import click import click
from leggen.main import cli from leggen.main import cli
from leggen.utils.network import delete as http_delete
from leggen.utils.text import info, success from leggen.utils.text import info, success
@@ -16,11 +15,12 @@ def delete(ctx, requisition_id: str):
Check `leggen status` to get the REQUISITION_ID Check `leggen status` to get the REQUISITION_ID
""" """
import requests
info(f"Deleting Bank Requisition: {requisition_id}") info(f"Deleting Bank Requisition: {requisition_id}")
_ = http_delete( api_url = ctx.obj.get("api_url", "http://localhost:8000")
ctx, res = requests.delete(f"{api_url}/requisitions/{requisition_id}")
f"/requisitions/{requisition_id}", res.raise_for_status()
)
success(f"Bank Requisition {requisition_id} deleted") success(f"Bank Requisition {requisition_id} deleted")

View File

@@ -27,7 +27,7 @@ def sync(ctx: click.Context, wait: bool, force: bool):
result = api_client.sync_now(force=force) result = api_client.sync_now(force=force)
if result.get("success"): if result.get("success"):
success(f"Sync completed successfully!") success("Sync completed successfully!")
info(f"Accounts processed: {result.get('accounts_processed', 0)}") info(f"Accounts processed: {result.get('accounts_processed', 0)}")
info(f"Transactions added: {result.get('transactions_added', 0)}") info(f"Transactions added: {result.get('transactions_added', 0)}")
info(f"Balances updated: {result.get('balances_updated', 0)}") info(f"Balances updated: {result.get('balances_updated', 0)}")

View File

@@ -5,7 +5,6 @@ from pathlib import Path
import click import click
from leggen.utils.auth import get_token
from leggen.utils.config import load_config from leggen.utils.config import load_config
from leggen.utils.text import error from leggen.utils.text import error
@@ -111,14 +110,4 @@ def cli(ctx: click.Context, api_url: str):
return return
# Store API URL in context for commands to use # Store API URL in context for commands to use
if api_url: ctx.obj["api_url"] = api_url
ctx.obj["api_url"] = api_url
# For backwards compatibility, still support direct GoCardless calls
# This will be used as fallback if leggend service is not available
try:
token = get_token(ctx)
ctx.obj["headers"] = {"Authorization": f"Bearer {token}"}
except Exception:
# If we can't get token, commands will rely on API service
pass

View File

@@ -1,62 +0,0 @@
import json
from pathlib import Path
import click
import requests
from leggen.utils.text import warning
def create_token(ctx: click.Context) -> str:
"""
Create a new token
"""
res = requests.post(
f"{ctx.obj['gocardless']['url']}/token/new/",
json={
"secret_id": ctx.obj["gocardless"]["key"],
"secret_key": ctx.obj["gocardless"]["secret"],
},
)
res.raise_for_status()
auth = res.json()
save_auth(auth)
return auth["access"]
def get_token(ctx: click.Context) -> str:
"""
Get the token from the auth file or request a new one
"""
auth_file = Path.home() / ".config" / "leggen" / "auth.json"
if auth_file.exists():
with click.open_file(str(auth_file), "r") as f:
auth = json.load(f)
if not auth.get("access"):
return create_token(ctx)
res = requests.post(
f"{ctx.obj['gocardless']['url']}/token/refresh/",
json={"refresh": auth["refresh"]},
)
try:
res.raise_for_status()
auth.update(res.json())
save_auth(auth)
return auth["access"]
except requests.exceptions.HTTPError:
warning(
f"Token probably expired, requesting a new one.\nResponse: {res.status_code}\n{res.text}"
)
return create_token(ctx)
else:
return create_token(ctx)
def save_auth(d: dict):
auth_dir = Path.home() / ".config" / "leggen"
auth_dir.mkdir(parents=True, exist_ok=True)
auth_file = auth_dir / "auth.json"
with click.open_file(str(auth_file), "w") as f:
json.dump(d, f)

View File

@@ -3,7 +3,6 @@ from datetime import datetime
import click import click
import leggen.database.sqlite as sqlite_engine import leggen.database.sqlite as sqlite_engine
from leggen.utils.network import get
from leggen.utils.text import info, warning from leggen.utils.text import info, warning
@@ -32,15 +31,21 @@ def persist_transactions(ctx: click.Context, account: str, transactions: list) -
def save_transactions(ctx: click.Context, account: str) -> list: def save_transactions(ctx: click.Context, account: str) -> list:
import requests
api_url = ctx.obj.get("api_url", "http://localhost:8000")
info(f"[{account}] Getting account details") info(f"[{account}] Getting account details")
account_info = get(ctx, f"/accounts/{account}") res = requests.get(f"{api_url}/accounts/{account}")
res.raise_for_status()
account_info = res.json()
info(f"[{account}] Getting transactions") info(f"[{account}] Getting transactions")
transactions = [] transactions = []
account_transactions = get(ctx, f"/accounts/{account}/transactions/").get( res = requests.get(f"{api_url}/accounts/{account}/transactions/")
"transactions", [] res.raise_for_status()
) account_transactions = res.json().get("transactions", [])
for transaction in account_transactions.get("booked", []): for transaction in account_transactions.get("booked", []):
booked_date = transaction.get("bookingDateTime") or transaction.get( booked_date = transaction.get("bookingDateTime") or transaction.get(

View File

@@ -1,64 +0,0 @@
import click
import requests
from leggen.utils.text import error
def get(ctx: click.Context, path: str, params: dict = {}):
"""
GET request to the GoCardless API
"""
url = f"{ctx.obj['gocardless']['url']}{path}"
res = requests.get(url, headers=ctx.obj["headers"], params=params)
try:
res.raise_for_status()
except Exception as e:
error(f"Error: {e}\n{res.text}")
ctx.abort()
return res.json()
def post(ctx: click.Context, path: str, data: dict = {}):
"""
POST request to the GoCardless API
"""
url = f"{ctx.obj['gocardless']['url']}{path}"
res = requests.post(url, headers=ctx.obj["headers"], json=data)
try:
res.raise_for_status()
except Exception as e:
error(f"Error: {e}\n{res.text}")
ctx.abort()
return res.json()
def put(ctx: click.Context, path: str, data: dict = {}):
"""
PUT request to the GoCardless API
"""
url = f"{ctx.obj['gocardless']['url']}{path}"
res = requests.put(url, headers=ctx.obj["headers"], json=data)
try:
res.raise_for_status()
except Exception as e:
error(f"Error: {e}\n{res.text}")
ctx.abort()
return res.json()
def delete(ctx: click.Context, path: str):
"""
DELETE request to the GoCardless API
"""
url = f"{ctx.obj['gocardless']['url']}{path}"
res = requests.delete(url, headers=ctx.obj["headers"])
try:
res.raise_for_status()
except Exception as e:
error(f"Error: {e}\n{res.text}")
ctx.abort()
return res.json()

0
leggend/__init__.py Normal file
View File

View File

@@ -1,4 +1,3 @@
from datetime import datetime
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
from pydantic import BaseModel from pydantic import BaseModel

View File

@@ -1,4 +1,4 @@
from typing import Dict, Any, Optional, List from typing import Dict, Optional, List
from pydantic import BaseModel from pydantic import BaseModel

View File

@@ -1,5 +1,5 @@
from datetime import datetime from datetime import datetime
from typing import Optional, Dict, Any from typing import Optional
from pydantic import BaseModel from pydantic import BaseModel

View File

@@ -1,4 +1,4 @@
from typing import List, Optional from typing import Optional, List, Union
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from loguru import logger from loguru import logger
@@ -74,7 +74,9 @@ async def get_all_accounts() -> APIResponse:
except Exception as e: except Exception as e:
logger.error(f"Failed to get accounts: {e}") logger.error(f"Failed to get accounts: {e}")
raise HTTPException(status_code=500, detail=f"Failed to get accounts: {str(e)}") raise HTTPException(
status_code=500, detail=f"Failed to get accounts: {str(e)}"
) from e
@router.get("/accounts/{account_id}", response_model=APIResponse) @router.get("/accounts/{account_id}", response_model=APIResponse)
@@ -117,7 +119,9 @@ async def get_account_details(account_id: str) -> APIResponse:
except Exception as e: except Exception as e:
logger.error(f"Failed to get account details for {account_id}: {e}") logger.error(f"Failed to get account details for {account_id}: {e}")
raise HTTPException(status_code=404, detail=f"Account not found: {str(e)}") raise HTTPException(
status_code=404, detail=f"Account not found: {str(e)}"
) from e
@router.get("/accounts/{account_id}/balances", response_model=APIResponse) @router.get("/accounts/{account_id}/balances", response_model=APIResponse)
@@ -146,7 +150,9 @@ async def get_account_balances(account_id: str) -> APIResponse:
except Exception as e: except Exception as e:
logger.error(f"Failed to get balances for account {account_id}: {e}") logger.error(f"Failed to get balances for account {account_id}: {e}")
raise HTTPException(status_code=404, detail=f"Failed to get balances: {str(e)}") raise HTTPException(
status_code=404, detail=f"Failed to get balances: {str(e)}"
) from e
@router.get("/accounts/{account_id}/transactions", response_model=APIResponse) @router.get("/accounts/{account_id}/transactions", response_model=APIResponse)
@@ -172,11 +178,17 @@ async def get_account_transactions(
# Apply pagination # Apply pagination
total_transactions = len(processed_transactions) total_transactions = len(processed_transactions)
paginated_transactions = processed_transactions[offset : offset + limit] actual_offset = offset or 0
actual_limit = limit or 100
paginated_transactions = processed_transactions[
actual_offset : actual_offset + actual_limit
]
data: Union[List[TransactionSummary], List[Transaction]]
if summary_only: if summary_only:
# Return simplified transaction summaries # Return simplified transaction summaries
summaries = [ data = [
TransactionSummary( TransactionSummary(
internal_transaction_id=txn["internalTransactionId"], internal_transaction_id=txn["internalTransactionId"],
date=txn["transactionDate"], date=txn["transactionDate"],
@@ -188,10 +200,9 @@ async def get_account_transactions(
) )
for txn in paginated_transactions for txn in paginated_transactions
] ]
data = summaries
else: else:
# Return full transaction details # Return full transaction details
transactions = [ data = [
Transaction( Transaction(
internal_transaction_id=txn["internalTransactionId"], internal_transaction_id=txn["internalTransactionId"],
institution_id=txn["institutionId"], institution_id=txn["institutionId"],
@@ -206,16 +217,15 @@ async def get_account_transactions(
) )
for txn in paginated_transactions for txn in paginated_transactions
] ]
data = transactions
return APIResponse( return APIResponse(
success=True, success=True,
data=data, data=data,
message=f"Retrieved {len(data)} transactions (showing {offset + 1}-{offset + len(data)} of {total_transactions})", message=f"Retrieved {len(data)} transactions (showing {actual_offset + 1}-{actual_offset + len(data)} of {total_transactions})",
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to get transactions for account {account_id}: {e}") logger.error(f"Failed to get transactions for account {account_id}: {e}")
raise HTTPException( raise HTTPException(
status_code=404, detail=f"Failed to get transactions: {str(e)}" status_code=404, detail=f"Failed to get transactions: {str(e)}"
) ) from e

View File

@@ -1,8 +1,7 @@
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from loguru import logger from loguru import logger
from leggend.api.models.common import APIResponse, ErrorResponse from leggend.api.models.common import APIResponse
from leggend.api.models.banks import ( from leggend.api.models.banks import (
BankInstitution, BankInstitution,
BankConnectionRequest, BankConnectionRequest,
@@ -46,15 +45,16 @@ async def get_bank_institutions(
logger.error(f"Failed to get institutions for {country}: {e}") logger.error(f"Failed to get institutions for {country}: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to get institutions: {str(e)}" status_code=500, detail=f"Failed to get institutions: {str(e)}"
) ) from e
@router.post("/banks/connect", response_model=APIResponse) @router.post("/banks/connect", response_model=APIResponse)
async def connect_to_bank(request: BankConnectionRequest) -> APIResponse: async def connect_to_bank(request: BankConnectionRequest) -> APIResponse:
"""Create a connection to a bank (requisition)""" """Create a connection to a bank (requisition)"""
try: try:
redirect_url = request.redirect_url or "http://localhost:8000/"
requisition_data = await gocardless_service.create_requisition( requisition_data = await gocardless_service.create_requisition(
request.institution_id, request.redirect_url request.institution_id, redirect_url
) )
requisition = BankRequisition( requisition = BankRequisition(
@@ -69,14 +69,14 @@ async def connect_to_bank(request: BankConnectionRequest) -> APIResponse:
return APIResponse( return APIResponse(
success=True, success=True,
data=requisition, data=requisition,
message=f"Bank connection created. Please visit the link to authorize.", message="Bank connection created. Please visit the link to authorize.",
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to connect to bank {request.institution_id}: {e}") logger.error(f"Failed to connect to bank {request.institution_id}: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to connect to bank: {str(e)}" status_code=500, detail=f"Failed to connect to bank: {str(e)}"
) ) from e
@router.get("/banks/status", response_model=APIResponse) @router.get("/banks/status", response_model=APIResponse)
@@ -114,7 +114,7 @@ async def get_bank_connections_status() -> APIResponse:
logger.error(f"Failed to get bank connection status: {e}") logger.error(f"Failed to get bank connection status: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to get bank status: {str(e)}" status_code=500, detail=f"Failed to get bank status: {str(e)}"
) ) from e
@router.delete("/banks/connections/{requisition_id}", response_model=APIResponse) @router.delete("/banks/connections/{requisition_id}", response_model=APIResponse)
@@ -132,7 +132,7 @@ async def delete_bank_connection(requisition_id: str) -> APIResponse:
logger.error(f"Failed to delete bank connection {requisition_id}: {e}") logger.error(f"Failed to delete bank connection {requisition_id}: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to delete connection: {str(e)}" status_code=500, detail=f"Failed to delete connection: {str(e)}"
) ) from e
@router.get("/banks/countries", response_model=APIResponse) @router.get("/banks/countries", response_model=APIResponse)

View File

@@ -1,4 +1,4 @@
from typing import Optional from typing import Dict, Any
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from loguru import logger from loguru import logger
@@ -60,7 +60,7 @@ async def get_notification_settings() -> APIResponse:
logger.error(f"Failed to get notification settings: {e}") logger.error(f"Failed to get notification settings: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to get notification settings: {str(e)}" status_code=500, detail=f"Failed to get notification settings: {str(e)}"
) ) from e
@router.put("/notifications/settings", response_model=APIResponse) @router.put("/notifications/settings", response_model=APIResponse)
@@ -84,7 +84,7 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
} }
# Update filters config # Update filters config
filters_config = {} filters_config: Dict[str, Any] = {}
if settings.filters.case_insensitive: if settings.filters.case_insensitive:
filters_config["case-insensitive"] = settings.filters.case_insensitive filters_config["case-insensitive"] = settings.filters.case_insensitive
if settings.filters.case_sensitive: if settings.filters.case_sensitive:
@@ -110,7 +110,7 @@ async def update_notification_settings(settings: NotificationSettings) -> APIRes
logger.error(f"Failed to update notification settings: {e}") logger.error(f"Failed to update notification settings: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to update notification settings: {str(e)}" status_code=500, detail=f"Failed to update notification settings: {str(e)}"
) ) from e
@router.post("/notifications/test", response_model=APIResponse) @router.post("/notifications/test", response_model=APIResponse)
@@ -137,7 +137,7 @@ async def test_notification(test_request: NotificationTest) -> APIResponse:
logger.error(f"Failed to send test notification: {e}") logger.error(f"Failed to send test notification: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to send test notification: {str(e)}" status_code=500, detail=f"Failed to send test notification: {str(e)}"
) ) from e
@router.get("/notifications/services", response_model=APIResponse) @router.get("/notifications/services", response_model=APIResponse)
@@ -179,7 +179,7 @@ async def get_notification_services() -> APIResponse:
logger.error(f"Failed to get notification services: {e}") logger.error(f"Failed to get notification services: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to get notification services: {str(e)}" status_code=500, detail=f"Failed to get notification services: {str(e)}"
) ) from e
@router.delete("/notifications/settings/{service}", response_model=APIResponse) @router.delete("/notifications/settings/{service}", response_model=APIResponse)
@@ -206,4 +206,4 @@ async def delete_notification_service(service: str) -> APIResponse:
logger.error(f"Failed to delete notification service {service}: {e}") logger.error(f"Failed to delete notification service {service}: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to delete notification service: {str(e)}" status_code=500, detail=f"Failed to delete notification service: {str(e)}"
) ) from e

View File

@@ -3,7 +3,7 @@ from fastapi import APIRouter, HTTPException, BackgroundTasks
from loguru import logger from loguru import logger
from leggend.api.models.common import APIResponse from leggend.api.models.common import APIResponse
from leggend.api.models.sync import SyncRequest, SyncStatus, SyncResult, SchedulerConfig from leggend.api.models.sync import SyncRequest, SchedulerConfig
from leggend.services.sync_service import SyncService from leggend.services.sync_service import SyncService
from leggend.background.scheduler import scheduler from leggend.background.scheduler import scheduler
from leggend.config import config from leggend.config import config
@@ -31,7 +31,7 @@ async def get_sync_status() -> APIResponse:
logger.error(f"Failed to get sync status: {e}") logger.error(f"Failed to get sync status: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to get sync status: {str(e)}" status_code=500, detail=f"Failed to get sync status: {str(e)}"
) ) from e
@router.post("/sync", response_model=APIResponse) @router.post("/sync", response_model=APIResponse)
@@ -78,7 +78,9 @@ async def trigger_sync(
except Exception as e: except Exception as e:
logger.error(f"Failed to trigger sync: {e}") logger.error(f"Failed to trigger sync: {e}")
raise HTTPException(status_code=500, detail=f"Failed to trigger sync: {str(e)}") raise HTTPException(
status_code=500, detail=f"Failed to trigger sync: {str(e)}"
) from e
@router.post("/sync/now", response_model=APIResponse) @router.post("/sync/now", response_model=APIResponse)
@@ -104,7 +106,9 @@ async def sync_now(sync_request: Optional[SyncRequest] = None) -> APIResponse:
except Exception as e: except Exception as e:
logger.error(f"Failed to run sync: {e}") logger.error(f"Failed to run sync: {e}")
raise HTTPException(status_code=500, detail=f"Failed to run sync: {str(e)}") raise HTTPException(
status_code=500, detail=f"Failed to run sync: {str(e)}"
) from e
@router.get("/sync/scheduler", response_model=APIResponse) @router.get("/sync/scheduler", response_model=APIResponse)
@@ -134,7 +138,7 @@ async def get_scheduler_config() -> APIResponse:
logger.error(f"Failed to get scheduler config: {e}") logger.error(f"Failed to get scheduler config: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to get scheduler config: {str(e)}" status_code=500, detail=f"Failed to get scheduler config: {str(e)}"
) ) from e
@router.put("/sync/scheduler", response_model=APIResponse) @router.put("/sync/scheduler", response_model=APIResponse)
@@ -152,7 +156,7 @@ async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIRespo
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=400, detail=f"Invalid cron expression: {str(e)}" status_code=400, detail=f"Invalid cron expression: {str(e)}"
) ) from e
# Update configuration # Update configuration
schedule_data = scheduler_config.dict(exclude_none=True) schedule_data = scheduler_config.dict(exclude_none=True)
@@ -171,7 +175,7 @@ async def update_scheduler_config(scheduler_config: SchedulerConfig) -> APIRespo
logger.error(f"Failed to update scheduler config: {e}") logger.error(f"Failed to update scheduler config: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to update scheduler config: {str(e)}" status_code=500, detail=f"Failed to update scheduler config: {str(e)}"
) ) from e
@router.post("/sync/scheduler/start", response_model=APIResponse) @router.post("/sync/scheduler/start", response_model=APIResponse)
@@ -188,7 +192,7 @@ async def start_scheduler() -> APIResponse:
logger.error(f"Failed to start scheduler: {e}") logger.error(f"Failed to start scheduler: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to start scheduler: {str(e)}" status_code=500, detail=f"Failed to start scheduler: {str(e)}"
) ) from e
@router.post("/sync/scheduler/stop", response_model=APIResponse) @router.post("/sync/scheduler/stop", response_model=APIResponse)
@@ -205,4 +209,4 @@ async def stop_scheduler() -> APIResponse:
logger.error(f"Failed to stop scheduler: {e}") logger.error(f"Failed to stop scheduler: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to stop scheduler: {str(e)}" status_code=500, detail=f"Failed to stop scheduler: {str(e)}"
) ) from e

View File

@@ -1,4 +1,4 @@
from typing import List, Optional from typing import Optional, List, Union
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from loguru import logger from loguru import logger
@@ -120,7 +120,13 @@ async def get_all_transactions(
# Apply pagination # Apply pagination
total_transactions = len(filtered_transactions) total_transactions = len(filtered_transactions)
paginated_transactions = filtered_transactions[offset : offset + limit] actual_offset = offset or 0
actual_limit = limit or 100
paginated_transactions = filtered_transactions[
actual_offset : actual_offset + actual_limit
]
data: Union[List[TransactionSummary], List[Transaction]]
if summary_only: if summary_only:
# Return simplified transaction summaries # Return simplified transaction summaries
@@ -157,14 +163,14 @@ async def get_all_transactions(
return APIResponse( return APIResponse(
success=True, success=True,
data=data, data=data,
message=f"Retrieved {len(data)} transactions (showing {offset + 1}-{offset + len(data)} of {total_transactions})", message=f"Retrieved {len(data)} transactions (showing {actual_offset + 1}-{actual_offset + len(data)} of {total_transactions})",
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to get transactions: {e}") logger.error(f"Failed to get transactions: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to get transactions: {str(e)}" status_code=500, detail=f"Failed to get transactions: {str(e)}"
) ) from e
@router.get("/transactions/stats", response_model=APIResponse) @router.get("/transactions/stats", response_model=APIResponse)
@@ -270,4 +276,4 @@ async def get_transaction_stats(
logger.error(f"Failed to get transaction stats: {e}") logger.error(f"Failed to get transaction stats: {e}")
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to get transaction stats: {str(e)}" status_code=500, detail=f"Failed to get transaction stats: {str(e)}"
) ) from e

View File

@@ -17,7 +17,7 @@ class Config:
cls._instance = super().__new__(cls) cls._instance = super().__new__(cls)
return cls._instance return cls._instance
def load_config(self, config_path: str = None) -> Dict[str, Any]: def load_config(self, config_path: Optional[str] = None) -> Dict[str, Any]:
if self._config is not None: if self._config is not None:
return self._config return self._config
@@ -43,7 +43,9 @@ class Config:
return self._config return self._config
def save_config( def save_config(
self, config_data: Dict[str, Any] = None, config_path: str = None self,
config_data: Optional[Dict[str, Any]] = None,
config_path: Optional[str] = None,
) -> None: ) -> None:
"""Save configuration to TOML file""" """Save configuration to TOML file"""
if config_data is None: if config_data is None:
@@ -55,6 +57,11 @@ class Config:
str(Path.home() / ".config" / "leggen" / "config.toml"), str(Path.home() / ".config" / "leggen" / "config.toml"),
) )
if config_path is None:
raise ValueError("No config path specified")
if config_data is None:
raise ValueError("No config data to save")
# Ensure directory exists # Ensure directory exists
Path(config_path).parent.mkdir(parents=True, exist_ok=True) Path(config_path).parent.mkdir(parents=True, exist_ok=True)
@@ -75,6 +82,9 @@ class Config:
if self._config is None: if self._config is None:
self.load_config() self.load_config()
if self._config is None:
raise RuntimeError("Failed to load config")
if section not in self._config: if section not in self._config:
self._config[section] = {} self._config[section] = {}
@@ -86,6 +96,9 @@ class Config:
if self._config is None: if self._config is None:
self.load_config() self.load_config()
if self._config is None:
raise RuntimeError("Failed to load config")
self._config[section] = data self._config[section] = data
self.save_config() self.save_config()
@@ -93,6 +106,8 @@ class Config:
def config(self) -> Dict[str, Any]: def config(self) -> Dict[str, Any]:
if self._config is None: if self._config is None:
self.load_config() self.load_config()
if self._config is None:
raise RuntimeError("Failed to load config")
return self._config return self._config
@property @property

View File

@@ -1,4 +1,3 @@
import asyncio
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from importlib import metadata from importlib import metadata

View File

@@ -1,5 +1,5 @@
from datetime import datetime from datetime import datetime
from typing import List, Dict, Any, Optional from typing import List, Dict, Any
from loguru import logger from loguru import logger
@@ -75,7 +75,10 @@ class DatabaseService:
datetime.fromisoformat(booked_date), datetime.fromisoformat(value_date) datetime.fromisoformat(booked_date), datetime.fromisoformat(value_date)
) )
else: else:
min_date = datetime.fromisoformat(booked_date or value_date) date_str = booked_date or value_date
if not date_str:
raise ValueError("No valid date found in transaction")
min_date = datetime.fromisoformat(date_str)
# Extract amount and currency # Extract amount and currency
transaction_amount = transaction.get("transactionAmount", {}) transaction_amount = transaction.get("transactionAmount", {})

View File

@@ -1,8 +1,7 @@
import asyncio
import json import json
import httpx import httpx
from pathlib import Path from pathlib import Path
from typing import Dict, Any, List, Optional from typing import Dict, Any, List
from loguru import logger from loguru import logger

View File

@@ -69,7 +69,7 @@ class NotificationService:
description = transaction.get("description", "").lower() description = transaction.get("description", "").lower()
# Check case-insensitive filters # Check case-insensitive filters
for filter_name, filter_value in filters_case_insensitive.items(): for _filter_name, filter_value in filters_case_insensitive.items():
if filter_value.lower() in description: if filter_value.lower() in description:
matching.append( matching.append(
{ {

View File

@@ -1,10 +1,8 @@
import asyncio
from datetime import datetime from datetime import datetime
from typing import List, Dict, Any from typing import List
from loguru import logger from loguru import logger
from leggend.config import config
from leggend.api.models.sync import SyncResult, SyncStatus from leggend.api.models.sync import SyncResult, SyncStatus
from leggend.services.gocardless_service import GoCardlessService from leggend.services.gocardless_service import GoCardlessService
from leggend.services.database_service import DatabaseService from leggend.services.database_service import DatabaseService

View File

@@ -49,9 +49,12 @@ dev = [
"pre-commit>=3.6.0", "pre-commit>=3.6.0",
"pytest>=8.0.0", "pytest>=8.0.0",
"pytest-asyncio>=0.23.0", "pytest-asyncio>=0.23.0",
"pytest-mock>=3.12.0", "pytest-mock>=3.12.0",
"respx>=0.21.0", "respx>=0.21.0",
"requests-mock>=1.12.0", "requests-mock>=1.12.0",
"mypy>=1.17.1",
"types-tabulate>=0.9.0.20241207",
"types-requests>=2.32.4.20250809",
] ]
[tool.hatch.build.targets.sdist] [tool.hatch.build.targets.sdist]
@@ -71,20 +74,19 @@ lint.extend-select = ["B", "C4", "PIE", "T20", "SIM", "TCH"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
python_files = "test_*.py" python_files = "test_*.py"
python_classes = "Test*" python_classes = "Test*"
python_functions = "test_*" python_functions = "test_*"
addopts = [ addopts = ["-v", "--tb=short", "--strict-markers", "--disable-warnings"]
"-v",
"--tb=short",
"--strict-markers",
"--disable-warnings"
]
asyncio_mode = "auto" asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function" asyncio_default_fixture_loop_scope = "function"
markers = [ markers = [
"unit: Unit tests", "unit: Unit tests",
"integration: Integration tests", "integration: Integration tests",
"slow: Slow running tests", "slow: Slow running tests",
"api: API endpoint tests", "api: API endpoint tests",
"cli: CLI command tests" "cli: CLI command tests",
] ]
[[tool.mypy.overrides]]
module = ["apscheduler.*"]
ignore_missing_imports = true

View File

@@ -5,8 +5,6 @@ import respx
import httpx import httpx
from unittest.mock import patch from unittest.mock import patch
from leggend.services.gocardless_service import GoCardlessService
@pytest.mark.api @pytest.mark.api
class TestBanksAPI: class TestBanksAPI:

View File

@@ -1,6 +1,7 @@
"""Tests for CLI API client.""" """Tests for CLI API client."""
import pytest import pytest
import requests
import requests_mock import requests_mock
from unittest.mock import patch from unittest.mock import patch
@@ -85,7 +86,7 @@ class TestLeggendAPIClient:
"""Test handling of connection errors.""" """Test handling of connection errors."""
client = LeggendAPIClient("http://localhost:9999") # Non-existent service client = LeggendAPIClient("http://localhost:9999") # Non-existent service
with pytest.raises(Exception): with pytest.raises((requests.ConnectionError, requests.RequestException)):
client.get_accounts() client.get_accounts()
def test_http_error_handling(self): def test_http_error_handling(self):
@@ -99,7 +100,7 @@ class TestLeggendAPIClient:
json={"detail": "Internal server error"}, json={"detail": "Internal server error"},
) )
with pytest.raises(Exception): with pytest.raises((requests.HTTPError, requests.RequestException)):
client.get_accounts() client.get_accounts()
def test_custom_api_url(self): def test_custom_api_url(self):

View File

@@ -1,8 +1,6 @@
"""Tests for configuration management.""" """Tests for configuration management."""
import pytest import pytest
import tempfile
from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
from leggend.config import Config from leggend.config import Config
@@ -164,9 +162,11 @@ class TestConfig:
config = Config() config = Config()
config._config = None config._config = None
with patch("builtins.open", side_effect=FileNotFoundError): with (
with pytest.raises(FileNotFoundError): patch("builtins.open", side_effect=FileNotFoundError),
config.load_config() pytest.raises(FileNotFoundError),
):
config.load_config()
def test_notifications_config(self): def test_notifications_config(self):
"""Test notifications configuration access.""" """Test notifications configuration access."""

View File

@@ -1,13 +1,10 @@
"""Tests for background scheduler.""" """Tests for background scheduler."""
import pytest import pytest
import asyncio from unittest.mock import patch, AsyncMock, MagicMock
from unittest.mock import Mock, patch, AsyncMock, MagicMock
from datetime import datetime from datetime import datetime
from apscheduler.schedulers.blocking import BlockingScheduler
from leggend.background.scheduler import BackgroundScheduler from leggend.background.scheduler import BackgroundScheduler
from leggend.services.sync_service import SyncService
@pytest.mark.unit @pytest.mark.unit

65
uv.lock generated
View File

@@ -240,6 +240,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "mypy" },
{ name = "pre-commit" }, { name = "pre-commit" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
@@ -247,6 +248,8 @@ dev = [
{ name = "requests-mock" }, { name = "requests-mock" },
{ name = "respx" }, { name = "respx" },
{ name = "ruff" }, { name = "ruff" },
{ name = "types-requests" },
{ name = "types-tabulate" },
] ]
[package.metadata] [package.metadata]
@@ -265,6 +268,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "mypy", specifier = ">=1.17.1" },
{ name = "pre-commit", specifier = ">=3.6.0" }, { name = "pre-commit", specifier = ">=3.6.0" },
{ name = "pytest", specifier = ">=8.0.0" }, { name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.23.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" },
@@ -272,6 +276,8 @@ dev = [
{ name = "requests-mock", specifier = ">=1.12.0" }, { name = "requests-mock", specifier = ">=1.12.0" },
{ name = "respx", specifier = ">=0.21.0" }, { name = "respx", specifier = ">=0.21.0" },
{ name = "ruff", specifier = ">=0.6.1" }, { name = "ruff", specifier = ">=0.6.1" },
{ name = "types-requests", specifier = ">=2.32.4.20250809" },
{ name = "types-tabulate", specifier = ">=0.9.0.20241207" },
] ]
[[package]] [[package]]
@@ -287,6 +293,35 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" }, { url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595, upload-time = "2024-12-06T11:20:54.538Z" },
] ]
[[package]]
name = "mypy"
version = "1.17.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mypy-extensions" },
{ name = "pathspec" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/22/ea637422dedf0bf36f3ef238eab4e455e2a0dcc3082b5cc067615347ab8e/mypy-1.17.1.tar.gz", hash = "sha256:25e01ec741ab5bb3eec8ba9cdb0f769230368a22c959c4937360efb89b7e9f01", size = 3352570, upload-time = "2025-07-31T07:54:19.204Z" }
wheels = [
{ 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" },
]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]] [[package]]
name = "nodeenv" name = "nodeenv"
version = "1.9.1" version = "1.9.1"
@@ -305,6 +340,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
] ]
[[package]]
name = "pathspec"
version = "0.12.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
]
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "4.3.6" version = "4.3.6"
@@ -558,6 +602,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
] ]
[[package]]
name = "types-requests"
version = "2.32.4.20250809"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ed/b0/9355adb86ec84d057fea765e4c49cce592aaf3d5117ce5609a95a7fc3dac/types_requests-2.32.4.20250809.tar.gz", hash = "sha256:d8060de1c8ee599311f56ff58010fb4902f462a1470802cf9f6ed27bc46c4df3", size = 23027, upload-time = "2025-08-09T03:17:10.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/6f/ec0012be842b1d888d46884ac5558fd62aeae1f0ec4f7a581433d890d4b5/types_requests-2.32.4.20250809-py3-none-any.whl", hash = "sha256:f73d1832fb519ece02c85b1f09d5f0dd3108938e7d47e7f94bbfa18a6782b163", size = 20644, upload-time = "2025-08-09T03:17:09.716Z" },
]
[[package]]
name = "types-tabulate"
version = "0.9.0.20241207"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/43/16030404a327e4ff8c692f2273854019ed36718667b2993609dc37d14dd4/types_tabulate-0.9.0.20241207.tar.gz", hash = "sha256:ac1ac174750c0a385dfd248edc6279fa328aaf4ea317915ab879a2ec47833230", size = 8195, upload-time = "2024-12-07T02:54:42.554Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/86/a9ebfd509cbe74471106dffed320e208c72537f9aeb0a55eaa6b1b5e4d17/types_tabulate-0.9.0.20241207-py3-none-any.whl", hash = "sha256:b8dad1343c2a8ba5861c5441370c3e35908edd234ff036d4298708a1d4cf8a85", size = 8307, upload-time = "2024-12-07T02:54:41.031Z" },
]
[[package]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"