diff --git a/flake.nix b/flake.nix index 302cb1a..cb8ba41 100644 --- a/flake.nix +++ b/flake.nix @@ -12,12 +12,128 @@ pkgs = nixpkgs.legacyPackages.${system}; python = pkgs.python311; pythonPackages = python.pkgs; + + # Custom packages not in nixpkgs + oathtool = pythonPackages.buildPythonPackage rec { + pname = "oathtool"; + version = "2.3.1"; + pyproject = true; + + src = pythonPackages.fetchPypi { + inherit pname version; + hash = "sha256-DfP22b9/cShz/fFETzPNWKa9W2h+0Eolar14OTrPLCU="; + }; + + build-system = with pythonPackages; [ setuptools setuptools-scm ]; + dependencies = with pythonPackages; [ autocommand path ]; + }; + + ynab = pythonPackages.buildPythonPackage rec { + pname = "ynab"; + version = "1.9.0"; + format = "wheel"; + + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/b2/9c/0ccd11bcdf7522fcb2823fcd7ffbb48e3164d72caaf3f920c7b068347175/ynab-1.9.0-py3-none-any.whl"; + hash = "sha256-cqwCGWBbQoAUloTs0P7DvXXZOHctZc3uqbPmahsvRw0="; + }; + + dependencies = with pythonPackages; [ + urllib3 + python-dateutil + pydantic + typing-extensions + certifi + ]; + }; + + httpx-sse = pythonPackages.buildPythonPackage rec { + pname = "httpx-sse"; + version = "0.4.0"; + format = "wheel"; + + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/e1/9b/a181f281f65d776426002f330c31849b86b31fc9d848db62e16f03ff739f/httpx_sse-0.4.0-py3-none-any.whl"; + hash = "sha256-8ymvbq5X6qK9/ZYrQlJHZK9oB16oc3Ci3pIK9TQeMY8="; + }; + + dependencies = with pythonPackages; [ httpx ]; + }; + + pydantic-settings = pythonPackages.buildPythonPackage rec { + pname = "pydantic-settings"; + version = "2.7.1"; + format = "wheel"; + + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/b4/46/93416fdae86d40879714f72956ac14df9c7b76f7d41a4d68aa9f71a0028b/pydantic_settings-2.7.1-py3-none-any.whl"; + hash = "sha256-WQvp5uJNBtszpCYoKe3vaCUA7wCFZalpxz051fi/s/0="; + }; + + dependencies = with pythonPackages; [ pydantic python-dotenv ]; + }; + + sse-starlette = pythonPackages.buildPythonPackage rec { + pname = "sse-starlette"; + version = "2.2.1"; + format = "wheel"; + + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/d9/e0/5b8bd393f27f4a62461c5cf2479c75a2cc2ffa330976f9f00f5f6e4f50eb/sse_starlette-2.2.1-py3-none-any.whl"; + hash = "sha256-ZBCj07oMiednXUwnOjAdZGScA6XvHKEB8QtH+JX9Dpk="; + }; + + dependencies = with pythonPackages; [ starlette anyio ]; + }; + + typing-inspection = pythonPackages.buildPythonPackage rec { + pname = "typing-inspection"; + version = "0.4.0"; + format = "wheel"; + + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl"; + hash = "sha256-UOclWfzSpjZ6Gfen5hDmr8ufrJQMZQKQ7tiT1hOGgy8="; + }; + + dependencies = with pythonPackages; [ typing-extensions ]; + }; + + mcp = pythonPackages.buildPythonPackage rec { + pname = "mcp"; + version = "1.25.0"; + format = "wheel"; + + src = pkgs.fetchurl { + url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl"; + hash = "sha256-s3w4FEpmat0IYmFMx57Cdul9cqqMom1iKBjU4ni5cho="; + }; + + dependencies = with pythonPackages; [ + anyio + httpx + jsonschema + pydantic + pyjwt + starlette + typing-extensions + uvicorn + python-multipart + python-dotenv + typer + ] ++ [ + httpx-sse + pydantic-settings + sse-starlette + typing-inspection + ]; + }; in { packages = { default = pythonPackages.buildPythonApplication { pname = "moneyflow"; - version = "0.5.3"; + version = "0.8.1"; format = "pyproject"; src = ./.; @@ -35,39 +151,10 @@ textual cryptography python-dateutil - # oathtool - pure Python TOTP generator (not in nixpkgs) - (buildPythonPackage rec { - pname = "oathtool"; - version = "2.3.1"; - pyproject = true; - - src = fetchPypi { - inherit pname version; - hash = "sha256-DfP22b9/cShz/fFETzPNWKa9W2h+0Eolar14OTrPLCU="; - }; - - build-system = [ setuptools setuptools-scm ]; - dependencies = [ autocommand path ]; - }) - # ynab - YNAB API client (not in nixpkgs) - using pre-built wheel - (buildPythonPackage rec { - pname = "ynab"; - version = "1.9.0"; - format = "wheel"; - - src = pkgs.fetchurl { - url = "https://files.pythonhosted.org/packages/b2/9c/0ccd11bcdf7522fcb2823fcd7ffbb48e3164d72caaf3f920c7b068347175/ynab-1.9.0-py3-none-any.whl"; - hash = "sha256-cqwCGWBbQoAUloTs0P7DvXXZOHctZc3uqbPmahsvRw0="; - }; - - dependencies = [ - urllib3 - python-dateutil - pydantic - typing-extensions - certifi - ]; - }) + ] ++ [ + oathtool + ynab + mcp ]; # Skip tests during build (can be run separately) diff --git a/moneyflow/app.py b/moneyflow/app.py index 9e4c8aa..b7b480f 100644 --- a/moneyflow/app.py +++ b/moneyflow/app.py @@ -304,8 +304,7 @@ async def on_mount(self) -> None: self.query_one("#loading", LoadingIndicator).display = False self.query_one("#loading-status", Static).display = False - # Attempt to use saved session or show login prompt - # Must run in a worker to use push_screen with wait_for_dismiss + # Start data initialization in a worker self.run_worker(self.initialize_data(), exclusive=True) except Exception as e: # Try to show error to user @@ -361,9 +360,10 @@ def _initialize_managers( backend_type=backend_type, ) - # Initialize cache manager (only if encryption key available) - # Backends like Amazon don't have encryption keys and don't need caching - if self.cache_path is not None and self.encryption_key is not None: + # Initialize cache manager for backends that support caching + # cache_path is None for backends like Amazon that don't need caching + # encryption_key can be None for plaintext credentials (CacheManager supports both modes) + if self.cache_path is not None: # Determine cache directory if self.cache_path == "": # Default cache location - use profile-specific or legacy location @@ -461,7 +461,15 @@ async def _handle_credentials(self): logger.debug(f"Credentials exist: {cred_manager.credentials_exist()}") if cred_manager.credentials_exist(): - # Show unlock screen + # Check if credentials are encrypted or plaintext + if cred_manager.is_plaintext(): + # Plaintext credentials - load directly without unlock screen + logger.debug("Loading plaintext credentials (no encryption)") + creds, _ = cred_manager.load_credentials() + self.encryption_key = None # No encryption key for plaintext + return creds + + # Encrypted credentials - show unlock screen result = await self.push_screen(CredentialUnlockScreen(), wait_for_dismiss=True) if result is None: @@ -593,7 +601,15 @@ async def _handle_account_selection(self): return account.id, profile_dir, creds - # Load existing credentials + # Check if credentials are plaintext (no encryption) + if cred_manager.is_plaintext(): + # Plaintext credentials - load directly without unlock screen + logger.debug(f"Loading plaintext credentials for account {account.id}") + creds, _ = cred_manager.load_credentials() + self.encryption_key = None # No encryption key for plaintext + return account.id, profile_dir, creds + + # Encrypted credentials - show unlock screen creds = await self.push_screen( CredentialUnlockScreen(profile_dir=profile_dir), wait_for_dismiss=True ) diff --git a/moneyflow/app_controller.py b/moneyflow/app_controller.py index 5f8b8cd..f44b2a7 100644 --- a/moneyflow/app_controller.py +++ b/moneyflow/app_controller.py @@ -1651,11 +1651,10 @@ def handle_commit_result( bulk_merchant_renames, ) - # Clear pending edits on success - self.data_manager.pending_edits.clear() - logger.info("Cleared pending edits") - # Update cache with edited data (if caching is enabled) + # NOTE: Must happen BEFORE clearing pending_edits because `edits` parameter + # is the same list object (passed by reference), so clearing pending_edits + # would empty the edits list before we can use it for cache updates! if self.cache_manager and cache_filters: try: if is_filtered_view: @@ -1664,10 +1663,51 @@ def handle_commit_result( hot_df = self.cache_manager.load_hot_cache() cold_df = self.cache_manager.load_cold_cache() if hot_df is None or cold_df is None: + # This can happen if cache files don't exist, are corrupted, + # or encryption mode changed since they were created. + # We'll try to recover by applying edits to whichever tier + # loaded successfully, and preserve the other. logger.warning( - "Filtered view detected but cache tiers are unavailable; " - "skipping cache update to avoid corruption" + "Filtered view: cache tier(s) unavailable (hot=%s, cold=%s). " + "Attempting partial update to preserve edits.", + hot_df is not None, + cold_df is not None, ) + if hot_df is not None: + updated_hot = CommitOrchestrator.apply_edits_to_dataframe( + hot_df, + edits, + self.data_manager.categories, + self.data_manager.apply_category_groups, + bulk_merchant_renames, + ) + self.cache_manager.save_hot_cache( + hot_df=updated_hot, + categories=self.data_manager.categories, + category_groups=self.data_manager.category_groups, + ) + logger.info("Updated hot cache with edits (cold unavailable)") + if cold_df is not None: + updated_cold = CommitOrchestrator.apply_edits_to_dataframe( + cold_df, + edits, + self.data_manager.categories, + self.data_manager.apply_category_groups, + bulk_merchant_renames, + ) + self.cache_manager.save_cold_cache(cold_df=updated_cold) + logger.info("Updated cold cache with edits (hot unavailable)") + if hot_df is None and cold_df is None: + # Neither tier available - cache is corrupted or missing. + # In filtered view, data_manager.df only contains the filtered + # subset, so we CANNOT safely save it as the full cache (would + # lose historical data). Just log the error - edits are already + # saved to backend, so next --refresh will restore consistency. + logger.error( + "Neither cache tier could be loaded in filtered view! " + "Cache may be corrupted. Edits saved to backend but not to " + "local cache. Use --refresh to rebuild cache from backend." + ) else: logger.info("Filtered view detected - updating cached tiers with edits") updated_hot = CommitOrchestrator.apply_edits_to_dataframe( @@ -1704,6 +1744,10 @@ def handle_commit_result( # Cache update failed - not critical, just log logger.warning(f"Cache update failed: {e}", exc_info=True) + # Clear pending edits on success (after cache update to preserve edits list) + self.data_manager.pending_edits.clear() + logger.info("Cleared pending edits") + # Refresh to show updated data (smooth update) # Note: View already restored in app.py before commit started logger.debug( diff --git a/moneyflow/cache_manager.py b/moneyflow/cache_manager.py index b7d37b5..55db052 100644 --- a/moneyflow/cache_manager.py +++ b/moneyflow/cache_manager.py @@ -15,6 +15,8 @@ import io import json import logging +import os +import tempfile from datetime import date, datetime, timedelta from enum import Enum from pathlib import Path @@ -23,6 +25,8 @@ import polars as pl from cryptography.fernet import Fernet, InvalidToken +from .file_utils import secure_write_file + logger = logging.getLogger(__name__) @@ -37,16 +41,19 @@ class RefreshStrategy(Enum): class CacheManager: """ - Manage encrypted two-tier caching of transaction data to disk. + Manage two-tier caching of transaction data to disk. - Cache files are encrypted using Fernet symmetric encryption with the same - key used for credential encryption. This ensures financial data is protected at rest. + Supports two modes: + 1. Encrypted mode (encryption_key provided): Cache files are encrypted using + Fernet symmetric encryption with the same key used for credential encryption. + 2. Unencrypted mode (encryption_key is None): Cache files are stored as + plain Parquet/JSON with restricted file permissions. Two-tier cache structure: - cache_metadata.json: Unencrypted metadata for fast validation - - hot_transactions.parquet.enc: Encrypted recent transactions (last 90 days) - - cold_transactions.parquet.enc: Encrypted historical transactions (>90 days) - - categories.json.enc: Encrypted category hierarchy + - hot_transactions.parquet[.enc]: Recent transactions (last 90 days) + - cold_transactions.parquet[.enc]: Historical transactions (>90 days) + - categories.json[.enc]: Category hierarchy """ CACHE_VERSION = "3.0" # Bumped for two-tier cache format @@ -69,7 +76,7 @@ def __init__(self, cache_dir: Optional[str] = None, encryption_key: Optional[byt Args: cache_dir: Directory for cache files. Defaults to ~/.moneyflow/cache/ encryption_key: Fernet encryption key (32-byte URL-safe base64-encoded). - If None, caching will be disabled. + If None, caching will use unencrypted files. """ if cache_dir: self.cache_dir = Path(cache_dir).expanduser() @@ -79,10 +86,22 @@ def __init__(self, cache_dir: Optional[str] = None, encryption_key: Optional[byt # Create cache directory if it doesn't exist self.cache_dir.mkdir(parents=True, exist_ok=True) - # Two-tier encrypted cache files - self.hot_transactions_file = self.cache_dir / "hot_transactions.parquet.enc" - self.cold_transactions_file = self.cache_dir / "cold_transactions.parquet.enc" - self.categories_file = self.cache_dir / "categories.json.enc" + # Encryption setup + self.encryption_key = encryption_key + self.fernet = Fernet(encryption_key) if encryption_key else None + self.is_encrypted = encryption_key is not None + + # File paths depend on encryption mode + if self.is_encrypted: + # Encrypted cache files + self.hot_transactions_file = self.cache_dir / "hot_transactions.parquet.enc" + self.cold_transactions_file = self.cache_dir / "cold_transactions.parquet.enc" + self.categories_file = self.cache_dir / "categories.json.enc" + else: + # Unencrypted cache files + self.hot_transactions_file = self.cache_dir / "hot_transactions.parquet" + self.cold_transactions_file = self.cache_dir / "cold_transactions.parquet" + self.categories_file = self.cache_dir / "categories.json" # Legacy single-file cache (for detection and cleanup) self.legacy_transactions_file = self.cache_dir / "transactions.parquet.enc" @@ -90,10 +109,6 @@ def __init__(self, cache_dir: Optional[str] = None, encryption_key: Optional[byt # Unencrypted metadata for fast validation self.metadata_file = self.cache_dir / "cache_metadata.json" - # Encryption setup - self.encryption_key = encryption_key - self.fernet = Fernet(encryption_key) if encryption_key else None - def _get_boundary_date(self) -> date: """Get the boundary date between hot and cold cache (90 days ago).""" return date.today() - timedelta(days=self.HOT_WINDOW_DAYS) @@ -371,37 +386,60 @@ def load_metadata(self) -> Dict[str, Any]: return json.load(f) def _save_metadata(self, metadata: Dict[str, Any]) -> None: - """Save cache metadata.""" - with open(self.metadata_file, "w") as f: - json.dump(metadata, f, indent=2) - - def _save_encrypted_parquet(self, df: pl.DataFrame, file_path: Path) -> None: - """Save DataFrame as encrypted Parquet file.""" - if not self.fernet: - raise ValueError("Cannot save cache: encryption key not set") - - buffer = io.BytesIO() - df.write_parquet(buffer) - parquet_bytes = buffer.getvalue() - encrypted_parquet = self.fernet.encrypt(parquet_bytes) - - with open(file_path, "wb") as f: - f.write(encrypted_parquet) - - def _load_encrypted_parquet(self, file_path: Path) -> Optional[pl.DataFrame]: - """Load DataFrame from encrypted Parquet file.""" - if not self.fernet: - raise ValueError("Cannot load cache: encryption key not set") - + """Save cache metadata with secure permissions.""" + json_data = json.dumps(metadata, indent=2) + secure_write_file(self.metadata_file, json_data, "w") + + def _save_parquet(self, df: pl.DataFrame, file_path: Path) -> None: + """Save DataFrame as Parquet file (encrypted or plain based on mode).""" + if self.is_encrypted: + # Encrypted mode - must buffer to encrypt + buffer = io.BytesIO() + df.write_parquet(buffer) + parquet_bytes = buffer.getvalue() + encrypted_parquet = self.fernet.encrypt(parquet_bytes) + secure_write_file(file_path, encrypted_parquet, "wb") + else: + # Unencrypted mode - use streaming write with atomic rename for security + # Write to temp file with secure permissions, then atomic rename + dir_path = file_path.parent + fd, temp_path = tempfile.mkstemp(dir=dir_path, prefix=".tmp_parquet_") + try: + os.fchmod(fd, 0o600) + os.close(fd) + fd = -1 # Mark as closed + # Stream write directly to temp file (no memory buffering) + df.write_parquet(temp_path) + # Atomic rename to final location + os.replace(temp_path, file_path) + except Exception: + if fd >= 0: + try: + os.close(fd) + except OSError: + pass + try: + os.unlink(temp_path) + except OSError: + pass + raise + + def _load_parquet(self, file_path: Path) -> Optional[pl.DataFrame]: + """Load DataFrame from Parquet file (encrypted or plain based on mode).""" if not file_path.exists(): return None try: - with open(file_path, "rb") as f: - encrypted_parquet = f.read() + if self.is_encrypted: + # Encrypted mode + with open(file_path, "rb") as f: + encrypted_parquet = f.read() - parquet_bytes = self.fernet.decrypt(encrypted_parquet) - return pl.read_parquet(io.BytesIO(parquet_bytes)) + parquet_bytes = self.fernet.decrypt(encrypted_parquet) + return pl.read_parquet(io.BytesIO(parquet_bytes)) + else: + # Unencrypted mode - read directly + return pl.read_parquet(file_path) except InvalidToken: print(f"Warning: Failed to decrypt {file_path.name} (invalid encryption key)") @@ -436,18 +474,20 @@ def _build_tier_metadata( return metadata def _save_categories(self, categories: Dict, category_groups: Dict) -> None: - """Encrypt and write categories data to disk.""" - if not self.fernet: - raise ValueError("Cannot save cache: encryption key not set") - + """Save categories data to disk (encrypted or plain based on mode).""" cache_data = { "categories": categories, "category_groups": category_groups, } categories_json = json.dumps(cache_data, indent=2) - encrypted_categories = self.fernet.encrypt(categories_json.encode()) - with open(self.categories_file, "wb") as f: - f.write(encrypted_categories) + + if self.is_encrypted: + # Encrypted mode + encrypted_categories = self.fernet.encrypt(categories_json.encode()) + secure_write_file(self.categories_file, encrypted_categories, "wb") + else: + # Unencrypted mode - write with secure permissions from creation + secure_write_file(self.categories_file, categories_json, "w") def save_cache( self, @@ -470,13 +510,7 @@ def save_cache( category_groups: Dict of category groups year: Year filter used (if any) since: Since date filter used (if any) - - Raises: - ValueError: If encryption key is not set """ - if not self.fernet: - raise ValueError("Cannot save cache: encryption key not set") - # Calculate boundary date boundary_date = self._get_boundary_date() boundary_str = boundary_date.isoformat() @@ -533,8 +567,8 @@ def save_cache( pass # No existing metadata # Save both tiers - self._save_encrypted_parquet(hot_df, self.hot_transactions_file) - self._save_encrypted_parquet(cold_df, self.cold_transactions_file) + self._save_parquet(hot_df, self.hot_transactions_file) + self._save_parquet(cold_df, self.cold_transactions_file) # Save categories self._save_categories(categories, category_groups) @@ -550,7 +584,7 @@ def save_cache( "year_filter": year, "since_filter": since, "total_transactions": len(transactions_df), - "encrypted": True, + "encrypted": self.is_encrypted, } self._save_metadata(metadata) @@ -573,9 +607,6 @@ def save_hot_cache( categories: Optional updated categories (if None, preserves existing) category_groups: Optional updated category groups """ - if not self.fernet: - raise ValueError("Cannot save cache: encryption key not set") - # Log hot cache save hot_earliest = hot_df["date"].min() if len(hot_df) > 0 else None hot_latest = hot_df["date"].max() if len(hot_df) > 0 else None @@ -585,7 +616,7 @@ def save_hot_cache( ) # Save hot tier - self._save_encrypted_parquet(hot_df, self.hot_transactions_file) + self._save_parquet(hot_df, self.hot_transactions_file) # Update categories if provided if categories is not None and category_groups is not None: @@ -621,9 +652,6 @@ def save_cold_cache(self, cold_df: pl.DataFrame) -> None: Args: cold_df: DataFrame of historical transactions """ - if not self.fernet: - raise ValueError("Cannot save cache: encryption key not set") - # Log cold cache save cold_earliest = cold_df["date"].min() if len(cold_df) > 0 else None cold_latest = cold_df["date"].max() if len(cold_df) > 0 else None @@ -633,7 +661,7 @@ def save_cold_cache(self, cold_df: pl.DataFrame) -> None: ) # Save cold tier - self._save_encrypted_parquet(cold_df, self.cold_transactions_file) + self._save_parquet(cold_df, self.cold_transactions_file) # Update metadata try: @@ -654,11 +682,11 @@ def save_cold_cache(self, cold_df: pl.DataFrame) -> None: def load_hot_cache(self) -> Optional[pl.DataFrame]: """Load only hot tier from cache.""" - return self._load_encrypted_parquet(self.hot_transactions_file) + return self._load_parquet(self.hot_transactions_file) def load_cold_cache(self) -> Optional[pl.DataFrame]: """Load only cold tier from cache.""" - return self._load_encrypted_parquet(self.cold_transactions_file) + return self._load_parquet(self.cold_transactions_file) def merge_tiers( self, hot_df: Optional[pl.DataFrame], cold_df: Optional[pl.DataFrame] @@ -698,13 +726,7 @@ def load_cache(self) -> Optional[Tuple[pl.DataFrame, Dict, Dict, Dict]]: Returns: Tuple of (transactions_df, categories, category_groups, metadata) or None if cache invalid - - Raises: - ValueError: If encryption key is not set """ - if not self.fernet: - raise ValueError("Cannot load cache: encryption key not set") - if not self.cache_exists(): return None @@ -716,8 +738,8 @@ def load_cache(self) -> Optional[Tuple[pl.DataFrame, Dict, Dict, Dict]]: return None # Load both tiers - hot_df = self._load_encrypted_parquet(self.hot_transactions_file) - cold_df = self._load_encrypted_parquet(self.cold_transactions_file) + hot_df = self._load_parquet(self.hot_transactions_file) + cold_df = self._load_parquet(self.cold_transactions_file) if hot_df is None and cold_df is None: return None @@ -726,16 +748,23 @@ def load_cache(self) -> Optional[Tuple[pl.DataFrame, Dict, Dict, Dict]]: combined_df = self.merge_tiers(hot_df, cold_df) # Load categories - with open(self.categories_file, "rb") as f: - encrypted_categories = f.read() - - try: - categories_json = self.fernet.decrypt(encrypted_categories).decode() - except InvalidToken: - print("Warning: Failed to decrypt categories cache (invalid encryption key)") - return None + if self.is_encrypted: + # Encrypted mode + with open(self.categories_file, "rb") as f: + encrypted_categories = f.read() + + try: + categories_json = self.fernet.decrypt(encrypted_categories).decode() + except InvalidToken: + print("Warning: Failed to decrypt categories cache (invalid encryption key)") + return None + + cache_data = json.loads(categories_json) + else: + # Unencrypted mode + with open(self.categories_file, "r") as f: + cache_data = json.load(f) - cache_data = json.loads(categories_json) categories = cache_data["categories"] category_groups = cache_data["category_groups"] @@ -746,12 +775,19 @@ def load_cache(self) -> Optional[Tuple[pl.DataFrame, Dict, Dict, Dict]]: return None def clear_cache(self) -> None: - """Delete all cache files (both two-tier and legacy).""" + """Delete all cache files (encrypted, unencrypted, and legacy).""" + # Delete both encrypted and unencrypted versions files = [ - self.hot_transactions_file, - self.cold_transactions_file, + # Encrypted files + self.cache_dir / "hot_transactions.parquet.enc", + self.cache_dir / "cold_transactions.parquet.enc", + self.cache_dir / "categories.json.enc", + # Unencrypted files + self.cache_dir / "hot_transactions.parquet", + self.cache_dir / "cold_transactions.parquet", + self.cache_dir / "categories.json", + # Metadata and legacy self.metadata_file, - self.categories_file, self.legacy_transactions_file, ] for file in files: diff --git a/moneyflow/credentials.py b/moneyflow/credentials.py index 84978af..836a4be 100644 --- a/moneyflow/credentials.py +++ b/moneyflow/credentials.py @@ -1,8 +1,12 @@ """ Secure credential management for finance backend authentication. -Stores encrypted credentials in ~/.moneyflow/credentials.enc -Uses Fernet symmetric encryption with a user-provided password. +Supports two storage modes: +1. Encrypted: Credentials stored in ~/.moneyflow/credentials.enc using + Fernet symmetric encryption with a user-provided password. +2. Unencrypted: Credentials stored in ~/.moneyflow/credentials.json as + plaintext JSON with file permissions restricted to owner only. + Supports multiple backends (Monarch Money, YNAB, etc.). """ @@ -11,24 +15,31 @@ import os from getpass import getpass from pathlib import Path -from typing import Dict, Optional +from typing import Dict, Optional, Tuple from cryptography.fernet import Fernet, InvalidToken from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from .file_utils import secure_write_file + class CredentialManager: """ - Manages encrypted credentials for finance backends. + Manages credentials for finance backends with optional encryption. - Supports two modes: - 1. Multi-account mode (profile_dir specified): - Credentials stored in {profile_dir}/credentials.enc + Supports three modes: + 1. Encrypted mode: Credentials stored in credentials.enc with Fernet encryption + 2. Unencrypted mode: Credentials stored in credentials.json as plaintext + 3. Auto-detect: Checks for existing credentials and determines mode + + Also supports: + - Multi-account mode (profile_dir specified): + Credentials stored in {profile_dir}/credentials.{enc|json} Each account has isolated credentials/salt - 2. Legacy mode (profile_dir=None): - Credentials stored in {config_dir}/credentials.enc + - Legacy mode (profile_dir=None): + Credentials stored in {config_dir}/credentials.{enc|json} Single account (backward compatible) """ @@ -57,6 +68,7 @@ def __init__(self, config_dir: Optional[Path] = None, profile_dir: Optional[Path self.storage_dir = storage_dir self.credentials_file = storage_dir / "credentials.enc" + self.plaintext_credentials_file = storage_dir / "credentials.json" self.salt_file = storage_dir / "salt" # Create storage directory if it doesn't exist @@ -94,16 +106,29 @@ def _get_or_create_salt(self) -> bytes: return f.read() else: salt = os.urandom(16) - with open(self.salt_file, "wb") as f: - f.write(salt) - # Ensure only user can read - os.chmod(self.salt_file, 0o600) + secure_write_file(self.salt_file, salt, "wb") return salt def credentials_exist(self) -> bool: - """Check if credentials file exists.""" + """Check if credentials file exists (encrypted or plaintext).""" + return self.credentials_file.exists() or self.plaintext_credentials_file.exists() + + def is_encrypted(self) -> bool: + """Check if existing credentials are encrypted. + + Returns: + True if encrypted credentials exist, False if plaintext or no credentials. + """ return self.credentials_file.exists() + def is_plaintext(self) -> bool: + """Check if existing credentials are plaintext (unencrypted). + + Returns: + True if plaintext credentials exist, False otherwise. + """ + return self.plaintext_credentials_file.exists() and not self.credentials_file.exists() + def save_credentials( self, email: str, @@ -111,36 +136,24 @@ def save_credentials( mfa_secret: str, encryption_password: Optional[str] = None, backend_type: str = "monarch", + use_encryption: bool = True, ) -> None: """ - Save encrypted credentials to disk. + Save credentials to disk, optionally encrypted. Args: email: Backend account email password: Backend account password mfa_secret: OTP/TOTP secret for 2FA encryption_password: Password to encrypt credentials. - If None, will prompt user. + Required if use_encryption=True. + If None and use_encryption=True, will prompt user. backend_type: Backend type (e.g., 'monarch', 'ynab'). Defaults to 'monarch' for backward compatibility. + use_encryption: If True, encrypt credentials with password. + If False, store as plaintext JSON (default: True). """ - # Get encryption password - if encryption_password is None: - print("Set a password to encrypt your credentials:") - encryption_password = getpass("Encryption password: ") - confirm = getpass("Confirm password: ") - - if encryption_password != confirm: - raise ValueError("Passwords do not match!") - - # Get or create salt - salt = self._get_or_create_salt() - - # Derive encryption key - key = self._derive_key(encryption_password, salt) - fernet = Fernet(key) - - # Prepare credentials (now includes backend_type) + # Prepare credentials credentials = { "email": email, "password": password, @@ -148,38 +161,89 @@ def save_credentials( "backend_type": backend_type, } - # Encrypt and save - encrypted = fernet.encrypt(json.dumps(credentials).encode()) + if use_encryption: + # Get encryption password if not provided + if encryption_password is None: + print("Set a password to encrypt your credentials:") + encryption_password = getpass("Encryption password: ") + confirm = getpass("Confirm password: ") + + if encryption_password != confirm: + raise ValueError("Passwords do not match!") + + # Get or create salt + salt = self._get_or_create_salt() + + # Derive encryption key + key = self._derive_key(encryption_password, salt) + fernet = Fernet(key) + + # Encrypt and save with secure permissions from creation + encrypted = fernet.encrypt(json.dumps(credentials).encode()) + secure_write_file(self.credentials_file, encrypted, "wb") + + # Remove plaintext file if it exists (switching from plaintext to encrypted) + if self.plaintext_credentials_file.exists(): + self.plaintext_credentials_file.unlink() - with open(self.credentials_file, "wb") as f: - f.write(encrypted) + print(f"✓ Encrypted credentials saved to {self.credentials_file}") + else: + # Save as plaintext JSON with secure permissions from creation + json_data = json.dumps(credentials, indent=2) + secure_write_file(self.plaintext_credentials_file, json_data, "w") - # Ensure only user can read - os.chmod(self.credentials_file, 0o600) + # Remove encrypted file and salt if they exist (switching from encrypted to plaintext) + if self.credentials_file.exists(): + self.credentials_file.unlink() + if self.salt_file.exists(): + self.salt_file.unlink() - print(f"✓ Credentials saved to {self.credentials_file}") + print(f"✓ Credentials saved to {self.plaintext_credentials_file}") def load_credentials( self, encryption_password: Optional[str] = None - ) -> tuple[Dict[str, str], bytes]: + ) -> Tuple[Dict[str, str], Optional[bytes]]: """ - Load and decrypt credentials from disk. + Load credentials from disk (encrypted or plaintext). + + Automatically detects whether credentials are encrypted or plaintext. + For encrypted credentials, requires encryption_password. + For plaintext credentials, encryption_password is ignored. Args: encryption_password: Password to decrypt credentials. - If None, will prompt user. + Required for encrypted credentials. + If None and encrypted, will prompt user. + Ignored for plaintext credentials. Returns: Tuple of: - Dictionary with 'email', 'password', 'mfa_secret', and 'backend_type' keys. For backward compatibility, 'backend_type' defaults to 'monarch' if not present. - - Encryption key (32-byte URL-safe base64-encoded) for use with cache encryption + - Encryption key (32-byte URL-safe base64-encoded) for use with cache encryption, + or None if credentials are plaintext. Raises: FileNotFoundError: If credentials file doesn't exist - ValueError: If password is incorrect + ValueError: If password is incorrect (encrypted mode only) """ - if not self.credentials_file.exists(): + # Prefer encrypted credentials when both exist (security-first approach) + # This matches the is_encrypted() / is_plaintext() method semantics + if self.credentials_file.exists(): + # Load encrypted credentials + pass # Fall through to encrypted loading below + elif self.plaintext_credentials_file.exists(): + # Only use plaintext if no encrypted file exists + with open(self.plaintext_credentials_file, "r") as f: + credentials = json.load(f) + + # Backward compatibility: add backend_type if not present + if "backend_type" not in credentials: + credentials["backend_type"] = "monarch" + + # No encryption key for plaintext credentials + return credentials, None + else: raise FileNotFoundError( f"Credentials file not found: {self.credentials_file}\n" "Run with --setup-credentials to create one." @@ -214,21 +278,32 @@ def load_credentials( raise ValueError("Incorrect password!") def delete_credentials(self) -> None: - """Delete stored credentials.""" + """Delete stored credentials (encrypted and/or plaintext).""" + deleted = False + if self.credentials_file.exists(): self.credentials_file.unlink() - print(f"✓ Credentials deleted from {self.credentials_file}") + print(f"✓ Encrypted credentials deleted from {self.credentials_file}") + deleted = True + + if self.plaintext_credentials_file.exists(): + self.plaintext_credentials_file.unlink() + print(f"✓ Plaintext credentials deleted from {self.plaintext_credentials_file}") + deleted = True if self.salt_file.exists(): self.salt_file.unlink() + if not deleted: + print("No credentials found to delete.") + def setup_credentials_interactive() -> None: """ Interactive setup for storing finance backend credentials. This walks the user through selecting a backend and entering their credentials - with encryption setup. + with optional encryption. """ print("=" * 70) print("Finance Backend Credential Setup") @@ -257,9 +332,6 @@ def setup_credentials_interactive() -> None: print("Monarch Money Credential Setup") print("=" * 70) print() - print("This will securely store your Monarch Money credentials") - print("encrypted with a password of your choice.") - print() print("IMPORTANT: You'll need your 2FA/OTP secret key for automatic login.") print("This is the BASE32 secret shown when you first set up 2FA") print("(usually a long string like: JBSWY3DPEHPK3PXP)") @@ -286,9 +358,6 @@ def setup_credentials_interactive() -> None: print("YNAB Credential Setup") print("=" * 70) print() - print("This will securely store your YNAB Personal Access Token") - print("encrypted with a password of your choice.") - print() print("How to get your YNAB Personal Access Token:") print(" 1. Sign in to the YNAB web app") print(" 2. Go to Account Settings → Developer Settings") @@ -304,24 +373,56 @@ def setup_credentials_interactive() -> None: password = access_token mfa_secret = "" + # Ask about encryption + print() + print("=" * 70) + print("Security Options") + print("=" * 70) + print() + print("Would you like to password-protect your stored credentials?") + print() + print(" y = Yes, encrypt with a password (more secure)") + print(" n = No, store without encryption (more convenient)") + print() + print("Note: Either way, credentials are stored with restricted file permissions.") + print("Password protection adds an extra layer of security if someone gains") + print("access to your computer.") + print() + + encrypt_choice = input("Password protect credentials? [n]: ").strip().lower() or "n" + use_encryption = encrypt_choice in ("y", "yes") + # Save credentials with backend type manager = CredentialManager() - manager.save_credentials(email, password, mfa_secret, backend_type=backend_type) + + if use_encryption: + # Let save_credentials prompt for the encryption password + manager.save_credentials( + email, password, mfa_secret, backend_type=backend_type, use_encryption=True + ) + cred_file = manager.credentials_file + extra_step = " 2. You'll need to enter your encryption password each time" + else: + manager.save_credentials( + email, password, mfa_secret, backend_type=backend_type, use_encryption=False + ) + cred_file = manager.plaintext_credentials_file + extra_step = " 2. No password needed - credentials load automatically" print() print("=" * 70) print("✓ Setup Complete!") print("=" * 70) print() - print("Your credentials are encrypted and stored at:") - print(f" {manager.credentials_file}") + print("Your credentials are stored at:") + print(f" {cred_file}") print() print("Next steps:") print(" 1. Run the TUI: uv run moneyflow") - print(" 2. You'll only need to enter your encryption password") + print(extra_step) print() print("To reset credentials:") - print(f" rm {manager.credentials_file}") + print(f" rm {cred_file}") print(" uv run moneyflow") print() print("=" * 70) diff --git a/moneyflow/file_utils.py b/moneyflow/file_utils.py new file mode 100644 index 0000000..3258354 --- /dev/null +++ b/moneyflow/file_utils.py @@ -0,0 +1,97 @@ +""" +Shared file utilities for secure file operations. + +Provides helpers for writing files with restrictive permissions from creation, +avoiding race conditions where files briefly have default permissions. +""" + +import os +import tempfile +from pathlib import Path +from typing import Union + + +def secure_write_file(path: Path, data: Union[bytes, str], mode: str = "wb") -> None: + """ + Write data to a file with restrictive permissions (0o600) from creation. + + This avoids the chmod race condition where a file is created with default + permissions and then chmod'd, leaving a window where others could read it. + + Args: + path: Path to write to + data: Data to write (bytes for 'wb', str for 'w') + mode: 'wb' for binary, 'w' for text + + Raises: + OSError: If file operations fail + """ + # O_WRONLY: write only, O_CREAT: create if not exists, O_TRUNC: truncate if exists + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + fd = os.open(path, flags, 0o600) + try: + # Explicitly set permissions - os.open mode is only applied for new files + os.fchmod(fd, 0o600) + # os.fdopen takes ownership of the fd and closes it when the file object closes + with os.fdopen(fd, mode) as f: + if mode == "w" and isinstance(data, bytes): + f.write(data.decode()) + elif mode == "wb" and isinstance(data, str): + f.write(data.encode()) + else: + f.write(data) + except Exception: + # If os.fdopen fails, we need to close the fd ourselves + # But if it succeeds and write fails, fdopen's context manager handles it + # os.fdopen() only fails before taking ownership, so check if fd is still valid + try: + os.close(fd) + except OSError: + # fd was already closed by os.fdopen + pass + raise + + +def secure_atomic_write(path: Path, data: bytes) -> None: + """ + Write data atomically with restrictive permissions. + + Creates a temp file with secure permissions, writes data, then atomically + renames to the target path. This provides both security and atomicity. + + Args: + path: Final path to write to + data: Data to write (bytes) + + Raises: + OSError: If file operations fail + """ + # Create temp file in same directory for atomic rename + dir_path = path.parent + dir_path.mkdir(mode=0o700, parents=True, exist_ok=True) + + # Create temp file with secure permissions + fd, temp_path = tempfile.mkstemp(dir=dir_path, prefix=".tmp_") + try: + os.fchmod(fd, 0o600) + # Use fdopen for proper write handling (handles short writes, interrupts) + with os.fdopen(fd, "wb") as f: + f.write(data) + f.flush() + os.fsync(f.fileno()) + fd = -1 # Mark as closed (fdopen took ownership) + + # Atomic rename - os.replace is guaranteed atomic on same filesystem + os.replace(temp_path, path) + except Exception: + if fd >= 0: + try: + os.close(fd) + except OSError: + pass + # Clean up temp file on error + try: + os.unlink(temp_path) + except OSError: + pass + raise diff --git a/moneyflow/formatters.py b/moneyflow/formatters.py index a5d6be9..cd9a59c 100644 --- a/moneyflow/formatters.py +++ b/moneyflow/formatters.py @@ -305,6 +305,7 @@ def prepare_aggregation_columns( }, {"label": f"Count {count_arrow}".strip(), "key": "count", "width": 10}, {"label": total_label_text, "key": "total", "width": None}, # Auto-size to content + {"label": "%", "key": "pct", "width": 6}, # Percentage of total ] # Add top category column for merchant view @@ -398,6 +399,14 @@ def format_aggregation_rows( rows: list[tuple] = [] + # Compute separate totals for income (positive) and expenses (negative) + if not df.is_empty(): + total_income = df.filter(pl.col("total") > 0)["total"].sum() + total_expenses = abs(df.filter(pl.col("total") < 0)["total"].sum()) + else: + total_income = 0.0 + total_expenses = 0.0 + for row_dict in df.iter_rows(named=True): # Get the name from first column (merchant/category/group/account/time_period_display) name = str(row_dict.get(df.columns[0], "Unknown") or "Unknown") @@ -429,11 +438,29 @@ def format_aggregation_rows( if name in groups_with_pending_edits: flags += "*" + # Calculate percentage relative to income or expense total + # Income (positive) shown in green, expenses in standard color + if total > 0: + # Income: percentage of total income + if total_income > 0: + pct = total / total_income * 100 + pct_display: Union[str, Text] = Text(f"{pct:.1f}%", style="green") + else: + pct_display = Text("0.0%", style="green") + else: + # Expense: percentage of total expenses + if total_expenses > 0: + pct = abs(total) / total_expenses * 100 + pct_display = f"{pct:.1f}%" + else: + pct_display = "0.0%" + # Build base row data - row_data = [ + row_data: list[Union[str, Text]] = [ name, str(count), ViewPresenter.format_amount(total, for_table=True), + pct_display, ] # Add top category for merchant view diff --git a/moneyflow/mcp/__init__.py b/moneyflow/mcp/__init__.py new file mode 100644 index 0000000..dffeeae --- /dev/null +++ b/moneyflow/mcp/__init__.py @@ -0,0 +1,9 @@ +""" +MCP (Model Context Protocol) server for moneyflow. + +Exposes moneyflow functionality to LLM applications like Claude Desktop. +""" + +from .server import create_mcp_server, run_mcp_server + +__all__ = ["create_mcp_server", "run_mcp_server"] diff --git a/moneyflow/mcp/__main__.py b/moneyflow/mcp/__main__.py new file mode 100644 index 0000000..3f1a238 --- /dev/null +++ b/moneyflow/mcp/__main__.py @@ -0,0 +1,128 @@ +""" +CLI entry point for the moneyflow MCP server. + +Run the server with: + python -m moneyflow.mcp [--account ACCOUNT_ID] [--transport stdio|streamable-http] + +Or use the CLI command: + moneyflow-mcp [--account ACCOUNT_ID] [--transport stdio|streamable-http] +""" + +import argparse +import logging +import sys + + +def main(): + parser = argparse.ArgumentParser( + description="Run the moneyflow MCP server for Claude Desktop integration", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + # Run with stdio transport (default, for Claude Desktop) + python -m moneyflow.mcp + + # Run with specific account + python -m moneyflow.mcp --account my-monarch-account + + # Run in read-only mode (no modifications allowed) + python -m moneyflow.mcp --read-only + + # Run with HTTP transport (for remote access via Tailscale) + python -m moneyflow.mcp --transport streamable-http + +Security Note: + This server exposes your financial data to LLM applications. + Only run on trusted networks (localhost or Tailscale). + + For HTTP transport, ensure you're using a secure network like Tailscale. + There is NO built-in authentication - anyone who can reach the server + can access your financial data. + +Environment Variables: + MONEYFLOW_PASSWORD Password for encrypted credentials (if account uses + password protection). Required for encrypted accounts + since MCP server cannot prompt interactively. +""", + ) + + parser.add_argument( + "--account", + "-a", + help="Account ID to use (defaults to last active account)", + ) + parser.add_argument( + "--config-dir", + help="Custom config directory (defaults to ~/.moneyflow)", + ) + parser.add_argument( + "--transport", + "-t", + choices=["stdio", "streamable-http"], + default="stdio", + help="Transport type (default: stdio)", + ) + parser.add_argument( + "--read-only", + "-r", + action="store_true", + help="Run in read-only mode (disable all write operations)", + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Enable verbose logging", + ) + + args = parser.parse_args() + + # Configure logging + log_level = logging.DEBUG if args.verbose else logging.INFO + logging.basicConfig( + level=log_level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + stream=sys.stderr, # Log to stderr so it doesn't interfere with stdio transport + ) + + # Security warning for HTTP transport + if args.transport == "streamable-http": + print( + "=" * 70, + "SECURITY WARNING: HTTP Transport", + "=" * 70, + "", + "You are running the MCP server with HTTP transport.", + "This server has NO built-in authentication.", + "", + "Anyone who can reach this server can:", + " - Read all your financial transactions", + " - View spending summaries and account details", + " - Modify transaction categories (unless --read-only is set)", + "", + "Only use HTTP transport on secure networks like Tailscale.", + "For local Claude Desktop use, prefer stdio transport (default).", + "", + "=" * 70, + sep="\n", + file=sys.stderr, + ) + + from .server import run_mcp_server + + try: + run_mcp_server( + account_id=args.account, + config_dir=args.config_dir, + transport=args.transport, + read_only=args.read_only, + ) + except KeyboardInterrupt: + sys.exit(0) + except Exception as e: + logging.error(f"Server error: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/moneyflow/mcp/server.py b/moneyflow/mcp/server.py new file mode 100644 index 0000000..77768d8 --- /dev/null +++ b/moneyflow/mcp/server.py @@ -0,0 +1,1046 @@ +""" +MCP server implementation for moneyflow. + +Exposes personal finance data and operations through the Model Context Protocol, +allowing LLM applications like Claude Desktop to interact with your financial data. + +Security Considerations: +- The MCP server runs locally or on a trusted network (e.g., Tailscale) +- Uses the same credential system as the TUI (encrypted or plaintext) +- No built-in authentication - relies on network-level security +- Sensitive financial data is exposed - only run on trusted networks +- Use --read-only mode when you only need to query data +- HTTP transport should only be used over secure networks (e.g., Tailscale) + +Usage: + # Run with stdio transport (for local Claude Desktop) + python -m moneyflow.mcp + + # Run in read-only mode (no modifications allowed) + python -m moneyflow.mcp --read-only + + # Or programmatically + from moneyflow.mcp import run_mcp_server + run_mcp_server(account_id="your-account-id") +""" + +import json +import logging +import os +from datetime import date, datetime, timedelta +from pathlib import Path +from typing import Any, Dict, List, Optional + +import polars as pl + +logger = logging.getLogger(__name__) + +# Environment variable for encrypted credential password +ENV_PASSWORD = "MONEYFLOW_PASSWORD" + +# Security: Maximum number of results to prevent memory issues +MAX_LIMIT = 1000 + +# Security: Maximum batch size to prevent abuse +MAX_BATCH_SIZE = 100 + + +def create_mcp_server( + account_id: Optional[str] = None, + config_dir: Optional[str] = None, + read_only: bool = False, +): + """ + Create and configure the MCP server for moneyflow. + + Args: + account_id: The account ID to use. If None, uses the last active account. + config_dir: Custom config directory. Defaults to ~/.moneyflow + read_only: If True, disable all write operations (category updates, etc.) + + Returns: + Configured FastMCP server instance + """ + from mcp.server.fastmcp import FastMCP + + from ..account_manager import AccountManager + from ..backends import get_backend + from ..credentials import CredentialManager + from ..data_manager import DataManager + + mcp = FastMCP("moneyflow") + + # State that will be initialized on first use + _state: Dict[str, Any] = { + "data_manager": None, + "transactions_df": None, + "categories": None, + "category_groups": None, + "initialized": False, + "account_id": account_id, + "config_dir": config_dir, + "read_only": read_only, + } + + async def _ensure_initialized(): + """Lazy initialization of the data manager and data.""" + if _state["initialized"]: + return + + config_path = Path(_state["config_dir"]) if _state["config_dir"] else None + account_manager = AccountManager(config_dir=config_path) + + # Get account to use + if _state["account_id"]: + account = account_manager.get_account(_state["account_id"]) + if not account: + raise ValueError(f"Account '{_state['account_id']}' not found") + else: + account = account_manager.get_last_active_account() + if not account: + raise ValueError("No accounts configured. Run 'moneyflow' to set up.") + + profile_dir = account_manager.get_profile_dir(account.id) + logger.info(f"Using account: {account.name} ({account.backend_type})") + + # Load credentials + cred_manager = CredentialManager(config_dir=config_path, profile_dir=profile_dir) + + if not cred_manager.credentials_exist(): + raise ValueError(f"No credentials found for account '{account.id}'") + + # Check if credentials are encrypted + if cred_manager.is_encrypted(): + # Try to get password from environment variable + encryption_password = os.environ.get(ENV_PASSWORD) + if not encryption_password: + raise ValueError( + f"Account '{account.id}' uses encrypted credentials.\n\n" + f"The MCP server cannot prompt for a password interactively.\n" + f"Options:\n" + f" 1. Set the {ENV_PASSWORD} environment variable:\n" + f" export {ENV_PASSWORD}='your-password'\n" + f" moneyflow-mcp\n\n" + f" 2. Use an account with unencrypted credentials:\n" + f" Run 'moneyflow' and set up a new account without password protection\n" + ) + creds, encryption_key = cred_manager.load_credentials( + encryption_password=encryption_password + ) + else: + # Plaintext credentials - load directly + creds, encryption_key = cred_manager.load_credentials() + + # Create backend + backend = get_backend(account.backend_type) + + # Login to backend + if account.backend_type == "monarch": + await backend.login( + email=creds["email"], + password=creds["password"], + mfa_secret_key=creds.get("mfa_secret"), + ) + elif account.backend_type == "ynab": + # YNAB uses the password field to store the access token + # Cast to YNABBackend to access the budget_id parameter + from ..backends.ynab import YNABBackend + + if isinstance(backend, YNABBackend): + await backend.login(password=creds["password"], budget_id=account.budget_id) + else: + await backend.login(password=creds["password"]) + + # Create data manager (caching handled separately if needed) + config_dir_str = str(config_path) if config_path else str(Path.home() / ".moneyflow") + data_manager = DataManager( + mm=backend, + config_dir=config_dir_str, + profile_dir=profile_dir, + backend_type=account.backend_type, + ) + + # Fetch data (uses cache if available) + df, categories, category_groups = await data_manager.fetch_all_data() + + _state["data_manager"] = data_manager + _state["transactions_df"] = df + _state["categories"] = categories + _state["category_groups"] = category_groups + _state["initialized"] = True + _state["account"] = account + + logger.info(f"Loaded {len(df)} transactions") + + def _format_amount(amount: float) -> str: + """Format amount as currency string.""" + if amount < 0: + return f"-${abs(amount):,.2f}" + return f"${amount:,.2f}" + + def _clamp_limit(limit: int) -> int: + """Clamp limit to MAX_LIMIT to prevent memory issues.""" + return max(1, min(limit, MAX_LIMIT)) + + def _df_to_records(df: pl.DataFrame, limit: int = 100) -> List[Dict[str, Any]]: + """Convert DataFrame to list of records with formatting.""" + if len(df) == 0: + return [] + + # Clamp and apply limit + limit = _clamp_limit(limit) + if len(df) > limit: + df = df.head(limit) + + records = [] + for row in df.iter_rows(named=True): + record = { + "id": row.get("id", ""), + "date": str(row.get("date", "")), + "merchant": row.get("merchant", ""), + "category": row.get("category", ""), + "amount": row.get("amount", 0), + "amount_formatted": _format_amount(row.get("amount", 0)), + "account": row.get("account", ""), + } + if row.get("notes"): + record["notes"] = row["notes"] + if row.get("is_hidden"): + record["is_hidden"] = True + records.append(record) + + return records + + # ========== TOOLS ========== + + @mcp.tool() + async def search_transactions( + query: str, + limit: int = 50, + ) -> str: + """ + Search transactions by merchant name, category, or notes. + + Args: + query: Search query (searches merchant, category, and notes) + limit: Maximum number of results to return (default 50) + + Returns: + JSON array of matching transactions + """ + await _ensure_initialized() + + dm = _state["data_manager"] + df = _state["transactions_df"] + + results = dm.search_transactions(df, query) + records = _df_to_records(results, limit=limit) + + return json.dumps(records, indent=2) + + @mcp.tool() + async def get_transactions( + start_date: Optional[str] = None, + end_date: Optional[str] = None, + category: Optional[str] = None, + merchant: Optional[str] = None, + min_amount: Optional[float] = None, + max_amount: Optional[float] = None, + limit: int = 100, + ) -> str: + """ + Get transactions with optional filters. + + Args: + start_date: Filter transactions on or after this date (YYYY-MM-DD) + end_date: Filter transactions on or before this date (YYYY-MM-DD) + category: Filter by category name (exact match) + merchant: Filter by merchant name (contains, case-insensitive) + min_amount: Minimum amount (use negative for expenses) + max_amount: Maximum amount (use negative for expenses) + limit: Maximum number of results (default 100) + + Returns: + JSON array of transactions + """ + await _ensure_initialized() + + df = _state["transactions_df"] + + # Apply filters + if start_date: + df = df.filter(pl.col("date") >= start_date) + if end_date: + df = df.filter(pl.col("date") <= end_date) + if category: + df = df.filter(pl.col("category") == category) + if merchant: + df = df.filter( + pl.col("merchant").str.to_lowercase().str.contains(merchant.lower(), literal=True) + ) + if min_amount is not None: + df = df.filter(pl.col("amount") >= min_amount) + if max_amount is not None: + df = df.filter(pl.col("amount") <= max_amount) + + records = _df_to_records(df, limit=limit) + return json.dumps(records, indent=2) + + @mcp.tool() + async def get_spending_summary( + start_date: Optional[str] = None, + end_date: Optional[str] = None, + group_by: str = "category", + ) -> str: + """ + Get spending summary grouped by category or merchant. + + Args: + start_date: Start date for the summary (YYYY-MM-DD). Defaults to 30 days ago. + end_date: End date for the summary (YYYY-MM-DD). Defaults to today. + group_by: Group spending by "category" or "merchant" (default: category) + + Returns: + JSON object with spending summary + """ + await _ensure_initialized() + + df = _state["transactions_df"] + + # Default date range: last 30 days + if not end_date: + end_date = date.today().isoformat() + if not start_date: + start_date = (date.today() - timedelta(days=30)).isoformat() + + # Filter by date + df = df.filter((pl.col("date") >= start_date) & (pl.col("date") <= end_date)) + + # Filter to expenses only (negative amounts) + expenses = df.filter(pl.col("amount") < 0) + + # Group and sum + group_col = "category" if group_by == "category" else "merchant" + summary = ( + expenses.group_by(group_col) + .agg([pl.col("amount").sum().alias("total"), pl.col("id").count().alias("count")]) + .sort("total") # Sort by total (most negative first = highest spending) + ) + + # Format results + results = { + "period": {"start": start_date, "end": end_date}, + "total_spending": _format_amount(expenses["amount"].sum()), + "transaction_count": len(expenses), + "by_" + group_by: [], + } + + for row in summary.iter_rows(named=True): + results["by_" + group_by].append( + { + group_by: row[group_col], + "total": _format_amount(row["total"]), + "count": row["count"], + } + ) + + return json.dumps(results, indent=2) + + @mcp.tool() + async def get_categories() -> str: + """ + Get all available categories and their groups. + + Returns: + JSON object with categories organized by group + """ + await _ensure_initialized() + + categories = _state["categories"] + category_groups = _state["category_groups"] + + # Organize categories by group + by_group: Dict[str, List[str]] = {} + for cat_id, cat_name in categories.items(): + group_name = category_groups.get(cat_id, "Other") + if group_name not in by_group: + by_group[group_name] = [] + by_group[group_name].append(cat_name) + + # Sort categories within groups + for group in by_group: + by_group[group].sort() + + return json.dumps(by_group, indent=2) + + @mcp.tool() + async def get_merchants(limit: int = 100) -> str: + """ + Get all merchants with transaction counts. + + Args: + limit: Maximum number of merchants to return (default 100, max 1000) + + Returns: + JSON array of merchants with transaction counts, sorted by frequency + """ + await _ensure_initialized() + + df = _state["transactions_df"] + limit = _clamp_limit(limit) + + merchant_counts = ( + df.group_by("merchant") + .agg( + [ + pl.col("id").count().alias("transaction_count"), + pl.col("amount").sum().alias("total_amount"), + ] + ) + .sort("transaction_count", descending=True) + .head(limit) + ) + + results = [] + for row in merchant_counts.iter_rows(named=True): + results.append( + { + "merchant": row["merchant"], + "transaction_count": row["transaction_count"], + "total_amount": _format_amount(row["total_amount"]), + } + ) + + return json.dumps(results, indent=2) + + @mcp.tool() + async def get_account_info() -> str: + """ + Get information about the connected account. + + Returns: + JSON object with account information + """ + await _ensure_initialized() + + account = _state["account"] + df = _state["transactions_df"] + + # Calculate date range + if len(df) > 0: + date_range = { + "earliest": str(df["date"].min()), + "latest": str(df["date"].max()), + } + else: + date_range = {"earliest": None, "latest": None} + + return json.dumps( + { + "account_id": account.id, + "name": account.name, + "backend_type": account.backend_type, + "transaction_count": len(df), + "date_range": date_range, + "category_count": len(_state["categories"]), + }, + indent=2, + ) + + @mcp.tool() + async def refresh_data() -> str: + """ + Refresh transaction data from the backend API. + + This bypasses the cache and fetches fresh data. Use sparingly + as it may be rate-limited by the backend. + + Returns: + JSON object with refresh status + """ + await _ensure_initialized() + + dm = _state["data_manager"] + + # Fetch fresh data from API (DataManager doesn't use cache) + df, categories, category_groups = await dm.fetch_all_data() + + _state["transactions_df"] = df + _state["categories"] = categories + _state["category_groups"] = category_groups + + return json.dumps( + { + "status": "success", + "transaction_count": len(df), + "refreshed_at": datetime.now().isoformat(), + }, + indent=2, + ) + + # ========== CATEGORIZATION TOOLS ========== + + @mcp.tool() + async def get_uncategorized_transactions( + limit: int = 100, + merchant: Optional[str] = None, + ) -> str: + """ + Get transactions that need categorization. + + Finds transactions with category "Uncategorized" that need to be assigned + to a proper category. Use this to identify items that need attention. + + Args: + limit: Maximum number of transactions to return (default 100) + merchant: Optional filter by merchant name (contains, case-insensitive) + + Returns: + JSON array of uncategorized transactions + """ + await _ensure_initialized() + + df = _state["transactions_df"] + + # Filter to uncategorized + uncategorized = df.filter( + (pl.col("category") == "Uncategorized") + | (pl.col("category").is_null()) + | (pl.col("category") == "") + ) + + if merchant: + uncategorized = uncategorized.filter( + pl.col("merchant").str.to_lowercase().str.contains(merchant.lower(), literal=True) + ) + + records = _df_to_records(uncategorized, limit=limit) + return json.dumps( + { + "total_uncategorized": len(uncategorized), + "showing": len(records), + "transactions": records, + }, + indent=2, + ) + + @mcp.tool() + async def update_transaction_category( + transaction_id: str, + category_name: Optional[str] = None, + category_id: Optional[str] = None, + dry_run: bool = False, + ) -> str: + """ + Update the category of a single transaction. + + Changes the category assignment for a transaction. The change is + immediately committed to the backend API unless dry_run is True. + + Args: + transaction_id: The unique ID of the transaction to update + category_name: The name of the category to assign (use category_id if names are ambiguous) + category_id: The ID of the category to assign (preferred for disambiguation) + dry_run: If True, validate and show what would change without committing + + Note: Provide either category_name or category_id, not both. Use category_id + when multiple categories share the same name. + + Returns: + JSON object with update status + """ + await _ensure_initialized() + + # Validate parameters + if not category_name and not category_id: + return json.dumps( + { + "status": "error", + "message": "Either category_name or category_id must be provided", + }, + indent=2, + ) + + if category_name and category_id: + return json.dumps( + { + "status": "error", + "message": "Provide either category_name or category_id, not both", + }, + indent=2, + ) + + # Check read-only mode + if _state["read_only"] and not dry_run: + return json.dumps( + { + "status": "error", + "message": "Server is in read-only mode. Use dry_run=True to preview changes.", + }, + indent=2, + ) + + dm = _state["data_manager"] + df = _state["transactions_df"] + categories = _state["categories"] + + # Resolve category - by ID or by name + if category_id: + # Direct ID lookup + if category_id not in categories: + return json.dumps( + { + "status": "error", + "message": f"Category ID '{category_id}' not found", + }, + indent=2, + ) + resolved_category_id = category_id + else: + # Name lookup with duplicate detection + matching_categories = [ + (cat_id, cat_name) + for cat_id, cat_name in categories.items() + if cat_name.lower() == category_name.lower() + ] + + if not matching_categories: + available = sorted(set(categories.values())) + return json.dumps( + { + "status": "error", + "message": f"Category '{category_name}' not found", + "available_categories": available[:50], + }, + indent=2, + ) + + if len(matching_categories) > 1: + # Multiple categories with same name - require ID for disambiguation + return json.dumps( + { + "status": "error", + "message": f"Multiple categories named '{category_name}' exist. " + "Please use category_id parameter instead.", + "matching_categories": [ + {"id": cat_id, "name": cat_name} + for cat_id, cat_name in matching_categories + ], + }, + indent=2, + ) + + resolved_category_id = matching_categories[0][0] + + # Get the resolved category name + resolved_category_name = categories[resolved_category_id] + + # Find the transaction + tx_rows = df.filter(pl.col("id") == transaction_id) + if len(tx_rows) == 0: + return json.dumps( + {"status": "error", "message": f"Transaction '{transaction_id}' not found"}, + indent=2, + ) + + old_category = tx_rows["category"][0] + tx = tx_rows.row(0, named=True) + + # Dry run - just show what would happen + if dry_run: + return json.dumps( + { + "status": "dry_run", + "message": "No changes made (dry run)", + "would_update": { + "transaction_id": transaction_id, + "merchant": tx.get("merchant"), + "amount": _format_amount(tx.get("amount", 0)), + "date": str(tx.get("date")), + "old_category": old_category, + "new_category": resolved_category_name, + }, + }, + indent=2, + ) + + # Update via backend API + try: + await dm.mm.update_transaction( + transaction_id=transaction_id, + category_id=resolved_category_id, + ) + + # Update local DataFrame + _state["transactions_df"] = df.with_columns( + pl.when(pl.col("id") == transaction_id) + .then(pl.lit(resolved_category_name)) + .otherwise(pl.col("category")) + .alias("category") + ) + + return json.dumps( + { + "status": "success", + "transaction_id": transaction_id, + "old_category": old_category, + "new_category": resolved_category_name, + }, + indent=2, + ) + except Exception as e: + return json.dumps( + {"status": "error", "message": f"Failed to update: {str(e)}"}, + indent=2, + ) + + @mcp.tool() + async def batch_update_category( + transaction_ids: List[str], + category_name: str, + dry_run: bool = False, + ) -> str: + """ + Update the category for multiple transactions at once. + + Applies the same category to all specified transactions. Changes are + immediately committed to the backend API unless dry_run is True. + + Args: + transaction_ids: List of transaction IDs to update (max 100) + category_name: The name of the category to assign + dry_run: If True, validate and show what would change without committing + + Returns: + JSON object with batch update results + """ + await _ensure_initialized() + + # Check read-only mode + if _state["read_only"] and not dry_run: + return json.dumps( + { + "status": "error", + "message": "Server is in read-only mode. Use dry_run=True to preview changes.", + }, + indent=2, + ) + + # Enforce batch size limit + if len(transaction_ids) > MAX_BATCH_SIZE: + return json.dumps( + { + "status": "error", + "message": f"Batch size {len(transaction_ids)} exceeds maximum of {MAX_BATCH_SIZE}", + }, + indent=2, + ) + + dm = _state["data_manager"] + df = _state["transactions_df"] + categories = _state["categories"] + + # Find the category ID + category_id = None + for cat_id, cat_name in categories.items(): + if cat_name.lower() == category_name.lower(): + category_id = cat_id + break + + if not category_id: + return json.dumps( + {"status": "error", "message": f"Category '{category_name}' not found"}, + indent=2, + ) + + # Find all transactions and validate they exist + found_transactions = [] + not_found = [] + for tx_id in transaction_ids: + tx_rows = df.filter(pl.col("id") == tx_id) + if len(tx_rows) == 0: + not_found.append(tx_id) + else: + tx = tx_rows.row(0, named=True) + found_transactions.append( + { + "transaction_id": tx_id, + "merchant": tx.get("merchant"), + "amount": _format_amount(tx.get("amount", 0)), + "date": str(tx.get("date")), + "old_category": tx.get("category"), + } + ) + + # Dry run - just show what would happen + if dry_run: + return json.dumps( + { + "status": "dry_run", + "message": "No changes made (dry run)", + "would_update": len(found_transactions), + "not_found": len(not_found), + "new_category": category_name, + "transactions": found_transactions, + "not_found_ids": not_found if not_found else None, + }, + indent=2, + ) + + success_count = 0 + failure_count = 0 + errors = [] + + for tx_id in transaction_ids: + if tx_id in not_found: + failure_count += 1 + errors.append({"transaction_id": tx_id, "error": "Transaction not found"}) + continue + + try: + await dm.mm.update_transaction( + transaction_id=tx_id, + category_id=category_id, + ) + success_count += 1 + except Exception as e: + failure_count += 1 + errors.append({"transaction_id": tx_id, "error": str(e)}) + + # Update local DataFrame for successful updates + if success_count > 0: + successful_ids = [ + tx_id + for tx_id in transaction_ids + if tx_id not in [e["transaction_id"] for e in errors] + ] + _state["transactions_df"] = df.with_columns( + pl.when(pl.col("id").is_in(successful_ids)) + .then(pl.lit(category_name)) + .otherwise(pl.col("category")) + .alias("category") + ) + + return json.dumps( + { + "status": "success" if failure_count == 0 else "partial", + "success_count": success_count, + "failure_count": failure_count, + "new_category": category_name, + "errors": errors if errors else None, + }, + indent=2, + ) + + @mcp.tool() + async def get_amazon_order_details( + transaction_id: str, + ) -> str: + """ + Look up Amazon order details for a transaction. + + For Amazon transactions, searches local Amazon order database to find + matching orders and show what items were purchased. This helps with + categorizing Amazon transactions by revealing the actual products. + + Args: + transaction_id: The ID of the transaction to look up + + Returns: + JSON object with Amazon order matches (items, prices, order IDs) + """ + await _ensure_initialized() + + from ..amazon_linker import AmazonLinker + + df = _state["transactions_df"] + config_path = ( + Path(_state["config_dir"]) if _state["config_dir"] else Path.home() / ".moneyflow" + ) + + # Find the transaction + tx_rows = df.filter(pl.col("id") == transaction_id) + if len(tx_rows) == 0: + return json.dumps( + {"status": "error", "message": f"Transaction '{transaction_id}' not found"}, + indent=2, + ) + + tx = tx_rows.row(0, named=True) + merchant = tx.get("merchant", "") + amount = tx.get("amount", 0) + tx_date = tx.get("date") + + # Create linker and search + linker = AmazonLinker(config_dir=config_path) + + if not linker.is_amazon_merchant(merchant): + return json.dumps( + { + "status": "not_amazon", + "message": f"Transaction merchant '{merchant}' doesn't appear to be Amazon", + "transaction": { + "id": transaction_id, + "merchant": merchant, + "amount": _format_amount(amount), + "date": str(tx_date), + }, + }, + indent=2, + ) + + # Search for matching orders + matches = linker.find_matching_orders( + amount=amount, + transaction_date=str(tx_date), + ) + + if not matches: + return json.dumps( + { + "status": "no_matches", + "message": "No matching Amazon orders found in local database", + "transaction": { + "id": transaction_id, + "merchant": merchant, + "amount": _format_amount(amount), + "date": str(tx_date), + }, + "hint": "Make sure you've imported Amazon order history via the Amazon backend", + }, + indent=2, + ) + + # Format matches + formatted_matches = [] + for match in matches: + formatted_matches.append( + { + "order_id": match.order_id, + "order_date": match.order_date, + "total_amount": _format_amount(match.total_amount), + "confidence": match.confidence, + "source_profile": match.source_profile, + "items": [ + { + "name": item.get("name", "Unknown"), + "amount": _format_amount(item.get("amount", 0)), + "quantity": item.get("quantity", 1), + } + for item in match.items + ], + } + ) + + return json.dumps( + { + "status": "found", + "transaction": { + "id": transaction_id, + "merchant": merchant, + "amount": _format_amount(amount), + "date": str(tx_date), + "current_category": tx.get("category", "Uncategorized"), + }, + "amazon_matches": formatted_matches, + }, + indent=2, + ) + + @mcp.tool() + async def get_transaction_details( + transaction_id: str, + ) -> str: + """ + Get full details for a specific transaction. + + Returns all available information about a transaction including + merchant, category, amount, date, account, and any notes. + + Args: + transaction_id: The ID of the transaction + + Returns: + JSON object with transaction details + """ + await _ensure_initialized() + + df = _state["transactions_df"] + + tx_rows = df.filter(pl.col("id") == transaction_id) + if len(tx_rows) == 0: + return json.dumps( + {"status": "error", "message": f"Transaction '{transaction_id}' not found"}, + indent=2, + ) + + tx = tx_rows.row(0, named=True) + + return json.dumps( + { + "id": tx.get("id"), + "date": str(tx.get("date")), + "merchant": tx.get("merchant"), + "category": tx.get("category"), + "amount": tx.get("amount"), + "amount_formatted": _format_amount(tx.get("amount", 0)), + "account": tx.get("account"), + "notes": tx.get("notes"), + "is_hidden": tx.get("is_hidden", False), + }, + indent=2, + ) + + # ========== RESOURCES ========== + + @mcp.resource("moneyflow://account") + async def account_resource() -> str: + """Account information for the connected financial account.""" + return await get_account_info() + + @mcp.resource("moneyflow://categories") + async def categories_resource() -> str: + """All available spending categories organized by group.""" + return await get_categories() + + @mcp.resource("moneyflow://merchants/top") + async def top_merchants_resource() -> str: + """Top 50 merchants by transaction frequency.""" + return await get_merchants(limit=50) + + @mcp.resource("moneyflow://spending/monthly") + async def monthly_spending_resource() -> str: + """Spending summary for the current month by category.""" + today = date.today() + start_of_month = today.replace(day=1).isoformat() + return await get_spending_summary(start_date=start_of_month, end_date=today.isoformat()) + + @mcp.resource("moneyflow://transactions/recent") + async def recent_transactions_resource() -> str: + """Most recent 50 transactions.""" + return await get_transactions(limit=50) + + return mcp + + +def run_mcp_server( + account_id: Optional[str] = None, + config_dir: Optional[str] = None, + transport: str = "stdio", + read_only: bool = False, +): + """ + Run the MCP server. + + Args: + account_id: Account ID to use (defaults to last active) + config_dir: Custom config directory + transport: Transport type - "stdio" or "streamable-http" + read_only: If True, disable all write operations + """ + mcp = create_mcp_server(account_id=account_id, config_dir=config_dir, read_only=read_only) + mcp.run(transport=transport) + + +if __name__ == "__main__": + run_mcp_server() diff --git a/moneyflow/screens/credential_screens.py b/moneyflow/screens/credential_screens.py index 1dde11e..1e60c63 100644 --- a/moneyflow/screens/credential_screens.py +++ b/moneyflow/screens/credential_screens.py @@ -207,6 +207,16 @@ class CredentialSetupScreen(ModalScreen): margin-bottom: 1; } + #encryption-section { + margin-top: 1; + padding: 1; + border: round $primary; + } + + #encryption-fields { + display: none; + } + #button-container { layout: horizontal; width: 100%; @@ -244,12 +254,6 @@ def compose(self) -> ComposeResult: if self.backend_type == "ynab": yield Label("🔐 YNAB Credential Setup", id="setup-title") - yield Static( - "This will securely store your YNAB Personal Access Token\n" - "encrypted with a password of your choice.", - classes="setup-help", - ) - yield Label("YNAB Personal Access Token:", classes="setup-label") yield Static( "Get this from: Account Settings → Developer Settings → New Token", @@ -264,12 +268,6 @@ def compose(self) -> ComposeResult: else: yield Label("🔐 Monarch Money Credential Setup", id="setup-title") - yield Static( - "This will securely store your Monarch Money credentials\n" - "encrypted with a password of your choice.", - classes="setup-help", - ) - yield Label("Monarch Money Email:", classes="setup-label") yield Input(placeholder="your@email.com", id="email-input", classes="setup-input") @@ -293,24 +291,36 @@ def compose(self) -> ComposeResult: classes="setup-input", ) - yield Label("Encryption Password (for moneyflow):", classes="setup-label") - yield Static( - "Create a NEW password to encrypt your stored credentials", classes="setup-help" - ) - yield Input( - placeholder="encryption password", - password=True, - id="encrypt-pass-input", - classes="setup-input", - ) + # Encryption toggle section + with Container(id="encryption-section"): + yield Checkbox( + "Password protect stored credentials", + value=False, + id="encryption-checkbox", + ) + yield Static( + "Adds an encryption layer. Uncheck for auto-login convenience.\n" + "Either way, credentials have restricted file permissions.", + classes="setup-help", + ) - yield Label("Confirm Encryption Password:", classes="setup-label") - yield Input( - placeholder="confirm password", - password=True, - id="confirm-pass-input", - classes="setup-input", - ) + # Encryption password fields (hidden by default) + with Container(id="encryption-fields", classes="encryption-fields"): + yield Label("Encryption Password:", classes="setup-label") + yield Input( + placeholder="encryption password", + password=True, + id="encrypt-pass-input", + classes="setup-input", + ) + + yield Label("Confirm Encryption Password:", classes="setup-label") + yield Input( + placeholder="confirm password", + password=True, + id="confirm-pass-input", + classes="setup-input", + ) with Container(id="button-container"): yield Button("Save Credentials", variant="primary", id="save-button") @@ -318,6 +328,21 @@ def compose(self) -> ComposeResult: yield Label("", id="error-label") + def on_checkbox_changed(self, event: Checkbox.Changed) -> None: + """Toggle encryption password fields visibility.""" + if event.checkbox.id == "encryption-checkbox": + encryption_fields = self.query_one("#encryption-fields", Container) + if event.value: + encryption_fields.display = True + else: + encryption_fields.display = False + # Clear the encryption password fields when unchecked + try: + self.query_one("#encrypt-pass-input", Input).value = "" + self.query_one("#confirm-pass-input", Input).value = "" + except Exception: + pass + async def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "exit-button": self.app.exit() @@ -330,18 +355,27 @@ async def save_credentials(self) -> None: """Validate and save credentials.""" error_label = self.query_one("#error-label", Label) - encrypt_pass = self.query_one("#encrypt-pass-input", Input).value - confirm_pass = self.query_one("#confirm-pass-input", Input).value + # Check if encryption is enabled + use_encryption = self.query_one("#encryption-checkbox", Checkbox).value - if encrypt_pass != confirm_pass: - error_label.update("❌ Encryption passwords do not match!") - return + encrypt_pass = "" + if use_encryption: + encrypt_pass = self.query_one("#encrypt-pass-input", Input).value + confirm_pass = self.query_one("#confirm-pass-input", Input).value + + if encrypt_pass != confirm_pass: + error_label.update("❌ Encryption passwords do not match!") + return + + if not encrypt_pass: + error_label.update("❌ Please enter an encryption password") + return if self.backend_type == "ynab": password = self.query_one("#password-input", Input).value.strip() - if not password or not encrypt_pass: - error_label.update("❌ Please fill in all fields") + if not password: + error_label.update("❌ Please enter your YNAB access token") return email = "" @@ -351,8 +385,8 @@ async def save_credentials(self) -> None: password = self.query_one("#password-input", Input).value mfa_secret = self.query_one("#mfa-input", Input).value.strip().replace(" ", "").upper() - if not email or not password or not mfa_secret or not encrypt_pass: - error_label.update("❌ Please fill in all fields") + if not email or not password or not mfa_secret: + error_label.update("❌ Please fill in all credential fields") return if "@" not in email: @@ -369,13 +403,16 @@ async def save_credentials(self) -> None: email=email, password=password, mfa_secret=mfa_secret, - encryption_password=encrypt_pass, + encryption_password=encrypt_pass if use_encryption else None, backend_type=self.backend_type, + use_encryption=use_encryption, ) # Load credentials back to get the encryption key for cache encryption - _, encryption_key = cred_manager.load_credentials(encryption_password=encrypt_pass) - self.app.encryption_key = encryption_key + _, encryption_key = cred_manager.load_credentials( + encryption_password=encrypt_pass if use_encryption else None + ) + self.app.encryption_key = encryption_key # Will be None if unencrypted error_label.update("✅ Credentials saved! Loading app...") diff --git a/pyproject.toml b/pyproject.toml index 2d77830..758a7b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,7 +27,7 @@ classifiers = [ dependencies = [ "aiohttp>=3.8.4", - "click>=8.1.0,<8.3.0", # 8.3.0 breaks mkdocs live reload + "click>=8.1.0,<8.3.0", # 8.3.0 breaks mkdocs live reload "gql>=3.4", "oathtool>=2.3.1", "polars>=0.19.0", @@ -36,6 +36,7 @@ dependencies = [ "cryptography>=41.0.0", "python-dateutil>=2.8.0", "ynab>=1.9.0", + "mcp[cli]>=1.25.0", ] [project.urls] @@ -46,6 +47,7 @@ Issues = "https://github.com/wesm/moneyflow/issues" [project.scripts] moneyflow = "moneyflow.cli:cli" moneyflow-setup = "moneyflow.credentials:setup_credentials_interactive" +moneyflow-mcp = "moneyflow.mcp.__main__:main" [build-system] requires = ["hatchling"] diff --git a/scripts/changelog.sh b/scripts/changelog.sh new file mode 100755 index 0000000..3d23daf --- /dev/null +++ b/scripts/changelog.sh @@ -0,0 +1,75 @@ +#!/bin/bash +# Generate a changelog since the last release using codex +# Usage: ./scripts/changelog.sh [version] [start_tag] [extra_instructions] +# If version is not provided, uses "NEXT" as placeholder +# If start_tag is "-" or empty, auto-detects the previous tag + +set -e + +VERSION="${1:-NEXT}" +START_TAG="$2" +EXTRA_INSTRUCTIONS="$3" + +# Determine the starting point +if [ -n "$START_TAG" ] && [ "$START_TAG" != "-" ]; then + # Use provided start tag + RANGE="$START_TAG..HEAD" + echo "Generating changelog from $START_TAG to HEAD..." >&2 +else + # Auto-detect previous tag + PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -z "$PREV_TAG" ]; then + # No previous tag, use first commit + FIRST_COMMIT=$(git rev-list --max-parents=0 HEAD) + RANGE="$FIRST_COMMIT..HEAD" + echo "No previous release found. Generating changelog for all commits..." >&2 + else + RANGE="$PREV_TAG..HEAD" + echo "Generating changelog from $PREV_TAG to HEAD..." >&2 + fi +fi + +# Get commit log for changelog generation +COMMITS=$(git log $RANGE --pretty=format:"- %s (%h)" --no-merges) +DIFF_STAT=$(git diff --stat $RANGE) + +if [ -z "$COMMITS" ]; then + echo "No commits since $PREV_TAG" >&2 + exit 0 +fi + +# Use codex to generate the changelog +echo "Using codex to generate changelog..." >&2 + +TMPFILE=$(mktemp) +trap 'rm -f "$TMPFILE"' EXIT + +codex exec --skip-git-repo-check --sandbox read-only -c reasoning_effort=high -o "$TMPFILE" - >/dev/null < 0, "Demo mode should populate data table" + + @pytest.mark.integration + async def test_demo_mode_keyboard_navigation(self, tmp_path): + """Demo mode should respond to keyboard navigation.""" + app = MoneyflowApp(demo_mode=True, config_dir=str(tmp_path)) + + async with app.run_test() as pilot: + # Wait for app to initialize + for _ in range(40): + if app.controller is not None and app.state.current_data is not None: + break + await pilot.pause() + + # Press 'j' to move down + await pilot.press("j") + await pilot.pause() + + # Press 'k' to move up + await pilot.press("k") + await pilot.pause() + + # App should still be running + assert app.is_running + + +# ============================================================================ +# Account Selection Workflow Tests +# ============================================================================ + + +class TestAccountSelectionWorkflow: + """Test the account selection workflow.""" + + @pytest.mark.integration + async def test_account_selector_screen_mounts(self, tmp_path): + """Account selector should mount without errors.""" + app = MoneyflowApp(demo_mode=True, config_dir=str(tmp_path)) + + async with app.run_test() as pilot: + # Wait for app to fully initialize + assert await _wait_for_app_ready(app, pilot), "App failed to initialize" + + # Push the account selector screen + screen = AccountSelectorScreen(config_dir=str(tmp_path)) + app.push_screen(screen) + await pilot.pause() + + # Screen should be displayed + assert app.screen is screen + + @pytest.mark.integration + async def test_account_selector_keyboard_navigation(self, tmp_path): + """Account selector should support keyboard navigation.""" + app = MoneyflowApp(demo_mode=True, config_dir=str(tmp_path)) + + async with app.run_test() as pilot: + # Wait for app to fully initialize + assert await _wait_for_app_ready(app, pilot), "App failed to initialize" + + screen = AccountSelectorScreen(config_dir=str(tmp_path)) + app.push_screen(screen) + await pilot.pause() + + # Navigate with j/k + await pilot.press("j") + await pilot.pause() + await pilot.press("k") + await pilot.pause() + + # Screen should still be displayed + assert app.screen is screen + + +# ============================================================================ +# Credential Setup Workflow Tests +# ============================================================================ + + +async def _wait_for_app_ready(app, pilot, attempts=40) -> bool: + """Wait for app to initialize (theme CSS loaded). + + Returns True if app became ready, False on timeout. + Callers should assert the return value to catch initialization failures. + """ + for _ in range(attempts): + if app.controller is not None and app.state.current_data is not None: + return True + await pilot.pause() + return False + + +class TestCredentialSetupWorkflow: + """Test the credential setup workflow.""" + + @pytest.mark.integration + async def test_credential_setup_screen_mounts(self, tmp_path): + """Credential setup screen should mount with expected widgets.""" + app = MoneyflowApp(demo_mode=True, config_dir=str(tmp_path)) + + async with app.run_test() as pilot: + # Wait for app to fully initialize (loads theme CSS) + assert await _wait_for_app_ready(app, pilot), "App failed to initialize" + + screen = CredentialSetupScreen( + backend_type="monarch", + profile_dir=tmp_path / "profiles" / "test", + ) + app.push_screen(screen) + await pilot.pause() + + # Screen should be displayed (pushed onto stack) + assert isinstance(app.screen, CredentialSetupScreen) + + # Verify key widgets are present + email_input = screen.query("#email-input") + assert len(list(email_input)) == 1, "Email input should be present" + + password_input = screen.query("#password-input") + assert len(list(password_input)) == 1, "Password input should be present" + + encryption_checkbox = screen.query("#encryption-checkbox") + assert len(list(encryption_checkbox)) == 1, "Encryption checkbox should be present" + + @pytest.mark.integration + async def test_credential_setup_encryption_toggle(self, tmp_path): + """Encryption checkbox should toggle password fields visibility.""" + app = MoneyflowApp(demo_mode=True, config_dir=str(tmp_path)) + + async with app.run_test() as pilot: + # Wait for app to fully initialize (loads theme CSS) + assert await _wait_for_app_ready(app, pilot), "App failed to initialize" + + screen = CredentialSetupScreen( + backend_type="monarch", + profile_dir=tmp_path / "profiles" / "test", + ) + app.push_screen(screen) + await pilot.pause() + await pilot.pause() # Extra pause for screen composition + + # Screen should be displayed + assert isinstance(app.screen, CredentialSetupScreen) + + # Get encryption checkbox and fields + checkbox = screen.query_one("#encryption-checkbox", Checkbox) + encryption_fields = screen.query_one("#encryption-fields", Container) + + # By default, encryption is unchecked (for auto-login convenience) + assert checkbox.value is False, "Encryption should be disabled by default" + assert encryption_fields.display is False, "Encryption fields should be hidden" + + # Toggle checkbox on to enable encryption + checkbox.toggle() + await pilot.pause() + + # Fields should now be visible + assert checkbox.value is True, "Encryption should be enabled after toggle" + assert encryption_fields.display is True, "Encryption fields should be visible" + + # Toggle checkbox back off + checkbox.toggle() + await pilot.pause() + + # Fields should be hidden again + assert checkbox.value is False, "Encryption should be disabled" + assert encryption_fields.display is False, "Encryption fields should be hidden again" + + @pytest.mark.integration + async def test_backend_selection_screen_mounts(self, tmp_path): + """Backend selection screen should mount without errors.""" + app = MoneyflowApp(demo_mode=True, config_dir=str(tmp_path)) + + async with app.run_test() as pilot: + # Wait for app to fully initialize (loads theme CSS) + assert await _wait_for_app_ready(app, pilot), "App failed to initialize" + + screen = BackendSelectionScreen() + app.push_screen(screen) + await pilot.pause() + + # Screen should be displayed + assert app.screen is screen + + +# ============================================================================ +# Search Workflow Tests +# ============================================================================ + + +class TestSearchWorkflow: + """Test the search workflow.""" + + @pytest.mark.integration + async def test_search_screen_opens(self, tmp_path): + """Search screen should open with / key.""" + app = MoneyflowApp(demo_mode=True, config_dir=str(tmp_path)) + + async with app.run_test() as pilot: + # Wait for app to initialize + for _ in range(40): + if app.controller is not None and app.state.current_data is not None: + break + await pilot.pause() + + # Press / to open search + await pilot.press("/") + await pilot.pause() + + # Search screen should be open + # (it's a modal, so it overlays the main screen) + search_input = app.query("SearchScreen Input") + assert len(list(search_input)) > 0 or isinstance(app.screen, SearchScreen) + + +# ============================================================================ +# Edit Workflow Tests +# ============================================================================ + + +class TestEditWorkflow: + """Test the edit workflows.""" + + @pytest.mark.integration + async def test_select_category_screen_mounts(self, tmp_path): + """Select category screen should mount without errors.""" + app = MoneyflowApp(demo_mode=True, config_dir=str(tmp_path)) + + async with app.run_test() as pilot: + # Wait for app to fully initialize + assert await _wait_for_app_ready(app, pilot), "App failed to initialize" + + screen = SelectCategoryScreen( + categories={ + "cat1": {"name": "Groceries"}, + "cat2": {"name": "Shopping"}, + }, + current_category_id="cat1", + ) + app.push_screen(screen) + await pilot.pause() + + assert app.screen is screen + + @pytest.mark.integration + async def test_edit_merchant_screen_mounts(self, tmp_path): + """Edit merchant screen should mount without errors.""" + app = MoneyflowApp(demo_mode=True, config_dir=str(tmp_path)) + + async with app.run_test() as pilot: + # Wait for app to fully initialize + assert await _wait_for_app_ready(app, pilot), "App failed to initialize" + + screen = EditMerchantScreen( + current_merchant="Test Store", + all_merchants=["Store A", "Store B", "Test Store"], + ) + app.push_screen(screen) + await pilot.pause() + + assert app.screen is screen + + +# ============================================================================ +# Mock Backend Integration Tests +# ============================================================================ + + +class TestDemoModeDataLoading: + """Test app data loading in demo mode.""" + + @pytest.mark.integration + async def test_demo_mode_populates_state_data(self, tmp_path): + """Demo mode should populate app state with sample data.""" + app = MoneyflowApp(demo_mode=True, config_dir=str(tmp_path)) + + async with app.run_test() as pilot: + # Wait for demo mode to initialize + ready = False + for _ in range(40): + if app.controller is not None and app.state.current_data is not None: + ready = True + break + await pilot.pause() + + assert ready, "App failed to initialize in demo mode" + # App should have data + assert app.state.current_data is not None + assert len(app.state.current_data) > 0 + + +# ============================================================================ +# Error Handling Tests +# ============================================================================ + + +class TestErrorHandling: + """Test error handling in TUI workflows.""" + + @pytest.mark.integration + async def test_app_handles_missing_config_gracefully(self, tmp_path): + """App should handle missing config directory gracefully.""" + # Use a fresh tmp_path with no existing config + app = MoneyflowApp(demo_mode=True, config_dir=str(tmp_path / "nonexistent")) + + async with app.run_test() as pilot: + # App should start without crashing + await pilot.pause() + assert app.is_running diff --git a/tests/test_amazon_orders_importer.py b/tests/test_amazon_orders_importer.py index 76fc6c1..53c248f 100644 --- a/tests/test_amazon_orders_importer.py +++ b/tests/test_amazon_orders_importer.py @@ -593,12 +593,13 @@ def test_cache_manager_not_created_without_encryption_key(self, temp_config_dir) assert cache_mgr.fernet is None assert cache_mgr.encryption_key is None - def test_cache_manager_save_raises_without_encryption( + def test_cache_manager_saves_unencrypted_without_key( self, temp_config_dir, sample_orders_csv, temp_db ): - """CacheManager.save_cache should raise ValueError without encryption key.""" + """CacheManager should save unencrypted cache when encryption key is None.""" + from pathlib import Path + import polars as pl - import pytest from moneyflow.cache_manager import CacheManager @@ -609,9 +610,21 @@ def test_cache_manager_save_raises_without_encryption( categories = {"cat_1": "Category 1"} category_groups = {} - # Should raise ValueError, not crash with unclear error - with pytest.raises(ValueError, match="encryption key not set"): - cache_mgr.save_cache(df, categories, category_groups) + # Should save without encryption (no longer raises ValueError) + cache_mgr.save_cache(df, categories, category_groups) + + # Verify unencrypted files were created (not .enc extension) + cache_dir = Path(temp_config_dir) + assert (cache_dir / "hot_transactions.parquet").exists() + assert (cache_dir / "categories.json").exists() + assert not (cache_dir / "hot_transactions.parquet.enc").exists() + + # Verify we can load the cache back + result = cache_mgr.load_cache() + assert result is not None + loaded_df, loaded_categories, _, _ = result + assert len(loaded_df) == 1 + assert loaded_categories == categories @pytest.mark.asyncio async def test_data_manager_works_without_cache( diff --git a/tests/test_app_controller.py b/tests/test_app_controller.py index 17dd1dd..fc49c78 100644 --- a/tests/test_app_controller.py +++ b/tests/test_app_controller.py @@ -57,7 +57,7 @@ async def test_refresh_view_updates_table(self, controller, mock_view): # Should have updated table assert len(mock_view.table_updates) == 1 update = mock_view.get_last_table_update() - assert update["column_count"] == 5 # Merchant, Count, Total, Top Category, Flags + assert update["column_count"] == 6 # Merchant, Count, Total, %, Top Category, Flags assert update["row_count"] > 0 # Should have data async def test_refresh_view_with_force_rebuild_true(self, controller, mock_view): @@ -123,13 +123,14 @@ async def test_merchant_view(self, controller, mock_view): controller.refresh_view() update = mock_view.get_last_table_update() - assert update["column_count"] == 5 - # Columns should be: Merchant, Count, Total, Top Category, Flags + assert update["column_count"] == 6 + # Columns should be: Merchant, Count, Total, %, Top Category, Flags assert update["columns"][0]["key"] == "merchant" assert update["columns"][1]["key"] == "count" assert update["columns"][2]["key"] == "total" - assert update["columns"][3]["key"] == "top_category_display" - assert update["columns"][4]["key"] == "flags" + assert update["columns"][3]["key"] == "pct" + assert update["columns"][4]["key"] == "top_category_display" + assert update["columns"][5]["key"] == "flags" async def test_category_view(self, controller, mock_view): """Test category aggregation view.""" @@ -138,7 +139,7 @@ async def test_category_view(self, controller, mock_view): controller.refresh_view() update = mock_view.get_last_table_update() - assert update["column_count"] == 4 + assert update["column_count"] == 5 # Category, Count, Total, %, Flags assert update["columns"][0]["key"] == "category" async def test_detail_view(self, controller, mock_view): @@ -646,6 +647,183 @@ def save_cache( updated_merchant = stub_cache.saved_hot.filter(pl.col("id") == edit_id)["merchant"][0] assert updated_merchant == "Edited" + async def test_filtered_view_partial_cache_update_when_cold_unavailable(self, controller): + """When cold cache is unavailable, hot should still be updated.""" + from moneyflow.state import TransactionEdit + + class StubCacheManager: + def __init__(self, hot_df): + self._hot_df = hot_df + self.saved_hot = None + self.saved_cold = None + self.saved_full = None + + def load_hot_cache(self): + return self._hot_df.clone() if self._hot_df is not None else None + + def load_cold_cache(self): + return None # Simulate cold cache unavailable + + def save_hot_cache(self, hot_df, categories, category_groups): + self.saved_hot = hot_df + + def save_cold_cache(self, cold_df): + self.saved_cold = cold_df + + def save_cache( + self, transactions_df, categories, category_groups, year=None, since=None + ): + self.saved_full = transactions_df + + full_df = controller.data_manager.df + hot_df = full_df.head(3) + + edit_id = hot_df["id"][0] + old_merchant = hot_df["merchant"][0] + edits = [TransactionEdit(edit_id, "merchant", old_merchant, "Edited", datetime.now())] + controller.data_manager.pending_edits = edits.copy() + controller.data_manager.df = hot_df.head(1).clone() + + stub_cache = StubCacheManager(hot_df) + controller.cache_manager = stub_cache + + saved_state = controller.state.save_view_state() + controller.handle_commit_result( + success_count=1, + failure_count=0, + edits=edits, + saved_state=saved_state, + cache_filters={"year": None, "since": None}, + is_filtered_view=True, + ) + + # Hot should be saved with edits, cold should not be touched + assert stub_cache.saved_hot is not None, "Hot cache should be updated" + assert stub_cache.saved_cold is None, "Cold cache should not be updated (was unavailable)" + assert stub_cache.saved_full is None, "Full cache should not be called" + + updated_merchant = stub_cache.saved_hot.filter(pl.col("id") == edit_id)["merchant"][0] + assert updated_merchant == "Edited" + + async def test_filtered_view_partial_cache_update_when_hot_unavailable(self, controller): + """When hot cache is unavailable, cold should still be updated.""" + from moneyflow.state import TransactionEdit + + class StubCacheManager: + def __init__(self, cold_df): + self._cold_df = cold_df + self.saved_hot = None + self.saved_cold = None + self.saved_full = None + + def load_hot_cache(self): + return None # Simulate hot cache unavailable + + def load_cold_cache(self): + return self._cold_df.clone() if self._cold_df is not None else None + + def save_hot_cache(self, hot_df, categories, category_groups): + self.saved_hot = hot_df + + def save_cold_cache(self, cold_df): + self.saved_cold = cold_df + + def save_cache( + self, transactions_df, categories, category_groups, year=None, since=None + ): + self.saved_full = transactions_df + + full_df = controller.data_manager.df + cold_df = full_df.tail(3) + + # Edit a transaction that's in cold cache + edit_id = cold_df["id"][0] + old_merchant = cold_df["merchant"][0] + edits = [TransactionEdit(edit_id, "merchant", old_merchant, "ColdEdited", datetime.now())] + controller.data_manager.pending_edits = edits.copy() + controller.data_manager.df = cold_df.head(1).clone() + + stub_cache = StubCacheManager(cold_df) + controller.cache_manager = stub_cache + + saved_state = controller.state.save_view_state() + controller.handle_commit_result( + success_count=1, + failure_count=0, + edits=edits, + saved_state=saved_state, + cache_filters={"year": None, "since": None}, + is_filtered_view=True, + ) + + # Cold should be saved with edits, hot should not be touched + assert stub_cache.saved_cold is not None, "Cold cache should be updated" + assert stub_cache.saved_hot is None, "Hot cache should not be updated (was unavailable)" + assert stub_cache.saved_full is None, "Full cache should not be called" + + updated_merchant = stub_cache.saved_cold.filter(pl.col("id") == edit_id)["merchant"][0] + assert updated_merchant == "ColdEdited" + + async def test_filtered_view_both_tiers_unavailable_does_not_corrupt_cache(self, controller): + """When both cache tiers are unavailable, we should NOT write partial data. + + In filtered view, data_manager.df only contains the filtered subset. + Writing it as the full cache would lose historical transactions. + Instead, we just log an error - edits are saved to backend. + """ + from moneyflow.state import TransactionEdit + + class StubCacheManager: + def __init__(self): + self.saved_hot = None + self.saved_cold = None + self.saved_full = None + + def load_hot_cache(self): + return None # Both tiers unavailable + + def load_cold_cache(self): + return None # Both tiers unavailable + + def save_hot_cache(self, hot_df, categories, category_groups): + self.saved_hot = hot_df + + def save_cold_cache(self, cold_df): + self.saved_cold = cold_df + + def save_cache( + self, transactions_df, categories, category_groups, year=None, since=None + ): + self.saved_full = transactions_df + + # Set up a filtered view with only partial data + full_df = controller.data_manager.df + filtered_df = full_df.head(1) # Only 1 transaction (simulates MTD filter) + + edit_id = filtered_df["id"][0] + old_merchant = filtered_df["merchant"][0] + edits = [TransactionEdit(edit_id, "merchant", old_merchant, "Edited", datetime.now())] + controller.data_manager.pending_edits = edits.copy() + controller.data_manager.df = filtered_df.clone() + + stub_cache = StubCacheManager() + controller.cache_manager = stub_cache + + saved_state = controller.state.save_view_state() + controller.handle_commit_result( + success_count=1, + failure_count=0, + edits=edits, + saved_state=saved_state, + cache_filters={"year": None, "since": None}, + is_filtered_view=True, + ) + + # CRITICAL: No cache writes should happen to avoid data loss + assert stub_cache.saved_hot is None, "Hot cache should NOT be written" + assert stub_cache.saved_cold is None, "Cold cache should NOT be written" + assert stub_cache.saved_full is None, "Full cache should NOT be written (would lose data)" + class TestEditQueueing: """ diff --git a/tests/test_cache.py b/tests/test_cache.py index c9a64b3..199b38a 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -1570,3 +1570,153 @@ def test_filter_mtd_scenario(self): assert len(filtered) == 2 assert all(d.month == 12 for d in filtered["date"].to_list()) + + +class TestCachePersistenceAfterEdits: + """ + Test that edits are actually persisted to cache files. + + This addresses the bug where changes committed to the backend + were not persisted to the local cache. + """ + + def test_save_cache_persists_edits( + self, cache_manager, sample_categories, sample_category_groups + ): + """Test that changes to transactions are persisted after save_cache.""" + # Create initial data + df = create_mixed_transactions_df() + original_merchant = df.filter(pl.col("id") == df["id"][0])["merchant"][0] + cache_manager.save_cache(df, sample_categories, sample_category_groups) + + # Simulate an edit (like what happens after a commit) + edited_df = df.with_columns( + pl.when(pl.col("id") == df["id"][0]) + .then(pl.lit("EDITED_MERCHANT")) + .otherwise(pl.col("merchant")) + .alias("merchant") + ) + + # Save the edited data + cache_manager.save_cache(edited_df, sample_categories, sample_category_groups) + + # Load and verify the edit was persisted + result = cache_manager.load_cache() + assert result is not None + loaded_df, _, _, _ = result + + edited_row = loaded_df.filter(pl.col("id") == df["id"][0]) + assert len(edited_row) == 1 + assert edited_row["merchant"][0] == "EDITED_MERCHANT" + assert edited_row["merchant"][0] != original_merchant + + def test_save_hot_cache_persists_edits( + self, cache_manager, sample_categories, sample_category_groups + ): + """Test that edits to hot cache are persisted.""" + # Create initial data with both tiers + df = create_mixed_transactions_df() + cache_manager.save_cache(df, sample_categories, sample_category_groups) + + # Load the hot cache + hot_df = cache_manager.load_hot_cache() + assert hot_df is not None + assert len(hot_df) > 0 + + # Edit a hot transaction + hot_id = hot_df["id"][0] + edited_hot = hot_df.with_columns( + pl.when(pl.col("id") == hot_id) + .then(pl.lit("HOT_EDITED")) + .otherwise(pl.col("merchant")) + .alias("merchant") + ) + + # Save hot cache only + cache_manager.save_hot_cache(edited_hot, sample_categories, sample_category_groups) + + # Load and verify + reloaded_hot = cache_manager.load_hot_cache() + edited_row = reloaded_hot.filter(pl.col("id") == hot_id) + assert edited_row["merchant"][0] == "HOT_EDITED" + + # Verify cold cache is unchanged + reloaded_cold = cache_manager.load_cold_cache() + assert reloaded_cold is not None + + def test_save_cold_cache_persists_edits( + self, cache_manager, sample_categories, sample_category_groups + ): + """Test that edits to cold cache are persisted.""" + # Create initial data with both tiers + df = create_mixed_transactions_df() + cache_manager.save_cache(df, sample_categories, sample_category_groups) + + # Load the cold cache + cold_df = cache_manager.load_cold_cache() + assert cold_df is not None + assert len(cold_df) > 0 + + # Edit a cold transaction + cold_id = cold_df["id"][0] + edited_cold = cold_df.with_columns( + pl.when(pl.col("id") == cold_id) + .then(pl.lit("COLD_EDITED")) + .otherwise(pl.col("merchant")) + .alias("merchant") + ) + + # Save cold cache only + cache_manager.save_cold_cache(edited_cold) + + # Load and verify + reloaded_cold = cache_manager.load_cold_cache() + edited_row = reloaded_cold.filter(pl.col("id") == cold_id) + assert edited_row["merchant"][0] == "COLD_EDITED" + + # Verify hot cache is unchanged + reloaded_hot = cache_manager.load_hot_cache() + assert reloaded_hot is not None + + def test_atomic_write_creates_no_temp_files_on_success( + self, cache_manager, sample_categories, sample_category_groups + ): + """Verify no temp files are left behind after successful saves.""" + df = create_mixed_transactions_df() + cache_manager.save_cache(df, sample_categories, sample_category_groups) + + # Check for leftover temp files + cache_dir = cache_manager.cache_dir + temp_files = list(cache_dir.glob(".tmp_*")) + assert len(temp_files) == 0, f"Temp files left behind: {temp_files}" + + def test_multiple_sequential_saves_persist_correctly( + self, cache_manager, sample_categories, sample_category_groups + ): + """Test that multiple sequential saves all persist correctly.""" + df = create_mixed_transactions_df() + cache_manager.save_cache(df, sample_categories, sample_category_groups) + + # Get an ID that's definitely in the hot cache + hot_df = cache_manager.load_hot_cache() + assert hot_df is not None and len(hot_df) > 0, "Hot cache should have data" + target_id = hot_df["id"][0] + + # Perform multiple edits and saves + for i in range(5): + hot_df = cache_manager.load_hot_cache() + edited_hot = hot_df.with_columns( + pl.when(pl.col("id") == target_id) + .then(pl.lit(f"EDIT_{i}")) + .otherwise(pl.col("merchant")) + .alias("merchant") + ) + cache_manager.save_hot_cache(edited_hot, sample_categories, sample_category_groups) + + # Verify final state - should be the last edit (EDIT_4) + final_hot = cache_manager.load_hot_cache() + final_row = final_hot.filter(pl.col("id") == target_id) + assert len(final_row) == 1, f"Target ID {target_id} should exist in hot cache" + assert final_row["merchant"][0] == "EDIT_4", ( + f"Expected merchant to be 'EDIT_4', got '{final_row['merchant'][0]}'" + ) diff --git a/tests/test_credentials.py b/tests/test_credentials.py index 8cc30e2..894f796 100644 --- a/tests/test_credentials.py +++ b/tests/test_credentials.py @@ -588,3 +588,184 @@ def test_multiple_profiles_same_backend_type(self, temp_config_dir): assert creds_business["backend_type"] == "monarch" assert creds_personal["email"] == "personal@example.com" assert creds_business["email"] == "business@example.com" + + +# ============================================================================ +# Test: Plaintext Credentials +# ============================================================================ + + +class TestPlaintextCredentials: + """Tests for plaintext (unencrypted) credential storage.""" + + def test_save_plaintext_credentials(self, credential_manager, temp_config_dir): + """Test saving credentials without encryption.""" + credential_manager.save_credentials( + email="test@example.com", + password="test_password_123", + mfa_secret="TESTSECRET123456", + use_encryption=False, + ) + + # Should create plaintext file, not encrypted file + plaintext_file = temp_config_dir / "credentials.json" + encrypted_file = temp_config_dir / "credentials.enc" + + assert plaintext_file.exists() + assert not encrypted_file.exists() + + def test_plaintext_file_permissions(self, credential_manager, temp_config_dir): + """Test that plaintext credential file has restricted permissions.""" + credential_manager.save_credentials( + email="test@example.com", + password="test_password_123", + mfa_secret="TESTSECRET123456", + use_encryption=False, + ) + + plaintext_file = temp_config_dir / "credentials.json" + stat_info = plaintext_file.stat() + # Should be owner read/write only (0600) + assert oct(stat_info.st_mode)[-3:] == "600" + + def test_load_plaintext_credentials(self, credential_manager): + """Test loading plaintext credentials.""" + credential_manager.save_credentials( + email="test@example.com", + password="test_password_123", + mfa_secret="TESTSECRET123456", + use_encryption=False, + ) + + creds, key = credential_manager.load_credentials() + + assert creds["email"] == "test@example.com" + assert creds["password"] == "test_password_123" + assert creds["mfa_secret"] == "TESTSECRET123456" + # No encryption key for plaintext credentials + assert key is None + + def test_is_encrypted_returns_false_for_plaintext(self, credential_manager): + """Test is_encrypted() returns False for plaintext credentials.""" + credential_manager.save_credentials( + email="test@example.com", + password="test_password_123", + mfa_secret="TESTSECRET123456", + use_encryption=False, + ) + + assert not credential_manager.is_encrypted() + assert credential_manager.is_plaintext() + + def test_is_encrypted_returns_true_for_encrypted(self, credential_manager): + """Test is_encrypted() returns True for encrypted credentials.""" + credential_manager.save_credentials( + email="test@example.com", + password="test_password_123", + mfa_secret="TESTSECRET123456", + encryption_password="test_encrypt_pass", + use_encryption=True, + ) + + assert credential_manager.is_encrypted() + assert not credential_manager.is_plaintext() + + def test_plaintext_removes_encrypted_on_save(self, credential_manager, temp_config_dir): + """Test saving plaintext removes existing encrypted credentials.""" + # First save encrypted + credential_manager.save_credentials( + email="old@example.com", + password="old_pass", + mfa_secret="OLDSECRET", + encryption_password="encrypt_pass", + use_encryption=True, + ) + + encrypted_file = temp_config_dir / "credentials.enc" + assert encrypted_file.exists() + + # Now save plaintext + credential_manager.save_credentials( + email="new@example.com", + password="new_pass", + mfa_secret="NEWSECRET", + use_encryption=False, + ) + + # Encrypted file should be removed + assert not encrypted_file.exists() + assert (temp_config_dir / "credentials.json").exists() + + +class TestCredentialPrecedence: + """Tests for credential loading precedence when both files exist.""" + + def test_encrypted_takes_precedence_over_plaintext(self, credential_manager, temp_config_dir): + """Test that encrypted credentials are loaded when both exist.""" + # Save plaintext first + credential_manager.save_credentials( + email="plaintext@example.com", + password="plaintext_pass", + mfa_secret="PLAINTEXTSECRET", + use_encryption=False, + ) + + # Manually create an encrypted file (simulating edge case) + # We need to use the save mechanism but then also create plaintext + credential_manager.save_credentials( + email="encrypted@example.com", + password="encrypted_pass", + mfa_secret="ENCRYPTEDSECRET", + encryption_password="test_pass", + use_encryption=True, + ) + + # Now manually recreate plaintext to have both files + plaintext_file = temp_config_dir / "credentials.json" + plaintext_file.write_text( + json.dumps( + { + "email": "plaintext@example.com", + "password": "plaintext_pass", + "mfa_secret": "PLAINTEXTSECRET", + "backend_type": "monarch", + } + ) + ) + + # Both files should now exist + assert (temp_config_dir / "credentials.enc").exists() + assert plaintext_file.exists() + + # Loading should prefer encrypted + creds, key = credential_manager.load_credentials(encryption_password="test_pass") + + assert creds["email"] == "encrypted@example.com" + assert key is not None # Should have encryption key + + def test_is_plaintext_false_when_both_exist(self, credential_manager, temp_config_dir): + """Test is_plaintext() returns False when both encrypted and plaintext exist.""" + # Create encrypted file + credential_manager.save_credentials( + email="encrypted@example.com", + password="encrypted_pass", + mfa_secret="ENCRYPTEDSECRET", + encryption_password="test_pass", + use_encryption=True, + ) + + # Manually create plaintext file too + plaintext_file = temp_config_dir / "credentials.json" + plaintext_file.write_text( + json.dumps( + { + "email": "plaintext@example.com", + "password": "plaintext_pass", + "mfa_secret": "PLAINTEXTSECRET", + } + ) + ) + + # is_plaintext should return False because encrypted exists + assert credential_manager.is_encrypted() + assert not credential_manager.is_plaintext() diff --git a/tests/test_file_utils.py b/tests/test_file_utils.py new file mode 100644 index 0000000..cea7727 --- /dev/null +++ b/tests/test_file_utils.py @@ -0,0 +1,268 @@ +""" +Unit tests for file_utils module. + +Tests cover: +- Secure file writing with correct permissions +- Error handling for fd leaks +- Atomic write operations +""" + +import os +import stat + +from moneyflow.file_utils import secure_atomic_write, secure_write_file + + +class TestSecureWriteFile: + """Tests for secure_write_file function.""" + + def test_creates_file_with_0600_permissions(self, tmp_path): + """File should be created with 0o600 permissions.""" + test_file = tmp_path / "test.txt" + secure_write_file(test_file, b"test content", "wb") + + assert test_file.exists() + mode = stat.S_IMODE(os.stat(test_file).st_mode) + assert mode == 0o600, f"Expected 0o600, got {oct(mode)}" + + def test_writes_binary_content(self, tmp_path): + """Should correctly write binary content.""" + test_file = tmp_path / "test.bin" + content = b"\x00\x01\x02\x03\xff" + secure_write_file(test_file, content, "wb") + + assert test_file.read_bytes() == content + + def test_writes_text_content(self, tmp_path): + """Should correctly write text content.""" + test_file = tmp_path / "test.txt" + content = "Hello, world!\nLine 2" + secure_write_file(test_file, content, "w") + + assert test_file.read_text() == content + + def test_converts_bytes_to_text_in_text_mode(self, tmp_path): + """Should convert bytes to text when mode is 'w'.""" + test_file = tmp_path / "test.txt" + content = b"byte content" + secure_write_file(test_file, content, "w") + + assert test_file.read_text() == "byte content" + + def test_converts_text_to_bytes_in_binary_mode(self, tmp_path): + """Should convert text to bytes when mode is 'wb'.""" + test_file = tmp_path / "test.bin" + content = "text content" + secure_write_file(test_file, content, "wb") + + assert test_file.read_bytes() == b"text content" + + def test_overwrites_existing_file(self, tmp_path): + """Should overwrite existing file content.""" + test_file = tmp_path / "test.txt" + test_file.write_text("old content") + + secure_write_file(test_file, "new content", "w") + + assert test_file.read_text() == "new content" + + def test_maintains_permissions_on_overwrite(self, tmp_path): + """Permissions should remain 0o600 after overwrite.""" + test_file = tmp_path / "test.txt" + # Create file with different permissions first + test_file.write_text("old content") + os.chmod(test_file, 0o644) + + secure_write_file(test_file, "new content", "w") + + mode = stat.S_IMODE(os.stat(test_file).st_mode) + assert mode == 0o600 + + +class TestSecureAtomicWrite: + """Tests for secure_atomic_write function.""" + + def test_creates_file_with_0600_permissions(self, tmp_path): + """File should be created with 0o600 permissions.""" + test_file = tmp_path / "test.bin" + secure_atomic_write(test_file, b"test content") + + assert test_file.exists() + mode = stat.S_IMODE(os.stat(test_file).st_mode) + assert mode == 0o600, f"Expected 0o600, got {oct(mode)}" + + def test_writes_content_correctly(self, tmp_path): + """Should correctly write content.""" + test_file = tmp_path / "test.bin" + content = b"atomic content \x00\xff" + secure_atomic_write(test_file, content) + + assert test_file.read_bytes() == content + + def test_atomic_overwrite(self, tmp_path): + """Should atomically replace existing file.""" + test_file = tmp_path / "test.bin" + test_file.write_bytes(b"old content") + + secure_atomic_write(test_file, b"new content") + + assert test_file.read_bytes() == b"new content" + + def test_no_temp_file_left_on_success(self, tmp_path): + """No temporary files should remain after successful write.""" + test_file = tmp_path / "test.bin" + secure_atomic_write(test_file, b"content") + + # Check no .tmp_ files remain + tmp_files = list(tmp_path.glob(".tmp_*")) + assert len(tmp_files) == 0 + + def test_creates_parent_directories(self, tmp_path): + """Should create parent directories if needed.""" + test_file = tmp_path / "subdir" / "nested" / "test.bin" + secure_atomic_write(test_file, b"content") + + assert test_file.exists() + assert test_file.read_bytes() == b"content" + + +class TestCredentialsFilePermissions: + """Tests that credential files are created with secure permissions.""" + + def test_encrypted_credentials_have_secure_permissions(self, tmp_path): + """Encrypted credentials should have 0o600 permissions.""" + from moneyflow.credentials import CredentialManager + + cm = CredentialManager(config_dir=tmp_path) + cm.save_credentials( + email="test@example.com", + password="secret", + mfa_secret="", + encryption_password="testpass", + use_encryption=True, + ) + + cred_file = tmp_path / "credentials.enc" + assert cred_file.exists() + mode = stat.S_IMODE(os.stat(cred_file).st_mode) + assert mode == 0o600, f"Encrypted creds should be 0o600, got {oct(mode)}" + + def test_plaintext_credentials_have_secure_permissions(self, tmp_path): + """Plaintext credentials should have 0o600 permissions.""" + from moneyflow.credentials import CredentialManager + + cm = CredentialManager(config_dir=tmp_path) + cm.save_credentials( + email="test@example.com", + password="secret", + mfa_secret="", + encryption_password=None, + use_encryption=False, + ) + + cred_file = tmp_path / "credentials.json" + assert cred_file.exists() + mode = stat.S_IMODE(os.stat(cred_file).st_mode) + assert mode == 0o600, f"Plaintext creds should be 0o600, got {oct(mode)}" + + def test_salt_file_has_secure_permissions(self, tmp_path): + """Salt file should have 0o600 permissions.""" + from moneyflow.credentials import CredentialManager + + cm = CredentialManager(config_dir=tmp_path) + cm.save_credentials( + email="test@example.com", + password="secret", + mfa_secret="", + encryption_password="testpass", + use_encryption=True, + ) + + salt_file = tmp_path / "salt" + assert salt_file.exists() + mode = stat.S_IMODE(os.stat(salt_file).st_mode) + assert mode == 0o600, f"Salt file should be 0o600, got {oct(mode)}" + + +class TestCacheFilePermissions: + """Tests that cache files are created with secure permissions.""" + + def test_unencrypted_cache_metadata_has_secure_permissions(self, tmp_path): + """Unencrypted cache metadata should have 0o600 permissions.""" + import polars as pl + + from moneyflow.cache_manager import CacheManager + + cache_dir = tmp_path / "cache" + cm = CacheManager(cache_dir=cache_dir, encryption_key=None) + + # Create minimal data to save + df = pl.DataFrame( + { + "id": ["1"], + "date": ["2024-01-01"], + "merchant": ["Test"], + "amount": [-10.0], + "category": ["Test"], + "hideFromReports": [False], + } + ) + cm.save_cache(df, {}, {}) + + metadata_file = cache_dir / "cache_metadata.json" + assert metadata_file.exists() + mode = stat.S_IMODE(os.stat(metadata_file).st_mode) + assert mode == 0o600, f"Cache metadata should be 0o600, got {oct(mode)}" + + def test_unencrypted_parquet_has_secure_permissions(self, tmp_path): + """Unencrypted parquet files should have 0o600 permissions.""" + import polars as pl + + from moneyflow.cache_manager import CacheManager + + cache_dir = tmp_path / "cache" + cm = CacheManager(cache_dir=cache_dir, encryption_key=None) + + df = pl.DataFrame( + { + "id": ["1"], + "date": ["2024-01-01"], + "merchant": ["Test"], + "amount": [-10.0], + "category": ["Test"], + "hideFromReports": [False], + } + ) + cm.save_cache(df, {}, {}) + + # Check hot transactions file + hot_file = cache_dir / "hot_transactions.parquet" + if hot_file.exists(): + mode = stat.S_IMODE(os.stat(hot_file).st_mode) + assert mode == 0o600, f"Hot cache should be 0o600, got {oct(mode)}" + + def test_unencrypted_categories_has_secure_permissions(self, tmp_path): + """Unencrypted categories file should have 0o600 permissions.""" + import polars as pl + + from moneyflow.cache_manager import CacheManager + + cache_dir = tmp_path / "cache" + cm = CacheManager(cache_dir=cache_dir, encryption_key=None) + + df = pl.DataFrame( + { + "id": ["1"], + "date": ["2024-01-01"], + "merchant": ["Test"], + "amount": [-10.0], + "category": ["Test"], + "hideFromReports": [False], + } + ) + cm.save_cache(df, {"cat1": "Test"}, {"grp1": "TestGroup"}) + + categories_file = cache_dir / "categories.json" + assert categories_file.exists() + mode = stat.S_IMODE(os.stat(categories_file).st_mode) + assert mode == 0o600, f"Categories file should be 0o600, got {oct(mode)}" diff --git a/tests/test_formatters.py b/tests/test_formatters.py index 12cc729..c9e82ad 100644 --- a/tests/test_formatters.py +++ b/tests/test_formatters.py @@ -85,14 +85,16 @@ def test_merchant_columns(self): "merchant", SortMode.COUNT, SortDirection.DESC ) - assert len(cols) == 5 # merchant, count, total, top_category_display, flags + assert len(cols) == 6 # merchant, count, total, pct, top_category_display, flags assert cols[0]["label"] == "Merchant" assert cols[0]["key"] == "merchant" assert cols[0]["width"] == 40 - assert cols[3]["label"] == "Top Category" - assert cols[3]["key"] == "top_category_display" - assert cols[3]["width"] == 35 - assert cols[4]["key"] == "flags" + assert cols[3]["label"] == "%" + assert cols[3]["key"] == "pct" + assert cols[4]["label"] == "Top Category" + assert cols[4]["key"] == "top_category_display" + assert cols[4]["width"] == 35 + assert cols[5]["key"] == "flags" def test_merchant_columns_with_custom_config(self): """Should use custom column widths when provided.""" @@ -118,11 +120,14 @@ def test_merchant_columns_with_custom_labels(self): assert normalize_label(cols[2]["label"]) == "Total ($)" assert cols[2]["key"] == "total" - assert cols[3]["label"] == "Top Category" - assert cols[3]["key"] == "top_category_display" + assert cols[3]["label"] == "%" + assert cols[3]["key"] == "pct" - assert cols[4]["label"] == "" - assert cols[4]["key"] == "flags" + assert cols[4]["label"] == "Top Category" + assert cols[4]["key"] == "top_category_display" + + assert cols[5]["label"] == "" + assert cols[5]["key"] == "flags" def test_category_columns(self): """Should create correct columns for category view.""" @@ -229,8 +234,10 @@ def test_formats_basic_merchant_rows(self): rows = ViewPresenter.format_aggregation_rows(df) assert len(rows) == 2 - assert normalize_row(rows[0]) == ("Amazon", "50", "-1,234.56", "") - assert normalize_row(rows[1]) == ("Starbucks", "30", "-89.70", "") + # Grand total = 1234.56 + 89.70 = 1324.26 + # Amazon: 1234.56 / 1324.26 = 93.2%, Starbucks: 89.70 / 1324.26 = 6.8% + assert normalize_row(rows[0]) == ("Amazon", "50", "-1,234.56", "93.2%", "") + assert normalize_row(rows[1]) == ("Starbucks", "30", "-89.70", "6.8%", "") def test_formats_merchant_rows_with_top_category(self): """Should format merchant rows with top category column.""" @@ -247,8 +254,24 @@ def test_formats_merchant_rows_with_top_category(self): rows = ViewPresenter.format_aggregation_rows(df, group_by_field="merchant") assert len(rows) == 2 - assert normalize_row(rows[0]) == ("Whole Foods", "10", "-150.00", "Groceries 100%", "") - assert normalize_row(rows[1]) == ("Starbucks", "20", "-80.00", "Coffee Shops 85%", "") + # Grand total = 150 + 80 = 230 + # Whole Foods: 150 / 230 = 65.2%, Starbucks: 80 / 230 = 34.8% + assert normalize_row(rows[0]) == ( + "Whole Foods", + "10", + "-150.00", + "65.2%", + "Groceries 100%", + "", + ) + assert normalize_row(rows[1]) == ( + "Starbucks", + "20", + "-80.00", + "34.8%", + "Coffee Shops 85%", + "", + ) def test_formats_category_rows(self): """Should format category aggregation rows correctly.""" @@ -258,8 +281,10 @@ def test_formats_category_rows(self): rows = ViewPresenter.format_aggregation_rows(df) - assert normalize_row(rows[0]) == ("Groceries", "100", "-2,500.00", "") - assert normalize_row(rows[1]) == ("Dining", "45", "-567.89", "") + # Grand total = 2500 + 567.89 = 3067.89 + # Groceries: 2500 / 3067.89 = 81.5%, Dining: 567.89 / 3067.89 = 18.5% + assert normalize_row(rows[0]) == ("Groceries", "100", "-2,500.00", "81.5%", "") + assert normalize_row(rows[1]) == ("Dining", "45", "-567.89", "18.5%", "") def test_handles_null_names(self): """Should handle null merchant/category names.""" @@ -289,7 +314,8 @@ def test_formats_large_numbers(self): rows = ViewPresenter.format_aggregation_rows(df) - assert normalize_row(rows[0]) == ("BigCorp", "1000", "-123,456.78", "") + # Single row = 100.0% of total + assert normalize_row(rows[0]) == ("BigCorp", "1000", "-123,456.78", "100.0%", "") def test_formats_positive_amounts(self): """Should format positive amounts (income) correctly.""" @@ -297,7 +323,30 @@ def test_formats_positive_amounts(self): rows = ViewPresenter.format_aggregation_rows(df) - assert normalize_row(rows[0]) == ("Employer", "2", "+5,000.00", "") + # Single row = 100.0% of total income + assert normalize_row(rows[0]) == ("Employer", "2", "+5,000.00", "100.0%", "") + + def test_percentages_calculated_separately_for_income_and_expenses(self): + """Percentages should be relative to total income or total expenses separately.""" + df = pl.DataFrame( + { + "category": ["Salary", "Bonus", "Groceries", "Dining"], + "count": [1, 1, 10, 5], + "total": [5000.00, 1000.00, -400.00, -100.00], # Mixed income/expenses + } + ) + + rows = ViewPresenter.format_aggregation_rows(df) + + # Income: Salary=5000, Bonus=1000, total_income=6000 + # Salary: 5000/6000 = 83.3%, Bonus: 1000/6000 = 16.7% + assert normalize_row(rows[0]) == ("Salary", "1", "+5,000.00", "83.3%", "") + assert normalize_row(rows[1]) == ("Bonus", "1", "+1,000.00", "16.7%", "") + + # Expenses: Groceries=-400, Dining=-100, total_expenses=500 + # Groceries: 400/500 = 80.0%, Dining: 100/500 = 20.0% + assert normalize_row(rows[2]) == ("Groceries", "10", "-400.00", "80.0%", "") + assert normalize_row(rows[3]) == ("Dining", "5", "-100.00", "20.0%", "") def test_shows_pending_edit_indicator(self): """Should show * for groups with pending edits.""" @@ -331,10 +380,13 @@ def test_shows_pending_edit_indicator(self): pending_edit_ids=pending_edit_ids, ) + # Grand total = 1234.56 + 89.70 + 456.78 = 1781.04 + # Amazon: 69.3%, Starbucks: 5.0%, Target: 25.6% assert normalize_row(rows[0]) == ( "Amazon", "50", "-1,234.56", + "69.3%", "Shopping 80%", "*", ) # Has pending edits @@ -342,6 +394,7 @@ def test_shows_pending_edit_indicator(self): "Starbucks", "30", "-89.70", + "5.0%", "Coffee Shops 100%", "", ) # No pending edits @@ -349,6 +402,7 @@ def test_shows_pending_edit_indicator(self): "Target", "20", "-456.78", + "25.6%", "Shopping 90%", "", ) # No pending edits @@ -377,8 +431,10 @@ def test_no_pending_edits_shows_empty_flag(self): pending_edit_ids=pending_edit_ids, ) - assert normalize_row(rows[0]) == ("Groceries", "100", "-2,500.00", "") - assert normalize_row(rows[1]) == ("Dining", "45", "-567.89", "") + # Grand total = 2500 + 567.89 = 3067.89 + # Groceries: 81.5%, Dining: 18.5% + assert normalize_row(rows[0]) == ("Groceries", "100", "-2,500.00", "81.5%", "") + assert normalize_row(rows[1]) == ("Dining", "45", "-567.89", "18.5%", "") def test_pending_edits_without_detail_df(self): """Should handle missing detail_df gracefully.""" @@ -397,10 +453,12 @@ def test_pending_edits_without_detail_df(self): agg_df, detail_df=None, group_by_field="merchant", pending_edit_ids={"txn1"} ) + # Single row = 100.0% of total assert normalize_row(rows[0]) == ( "Amazon", "50", "-1,234.56", + "100.0%", "Shopping 85%", "", ) # No pending indicator without detail_df @@ -426,10 +484,18 @@ def test_complete_merchant_view(self): ) assert view["empty"] is False - assert len(view["columns"]) == 5 # merchant, count, total, top_category_display, flags + assert len(view["columns"]) == 6 # merchant, count, total, pct, top_category_display, flags assert len(view["rows"]) == 2 assert view["columns"][0]["label"] == "Merchant" - assert normalize_row(view["rows"][0]) == ("Amazon", "50", "-1,234.56", "Shopping 90%", "") + # Grand total = 1234.56 + 89.70 = 1324.26, Amazon: 93.2% + assert normalize_row(view["rows"][0]) == ( + "Amazon", + "50", + "-1,234.56", + "93.2%", + "Shopping 90%", + "", + ) def test_empty_dataframe_view(self): """Should handle empty DataFrame gracefully.""" @@ -443,7 +509,7 @@ def test_empty_dataframe_view(self): ) assert view["empty"] is True - assert len(view["columns"]) == 5 # Merchant view has 5 columns + assert len(view["columns"]) == 6 # Merchant view has 6 columns (including pct) assert view["rows"] == [] def test_category_view_with_sort_indicators(self): diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py new file mode 100644 index 0000000..bd2dffe --- /dev/null +++ b/tests/test_mcp_server.py @@ -0,0 +1,975 @@ +""" +Unit tests for the MCP server. + +Tests cover: +- Security features (read-only mode, limit caps, dry-run) +- Tool functionality (search, get transactions, categorization) +- Error handling +""" + +from datetime import date, timedelta +from pathlib import Path + +import polars as pl +import pytest + +from moneyflow.mcp.server import ENV_PASSWORD, MAX_BATCH_SIZE, MAX_LIMIT, create_mcp_server + +# ============================================================================ +# Fixtures +# ============================================================================ + + +@pytest.fixture +def sample_transactions(): + """Create sample transaction data for testing.""" + today = date.today() + return pl.DataFrame( + { + "id": ["tx1", "tx2", "tx3", "tx4", "tx5"], + "date": [today - timedelta(days=i) for i in range(5)], + "merchant": ["Amazon", "Starbucks", "Amazon", "Walmart", "Target"], + "category": ["Shopping", "Food & Drink", "Uncategorized", "Groceries", "Shopping"], + "amount": [-50.00, -5.50, -125.00, -75.00, -30.00], + "account": ["Chase", "Chase", "Chase", "Chase", "Chase"], + "notes": [None, "Morning coffee", None, None, None], + "is_hidden": [False, False, False, False, False], + } + ) + + +@pytest.fixture +def sample_categories(): + """Create sample category data for testing.""" + return { + "cat1": "Shopping", + "cat2": "Food & Drink", + "cat3": "Groceries", + "cat4": "Entertainment", + "cat5": "Uncategorized", + } + + +# ============================================================================ +# Test: MAX_LIMIT constant +# ============================================================================ + + +class TestMaxLimit: + """Tests for the MAX_LIMIT constant.""" + + def test_max_limit_is_reasonable(self): + """MAX_LIMIT should be set to prevent memory issues.""" + assert MAX_LIMIT == 1000 + assert MAX_LIMIT > 0 + + +# ============================================================================ +# Test: Server Creation +# ============================================================================ + + +class TestServerCreation: + """Tests for MCP server creation.""" + + def test_create_server_default_params(self): + """Server can be created with default parameters.""" + mcp = create_mcp_server() + assert mcp is not None + + def test_create_server_with_account_id(self): + """Server can be created with a specific account ID.""" + mcp = create_mcp_server(account_id="my-account") + assert mcp is not None + + def test_create_server_with_config_dir(self): + """Server can be created with a custom config directory.""" + mcp = create_mcp_server(config_dir="/custom/path") + assert mcp is not None + + def test_create_server_read_only(self): + """Server can be created in read-only mode.""" + mcp = create_mcp_server(read_only=True) + assert mcp is not None + + def test_create_server_all_params(self): + """Server can be created with all parameters.""" + mcp = create_mcp_server( + account_id="my-account", + config_dir="/custom/path", + read_only=True, + ) + assert mcp is not None + + +# ============================================================================ +# Test: Limit Clamping (via internal function behavior) +# ============================================================================ + + +class TestLimitClamping: + """Tests for limit clamping behavior.""" + + def test_clamp_limit_within_range(self): + """Limits within MAX_LIMIT should be unchanged.""" + # We test this indirectly through the server behavior + # by verifying MAX_LIMIT is set correctly + assert MAX_LIMIT == 1000 + + def test_clamp_limit_zero_becomes_one(self): + """Zero limit should become 1 (minimum).""" + # The _clamp_limit function uses max(1, min(limit, MAX_LIMIT)) + # so 0 becomes 1 + assert max(1, min(0, MAX_LIMIT)) == 1 + + def test_clamp_limit_negative_becomes_one(self): + """Negative limit should become 1.""" + assert max(1, min(-10, MAX_LIMIT)) == 1 + + def test_clamp_limit_exceeds_max(self): + """Limit exceeding MAX_LIMIT should be clamped.""" + assert max(1, min(10000, MAX_LIMIT)) == MAX_LIMIT + + +# ============================================================================ +# Test: Tool Output Format +# ============================================================================ + + +class TestToolOutputFormat: + """Tests for tool output JSON format.""" + + def test_format_amount_negative(self): + """Negative amounts should be formatted correctly.""" + # Test the formatting logic + amount = -50.00 + if amount < 0: + formatted = f"-${abs(amount):,.2f}" + else: + formatted = f"${amount:,.2f}" + assert formatted == "-$50.00" + + def test_format_amount_positive(self): + """Positive amounts should be formatted correctly.""" + amount = 100.50 + if amount < 0: + formatted = f"-${abs(amount):,.2f}" + else: + formatted = f"${amount:,.2f}" + assert formatted == "$100.50" + + def test_format_amount_large(self): + """Large amounts should include comma separators.""" + amount = 1234567.89 + if amount < 0: + formatted = f"-${abs(amount):,.2f}" + else: + formatted = f"${amount:,.2f}" + assert formatted == "$1,234,567.89" + + +# ============================================================================ +# Test: Security - Read-Only Mode +# ============================================================================ + + +class TestReadOnlyMode: + """Tests for read-only mode functionality.""" + + def test_read_only_mode_set_in_state(self): + """Read-only mode should be stored in server state.""" + # Create server in read-only mode + mcp_readonly = create_mcp_server(read_only=True) + mcp_normal = create_mcp_server(read_only=False) + + # Both should be created successfully + assert mcp_readonly is not None + assert mcp_normal is not None + + +# ============================================================================ +# Test: Encrypted Credentials Handling +# ============================================================================ + + +class TestEncryptedCredentials: + """Tests for encrypted credentials handling.""" + + def test_env_password_constant_defined(self): + """ENV_PASSWORD constant should be defined.""" + assert ENV_PASSWORD == "MONEYFLOW_PASSWORD" + + def test_env_password_documented_in_help(self): + """Environment variable should be documented in CLI help.""" + main_file = Path(__file__).parent.parent / "moneyflow" / "mcp" / "__main__.py" + content = main_file.read_text() + + assert "MONEYFLOW_PASSWORD" in content + assert "Environment Variables:" in content + + +# ============================================================================ +# Test: Security - Batch Size Limit +# ============================================================================ + + +class TestBatchSizeLimit: + """Tests for batch size limit.""" + + def test_max_batch_size_is_100(self): + """MAX_BATCH_SIZE should be 100.""" + assert MAX_BATCH_SIZE == 100 + + def test_max_batch_size_is_reasonable(self): + """MAX_BATCH_SIZE should be reasonable to prevent abuse.""" + assert MAX_BATCH_SIZE > 0 + assert MAX_BATCH_SIZE <= 1000 + + +# ============================================================================ +# Test: Dry Run Mode +# ============================================================================ + + +class TestDryRunMode: + """Tests for dry-run mode functionality.""" + + def test_dry_run_response_format(self): + """Dry run responses should have expected format.""" + # Test the expected structure of dry_run responses + expected_keys = ["status", "message", "would_update"] + sample_response = { + "status": "dry_run", + "message": "No changes made (dry run)", + "would_update": {"transaction_id": "tx1"}, + } + + for key in expected_keys: + assert key in sample_response + + assert sample_response["status"] == "dry_run" + + +# ============================================================================ +# Test: CLI Arguments (via main module) +# ============================================================================ + + +class TestCLIArguments: + """Tests for CLI argument parsing.""" + + def test_main_module_exists(self): + """The __main__ module should exist.""" + from moneyflow.mcp import __main__ + + assert hasattr(__main__, "main") + + def test_argparse_setup(self): + """ArgumentParser should be configured correctly.""" + from moneyflow.mcp.__main__ import main + + # The main function exists and is callable + assert callable(main) + + +# ============================================================================ +# Test: HTTP Transport Warning +# ============================================================================ + + +class TestHTTPTransportWarning: + """Tests for HTTP transport security warning.""" + + def test_warning_mentions_security(self): + """The HTTP warning should mention security concerns.""" + # Read the __main__.py file content to verify warning text + main_file = Path(__file__).parent.parent / "moneyflow" / "mcp" / "__main__.py" + content = main_file.read_text() + + assert "SECURITY WARNING" in content + assert "NO built-in authentication" in content + assert "Tailscale" in content + + +# ============================================================================ +# Test: Error Handling +# ============================================================================ + + +class TestErrorHandling: + """Tests for error handling in MCP server.""" + + def test_error_response_format(self): + """Error responses should have consistent format.""" + error_response = { + "status": "error", + "message": "Something went wrong", + } + + assert "status" in error_response + assert error_response["status"] == "error" + assert "message" in error_response + + def test_category_not_found_includes_available(self): + """Category not found errors should include available categories.""" + # This is a documentation test - verifying expected behavior + error_response = { + "status": "error", + "message": "Category 'InvalidCategory' not found", + "available_categories": ["Shopping", "Food & Drink", "Groceries"], + } + + assert "available_categories" in error_response + assert len(error_response["available_categories"]) > 0 + + +# ============================================================================ +# Test: DataFrame Conversion +# ============================================================================ + + +class TestDataFrameConversion: + """Tests for DataFrame to records conversion.""" + + def test_empty_dataframe_returns_empty_list(self, sample_transactions): + """Empty DataFrame should return empty list.""" + empty_df = sample_transactions.filter(pl.col("id") == "nonexistent") + assert len(empty_df) == 0 + + def test_records_have_required_fields(self, sample_transactions): + """Records should have all required fields.""" + required_fields = ["id", "date", "merchant", "category", "amount", "account"] + + # Check a sample row has all fields + row = sample_transactions.row(0, named=True) + for field in required_fields: + assert field in row + + +# ============================================================================ +# Test: Category Lookup +# ============================================================================ + + +class TestCategoryLookup: + """Tests for category lookup functionality.""" + + def test_case_insensitive_lookup(self, sample_categories): + """Category lookup should be case-insensitive.""" + target = "shopping" + + found = None + for cat_id, cat_name in sample_categories.items(): + if cat_name.lower() == target.lower(): + found = cat_id + break + + assert found is not None + assert found == "cat1" + + def test_exact_match_required(self, sample_categories): + """Partial matches should not be found.""" + target = "shop" # Partial match of "Shopping" + + found = None + for cat_id, cat_name in sample_categories.items(): + if cat_name.lower() == target.lower(): + found = cat_id + break + + assert found is None + + +# ============================================================================ +# Test: Uncategorized Filter +# ============================================================================ + + +class TestUncategorizedFilter: + """Tests for uncategorized transaction filtering.""" + + def test_finds_uncategorized_transactions(self, sample_transactions): + """Should find transactions with 'Uncategorized' category.""" + uncategorized = sample_transactions.filter( + (pl.col("category") == "Uncategorized") + | (pl.col("category").is_null()) + | (pl.col("category") == "") + ) + + assert len(uncategorized) == 1 + assert uncategorized["id"][0] == "tx3" + + def test_merchant_filter_works(self, sample_transactions): + """Merchant filter should work with uncategorized filter.""" + uncategorized = sample_transactions.filter( + (pl.col("category") == "Uncategorized") + | (pl.col("category").is_null()) + | (pl.col("category") == "") + ) + + amazon_uncategorized = uncategorized.filter( + pl.col("merchant").str.to_lowercase().str.contains("amazon") + ) + + assert len(amazon_uncategorized) == 1 + + +# ============================================================================ +# Test: Search Functionality +# ============================================================================ + + +class TestSearchFunctionality: + """Tests for transaction search functionality.""" + + def test_search_by_merchant(self, sample_transactions): + """Search should find transactions by merchant name.""" + results = sample_transactions.filter( + pl.col("merchant").str.to_lowercase().str.contains("amazon") + ) + + assert len(results) == 2 + assert all(r == "Amazon" for r in results["merchant"].to_list()) + + def test_search_case_insensitive(self, sample_transactions): + """Search should be case-insensitive.""" + results_lower = sample_transactions.filter( + pl.col("merchant").str.to_lowercase().str.contains("amazon") + ) + results_upper = sample_transactions.filter( + pl.col("merchant").str.to_lowercase().str.contains("AMAZON".lower()) + ) + + assert len(results_lower) == len(results_upper) + + +# ============================================================================ +# Test: Spending Summary +# ============================================================================ + + +class TestSpendingSummary: + """Tests for spending summary functionality.""" + + def test_groups_by_category(self, sample_transactions): + """Summary should group transactions by category.""" + expenses = sample_transactions.filter(pl.col("amount") < 0) + summary = expenses.group_by("category").agg([pl.col("amount").sum().alias("total")]) + + assert len(summary) > 0 + assert "Shopping" in summary["category"].to_list() + + def test_only_includes_expenses(self, sample_transactions): + """Summary should only include negative amounts (expenses).""" + expenses = sample_transactions.filter(pl.col("amount") < 0) + + assert len(expenses) == len(sample_transactions) # All are expenses + assert all(a < 0 for a in expenses["amount"].to_list()) + + +# ============================================================================ +# Test: Resources +# ============================================================================ + + +class TestResources: + """Tests for MCP resources.""" + + def test_resources_are_defined(self): + """MCP resources should be defined.""" + # Resources are defined in the server + # We just verify the server can be created + mcp = create_mcp_server() + assert mcp is not None + + +# ============================================================================ +# Test: Tool Registration +# ============================================================================ + + +class TestToolRegistration: + """Tests for MCP tool registration.""" + + def test_server_has_tools(self): + """MCP server should have tools registered.""" + mcp = create_mcp_server() + assert mcp is not None + # The actual tool registration is done via decorators + # We verify the server is created successfully + + +# ============================================================================ +# Integration-style tests (require more setup) +# ============================================================================ + + +class TestIntegration: + """Integration-style tests that verify end-to-end behavior.""" + + def test_server_creation_does_not_initialize(self): + """Server creation should not trigger initialization.""" + # Creating a server should be lazy - no connection attempts + mcp = create_mcp_server( + account_id="nonexistent-account", + config_dir="/nonexistent/path", + ) + + # Server should be created without errors + # (actual initialization happens on first tool call) + assert mcp is not None + + def test_read_only_flag_propagates(self): + """Read-only flag should be set in server configuration.""" + mcp_ro = create_mcp_server(read_only=True) + mcp_rw = create_mcp_server(read_only=False) + + # Both should be created - the flag is stored internally + assert mcp_ro is not None + assert mcp_rw is not None + + +# ============================================================================ +# Test: Tool Function Signatures +# ============================================================================ + + +class TestToolFunctionSignatures: + """Tests that tool functions have valid signatures (no invalid parameters).""" + + def test_fetch_all_data_signature(self): + """Verify fetch_all_data has the expected signature (no force_refresh).""" + import inspect + + from moneyflow.data_manager import DataManager + + sig = inspect.signature(DataManager.fetch_all_data) + param_names = list(sig.parameters.keys()) + + # fetch_all_data should NOT have force_refresh parameter + assert "force_refresh" not in param_names + + # It should have these parameters + assert "self" in param_names + assert "start_date" in param_names + assert "end_date" in param_names + assert "progress_callback" in param_names + + def test_refresh_data_does_not_use_invalid_params(self): + """Verify refresh_data tool doesn't use invalid parameters.""" + # This test verifies our fix by checking the source code + import inspect + + from moneyflow.mcp.server import create_mcp_server + + source = inspect.getsource(create_mcp_server) + + # The refresh_data function should NOT call fetch_all_data(force_refresh=True) + # It should just call fetch_all_data() without that parameter + assert "force_refresh=True" not in source + + +# ============================================================================ +# Test: Literal String Matching (Security) +# ============================================================================ + + +class TestLiteralStringMatching: + """Tests that merchant filtering uses literal string matching, not regex.""" + + def test_merchant_filter_uses_literal_true(self): + """Verify merchant filters use literal=True to prevent regex injection.""" + import inspect + + from moneyflow.mcp.server import create_mcp_server + + source = inspect.getsource(create_mcp_server) + + # All str.contains calls with merchant should use literal=True + # Count occurrences of the pattern + contains_calls = source.count(".str.contains(merchant") + + # Each should have literal=True + literal_calls = source.count(".str.contains(merchant.lower(), literal=True)") + + # All merchant contains calls should use literal=True + assert contains_calls == literal_calls + assert contains_calls >= 2 # At least get_transactions and get_uncategorized + + def test_regex_characters_in_merchant_name(self, sample_transactions): + """Verify regex special characters in merchant don't cause regex matching.""" + # This tests that a merchant name with regex characters + # would be treated literally, not as regex + merchant_with_regex = "Amazon.*" # Would match everything in regex mode + + # With literal=True, this should match literally + results = sample_transactions.filter( + pl.col("merchant") + .str.to_lowercase() + .str.contains(merchant_with_regex.lower(), literal=True) + ) + + # Should NOT match "Amazon" because we're looking for literal "amazon.*" + assert len(results) == 0 + + +# ============================================================================ +# Test: update_transaction_category Tool Parameter Validation +# ============================================================================ + + +class TestUpdateTransactionCategoryParams: + """Tests for update_transaction_category parameter validation and disambiguation.""" + + @pytest.fixture + def categories_with_duplicates(self): + """Categories with duplicate names (can happen across groups/backends).""" + return { + "cat1": "Shopping", + "cat2": "Food & Drink", + "cat3": "Groceries", + "cat4": "Shopping", # Duplicate name! + "cat5": "Uncategorized", + } + + def test_update_category_requires_name_or_id(self): + """Should error when neither category_name nor category_id is provided.""" + import inspect + + from moneyflow.mcp.server import create_mcp_server + + source = inspect.getsource(create_mcp_server) + + # Should contain validation for missing params + assert "Either category_name or category_id must be provided" in source + + def test_update_category_rejects_both_params(self): + """Should error when both category_name and category_id are provided.""" + import inspect + + from moneyflow.mcp.server import create_mcp_server + + source = inspect.getsource(create_mcp_server) + + # Should contain validation for both params + assert "Provide either category_name or category_id, not both" in source + + def test_update_category_detects_duplicate_names(self): + """Should detect and report duplicate category names.""" + import inspect + + from moneyflow.mcp.server import create_mcp_server + + source = inspect.getsource(create_mcp_server) + + # Should contain duplicate detection logic + assert "Multiple categories named" in source + assert "Please use category_id parameter instead" in source + assert "matching_categories" in source + + def test_update_category_supports_category_id(self): + """Should support direct category_id lookup.""" + import inspect + + from moneyflow.mcp.server import create_mcp_server + + source = inspect.getsource(create_mcp_server) + + # Should have category_id parameter and direct lookup + assert "category_id: Optional[str]" in source + assert "resolved_category_id = category_id" in source + assert "Category ID" in source + + def test_duplicate_name_returns_all_matching_ids(self): + """When names are ambiguous, error should include all matching IDs.""" + import inspect + + from moneyflow.mcp.server import create_mcp_server + + source = inspect.getsource(create_mcp_server) + + # Should build list of matching categories with IDs + assert "for cat_id, cat_name in matching_categories" in source + assert '"id": cat_id' in source + assert '"name": cat_name' in source + + +# ============================================================================ +# Functional Tests: update_transaction_category +# ============================================================================ + + +class TestUpdateTransactionCategoryFunctional: + """Functional tests that actually call the MCP tool and verify responses.""" + + @pytest.fixture + def mock_account(self): + """Create a mock account object with required attributes.""" + from types import SimpleNamespace + + return SimpleNamespace( + id="test-account", + name="Test Account", + backend_type="demo", + budget_id=None, + ) + + @pytest.fixture + def categories_with_duplicates(self): + """Categories with duplicate names for disambiguation testing.""" + return { + "cat1": "Shopping", + "cat2": "Food & Drink", + "cat3": "Groceries", + "cat4": "Shopping", # Duplicate name! + "cat5": "Uncategorized", + } + + @pytest.mark.asyncio + async def test_missing_both_params_returns_error( + self, sample_transactions, sample_categories, mock_account + ): + """Should return error when neither category_name nor category_id is provided.""" + import json + from unittest.mock import AsyncMock, MagicMock, patch + + # Patch imports at their source modules (they're imported inside create_mcp_server) + with ( + patch("moneyflow.account_manager.AccountManager") as mock_am, + patch("moneyflow.credentials.CredentialManager") as mock_cm, + patch("moneyflow.backends.get_backend") as mock_get_backend, + patch("moneyflow.data_manager.DataManager") as mock_dm_cls, + ): + # Set up account manager mock + mock_am.return_value.get_last_active_account.return_value = mock_account + mock_am.return_value.get_profile_dir.return_value = None + + # Set up credential manager mock + mock_cm.return_value.credentials_exist.return_value = True + mock_cm.return_value.is_encrypted.return_value = False + mock_cm.return_value.load_credentials.return_value = ( + {"email": "test@test.com", "password": "test", "mfa_secret": ""}, + None, + ) + + # Set up backend mock + mock_backend = AsyncMock() + mock_get_backend.return_value = mock_backend + + # Set up data manager mock + mock_dm = MagicMock() + mock_dm.fetch_all_data = AsyncMock( + return_value=(sample_transactions, sample_categories, {}) + ) + mock_dm_cls.return_value = mock_dm + + # Create server with mocked dependencies + mcp = create_mcp_server() + + # Call the tool without category_name or category_id + result = await mcp.call_tool( + "update_transaction_category", + {"transaction_id": "tx1", "dry_run": True}, + ) + + # Parse response + content_list, extras = result + response_text = content_list[0].text + response = json.loads(response_text) + + # Verify error response + assert response["status"] == "error" + assert "Either category_name or category_id must be provided" in response["message"] + + @pytest.mark.asyncio + async def test_both_params_returns_error( + self, sample_transactions, sample_categories, mock_account + ): + """Should return error when both category_name and category_id are provided.""" + import json + from unittest.mock import AsyncMock, MagicMock, patch + + with ( + patch("moneyflow.account_manager.AccountManager") as mock_am, + patch("moneyflow.credentials.CredentialManager") as mock_cm, + patch("moneyflow.backends.get_backend") as mock_get_backend, + patch("moneyflow.data_manager.DataManager") as mock_dm_cls, + ): + mock_am.return_value.get_last_active_account.return_value = mock_account + mock_am.return_value.get_profile_dir.return_value = None + mock_cm.return_value.credentials_exist.return_value = True + mock_cm.return_value.is_encrypted.return_value = False + mock_cm.return_value.load_credentials.return_value = ( + {"email": "test@test.com", "password": "test", "mfa_secret": ""}, + None, + ) + mock_backend = AsyncMock() + mock_get_backend.return_value = mock_backend + + mock_dm = MagicMock() + mock_dm.fetch_all_data = AsyncMock( + return_value=(sample_transactions, sample_categories, {}) + ) + mock_dm_cls.return_value = mock_dm + + mcp = create_mcp_server() + + # Call with both params + result = await mcp.call_tool( + "update_transaction_category", + { + "transaction_id": "tx1", + "category_name": "Shopping", + "category_id": "cat1", + "dry_run": True, + }, + ) + + content_list, extras = result + response = json.loads(content_list[0].text) + + assert response["status"] == "error" + assert "Provide either category_name or category_id, not both" in response["message"] + + @pytest.mark.asyncio + async def test_duplicate_names_returns_disambiguation_error( + self, sample_transactions, categories_with_duplicates, mock_account + ): + """Should return error with matching IDs when category name is ambiguous.""" + import json + from unittest.mock import AsyncMock, MagicMock, patch + + with ( + patch("moneyflow.account_manager.AccountManager") as mock_am, + patch("moneyflow.credentials.CredentialManager") as mock_cm, + patch("moneyflow.backends.get_backend") as mock_get_backend, + patch("moneyflow.data_manager.DataManager") as mock_dm_cls, + ): + mock_am.return_value.get_last_active_account.return_value = mock_account + mock_am.return_value.get_profile_dir.return_value = None + mock_cm.return_value.credentials_exist.return_value = True + mock_cm.return_value.is_encrypted.return_value = False + mock_cm.return_value.load_credentials.return_value = ( + {"email": "test@test.com", "password": "test", "mfa_secret": ""}, + None, + ) + mock_backend = AsyncMock() + mock_get_backend.return_value = mock_backend + + mock_dm = MagicMock() + mock_dm.fetch_all_data = AsyncMock( + return_value=(sample_transactions, categories_with_duplicates, {}) + ) + mock_dm_cls.return_value = mock_dm + + mcp = create_mcp_server() + + # Call with duplicate category name + result = await mcp.call_tool( + "update_transaction_category", + {"transaction_id": "tx1", "category_name": "Shopping", "dry_run": True}, + ) + + content_list, extras = result + response = json.loads(content_list[0].text) + + assert response["status"] == "error" + assert "Multiple categories named 'Shopping' exist" in response["message"] + assert "matching_categories" in response + assert len(response["matching_categories"]) == 2 + + # Verify both IDs are included + matching_ids = {c["id"] for c in response["matching_categories"]} + assert "cat1" in matching_ids + assert "cat4" in matching_ids + + @pytest.mark.asyncio + async def test_category_id_bypasses_name_lookup( + self, sample_transactions, categories_with_duplicates, mock_account + ): + """Should successfully use category_id even when names are duplicate.""" + import json + from unittest.mock import AsyncMock, MagicMock, patch + + with ( + patch("moneyflow.account_manager.AccountManager") as mock_am, + patch("moneyflow.credentials.CredentialManager") as mock_cm, + patch("moneyflow.backends.get_backend") as mock_get_backend, + patch("moneyflow.data_manager.DataManager") as mock_dm_cls, + ): + mock_am.return_value.get_last_active_account.return_value = mock_account + mock_am.return_value.get_profile_dir.return_value = None + mock_cm.return_value.credentials_exist.return_value = True + mock_cm.return_value.is_encrypted.return_value = False + mock_cm.return_value.load_credentials.return_value = ( + {"email": "test@test.com", "password": "test", "mfa_secret": ""}, + None, + ) + mock_backend = AsyncMock() + mock_get_backend.return_value = mock_backend + + mock_dm = MagicMock() + mock_dm.fetch_all_data = AsyncMock( + return_value=(sample_transactions, categories_with_duplicates, {}) + ) + mock_dm_cls.return_value = mock_dm + + mcp = create_mcp_server() + + # Call with specific category_id (should work despite duplicate names) + result = await mcp.call_tool( + "update_transaction_category", + {"transaction_id": "tx1", "category_id": "cat4", "dry_run": True}, + ) + + content_list, extras = result + response = json.loads(content_list[0].text) + + # Should succeed as dry_run + assert response["status"] == "dry_run" + assert response["would_update"]["new_category"] == "Shopping" + + @pytest.mark.asyncio + async def test_invalid_category_id_returns_error( + self, sample_transactions, sample_categories, mock_account + ): + """Should return error when category_id doesn't exist.""" + import json + from unittest.mock import AsyncMock, MagicMock, patch + + with ( + patch("moneyflow.account_manager.AccountManager") as mock_am, + patch("moneyflow.credentials.CredentialManager") as mock_cm, + patch("moneyflow.backends.get_backend") as mock_get_backend, + patch("moneyflow.data_manager.DataManager") as mock_dm_cls, + ): + mock_am.return_value.get_last_active_account.return_value = mock_account + mock_am.return_value.get_profile_dir.return_value = None + mock_cm.return_value.credentials_exist.return_value = True + mock_cm.return_value.is_encrypted.return_value = False + mock_cm.return_value.load_credentials.return_value = ( + {"email": "test@test.com", "password": "test", "mfa_secret": ""}, + None, + ) + mock_backend = AsyncMock() + mock_get_backend.return_value = mock_backend + + mock_dm = MagicMock() + mock_dm.fetch_all_data = AsyncMock( + return_value=(sample_transactions, sample_categories, {}) + ) + mock_dm_cls.return_value = mock_dm + + mcp = create_mcp_server() + + # Call with nonexistent category_id + result = await mcp.call_tool( + "update_transaction_category", + {"transaction_id": "tx1", "category_id": "nonexistent", "dry_run": True}, + ) + + content_list, extras = result + response = json.loads(content_list[0].text) + + assert response["status"] == "error" + assert "Category ID 'nonexistent' not found" in response["message"] diff --git a/uv.lock b/uv.lock index c02796c..da5f45f 100644 --- a/uv.lock +++ b/uv.lock @@ -660,6 +660,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/4f/7297663840621022bc73c22d7d9d80dbc78b4db6297f764b545cd5dd462d/graphql_core-3.2.6-py3-none-any.whl", hash = "sha256:78b016718c161a6fb20a7d97bbf107f331cd1afe53e45566c59f776ed7f0b45f", size = 203416, upload-time = "2025-01-26T16:36:24.868Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + [[package]] name = "idna" version = "3.10" @@ -690,6 +736,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "jsonschema" +version = "4.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "linkify-it-py" version = "2.0.3" @@ -805,6 +878,37 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mcp" +version = "1.25.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" }, +] + +[package.optional-dependencies] +cli = [ + { name = "python-dotenv" }, + { name = "typer" }, +] + [[package]] name = "mdit-py-plugins" version = "0.5.0" @@ -913,6 +1017,7 @@ dependencies = [ { name = "click" }, { name = "cryptography" }, { name = "gql" }, + { name = "mcp", extra = ["cli"] }, { name = "oathtool" }, { name = "polars" }, { name = "python-dateutil" }, @@ -943,6 +1048,7 @@ requires-dist = [ { name = "click", specifier = ">=8.1.0,<8.3.0" }, { name = "cryptography", specifier = ">=41.0.0" }, { name = "gql", specifier = ">=3.4" }, + { name = "mcp", extras = ["cli"], specifier = ">=1.25.0" }, { name = "oathtool", specifier = ">=2.3.1" }, { name = "polars", specifier = ">=0.19.0" }, { name = "python-dateutil", specifier = ">=2.8.0" }, @@ -1455,6 +1561,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/f7/925f65d930802e3ea2eb4d5afa4cb8730c8dc0d2cb89a59dc4ed2fcb2d74/pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f", size = 2147775, upload-time = "2025-10-14T10:23:45.406Z" }, ] +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1464,6 +1584,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pymdown-extensions" version = "10.16.1" @@ -1557,6 +1691,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, ] +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1624,6 +1795,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -1652,6 +1837,114 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + [[package]] name = "ruff" version = "0.14.0" @@ -1678,6 +1971,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/2a/65880dfd0e13f7f13a775998f34703674a4554906167dce02daf7865b954/ruff-0.14.0-py3-none-win_arm64.whl", hash = "sha256:f42c9495f5c13ff841b1da4cb3c2a42075409592825dada7c5885c2c844ac730", size = 12565142, upload-time = "2025-10-07T18:21:53.577Z" }, ] +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1696,6 +1998,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sse-starlette" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/62/08/8f554b0e5bad3e4e880521a1686d96c05198471eed860b0eb89b57ea3636/sse_starlette-3.1.1.tar.gz", hash = "sha256:bffa531420c1793ab224f63648c059bcadc412bf9fdb1301ac8de1cf9a67b7fb", size = 24306, upload-time = "2025-12-26T15:22:53.836Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/31/4c281581a0f8de137b710a07f65518b34bcf333b201cfa06cfda9af05f8a/sse_starlette-3.1.1-py3-none-any.whl", hash = "sha256:bb38f71ae74cfd86b529907a9fda5632195dfa6ae120f214ea4c890c7ee9d436", size = 12442, upload-time = "2025-12-26T15:22:52.911Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + [[package]] name = "textual" version = "6.3.0" @@ -1794,6 +2122,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] +[[package]] +name = "typer" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/30/ff9ede605e3bd086b4dd842499814e128500621f7951ca1e5ce84bbf61b1/typer-0.21.0.tar.gz", hash = "sha256:c87c0d2b6eee3b49c5c64649ec92425492c14488096dfbc8a0c2799b2f6f9c53", size = 106781, upload-time = "2025-12-25T09:54:53.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e4/5ebc1899d31d2b1601b32d21cfb4bba022ae6fce323d365f0448031b1660/typer-0.21.0-py3-none-any.whl", hash = "sha256:c79c01ca6b30af9fd48284058a7056ba0d3bf5cf10d0ff3d0c5b11b68c258ac6", size = 47109, upload-time = "2025-12-25T09:54:51.918Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" @@ -1833,6 +2176,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" }, ] +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + [[package]] name = "watchdog" version = "6.0.0"