commit ac93e6074b369132cf267612542c7311d0f636ce Author: Joe Arndt Date: Wed May 6 18:25:17 2026 -0500 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ee070ad --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# IDE +.idea/ + +# Development +.venv/ +__pycache__/ +*.pyc +*.pyo +*.egg-info +build/ +dist/ +.ruff_cache + +# AI +.claude/ + +# App +*.db +spending/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..051d851 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,111 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +**Common Cents** is a terminal TUI spending tracker built with Python 3.13, [Textual](https://textual.textualize.io/), and SQLite. The application is fully implemented and running. + +## Development Setup + +- Python 3.13, managed by **uv** +- Formatter: **Black** (line length 88, target py313) +- Linter: **ruff** (config in `pyproject.toml`) +- Tests: **pytest** (in `tests/`) — covers `money`, `db`, `csv_import`/`csv_export`, `category_tree`, the bulk-edit data path, the persisted-config layer, and the reports date-range helpers. Pure-Python, no Textual rendering required. +- Database: SQLite, initialized from `src/common_cents/schema.sql` (packaged alongside the module so it's available after `pip install`) +- Sample data: `sample_spending.csv` + +```bash +uv sync --all-extras # create .venv and install all deps including dev +uv run common-cents # run the app +uv run pytest # run tests (fast — pure-Python, no UI) +uv run ruff check . # lint +uv run black . # format +uv build # build wheel + sdist into dist/ +``` + +Both `ruff check` and `pytest` must pass before merging. Ruff is configured to ignore `PLC0415` (we use nested imports to avoid circular screen imports) and `PLR0913` (DB methods take many parameters by design). See `[tool.ruff.lint]` in `pyproject.toml`. + +### Project layout + +- `src/common_cents/__main__.py` — CLI entry point; resolves the DB path. +- `src/common_cents/app.py` — `CommonCentsApp`, takes `db_path`, pushes the main menu, and exposes `switch_db` / `reset_db_to_default` for the Options screen. +- `src/common_cents/config.py` — persisted user config (`db_path` key) under `$XDG_CONFIG_HOME/common-cents/config.json`. +- `src/common_cents/screens/` — one module per screen (main menu, spending, bulk-edit form, metadata, reports, CSV import, CSV export, options, modals). `_base.py` provides an `AppScreen` mixin with a typed `self.db` accessor. +- `src/common_cents/widgets/` — reusable widgets (`CenteredFooter`, `AutocompleteInput`). +- `src/common_cents/db.py` — SQLite wrapper. Amounts are stored as integer cents. `PRAGMA user_version` drives schema migrations on startup. +- `src/common_cents/csv_import.py` / `csv_export.py` — CSV reader and writer. +- `src/common_cents/money.py` — money parsing/formatting helpers (string-based, no float), plus the `format_budget_cell` colour-graded `$used / $budget (%)` renderable. +- `src/common_cents/category_tree.py` — `Parent:Child[:Grandchild...]` trie shared by the reports screen and the dashboard's top-categories panel. +- `src/common_cents/schema.sql` — database schema (four tables: `metadata`, `spending`, `spending_metadata`, `budget`); packaged as data alongside the module. + +### Database location and resolution + +The DB lives at `$XDG_DATA_HOME/common-cents/common-cents.db` (fallback `~/.local/share/common-cents/common-cents.db`) by default. + +Resolution order in `_resolve_db_path` (`__main__.py`), highest priority first: + +1. `--db PATH` — CLI flag. +2. `$COMMON_CENTS_DB` — environment variable. +3. The path persisted by the in-app **Options** screen (`o` from the main menu), stored in `$XDG_CONFIG_HOME/common-cents/config.json` (fallback `~/.config/common-cents/config.json`) under the `db_path` key. The Options screen calls `app.switch_db`, which opens the new DB first and closes the previous connection only on success — a failed switch leaves the old connection intact. +4. `./common-cents.db` — legacy CWD fallback. Used with a one-line stderr migration hint, only if the default location does not yet exist. +5. The default path above. + +To recreate the database from scratch: +```bash +DB="${COMMON_CENTS_DB:-${XDG_DATA_HOME:-$HOME/.local/share}/common-cents/common-cents.db}" +rm -f "$DB" && mkdir -p "$(dirname "$DB")" && sqlite3 "$DB" < src/common_cents/schema.sql +``` + +## Architecture + +### Entry point +`app.py` — `CommonCentsApp(App)`. Sets `self.theme = "atom-one-dark"` in `__init__` (class-level `THEME` is silently ignored in Textual 8). Overrides `get_key_display` to uppercase all footer key labels. Exposes `switch_db(path, persist=True)` and `reset_db_to_default()` so the Options screen can swap the live `Database` connection without restarting the app — both methods open the new connection first and only close the previous one on success, so a failed switch leaves the old DB intact. + +### Screens (`src/common_cents/screens/`) +- **`_base.py`** — `AppScreen[T]` is a thin `Screen[T]` subclass exposing `self.db` (typed as `Database`) so screens never have to reach for `self.app.db` and re-cast. New screens should inherit from `AppScreen` rather than `Screen` directly. +- **`main_menu.py`** — dashboard with monthly summary, hierarchical category breakdown (with prev-month delta and `format_budget_cell` cell), and recent spending. Bindings: `s/m/r/i/x/o/q` for Spending / Metadata / Reports / Import / Export / Options / Quit. The categories panel shares the trie from `common_cents.category_tree` with the reports screen, so subcategory spending rolls up into parents (e.g. `Food:Restaurants` contributes to `Food`'s total + budget). The panel is wrapped in a `VerticalScroll` and rows are filtered to those with this-month spending, prev-month spending, or a configured budget — prev-month-only branches still roll up into ancestors' delta but are hidden at render so they don't clutter the list. Title shows the active DB path; `on_screen_resume` re-renders so a DB switch is reflected immediately. +- **`spending.py`** — full CRUD for spending records with a two-row filter panel (date from / date to / min $ / max $ on row 1; category / merchant / tag / notes substring + Clear button on row 2). Selection state lives in `_selected_ids: set[int]` and is keyed by spending id, so it survives filter changes. Bindings: `a/e/d/u` (add/edit/delete/undo last delete), `space`/`*` to toggle a row / all visible rows, `b` to bulk-edit the selection, `escape` to clear the selection or pop. The undo buffer (`_undo`) snapshots category/merchant/tag *names* (not ids) so it works even if metadata was renamed/deleted in the meantime — `db.add_spending` re-resolves names. Invalid filter inputs paint themselves red via `:invalid` but don't clobber the current view. +- **`_spending_form.py`** — modal form for add/edit. Uses `AutocompleteInput` for category, merchant, and tags with inline creation. +- **`_spending_bulk_form.py`** — `BulkEditScreen` applies a per-field diff to a list of spending ids. Each row is a checkbox + field; typing in a field auto-ticks its checkbox so the user doesn't have to remember the dual-step. Only ticked fields are written; everything else is preserved per row by reading `get_spending_by_id` and overlaying. Tags replace the existing set on each record. Magic `[clear]` token in Merchant or Notes blanks those columns. Returns the count actually updated; `0` means cancelled / nothing applicable. +- **`metadata.py`** — `MetadataScreen` is a three-column layout (Categories | Merchants | Tags), each column a labeled `DataTable`. Categories carry an optional monthly budget rendered in a second column. The currently focused column determines what `e` (edit) and `d` (delete) operate on. `a` (add) opens `MetadataAddModal` with a kind selector defaulting to the focused column; the modal also exposes a budget input that hides itself when kind ≠ category. `c`/`m`/`t` focus the corresponding column (hidden from the footer); `tab`/`shift-tab` rotates focus. `MetadataFormModal` (name-only) is used for edit-of-merchant/tag and reused by `_spending_form.py` for inline tag creation. `CategoryFormModal` (name + budget) is used for category edit; a blank budget clears any existing one. Delete is blocked at the app layer if the row is referenced by spending. Names with usage_count = 0 render in yellow as a "you can safely delete me" hint. +- **`reports.py`** — category breakdown report with period selector (This Month / Last Month / YTD / Last 12 Months / Custom) and an always-visible filter row (Category substring, Tag substring, Min $, Max $ on category totals). For Custom, two `Select` dropdowns of `MMM YYYY` months span from the earliest spending row (or the trailing 12 months if the DB is empty) to the current month — invalid swap-order is auto-corrected. The "Clear" button resets the four filter inputs. Categories follow a `Parent:Child[:Grandchild...]` convention with arbitrary depth; the trie is built by `common_cents.category_tree.build_category_tree` from the flat path strings (intermediate ancestors are derived — their `self_total` is `0` unless the user actually recorded spending against the exact path), and `_render_nodes` walks it recursively. When an internal node has nonzero `self_total` (the user recorded spending against a parent path *and* deeper paths underneath it), a synthetic `(direct)` child leaf is rendered so the displayed totals reconcile. Monthly budgets are scaled to the displayed period by `_months_in_range` (e.g. a $500/month budget becomes $2500 for a 5-month YTD window). +- **`import_csv.py`** — CSV import with `DirectoryTree` file browser, inline preview, and post-import confirmation dialog. Rows whose `(date, cents, category, merchant, notes, tags)` already match an existing spending record (or an earlier row in the same batch) are flagged in the warnings modal and skipped during import. +- **`export_csv.py`** — CSV export to a directory chosen via `DirectoryTree` (directories and existing CSVs shown for orientation; clicking a CSV prefills the filename to overwrite it). Default filename is `common-cents-YYYY-MM-DD.csv`. Output round-trips through `parse_csv` (header `DATE,CENTS,CATEGORY,MERCHANT,NOTES,TAGS`, multi-tag values comma-joined and CSV-quoted by `csv.writer`). Tags within a row are sorted alphabetically for deterministic output. Existing-file overwrites require a warning confirmation. +- **`options.py`** — `OptionsScreen` switches the active database. Filtered `_DBDirectoryTree` shows only directories and `.db`/`.sqlite` files; clicking a file or typing a path into the bottom input prompts a `ConfirmModal` and then calls `app.switch_db`. `r` resets to the platform-default DB and clears the persisted config override. The currently active path is shown at the top relative to `~` when possible. +- **`_confirm.py`** — reusable `ConfirmModal` with optional detail lines, configurable button labels, and success/warning border variants. + +### Widgets (`src/common_cents/widgets/`) +- **`footer.py`** — `CenteredFooter(Footer)` centers itself by computing `(screen_width - footer_width) // 2` as a left margin on mount and resize. Textual CSS `margin: auto` is not supported — centering must be done in Python. +- **`autocomplete.py`** — `AutocompleteInput` composite widget: an `Input` with a dropdown `OptionList`. Shows full sorted list on focus or when input is cleared; filters as you type. + +### Data layer (`db.py`) +Single `Database` class wrapping a `sqlite3.Connection`. The connection opens (and migrations run) eagerly in `Database.__init__`, so `self.conn` is always populated and methods can use it without None-checks. All amounts are stored and passed as **integer cents**. The class is also a context manager (`__enter__`/`__exit__`) — useful in tests. `default_db_path()` and the budget helpers (`set_budget`/`clear_budget`/`get_budget`) live alongside the Database class. + +### Persisted config (`config.py`) +`load_config` / `save_config` read and write a JSON file at `$XDG_CONFIG_HOME/common-cents/config.json` (fallback `~/.config/common-cents/config.json`). The only key currently used is `db_path`; thin wrappers `get_active_db_path` / `set_active_db_path` / `clear_active_db_path` are what the Options screen and `__main__.py` consume. Malformed or missing files return `{}` so a corrupt config never blocks startup. + +### CSV import (`csv_import.py`) +`parse_csv(path) -> ParseResult`. Required columns: `DATE`, `CENTS`, `CATEGORY`, `MERCHANT`. Optional: `NOTES`, `TAGS`. Tags split by `,` (multi-tag values must be quoted in the CSV, e.g. `"work,personal"`, since the CSV itself is comma-delimited). Cents may be comma-formatted (`"14,901"`). Returns `ParseResult` with `rows`, `warnings`, and `errors`. + +## Database Schema + +Four tables (`src/common_cents/schema.sql`): +- **metadata** — unified table for categories, merchants, and tags. Columns: `id`, `name` (COLLATE NOCASE), `kind` (CHECK in `'category'`/`'merchant'`/`'tag'`), `created_at`. `UNIQUE(kind, name COLLATE NOCASE)`. Deletion is blocked at the app layer if the row is referenced by spending. Names containing `,` are rejected by `_validate_name` because the CSV importer/exporter use `,` as the tag delimiter. +- **spending** — `date` (DATE), `cents` (INTEGER > 0), `notes` (optional), `created_at`, `updated_at`. No FK columns — all metadata associations live in the join table below. +- **spending_metadata** — single join table linking spending rows to any metadata row regardless of kind. Carries a denormalised `kind` column (`'category' | 'merchant' | 'tag'`) so reads can filter without joining `metadata`. Cascades deletes from both `spending` and `metadata`. Cardinality is **schema-enforced**: partial unique indexes `WHERE kind = 'category'` / `WHERE kind = 'merchant'` cap each spending row at one category and at most one merchant; tags are unrestricted. `db.py` write paths route through `_link_metadata`; reads use scalar subqueries filtered on `sm.kind` to surface category/merchant/tags per spending row, avoiding the cross-product blowup of joining the table three times. +- **budget** — optional per-category monthly cap. `metadata_id PRIMARY KEY REFERENCES metadata(id) ON DELETE CASCADE`, `monthly_cents INTEGER NOT NULL CHECK (monthly_cents > 0)`. Application code only writes rows whose `metadata.kind = 'category'`; the schema doesn't enforce that constraint (it'd require triggers), so callers must respect it. `get_metadata` returns `budget_cents` as a scalar subquery so the metadata screen can render the column without a separate query. + +Indexes: `metadata(kind, name)`, `spending(date)`, `spending_metadata(metadata_id)`, `spending_metadata(spending_id, kind)`, plus the two partial unique indexes above. + +### Schema versioning + +`PRAGMA user_version` tracks schema state. Fresh DBs are initialised at the current version (the value `schema.sql` sets at the bottom — currently `2`); older DBs are migrated in `Database._migrate`, which chains `_migrate_to_v1` (added the `kind` column to `spending_metadata` plus the cardinality unique indexes) and `_migrate_to_v2` (added the `budget` table). Bumping the schema requires updating `SCHEMA_VERSION` in `db.py`, adding a `_migrate_to_vN` method, extending the chain in `_migrate`, and ensuring the new `schema.sql` ends with `PRAGMA user_version = N`. A DB with `user_version` higher than `SCHEMA_VERSION` raises rather than silently downgrading. + +## Key Conventions + +- **Amounts**: always integer cents internally. Use `common_cents.money` for parsing and formatting: `parse_dollars` (user input → cents, returns `None` for blank/invalid, string-based — no float), `parse_cents_csv` (CSV `CENTS` column → cents, raises on bad input), `format_cents` (display, `$1,234.56`), `format_cents_input` (bare value for editable `Input`, `1234.56`), `format_budget_cell` (`$used / $budget (N%)` Text coloured green/yellow/red by % consumed). Don't reintroduce per-screen `_fmt`/`_parse_dollars` helpers. +- **Vim navigation**: all `DataTable` and `DirectoryTree` screens bind `j/k/g/G` (hidden from footer) delegating to the focused widget's built-in cursor actions. +- **Footer**: always use `CenteredFooter` (not the built-in `Footer`) in new screens. +- **Rich tables in Static**: use `rich.box.SQUARE` for spreadsheet-style grids. Stack a title above a table using `rich.console.Group(Text(..., justify="center"), table)`. +- **Textual CSS `auto` margins**: not supported — compute margins in Python via `widget.styles.margin`. diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f0e287 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Common Cents + +A keyboard-driven spending tracker that runs in your terminal. Built with Python and [Textual](https://textual.textualize.io/); your data is stored locally in SQLite — no cloud, no account. + +## Install & run + +You'll need Python 3.13 and [uv](https://docs.astral.sh/uv/). + +```bash +uv sync --all-extras +uv run common-cents +``` + +That's it — the database is created automatically the first time you launch. + +## What you can do + +From the main menu: + +- **Spending** — record what you spent, where, and on what. Categories, merchants, and tags autocomplete as you type, and any new ones can be created on the fly. Filter by date, amount, category, merchant, tag, or a substring of the notes. Press `space` to mark rows, `*` to mark them all, then `b` to apply a single change (a new category, a tag set, a date) to the whole batch. Just deleted something by accident? `u` puts it back. +- **Metadata** — manage your categories, merchants, and tags in one place. Give a category a monthly budget and Common Cents will track how close you are to it. +- **Reports** — see where your money went, broken down by category, with progress bars and budget targets. Period selector covers This Month / Last Month / YTD / Last 12 Months, plus a custom from-month / to-month range. +- **Dashboard** — month-by-month totals for the year, a top-categories list with budget progress and how this month compares to last, and your most recent activity. +- **Import / Export** — bring in spending from a CSV (with a built-in browser, preview, and duplicate detection), or export everything back to CSV. +- **Options** — point the app at a different database file, or create a new one. Useful if you want to keep separate ledgers — household vs. side hustle, for example. + +## Categories with subcategories + +Categories use a `Parent:Child` convention with arbitrary depth — `Food`, `Food:Groceries`, `Food:Restaurants:Lunch`, and so on. Reports and the dashboard roll subcategories up into their parents automatically, so a budget on `Food` covers everything underneath it. + +## Keyboard cheat-sheet + +Most screens share these keys: + +| Key | Action | +|---|---| +| `a` / `e` / `d` | Add / Edit / Delete | +| `j` `k` `g` `G` | Vim-style cursor movement | +| `escape` | Back | +| `q` | Quit (from the main menu) | + +Spending screen extras: `space` toggles row selection, `*` toggles all visible rows, `b` opens bulk-edit, `u` undoes the last delete. + +## Where your data lives + +The database file is stored at: + +- `$XDG_DATA_HOME/common-cents/common-cents.db` — or +- `~/.local/share/common-cents/common-cents.db` if `$XDG_DATA_HOME` isn't set. + +To use a different file, either pick it from the in-app **Options** screen (`o` from the main menu) or pass `--db /path/to/file.db` on launch. The Options screen remembers your choice across runs. + +## CSV format + +Both import and export use this format. Headers are case-sensitive. + +| Column | Required | Notes | +|---|---|---| +| `DATE` | yes | `YYYY-MM-DD` | +| `CENTS` | yes | Integer cents — commas allowed (`"14,901"`) | +| `CATEGORY` | yes | Use `Parent:Child` for subcategories | +| `MERCHANT` | no | May be blank | +| `NOTES` | no | Free-form | +| `TAGS` | no | Comma-separated within the field — quote it: `"work,lunch"` | + +See `sample_spending.csv` for a working example. + +## Development + +If you want to hack on Common Cents, the architecture, dev workflow, and code conventions are documented in [`CLAUDE.md`](CLAUDE.md). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..db55b66 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,47 @@ +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "common-cents" +version = "0.1.0" +requires-python = ">=3.13" +dependencies = [ + "textual==8.2.4", +] + +[project.scripts] +common-cents = "common_cents.__main__:main" + +[project.optional-dependencies] +dev = [ + "textual-dev", + "pytest>=8", + "ruff>=0.6", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +common_cents = ["schema.sql"] + +[tool.black] +line-length = 88 +target-version = ["py313"] + +[tool.ruff] +line-length = 88 +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "B", "UP", "PL", "SIM"] +ignore = [ + "E501", # line length — Black handles formatting + "PLR0913", # too many args — DB methods take many params by design + "PLR2004", # magic numbers — common in TUI layout / formatting + "PLC0415", # nested imports — used to avoid circular screen imports +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/sample_spending.csv b/sample_spending.csv new file mode 100644 index 0000000..0206043 --- /dev/null +++ b/sample_spending.csv @@ -0,0 +1,930 @@ +DATE,CENTS,CATEGORY,MERCHANT,NOTES,TAGS +2024-05-01,150101,Household:Mortgage,US Bank,, +2024-05-03,999,Subscriptions:Apps,Apple,iCloud storage, +2024-05-05,8570,Utilities:Internet,Spectrum,, +2024-05-05,3697,Personal:Haircut,Great Clips,, +2024-05-07,10539,Utilities:Electric,Duke Energy,, +2024-05-07,3829,Entertainment:Food,LaRosa's,Pizza night, +2024-05-08,6358,Automotive:Gasoline,Casey's,,Van +2024-05-10,14810,Utilities:Phone,Phone Company,Family plan, +2024-05-11,2642,Entertainment:Food,Chipotle,, +2024-05-11,4000,Healthcare:Chiropractic,City Chiro,Joann adjustment, +2024-05-12,6114,Utilities:Water,City Water,, +2024-05-14,2599,Entertainment:Food,Chipotle,, +2024-05-15,2899,Utilities:Trash,Rumpke,, +2024-05-15,23435,Groceries:Food,Costco,, +2024-05-15,7357,Clothing:Shoes,Famous Footwear,, +2024-05-18,3236,Utilities:Gas,Duke Energy,Heating, +2024-05-18,5117,Groceries:Supplies,Costco,, +2024-05-19,3275,Entertainment:Food,Skyline Chili,, +2024-05-20,14500,Automotive:Insurance,Progressive,, +2024-05-20,4167,Clothing:Apparel,Kohl's,, +2024-05-21,2636,Clothing:Accessories,Macy's,, +2024-05-22,1799,Subscriptions:Streaming:Video,Netflix,, +2024-05-22,3628,Entertainment:Movies,AMC Theatres,Family movie, +2024-05-23,5562,Household:Improvement,Home Depot,, +2024-05-24,7464,Automotive:Gasoline,Shell,,Truck +2024-05-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2024-05-25,13961,Groceries:Food,Aldi,, +2024-05-25,3303,Groceries:Supplies,Target,Paper towels & detergent, +2024-05-25,6224,Automotive:Gasoline,Shell,,Van +2024-05-26,11651,Groceries:Food,Trader Bob's,, +2024-05-26,8564,Automotive:Gasoline,Casey's,,Truck +2024-05-27,999,Subscriptions:Streaming:Video,Disney+,, +2024-05-27,2811,Entertainment:Activities,Cincinnati Zoo,, +2024-05-28,1499,Subscriptions:Apps,1Password,, +2024-05-28,8597,Clothing:Apparel,Old Navy,, +2024-05-29,5977,Automotive:Gasoline,Marathon,,Van +2024-05-29,1863,Entertainment:Food,Graeter's,Ice cream, +2024-06-01,150101,Household:Mortgage,US Bank,, +2024-06-03,999,Subscriptions:Apps,Apple,iCloud storage, +2024-06-04,6299,Automotive:Maintenance,Valvoline,Oil change,Truck +2024-06-05,8450,Utilities:Internet,Spectrum,, +2024-06-05,4218,Entertainment:Movies,AMC Theatres,Family movie, +2024-06-06,3319,Groceries:Supplies,Target,Paper towels & detergent, +2024-06-06,762,Entertainment:Food,Casey's,, +2024-06-07,14140,Utilities:Electric,Duke Energy,, +2024-06-07,6455,Automotive:Gasoline,Kroger,,Van +2024-06-08,1024,Entertainment:Food,Dairy Queen,Date Night, +2024-06-09,22597,Groceries:Food,Costco,, +2024-06-09,5512,Automotive:Gasoline,Kroger,,Van +2024-06-09,3973,Clothing:Shoes,Famous Footwear,, +2024-06-10,14299,Utilities:Phone,Phone Company,Family plan, +2024-06-10,2687,Groceries:Supplies,Amazon,Cleaning supplies, +2024-06-11,4000,Healthcare:Chiropractic,City Chiro,Joann adjustment, +2024-06-12,5096,Utilities:Water,City Water,, +2024-06-12,9345,Automotive:Gasoline,Kroger,,Truck +2024-06-13,2906,Entertainment:Activities,Cincinnati Zoo,, +2024-06-15,2899,Utilities:Trash,Rumpke,, +2024-06-16,14188,Groceries:Food,Trader Bob's,, +2024-06-16,1375,Entertainment:Food,Dairy Queen,Date Night, +2024-06-17,4842,Entertainment:Food,El Corral,Family dinner, +2024-06-18,2500,Utilities:Gas,Duke Energy,Heating, +2024-06-18,9743,Automotive:Gasoline,Casey's,,Truck +2024-06-18,8970,Clothing:Apparel,Kohl's,, +2024-06-20,14500,Automotive:Insurance,Progressive,, +2024-06-21,10396,Groceries:Food,Trader Bob's,, +2024-06-21,3044,Clothing:Accessories,Macy's,, +2024-06-22,1799,Subscriptions:Streaming:Video,Netflix,, +2024-06-24,11218,Groceries:Food,Kroger,, +2024-06-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2024-06-27,999,Subscriptions:Streaming:Video,Disney+,, +2024-06-27,5394,Pets:Food,Chewy,Dog food, +2024-06-28,1499,Subscriptions:Apps,1Password,, +2024-06-28,6409,Clothing:Shoes,Famous Footwear,Kids, +2024-07-01,150101,Household:Mortgage,US Bank,, +2024-07-02,14145,Groceries:Food,Trader Bob's,, +2024-07-03,999,Subscriptions:Apps,Apple,iCloud storage, +2024-07-05,8512,Utilities:Internet,Spectrum,, +2024-07-06,1443,Entertainment:Food,Graeter's,Ice cream, +2024-07-06,6000,Entertainment:Movies,AMC Theatres,Family movie, +2024-07-07,17101,Utilities:Electric,Duke Energy,, +2024-07-08,4213,Clothing:Apparel,Kohl's,, +2024-07-09,10504,Groceries:Food,Kroger,, +2024-07-10,14768,Utilities:Phone,Phone Company,Family plan, +2024-07-10,9700,Automotive:Gasoline,Casey's,,Truck +2024-07-11,47800,Automotive:Repairs,Tire Discounters,Set of 4 tires,Van +2024-07-12,4960,Utilities:Water,City Water,, +2024-07-12,4898,Entertainment:Food,El Corral,Family dinner, +2024-07-14,1146,Entertainment:Food,Dairy Queen,Date Night, +2024-07-15,2899,Utilities:Trash,Rumpke,, +2024-07-15,2261,Groceries:Supplies,Target,, +2024-07-15,4663,Entertainment:Food,LaRosa's,Pizza night, +2024-07-16,938,Entertainment:Food,Casey's,, +2024-07-18,2437,Utilities:Gas,Duke Energy,Heating, +2024-07-18,7896,Clothing:Apparel,Old Navy,, +2024-07-18,4000,Healthcare:Chiropractic,City Chiro,Bob adjustment, +2024-07-18,12790,Household:Improvement,Home Depot,, +2024-07-19,7501,Automotive:Gasoline,Shell,,Truck +2024-07-19,4483,Clothing:Shoes,Famous Footwear,, +2024-07-20,14500,Automotive:Insurance,Progressive,, +2024-07-20,987,Entertainment:Food,Dairy Queen,Date Night, +2024-07-22,1799,Subscriptions:Streaming:Video,Netflix,, +2024-07-23,13292,Groceries:Food,Trader Bob's,, +2024-07-23,7328,Automotive:Gasoline,Casey's,,Truck +2024-07-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2024-07-25,5786,Automotive:Gasoline,Marathon,,Van +2024-07-25,3397,Personal:Haircut,Great Clips,, +2024-07-26,13232,Groceries:Food,Trader Bob's,, +2024-07-27,999,Subscriptions:Streaming:Video,Disney+,, +2024-07-27,3413,Entertainment:Food,LaRosa's,Pizza night, +2024-07-28,1499,Subscriptions:Apps,1Password,, +2024-08-01,150101,Household:Mortgage,US Bank,, +2024-08-01,1346,Entertainment:Food,Dairy Queen,Date Night, +2024-08-03,999,Subscriptions:Apps,Apple,iCloud storage, +2024-08-04,3895,Entertainment:Movies,AMC Theatres,Family movie, +2024-08-05,8471,Utilities:Internet,Spectrum,, +2024-08-06,6679,Automotive:Gasoline,Marathon,,Van +2024-08-07,16535,Utilities:Electric,Duke Energy,, +2024-08-08,9294,Automotive:Gasoline,Kroger,,Truck +2024-08-08,5430,Pets:Food,Chewy,Dog food, +2024-08-10,14525,Utilities:Phone,Phone Company,Family plan, +2024-08-10,9713,Automotive:Gasoline,Shell,,Truck +2024-08-11,10700,Groceries:Food,Aldi,, +2024-08-11,5768,Groceries:Supplies,Costco,, +2024-08-11,4679,Entertainment:Activities,Cincinnati Zoo,, +2024-08-12,6235,Utilities:Water,City Water,, +2024-08-12,18417,Groceries:Food,Costco,, +2024-08-12,731,Entertainment:Food,Casey's,, +2024-08-13,4613,Entertainment:Food,LaRosa's,Pizza night, +2024-08-14,15211,Groceries:Food,Trader Bob's,, +2024-08-15,2899,Utilities:Trash,Rumpke,, +2024-08-15,5776,Automotive:Gasoline,Marathon,,Van +2024-08-16,7083,Clothing:Shoes,Amazon,, +2024-08-18,2431,Utilities:Gas,Duke Energy,Heating, +2024-08-18,5646,Automotive:Gasoline,Marathon,,Van +2024-08-19,4947,Clothing:Shoes,Famous Footwear,Kids, +2024-08-20,14500,Automotive:Insurance,Progressive,, +2024-08-20,1475,Entertainment:Food,Graeter's,Ice cream, +2024-08-21,1348,Entertainment:Food,Dairy Queen,Date Night, +2024-08-21,8256,Pets:Vet,All Creatures Vet,, +2024-08-22,1799,Subscriptions:Streaming:Video,Netflix,, +2024-08-22,1500,Automotive:Maintenance,Mike's Car Wash,,Van +2024-08-23,2756,Groceries:Supplies,Kroger,Toilet paper bulk, +2024-08-23,2307,Entertainment:Food,Skyline Chili,, +2024-08-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2024-08-26,13444,Groceries:Food,Trader Bob's,, +2024-08-26,3306,Entertainment:Food,Skyline Chili,, +2024-08-27,999,Subscriptions:Streaming:Video,Disney+,, +2024-08-28,1499,Subscriptions:Apps,1Password,, +2024-08-31,6806,Clothing:Apparel,Old Navy,, +2024-09-01,150101,Household:Mortgage,US Bank,, +2024-09-01,4643,Entertainment:Activities,Cincinnati Zoo,, +2024-09-03,999,Subscriptions:Apps,Apple,iCloud storage, +2024-09-05,8610,Utilities:Internet,Spectrum,, +2024-09-05,6243,Automotive:Gasoline,Kroger,,Van +2024-09-06,18271,Groceries:Food,Costco,, +2024-09-07,13578,Utilities:Electric,Duke Energy,, +2024-09-07,14506,Groceries:Food,Aldi,, +2024-09-07,6483,Clothing:Shoes,Amazon,, +2024-09-10,14085,Utilities:Phone,Phone Company,Family plan, +2024-09-10,14817,Groceries:Food,Trader Bob's,, +2024-09-11,3200,Personal:Haircut,Great Clips,, +2024-09-12,6164,Utilities:Water,City Water,, +2024-09-12,713,Entertainment:Food,Casey's,, +2024-09-12,3978,Entertainment:Movies,AMC Theatres,Family movie, +2024-09-15,2899,Utilities:Trash,Rumpke,, +2024-09-15,8697,Automotive:Gasoline,Marathon,,Truck +2024-09-16,2643,Entertainment:Food,Skyline Chili,, +2024-09-17,905,Entertainment:Food,Casey's,, +2024-09-18,3210,Utilities:Gas,Duke Energy,Heating, +2024-09-18,5866,Automotive:Gasoline,Marathon,,Van +2024-09-20,14500,Automotive:Insurance,Progressive,, +2024-09-20,9778,Groceries:Food,Aldi,, +2024-09-20,12176,Household:Improvement,Home Depot,, +2024-09-21,6187,Automotive:Gasoline,Circle K,,Van +2024-09-21,2254,Entertainment:Food,Panera,Lunch, +2024-09-21,5592,Pets:Food,Chewy,Dog food, +2024-09-22,1799,Subscriptions:Streaming:Video,Netflix,, +2024-09-22,3478,Clothing:Accessories,Macy's,, +2024-09-23,4885,Groceries:Supplies,Costco,Paper towels & detergent, +2024-09-23,7991,Automotive:Gasoline,Marathon,,Truck +2024-09-23,3922,Clothing:Apparel,Amazon,, +2024-09-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2024-09-26,4977,Entertainment:Food,El Corral,Family dinner, +2024-09-27,999,Subscriptions:Streaming:Video,Disney+,, +2024-09-28,1499,Subscriptions:Apps,1Password,, +2024-09-28,1176,Entertainment:Food,Dairy Queen,Date Night, +2024-09-29,5553,Entertainment:Food,El Corral,Family dinner, +2024-10-01,150101,Household:Mortgage,US Bank,, +2024-10-03,999,Subscriptions:Apps,Apple,iCloud storage, +2024-10-03,5930,Clothing:Apparel,Amazon,, +2024-10-04,4374,Entertainment:Food,LaRosa's,Pizza night, +2024-10-05,8425,Utilities:Internet,Spectrum,, +2024-10-05,13980,Groceries:Food,Trader Bob's,, +2024-10-05,4377,Entertainment:Food,LaRosa's,Pizza night, +2024-10-07,9784,Utilities:Electric,Duke Energy,, +2024-10-07,21144,Groceries:Food,Costco,, +2024-10-07,2664,Entertainment:Food,Panera,Lunch, +2024-10-10,14891,Utilities:Phone,Phone Company,Family plan, +2024-10-12,5859,Utilities:Water,City Water,, +2024-10-13,5525,Automotive:Gasoline,Casey's,,Van +2024-10-13,5975,Pets:Food,Chewy,Dog food, +2024-10-15,2899,Utilities:Trash,Rumpke,, +2024-10-15,2650,Entertainment:Food,Chipotle,, +2024-10-15,6850,Automotive:Maintenance,Valvoline,Oil change,Van +2024-10-16,6192,Automotive:Gasoline,Circle K,,Van +2024-10-18,4855,Utilities:Gas,Duke Energy,Heating, +2024-10-20,14500,Automotive:Insurance,Progressive,, +2024-10-20,8106,Clothing:Apparel,Amazon,, +2024-10-21,2780,Entertainment:Food,Panera,Lunch, +2024-10-22,1799,Subscriptions:Streaming:Video,Netflix,, +2024-10-23,4400,Groceries:Supplies,Costco,Trash bags & foil, +2024-10-23,1097,Entertainment:Food,Dairy Queen,Date Night, +2024-10-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2024-10-25,1149,Entertainment:Food,Dairy Queen,Date Night, +2024-10-26,6776,Automotive:Gasoline,Kroger,,Van +2024-10-27,999,Subscriptions:Streaming:Video,Disney+,, +2024-10-27,12128,Groceries:Food,Kroger,, +2024-10-28,1499,Subscriptions:Apps,1Password,, +2024-10-28,16093,Household:Repairs,Lowe's,, +2024-11-01,150101,Household:Mortgage,US Bank,, +2024-11-03,999,Subscriptions:Apps,Apple,iCloud storage, +2024-11-03,10701,Groceries:Food,Aldi,, +2024-11-04,3384,Groceries:Supplies,Target,Cleaning supplies, +2024-11-05,8413,Utilities:Internet,Spectrum,, +2024-11-05,15943,Groceries:Food,Costco,, +2024-11-07,10421,Utilities:Electric,Duke Energy,, +2024-11-07,15574,Groceries:Food,Aldi,, +2024-11-08,1979,Entertainment:Food,Panera,Lunch, +2024-11-10,14258,Utilities:Phone,Phone Company,Family plan, +2024-11-12,4960,Utilities:Water,City Water,, +2024-11-12,10800,Groceries:Food,Kroger,, +2024-11-12,787,Entertainment:Food,Casey's,, +2024-11-14,4000,Healthcare:Chiropractic,City Chiro,Jimmy adjustment, +2024-11-14,6031,Pets:Food,Chewy,Dog food, +2024-11-15,2899,Utilities:Trash,Rumpke,, +2024-11-15,1349,Entertainment:Food,Graeter's,Ice cream, +2024-11-18,6469,Utilities:Gas,Duke Energy,Heating, +2024-11-18,2977,Groceries:Supplies,Kroger,Trash bags & foil, +2024-11-18,6541,Automotive:Gasoline,Shell,,Van +2024-11-20,14500,Automotive:Insurance,Progressive,, +2024-11-20,7806,Automotive:Gasoline,Kroger,,Truck +2024-11-22,1799,Subscriptions:Streaming:Video,Netflix,, +2024-11-23,2573,Clothing:Accessories,Macy's,, +2024-11-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2024-11-25,5281,Clothing:Shoes,Famous Footwear,, +2024-11-26,9178,Clothing:Apparel,Target,, +2024-11-27,999,Subscriptions:Streaming:Video,Disney+,, +2024-11-27,6389,Automotive:Gasoline,Marathon,,Van +2024-11-28,1499,Subscriptions:Apps,1Password,, +2024-11-29,1195,Entertainment:Food,Dairy Queen,Date Night, +2024-11-30,7400,Automotive:Maintenance,Tire Discounters,Tire rotation & alignment,Truck +2024-12-01,150101,Household:Mortgage,US Bank,, +2024-12-02,21459,Groceries:Food,Costco,, +2024-12-02,5753,Clothing:Apparel,Old Navy,, +2024-12-03,999,Subscriptions:Apps,Apple,iCloud storage, +2024-12-05,8454,Utilities:Internet,Spectrum,, +2024-12-05,9246,Automotive:Gasoline,Marathon,,Truck +2024-12-05,8629,Gifts:Holiday,Amazon,Christmas, +2024-12-05,10518,Gifts:Holiday,Amazon,Christmas, +2024-12-06,14343,Groceries:Food,Aldi,, +2024-12-06,6801,Gifts:Holiday,Target,Christmas, +2024-12-07,13602,Utilities:Electric,Duke Energy,, +2024-12-09,5417,Entertainment:Movies,AMC Theatres,Family movie, +2024-12-10,14824,Utilities:Phone,Phone Company,Family plan, +2024-12-12,5331,Utilities:Water,City Water,, +2024-12-13,8009,Automotive:Gasoline,Circle K,,Truck +2024-12-13,3969,Gifts:Holiday,Best Buy,Christmas, +2024-12-14,2839,Entertainment:Food,Panera,Lunch, +2024-12-15,2899,Utilities:Trash,Rumpke,, +2024-12-15,2250,Entertainment:Food,Panera,Lunch, +2024-12-15,3315,Clothing:Accessories,Macy's,, +2024-12-16,3730,Groceries:Supplies,Kroger,Trash bags & foil, +2024-12-16,5366,Pets:Food,Chewy,Dog food, +2024-12-16,4705,Gifts:Holiday,Amazon,Christmas, +2024-12-17,4766,Household:Improvement,Home Depot,, +2024-12-18,9250,Utilities:Gas,Duke Energy,Heating, +2024-12-18,7712,Gifts:Holiday,Amazon,Christmas, +2024-12-18,4769,Gifts:Holiday,Amazon,Christmas, +2024-12-19,1910,Entertainment:Food,Graeter's,Ice cream, +2024-12-19,12978,Clothing:Shoes,Amazon,, +2024-12-20,14500,Automotive:Insurance,Progressive,, +2024-12-20,3498,Groceries:Supplies,Target,Cleaning supplies, +2024-12-21,16655,Household:Repairs,Lowe's,, +2024-12-21,3781,Gifts:Holiday,Best Buy,Christmas, +2024-12-22,1799,Subscriptions:Streaming:Video,Netflix,, +2024-12-22,7800,Automotive:Gasoline,Circle K,,Truck +2024-12-23,6513,Automotive:Gasoline,Kroger,,Van +2024-12-23,7976,Clothing:Apparel,Kohl's,, +2024-12-24,12016,Groceries:Food,Trader Bob's,, +2024-12-24,2914,Entertainment:Food,Chipotle,, +2024-12-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2024-12-25,11908,Groceries:Food,Aldi,, +2024-12-25,3816,Entertainment:Food,Chipotle,, +2024-12-27,999,Subscriptions:Streaming:Video,Disney+,, +2024-12-27,3415,Entertainment:Food,LaRosa's,Pizza night, +2024-12-28,1499,Subscriptions:Apps,1Password,, +2024-12-29,2681,Entertainment:Food,Panera,Lunch, +2025-01-01,150101,Household:Mortgage,US Bank,, +2025-01-03,999,Subscriptions:Apps,Apple,iCloud storage, +2025-01-03,1012,Entertainment:Food,Dairy Queen,Date Night, +2025-01-05,8408,Utilities:Internet,Spectrum,, +2025-01-06,2877,Groceries:Supplies,Target,Trash bags & foil, +2025-01-06,1296,Entertainment:Food,Graeter's,Ice cream, +2025-01-07,13902,Utilities:Electric,Duke Energy,, +2025-01-07,3345,Clothing:Apparel,Kohl's,, +2025-01-08,3614,Personal:Haircut,Great Clips,, +2025-01-09,7402,Automotive:Gasoline,Circle K,,Truck +2025-01-10,14150,Utilities:Phone,Phone Company,Family plan, +2025-01-12,6036,Utilities:Water,City Water,, +2025-01-14,8392,Automotive:Gasoline,Shell,,Truck +2025-01-15,2899,Utilities:Trash,Rumpke,, +2025-01-15,1805,Healthcare:Pharmacy,CVS,, +2025-01-16,7411,Automotive:Gasoline,Casey's,,Truck +2025-01-17,15721,Groceries:Food,Aldi,, +2025-01-18,12139,Utilities:Gas,Duke Energy,Heating, +2025-01-19,5500,Automotive:Maintenance,BMV,Registration renewal,Van +2025-01-20,14500,Automotive:Insurance,Progressive,, +2025-01-20,10033,Groceries:Food,Aldi,, +2025-01-22,1799,Subscriptions:Streaming:Video,Netflix,, +2025-01-23,10861,Groceries:Food,Kroger,, +2025-01-24,6139,Clothing:Apparel,Amazon,, +2025-01-24,5205,Pets:Food,Chewy,Dog food, +2025-01-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2025-01-26,3247,Groceries:Supplies,Target,Cleaning supplies, +2025-01-26,7799,Automotive:Gasoline,Circle K,,Truck +2025-01-26,1245,Entertainment:Food,Dairy Queen,Date Night, +2025-01-26,5643,Clothing:Accessories,Macy's,, +2025-01-27,999,Subscriptions:Streaming:Video,Disney+,, +2025-01-27,3779,Entertainment:Food,Chipotle,, +2025-01-28,1499,Subscriptions:Apps,1Password,, +2025-01-30,2116,Entertainment:Food,Panera,Lunch, +2025-01-31,4000,Healthcare:Chiropractic,City Chiro,Jimmy adjustment, +2025-02-01,150101,Household:Mortgage,US Bank,, +2025-02-02,9413,Automotive:Gasoline,Kroger,,Truck +2025-02-02,4013,Entertainment:Food,LaRosa's,Pizza night, +2025-02-02,4872,Clothing:Accessories,Macy's,, +2025-02-03,999,Subscriptions:Apps,Apple,iCloud storage, +2025-02-04,15620,Groceries:Food,Aldi,, +2025-02-05,8415,Utilities:Internet,Spectrum,, +2025-02-07,13568,Utilities:Electric,Duke Energy,, +2025-02-07,11206,Groceries:Food,Kroger,, +2025-02-08,8264,Automotive:Gasoline,Shell,,Truck +2025-02-08,4379,Clothing:Shoes,Famous Footwear,, +2025-02-10,14836,Utilities:Phone,Phone Company,Family plan, +2025-02-12,5371,Utilities:Water,City Water,, +2025-02-14,3662,Personal:Haircut,Great Clips,, +2025-02-15,2899,Utilities:Trash,Rumpke,, +2025-02-15,4053,Groceries:Supplies,Costco,Paper towels & detergent, +2025-02-15,4166,Entertainment:Food,LaRosa's,Pizza night, +2025-02-18,9178,Utilities:Gas,Duke Energy,Heating, +2025-02-18,1001,Entertainment:Food,Dairy Queen,Date Night, +2025-02-18,8068,Clothing:Apparel,Kohl's,, +2025-02-20,14500,Automotive:Insurance,Progressive,, +2025-02-21,7044,Automotive:Gasoline,Circle K,,Van +2025-02-22,1799,Subscriptions:Streaming:Video,Netflix,, +2025-02-22,8729,Automotive:Gasoline,Circle K,,Truck +2025-02-22,4494,Entertainment:Movies,AMC Theatres,Family movie, +2025-02-22,6499,Automotive:Maintenance,Valvoline,Oil change,Truck +2025-02-23,14462,Groceries:Food,Kroger,, +2025-02-23,2684,Groceries:Supplies,Target,Trash bags & foil, +2025-02-24,12821,Groceries:Food,Aldi,, +2025-02-24,2128,Entertainment:Activities,Cincinnati Zoo,, +2025-02-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2025-02-25,4321,Clothing:Apparel,Target,, +2025-02-26,840,Entertainment:Food,Casey's,, +2025-02-27,999,Subscriptions:Streaming:Video,Disney+,, +2025-02-28,1499,Subscriptions:Apps,1Password,, +2025-03-01,150101,Household:Mortgage,US Bank,, +2025-03-01,5961,Pets:Food,Chewy,Dog food, +2025-03-01,8090,Household:Improvement,Home Depot,, +2025-03-01,2615,Entertainment:Activities,Cincinnati Zoo,, +2025-03-02,1298,Entertainment:Food,Dairy Queen,Date Night, +2025-03-03,999,Subscriptions:Apps,Apple,iCloud storage, +2025-03-03,13773,Groceries:Food,Aldi,, +2025-03-03,6511,Automotive:Gasoline,Shell,,Van +2025-03-04,21500,Automotive:Repairs,Tire Discounters,Alternator replacement,Truck +2025-03-05,8513,Utilities:Internet,Spectrum,, +2025-03-05,2473,Groceries:Supplies,Kroger,Toilet paper bulk, +2025-03-05,4858,Entertainment:Movies,AMC Theatres,Family movie, +2025-03-07,10031,Utilities:Electric,Duke Energy,, +2025-03-08,12889,Groceries:Food,Aldi,, +2025-03-09,1371,Entertainment:Food,Dairy Queen,Date Night, +2025-03-10,14079,Utilities:Phone,Phone Company,Family plan, +2025-03-10,3431,Clothing:Accessories,Macy's,, +2025-03-11,7380,Automotive:Gasoline,Circle K,,Truck +2025-03-12,5501,Utilities:Water,City Water,, +2025-03-13,7485,Clothing:Apparel,Old Navy,, +2025-03-13,7245,Clothing:Shoes,Famous Footwear,, +2025-03-14,4392,Entertainment:Food,LaRosa's,Pizza night, +2025-03-15,2899,Utilities:Trash,Rumpke,, +2025-03-15,4148,Clothing:Apparel,Amazon,, +2025-03-15,142500,Household:Insurance,State Farm,Annual premium, +2025-03-18,6527,Utilities:Gas,Duke Energy,Heating, +2025-03-20,14500,Automotive:Insurance,Progressive,, +2025-03-21,9564,Automotive:Gasoline,Casey's,,Truck +2025-03-22,1799,Subscriptions:Streaming:Video,Netflix,, +2025-03-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2025-03-25,6365,Entertainment:Food,El Corral,Family dinner, +2025-03-27,999,Subscriptions:Streaming:Video,Disney+,, +2025-03-27,5384,Automotive:Gasoline,Kroger,,Van +2025-03-28,1499,Subscriptions:Apps,1Password,, +2025-03-28,10237,Groceries:Food,Trader Bob's,, +2025-03-29,14822,Groceries:Food,Kroger,, +2025-03-29,7566,Automotive:Gasoline,Marathon,,Truck +2025-03-31,3874,Personal:Haircut,Great Clips,, +2025-04-01,150101,Household:Mortgage,US Bank,, +2025-04-02,5700,Clothing:Shoes,Amazon,Kids, +2025-04-02,4000,Healthcare:Chiropractic,City Chiro,Joann adjustment, +2025-04-03,999,Subscriptions:Apps,Apple,iCloud storage, +2025-04-04,698,Entertainment:Food,Casey's,, +2025-04-05,8431,Utilities:Internet,Spectrum,, +2025-04-07,10779,Utilities:Electric,Duke Energy,, +2025-04-07,5796,Automotive:Gasoline,Shell,,Van +2025-04-07,4013,Entertainment:Food,LaRosa's,Pizza night, +2025-04-07,1416,Clothing:Accessories,Macy's,, +2025-04-09,14913,Groceries:Food,Kroger,, +2025-04-10,14340,Utilities:Phone,Phone Company,Family plan, +2025-04-10,7398,Automotive:Gasoline,Shell,,Truck +2025-04-10,12031,Clothing:Shoes,Amazon,, +2025-04-11,6012,Pets:Food,Chewy,Dog food, +2025-04-12,5373,Utilities:Water,City Water,, +2025-04-12,1127,Entertainment:Food,Dairy Queen,Date Night, +2025-04-12,2860,Personal:Haircut,Great Clips,, +2025-04-12,2899,Automotive:Maintenance,AutoZone,Wipers & coolant,Van +2025-04-14,1716,Entertainment:Food,Graeter's,Ice cream, +2025-04-15,2899,Utilities:Trash,Rumpke,, +2025-04-15,9542,Automotive:Gasoline,Circle K,,Truck +2025-04-16,3061,Entertainment:Food,Skyline Chili,, +2025-04-17,9600,Groceries:Food,Kroger,, +2025-04-18,4734,Utilities:Gas,Duke Energy,Heating, +2025-04-19,1438,Entertainment:Food,Dairy Queen,Date Night, +2025-04-20,14500,Automotive:Insurance,Progressive,, +2025-04-22,1799,Subscriptions:Streaming:Video,Netflix,, +2025-04-23,3949,Groceries:Supplies,Kroger,Toilet paper bulk, +2025-04-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2025-04-25,13674,Groceries:Food,Trader Bob's,, +2025-04-26,6122,Clothing:Apparel,Amazon,, +2025-04-27,999,Subscriptions:Streaming:Video,Disney+,, +2025-04-27,3201,Entertainment:Food,Skyline Chili,, +2025-04-27,7544,Household:Repairs,Lowe's,, +2025-04-28,1499,Subscriptions:Apps,1Password,, +2025-05-01,150101,Household:Mortgage,US Bank,, +2025-05-02,14141,Groceries:Food,Kroger,, +2025-05-02,7396,Automotive:Gasoline,Marathon,,Truck +2025-05-03,999,Subscriptions:Apps,Apple,iCloud storage, +2025-05-05,8624,Utilities:Internet,Spectrum,, +2025-05-07,10035,Utilities:Electric,Duke Energy,, +2025-05-07,7232,Automotive:Gasoline,Shell,,Truck +2025-05-07,4705,Entertainment:Food,LaRosa's,Pizza night, +2025-05-08,7337,Automotive:Gasoline,Shell,,Truck +2025-05-08,8711,Clothing:Apparel,Old Navy,, +2025-05-09,686,Entertainment:Food,Casey's,, +2025-05-10,14260,Utilities:Phone,Phone Company,Family plan, +2025-05-10,4302,Clothing:Accessories,Macy's,, +2025-05-10,5142,Pets:Food,Chewy,Dog food, +2025-05-12,6140,Utilities:Water,City Water,, +2025-05-12,988,Entertainment:Food,Casey's,, +2025-05-13,4201,Clothing:Shoes,Amazon,, +2025-05-14,3298,Entertainment:Food,Skyline Chili,, +2025-05-15,2899,Utilities:Trash,Rumpke,, +2025-05-15,12602,Groceries:Food,Trader Bob's,, +2025-05-15,3151,Groceries:Supplies,Kroger,Paper towels & detergent, +2025-05-16,3000,Entertainment:Food,Skyline Chili,, +2025-05-17,26494,Groceries:Food,Costco,, +2025-05-18,3209,Utilities:Gas,Duke Energy,Heating, +2025-05-19,4000,Healthcare:Chiropractic,City Chiro,Jimmy adjustment, +2025-05-20,14500,Automotive:Insurance,Progressive,, +2025-05-20,8972,Clothing:Apparel,Kohl's,, +2025-05-22,1799,Subscriptions:Streaming:Video,Netflix,, +2025-05-23,3205,Groceries:Supplies,Kroger,Paper towels & detergent, +2025-05-23,5989,Automotive:Gasoline,Kroger,,Van +2025-05-23,2645,Entertainment:Food,Panera,Lunch, +2025-05-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2025-05-25,18619,Groceries:Food,Costco,, +2025-05-26,6658,Pets:Vet,All Creatures Vet,, +2025-05-26,3537,Personal:Haircut,Great Clips,, +2025-05-27,999,Subscriptions:Streaming:Video,Disney+,, +2025-05-28,1499,Subscriptions:Apps,1Password,, +2025-05-30,12467,Household:Improvement,Home Depot,, +2025-05-31,3488,Healthcare:Pharmacy,CVS,, +2025-06-01,150101,Household:Mortgage,US Bank,, +2025-06-01,2292,Entertainment:Food,Panera,Lunch, +2025-06-02,3017,Personal:Haircut,Great Clips,, +2025-06-03,999,Subscriptions:Apps,Apple,iCloud storage, +2025-06-03,8236,Clothing:Apparel,Target,, +2025-06-05,8552,Utilities:Internet,Spectrum,, +2025-06-05,839,Entertainment:Food,Casey's,, +2025-06-06,6885,Groceries:Supplies,Costco,Toilet paper bulk, +2025-06-06,8093,Automotive:Gasoline,Shell,,Truck +2025-06-07,14323,Utilities:Electric,Duke Energy,, +2025-06-08,17795,Groceries:Food,Costco,, +2025-06-08,5813,Entertainment:Food,El Corral,Family dinner, +2025-06-08,6750,Automotive:Maintenance,Valvoline,Oil change,Van +2025-06-09,8029,Automotive:Gasoline,Marathon,,Truck +2025-06-10,14455,Utilities:Phone,Phone Company,Family plan, +2025-06-12,5853,Utilities:Water,City Water,, +2025-06-12,8674,Clothing:Shoes,Amazon,, +2025-06-12,5084,Clothing:Shoes,Famous Footwear,Kids, +2025-06-13,8505,Automotive:Gasoline,Shell,,Truck +2025-06-13,2813,Entertainment:Food,Skyline Chili,, +2025-06-14,1491,Clothing:Accessories,Macy's,, +2025-06-14,9476,Household:Repairs,Lowe's,, +2025-06-15,2899,Utilities:Trash,Rumpke,, +2025-06-15,15644,Groceries:Food,Aldi,, +2025-06-18,2628,Utilities:Gas,Duke Energy,Heating, +2025-06-18,5600,Pets:Food,Chewy,Dog food, +2025-06-20,14500,Automotive:Insurance,Progressive,, +2025-06-20,4000,Healthcare:Chiropractic,City Chiro,Jimmy adjustment, +2025-06-21,8076,Clothing:Apparel,Target,, +2025-06-22,1799,Subscriptions:Streaming:Video,Netflix,, +2025-06-22,6190,Automotive:Gasoline,Kroger,,Van +2025-06-22,3175,Healthcare:Pharmacy,CVS,, +2025-06-22,5045,Household:Improvement,Home Depot,, +2025-06-23,772,Entertainment:Food,Casey's,, +2025-06-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2025-06-25,20726,Groceries:Food,Costco,, +2025-06-25,6695,Automotive:Gasoline,Circle K,,Van +2025-06-25,6073,Entertainment:Movies,AMC Theatres,Family movie, +2025-06-26,1651,Entertainment:Food,Graeter's,Ice cream, +2025-06-27,999,Subscriptions:Streaming:Video,Disney+,, +2025-06-28,1499,Subscriptions:Apps,1Password,, +2025-06-28,2885,Entertainment:Food,Chipotle,, +2025-07-01,150101,Household:Mortgage,US Bank,, +2025-07-03,999,Subscriptions:Apps,Apple,iCloud storage, +2025-07-03,14471,Groceries:Food,Kroger,, +2025-07-04,11156,Groceries:Food,Kroger,, +2025-07-04,8348,Automotive:Gasoline,Marathon,,Truck +2025-07-05,8475,Utilities:Internet,Spectrum,, +2025-07-06,6701,Automotive:Gasoline,Circle K,,Van +2025-07-07,14956,Utilities:Electric,Duke Energy,, +2025-07-07,1287,Entertainment:Food,Dairy Queen,Date Night, +2025-07-10,14309,Utilities:Phone,Phone Company,Family plan, +2025-07-11,6365,Automotive:Gasoline,Kroger,,Van +2025-07-11,3282,Entertainment:Food,Skyline Chili,, +2025-07-12,6261,Utilities:Water,City Water,, +2025-07-12,4000,Healthcare:Chiropractic,City Chiro,Joann adjustment, +2025-07-15,2899,Utilities:Trash,Rumpke,, +2025-07-17,5929,Groceries:Supplies,Costco,Paper towels & detergent, +2025-07-18,2617,Utilities:Gas,Duke Energy,Heating, +2025-07-20,14500,Automotive:Insurance,Progressive,, +2025-07-20,9835,Groceries:Food,Kroger,, +2025-07-20,1500,Automotive:Maintenance,Mike's Car Wash,,Truck +2025-07-21,3832,Groceries:Supplies,Target,Paper towels & detergent, +2025-07-21,16185,Pets:Vet,All Creatures Vet,, +2025-07-22,1799,Subscriptions:Streaming:Video,Netflix,, +2025-07-22,12960,Groceries:Food,Kroger,, +2025-07-22,4409,Clothing:Apparel,Target,, +2025-07-23,6185,Entertainment:Movies,AMC Theatres,Family movie, +2025-07-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2025-07-25,9279,Clothing:Apparel,Kohl's,, +2025-07-27,999,Subscriptions:Streaming:Video,Disney+,, +2025-07-27,1197,Entertainment:Food,Dairy Queen,Date Night, +2025-07-27,21832,Household:Repairs,Lowe's,, +2025-07-28,1499,Subscriptions:Apps,1Password,, +2025-07-28,1218,Entertainment:Food,Dairy Queen,Date Night, +2025-07-28,4931,Clothing:Shoes,Amazon,Kids, +2025-07-30,6014,Pets:Food,Chewy,Dog food, +2025-08-01,150101,Household:Mortgage,US Bank,, +2025-08-02,1444,Clothing:Accessories,Macy's,, +2025-08-03,999,Subscriptions:Apps,Apple,iCloud storage, +2025-08-04,12555,Pets:Vet,All Creatures Vet,, +2025-08-05,8646,Utilities:Internet,Spectrum,, +2025-08-05,19022,Groceries:Food,Costco,, +2025-08-05,4155,Entertainment:Movies,AMC Theatres,Family movie, +2025-08-06,10431,Groceries:Food,Kroger,, +2025-08-06,6489,Automotive:Gasoline,Casey's,,Van +2025-08-06,2437,Entertainment:Food,Skyline Chili,, +2025-08-07,17266,Utilities:Electric,Duke Energy,, +2025-08-08,2243,Entertainment:Food,Panera,Lunch, +2025-08-09,6163,Automotive:Gasoline,Kroger,,Van +2025-08-09,5295,Pets:Food,Chewy,Dog food, +2025-08-10,14534,Utilities:Phone,Phone Company,Family plan, +2025-08-12,6075,Utilities:Water,City Water,, +2025-08-13,2381,Entertainment:Food,Skyline Chili,, +2025-08-14,2267,Groceries:Supplies,Kroger,Paper towels & detergent, +2025-08-14,3214,Healthcare:Pharmacy,CVS,, +2025-08-15,2899,Utilities:Trash,Rumpke,, +2025-08-17,7652,Automotive:Gasoline,Circle K,,Truck +2025-08-17,8627,Clothing:Apparel,Kohl's,, +2025-08-18,2796,Utilities:Gas,Duke Energy,Heating, +2025-08-18,10338,Groceries:Food,Kroger,, +2025-08-19,3930,Groceries:Supplies,Amazon,Trash bags & foil, +2025-08-20,14500,Automotive:Insurance,Progressive,, +2025-08-20,4940,Clothing:Apparel,Old Navy,, +2025-08-21,1497,Entertainment:Food,Graeter's,Ice cream, +2025-08-21,4000,Healthcare:Chiropractic,City Chiro,Joann adjustment, +2025-08-22,1799,Subscriptions:Streaming:Video,Netflix,, +2025-08-23,3525,Entertainment:Food,Chipotle,, +2025-08-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2025-08-26,9308,Automotive:Gasoline,Kroger,,Truck +2025-08-27,999,Subscriptions:Streaming:Video,Disney+,, +2025-08-27,13919,Groceries:Food,Trader Bob's,, +2025-08-28,1499,Subscriptions:Apps,1Password,, +2025-08-29,12400,Automotive:Repairs,AutoZone,Battery & starter check,Van +2025-09-01,150101,Household:Mortgage,US Bank,, +2025-09-01,2963,Entertainment:Food,Skyline Chili,, +2025-09-03,999,Subscriptions:Apps,Apple,iCloud storage, +2025-09-03,6604,Automotive:Gasoline,Casey's,,Van +2025-09-04,6614,Clothing:Apparel,Kohl's,, +2025-09-04,7013,Clothing:Apparel,Old Navy,, +2025-09-05,8451,Utilities:Internet,Spectrum,, +2025-09-05,11528,Household:Repairs,Lowe's,, +2025-09-06,15737,Groceries:Food,Aldi,, +2025-09-07,14050,Utilities:Electric,Duke Energy,, +2025-09-09,7929,Automotive:Gasoline,Kroger,,Truck +2025-09-10,14386,Utilities:Phone,Phone Company,Family plan, +2025-09-10,2304,Groceries:Supplies,Target,Toilet paper bulk, +2025-09-10,1800,Entertainment:Food,Graeter's,Ice cream, +2025-09-11,7285,Automotive:Gasoline,Kroger,,Truck +2025-09-12,5143,Utilities:Water,City Water,, +2025-09-12,14249,Groceries:Food,Trader Bob's,, +2025-09-14,6299,Automotive:Maintenance,Valvoline,Oil change,Truck +2025-09-15,2899,Utilities:Trash,Rumpke,, +2025-09-17,11521,Groceries:Food,Kroger,, +2025-09-18,3259,Utilities:Gas,Duke Energy,Heating, +2025-09-18,1249,Entertainment:Food,Dairy Queen,Date Night, +2025-09-19,7633,Automotive:Gasoline,Shell,,Truck +2025-09-19,1102,Entertainment:Food,Dairy Queen,Date Night, +2025-09-20,14500,Automotive:Insurance,Progressive,, +2025-09-22,1799,Subscriptions:Streaming:Video,Netflix,, +2025-09-24,1183,Entertainment:Food,Dairy Queen,Date Night, +2025-09-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2025-09-27,999,Subscriptions:Streaming:Video,Disney+,, +2025-09-28,1499,Subscriptions:Apps,1Password,, +2025-10-01,150101,Household:Mortgage,US Bank,, +2025-10-01,4056,Entertainment:Food,LaRosa's,Pizza night, +2025-10-02,5416,Pets:Food,Chewy,Dog food, +2025-10-03,999,Subscriptions:Apps,Apple,iCloud storage, +2025-10-03,8399,Clothing:Apparel,Kohl's,, +2025-10-03,7241,Clothing:Shoes,Amazon,, +2025-10-04,9523,Automotive:Gasoline,Kroger,,Truck +2025-10-05,8657,Utilities:Internet,Spectrum,, +2025-10-06,14293,Groceries:Food,Kroger,, +2025-10-06,2558,Entertainment:Food,Skyline Chili,, +2025-10-07,9185,Utilities:Electric,Duke Energy,, +2025-10-08,9457,Automotive:Gasoline,Kroger,,Truck +2025-10-09,13587,Groceries:Food,Kroger,, +2025-10-09,5721,Automotive:Gasoline,Casey's,,Van +2025-10-09,3249,Entertainment:Food,Chipotle,, +2025-10-10,14090,Utilities:Phone,Phone Company,Family plan, +2025-10-11,6499,Automotive:Maintenance,Valvoline,Oil change,Truck +2025-10-12,6448,Utilities:Water,City Water,, +2025-10-12,3985,Groceries:Supplies,Target,Paper towels & detergent, +2025-10-15,2899,Utilities:Trash,Rumpke,, +2025-10-15,4703,Entertainment:Activities,Cincinnati Zoo,, +2025-10-18,4489,Utilities:Gas,Duke Energy,Heating, +2025-10-18,6350,Clothing:Apparel,Target,, +2025-10-19,1007,Entertainment:Food,Dairy Queen,Date Night, +2025-10-19,4403,Groceries:Supplies,Costco,Trash bags & foil, +2025-10-20,14500,Automotive:Insurance,Progressive,, +2025-10-22,1799,Subscriptions:Streaming:Video,Netflix,, +2025-10-23,3558,Entertainment:Food,LaRosa's,Pizza night, +2025-10-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2025-10-25,9010,Groceries:Food,Trader Bob's,, +2025-10-25,1632,Entertainment:Food,Graeter's,Ice cream, +2025-10-26,2328,Entertainment:Food,Skyline Chili,, +2025-10-27,999,Subscriptions:Streaming:Video,Disney+,, +2025-10-28,1499,Subscriptions:Apps,1Password,, +2025-10-28,4000,Healthcare:Chiropractic,City Chiro,Bob adjustment, +2025-11-01,150101,Household:Mortgage,US Bank,, +2025-11-01,3339,Personal:Haircut,Great Clips,, +2025-11-02,3739,Groceries:Supplies,Target,Toilet paper bulk, +2025-11-03,999,Subscriptions:Apps,Apple,iCloud storage, +2025-11-04,4094,Groceries:Supplies,Kroger,Paper towels & detergent, +2025-11-05,8413,Utilities:Internet,Spectrum,, +2025-11-05,3117,Entertainment:Food,Skyline Chili,, +2025-11-07,11272,Utilities:Electric,Duke Energy,, +2025-11-07,10088,Groceries:Food,Aldi,, +2025-11-08,4101,Entertainment:Food,LaRosa's,Pizza night, +2025-11-08,1500,Automotive:Maintenance,Mike's Car Wash,,Van +2025-11-09,8163,Automotive:Gasoline,Marathon,,Truck +2025-11-09,2843,Entertainment:Food,Chipotle,, +2025-11-09,5985,Entertainment:Movies,AMC Theatres,Family movie, +2025-11-10,14428,Utilities:Phone,Phone Company,Family plan, +2025-11-10,3880,Entertainment:Activities,Cincinnati Zoo,, +2025-11-12,5359,Utilities:Water,City Water,, +2025-11-12,8517,Automotive:Gasoline,Kroger,,Truck +2025-11-13,2013,Entertainment:Food,Panera,Lunch, +2025-11-13,8131,Clothing:Apparel,Amazon,, +2025-11-15,2899,Utilities:Trash,Rumpke,, +2025-11-15,4849,Entertainment:Food,El Corral,Family dinner, +2025-11-18,7035,Utilities:Gas,Duke Energy,Heating, +2025-11-19,5818,Automotive:Gasoline,Kroger,,Van +2025-11-20,14500,Automotive:Insurance,Progressive,, +2025-11-21,11858,Groceries:Food,Trader Bob's,, +2025-11-21,1001,Entertainment:Food,Casey's,, +2025-11-22,1799,Subscriptions:Streaming:Video,Netflix,, +2025-11-22,25185,Groceries:Food,Costco,, +2025-11-23,8089,Automotive:Gasoline,Circle K,,Truck +2025-11-24,5276,Household:Improvement,Home Depot,, +2025-11-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2025-11-26,896,Entertainment:Food,Casey's,, +2025-11-27,999,Subscriptions:Streaming:Video,Disney+,, +2025-11-28,1499,Subscriptions:Apps,1Password,, +2025-11-28,5705,Automotive:Gasoline,Kroger,,Van +2025-11-29,2249,Clothing:Accessories,Macy's,, +2025-12-01,150101,Household:Mortgage,US Bank,, +2025-12-01,5095,Pets:Food,Chewy,Dog food, +2025-12-02,5760,Automotive:Gasoline,Shell,,Van +2025-12-03,999,Subscriptions:Apps,Apple,iCloud storage, +2025-12-03,4000,Healthcare:Chiropractic,City Chiro,Bob adjustment, +2025-12-03,3914,Household:Improvement,Home Depot,, +2025-12-03,10454,Gifts:Holiday,Amazon,Christmas, +2025-12-05,8586,Utilities:Internet,Spectrum,, +2025-12-05,8211,Automotive:Gasoline,Kroger,,Truck +2025-12-05,2482,Entertainment:Food,Panera,Lunch, +2025-12-05,3969,Personal:Haircut,Great Clips,, +2025-12-05,4197,Clothing:Shoes,Famous Footwear,, +2025-12-06,9147,Gifts:Holiday,Amazon,Christmas, +2025-12-07,12743,Utilities:Electric,Duke Energy,, +2025-12-07,4957,Gifts:Holiday,Amazon,Christmas, +2025-12-08,4500,Entertainment:Movies,AMC Theatres,Family movie, +2025-12-09,2137,Clothing:Accessories,Macy's,, +2025-12-10,14584,Utilities:Phone,Phone Company,Family plan, +2025-12-10,7972,Gifts:Holiday,Best Buy,Christmas, +2025-12-12,5147,Utilities:Water,City Water,, +2025-12-12,2663,Groceries:Supplies,Amazon,, +2025-12-13,7575,Automotive:Gasoline,Kroger,,Truck +2025-12-13,6999,Automotive:Maintenance,Valvoline,Oil change,Van +2025-12-15,2899,Utilities:Trash,Rumpke,, +2025-12-16,10252,Gifts:Holiday,Best Buy,Christmas, +2025-12-17,6941,Automotive:Gasoline,Kroger,,Van +2025-12-18,10695,Utilities:Gas,Duke Energy,Heating, +2025-12-18,10002,Groceries:Food,Aldi,, +2025-12-18,815,Entertainment:Food,Casey's,, +2025-12-18,1971,Healthcare:Pharmacy,CVS,, +2025-12-18,2265,Groceries:Supplies,Target,Toilet paper bulk, +2025-12-20,14500,Automotive:Insurance,Progressive,, +2025-12-21,27192,Groceries:Food,Costco,, +2025-12-21,8822,Gifts:Holiday,Target,Christmas, +2025-12-22,1799,Subscriptions:Streaming:Video,Netflix,, +2025-12-22,13168,Groceries:Food,Aldi,, +2025-12-22,3793,Entertainment:Food,LaRosa's,Pizza night, +2025-12-22,6838,Clothing:Apparel,Kohl's,, +2025-12-22,5500,Gifts:Holiday,Best Buy,Christmas, +2025-12-22,6292,Gifts:Holiday,Amazon,Christmas, +2025-12-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2025-12-25,3198,Entertainment:Food,Chipotle,, +2025-12-27,999,Subscriptions:Streaming:Video,Disney+,, +2025-12-28,1499,Subscriptions:Apps,1Password,, +2025-12-28,13656,Groceries:Food,Kroger,, +2025-12-29,4241,Clothing:Apparel,Amazon,, +2026-01-01,150101,Household:Mortgage,US Bank,, +2026-01-03,999,Subscriptions:Apps,Apple,iCloud storage, +2026-01-03,985,Entertainment:Food,Dairy Queen,Date Night, +2026-01-05,8625,Utilities:Internet,Spectrum,, +2026-01-05,21639,Groceries:Food,Costco,, +2026-01-07,15357,Utilities:Electric,Duke Energy,, +2026-01-08,12575,Groceries:Food,Trader Bob's,, +2026-01-08,4782,Entertainment:Food,El Corral,Family dinner, +2026-01-10,14313,Utilities:Phone,Phone Company,Family plan, +2026-01-10,9566,Pets:Vet,All Creatures Vet,, +2026-01-12,6536,Utilities:Water,City Water,, +2026-01-12,8243,Automotive:Gasoline,Kroger,,Truck +2026-01-13,6448,Clothing:Apparel,Target,, +2026-01-15,2899,Utilities:Trash,Rumpke,, +2026-01-15,14266,Groceries:Food,Kroger,, +2026-01-15,2900,Entertainment:Food,Chipotle,, +2026-01-18,11590,Utilities:Gas,Duke Energy,Heating, +2026-01-18,4354,Entertainment:Food,LaRosa's,Pizza night, +2026-01-18,5659,Clothing:Accessories,Macy's,, +2026-01-19,4052,Entertainment:Activities,Cincinnati Zoo,, +2026-01-20,14500,Automotive:Insurance,Progressive,, +2026-01-21,5500,Automotive:Maintenance,BMV,Registration renewal,Truck +2026-01-22,1799,Subscriptions:Streaming:Video,Netflix,, +2026-01-23,9246,Groceries:Food,Kroger,, +2026-01-23,3460,Healthcare:Pharmacy,CVS,, +2026-01-24,4644,Entertainment:Movies,AMC Theatres,Family movie, +2026-01-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2026-01-25,8004,Automotive:Gasoline,Kroger,,Truck +2026-01-26,4000,Healthcare:Chiropractic,City Chiro,Joann adjustment, +2026-01-26,5946,Pets:Food,Chewy,Dog food, +2026-01-27,999,Subscriptions:Streaming:Video,Disney+,, +2026-01-27,5557,Automotive:Gasoline,Kroger,,Van +2026-01-28,1499,Subscriptions:Apps,1Password,, +2026-01-28,2138,Entertainment:Food,Panera,Lunch, +2026-01-28,3887,Personal:Haircut,Great Clips,, +2026-01-29,5645,Automotive:Gasoline,Kroger,,Van +2026-01-29,6822,Groceries:Supplies,Costco,Paper towels & detergent, +2026-01-29,32400,Automotive:Repairs,Tire Discounters,Front brake pads & rotors,Van +2026-01-30,3480,Entertainment:Food,LaRosa's,Pizza night, +2026-02-01,150101,Household:Mortgage,US Bank,, +2026-02-01,997,Entertainment:Food,Dairy Queen,Date Night, +2026-02-03,999,Subscriptions:Apps,Apple,iCloud storage, +2026-02-03,5804,Automotive:Gasoline,Kroger,,Van +2026-02-03,3596,Entertainment:Food,Chipotle,, +2026-02-04,12057,Groceries:Food,Aldi,, +2026-02-04,8511,Automotive:Gasoline,Kroger,,Truck +2026-02-04,7753,Pets:Vet,All Creatures Vet,, +2026-02-05,8410,Utilities:Internet,Spectrum,, +2026-02-07,12169,Utilities:Electric,Duke Energy,, +2026-02-10,14585,Utilities:Phone,Phone Company,Family plan, +2026-02-10,17409,Household:Repairs,Lowe's,, +2026-02-11,4193,Entertainment:Movies,AMC Theatres,Family movie, +2026-02-11,2090,Clothing:Accessories,Macy's,, +2026-02-12,5094,Utilities:Water,City Water,, +2026-02-14,2273,Groceries:Supplies,Target,Cleaning supplies, +2026-02-14,3499,Automotive:Maintenance,AutoZone,Air filter & wipers,Van +2026-02-15,2899,Utilities:Trash,Rumpke,, +2026-02-15,8312,Automotive:Gasoline,Kroger,,Truck +2026-02-15,2511,Entertainment:Food,Skyline Chili,, +2026-02-18,9345,Utilities:Gas,Duke Energy,Heating, +2026-02-19,1350,Entertainment:Food,Graeter's,Ice cream, +2026-02-20,14500,Automotive:Insurance,Progressive,, +2026-02-20,3316,Entertainment:Food,Chipotle,, +2026-02-20,3943,Groceries:Supplies,Kroger,Trash bags & foil, +2026-02-21,16043,Groceries:Food,Kroger,, +2026-02-21,5538,Automotive:Gasoline,Kroger,,Van +2026-02-22,1799,Subscriptions:Streaming:Video,Netflix,, +2026-02-22,1712,Healthcare:Pharmacy,CVS,, +2026-02-22,18599,Automotive:Repairs,AutoZone,Battery replacement,Truck +2026-02-23,13492,Groceries:Food,Aldi,, +2026-02-23,6206,Automotive:Gasoline,Kroger,,Van +2026-02-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2026-02-25,2563,Entertainment:Food,Skyline Chili,, +2026-02-26,8721,Clothing:Apparel,Target,, +2026-02-27,999,Subscriptions:Streaming:Video,Disney+,, +2026-02-27,8260,Clothing:Apparel,Kohl's,, +2026-02-28,1499,Subscriptions:Apps,1Password,, +2026-03-01,150101,Household:Mortgage,US Bank,, +2026-03-02,5865,Entertainment:Food,El Corral,Family dinner, +2026-03-03,999,Subscriptions:Apps,Apple,iCloud storage, +2026-03-05,8588,Utilities:Internet,Spectrum,, +2026-03-05,3071,Entertainment:Food,Chipotle,, +2026-03-07,11733,Utilities:Electric,Duke Energy,, +2026-03-07,6418,Automotive:Gasoline,Kroger,,Van +2026-03-07,6750,Automotive:Maintenance,Valvoline,Oil change,Truck +2026-03-10,14322,Utilities:Phone,Phone Company,Family plan, +2026-03-10,1141,Entertainment:Food,Dairy Queen,Date Night, +2026-03-12,6662,Utilities:Water,City Water,, +2026-03-12,4631,Entertainment:Food,LaRosa's,Pizza night, +2026-03-12,13558,Clothing:Shoes,Famous Footwear,, +2026-03-14,801,Entertainment:Food,Casey's,, +2026-03-15,2899,Utilities:Trash,Rumpke,, +2026-03-15,15422,Groceries:Food,Costco,, +2026-03-15,142500,Household:Insurance,State Farm,Annual premium, +2026-03-16,7683,Automotive:Gasoline,Kroger,,Truck +2026-03-18,6549,Utilities:Gas,Duke Energy,Heating, +2026-03-19,7835,Automotive:Gasoline,Kroger,,Truck +2026-03-20,14500,Automotive:Insurance,Progressive,, +2026-03-22,1799,Subscriptions:Streaming:Video,Netflix,, +2026-03-22,11482,Groceries:Food,Kroger,, +2026-03-22,5458,Pets:Food,Chewy,Dog food, +2026-03-22,3253,Groceries:Supplies,Target,Cleaning supplies, +2026-03-24,4000,Healthcare:Chiropractic,City Chiro,Bob adjustment, +2026-03-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2026-03-25,8737,Automotive:Gasoline,Kroger,,Truck +2026-03-26,6627,Clothing:Apparel,Old Navy,, +2026-03-27,999,Subscriptions:Streaming:Video,Disney+,, +2026-03-27,3985,Entertainment:Activities,Cincinnati Zoo,, +2026-03-27,3266,Personal:Haircut,Great Clips,, +2026-03-28,1499,Subscriptions:Apps,1Password,, +2026-03-28,13969,Groceries:Food,Aldi,, +2026-03-28,8134,Household:Repairs,Lowe's,, +2026-04-01,150101,Household:Mortgage,US Bank,, +2026-04-03,999,Subscriptions:Apps,Apple,iCloud storage, +2026-04-03,6762,Automotive:Gasoline,Kroger,,Van +2026-04-04,14316,Groceries:Food,Trader Bob's,, +2026-04-05,8444,Utilities:Internet,Spectrum,, +2026-04-05,6163,Automotive:Gasoline,Kroger,,Van +2026-04-05,2143,Clothing:Accessories,Macy's,, +2026-04-06,12301,Clothing:Shoes,Famous Footwear,, +2026-04-07,9401,Utilities:Electric,Duke Energy,, +2026-04-10,14740,Utilities:Phone,Phone Company,Family plan, +2026-04-10,15152,Groceries:Food,Costco,, +2026-04-10,8802,Automotive:Gasoline,Kroger,,Truck +2026-04-12,5325,Utilities:Water,City Water,, +2026-04-13,15934,Groceries:Food,Kroger,, +2026-04-13,3129,Groceries:Supplies,Target,Toilet paper bulk, +2026-04-14,6546,Clothing:Apparel,Kohl's,, +2026-04-15,2899,Utilities:Trash,Rumpke,, +2026-04-15,8268,Automotive:Gasoline,Kroger,,Truck +2026-04-15,3705,Personal:Haircut,Great Clips,, +2026-04-17,3305,Entertainment:Food,Chipotle,, +2026-04-17,8713,Clothing:Apparel,Kohl's,, +2026-04-18,4666,Utilities:Gas,Duke Energy,Heating, +2026-04-18,3503,Entertainment:Food,LaRosa's,Pizza night, +2026-04-18,4000,Healthcare:Chiropractic,City Chiro,Jimmy adjustment, +2026-04-18,3406,Household:Improvement,Home Depot,, +2026-04-19,9499,Automotive:Maintenance,Tire Discounters,Oil change & tire rotation,Van +2026-04-20,14500,Automotive:Insurance,Progressive,, +2026-04-21,9011,Automotive:Gasoline,Kroger,,Truck +2026-04-22,1799,Subscriptions:Streaming:Video,Netflix,, +2026-04-22,1918,Entertainment:Food,Graeter's,Ice cream, +2026-04-22,3425,Healthcare:Pharmacy,CVS,, +2026-04-24,5306,Pets:Food,Chewy,Dog food, +2026-04-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2026-04-26,17625,Groceries:Food,Costco,, +2026-04-26,1407,Entertainment:Food,Graeter's,Ice cream, +2026-04-26,5735,Entertainment:Movies,AMC Theatres,Family movie, +2026-04-27,999,Subscriptions:Streaming:Video,Disney+,, +2026-04-27,2462,Groceries:Supplies,Kroger,, +2026-04-28,1499,Subscriptions:Apps,1Password,, +2026-05-01,150101,Household:Mortgage,US Bank,, +2026-05-03,999,Subscriptions:Apps,Apple,iCloud storage, +2026-05-03,7188,Automotive:Gasoline,Kroger,,Van +2026-05-04,12745,Groceries:Food,Trader Bob's,, +2026-05-05,8570,Utilities:Internet,Spectrum,, +2026-05-05,3697,Personal:Haircut,Great Clips,, +2026-05-05,6500,Automotive:Gasoline,Kroger,,Truck +2026-05-06,15487,Groceries:Food,Kroger,, +2026-05-07,9655,Utilities:Electric,Duke Energy,, +2026-05-08,6849,Automotive:Gasoline,Kroger,,Van +2026-05-09,3845,Entertainment:Food,LaRosa's,Pizza night, +2026-05-10,14740,Utilities:Phone,Phone Company,Family plan, +2026-05-11,16203,Groceries:Food,Costco,, +2026-05-12,5325,Utilities:Water,City Water,, +2026-05-13,7912,Automotive:Gasoline,Kroger,,Truck +2026-05-14,8254,Clothing:Apparel,Kohl's,, +2026-05-15,2899,Utilities:Trash,Rumpke,, +2026-05-15,2856,Healthcare:Pharmacy,CVS,, +2026-05-16,13492,Groceries:Food,Kroger,, +2026-05-17,3499,Entertainment:Food,Chipotle,, +2026-05-18,4523,Utilities:Gas,Duke Energy,Heating, +2026-05-19,5306,Pets:Food,Chewy,Dog food, +2026-05-20,14500,Automotive:Insurance,Progressive,, +2026-05-21,7245,Automotive:Gasoline,Kroger,,Van +2026-05-22,1799,Subscriptions:Streaming:Video,Netflix,, +2026-05-23,5982,Entertainment:Movies,AMC Theatres,Family movie, +2026-05-24,11876,Groceries:Food,Trader Bob's,, +2026-05-25,1199,Subscriptions:Streaming:Music,Spotify,Family plan, +2026-05-26,17234,Groceries:Food,Costco,, +2026-05-26,1556,Entertainment:Food,Graeter's,Ice cream, +2026-05-27,999,Subscriptions:Streaming:Video,Disney+,, +2026-05-28,1499,Subscriptions:Apps,1Password,, +2026-05-28,8124,Automotive:Gasoline,Kroger,,Truck +2026-05-29,4000,Healthcare:Chiropractic,City Chiro,Jimmy adjustment, +2026-05-30,3845,Household:Improvement,Home Depot,, +2026-05-31,2895,Entertainment:Food,LaRosa's,Sunday dinner, diff --git a/src/common_cents/__init__.py b/src/common_cents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/common_cents/__main__.py b/src/common_cents/__main__.py new file mode 100644 index 0000000..25fb178 --- /dev/null +++ b/src/common_cents/__main__.py @@ -0,0 +1,61 @@ +import argparse +import os +import sys +from pathlib import Path + +from common_cents.app import CommonCentsApp +from common_cents.config import get_active_db_path +from common_cents.db import default_db_path + +_LEGACY_CWD_DB = Path("common-cents.db") + + +def _resolve_db_path(cli_path: Path | None) -> Path: + """Resolve the DB path. Order: --db, $COMMON_CENTS_DB, persisted config, + legacy ./common-cents.db, default.""" + if cli_path is not None: + return cli_path + env = os.environ.get("COMMON_CENTS_DB") + if env: + return Path(env).expanduser() + stored = get_active_db_path() + if stored is not None: + return stored + default = default_db_path() + if not default.exists() and _LEGACY_CWD_DB.exists(): + print( + f"Note: using ./{_LEGACY_CWD_DB.name} for backward compatibility.\n" + f" To make the new default permanent:\n" + f" mkdir -p {default.parent} && mv {_LEGACY_CWD_DB} {default}\n" + f" Suppress this message with --db or COMMON_CENTS_DB.", + file=sys.stderr, + ) + return _LEGACY_CWD_DB.resolve() + return default + + +def main() -> None: + default = default_db_path() + parser = argparse.ArgumentParser( + prog="common-cents", + description="Terminal TUI spending tracker.", + ) + parser.add_argument( + "--db", + type=Path, + default=None, + help=( + "Path to the SQLite database file. " + "Overrides $COMMON_CENTS_DB. " + f"Default: {default}" + ), + ) + args = parser.parse_args() + + db_path = _resolve_db_path(args.db) + db_path.parent.mkdir(parents=True, exist_ok=True) + CommonCentsApp(db_path=db_path).run() + + +if __name__ == "__main__": + main() diff --git a/src/common_cents/app.py b/src/common_cents/app.py new file mode 100644 index 0000000..7437be4 --- /dev/null +++ b/src/common_cents/app.py @@ -0,0 +1,78 @@ +from pathlib import Path + +from textual.app import App + +from common_cents.config import clear_active_db_path, set_active_db_path +from common_cents.db import Database, default_db_path +from common_cents.screens.main_menu import MainMenuScreen + + +class CommonCentsApp(App): + TITLE = "Common Cents" + + CSS = """ + Footer { + background: transparent; + height: auto; + width: auto; + margin: 0 0 0 0; + border: round $primary-darken-1; + padding: 0 1; + overflow: hidden hidden; + align: center middle; + } + FooterKey { + background: $surface; + margin: 0 2 0 0; + } + FooterKey:hover { + background: $primary 20%; + } + FooterKey .footer-key--key { + background: $primary; + color: $background; + text-style: bold; + padding: 0 1; + } + FooterKey .footer-key--description { + color: $text-muted; + background: $surface; + padding: 0 1 0 1; + } + FooterKey.-command-palette { + display: none; + } + """ + + def __init__(self, db_path: Path) -> None: + super().__init__() + self.theme = "atom-one-dark" + self.db = Database(db_path) + + def get_key_display(self, binding) -> str: + return super().get_key_display(binding).upper() + + def on_mount(self) -> None: + self.push_screen(MainMenuScreen()) + + def on_unmount(self) -> None: + self.db.close() + + def switch_db(self, new_path: Path, *, persist: bool = True) -> None: + """Swap the active database. Opens `new_path` (creating it if missing), + closes the previous connection, and persists the choice unless + `persist=False`. Raises if the new DB cannot be opened — the previous + connection is left intact in that case.""" + new_path = new_path.expanduser() + new_path.parent.mkdir(parents=True, exist_ok=True) + new_db = Database(new_path) + old_db = self.db + self.db = new_db + old_db.close() + if persist: + set_active_db_path(new_path) + + def reset_db_to_default(self) -> None: + """Switch back to the platform-default DB and clear the persisted override.""" + self.switch_db(default_db_path(), persist=False) + clear_active_db_path() diff --git a/src/common_cents/category_tree.py b/src/common_cents/category_tree.py new file mode 100644 index 0000000..6dcf2cc --- /dev/null +++ b/src/common_cents/category_tree.py @@ -0,0 +1,60 @@ +"""Category trie shared by the reports screen and the main-menu top-categories +panel. + +Categories follow the ``Parent:Child[:Grandchild...]`` convention with arbitrary +depth. Each path resolves to (or creates) a leaf and its ancestors; intermediate +ancestors are derived from descendants' paths and carry zero ``self_total`` / +``self_prev_total`` unless the user recorded spending against that exact path. +""" + +from dataclasses import dataclass, field + + +@dataclass +class CategoryNode: + name: str + path: str + self_total: int = 0 + self_prev_total: int = 0 + children: dict[str, "CategoryNode"] = field(default_factory=dict) + + def total(self) -> int: + return self.self_total + sum(c.total() for c in self.children.values()) + + def prev_total(self) -> int: + return self.self_prev_total + sum( + c.prev_total() for c in self.children.values() + ) + + +def build_category_tree( + totals: dict[str, int], + prev_totals: dict[str, int] | None = None, + extra_paths: list[str] | None = None, +) -> list[CategoryNode]: + """Build a trie from per-path totals. + + ``extra_paths`` ensures nodes exist even when neither totals dict references + them — used by the main menu so a budgeted category with no spending still + appears as ``$0 / $X``. + """ + prev_totals = prev_totals or {} + paths: set[str] = set(totals) | set(prev_totals) | set(extra_paths or []) + roots: dict[str, CategoryNode] = {} + for path in paths: + segments = [s for s in path.split(":") if s] + if not segments: + continue + siblings = roots + cumulative: list[str] = [] + node: CategoryNode | None = None + for seg in segments: + cumulative.append(seg) + if seg not in siblings: + siblings[seg] = CategoryNode(name=seg, path=":".join(cumulative)) + node = siblings[seg] + siblings = node.children + if node is not None: + node.self_total = totals.get(path, 0) + node.self_prev_total = prev_totals.get(path, 0) + return list(roots.values()) diff --git a/src/common_cents/config.py b/src/common_cents/config.py new file mode 100644 index 0000000..d7b6261 --- /dev/null +++ b/src/common_cents/config.py @@ -0,0 +1,45 @@ +import json +import os +from pathlib import Path + + +def config_path() -> Path: + """User-config-dir location for persisted settings. + + Honours $XDG_CONFIG_HOME if set; otherwise uses ~/.config on all platforms. + """ + base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config") + return Path(base).expanduser() / "common-cents" / "config.json" + + +def load_config() -> dict: + path = config_path() + if not path.exists(): + return {} + try: + return json.loads(path.read_text()) + except (json.JSONDecodeError, OSError): + return {} + + +def save_config(data: dict) -> None: + path = config_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(data, indent=2)) + + +def get_active_db_path() -> Path | None: + raw = load_config().get("db_path") + return Path(raw).expanduser() if raw else None + + +def set_active_db_path(path: Path) -> None: + data = load_config() + data["db_path"] = str(path) + save_config(data) + + +def clear_active_db_path() -> None: + data = load_config() + if data.pop("db_path", None) is not None: + save_config(data) diff --git a/src/common_cents/csv_export.py b/src/common_cents/csv_export.py new file mode 100644 index 0000000..75980ab --- /dev/null +++ b/src/common_cents/csv_export.py @@ -0,0 +1,27 @@ +import csv +from pathlib import Path + +HEADER = ["DATE", "CENTS", "CATEGORY", "MERCHANT", "NOTES", "TAGS"] + + +def write_csv(path: Path, rows: list[dict]) -> None: + """ + Write spending rows to `path` in the standard import format. + + Each row dict must contain: date, cents, category, merchant, notes, tags + (where tags is a list[str]). The output round-trips through `parse_csv`. + """ + with path.open("w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(HEADER) + for r in rows: + writer.writerow( + [ + r["date"], + r["cents"], + r["category"], + r["merchant"] or "", + r["notes"] or "", + ",".join(r["tags"]), + ] + ) diff --git a/src/common_cents/csv_import.py b/src/common_cents/csv_import.py new file mode 100644 index 0000000..3050ba1 --- /dev/null +++ b/src/common_cents/csv_import.py @@ -0,0 +1,119 @@ +import csv +from dataclasses import dataclass, field +from datetime import date +from pathlib import Path + +from common_cents.money import parse_cents_csv + +REQUIRED_COLS = {"DATE", "CENTS", "CATEGORY", "MERCHANT"} +SUGGESTED_COLS = {"NOTES", "TAGS"} + + +@dataclass +class ImportRow: + date: str + cents: int + category: str + merchant: str + notes: str | None + tags: list[str] + + +@dataclass +class ParseResult: + rows: list[ImportRow] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + + +def parse_csv(path: Path) -> ParseResult: + result = ParseResult() + + try: + text = path.read_text(encoding="utf-8-sig") + except OSError as e: + result.errors.append(str(e)) + return result + + reader = csv.DictReader(text.splitlines()) + if reader.fieldnames is None: + result.errors.append("File is empty or has no header row.") + return result + + headers = {h.strip().upper() for h in reader.fieldnames} + missing_required = REQUIRED_COLS - headers + if missing_required: + result.errors.append( + f"Missing required columns: {', '.join(sorted(missing_required))}" + ) + return result + + missing_suggested = SUGGESTED_COLS - headers + if missing_suggested: + result.warnings.append( + f"Optional columns not found: {', '.join(sorted(missing_suggested))}. " + "Adding notes and tags to your spending records is recommended." + ) + + extra_cols = headers - REQUIRED_COLS - SUGGESTED_COLS + if extra_cols: + result.warnings.append( + f"Unknown columns will be ignored: {', '.join(sorted(extra_cols))}." + ) + + # Build normalised header map: upper-stripped → original fieldname + header_map = {h.strip().upper(): h for h in reader.fieldnames} + + for line_num, raw_row in enumerate(reader, start=2): + try: + date_val = raw_row[header_map["DATE"]].strip() + cents_raw = raw_row[header_map["CENTS"]].strip() + category = raw_row[header_map["CATEGORY"]].strip() + merchant = raw_row[header_map["MERCHANT"]].strip() + + if not date_val or not cents_raw or not category: + result.errors.append( + f"Row {line_num}: date, cents, and category are required." + ) + continue + + try: + date.fromisoformat(date_val) + except ValueError: + result.errors.append( + f"Row {line_num}: invalid date '{date_val}' — use YYYY-MM-DD." + ) + continue + + cents = parse_cents_csv(cents_raw) + if cents <= 0: + result.errors.append( + f"Row {line_num}: cents must be a positive number." + ) + continue + + notes_key = header_map.get("NOTES") + notes = raw_row[notes_key].strip() or None if notes_key else None + + tags_key = header_map.get("TAGS") + tags_raw = raw_row[tags_key].strip() if tags_key else "" + tags = ( + [t.strip() for t in tags_raw.split(",") if t.strip()] + if tags_raw + else [] + ) + + result.rows.append( + ImportRow( + date=date_val, + cents=cents, + category=category, + merchant=merchant, + notes=notes, + tags=tags, + ) + ) + except (ValueError, KeyError) as e: + result.errors.append(f"Row {line_num}: {e}") + + return result diff --git a/src/common_cents/db.py b/src/common_cents/db.py new file mode 100644 index 0000000..8d0d453 --- /dev/null +++ b/src/common_cents/db.py @@ -0,0 +1,644 @@ +import os +import sqlite3 +from pathlib import Path + +_SCHEMA = Path(__file__).parent / "schema.sql" + +KINDS = ("category", "merchant", "tag") +SCHEMA_VERSION = 2 + + +def _validate_name(name: str) -> None: + """Reject names that would break CSV round-trip. + + The export joins tags with ``,`` and the importer splits by ``,``; a comma + inside any metadata name would silently become a tag boundary on re-import. + """ + if "," in name: + raise ValueError("Name cannot contain commas") + + +def _validate_kind(kind: str) -> None: + if kind not in KINDS: + raise ValueError(f"Invalid kind '{kind}' (expected one of {KINDS})") + + +def default_db_path() -> Path: + """User-data-dir default for the SQLite database. + + Honours ``$XDG_DATA_HOME`` if set; otherwise uses ``~/.local/share`` on all + platforms — consistent with other Python CLI tools (mypy, ruff, uv). + """ + base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share") + return Path(base).expanduser() / "common-cents" / "common-cents.db" + + +# Subqueries used in spending SELECT lists to surface category/merchant/tags names. +# Each one resolves to a single value per spending row, avoiding the cross-product +# blowup of joining spending_metadata three times. They filter on sm.kind (which +# is enforced to match metadata.kind by application code) so the partial unique +# index on (spending_id) WHERE kind = ... can satisfy the lookup without scanning. +_CATEGORY_NAME_SUBQ = """ + (SELECT m.name FROM spending_metadata sm JOIN metadata m ON m.id = sm.metadata_id + WHERE sm.spending_id = s.id AND sm.kind = 'category') +""" +_MERCHANT_NAME_SUBQ = """ + (SELECT m.name FROM spending_metadata sm JOIN metadata m ON m.id = sm.metadata_id + WHERE sm.spending_id = s.id AND sm.kind = 'merchant') +""" +_TAG_NAMES_SUBQ = """ + (SELECT GROUP_CONCAT(m.name, ', ') FROM spending_metadata sm + JOIN metadata m ON m.id = sm.metadata_id + WHERE sm.spending_id = s.id AND sm.kind = 'tag') +""" + + +def _exists_kind_name_filter(kind: str) -> str: + """SQL fragment for: spending row has a metadata of `kind` whose name LIKE ?.""" + return f""" + EXISTS ( + SELECT 1 FROM spending_metadata sm JOIN metadata m ON m.id = sm.metadata_id + WHERE sm.spending_id = s.id AND sm.kind = '{kind}' AND m.name LIKE ? + ) + """ + + +class Database: + def __init__(self, path: Path) -> None: + self.path = path + self.conn = sqlite3.connect(path) + self.conn.row_factory = sqlite3.Row + self.conn.execute("PRAGMA foreign_keys = ON") + if not self._is_initialized(): + # Fresh DB. schema.sql sets PRAGMA user_version itself. + self.conn.executescript(_SCHEMA.read_text()) + self.conn.commit() + else: + self._migrate() + + def close(self) -> None: + """Close the underlying SQLite connection. Safe to call repeatedly.""" + self.conn.close() + + def __enter__(self) -> "Database": + return self + + def __exit__(self, *exc: object) -> None: + self.close() + + def _is_initialized(self) -> bool: + row = self.conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='spending'" + ).fetchone() + return row is not None + + def _migrate(self) -> None: + """Apply pending schema migrations based on PRAGMA user_version.""" + version = self.conn.execute("PRAGMA user_version").fetchone()[0] + if version < 1: + self._migrate_to_v1() + version = 1 + if version < 2: + self._migrate_to_v2() + version = 2 + if version != SCHEMA_VERSION: + raise RuntimeError( + f"DB user_version={version} but app expects {SCHEMA_VERSION}. " + f"This DB was likely written by a newer build of common-cents." + ) + + def _migrate_to_v1(self) -> None: + """v0 → v1: add `kind` to spending_metadata; enforce 1-category / ≤1-merchant. + + Follows SQLite's official 12-step table-rewrite procedure + (https://sqlite.org/lang_altertable.html#otheralter): disable foreign + keys, recreate the table inside a transaction, then verify with + foreign_key_check before re-enabling FK enforcement. + """ + self.conn.execute("PRAGMA foreign_keys = OFF") + try: + with self.conn: + self.conn.executescript(""" + CREATE TABLE spending_metadata_new ( + spending_id INTEGER NOT NULL REFERENCES spending (id) ON DELETE CASCADE, + metadata_id INTEGER NOT NULL REFERENCES metadata (id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('category', 'merchant', 'tag')), + PRIMARY KEY (spending_id, metadata_id) + ); + + INSERT INTO spending_metadata_new (spending_id, metadata_id, kind) + SELECT sm.spending_id, sm.metadata_id, m.kind + FROM spending_metadata sm + JOIN metadata m ON m.id = sm.metadata_id; + + DROP TABLE spending_metadata; + ALTER TABLE spending_metadata_new RENAME TO spending_metadata; + + CREATE INDEX idx_spending_metadata_meta + ON spending_metadata (metadata_id); + CREATE INDEX idx_spending_metadata_spending_kind + ON spending_metadata (spending_id, kind); + CREATE UNIQUE INDEX idx_spending_metadata_one_category + ON spending_metadata (spending_id) WHERE kind = 'category'; + CREATE UNIQUE INDEX idx_spending_metadata_one_merchant + ON spending_metadata (spending_id) WHERE kind = 'merchant'; + + PRAGMA user_version = 1; + """) + violations = self.conn.execute("PRAGMA foreign_key_check").fetchall() + if violations: + raise RuntimeError( + f"foreign_key_check failed after v1 migration: {violations}" + ) + finally: + self.conn.execute("PRAGMA foreign_keys = ON") + + def _migrate_to_v2(self) -> None: + """v1 → v2: add the optional `budget` table for per-category monthly caps.""" + with self.conn: + self.conn.executescript(""" + CREATE TABLE budget ( + metadata_id INTEGER PRIMARY KEY REFERENCES metadata (id) ON DELETE CASCADE, + monthly_cents INTEGER NOT NULL CHECK (monthly_cents > 0) + ); + PRAGMA user_version = 2; + """) + + # --- Metadata (categories, merchants, tags) --- + + def get_metadata(self, kind: str) -> list[sqlite3.Row]: + # `budget_cents` is NULL for kinds other than 'category' (the budget + # table only references category metadata rows by application contract). + return self.conn.execute( + """ + SELECT m.id, m.name, + (SELECT COUNT(*) FROM spending_metadata WHERE metadata_id = m.id) + AS usage_count, + (SELECT monthly_cents FROM budget WHERE metadata_id = m.id) + AS budget_cents + FROM metadata m + WHERE m.kind = ? + ORDER BY m.name + """, + (kind,), + ).fetchall() + + # --- Budgets (per-category monthly cap) --- + + def set_budget(self, category_id: int, monthly_cents: int) -> None: + if monthly_cents <= 0: + raise ValueError("Budget must be positive") + self.conn.execute( + """ + INSERT INTO budget (metadata_id, monthly_cents) VALUES (?, ?) + ON CONFLICT (metadata_id) DO UPDATE SET monthly_cents = excluded.monthly_cents + """, + (category_id, monthly_cents), + ) + self.conn.commit() + + def clear_budget(self, category_id: int) -> None: + self.conn.execute("DELETE FROM budget WHERE metadata_id = ?", (category_id,)) + self.conn.commit() + + def get_budget(self, category_id: int) -> int | None: + row = self.conn.execute( + "SELECT monthly_cents FROM budget WHERE metadata_id = ?", (category_id,) + ).fetchone() + return row["monthly_cents"] if row else None + + def add_metadata(self, kind: str, name: str) -> int: + _validate_kind(kind) + _validate_name(name) + try: + cur = self.conn.execute( + "INSERT INTO metadata (kind, name) VALUES (?, ?)", (kind, name) + ) + self.conn.commit() + return cur.lastrowid + except sqlite3.IntegrityError as e: + raise ValueError( + f"{kind.capitalize()} '{name}' already exists" + ) from e + + def update_metadata(self, id: int, name: str) -> None: + _validate_name(name) + try: + cur = self.conn.execute( + "UPDATE metadata SET name = ? WHERE id = ?", (name, id) + ) + except sqlite3.IntegrityError as e: + raise ValueError(f"Name '{name}' already exists for this kind") from e + if cur.rowcount == 0: + raise ValueError(f"No metadata row with id {id}") + self.conn.commit() + + def delete_metadata(self, id: int) -> None: + with self.conn: + count = self.conn.execute( + "SELECT COUNT(*) FROM spending_metadata WHERE metadata_id = ?", (id,) + ).fetchone()[0] + if count: + raise ValueError(f"Cannot delete: used by {count} spending record(s)") + self.conn.execute("DELETE FROM metadata WHERE id = ?", (id,)) + + def get_or_create_metadata(self, kind: str, name: str) -> int: + with self.conn: + return self._get_or_create_metadata(kind, name) + + def _get_or_create_metadata(self, kind: str, name: str) -> int: + """Caller is responsible for the transaction.""" + _validate_name(name) + row = self.conn.execute( + "SELECT id FROM metadata WHERE kind = ? AND name = ? COLLATE NOCASE", + (kind, name), + ).fetchone() + if row: + return row["id"] + cur = self.conn.execute( + "INSERT INTO metadata (kind, name) VALUES (?, ?)", (kind, name) + ) + return cur.lastrowid + + # --- Spending --- + + def get_spending( + self, + start: str | None = None, + end: str | None = None, + category: str = "", + merchant: str = "", + tag: str = "", + notes: str = "", + min_cents: int | None = None, + max_cents: int | None = None, + ) -> list[sqlite3.Row]: + params: list = [] + clauses: list[str] = [] + + if start: + clauses.append("s.date >= ?") + params.append(start) + if end: + clauses.append("s.date <= ?") + params.append(end) + if category: + clauses.append(_exists_kind_name_filter("category")) + params.append(f"%{category}%") + if merchant: + clauses.append(_exists_kind_name_filter("merchant")) + params.append(f"%{merchant}%") + if tag: + clauses.append(_exists_kind_name_filter("tag")) + params.append(f"%{tag}%") + if notes: + # SQLite LIKE is ASCII-case-insensitive by default; rows with NULL + # notes never match, which matches expectations for a "search" box. + clauses.append("s.notes LIKE ?") + params.append(f"%{notes}%") + if min_cents is not None: + clauses.append("s.cents >= ?") + params.append(min_cents) + if max_cents is not None: + clauses.append("s.cents <= ?") + params.append(max_cents) + + where = ("WHERE " + " AND ".join(clauses)) if clauses else "" + return self.conn.execute( + f""" + SELECT + s.id, s.date, s.cents, s.notes, + {_CATEGORY_NAME_SUBQ} AS category, + {_MERCHANT_NAME_SUBQ} AS merchant, + {_TAG_NAMES_SUBQ} AS tags + FROM spending s + {where} + ORDER BY s.date DESC, s.id DESC + """, + params, + ).fetchall() + + def get_spending_by_id(self, id: int) -> sqlite3.Row: + return self.conn.execute( + f""" + SELECT s.id, s.date, s.cents, s.notes, + (SELECT metadata_id FROM spending_metadata + WHERE spending_id = s.id AND kind = 'category') + AS category_id, + (SELECT metadata_id FROM spending_metadata + WHERE spending_id = s.id AND kind = 'merchant') + AS merchant_id, + {_CATEGORY_NAME_SUBQ} AS category_name, + {_MERCHANT_NAME_SUBQ} AS merchant_name + FROM spending s + WHERE s.id = ? + """, + (id,), + ).fetchone() + + def get_spending_metadata_ids(self, spending_id: int, kind: str) -> list[int]: + rows = self.conn.execute( + "SELECT metadata_id FROM spending_metadata" + " WHERE spending_id = ? AND kind = ?", + (spending_id, kind), + ).fetchall() + return [r["metadata_id"] for r in rows] + + def _link_metadata( + self, + spending_id: int, + category_id: int, + merchant_id: int | None, + tag_ids: list[int], + ) -> None: + self.conn.execute( + "INSERT OR IGNORE INTO spending_metadata" + " (spending_id, metadata_id, kind) VALUES (?, ?, 'category')", + (spending_id, category_id), + ) + if merchant_id is not None: + self.conn.execute( + "INSERT OR IGNORE INTO spending_metadata" + " (spending_id, metadata_id, kind) VALUES (?, ?, 'merchant')", + (spending_id, merchant_id), + ) + if tag_ids: + self.conn.executemany( + "INSERT OR IGNORE INTO spending_metadata" + " (spending_id, metadata_id, kind) VALUES (?, ?, 'tag')", + [(spending_id, tid) for tid in tag_ids], + ) + + def add_spending( + self, + date: str, + cents: int, + category_name: str, + merchant_name: str | None, + notes: str | None, + tag_ids: list[int], + ) -> None: + with self.conn: + category_id = self._get_or_create_metadata("category", category_name) + merchant_id = ( + self._get_or_create_metadata("merchant", merchant_name) + if merchant_name + else None + ) + cursor = self.conn.execute( + "INSERT INTO spending (date, cents, notes) VALUES (?, ?, ?)", + (date, cents, notes), + ) + self._link_metadata(cursor.lastrowid, category_id, merchant_id, tag_ids) + + def update_spending( + self, + id: int, + date: str, + cents: int, + category_name: str, + merchant_name: str | None, + notes: str | None, + tag_ids: list[int], + ) -> None: + with self.conn: + category_id = self._get_or_create_metadata("category", category_name) + merchant_id = ( + self._get_or_create_metadata("merchant", merchant_name) + if merchant_name + else None + ) + self.conn.execute( + """ + UPDATE spending + SET date=?, cents=?, notes=?, + updated_at=strftime('%Y-%m-%d %H:%M:%f', 'now') + WHERE id=? + """, + (date, cents, notes, id), + ) + self.conn.execute( + "DELETE FROM spending_metadata WHERE spending_id = ?", (id,) + ) + self._link_metadata(id, category_id, merchant_id, tag_ids) + + def delete_spending(self, id: int) -> None: + with self.conn: + self.conn.execute("DELETE FROM spending WHERE id = ?", (id,)) + + def find_duplicate_indices(self, rows: list[dict]) -> set[int]: + """ + Return indices of rows that match either an existing spending record or + an earlier row in the same list, keyed on + (date, cents, category, merchant, notes, tags). Names/notes match + case-insensitively and notes are whitespace-trimmed; tags are compared + as an unordered set. + """ + if not rows: + return set() + existing = self.conn.execute(f""" + SELECT s.date, s.cents, s.notes, + {_CATEGORY_NAME_SUBQ} AS category, + {_MERCHANT_NAME_SUBQ} AS merchant, + {_TAG_NAMES_SUBQ} AS tags + FROM spending s + """).fetchall() + + def _key(date, cents, category, merchant, notes, tags): + return ( + date, + cents, + (category or "").lower(), + (merchant or "").lower(), + (notes or "").strip().lower(), + frozenset(t.lower() for t in tags), + ) + + seen = { + _key( + r["date"], + r["cents"], + r["category"], + r["merchant"], + r["notes"], + [t.strip() for t in (r["tags"] or "").split(",") if t.strip()], + ) + for r in existing + } + + duplicates: set[int] = set() + for i, row in enumerate(rows): + key = _key( + row["date"], + row["cents"], + row.get("category"), + row.get("merchant"), + row.get("notes"), + row.get("tags") or [], + ) + if key in seen: + duplicates.add(i) + else: + seen.add(key) + return duplicates + + def import_spending(self, rows: list[dict]) -> tuple[int, int]: + """ + Import a list of spending dicts atomically, skipping rows that already + exist in spending (matched by date, cents, category, merchant). + Returns (inserted, skipped). + """ + duplicate_indices = self.find_duplicate_indices(rows) + inserted = 0 + with self.conn: + for i, row in enumerate(rows): + if i in duplicate_indices: + continue + category_id = self._get_or_create_metadata("category", row["category"]) + merchant_id = ( + self._get_or_create_metadata("merchant", row["merchant"]) + if row.get("merchant") + else None + ) + cursor = self.conn.execute( + "INSERT INTO spending (date, cents, notes) VALUES (?, ?, ?)", + (row["date"], row["cents"], row.get("notes")), + ) + spending_id = cursor.lastrowid + tag_ids = [ + self._get_or_create_metadata("tag", t) for t in row.get("tags", []) + ] + self._link_metadata(spending_id, category_id, merchant_id, tag_ids) + inserted += 1 + return inserted, len(duplicate_indices) + + def get_spending_for_export(self) -> list[dict]: + """ + All spending rows in chronological order, shaped for CSV export. + Tags are returned as a sorted list[str] so exports are deterministic. + """ + rows = self.conn.execute(f""" + SELECT s.date, s.cents, s.notes, + {_CATEGORY_NAME_SUBQ} AS category, + {_MERCHANT_NAME_SUBQ} AS merchant, + (SELECT GROUP_CONCAT(m.name, ',') FROM spending_metadata sm + JOIN metadata m ON m.id = sm.metadata_id + WHERE sm.spending_id = s.id AND sm.kind = 'tag') AS tags + FROM spending s + ORDER BY s.date, s.id + """).fetchall() + return [ + { + "date": r["date"], + "cents": r["cents"], + "category": r["category"], + "merchant": r["merchant"], + "notes": r["notes"], + "tags": sorted(t for t in (r["tags"] or "").split(",") if t), + } + for r in rows + ] + + # --- Reports --- + + def get_spending_by_category( + self, + start: str, + end: str, + category: str = "", + tag: str = "", + min_cents: int | None = None, + max_cents: int | None = None, + ) -> list[sqlite3.Row]: + params: list = [start, end] + where_clauses = ["s.date >= ? AND s.date <= ?"] + having_clauses: list[str] = [] + having_params: list = [] + + if category: + where_clauses.append("c.name LIKE ?") + params.append(f"%{category}%") + + if tag: + where_clauses.append(_exists_kind_name_filter("tag")) + params.append(f"%{tag}%") + + if min_cents is not None: + having_clauses.append("total >= ?") + having_params.append(min_cents) + + if max_cents is not None: + having_clauses.append("total <= ?") + having_params.append(max_cents) + + where = " AND ".join(where_clauses) + having = ("HAVING " + " AND ".join(having_clauses)) if having_clauses else "" + return self.conn.execute( + f""" + SELECT c.name, SUM(s.cents) AS total + FROM spending s + JOIN spending_metadata sm ON sm.spending_id = s.id AND sm.kind = 'category' + JOIN metadata c ON c.id = sm.metadata_id + WHERE {where} + GROUP BY c.id, c.name + {having} + ORDER BY total DESC + """, + params + having_params, + ).fetchall() + + # --- Dashboard --- + + def get_earliest_spending_date(self) -> str | None: + """ISO date string of the earliest spending row, or None if the table is empty.""" + row = self.conn.execute("SELECT MIN(date) AS d FROM spending").fetchone() + return row["d"] if row and row["d"] else None + + def get_yearly_totals(self, year: int) -> dict[int, int]: + """Returns {month_number: total_cents} for months that have spending.""" + rows = self.conn.execute( + """ + SELECT CAST(strftime('%m', date) AS INTEGER) AS month, + SUM(cents) AS total + FROM spending + WHERE strftime('%Y', date) = ? + GROUP BY month + ORDER BY month + """, + (str(year),), + ).fetchall() + return {row["month"]: row["total"] for row in rows} + + def get_category_totals_for_month(self, ym: str) -> dict[str, int]: + """Per-category direct (non-rolled-up) spending for a ``YYYY-MM`` month. + + Returns ``{category_path: cents}``. Caller is responsible for rolling + descendant totals into parent paths if needed (the trie in + ``common_cents.category_tree`` does this). + """ + rows = self.conn.execute( + """ + SELECT c.name, SUM(s.cents) AS total + FROM spending s + JOIN spending_metadata sm + ON sm.spending_id = s.id AND sm.kind = 'category' + JOIN metadata c ON c.id = sm.metadata_id + WHERE strftime('%Y-%m', s.date) = ? + GROUP BY c.id, c.name + """, + (ym,), + ).fetchall() + return {r["name"]: r["total"] for r in rows} + + def get_recent_spending(self, limit: int = 5) -> list[sqlite3.Row]: + return self.conn.execute( + f""" + SELECT s.date, s.cents, s.notes, + {_CATEGORY_NAME_SUBQ} AS category, + {_MERCHANT_NAME_SUBQ} AS merchant + FROM spending s + ORDER BY s.date DESC, s.created_at DESC + LIMIT ? + """, + (limit,), + ).fetchall() diff --git a/src/common_cents/money.py b/src/common_cents/money.py new file mode 100644 index 0000000..7d70c6e --- /dev/null +++ b/src/common_cents/money.py @@ -0,0 +1,79 @@ +""" +Money parsing and formatting helpers. + +Internally money is always integer cents. These helpers translate between that +representation and the strings shown to (or typed by) users. +""" + + +def parse_dollars(value: str) -> int | None: + """ + Parse a user-typed dollar amount (e.g. ``$1,234.56``) to integer cents. + Returns ``None`` for blank or unparseable input. Uses string parsing — no + floats — so values like ``1.005`` truncate rather than round through binary. + """ + value = value.strip().lstrip("$").replace(",", "") + if not value: + return None + negative = value.startswith("-") + if negative: + value = value[1:] + if not value: + return None + if "." in value: + dollars, frac = value.split(".", 1) + else: + dollars, frac = value, "" + frac = frac[:2].ljust(2, "0") + try: + cents = int(dollars or "0") * 100 + int(frac) + except ValueError: + return None + return -cents if negative else cents + + +def parse_cents_csv(raw: str) -> int: + """ + Parse a CSV ``CENTS`` column value to integer cents. Accepts comma-formatted + integers (e.g. ``"14,901"``). Raises ``ValueError`` on invalid input. + """ + return int(raw.strip().replace(",", "")) + + +def format_cents(cents: int) -> str: + """Format integer cents for display, e.g. ``$1,234.56`` (or ``-$5.00``).""" + sign = "-" if cents < 0 else "" + n = abs(cents) + return f"{sign}${n // 100:,}.{n % 100:02d}" + + +def format_cents_input(cents: int) -> str: + """ + Format integer cents for placing into an editable ``Input`` field + (no ``$``, no thousands separator), e.g. ``1234.56``. + """ + return f"{cents // 100}.{cents % 100:02d}" + + +def format_budget_cell(used_cents: int, budget_cents: int | None): + """``$used / $budget (N%)`` colored by % consumed. Blank when no budget. + + Returns a ``rich.text.Text``. Color thresholds: green <80%, yellow 80–100%, + red >100%. Imported lazily so plain ``money`` parsing/formatting stays + free of UI deps. + """ + from rich.text import Text # noqa: PLC0415 + + if not budget_cents: + return Text("") + pct = used_cents / budget_cents * 100 + if pct > 100: + style = "red" + elif pct >= 80: + style = "yellow" + else: + style = "green" + return Text( + f"{format_cents(used_cents)} / {format_cents(budget_cents)} ({pct:.0f}%)", + style=style, + ) diff --git a/src/common_cents/schema.sql b/src/common_cents/schema.sql new file mode 100644 index 0000000..04fa631 --- /dev/null +++ b/src/common_cents/schema.sql @@ -0,0 +1,44 @@ +CREATE TABLE metadata ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL COLLATE NOCASE, + kind TEXT NOT NULL CHECK (kind IN ('category', 'merchant', 'tag')), + created_at TIMESTAMP NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')) +); + +CREATE UNIQUE INDEX idx_metadata_kind_name ON metadata (kind, name COLLATE NOCASE); + +CREATE TABLE spending ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date DATE NOT NULL, + cents INTEGER NOT NULL CHECK (cents > 0), + notes TEXT, + created_at TIMESTAMP NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')), + updated_at TIMESTAMP NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')) +); + +CREATE TABLE spending_metadata ( + spending_id INTEGER NOT NULL REFERENCES spending (id) ON DELETE CASCADE, + metadata_id INTEGER NOT NULL REFERENCES metadata (id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN ('category', 'merchant', 'tag')), + PRIMARY KEY (spending_id, metadata_id) +); + +CREATE INDEX idx_spending_date ON spending (date); +CREATE INDEX idx_spending_metadata_meta ON spending_metadata (metadata_id); +CREATE INDEX idx_spending_metadata_spending_kind ON spending_metadata (spending_id, kind); + +-- Cardinality enforcement: each spending row may have at most one category +-- and at most one merchant. Tags are unrestricted. +CREATE UNIQUE INDEX idx_spending_metadata_one_category + ON spending_metadata (spending_id) WHERE kind = 'category'; +CREATE UNIQUE INDEX idx_spending_metadata_one_merchant + ON spending_metadata (spending_id) WHERE kind = 'merchant'; + +-- Optional monthly budget per metadata row. Application code only sets these +-- for category rows; tags/merchants don't use it. Cascades on metadata delete. +CREATE TABLE budget ( + metadata_id INTEGER PRIMARY KEY REFERENCES metadata (id) ON DELETE CASCADE, + monthly_cents INTEGER NOT NULL CHECK (monthly_cents > 0) +); + +PRAGMA user_version = 2; diff --git a/src/common_cents/screens/__init__.py b/src/common_cents/screens/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/common_cents/screens/_base.py b/src/common_cents/screens/_base.py new file mode 100644 index 0000000..54627f4 --- /dev/null +++ b/src/common_cents/screens/_base.py @@ -0,0 +1,15 @@ +from typing import TYPE_CHECKING, TypeVar, cast + +from textual.screen import Screen + +if TYPE_CHECKING: + from common_cents.app import CommonCentsApp + from common_cents.db import Database + +_T = TypeVar("_T") + + +class AppScreen(Screen[_T]): + @property + def db(self) -> "Database": + return cast("CommonCentsApp", self.app).db diff --git a/src/common_cents/screens/_confirm.py b/src/common_cents/screens/_confirm.py new file mode 100644 index 0000000..f0a21cb --- /dev/null +++ b/src/common_cents/screens/_confirm.py @@ -0,0 +1,79 @@ +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Label + + +class ConfirmModal(ModalScreen[bool]): + DEFAULT_CSS = """ + ConfirmModal { + align: center middle; + } + ConfirmModal > Vertical { + width: 50; + height: auto; + background: $surface; + border: thick $error; + padding: 1 2; + } + ConfirmModal > Vertical.warning { + border: thick $warning; + } + ConfirmModal > Vertical.success { + border: thick $success; + } + ConfirmModal .detail { + color: $text-muted; + margin-bottom: 0; + } + ConfirmModal Horizontal { + height: auto; + margin-top: 1; + align: right middle; + } + ConfirmModal Button { + margin-left: 1; + } + """ + + BINDINGS = [Binding("escape", "cancel", "Cancel", show=False)] + + def __init__( + self, + message: str, + details: list[str] | None = None, + confirm_label: str = "Delete", + confirm_variant: str = "error", + cancel_label: str = "Cancel", + focus_confirm: bool = False, + ) -> None: + super().__init__() + self._message = message + self._details = details or [] + self._confirm_label = confirm_label + self._confirm_variant = confirm_variant + self._cancel_label = cancel_label + self._focus_confirm = focus_confirm + + def compose(self) -> ComposeResult: + css_class = self._confirm_variant if self._confirm_variant != "error" else "" + with Vertical(classes=css_class): + yield Label(self._message) + for detail in self._details: + yield Label(detail, classes="detail") + with Horizontal(): + yield Button( + self._confirm_label, variant=self._confirm_variant, id="confirm" + ) + yield Button(self._cancel_label, id="cancel") + + def on_mount(self) -> None: + focus_id = "#confirm" if self._focus_confirm else "#cancel" + self.query_one(focus_id, Button).focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + self.dismiss(event.button.id == "confirm") + + def action_cancel(self) -> None: + self.dismiss(False) diff --git a/src/common_cents/screens/_spending_bulk_form.py b/src/common_cents/screens/_spending_bulk_form.py new file mode 100644 index 0000000..d8fbbe2 --- /dev/null +++ b/src/common_cents/screens/_spending_bulk_form.py @@ -0,0 +1,265 @@ +from datetime import date + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.widgets import Button, Checkbox, Header, Input, Label, Static + +from common_cents.screens._base import AppScreen +from common_cents.screens._confirm import ConfirmModal +from common_cents.widgets.autocomplete import AutocompleteInput +from common_cents.widgets.footer import CenteredFooter + +# Sentinel returned when user wants to clear an optional text field. Stored in the +# diff dict only when the user explicitly typed [clear] in the Merchant or Notes +# field while its checkbox is on. Bulk-applying this writes None to the column. +_CLEAR_TOKEN = "[clear]" + + +def _split_tags(value: str) -> list[str]: + return [t.strip() for t in value.split(",") if t.strip()] + + +class BulkEditScreen(AppScreen[int]): + """Edit a set of spending rows in one shot. Returns the count actually + updated (0 means cancelled / nothing applicable). Only fields whose + checkbox is on are written to each row; everything else is preserved + via a per-row read of get_spending_by_id.""" + + DEFAULT_CSS = """ + BulkEditScreen VerticalScroll { + padding: 1 2; + } + BulkEditScreen #subtitle { + height: auto; + padding: 0 0 1 0; + color: $text-muted; + } + BulkEditScreen .row { + height: auto; + margin-bottom: 1; + } + BulkEditScreen .check { + width: 5; + height: auto; + padding-top: 1; + } + BulkEditScreen .field { + width: 1fr; + height: auto; + } + BulkEditScreen .field Label { + height: 1; + } + BulkEditScreen Input, BulkEditScreen AutocompleteInput { + width: 1fr; + } + BulkEditScreen #actions { + height: auto; + padding: 1 2; + align: right middle; + border-top: solid $primary; + } + BulkEditScreen #actions Button { + margin-left: 1; + } + """ + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + + _FIELDS = ("date", "category", "merchant", "tags", "notes") + + def __init__(self, spending_ids: list[int]) -> None: + super().__init__() + self._ids = list(spending_ids) + + def compose(self) -> ComposeResult: + yield Header() + with VerticalScroll(): + yield Static(id="subtitle") + yield Label( + "Tick a row's checkbox to apply that field. Typing in a field " + "auto-ticks its box. Use [clear] in Merchant or Notes to blank " + "them. Tags replace the existing tag set on each record.", + id="help", + ) + with Horizontal(classes="row"): + yield Checkbox(value=False, classes="check", id="cb-date") + with Vertical(classes="field"): + yield Label("Date") + yield Input(placeholder="YYYY-MM-DD", id="field-date") + with Horizontal(classes="row"): + yield Checkbox(value=False, classes="check", id="cb-category") + with Vertical(classes="field"): + yield Label("Category") + yield AutocompleteInput( + placeholder="Category name", id="field-category" + ) + with Horizontal(classes="row"): + yield Checkbox(value=False, classes="check", id="cb-merchant") + with Vertical(classes="field"): + yield Label("Merchant") + yield AutocompleteInput( + placeholder="Merchant name (or [clear])", id="field-merchant" + ) + with Horizontal(classes="row"): + yield Checkbox(value=False, classes="check", id="cb-tags") + with Vertical(classes="field"): + yield Label("Tags (replace, comma-separated)") + yield Input(placeholder="work, lunch", id="field-tags") + with Horizontal(classes="row"): + yield Checkbox(value=False, classes="check", id="cb-notes") + with Vertical(classes="field"): + yield Label("Notes") + yield Input(placeholder="Notes (or [clear])", id="field-notes") + with Horizontal(id="actions"): + yield Button("Apply", variant="primary", id="apply") + yield Button("Cancel", id="cancel") + yield CenteredFooter() + + def on_mount(self) -> None: + self.title = "Bulk Edit Spending" + n = len(self._ids) + self.query_one("#subtitle", Static).update( + f"Editing {n} selected record{'s' if n != 1 else ''}." + ) + categories = self.db.get_metadata("category") + merchants = self.db.get_metadata("merchant") + self.query_one("#field-category", AutocompleteInput).set_options( + [c["name"] for c in categories] + ) + self.query_one("#field-merchant", AutocompleteInput).set_options( + [m["name"] for m in merchants] + ) + + def on_input_changed(self, event: Input.Changed) -> None: + # AutocompleteInput emits Input.Changed via its inner Input child; the + # event ID matches the inner field-* widget. Auto-tick the checkbox so + # the user doesn't have to remember the dual-step. + input_id = event.input.id + if not input_id or not input_id.startswith("field-"): + return + field = input_id[len("field-") :] + if field not in self._FIELDS: + return + if event.value.strip(): + self.query_one(f"#cb-{field}", Checkbox).value = True + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "apply": + self._apply() + elif event.button.id == "cancel": + self.dismiss(0) + + def action_cancel(self) -> None: + self.dismiss(0) + + def _collect_diff(self) -> dict | None: + """Read the form into a dict of overrides (only checked fields). Returns + None on validation error (after notifying the user); empty dict if no + boxes were checked.""" + diff: dict = {} + if self.query_one("#cb-date", Checkbox).value: + v = self.query_one("#field-date", Input).value.strip() + try: + date.fromisoformat(v) + except ValueError: + self.app.notify("Invalid date — use YYYY-MM-DD", severity="error") + return None + diff["date"] = v + if self.query_one("#cb-category", Checkbox).value: + v = self.query_one("#field-category", AutocompleteInput).value.strip() + if not v: + self.app.notify("Category cannot be blank", severity="error") + return None + diff["category"] = v + if self.query_one("#cb-merchant", Checkbox).value: + v = self.query_one("#field-merchant", AutocompleteInput).value.strip() + diff["merchant"] = None if v.lower() == _CLEAR_TOKEN else (v or None) + if self.query_one("#cb-tags", Checkbox).value: + v = self.query_one("#field-tags", Input).value + diff["tag_names"] = _split_tags(v) + if self.query_one("#cb-notes", Checkbox).value: + v = self.query_one("#field-notes", Input).value.strip() + diff["notes"] = None if v.lower() == _CLEAR_TOKEN else (v or None) + return diff + + def _apply(self) -> None: + diff = self._collect_diff() + if diff is None: + return + if not diff: + self.app.notify("No fields ticked — nothing to apply.", severity="warning") + return + + n = len(self._ids) + details = [f"Records: {n}"] + for k, v in diff.items(): + if k == "tag_names": + details.append( + "Tags: " + (", ".join(v) if v else "[clear]") + ) + else: + details.append(f"{k.capitalize():9} {v if v is not None else '[clear]'}") + + def on_confirm(confirmed: bool) -> None: + if not confirmed: + return + try: + updated = self._write(diff) + except Exception as e: + self.app.notify(f"Bulk update failed: {e}", severity="error") + return + self.app.notify(f"Updated {updated} record(s).") + self.dismiss(updated) + + self.app.push_screen( + ConfirmModal( + f"Apply changes to {n} record{'s' if n != 1 else ''}?", + details=details, + confirm_label="Apply", + confirm_variant="warning", + cancel_label="Cancel", + focus_confirm=True, + ), + on_confirm, + ) + + def _write(self, diff: dict) -> int: + """Apply `diff` to every selected ID. Each row is fetched fresh, the + diff is overlaid on its current values, and update_spending writes the + result. Skips IDs that no longer exist (e.g. deleted in another window).""" + tag_ids: list[int] | None = None + if "tag_names" in diff: + tag_ids = [ + self.db.get_or_create_metadata("tag", name) + for name in diff["tag_names"] + ] + + updated = 0 + for spending_id in self._ids: + row = self.db.get_spending_by_id(spending_id) + if row is None: + continue + new_date = diff.get("date", row["date"]) + new_category = diff.get("category", row["category_name"]) + new_merchant = ( + diff["merchant"] if "merchant" in diff else row["merchant_name"] + ) + new_notes = diff["notes"] if "notes" in diff else row["notes"] + new_tag_ids = ( + tag_ids + if tag_ids is not None + else self.db.get_spending_metadata_ids(spending_id, "tag") + ) + self.db.update_spending( + spending_id, + new_date, + row["cents"], + new_category, + new_merchant, + new_notes, + new_tag_ids, + ) + updated += 1 + return updated diff --git a/src/common_cents/screens/_spending_form.py b/src/common_cents/screens/_spending_form.py new file mode 100644 index 0000000..61897c1 --- /dev/null +++ b/src/common_cents/screens/_spending_form.py @@ -0,0 +1,276 @@ +from datetime import date + +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.widgets import Button, Header, Input, Label + +from common_cents.money import format_cents_input, parse_dollars +from common_cents.screens._base import AppScreen +from common_cents.screens.metadata import MetadataFormModal +from common_cents.widgets.autocomplete import AutocompleteInput +from common_cents.widgets.footer import CenteredFooter + + +class SpendingFormScreen(AppScreen[bool]): + DEFAULT_CSS = """ + SpendingFormScreen VerticalScroll { + padding: 1 2; + } + SpendingFormScreen .row { + height: auto; + margin-bottom: 1; + } + SpendingFormScreen .cell { + width: 1fr; + height: auto; + margin-right: 2; + } + SpendingFormScreen .cell:last-of-type { + margin-right: 0; + } + SpendingFormScreen .cell-auto { + width: auto; + } + SpendingFormScreen .cell-auto Button { + margin-top: 1; + } + SpendingFormScreen .cell Label { + height: 1; + padding-bottom: 0; + } + SpendingFormScreen Input { + width: 1fr; + } + SpendingFormScreen #selected-tags-label { + margin-top: 1; + } + SpendingFormScreen #selected-tags { + height: auto; + min-height: 1; + margin-top: 1; + } + SpendingFormScreen #selected-tags Button.chip { + height: 1; + min-width: 0; + border: none; + border-top: none; + border-bottom: none; + background: $primary-muted; + color: $text; + text-style: bold; + margin-right: 1; + padding: 0 1; + } + SpendingFormScreen #selected-tags Button.chip:hover { + background: $error-muted; + color: $text; + } + SpendingFormScreen #actions { + height: auto; + padding: 1 2; + align: right middle; + border-top: solid $primary; + } + SpendingFormScreen #actions Button { + margin-left: 1; + } + """ + + BINDINGS = [Binding("escape", "cancel", "Cancel")] + + def __init__(self, spending_id: int | None = None) -> None: + super().__init__() + self._spending_id = spending_id + self._tags: list = [] + self._selected_tag_ids: list[int] = [] + + def compose(self) -> ComposeResult: + yield Header() + with VerticalScroll(): + with Horizontal(classes="row"): + with Vertical(classes="cell"): + yield Label("Date") + yield Input(placeholder="YYYY-MM-DD", id="date") + with Vertical(classes="cell"): + yield Label("Amount ($)") + yield Input(placeholder="0.00", id="amount") + with Vertical(classes="cell"): + yield Label("Category") + yield AutocompleteInput(placeholder="Category name", id="category") + with Horizontal(classes="row"): + with Vertical(classes="cell"): + yield Label("Merchant") + yield AutocompleteInput( + placeholder="Merchant name (optional)", id="merchant" + ) + with Vertical(classes="cell"): + yield Label("Tags") + yield AutocompleteInput( + placeholder="Type to add a tag", id="tag-input" + ) + with Vertical(classes="cell cell-auto"): + yield Button("New Tag", id="new-tag") + with Horizontal(classes="row"), Vertical(classes="cell"): + yield Label("Notes") + yield Input(placeholder="Optional", id="notes") + yield Label("Selected tags", id="selected-tags-label") + with Horizontal(id="selected-tags"): + pass + with Horizontal(id="actions"): + yield Button("Save", variant="primary", id="save") + yield Button("Cancel", id="cancel") + yield CenteredFooter() + + def on_mount(self) -> None: + self.title = "Edit Spending" if self._spending_id else "Add Spending" + self._load_options() + if self._spending_id: + self._populate_from_spending() + else: + self.query_one("#date", Input).value = date.today().isoformat() + + def _load_options(self) -> None: + categories = self.db.get_metadata("category") + merchants = self.db.get_metadata("merchant") + self._tags = self.db.get_metadata("tag") + + self.query_one("#category", AutocompleteInput).set_options( + [c["name"] for c in categories] + ) + self.query_one("#merchant", AutocompleteInput).set_options( + [m["name"] for m in merchants] + ) + self._refresh_tag_input_options() + + def _refresh_tag_input_options(self) -> None: + selected = set(self._selected_tag_ids) + available = [t["name"] for t in self._tags if t["id"] not in selected] + self.query_one("#tag-input", AutocompleteInput).set_options(available) + + def _add_chip(self, tag) -> None: + self.query_one("#selected-tags", Horizontal).mount( + Button(f"{tag['name']} ×", id=f"chip-{tag['id']}", classes="chip") + ) + + def _populate_from_spending(self) -> None: + record = self.db.get_spending_by_id(self._spending_id) + self._selected_tag_ids = list( + self.db.get_spending_metadata_ids(self._spending_id, "tag") + ) + + self.query_one("#date", Input).value = record["date"] + self.query_one("#amount", Input).value = format_cents_input(record["cents"]) + self.query_one("#category", AutocompleteInput).value = record["category_name"] + if record["merchant_name"]: + self.query_one("#merchant", AutocompleteInput).value = record[ + "merchant_name" + ] + if record["notes"]: + self.query_one("#notes", Input).value = record["notes"] + self._refresh_tag_input_options() + by_id = {t["id"]: t for t in self._tags} + for tag_id in self._selected_tag_ids: + tag = by_id.get(tag_id) + if tag is not None: + self._add_chip(tag) + + def on_button_pressed(self, event: Button.Pressed) -> None: + btn_id = event.button.id or "" + if btn_id.startswith("chip-"): + tag_id = int(btn_id.removeprefix("chip-")) + self._selected_tag_ids = [t for t in self._selected_tag_ids if t != tag_id] + event.button.remove() + self._refresh_tag_input_options() + return + match btn_id: + case "save": + self._save() + case "cancel": + self.dismiss(False) + case "new-tag": + self._add_new_tag() + + @on(AutocompleteInput.Submitted, "#tag-input") + def _on_tag_submitted(self, event: AutocompleteInput.Submitted) -> None: + event.stop() + if not event.is_known: + return + tag = next( + (t for t in self._tags if t["name"].lower() == event.value.lower()), + None, + ) + if tag is None or tag["id"] in self._selected_tag_ids: + event.control.value = "" + return + self._selected_tag_ids.append(tag["id"]) + self._add_chip(tag) + self._refresh_tag_input_options() + event.control.value = "" + + def _add_new_tag(self) -> None: + def on_result(name: str | None) -> None: + if not name: + return + try: + self.db.add_metadata("tag", name) + self._tags = self.db.get_metadata("tag") + new_tag = next(t for t in self._tags if t["name"] == name) + self._selected_tag_ids.append(new_tag["id"]) + self._add_chip(new_tag) + self._refresh_tag_input_options() + except ValueError as e: + self.app.notify(str(e), severity="error") + + self.app.push_screen(MetadataFormModal("Add Tag"), on_result) + + def action_cancel(self) -> None: + self.dismiss(False) + + def _save(self) -> None: + date_val = self.query_one("#date", Input).value.strip() + amount_val = self.query_one("#amount", Input).value.strip() + category_name = self.query_one("#category", AutocompleteInput).value.strip() + merchant_name = self.query_one("#merchant", AutocompleteInput).value.strip() + notes_val = self.query_one("#notes", Input).value.strip() or None + tag_ids = list(self._selected_tag_ids) + + try: + date.fromisoformat(date_val) + except ValueError: + self.app.notify("Invalid date — use YYYY-MM-DD", severity="error") + return + + cents = parse_dollars(amount_val) + if cents is None or cents <= 0: + self.app.notify("Invalid amount", severity="error") + return + + if not category_name: + self.app.notify("Category is required", severity="error") + return + + try: + if self._spending_id: + self.db.update_spending( + self._spending_id, + date_val, + cents, + category_name, + merchant_name or None, + notes_val, + tag_ids, + ) + else: + self.db.add_spending( + date_val, + cents, + category_name, + merchant_name or None, + notes_val, + tag_ids, + ) + self.dismiss(True) + except Exception as e: + self.app.notify(str(e), severity="error") diff --git a/src/common_cents/screens/export_csv.py b/src/common_cents/screens/export_csv.py new file mode 100644 index 0000000..0a17d5c --- /dev/null +++ b/src/common_cents/screens/export_csv.py @@ -0,0 +1,186 @@ +from collections.abc import Iterable +from datetime import date +from pathlib import Path + +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.widgets import DirectoryTree, Header, Input, Label, Static + +from common_cents.csv_export import write_csv +from common_cents.screens._base import AppScreen +from common_cents.screens._confirm import ConfirmModal +from common_cents.widgets.footer import CenteredFooter + + +class _DirsAndCSVTree(DirectoryTree): + def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]: + return [p for p in paths if p.is_dir() or p.suffix.lower() == ".csv"] + + +class ExportScreen(AppScreen): + DEFAULT_CSS = """ + ExportScreen #browser-label, ExportScreen #filename-label { + height: auto; + padding: 1 2 0 2; + } + ExportScreen #path-input { + margin: 0 2; + } + ExportScreen #dir-tree { + height: 1fr; + border-bottom: solid $primary; + } + ExportScreen #filename { + margin: 0 2; + } + ExportScreen #status { + height: auto; + padding: 1 2; + } + """ + + BINDINGS = [ + Binding("ctrl+s", "do_export", "Export"), + Binding("escape", "cancel", "Back"), + Binding("j", "vi_down", show=False), + Binding("k", "vi_up", show=False), + Binding("g", "vi_top", show=False), + Binding("G", "vi_bottom", show=False), + ] + + def compose(self) -> ComposeResult: + yield Header() + with Vertical(): + yield Label( + "Select destination directory (or click an existing CSV to reuse its name):", + id="browser-label", + ) + yield Input( + value=str(Path.cwd()), + placeholder="/path/to/directory", + id="path-input", + ) + yield _DirsAndCSVTree(Path.cwd(), id="dir-tree") + yield Label("Filename:", id="filename-label") + yield Input( + value=f"common-cents-{date.today().isoformat()}.csv", + id="filename", + ) + yield Static(id="status") + yield CenteredFooter() + + def on_mount(self) -> None: + self.title = "Export CSV" + self._refresh_status() + + def _refresh_status(self) -> None: + n = len(self.db.get_spending_for_export()) + if n == 0: + self._set_status("No spending records to export.", error=True) + else: + self._set_status( + f"{n} record(s) ready to export. Press CTRL+S to export.", + error=False, + ) + + def on_directory_tree_file_selected( + self, event: DirectoryTree.FileSelected + ) -> None: + event.stop() + self.query_one("#filename", Input).value = event.path.name + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "filename": + self.action_do_export() + elif event.input.id == "path-input": + target = Path(event.value.strip()).expanduser() + if not target.is_dir(): + self.app.notify(f"Not a directory: {target}", severity="error") + return + tree = self.query_one("#dir-tree", DirectoryTree) + tree.path = target + tree.focus() + + def action_cancel(self) -> None: + self.app.pop_screen() + + def action_vi_down(self) -> None: + if self.focused: + self.focused.action_cursor_down() + + def action_vi_up(self) -> None: + if self.focused: + self.focused.action_cursor_up() + + def action_vi_top(self) -> None: + if self.focused: + self.focused.action_scroll_home() + + def action_vi_bottom(self) -> None: + if self.focused: + self.focused.action_scroll_end() + + def action_do_export(self) -> None: + rows = self.db.get_spending_for_export() + if not rows: + self.app.notify("No spending records to export", severity="error") + return + + filename = self.query_one("#filename", Input).value.strip() + if not filename: + self.app.notify("Filename is required", severity="error") + return + if not filename.lower().endswith(".csv"): + filename += ".csv" + + target_dir = self._selected_directory() + target = (target_dir / filename).resolve() + + if target.exists(): + self.app.push_screen( + ConfirmModal( + "Overwrite existing file?", + details=[str(target)], + confirm_label="Overwrite", + confirm_variant="warning", + ), + lambda confirmed: self._write(target, rows) if confirmed else None, + ) + else: + self._write(target, rows) + + def _selected_directory(self) -> Path: + tree = self.query_one(_DirsAndCSVTree) + node = tree.cursor_node + if node is None or node.data is None: + return Path.cwd() + p = Path(node.data.path) + return p if p.is_dir() else p.parent + + def _write(self, target: Path, rows: list[dict]) -> None: + try: + write_csv(target, rows) + except OSError as e: + self.app.notify(str(e), severity="error") + return + self.app.push_screen( + ConfirmModal( + f"Exported {len(rows)} record(s).", + details=[str(target)], + confirm_label="Export Another", + confirm_variant="success", + cancel_label="Main Menu", + focus_confirm=True, + ), + self._handle_export_result, + ) + + def _handle_export_result(self, another: bool) -> None: + if not another: + self.app.pop_screen() + + def _set_status(self, msg: str, *, error: bool) -> None: + style = "bold red" if error else "dim" + self.query_one("#status", Static).update(Text(msg, style=style)) diff --git a/src/common_cents/screens/import_csv.py b/src/common_cents/screens/import_csv.py new file mode 100644 index 0000000..c1c67e2 --- /dev/null +++ b/src/common_cents/screens/import_csv.py @@ -0,0 +1,247 @@ +from collections.abc import Iterable +from pathlib import Path + +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical +from textual.widgets import DataTable, DirectoryTree, Header, Input, Label, Static + +from common_cents.csv_import import parse_csv +from common_cents.money import format_cents +from common_cents.screens._base import AppScreen +from common_cents.screens._confirm import ConfirmModal +from common_cents.widgets.footer import CenteredFooter + + +class _CSVDirectoryTree(DirectoryTree): + def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]: + return [p for p in paths if p.is_dir() or p.suffix.lower() == ".csv"] + + +class ImportScreen(AppScreen): + DEFAULT_CSS = """ + ImportScreen #browser-label { + height: auto; + padding: 1 2 0 2; + } + ImportScreen #path-input { + margin: 0 2; + } + ImportScreen #file-tree { + height: 1fr; + border-bottom: solid $primary; + } + ImportScreen #status { + height: auto; + padding: 0 2; + } + ImportScreen #preview-area { + height: 2fr; + } + """ + + BINDINGS = [ + Binding("i", "do_import", "Import"), + Binding("escape", "cancel", "Back"), + Binding("j", "vi_down", show=False), + Binding("k", "vi_up", show=False), + Binding("g", "vi_top", show=False), + Binding("G", "vi_bottom", show=False), + ] + + def compose(self) -> ComposeResult: + yield Header() + with Vertical(): + yield Label( + "Select a CSV file (type a path below to browse elsewhere):", + id="browser-label", + ) + yield Input( + value=str(Path.cwd()), + placeholder="/path/to/directory", + id="path-input", + ) + yield _CSVDirectoryTree(Path.cwd(), id="file-tree") + yield Static(id="status") + table = DataTable(id="preview-area") + table.cursor_type = "row" + table.add_columns("Date", "Amount", "Category", "Merchant", "Notes", "Tags") + yield table + yield CenteredFooter() + + def on_mount(self) -> None: + self.title = "Import CSV" + self._parsed = None + self._duplicate_count = 0 + + def on_directory_tree_file_selected( + self, event: DirectoryTree.FileSelected + ) -> None: + event.stop() + self._load_file(event.path) + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id != "path-input": + return + target = Path(event.value.strip()).expanduser() + if not target.is_dir(): + self.app.notify(f"Not a directory: {target}", severity="error") + return + tree = self.query_one("#file-tree", DirectoryTree) + tree.path = target + tree.focus() + + def action_cancel(self) -> None: + self.app.pop_screen() + + def action_vi_down(self) -> None: + if self.focused: + self.focused.action_cursor_down() + + def action_vi_up(self) -> None: + if self.focused: + self.focused.action_cursor_up() + + def action_vi_top(self) -> None: + # DataTable.action_scroll_home moves the *column* cursor; action_scroll_top + # moves the row cursor. Tree's action_scroll_home already moves its cursor. + if isinstance(self.focused, DataTable): + self.focused.action_scroll_top() + elif self.focused: + self.focused.action_scroll_home() + + def action_vi_bottom(self) -> None: + if isinstance(self.focused, DataTable): + self.focused.action_scroll_bottom() + elif self.focused: + self.focused.action_scroll_end() + + def action_do_import(self) -> None: + if not self._parsed or not self._parsed.rows: + return + rows = [ + { + "date": r.date, + "cents": r.cents, + "category": r.category, + "merchant": r.merchant, + "notes": r.notes, + "tags": r.tags, + } + for r in self._parsed.rows + ] + try: + inserted, skipped = self.db.import_spending(rows) + self._parsed = None + self._duplicate_count = 0 + self._clear_preview() + self._set_status("", error=False) + msg = f"Successfully imported {inserted} spending record(s)." + if skipped: + msg += f" Skipped {skipped} duplicate(s)." + self.app.push_screen( + ConfirmModal( + msg, + confirm_label="Import Another", + confirm_variant="success", + cancel_label="Main Menu", + focus_confirm=True, + ), + self._handle_import_result, + ) + except Exception as e: + self.app.notify(str(e), severity="error") + + def _handle_import_result(self, another: bool) -> None: + if not another: + self.app.pop_screen() + + def _load_file(self, path: Path) -> None: + result = parse_csv(path) + + if result.errors: + self._set_status("\n".join(result.errors), error=True) + self._clear_preview() + self._parsed = None + self._duplicate_count = 0 + return + + self._parsed = result + self._render_preview(result) + self._duplicate_count = len( + self.db.find_duplicate_indices( + [ + { + "date": r.date, + "cents": r.cents, + "category": r.category, + "merchant": r.merchant, + "notes": r.notes, + "tags": r.tags, + } + for r in result.rows + ] + ) + ) + + warnings = list(result.warnings) + if self._duplicate_count: + warnings.append( + f"{self._duplicate_count} row(s) match an existing spending record " + "and will be skipped on import." + ) + + if warnings: + self.app.push_screen( + ConfirmModal( + "Import warnings", + details=warnings, + confirm_label="Continue", + confirm_variant="warning", + ), + self._handle_warning_response, + ) + else: + self._set_ready_status() + + def _set_ready_status(self) -> None: + n = len(self._parsed.rows) + dup = self._duplicate_count + if dup: + msg = ( + f"Ready to import {n - dup} row(s) " + f"({dup} duplicate(s) will be skipped). Press I to import." + ) + else: + msg = f"Ready to import {n} row(s). Press I to import." + self._set_status(msg, error=False) + + def _handle_warning_response(self, confirmed: bool) -> None: + if confirmed and self._parsed: + self._set_ready_status() + else: + self._parsed = None + self._duplicate_count = 0 + self._set_status("Import cancelled.", error=False) + self._clear_preview() + + def _render_preview(self, result) -> None: + table = self.query_one("#preview-area", DataTable) + table.clear() + for row in result.rows: + table.add_row( + row.date, + format_cents(row.cents), + row.category, + row.merchant or "", + row.notes or "", + ", ".join(row.tags), + ) + + def _clear_preview(self) -> None: + self.query_one("#preview-area", DataTable).clear() + + def _set_status(self, msg: str, *, error: bool) -> None: + style = "bold red" if error else "dim" + self.query_one("#status", Static).update(Text(msg, style=style)) diff --git a/src/common_cents/screens/main_menu.py b/src/common_cents/screens/main_menu.py new file mode 100644 index 0000000..0da5518 --- /dev/null +++ b/src/common_cents/screens/main_menu.py @@ -0,0 +1,316 @@ +from datetime import date, timedelta +from pathlib import Path + +from rich.console import Group +from rich.table import Table +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.widgets import Header, Static + +from common_cents.category_tree import CategoryNode, build_category_tree +from common_cents.money import format_budget_cell, format_cents +from common_cents.screens._base import AppScreen +from common_cents.widgets.footer import CenteredFooter + + +def _display_path(path: Path) -> str: + try: + return f"~/{path.relative_to(Path.home())}" + except ValueError: + return str(path) + + +def _has_active(node: CategoryNode, budget_paths: set[str]) -> bool: + """A node renders if it (or any descendant) has this-month spending or a + configured budget. Without this, prev-month-only branches would clutter the + list while still rolling up into ancestors' delta.""" + if node.total() > 0 or node.path in budget_paths: + return True + return any(_has_active(c, budget_paths) for c in node.children.values()) + + +def _format_delta(total: int, prev: int) -> Text: + delta = total - prev + if prev > 0: + pct = abs(delta) / prev * 100 + if delta > 0: + return Text(f"+{format_cents(delta)} ({pct:.0f}% ↑)", style="red") + if delta < 0: + return Text(f"-{format_cents(-delta)} ({pct:.0f}% ↓)", style="green") + return Text("—", style="dim") + if total > 0: + return Text(f"+{format_cents(total)}", style="red") + return Text("—", style="dim") + + +class MainMenuScreen(AppScreen): + BINDINGS = [ + Binding("s", "go_spending", "Spending"), + Binding("m", "go_metadata", "Metadata"), + Binding("r", "go_reports", "Reports"), + Binding("i", "go_import", "Import"), + Binding("x", "go_export", "Export"), + Binding("o", "go_options", "Options"), + Binding("q", "app.quit", "Quit"), + ] + + DEFAULT_CSS = """ + MainMenuScreen #stats { + height: 1fr; + } + MainMenuScreen #top-row { + height: auto; + } + /* top-categories is the primary panel — it claims 60% of the row width; + monthly-summary takes the remaining 1fr (= 40%). */ + MainMenuScreen #monthly-summary { + width: 1fr; + padding: 1 2; + border-right: solid $primary; + } + MainMenuScreen #top-categories { + width: 60%; + height: 100%; + padding: 1 2; + } + /* recent-spending fills the rest of the stats column and scrolls + internally so all 50 rows can be reached. */ + MainMenuScreen #recent-spending { + height: 1fr; + padding: 1 2; + border-top: solid $primary; + } + """ + + def compose(self) -> ComposeResult: + yield Header() + with Vertical(id="stats"): + with Horizontal(id="top-row"): + yield Static(id="monthly-summary") + with VerticalScroll(id="top-categories"): + yield Static(id="top-categories-content") + with VerticalScroll(id="recent-spending"): + yield Static(id="recent-spending-content") + yield CenteredFooter() + + def on_mount(self) -> None: + self._refresh_title() + self._refresh_stats() + + def on_screen_resume(self) -> None: + self._refresh_title() + self._refresh_stats() + + def _refresh_title(self) -> None: + self.title = f"Common Cents - DB: {_display_path(self.db.path)}" + + def action_go_spending(self) -> None: + from common_cents.screens.spending import SpendingScreen + + self.app.push_screen(SpendingScreen()) + + def action_go_metadata(self) -> None: + from common_cents.screens.metadata import MetadataScreen + + self.app.push_screen(MetadataScreen()) + + def action_go_reports(self) -> None: + from common_cents.screens.reports import ReportsScreen + + self.app.push_screen(ReportsScreen()) + + def action_go_import(self) -> None: + from common_cents.screens.import_csv import ImportScreen + + self.app.push_screen(ImportScreen()) + + def action_go_export(self) -> None: + from common_cents.screens.export_csv import ExportScreen + + self.app.push_screen(ExportScreen()) + + def action_go_options(self) -> None: + from common_cents.screens.options import OptionsScreen + + self.app.push_screen(OptionsScreen()) + + def _refresh_stats(self) -> None: + now = date.today() + year_totals = self.db.get_yearly_totals(now.year) + prev_dec_total = self.db.get_yearly_totals(now.year - 1).get(12, 0) + this_month = now.strftime("%Y-%m") + prev_month = (now.replace(day=1) - timedelta(days=1)).strftime("%Y-%m") + this_totals = self.db.get_category_totals_for_month(this_month) + prev_totals = self.db.get_category_totals_for_month(prev_month) + budgets = { + r["name"]: r["budget_cents"] + for r in self.db.get_metadata("category") + if r["budget_cents"] is not None + } + recent = self.db.get_recent_spending(limit=50) + + self.query_one("#monthly-summary", Static).update( + self._render_monthly(year_totals, prev_dec_total) + ) + self.query_one("#top-categories-content", Static).update( + self._render_top_categories(this_totals, prev_totals, budgets) + ) + self.query_one("#recent-spending-content", Static).update( + self._render_recent(recent) + ) + + def _render_monthly(self, year_totals: dict, prev_dec_total: int) -> Group: + now = date.today() + + t = Table(box=None, show_header=True, padding=(0, 1)) + t.add_column("MONTH", no_wrap=True) + t.add_column("AMOUNT") + t.add_column("DELTA") + + for m in range(1, 13): + month_name = date(now.year, m, 1).strftime("%B") + is_current = m == now.month + is_future = m > now.month + + if is_future: + t.add_row( + Text(month_name, style="dim"), + Text("—", style="dim"), + Text("—", style="dim"), + ) + continue + + total = year_totals.get(m, 0) + amount = Text(format_cents(total), style="bold" if is_current else "") + month_label = Text(month_name, style="bold" if is_current else "") + + prev_total = prev_dec_total if m == 1 else year_totals.get(m - 1, 0) + diff = _format_delta(total, prev_total) + + t.add_row(month_label, amount, diff) + + return Group( + Text( + f"Monthly Summary — {now.year}", + style="bold underline", + justify="center", + ), + t, + ) + + def _render_top_categories( + self, + this_totals: dict[str, int], + prev_totals: dict[str, int], + budgets: dict[str, int], + ) -> Group: + now = date.today() + + t = Table(box=None, show_header=True, padding=(0, 1)) + t.add_column("CATEGORY", no_wrap=True) + t.add_column("AMOUNT") + t.add_column("DELTA") + t.add_column("BUDGET") + + # Trie includes any path with this-month spending, prev-month spending, + # or a budget. Prev-month-only paths roll up into ancestors' delta but + # are filtered out at render so they don't clutter the list. + nodes = build_category_tree(this_totals, prev_totals, extra_paths=list(budgets)) + budget_paths = set(budgets) + rendered = self._render_top_nodes(t, nodes, budgets, budget_paths, depth=0) + + if not rendered: + t.add_row(Text("No spending this month", style="dim"), "", "", "") + + return Group( + Text( + f"Top Categories — {now.strftime('%B %Y')}", + style="bold underline", + justify="center", + ), + t, + ) + + def _render_top_nodes( + self, + table: Table, + nodes: list[CategoryNode], + budgets: dict[str, int], + budget_paths: set[str], + depth: int, + ) -> bool: + """Render visible nodes; return True if any row was added.""" + any_rendered = False + visible = sorted( + (n for n in nodes if _has_active(n, budget_paths)), + key=lambda n: n.total(), + reverse=True, + ) + for node in visible: + total = node.total() + prev = node.prev_total() + + if depth == 0: + label_cell = Text(node.name, style="bold") + else: + label_cell = Text(f"{' ' * depth}{node.name}", style="dim") + + budget_cell: Text = Text("") + if node.name != "(direct)": + budget_cents = budgets.get(node.path) + if budget_cents is not None: + budget_cell = format_budget_cell(total, budget_cents) + + table.add_row( + label_cell, + format_cents(total), + _format_delta(total, prev), + budget_cell, + ) + any_rendered = True + + children = list(node.children.values()) + if children and node.self_total > 0: + children = children + [ + CategoryNode( + name="(direct)", path=node.path, self_total=node.self_total + ) + ] + if children: + self._render_top_nodes( + table, children, budgets, budget_paths, depth + 1 + ) + return any_rendered + + def _render_recent(self, rows: list) -> Group: + # expand=True so the table fills the full-width recent panel; the other + # two tables are sized to natural width by their narrower panels. + t = Table(box=None, show_header=True, padding=(0, 1), expand=True) + t.add_column("DATE", no_wrap=True) + t.add_column("AMOUNT", justify="right", min_width=10) + t.add_column("CATEGORY", no_wrap=True) + t.add_column("MERCHANT", no_wrap=True, style="dim") + t.add_column("NOTES", style="dim", ratio=1) + + if rows: + for row in rows: + t.add_row( + row["date"], + format_cents(row["cents"]), + row["category"], + row["merchant"] or "", + row["notes"] or "", + ) + else: + t.add_row(Text("No spending recorded", style="dim"), "", "", "", "") + + return Group( + Text( + f"Recent Spending — Latest ({len(rows)}) Records", + style="bold underline", + justify="center", + ), + t, + ) diff --git a/src/common_cents/screens/metadata.py b/src/common_cents/screens/metadata.py new file mode 100644 index 0000000..be10fbe --- /dev/null +++ b/src/common_cents/screens/metadata.py @@ -0,0 +1,561 @@ +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, DataTable, Header, Input, Label, Select + +from common_cents.money import format_cents, format_cents_input, parse_dollars +from common_cents.screens._base import AppScreen +from common_cents.widgets.footer import CenteredFooter + +KINDS: list[tuple[str, str]] = [ + ("category", "Categories"), + ("merchant", "Merchants"), + ("tag", "Tags"), +] +_SINGULAR = {"category": "Category", "merchant": "Merchant", "tag": "Tag"} + + +def _table_id(kind: str) -> str: + return f"table-{kind}" + + +class MetadataFormModal(ModalScreen[str | None]): + """Edit a metadata row's name. Used for edit and inline tag-add.""" + + DEFAULT_CSS = """ + MetadataFormModal { + align: center middle; + } + MetadataFormModal > Vertical { + width: 48; + height: auto; + background: $surface; + border: thick $primary; + padding: 1 2; + } + MetadataFormModal Label { + margin-bottom: 1; + } + MetadataFormModal .field-label { + color: $text-muted; + text-style: bold; + margin-top: 1; + margin-bottom: 0; + } + MetadataFormModal Horizontal { + height: auto; + margin-top: 1; + align: right middle; + } + MetadataFormModal Button { + margin-left: 1; + } + """ + + BINDINGS = [Binding("escape", "cancel", "Cancel", show=False)] + + def __init__(self, title: str, initial: str = "") -> None: + super().__init__() + self._title = title + self._initial = initial + + def compose(self) -> ComposeResult: + with Vertical(): + yield Label(self._title) + yield Label("Name", classes="field-label") + yield Input(value=self._initial, placeholder="Required") + with Horizontal(): + yield Button("Save", variant="primary", id="save") + yield Button("Cancel", id="cancel") + + def on_mount(self) -> None: + self.query_one(Input).focus() + + def on_input_submitted(self) -> None: + self._save() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "save": + self._save() + else: + self.dismiss(None) + + def action_cancel(self) -> None: + self.dismiss(None) + + def _save(self) -> None: + value = self.query_one(Input).value.strip() + if value: + self.dismiss(value) + + +class MetadataAddModal(ModalScreen[tuple[str, str, int | None] | None]): + """Add a new metadata row. Includes a kind selector and (when kind is + category) an optional monthly budget input.""" + + DEFAULT_CSS = """ + MetadataAddModal { + align: center middle; + } + MetadataAddModal > Vertical { + width: 56; + height: auto; + background: $surface; + border: thick $primary; + padding: 1 2; + } + MetadataAddModal Label { + margin-bottom: 1; + } + MetadataAddModal .field-label { + color: $text-muted; + text-style: bold; + margin-top: 1; + margin-bottom: 0; + } + MetadataAddModal .budget-row.hidden { + display: none; + } + MetadataAddModal Horizontal { + height: auto; + margin-top: 1; + align: right middle; + } + MetadataAddModal Button { + margin-left: 1; + } + """ + + BINDINGS = [Binding("escape", "cancel", "Cancel", show=False)] + + def __init__(self, initial_kind: str = "category") -> None: + super().__init__() + self._initial_kind = initial_kind + + def compose(self) -> ComposeResult: + with Vertical(): + yield Label("Add Metadata") + yield Label("Kind", classes="field-label") + yield Select( + options=[(title, kind) for kind, title in KINDS], + value=self._initial_kind, + allow_blank=False, + id="kind-select", + ) + yield Label("Name", classes="field-label") + yield Input(placeholder="Required", id="name-input") + budget_label = Label("Monthly budget", classes="field-label budget-row") + budget = Input( + placeholder="e.g. 500.00", + id="budget-input", + classes="budget-row", + ) + if self._initial_kind != "category": + budget_label.add_class("hidden") + budget.add_class("hidden") + yield budget_label + yield budget + with Horizontal(): + yield Button("Save", variant="primary", id="save") + yield Button("Cancel", id="cancel") + + def on_mount(self) -> None: + self.query_one("#name-input", Input).focus() + + def on_select_changed(self, event: Select.Changed) -> None: + if event.select.id != "kind-select": + return + show_budget = str(event.value) == "category" + for w in self.query(".budget-row"): + w.set_class(not show_budget, "hidden") + if not show_budget: + self.query_one("#budget-input", Input).value = "" + + def on_input_submitted(self) -> None: + self._save() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "save": + self._save() + else: + self.dismiss(None) + + def action_cancel(self) -> None: + self.dismiss(None) + + def _save(self) -> None: + name = self.query_one("#name-input", Input).value.strip() + kind = str(self.query_one("#kind-select", Select).value) + if not name or not kind: + return + budget_cents: int | None = None + if kind == "category": + raw = self.query_one("#budget-input", Input).value.strip() + if raw: + budget_cents = parse_dollars(raw) + if budget_cents is None or budget_cents <= 0: + self.app.notify( + "Budget must be a positive amount or blank", + severity="error", + ) + return + self.dismiss((kind, name, budget_cents)) + + +class CategoryFormModal(ModalScreen[tuple[str, int | None] | None]): + """Edit a category's name and optional monthly budget. + + Returns ``(name, budget_cents)``. ``budget_cents`` is ``None`` when the + user left the field blank — caller should treat that as "clear any + existing budget". + """ + + DEFAULT_CSS = """ + CategoryFormModal { + align: center middle; + } + CategoryFormModal > Vertical { + width: 56; + height: auto; + background: $surface; + border: thick $primary; + padding: 1 2; + } + CategoryFormModal Label { + margin-bottom: 1; + } + CategoryFormModal .field-label { + color: $text-muted; + text-style: bold; + margin-top: 1; + margin-bottom: 0; + } + CategoryFormModal Horizontal { + height: auto; + margin-top: 1; + align: right middle; + } + CategoryFormModal Button { + margin-left: 1; + } + """ + + BINDINGS = [Binding("escape", "cancel", "Cancel", show=False)] + + def __init__( + self, + title: str, + initial_name: str = "", + initial_budget_cents: int | None = None, + ) -> None: + super().__init__() + self._title = title + self._initial_name = initial_name + self._initial_budget = ( + format_cents_input(initial_budget_cents) if initial_budget_cents else "" + ) + + def compose(self) -> ComposeResult: + with Vertical(): + yield Label(self._title) + yield Label("Name", classes="field-label") + yield Input( + value=self._initial_name, placeholder="Required", id="name-input" + ) + yield Label("Monthly budget", classes="field-label") + yield Input( + value=self._initial_budget, + placeholder="e.g. 500.00 — blank to clear", + id="budget-input", + ) + with Horizontal(): + yield Button("Save", variant="primary", id="save") + yield Button("Cancel", id="cancel") + + def on_mount(self) -> None: + self.query_one("#name-input", Input).focus() + + def on_input_submitted(self) -> None: + self._save() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "save": + self._save() + else: + self.dismiss(None) + + def action_cancel(self) -> None: + self.dismiss(None) + + def _save(self) -> None: + name = self.query_one("#name-input", Input).value.strip() + if not name: + self.app.notify("Name is required", severity="error") + return + raw = self.query_one("#budget-input", Input).value.strip() + budget_cents: int | None = None + if raw: + budget_cents = parse_dollars(raw) + if budget_cents is None or budget_cents <= 0: + self.app.notify( + "Budget must be a positive amount or blank", severity="error" + ) + return + self.dismiss((name, budget_cents)) + + +class MetadataScreen(AppScreen): + TITLE = "Metadata" + + DEFAULT_CSS = """ + MetadataScreen #panels { + height: 1fr; + } + MetadataScreen .panel { + width: 1fr; + height: 1fr; + border-right: solid $primary; + } + MetadataScreen .panel.category { + min-width: 48; + } + MetadataScreen .panel.last { + border-right: none; + } + MetadataScreen .panel-title { + text-align: center; + background: $primary; + color: $background; + text-style: bold; + padding: 0 1; + height: 1; + } + MetadataScreen DataTable { + height: 1fr; + } + """ + + BINDINGS = [ + Binding("escape", "app.pop_screen", "Back"), + Binding("a", "add", "Add"), + Binding("e", "edit", "Edit"), + Binding("d", "delete", "Delete"), + Binding("c", "focus_kind('category')", show=False), + Binding("m", "focus_kind('merchant')", show=False), + Binding("t", "focus_kind('tag')", show=False), + Binding("j", "vi_down", show=False), + Binding("k", "vi_up", show=False), + Binding("g", "vi_top", show=False), + Binding("G", "vi_bottom", show=False), + ] + + def __init__(self) -> None: + super().__init__() + self._rows: dict[str, list] = {kind: [] for kind, _ in KINDS} + + def compose(self) -> ComposeResult: + yield Header() + with Horizontal(id="panels"): + for i, (kind, title) in enumerate(KINDS): + classes = f"panel {kind}" + if i == len(KINDS) - 1: + classes += " last" + with Vertical(classes=classes): + yield Label(title.upper(), classes="panel-title") + table = DataTable(id=_table_id(kind)) + table.cursor_type = "row" + yield table + yield CenteredFooter() + + def on_mount(self) -> None: + for kind, _ in KINDS: + self._reload(kind) + self._table("category").focus() + + # --- Lookup helpers --- + + def _table(self, kind: str) -> DataTable: + return self.query_one(f"#{_table_id(kind)}", DataTable) + + def _active_kind(self) -> str: + focused = self.focused + if ( + focused is not None + and focused.id is not None + and focused.id.startswith("table-") + ): + kind = focused.id.removeprefix("table-") + if kind in _SINGULAR: + return kind + return "category" + + def _reload(self, kind: str, restore_row: int = 0) -> None: + self._rows[kind] = self.db.get_metadata(kind) + self._render_table(kind, restore_row) + + def _render_table(self, kind: str, restore_row: int = 0) -> None: + rows = self._rows[kind] + table = self._table(kind) + table.clear(columns=True) + table.add_column("Name") + if kind == "category": + table.add_column("Budget", width=12) + max_chars = self._name_max_chars(kind) + for row in rows: + cells: list = [self._format_name(row["name"], row["usage_count"], max_chars)] + if kind == "category": + budget = row["budget_cents"] + cells.append(format_cents(budget) if budget else "—") + table.add_row(*cells) + if rows: + table.move_cursor(row=min(restore_row, len(rows) - 1)) + + def _name_max_chars(self, kind: str) -> int: + """Available characters for the Name column based on current panel width. + + Returns a large value before layout has happened, so initial render + shows full names; ``on_resize`` re-renders with the real width. + """ + panel = self._table(kind).parent + width = panel.size.width if panel is not None else 0 + if width <= 0: + return 999 + # panel: -1 border-right, -2 cell padding around Name column + avail = width - 1 - 2 + if kind == "category": + avail -= 14 # Budget column render width (12 + 2 padding) + return max(4, avail) + + @staticmethod + def _format_name(name: str, usage_count: int, max_chars: int) -> str | Text: + display = ( + name[: max(1, max_chars - 1)] + "…" if len(name) > max_chars else name + ) + if usage_count == 0: + return Text(display, style="yellow") + return display + + def on_resize(self) -> None: + for kind, _ in KINDS: + cursor = self._table(kind).cursor_row + self._render_table(kind, restore_row=max(cursor, 0)) + + # --- Focus / vim navigation, scoped to the active column --- + + def action_focus_kind(self, kind: str) -> None: + self._table(kind).focus() + + def action_vi_down(self) -> None: + self._table(self._active_kind()).action_cursor_down() + + def action_vi_up(self) -> None: + self._table(self._active_kind()).action_cursor_up() + + def action_vi_top(self) -> None: + self._table(self._active_kind()).move_cursor(row=0) + + def action_vi_bottom(self) -> None: + kind = self._active_kind() + if self._rows[kind]: + self._table(kind).move_cursor(row=len(self._rows[kind]) - 1) + + # --- CRUD --- + + def _cursor_row(self, kind: str) -> int: + return self._table(kind).cursor_row + + def action_add(self) -> None: + initial_kind = self._active_kind() + + def on_result(result: tuple[str, str, int | None] | None) -> None: + if not result: + return + kind, name, budget = result + try: + new_id = self.db.add_metadata(kind, name) + except ValueError as e: + self.app.notify(str(e), severity="error") + return + if kind == "category" and budget is not None: + self.db.set_budget(new_id, budget) + self._reload(kind, restore_row=len(self._rows[kind])) + self._table(kind).focus() + + self.app.push_screen(MetadataAddModal(initial_kind=initial_kind), on_result) + + def action_edit(self) -> None: + kind = self._active_kind() + rows = self._rows[kind] + if not rows: + return + row_pos = self._cursor_row(kind) + entity = rows[row_pos] + entity_id, current_name = entity["id"], entity["name"] + + if kind == "category": + current_budget = entity["budget_cents"] + + def on_cat_result(result: tuple[str, int | None] | None) -> None: + if result is None: + return + name, budget = result + try: + self.db.update_metadata(entity_id, name) + except ValueError as e: + self.app.notify(str(e), severity="error") + return + if budget is None: + self.db.clear_budget(entity_id) + else: + self.db.set_budget(entity_id, budget) + self._reload(kind, restore_row=row_pos) + + self.app.push_screen( + CategoryFormModal( + f"Edit {_SINGULAR[kind]}", + initial_name=current_name, + initial_budget_cents=current_budget, + ), + on_cat_result, + ) + return + + def on_result(name: str | None) -> None: + if name: + try: + self.db.update_metadata(entity_id, name) + self._reload(kind, restore_row=row_pos) + except ValueError as e: + self.app.notify(str(e), severity="error") + + self.app.push_screen( + MetadataFormModal(f"Edit {_SINGULAR[kind]}", initial=current_name), + on_result, + ) + + def action_delete(self) -> None: + kind = self._active_kind() + rows = self._rows[kind] + if not rows: + return + from common_cents.screens._confirm import ConfirmModal + + row_pos = self._cursor_row(kind) + entity = rows[row_pos] + entity_id, name = entity["id"], entity["name"] + + def on_confirmed(confirmed: bool) -> None: + if not confirmed: + return + try: + self.db.delete_metadata(entity_id) + self._reload(kind, restore_row=row_pos) + except ValueError as e: + self.app.notify(str(e), severity="error") + + self.app.push_screen( + ConfirmModal(f"Delete {_SINGULAR[kind].lower()} '{name}'?"), + on_confirmed, + ) diff --git a/src/common_cents/screens/options.py b/src/common_cents/screens/options.py new file mode 100644 index 0000000..75f743d --- /dev/null +++ b/src/common_cents/screens/options.py @@ -0,0 +1,209 @@ +from collections.abc import Iterable +from pathlib import Path + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.widgets import Button, DirectoryTree, Header, Input, Label, Static + +from common_cents.db import default_db_path +from common_cents.screens._base import AppScreen +from common_cents.screens._confirm import ConfirmModal +from common_cents.widgets.footer import CenteredFooter + + +class _DBDirectoryTree(DirectoryTree): + def filter_paths(self, paths: Iterable[Path]) -> Iterable[Path]: + return [ + p for p in paths if p.is_dir() or p.suffix.lower() in (".db", ".sqlite") + ] + + +def _display_path(path: Path) -> str: + try: + return f"~/{path.relative_to(Path.home())}" + except ValueError: + return str(path) + + +class OptionsScreen(AppScreen): + DEFAULT_CSS = """ + OptionsScreen #current-db { + height: auto; + padding: 1 2; + border-bottom: solid $primary; + } + OptionsScreen #browser-label { + height: auto; + padding: 1 2 0 2; + } + OptionsScreen #db-tree { + height: 1fr; + border-bottom: solid $primary; + } + OptionsScreen #new-db-row { + height: auto; + padding: 1 2; + } + OptionsScreen #new-db-row Label { + height: 1; + color: $text-muted; + text-style: bold; + } + OptionsScreen #new-db-row Input { + width: 1fr; + } + OptionsScreen #new-db-row Button { + width: auto; + margin-left: 1; + } + """ + + BINDINGS = [ + Binding("escape", "app.pop_screen", "Back"), + Binding("r", "reset_default", "Reset to Default"), + Binding("j", "vi_down", show=False), + Binding("k", "vi_up", show=False), + Binding("g", "vi_top", show=False), + Binding("G", "vi_bottom", show=False), + ] + + def compose(self) -> ComposeResult: + yield Header() + with Vertical(): + yield Static(id="current-db") + yield Label( + "Select an existing .db / .sqlite file to switch:", + id="browser-label", + ) + yield _DBDirectoryTree(Path.home(), id="db-tree") + with Vertical(id="new-db-row"): + yield Label("Or enter a path to create / open a new database:") + with Horizontal(): + yield Input( + placeholder="/path/to/new.db", + id="new-db-input", + ) + yield Button("Switch", id="switch-button", variant="primary") + yield CenteredFooter() + + def on_mount(self) -> None: + self.title = "Options" + self._refresh_current() + + def _refresh_current(self) -> None: + self.query_one("#current-db", Static).update( + f"Active database: [bold]{_display_path(self.db.path)}[/bold]\n" + f"Default location: {_display_path(default_db_path())}" + ) + + def on_directory_tree_file_selected( + self, event: DirectoryTree.FileSelected + ) -> None: + event.stop() + self._prompt_switch(event.path) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "switch-button": + self._submit_input() + + def on_input_submitted(self, event: Input.Submitted) -> None: + if event.input.id == "new-db-input": + self._submit_input() + + def _submit_input(self) -> None: + raw = self.query_one("#new-db-input", Input).value.strip() + if not raw: + self.app.notify("Enter a path first.", severity="warning") + return + self._prompt_switch(Path(raw).expanduser()) + + def _prompt_switch(self, path: Path) -> None: + path = path.expanduser() + if path.resolve() == self.db.path.resolve(): + self.app.notify("That database is already active.", severity="warning") + return + + is_new = not path.exists() + message = ( + f"Create new database at {_display_path(path)}?" + if is_new + else f"Switch active database to {_display_path(path)}?" + ) + details = [ + f"Current: {_display_path(self.db.path)}", + f"New: {_display_path(path)}", + ] + + def on_confirm(confirmed: bool) -> None: + if not confirmed: + return + try: + self.app.switch_db(path) + except Exception as e: + self.app.notify(f"Switch failed: {e}", severity="error") + return + self._refresh_current() + self.query_one("#new-db-input", Input).value = "" + verb = "Created and switched" if is_new else "Switched" + self.app.notify(f"{verb} to {_display_path(path)}.") + + self.app.push_screen( + ConfirmModal( + message, + details=details, + confirm_label="Create" if is_new else "Switch", + confirm_variant="success" if is_new else "warning", + cancel_label="Cancel", + focus_confirm=True, + ), + on_confirm, + ) + + def action_reset_default(self) -> None: + target = default_db_path() + if target.resolve() == self.db.path.resolve(): + self.app.notify("Already using the default database.", severity="warning") + return + + def on_confirm(confirmed: bool) -> None: + if not confirmed: + return + try: + self.app.reset_db_to_default() + except Exception as e: + self.app.notify(f"Reset failed: {e}", severity="error") + return + self._refresh_current() + self.app.notify(f"Reset to default: {_display_path(target)}.") + + self.app.push_screen( + ConfirmModal( + "Reset to the default database?", + details=[ + f"Current: {_display_path(self.db.path)}", + f"Default: {_display_path(target)}", + ], + confirm_label="Reset", + confirm_variant="warning", + cancel_label="Cancel", + focus_confirm=True, + ), + on_confirm, + ) + + def action_vi_down(self) -> None: + if self.focused: + self.focused.action_cursor_down() + + def action_vi_up(self) -> None: + if self.focused: + self.focused.action_cursor_up() + + def action_vi_top(self) -> None: + if self.focused: + self.focused.action_scroll_home() + + def action_vi_bottom(self) -> None: + if self.focused: + self.focused.action_scroll_end() diff --git a/src/common_cents/screens/reports.py b/src/common_cents/screens/reports.py new file mode 100644 index 0000000..b4dfdef --- /dev/null +++ b/src/common_cents/screens/reports.py @@ -0,0 +1,414 @@ +import calendar +from datetime import date, timedelta + +import rich.box +from rich.console import Console, ConsoleOptions, RenderResult +from rich.measure import Measurement +from rich.table import Table +from rich.text import Text +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical, VerticalScroll +from textual.validation import Function +from textual.widgets import ( + Button, + Header, + Input, + Label, + Select, + Static, +) + +from common_cents.category_tree import CategoryNode, build_category_tree +from common_cents.money import format_budget_cell, format_cents, parse_dollars +from common_cents.screens._base import AppScreen +from common_cents.widgets.footer import CenteredFooter + + +def _valid_filter_dollars(value: str) -> bool: + return not value.strip() or parse_dollars(value) is not None + + +def _months_in_range(start_iso: str, end_iso: str) -> int: + """Distinct calendar months touched by the inclusive range [start, end].""" + s = date.fromisoformat(start_iso) + e = date.fromisoformat(end_iso) + return (e.year - s.year) * 12 + (e.month - s.month) + 1 + + +def _month_first(ym: str) -> date: + """Parse YYYY-MM into the first day of that month. Raises ValueError on bad input.""" + return date.fromisoformat(f"{ym}-01") + + +def _month_last(ym: str) -> date: + d = _month_first(ym) + return d.replace(day=calendar.monthrange(d.year, d.month)[1]) + + +def _month_options(earliest: date, latest: date) -> list[tuple[str, str]]: + """Build (label, value) pairs descending from latest to earliest, e.g. + ("May 2026", "2026-05"). Latest first so the dropdown opens with recent + months at the top.""" + options: list[tuple[str, str]] = [] + y, m = latest.year, latest.month + while (y, m) >= (earliest.year, earliest.month): + d = date(y, m, 1) + options.append((d.strftime("%b %Y"), d.strftime("%Y-%m"))) + m -= 1 + if m == 0: + m = 12 + y -= 1 + return options + + +DATE_RANGE_OPTIONS: list[tuple[str, str]] = [ + ("This Month", "this-month"), + ("Last Month", "last-month"), + ("YTD", "ytd"), + ("Last 12 Months", "last-12"), + ("Custom", "custom"), +] + + +def _date_range( + key: str, custom_from: str = "", custom_to: str = "" +) -> tuple[str, str, str]: + today = date.today() + if key == "this-month": + start = today.replace(day=1) + end = _month_last(today.strftime("%Y-%m")) + label = f"This Month ({today.strftime('%B %Y')})" + elif key == "last-month": + end = today.replace(day=1) - timedelta(days=1) + start = end.replace(day=1) + label = f"Last Month ({end.strftime('%B %Y')})" + elif key == "ytd": + start = today.replace(month=1, day=1) + end = today + label = f"Year to Date ({today.year})" + elif key == "last-12": + # Trailing 12 calendar months: current month plus the 11 preceding it. + # Going back a full year (today.year - 1, today.month, 1) would span + # 13 distinct months because both the start month and today's month + # would be included. + end = today + m = today.month - 11 + y = today.year + if m <= 0: + m += 12 + y -= 1 + start = date(y, m, 1) + label = ( + f"Last 12 Months " f"({start.strftime('%b %Y')} – {end.strftime('%b %Y')})" + ) + else: # "custom" + from_ym = custom_from or today.strftime("%Y-%m") + to_ym = custom_to or today.strftime("%Y-%m") + if from_ym > to_ym: + from_ym, to_ym = to_ym, from_ym + start = _month_first(from_ym) + end = _month_last(to_ym) + if from_ym == to_ym: + label = f"Custom ({start.strftime('%b %Y')})" + else: + label = ( + f"Custom ({start.strftime('%b %Y')} – {end.strftime('%b %Y')})" + ) + return start.isoformat(), end.isoformat(), label + + +class _ProgressBar: + """Bar renderable that fills the available cell width.""" + + def __init__(self, pct: float, style: str = "green") -> None: + self.pct = pct + self.style = style + + def __rich_console__( + self, console: Console, options: ConsoleOptions + ) -> RenderResult: + width = max(1, options.max_width) + filled = round(self.pct / 100 * width) + yield Text("█" * filled + "░" * (width - filled), style=self.style) + + def __rich_measure__( + self, console: Console, options: ConsoleOptions + ) -> Measurement: + return Measurement(8, options.max_width) + + +class ReportsScreen(AppScreen): + DEFAULT_CSS = """ + ReportsScreen #controls-row { + height: auto; + padding: 1 2; + border-bottom: solid $primary; + } + ReportsScreen .control-cell { + width: 1fr; + height: auto; + padding-right: 2; + } + ReportsScreen .control-cell.custom-only { + display: none; + } + ReportsScreen .control-cell Label { + height: 1; + color: $text-muted; + text-style: bold; + } + ReportsScreen .control-cell Select, ReportsScreen .control-cell Input { + width: 1fr; + min-width: 0; + } + ReportsScreen .control-action { + width: auto; + padding-right: 0; + } + ReportsScreen #filter-clear { + min-width: 10; + } + ReportsScreen #report-content { + padding: 1 2; + height: 1fr; + } + """ + + BINDINGS = [ + Binding("escape", "app.pop_screen", "Back"), + ] + + def __init__(self) -> None: + super().__init__() + self._range_key = "this-month" + self._filters: dict = { + "category": "", + "tag": "", + "min_cents": None, + "max_cents": None, + } + + def compose(self) -> ComposeResult: + yield Header() + with Vertical(): + with Horizontal(id="controls-row"): + with Vertical(classes="control-cell", id="cell-select"): + yield Label("Date Range") + yield Select( + options=DATE_RANGE_OPTIONS, + value="this-month", + allow_blank=False, + id="date-range-selector", + ) + month_options = self._build_month_options() + this_ym = date.today().strftime("%Y-%m") + with Vertical(classes="control-cell custom-only", id="cell-from"): + yield Label("From Month") + yield Select( + options=month_options, + value=this_ym, + allow_blank=False, + id="custom-from", + ) + with Vertical(classes="control-cell custom-only", id="cell-to"): + yield Label("To Month") + yield Select( + options=month_options, + value=this_ym, + allow_blank=False, + id="custom-to", + ) + with Vertical(classes="control-cell"): + yield Label("Category") + yield Input(placeholder="filter…", id="filter-category") + with Vertical(classes="control-cell"): + yield Label("Tag") + yield Input(placeholder="filter…", id="filter-tag") + with Vertical(classes="control-cell"): + yield Label("Min $") + yield Input( + placeholder="0.00", + id="filter-min", + validators=[Function(_valid_filter_dollars, "Invalid amount")], + ) + with Vertical(classes="control-cell"): + yield Label("Max $") + yield Input( + placeholder="any", + id="filter-max", + validators=[Function(_valid_filter_dollars, "Invalid amount")], + ) + with Vertical(classes="control-cell control-action"): + yield Label(" ") + yield Button("Clear", id="filter-clear") + with VerticalScroll(id="report-content"): + yield Static(id="report-table") + yield CenteredFooter() + + def on_mount(self) -> None: + self.title = "Reports" + self._render_report() + + def on_select_changed(self, event: Select.Changed) -> None: + select_id = event.select.id + if select_id == "date-range-selector": + self._range_key = str(event.value) + is_custom = self._range_key == "custom" + for cell_id in ("#cell-from", "#cell-to"): + self.query_one(cell_id).display = is_custom + self._render_report() + elif select_id in ("custom-from", "custom-to"): + self._render_report() + + def _build_month_options(self) -> list[tuple[str, str]]: + """Span from the earliest spending month (or 12 months back if the + DB is empty) to the current month.""" + today = date.today() + earliest_iso = self.db.get_earliest_spending_date() + if earliest_iso: + earliest = date.fromisoformat(earliest_iso).replace(day=1) + else: + y, m = today.year, today.month - 11 + if m <= 0: + m += 12 + y -= 1 + earliest = date(y, m, 1) + return _month_options(earliest, today) + + def on_input_changed(self, event: Input.Changed) -> None: + input_id = event.input.id + if not input_id: + return + # Skip re-rendering on invalid input — the :invalid pseudo-class paints + # the field red and the previous valid filter result keeps showing. + if event.validation_result and not event.validation_result.is_valid: + return + if input_id in ("custom-from", "custom-to"): + self._render_report() + return + if not input_id.startswith("filter-"): + return + field = input_id[len("filter-") :] + if field == "min": + self._filters["min_cents"] = parse_dollars(event.value) + elif field == "max": + self._filters["max_cents"] = parse_dollars(event.value) + else: + self._filters[field] = event.value.strip() + self._render_report() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "filter-clear": + for input_id in ( + "filter-category", + "filter-tag", + "filter-min", + "filter-max", + ): + self.query_one(f"#{input_id}", Input).value = "" + + def _render_report(self) -> None: + custom_from = "" + custom_to = "" + if self._range_key == "custom": + custom_from = str(self.query_one("#custom-from", Select).value) + custom_to = str(self.query_one("#custom-to", Select).value) + start, end, label = _date_range(self._range_key, custom_from, custom_to) + rows = self.db.get_spending_by_category(start, end, **self._filters) + + # Scale monthly budgets to the displayed period: a $500/month budget + # becomes $2500 for a 5-month YTD window, etc. Keyed by category path + # so the reports trie can look up the budget for any node by node.path. + months = _months_in_range(start, end) + period_budgets = { + r["name"]: r["budget_cents"] * months + for r in self.db.get_metadata("category") + if r["budget_cents"] is not None + } + + self.query_one("#report-table", Static).update( + self._build_category_table(label, rows, period_budgets) + ) + + def _build_category_table( + self, label: str, rows: list, period_budgets: dict[str, int] + ) -> Table: + active = [v for v in self._filters.values() if v] + filter_note = f" [{len(active)} filter(s) active]" if active else "" + + totals = {r["name"]: r["total"] for r in rows} + nodes = build_category_tree(totals) if rows else [] + grand_total = sum(n.total() for n in nodes) + + t = Table( + box=rich.box.SQUARE, + show_header=True, + padding=(0, 1), + header_style="bold", + expand=True, + title=( + f"Total: {format_cents(grand_total)}{filter_note}" if nodes else None + ), + title_style="bold", + ) + t.add_column("Category", no_wrap=True) + t.add_column("Amount", justify="right", min_width=12) + t.add_column("% of Total", justify="right", min_width=10) + t.add_column("Budget", min_width=22) + t.add_column(label, ratio=1) + + if not nodes: + t.add_row(Text("No spending for this period", style="dim"), "", "", "", "") + return t + + self._render_nodes(t, nodes, grand_total, period_budgets, depth=0) + return t + + def _render_nodes( + self, + table: Table, + nodes: list[CategoryNode], + grand_total: int, + period_budgets: dict[str, int], + depth: int, + ) -> None: + for node in sorted(nodes, key=lambda n: n.total(), reverse=True): + node_total = node.total() + pct = node_total / grand_total * 100 if grand_total else 0 + + if depth == 0: + label_cell = Text(node.name, style="bold") + else: + label_cell = Text(f"{' ' * depth}{node.name}", style="dim") + + # Skip the budget cell on synthetic "(direct)" rows — the parent + # already shows the comparison; duplicating would double-bill. + budget_cell: Text = Text("") + if node.name != "(direct)": + period_budget = period_budgets.get(node.path) + if period_budget is not None: + budget_cell = format_budget_cell(node_total, period_budget) + + table.add_row( + label_cell, + format_cents(node_total), + f"{pct:.1f}%", + budget_cell, + _ProgressBar(pct), + ) + + children = list(node.children.values()) + if children and node.self_total > 0: + # The user recorded spending against this exact path AND deeper + # paths exist underneath it. Surface the parent's own spending + # as a synthetic "(direct)" leaf so the totals reconcile. + children = children + [ + CategoryNode( + name="(direct)", path=node.path, self_total=node.self_total + ) + ] + if children: + self._render_nodes( + table, children, grand_total, period_budgets, depth + 1 + ) diff --git a/src/common_cents/screens/spending.py b/src/common_cents/screens/spending.py new file mode 100644 index 0000000..00f2deb --- /dev/null +++ b/src/common_cents/screens/spending.py @@ -0,0 +1,395 @@ +import calendar +from datetime import date + +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.coordinate import Coordinate +from textual.validation import Function +from textual.widgets import Button, DataTable, Header, Input, Label + +from common_cents.money import format_cents, parse_dollars +from common_cents.screens._base import AppScreen +from common_cents.widgets.footer import CenteredFooter + +_SEL_MARK = "[b cyan]●[/]" +_SEL_BLANK = " " + + +def _parse_date(value: str) -> str | None: + value = value.strip() + try: + date.fromisoformat(value) + return value + except ValueError: + return None + + +def _valid_filter_date(value: str) -> bool: + """Blank is a valid filter (means 'no bound'); anything else must parse.""" + return not value.strip() or _parse_date(value) is not None + + +def _valid_filter_dollars(value: str) -> bool: + return not value.strip() or parse_dollars(value) is not None + + +def _month_bounds(today: date) -> tuple[str, str]: + last_day = calendar.monthrange(today.year, today.month)[1] + return today.replace(day=1).isoformat(), today.replace(day=last_day).isoformat() + + +class SpendingScreen(AppScreen): + DEFAULT_CSS = """ + SpendingScreen #filter-panel { + height: auto; + padding: 0 2 1 2; + border-bottom: solid $primary; + } + SpendingScreen #filter-row-1, + SpendingScreen #filter-row-2 { + height: auto; + margin-top: 1; + } + SpendingScreen .filter-group { + width: 1fr; + height: auto; + } + SpendingScreen .filter-group Label { + height: 1; + color: $text-muted; + text-style: bold; + } + SpendingScreen .filter-action { + width: auto; + } + SpendingScreen #filter-clear { + min-width: 10; + } + """ + + TITLE = "Spending" + BINDINGS = [ + Binding("escape", "back_or_clear", "Back"), + Binding("a", "add", "Add"), + Binding("e", "edit", "Edit"), + Binding("d", "delete", "Delete"), + Binding("u", "undo", "Undo"), + Binding("space", "toggle_select", "Select"), + Binding("asterisk", "toggle_select_all", "All"), + Binding("b", "bulk_edit", "Bulk Edit"), + Binding("j", "vi_down", show=False), + Binding("k", "vi_up", show=False), + Binding("g", "vi_top", show=False), + Binding("G", "vi_bottom", show=False), + ] + + def __init__(self) -> None: + super().__init__() + self._rows: list = [] + # Single-step undo buffer for the last delete on this screen instance. + # Cleared after restore. Reset whenever the user re-enters the screen. + self._undo: dict | None = None + # Persists across filter changes — selection is by spending id, not row index. + self._selected_ids: set[int] = set() + start, end = _month_bounds(date.today()) + self._filters: dict = { + "start": start, + "end": end, + "category": "", + "merchant": "", + "tag": "", + "notes": "", + "min_cents": None, + "max_cents": None, + } + + def compose(self) -> ComposeResult: + start, end = _month_bounds(date.today()) + yield Header() + with Vertical(): + with Vertical(id="filter-panel"): + with Horizontal(id="filter-row-1"): + with Vertical(classes="filter-group"): + yield Label("From") + yield Input( + value=start, + placeholder="YYYY-MM-DD", + id="filter-start", + validators=[Function(_valid_filter_date, "Invalid date")], + ) + with Vertical(classes="filter-group"): + yield Label("To") + yield Input( + value=end, + placeholder="YYYY-MM-DD", + id="filter-end", + validators=[Function(_valid_filter_date, "Invalid date")], + ) + with Vertical(classes="filter-group"): + yield Label("Min $") + yield Input( + placeholder="0.00", + id="filter-min", + validators=[ + Function(_valid_filter_dollars, "Invalid amount") + ], + ) + with Vertical(classes="filter-group"): + yield Label("Max $") + yield Input( + placeholder="any", + id="filter-max", + validators=[ + Function(_valid_filter_dollars, "Invalid amount") + ], + ) + with Horizontal(id="filter-row-2"): + with Vertical(classes="filter-group"): + yield Label("Category") + yield Input(placeholder="filter…", id="filter-category") + with Vertical(classes="filter-group"): + yield Label("Merchant") + yield Input(placeholder="filter…", id="filter-merchant") + with Vertical(classes="filter-group"): + yield Label("Tag") + yield Input(placeholder="filter…", id="filter-tag") + with Vertical(classes="filter-group"): + yield Label("Notes") + yield Input(placeholder="search…", id="filter-notes") + with Vertical(classes="filter-group filter-action"): + yield Label(" ") + yield Button("Clear", id="filter-clear") + table = DataTable() + table.cursor_type = "row" + table.add_column(" ", width=2, key="sel") + table.add_columns("Date", "Amount", "Category", "Merchant", "Tags", "Notes") + yield table + yield CenteredFooter() + + def on_mount(self) -> None: + self._reload() + self.query_one(DataTable).focus() + + def on_input_changed(self, event: Input.Changed) -> None: + input_id = event.input.id + if not input_id or not input_id.startswith("filter-"): + return + # Invalid inputs paint themselves red via the :invalid pseudo-class; + # don't let them clobber the current filter — keep showing the valid + # results until the user fixes their typo. + if event.validation_result and not event.validation_result.is_valid: + return + field = input_id[len("filter-") :] + if field == "start": + self._filters["start"] = _parse_date(event.value) + elif field == "end": + self._filters["end"] = _parse_date(event.value) + elif field == "min": + self._filters["min_cents"] = parse_dollars(event.value) + elif field == "max": + self._filters["max_cents"] = parse_dollars(event.value) + else: + self._filters[field] = event.value.strip() + self._reload() + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "filter-clear": + for input_id in ( + "filter-start", + "filter-end", + "filter-min", + "filter-max", + "filter-category", + "filter-merchant", + "filter-tag", + "filter-notes", + ): + self.query_one(f"#{input_id}", Input).value = "" + + def _reload(self, restore_row: int = 0) -> None: + self._rows = self.db.get_spending(**self._filters) + table = self.query_one(DataTable) + table.clear() + for row in self._rows: + mark = _SEL_MARK if row["id"] in self._selected_ids else _SEL_BLANK + table.add_row( + mark, + row["date"], + format_cents(row["cents"]), + row["category"], + row["merchant"] or "", + row["tags"] or "", + row["notes"] or "", + ) + if self._rows: + table.move_cursor(row=min(restore_row, len(self._rows) - 1)) + self._refresh_title() + + def _refresh_title(self) -> None: + count = len(self._rows) + base = f"Spending - showing {count} record{'s' if count != 1 else ''}" + sel = len(self._selected_ids) + self.title = f"{base} ({sel} selected)" if sel else base + + def _cursor_row(self) -> int: + return self.query_one(DataTable).cursor_row + + def action_vi_down(self) -> None: + self.query_one(DataTable).action_cursor_down() + + def action_vi_up(self) -> None: + self.query_one(DataTable).action_cursor_up() + + def action_vi_top(self) -> None: + self.query_one(DataTable).move_cursor(row=0) + + def action_vi_bottom(self) -> None: + if self._rows: + self.query_one(DataTable).move_cursor(row=len(self._rows) - 1) + + def action_add(self) -> None: + from common_cents.screens._spending_form import SpendingFormScreen + + row_count = len(self._rows) + + def on_result(saved: bool) -> None: + if saved: + self._reload(restore_row=row_count) + + self.app.push_screen(SpendingFormScreen(), on_result) + + def action_edit(self) -> None: + if not self._rows: + return + from common_cents.screens._spending_form import SpendingFormScreen + + spending_id = self._rows[self._cursor_row()]["id"] + row_pos = self._cursor_row() + + def on_result(saved: bool) -> None: + if saved: + self._reload(restore_row=row_pos) + + self.app.push_screen(SpendingFormScreen(spending_id=spending_id), on_result) + + def action_delete(self) -> None: + if not self._rows: + return + from common_cents.screens._confirm import ConfirmModal + + row_pos = self._cursor_row() + row = self._rows[row_pos] + details = [ + f"Date: {row['date']}", + f"Amount: {format_cents(row['cents'])}", + f"Category: {row['category']}", + f"Merchant: {row['merchant'] or '—'}", + f"Tags: {row['tags'] or '—'}", + f"Notes: {row['notes'] or '—'}", + ] + + def on_confirmed(confirmed: bool) -> None: + if not confirmed: + return + # Snapshot by name so undo still works if metadata is edited or + # deleted in the meantime — db.add_spending re-resolves names. + tag_ids = self.db.get_spending_metadata_ids(row["id"], "tag") + tag_names_by_id = {t["id"]: t["name"] for t in self.db.get_metadata("tag")} + self._undo = { + "date": row["date"], + "cents": row["cents"], + "category": row["category"], + "merchant": row["merchant"], + "notes": row["notes"], + "tag_names": [ + tag_names_by_id[i] for i in tag_ids if i in tag_names_by_id + ], + } + self.db.delete_spending(row["id"]) + self._selected_ids.discard(row["id"]) + self._reload(restore_row=row_pos) + self.app.notify("Deleted. Press U to undo.") + + self.app.push_screen( + ConfirmModal("Delete this spending record?", details), on_confirmed + ) + + def action_back_or_clear(self) -> None: + if self._selected_ids: + self._selected_ids.clear() + self._refresh_selection_marks() + self._refresh_title() + return + self.app.pop_screen() + + def action_toggle_select(self) -> None: + if not self._rows: + return + row_pos = self._cursor_row() + spending_id = self._rows[row_pos]["id"] + if spending_id in self._selected_ids: + self._selected_ids.discard(spending_id) + mark = _SEL_BLANK + else: + self._selected_ids.add(spending_id) + mark = _SEL_MARK + self.query_one(DataTable).update_cell_at(Coordinate(row_pos, 0), mark) + self._refresh_title() + + def action_toggle_select_all(self) -> None: + if not self._rows: + return + visible_ids = {r["id"] for r in self._rows} + if visible_ids.issubset(self._selected_ids): + self._selected_ids -= visible_ids + else: + self._selected_ids |= visible_ids + self._refresh_selection_marks() + self._refresh_title() + + def _refresh_selection_marks(self) -> None: + table = self.query_one(DataTable) + for i, row in enumerate(self._rows): + mark = _SEL_MARK if row["id"] in self._selected_ids else _SEL_BLANK + table.update_cell_at(Coordinate(i, 0), mark) + + def action_bulk_edit(self) -> None: + if not self._selected_ids: + self.app.notify("Select rows first (space to toggle).", severity="warning") + return + from common_cents.screens._spending_bulk_form import BulkEditScreen + + ids = list(self._selected_ids) + row_pos = self._cursor_row() if self._rows else 0 + + def on_result(updated: int) -> None: + if updated: + self._selected_ids.clear() + self._reload(restore_row=row_pos) + + self.app.push_screen(BulkEditScreen(ids), on_result) + + def action_undo(self) -> None: + if self._undo is None: + self.app.notify("Nothing to undo.", severity="warning") + return + snap = self._undo + try: + tag_ids = [ + self.db.get_or_create_metadata("tag", n) for n in snap["tag_names"] + ] + self.db.add_spending( + snap["date"], + snap["cents"], + snap["category"], + snap["merchant"], + snap["notes"], + tag_ids, + ) + except Exception as e: + self.app.notify(f"Undo failed: {e}", severity="error") + return + self._undo = None + self._reload(restore_row=self._cursor_row()) + self.app.notify("Restored.") diff --git a/src/common_cents/widgets/__init__.py b/src/common_cents/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/common_cents/widgets/autocomplete.py b/src/common_cents/widgets/autocomplete.py new file mode 100644 index 0000000..841547f --- /dev/null +++ b/src/common_cents/widgets/autocomplete.py @@ -0,0 +1,315 @@ +from textual import events, on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.css.query import NoMatches +from textual.message import Message +from textual.widget import Widget +from textual.widgets import Input, OptionList + + +class _FilterInput(Input): + """Input that translates Down/Escape into custom messages, and posts a focus signal. + + Note: do not name nested Message classes after any defined on Input + (Blurred, Submitted, Changed) — that shadows the parent's class and + breaks Textual's internal post_message calls. + """ + + class DownPressed(Message): + pass + + class EnterPressed(Message): + pass + + class FilterFocused(Message): + pass + + class EscapePressed(Message): + pass + + BINDINGS = [ + Binding("down", "down_pressed", show=False), + Binding("enter", "enter_pressed", show=False), + Binding("escape", "escape_pressed", show=False), + ] + + def action_down_pressed(self) -> None: + self.post_message(self.DownPressed()) + + def action_enter_pressed(self) -> None: + self.post_message(self.EnterPressed()) + + def action_escape_pressed(self) -> None: + self.post_message(self.EscapePressed()) + + def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: + # Only consume Escape when our enclosing AutocompleteInput's dropdown is + # visible. When the dropdown is hidden, returning False disables the + # binding so the keystroke bubbles up to the screen's cancel binding. + # Defer to super() for everything else — returning None would mark + # actions as "disabled+visible" and break Input's backspace/enter/etc. + if action == "escape_pressed": + parent = self.parent + while parent is not None and not isinstance(parent, AutocompleteInput): + parent = parent.parent + if parent is None: + return False + try: + return bool(parent.query_one(_SuggestionList).display) + except NoMatches: + return False + return super().check_action(action, parameters) + + def on_focus(self) -> None: + self.post_message(self.FilterFocused()) + + +class _SuggestionList(OptionList): + """OptionList that reports dismissal.""" + + BINDINGS = [Binding("escape", "dismiss_list", show=False)] + + class Dismissed(Message): + pass + + def action_dismiss_list(self) -> None: + self.post_message(self.Dismissed()) + + +class AutocompleteInput(Widget): + """ + Text input with a live-filtered suggestion dropdown. + + - Type to filter; the top match is auto-highlighted. + - Enter accepts the highlighted suggestion (focus stays on the input). + - Tab accepts the highlighted suggestion and advances focus to the next field. + - Down moves focus into the list (re-opening it if hidden); Enter from the list also selects. + - Escape closes the dropdown. + - The dropdown closes when focus leaves the widget entirely. + - `set_options()` refreshes a visible dropdown. + - `is_known_value` exposes whether the current value matches an option. + - Posts AutocompleteInput.Changed when the user types. + - Posts AutocompleteInput.Submitted on every commit (Enter / Tab / option click), + with `is_known` indicating whether the value matches a known option. + """ + + DEFAULT_CSS = """ + AutocompleteInput { + height: auto; + width: 1fr; + } + AutocompleteInput Input { + width: 1fr; + } + AutocompleteInput _SuggestionList { + width: 1fr; + height: auto; + display: none; + overlay: screen; + constrain: none inside; + border: tall $border-blurred; + background: $surface; + } + """ + + BINDINGS = [ + Binding("tab", "tab_accept", show=False), + ] + + class Changed(Message): + def __init__(self, autocomplete: "AutocompleteInput", value: str) -> None: + super().__init__() + self.autocomplete = autocomplete + self.value = value + + @property + def control(self) -> "AutocompleteInput": + return self.autocomplete + + class Submitted(Message): + """Posted when the user commits a value (Enter, Tab, or click).""" + + def __init__( + self, autocomplete: "AutocompleteInput", value: str, is_known: bool + ) -> None: + super().__init__() + self.autocomplete = autocomplete + self.value = value + self.is_known = is_known + + @property + def control(self) -> "AutocompleteInput": + return self.autocomplete + + def __init__( + self, + options: list[str] | None = None, + placeholder: str = "", + value: str = "", + id: str | None = None, + max_visible: int = 6, + ) -> None: + super().__init__(id=id) + self._all_options: list[str] = list(options or []) + self._placeholder = placeholder + self._initial_value = value + self._matches: list[str] = [] + self._max_visible = max_visible + self._suppress_refresh = 0 + + def compose(self) -> ComposeResult: + yield _FilterInput(placeholder=self._placeholder, value=self._initial_value) + sl = _SuggestionList() + sl.styles.max_height = self._max_visible + yield sl + + def on_mount(self) -> None: + if self._initial_value: + inp = self.query_one(_FilterInput) + inp.cursor_position = len(self._initial_value) + + @property + def value(self) -> str: + return self.query_one(_FilterInput).value + + @value.setter + def value(self, val: str) -> None: + inp = self.query_one(_FilterInput) + if inp.value != val: + self._suppress_refresh += 1 + inp.value = val + inp.cursor_position = len(val) + + @property + def is_known_value(self) -> bool: + v = self.value.strip().lower() + return any(o.lower() == v for o in self._all_options) + + def set_options(self, options: list[str]) -> None: + self._all_options = list(options) + sl = self.query_one(_SuggestionList) + if sl.display: + self._refresh_matches(self.value) + + def _rank_key(self, option: str, query: str) -> tuple[int, int, str]: + o = option.lower() + if not query: + return (0, 0, o) + q = query.lower() + if o.startswith(q): + return (0, 0, o) + return (1, o.find(q), o) + + def _refresh_matches(self, text: str) -> None: + sl = self.query_one(_SuggestionList) + query = text.strip() + if query: + candidates = [o for o in self._all_options if query.lower() in o.lower()] + else: + candidates = list(self._all_options) + candidates.sort(key=lambda o: self._rank_key(o, query)) + self._matches = candidates + sl.clear_options() + if self._matches: + for match in self._matches: + sl.add_option(match) + sl.display = True + sl.highlighted = 0 + else: + sl.display = False + + def on_input_changed(self, event: Input.Changed) -> None: + if event.input is not self.query_one(_FilterInput): + return + event.stop() + if self._suppress_refresh > 0: + self._suppress_refresh -= 1 + return + self._refresh_matches(event.value) + self.post_message(self.Changed(self, event.value)) + + @on(_FilterInput.FilterFocused) + def _on_filter_focused(self) -> None: + if not self._all_options: + return + text = self.query_one(_FilterInput).value.strip() + if not text: + self._refresh_matches("") + + @on(_FilterInput.DownPressed) + def _focus_suggestions(self) -> None: + sl = self.query_one(_SuggestionList) + if not sl.display and self._all_options: + self._refresh_matches(self.value) + if sl.display: + sl.focus() + + @on(_FilterInput.EnterPressed) + def _on_input_enter(self) -> None: + sl = self.query_one(_SuggestionList) + if sl.display and self._matches: + idx = sl.highlighted if sl.highlighted is not None else 0 + if 0 <= idx < len(self._matches): + self._accept(self._matches[idx]) + return + self._post_submitted() + + def _post_submitted(self) -> None: + val = self.value.strip() + if not val: + return + self.post_message(self.Submitted(self, val, self.is_known_value)) + + @on(_FilterInput.EscapePressed) + def _on_input_escape(self) -> None: + sl = self.query_one(_SuggestionList) + if sl.display: + sl.display = False + + def on_descendant_blur(self, event: events.DescendantBlur) -> None: + self.call_after_refresh(self._maybe_close_on_blur) + + def _maybe_close_on_blur(self) -> None: + if not self.is_mounted: + return + focused = self.app.focused + if focused is None or not self._contains_widget(focused): + for sl in self.query(_SuggestionList): + sl.display = False + + def _contains_widget(self, widget) -> bool: + node = widget + while node is not None: + if node is self: + return True + node = node.parent + return False + + def action_tab_accept(self) -> None: + sl = self.query_one(_SuggestionList) + if sl.display and self._matches: + idx = sl.highlighted if sl.highlighted is not None else 0 + if 0 <= idx < len(self._matches): + self._accept(self._matches[idx]) + self.screen.focus_next() + + def _accept(self, value: str) -> None: + inp = self.query_one(_FilterInput) + if inp.value != value: + self._suppress_refresh += 1 + inp.value = value + inp.cursor_position = len(value) + self.query_one(_SuggestionList).display = False + self._post_submitted() + + def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None: + idx = event.option_index + if 0 <= idx < len(self._matches): + self._accept(self._matches[idx]) + self.query_one(_FilterInput).focus() + event.stop() + + @on(_SuggestionList.Dismissed) + def _on_list_dismissed(self) -> None: + self.query_one(_SuggestionList).display = False + self.query_one(_FilterInput).focus() diff --git a/src/common_cents/widgets/footer.py b/src/common_cents/widgets/footer.py new file mode 100644 index 0000000..181bf7a --- /dev/null +++ b/src/common_cents/widgets/footer.py @@ -0,0 +1,16 @@ +from textual.widgets import Footer + + +class CenteredFooter(Footer): + def on_mount(self) -> None: + self.call_after_refresh(self._center) + + def on_resize(self) -> None: + self._center() + + def _center(self) -> None: + screen_w = self.app.size.width + footer_w = self.size.width + if footer_w > 0 and screen_w > 0: + left = max(0, (screen_w - footer_w) // 2) + self.styles.margin = (0, 0, 0, left) diff --git a/tests/test_bulk_edit.py b/tests/test_bulk_edit.py new file mode 100644 index 0000000..343b839 --- /dev/null +++ b/tests/test_bulk_edit.py @@ -0,0 +1,124 @@ +"""Pure-Python tests for the bulk-edit data path. The Textual modal itself +is exercised manually; here we lock in the semantics of the per-row diff +overlay so a regression in the loop is caught.""" + +from pathlib import Path + +import pytest + +from common_cents.db import Database +from common_cents.screens._spending_bulk_form import _split_tags + + +@pytest.fixture +def db(tmp_path: Path) -> Database: + return Database(tmp_path / "test.db") + + +def _seed(db: Database, *rows) -> list[int]: + for r in rows: + db.add_spending(*r) + return [row["id"] for row in db.get_spending()] + + +def _apply_diff(db: Database, ids: list[int], diff: dict) -> int: + """Mirror BulkEditScreen._write so the overlay logic can be tested headlessly.""" + tag_ids: list[int] | None = None + if "tag_names" in diff: + tag_ids = [db.get_or_create_metadata("tag", n) for n in diff["tag_names"]] + updated = 0 + for sid in ids: + row = db.get_spending_by_id(sid) + if row is None: + continue + new_date = diff.get("date", row["date"]) + new_category = diff.get("category", row["category_name"]) + new_merchant = diff["merchant"] if "merchant" in diff else row["merchant_name"] + new_notes = diff["notes"] if "notes" in diff else row["notes"] + new_tags = ( + tag_ids + if tag_ids is not None + else db.get_spending_metadata_ids(sid, "tag") + ) + db.update_spending( + sid, + new_date, + row["cents"], + new_category, + new_merchant, + new_notes, + new_tags, + ) + updated += 1 + return updated + + +def test_split_tags_strips_and_drops_empty(): + assert _split_tags("a, b , ,c") == ["a", "b", "c"] + assert _split_tags("") == [] + assert _split_tags(" ") == [] + + +def test_bulk_recategorize_preserves_other_fields(db): + ids = _seed( + db, + ("2026-05-01", 1500, "Food", "Whole Foods", "lunch", []), + ("2026-05-02", 2500, "Food", "Trader Joes", None, []), + ) + _apply_diff(db, ids, {"category": "Groceries"}) + rows = db.get_spending() + assert {r["category"] for r in rows} == {"Groceries"} + # merchants and notes preserved + by_date = {r["date"]: r for r in rows} + assert by_date["2026-05-01"]["merchant"] == "Whole Foods" + assert by_date["2026-05-01"]["notes"] == "lunch" + assert by_date["2026-05-02"]["merchant"] == "Trader Joes" + + +def test_bulk_clear_merchant_and_notes(db): + ids = _seed( + db, + ("2026-05-01", 1500, "Food", "X", "n1", []), + ("2026-05-02", 2500, "Food", "Y", "n2", []), + ) + _apply_diff(db, ids, {"merchant": None, "notes": None}) + for row in db.get_spending(): + assert row["merchant"] is None + assert row["notes"] is None + + +def test_bulk_replace_tags_overwrites_existing(db): + food_id = db.add_metadata("tag", "lunch") + ids = _seed( + db, + ("2026-05-01", 1500, "Food", None, None, [food_id]), + ("2026-05-02", 2500, "Food", None, None, []), + ) + _apply_diff(db, ids, {"tag_names": ["work", "client"]}) + for sid in ids: + names = sorted( + t["name"] + for t in db.get_metadata("tag") + if t["id"] in db.get_spending_metadata_ids(sid, "tag") + ) + assert names == ["client", "work"] + + +def test_bulk_clear_tags(db): + work_id = db.add_metadata("tag", "work") + ids = _seed( + db, + ("2026-05-01", 1500, "Food", None, None, [work_id]), + ) + _apply_diff(db, ids, {"tag_names": []}) + assert db.get_spending_metadata_ids(ids[0], "tag") == [] + + +def test_bulk_skips_missing_id(db): + ids = _seed( + db, + ("2026-05-01", 1500, "Food", None, None, []), + ) + n = _apply_diff(db, [*ids, 99999], {"category": "Travel"}) + assert n == 1 # the bogus id is skipped, not raised + assert db.get_spending()[0]["category"] == "Travel" diff --git a/tests/test_category_tree.py b/tests/test_category_tree.py new file mode 100644 index 0000000..7af0d86 --- /dev/null +++ b/tests/test_category_tree.py @@ -0,0 +1,64 @@ +from common_cents.category_tree import build_category_tree + + +def _by_name(nodes): + return {n.name: n for n in nodes} + + +def test_flat_paths(): + nodes = build_category_tree({"Food": 1000, "Travel": 500}) + by_name = _by_name(nodes) + assert set(by_name) == {"Food", "Travel"} + assert by_name["Food"].self_total == 1000 + assert by_name["Food"].total() == 1000 + + +def test_nested_paths_roll_up(): + nodes = build_category_tree( + {"Food": 1000, "Food:Restaurants": 2000, "Food:Restaurants:Sushi": 500} + ) + food = _by_name(nodes)["Food"] + assert food.self_total == 1000 + assert food.total() == 3500 + rest = food.children["Restaurants"] + assert rest.self_total == 2000 + assert rest.total() == 2500 + + +def test_intermediate_ancestors_have_zero_self_total(): + nodes = build_category_tree({"A:B:C": 100}) + a = _by_name(nodes)["A"] + assert a.self_total == 0 + assert a.total() == 100 + assert a.children["B"].self_total == 0 + assert a.children["B"].children["C"].self_total == 100 + + +def test_prev_totals_separate_from_current(): + nodes = build_category_tree({"Food": 100}, prev_totals={"Food": 200, "Travel": 50}) + by_name = _by_name(nodes) + assert by_name["Food"].self_total == 100 + assert by_name["Food"].self_prev_total == 200 + # Travel exists only in prev — appears in tree. + assert by_name["Travel"].self_total == 0 + assert by_name["Travel"].self_prev_total == 50 + + +def test_extra_paths_create_empty_nodes(): + nodes = build_category_tree({}, extra_paths=["Food:Restaurants"]) + food = _by_name(nodes)["Food"] + assert food.total() == 0 + assert "Restaurants" in food.children + + +def test_empty_inputs(): + assert build_category_tree({}) == [] + assert build_category_tree({}, {}) == [] + + +def test_blank_segments_skipped(): + """A path like ``::A`` should not create empty-named nodes.""" + nodes = build_category_tree({"::Food": 100}) + by_name = _by_name(nodes) + assert "" not in by_name + assert "Food" in by_name diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..552950f --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,57 @@ +from pathlib import Path + +import pytest + +from common_cents import config + + +@pytest.fixture(autouse=True) +def isolate_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point XDG_CONFIG_HOME at a temp dir so tests don't touch the real config.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + return tmp_path + + +def test_config_path_uses_xdg(isolate_config: Path) -> None: + assert config.config_path() == isolate_config / "common-cents" / "config.json" + + +def test_load_returns_empty_when_missing() -> None: + assert config.load_config() == {} + + +def test_load_returns_empty_on_corrupt_json(isolate_config: Path) -> None: + p = config.config_path() + p.parent.mkdir(parents=True) + p.write_text("{not valid json") + assert config.load_config() == {} + + +def test_set_and_get_active_db_path(tmp_path: Path) -> None: + target = tmp_path / "elsewhere" / "my.db" + config.set_active_db_path(target) + assert config.get_active_db_path() == target + + +def test_get_active_db_path_returns_none_when_unset() -> None: + assert config.get_active_db_path() is None + + +def test_clear_active_db_path(tmp_path: Path) -> None: + target = tmp_path / "x.db" + config.set_active_db_path(target) + config.clear_active_db_path() + assert config.get_active_db_path() is None + + +def test_clear_when_unset_is_noop() -> None: + config.clear_active_db_path() + assert config.get_active_db_path() is None + + +def test_set_preserves_other_keys() -> None: + config.save_config({"theme": "dark"}) + config.set_active_db_path(Path("/tmp/x.db")) + data = config.load_config() + assert data["theme"] == "dark" + assert data["db_path"] == "/tmp/x.db" diff --git a/tests/test_csv.py b/tests/test_csv.py new file mode 100644 index 0000000..84fd5ed --- /dev/null +++ b/tests/test_csv.py @@ -0,0 +1,82 @@ +from pathlib import Path + +from common_cents.csv_export import write_csv +from common_cents.csv_import import parse_csv + + +def _row(date, cents, cat, merch=None, notes=None, tags=None): + return { + "date": date, + "cents": cents, + "category": cat, + "merchant": merch, + "notes": notes, + "tags": tags or [], + } + + +def test_export_then_import_roundtrip(tmp_path: Path): + rows = [ + _row("2026-01-01", 100, "A", "M", None, ["x", "y"]), + _row("2026-01-02", 200, "B", None, 'Hello, "world"'), + _row("2026-01-03", 300, "C:D", None, None, ["t1", "t2", "t3"]), + ] + p = tmp_path / "out.csv" + write_csv(p, rows) + result = parse_csv(p) + assert result.errors == [] + assert len(result.rows) == 3 + assert result.rows[0].tags == ["x", "y"] + assert result.rows[1].notes == 'Hello, "world"' + assert result.rows[2].tags == ["t1", "t2", "t3"] + + +def test_parse_csv_missing_required(tmp_path: Path): + p = tmp_path / "bad.csv" + p.write_text("DATE,MERCHANT\n2026-01-01,Joe\n") + result = parse_csv(p) + assert result.errors + assert "CATEGORY" in result.errors[0] + assert "CENTS" in result.errors[0] + + +def test_parse_csv_missing_optional_warns(tmp_path: Path): + p = tmp_path / "ok.csv" + p.write_text("DATE,CENTS,CATEGORY,MERCHANT\n2026-01-01,100,Food,Joe\n") + result = parse_csv(p) + assert result.errors == [] + assert any("NOTES" in w and "TAGS" in w for w in result.warnings) + assert len(result.rows) == 1 + + +def test_parse_csv_invalid_date(tmp_path: Path): + p = tmp_path / "bad.csv" + p.write_text("DATE,CENTS,CATEGORY,MERCHANT\nnot-a-date,100,Food,\n") + result = parse_csv(p) + assert any("invalid date" in e for e in result.errors) + assert result.rows == [] + + +def test_parse_csv_zero_cents_rejected(tmp_path: Path): + p = tmp_path / "bad.csv" + p.write_text("DATE,CENTS,CATEGORY,MERCHANT\n2026-01-01,0,Food,\n") + result = parse_csv(p) + assert any("positive" in e for e in result.errors) + + +def test_parse_csv_comma_formatted_cents(tmp_path: Path): + p = tmp_path / "ok.csv" + p.write_text( + 'DATE,CENTS,CATEGORY,MERCHANT\n2026-01-01,"14,901",Food,\n' + ) + result = parse_csv(p) + assert result.errors == [] + assert result.rows[0].cents == 14901 + + +def test_export_sorts_nothing_within_row(tmp_path: Path): + """write_csv preserves caller's tag order; sorting is the DB layer's job.""" + p = tmp_path / "out.csv" + write_csv(p, [_row("2026-01-01", 100, "A", None, None, ["zeta", "alpha"])]) + text = p.read_text() + assert '"zeta,alpha"' in text diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..8865c73 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,240 @@ +from pathlib import Path + +import pytest + +from common_cents.db import Database + + +@pytest.fixture +def db(tmp_path: Path) -> Database: + return Database(tmp_path / "test.db") + + +def test_add_metadata_basic(db): + cat_id = db.add_metadata("category", "Food") + assert cat_id > 0 + rows = db.get_metadata("category") + assert [r["name"] for r in rows] == ["Food"] + + +def test_add_metadata_duplicate(db): + db.add_metadata("category", "Food") + with pytest.raises(ValueError, match="already exists"): + db.add_metadata("category", "Food") + + +def test_add_metadata_duplicate_case_insensitive(db): + db.add_metadata("category", "Food") + with pytest.raises(ValueError, match="already exists"): + db.add_metadata("category", "food") + + +def test_add_metadata_invalid_kind(db): + with pytest.raises(ValueError, match="Invalid kind"): + db.add_metadata("bogus", "X") + + +def test_add_metadata_rejects_comma_in_name(db): + with pytest.raises(ValueError, match="cannot contain commas"): + db.add_metadata("tag", "has,comma") + + +def test_update_metadata_unknown_id(db): + with pytest.raises(ValueError, match="No metadata row"): + db.update_metadata(99999, "Anything") + + +def test_update_metadata_rejects_comma(db): + cat_id = db.add_metadata("category", "Food") + with pytest.raises(ValueError, match="cannot contain commas"): + db.update_metadata(cat_id, "Foo,Bar") + + +def test_delete_metadata_blocked_when_in_use(db): + db.add_spending("2026-01-01", 100, "Food", None, None, []) + cat = db.get_metadata("category")[0] + with pytest.raises(ValueError, match="Cannot delete"): + db.delete_metadata(cat["id"]) + + +def test_delete_metadata_succeeds_when_unused(db): + cat_id = db.add_metadata("category", "Food") + db.delete_metadata(cat_id) + assert db.get_metadata("category") == [] + + +def test_add_spending_creates_metadata_inline(db): + db.add_spending("2026-05-01", 1500, "Food:Restaurants", "Joe's", "lunch", []) + rows = db.get_spending() + assert len(rows) == 1 + r = rows[0] + assert r["category"] == "Food:Restaurants" + assert r["merchant"] == "Joe's" + assert r["notes"] == "lunch" + assert r["cents"] == 1500 + + +def test_add_spending_rejects_comma_in_category(db): + with pytest.raises(ValueError, match="cannot contain commas"): + db.add_spending("2026-05-01", 100, "a,b", None, None, []) + + +def test_update_spending(db): + db.add_spending("2026-05-01", 1500, "Food", None, None, []) + sid = db.get_spending()[0]["id"] + db.update_spending(sid, "2026-05-02", 2000, "Travel", "Delta", "flight", []) + r = db.get_spending_by_id(sid) + assert r["date"] == "2026-05-02" + assert r["cents"] == 2000 + assert r["category_name"] == "Travel" + assert r["merchant_name"] == "Delta" + + +def test_delete_spending_cascades_links(db): + db.add_spending("2026-05-01", 1500, "Food", None, None, []) + sid = db.get_spending()[0]["id"] + db.delete_spending(sid) + assert db.get_spending() == [] + + +def test_budget_set_get_clear(db): + cat_id = db.add_metadata("category", "Food") + assert db.get_budget(cat_id) is None + db.set_budget(cat_id, 50000) + assert db.get_budget(cat_id) == 50000 + db.set_budget(cat_id, 60000) # upsert + assert db.get_budget(cat_id) == 60000 + db.clear_budget(cat_id) + assert db.get_budget(cat_id) is None + + +def test_budget_rejects_non_positive(db): + cat_id = db.add_metadata("category", "Food") + with pytest.raises(ValueError, match="positive"): + db.set_budget(cat_id, 0) + with pytest.raises(ValueError, match="positive"): + db.set_budget(cat_id, -100) + + +def test_find_duplicate_indices_against_existing(db): + db.add_spending("2026-01-15", 1500, "Food", "TJ", None, []) + rows = [ + { + "date": "2026-01-15", + "cents": 1500, + "category": "Food", + "merchant": "TJ", + "notes": None, + "tags": [], + }, + { + "date": "2026-01-16", + "cents": 1500, + "category": "Food", + "merchant": "TJ", + "notes": None, + "tags": [], + }, + ] + assert db.find_duplicate_indices(rows) == {0} + + +def test_find_duplicate_indices_within_batch(db): + rows = [ + { + "date": "2026-01-15", + "cents": 1500, + "category": "Food", + "merchant": None, + "notes": None, + "tags": [], + }, + { + "date": "2026-01-15", + "cents": 1500, + "category": "Food", + "merchant": None, + "notes": None, + "tags": [], + }, + ] + assert db.find_duplicate_indices(rows) == {1} + + +def test_import_spending_skips_duplicates(db): + db.add_spending("2026-01-15", 1500, "Food", None, None, []) + inserted, skipped = db.import_spending( + [ + { + "date": "2026-01-15", + "cents": 1500, + "category": "Food", + "merchant": None, + "notes": None, + "tags": [], + }, + { + "date": "2026-01-16", + "cents": 1500, + "category": "Food", + "merchant": None, + "notes": None, + "tags": [], + }, + ] + ) + assert (inserted, skipped) == (1, 1) + + +def test_get_spending_for_export_sorts_tags(db): + db.add_spending( + "2026-01-01", + 100, + "Food", + None, + None, + [ + db.get_or_create_metadata("tag", "zeta"), + db.get_or_create_metadata("tag", "alpha"), + ], + ) + rows = db.get_spending_for_export() + assert rows[0]["tags"] == ["alpha", "zeta"] + + +def test_get_yearly_totals(db): + db.add_spending("2026-01-15", 1000, "Food", None, None, []) + db.add_spending("2026-01-20", 500, "Food", None, None, []) + db.add_spending("2026-03-01", 2000, "Food", None, None, []) + totals = db.get_yearly_totals(2026) + assert totals == {1: 1500, 3: 2000} + + +def test_get_category_totals_for_month(db): + db.add_spending("2026-05-01", 1000, "Food", None, None, []) + db.add_spending("2026-05-15", 500, "Food:Restaurants", None, None, []) + db.add_spending("2026-04-01", 9999, "Food", None, None, []) + totals = db.get_category_totals_for_month("2026-05") + assert totals == {"Food": 1000, "Food:Restaurants": 500} + + +def test_get_spending_by_category_tag_filter(db): + work_id = db.add_metadata("tag", "work") + travel_id = db.add_metadata("tag", "travel") + db.add_spending("2026-05-01", 1000, "Food", None, None, [work_id]) + db.add_spending("2026-05-02", 2000, "Food", None, None, [travel_id]) + db.add_spending("2026-05-03", 500, "Travel", None, None, [travel_id]) + + # Substring match, single tag + rows = db.get_spending_by_category("2026-05-01", "2026-05-31", tag="work") + assert {(r["name"], r["total"]) for r in rows} == {("Food", 1000)} + + # Tag + category filter combine (AND) + rows = db.get_spending_by_category( + "2026-05-01", "2026-05-31", category="Travel", tag="travel" + ) + assert {(r["name"], r["total"]) for r in rows} == {("Travel", 500)} + + # Empty tag means "no tag filter" (back-compat with default) + rows = db.get_spending_by_category("2026-05-01", "2026-05-31", tag="") + assert {r["name"] for r in rows} == {"Food", "Travel"} diff --git a/tests/test_money.py b/tests/test_money.py new file mode 100644 index 0000000..2a3cd03 --- /dev/null +++ b/tests/test_money.py @@ -0,0 +1,77 @@ +import pytest + +from common_cents.money import ( + format_cents, + format_cents_input, + parse_cents_csv, + parse_dollars, +) + + +@pytest.mark.parametrize( + "raw, expected", + [ + ("1", 100), + ("1.5", 150), + ("1.50", 150), + ("$1,234.56", 123456), + (".50", 50), + ("5.", 500), + ("5.999", 599), # truncate, not round (string-based) + ("1.005", 100), + ("-5.00", -500), + ("-.50", -50), + ("", None), + (" ", None), + ("abc", None), + ("$", None), + ("-", None), + ("-$5", None), + ], +) +def test_parse_dollars(raw, expected): + assert parse_dollars(raw) == expected + + +@pytest.mark.parametrize( + "raw, expected", + [ + ("100", 100), + ("14,901", 14901), + (" 50 ", 50), + ], +) +def test_parse_cents_csv(raw, expected): + assert parse_cents_csv(raw) == expected + + +def test_parse_cents_csv_invalid(): + with pytest.raises(ValueError): + parse_cents_csv("abc") + + +@pytest.mark.parametrize( + "cents, expected", + [ + (0, "$0.00"), + (100, "$1.00"), + (1234, "$12.34"), + (123456, "$1,234.56"), + (-500, "-$5.00"), + (-12345, "-$123.45"), + ], +) +def test_format_cents(cents, expected): + assert format_cents(cents) == expected + + +@pytest.mark.parametrize( + "cents, expected", + [ + (100, "1.00"), + (1234, "12.34"), + (5, "0.05"), + ], +) +def test_format_cents_input(cents, expected): + assert format_cents_input(cents) == expected diff --git a/tests/test_reports.py b/tests/test_reports.py new file mode 100644 index 0000000..2c73400 --- /dev/null +++ b/tests/test_reports.py @@ -0,0 +1,97 @@ +from datetime import date + +from common_cents.screens.reports import ( + _date_range, + _month_options, + _months_in_range, +) + + +def test_months_in_range_same_month(): + assert _months_in_range("2026-05-01", "2026-05-31") == 1 + + +def test_months_in_range_multi_month(): + assert _months_in_range("2026-01-01", "2026-12-31") == 12 + + +def test_months_in_range_year_crossover(): + assert _months_in_range("2025-11-01", "2026-02-15") == 4 + + +def test_date_range_this_month(): + import calendar + + start, end, label = _date_range("this-month") + today = date.today() + last_day = calendar.monthrange(today.year, today.month)[1] + assert start == today.replace(day=1).isoformat() + assert end == today.replace(day=last_day).isoformat() + assert "This Month" in label + + +def test_date_range_last_month(): + start, end, _label = _date_range("last-month") + today = date.today() + end_d = date.fromisoformat(end) + # last day of previous month + assert end_d < today.replace(day=1) + assert date.fromisoformat(start).day == 1 + assert end_d.month == date.fromisoformat(start).month + + +def test_date_range_ytd(): + start, end, _label = _date_range("ytd") + today = date.today() + assert start == date(today.year, 1, 1).isoformat() + assert end == today.isoformat() + + +def test_date_range_last_12_months(): + """Trailing 12 months = current month + 11 prior, so spans 12 distinct months.""" + start, end, _label = _date_range("last-12") + assert _months_in_range(start, end) == 12 + + +def test_date_range_custom_month_bounds(): + """Custom range expands YYYY-MM to first/last day of the chosen months.""" + start, end, label = _date_range("custom", "2026-03", "2026-05") + assert start == "2026-03-01" + assert end == "2026-05-31" + assert "Mar 2026" in label and "May 2026" in label + + +def test_date_range_custom_single_month(): + start, end, label = _date_range("custom", "2026-02", "2026-02") + assert start == "2026-02-01" + assert end == "2026-02-28" + assert label == "Custom (Feb 2026)" + + +def test_date_range_custom_swaps_when_inverted(): + start, end, _label = _date_range("custom", "2026-12", "2026-01") + assert start == "2026-01-01" + assert end == "2026-12-31" + + +def test_date_range_custom_falls_back_to_current_month_when_blank(): + start, end, _label = _date_range("custom", "", "") + today = date.today() + assert start.startswith(today.strftime("%Y-%m")) + assert start.endswith("-01") + assert end.startswith(today.strftime("%Y-%m")) + + +def test_month_options_descending_and_inclusive(): + opts = _month_options(date(2026, 3, 1), date(2026, 5, 15)) + # latest first, three months, label = abbr month + year, value = YYYY-MM + assert opts == [ + ("May 2026", "2026-05"), + ("Apr 2026", "2026-04"), + ("Mar 2026", "2026-03"), + ] + + +def test_month_options_year_crossover(): + opts = _month_options(date(2025, 11, 1), date(2026, 1, 31)) + assert [v for _, v in opts] == ["2026-01", "2025-12", "2025-11"] diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6bea98b --- /dev/null +++ b/uv.lock @@ -0,0 +1,793 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, + { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +] + +[[package]] +name = "aiohttp-jinja2" +version = "1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e6/39/da5a94dd89b1af7241fb7fc99ae4e73505b5f898b540b6aba6dc7afe600e/aiohttp-jinja2-1.6.tar.gz", hash = "sha256:a3a7ff5264e5bca52e8ae547bbfd0761b72495230d438d05b6c0915be619b0e2", size = 53057, upload-time = "2023-11-18T15:30:52.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/90/65238d4246307195411b87a07d03539049819b022c01bcc773826f600138/aiohttp_jinja2-1.6-py3-none-any.whl", hash = "sha256:0df405ee6ad1b58e5a068a105407dc7dcc1704544c559f1938babde954f945c7", size = 11736, upload-time = "2023-11-18T15:30:50.743Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "click" +version = "8.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "common-cents" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "textual" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, + { name = "textual-dev" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, + { name = "textual", specifier = "==8.2.4" }, + { name = "textual-dev", marker = "extra == 'dev'" }, +] +provides-extras = ["dev"] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "idna" +version = "3.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +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 = "linkify-it-py" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "uc-micro-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/c9/06ea13676ef354f0af6169587ae292d3e2406e212876a413bf9eece4eb23/linkify_it_py-2.1.0.tar.gz", hash = "sha256:43360231720999c10e9328dc3691160e27a718e280673d444c38d7d3aaa3b98b", size = 29158, upload-time = "2026-03-01T07:48:47.683Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b4/de/88b3be5c31b22333b3ca2f6ff1de4e863d8fe45aaea7485f591970ec1d3e/linkify_it_py-2.1.0-py3-none-any.whl", hash = "sha256:0d252c1594ecba2ecedc444053db5d3a9b7ec1b0dd929c8f1d74dce89f86c05e", size = 19878, upload-time = "2026-03-01T07:48:46.098Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { 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 = "mdit-py-plugins" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/fd/a756d36c0bfba5f6e39a1cdbdbfdd448dc02692467d83816dff4592a1ebc/mdit_py_plugins-0.5.0.tar.gz", hash = "sha256:f4918cb50119f50446560513a8e311d574ff6aaed72606ddae6d35716fe809c6", size = 44655, upload-time = "2025-08-11T07:25:49.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/86/dd6e5db36df29e76c7a7699123569a4a18c1623ce68d826ed96c62643cae/mdit_py_plugins-0.5.0-py3-none-any.whl", hash = "sha256:07a08422fc1936a5d26d146759e9155ea466e842f5ab2f7d2266dd084c8dab1f", size = 57205, upload-time = "2025-08-11T07:25:47.597Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "msgpack" +version = "1.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/f2/bfb55a6236ed8725a96b0aa3acbd0ec17588e6a2c3b62a93eb513ed8783f/msgpack-1.1.2.tar.gz", hash = "sha256:3b60763c1373dd60f398488069bcdc703cd08a711477b5d480eecc9f9626f47e", size = 173581, upload-time = "2025-10-08T09:15:56.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/31/b46518ecc604d7edf3a4f94cb3bf021fc62aa301f0cb849936968164ef23/msgpack-1.1.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4efd7b5979ccb539c221a4c4e16aac1a533efc97f3b759bb5a5ac9f6d10383bf", size = 81212, upload-time = "2025-10-08T09:15:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/92/dc/c385f38f2c2433333345a82926c6bfa5ecfff3ef787201614317b58dd8be/msgpack-1.1.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:42eefe2c3e2af97ed470eec850facbe1b5ad1d6eacdbadc42ec98e7dcf68b4b7", size = 84315, upload-time = "2025-10-08T09:15:15.543Z" }, + { url = "https://files.pythonhosted.org/packages/d3/68/93180dce57f684a61a88a45ed13047558ded2be46f03acb8dec6d7c513af/msgpack-1.1.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fdf7d83102bf09e7ce3357de96c59b627395352a4024f6e2458501f158bf999", size = 412721, upload-time = "2025-10-08T09:15:16.567Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ba/459f18c16f2b3fc1a1ca871f72f07d70c07bf768ad0a507a698b8052ac58/msgpack-1.1.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fac4be746328f90caa3cd4bc67e6fe36ca2bf61d5c6eb6d895b6527e3f05071e", size = 424657, upload-time = "2025-10-08T09:15:17.825Z" }, + { url = "https://files.pythonhosted.org/packages/38/f8/4398c46863b093252fe67368b44edc6c13b17f4e6b0e4929dbf0bdb13f23/msgpack-1.1.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:fffee09044073e69f2bad787071aeec727183e7580443dfeb8556cbf1978d162", size = 402668, upload-time = "2025-10-08T09:15:19.003Z" }, + { url = "https://files.pythonhosted.org/packages/28/ce/698c1eff75626e4124b4d78e21cca0b4cc90043afb80a507626ea354ab52/msgpack-1.1.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5928604de9b032bc17f5099496417f113c45bc6bc21b5c6920caf34b3c428794", size = 419040, upload-time = "2025-10-08T09:15:20.183Z" }, + { url = "https://files.pythonhosted.org/packages/67/32/f3cd1667028424fa7001d82e10ee35386eea1408b93d399b09fb0aa7875f/msgpack-1.1.2-cp313-cp313-win32.whl", hash = "sha256:a7787d353595c7c7e145e2331abf8b7ff1e6673a6b974ded96e6d4ec09f00c8c", size = 65037, upload-time = "2025-10-08T09:15:21.416Z" }, + { url = "https://files.pythonhosted.org/packages/74/07/1ed8277f8653c40ebc65985180b007879f6a836c525b3885dcc6448ae6cb/msgpack-1.1.2-cp313-cp313-win_amd64.whl", hash = "sha256:a465f0dceb8e13a487e54c07d04ae3ba131c7c5b95e2612596eafde1dccf64a9", size = 72631, upload-time = "2025-10-08T09:15:22.431Z" }, + { url = "https://files.pythonhosted.org/packages/e5/db/0314e4e2db56ebcf450f277904ffd84a7988b9e5da8d0d61ab2d057df2b6/msgpack-1.1.2-cp313-cp313-win_arm64.whl", hash = "sha256:e69b39f8c0aa5ec24b57737ebee40be647035158f14ed4b40e6f150077e21a84", size = 64118, upload-time = "2025-10-08T09:15:23.402Z" }, + { url = "https://files.pythonhosted.org/packages/22/71/201105712d0a2ff07b7873ed3c220292fb2ea5120603c00c4b634bcdafb3/msgpack-1.1.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e23ce8d5f7aa6ea6d2a2b326b4ba46c985dbb204523759984430db7114f8aa00", size = 81127, upload-time = "2025-10-08T09:15:24.408Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9f/38ff9e57a2eade7bf9dfee5eae17f39fc0e998658050279cbb14d97d36d9/msgpack-1.1.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c15b7d74c939ebe620dd8e559384be806204d73b4f9356320632d783d1f7939", size = 84981, upload-time = "2025-10-08T09:15:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a9/3536e385167b88c2cc8f4424c49e28d49a6fc35206d4a8060f136e71f94c/msgpack-1.1.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:99e2cb7b9031568a2a5c73aa077180f93dd2e95b4f8d3b8e14a73ae94a9e667e", size = 411885, upload-time = "2025-10-08T09:15:27.22Z" }, + { url = "https://files.pythonhosted.org/packages/2f/40/dc34d1a8d5f1e51fc64640b62b191684da52ca469da9cd74e84936ffa4a6/msgpack-1.1.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:180759d89a057eab503cf62eeec0aa61c4ea1200dee709f3a8e9397dbb3b6931", size = 419658, upload-time = "2025-10-08T09:15:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ef/2b92e286366500a09a67e03496ee8b8ba00562797a52f3c117aa2b29514b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:04fb995247a6e83830b62f0b07bf36540c213f6eac8e851166d8d86d83cbd014", size = 403290, upload-time = "2025-10-08T09:15:29.764Z" }, + { url = "https://files.pythonhosted.org/packages/78/90/e0ea7990abea5764e4655b8177aa7c63cdfa89945b6e7641055800f6c16b/msgpack-1.1.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8e22ab046fa7ede9e36eeb4cfad44d46450f37bb05d5ec482b02868f451c95e2", size = 415234, upload-time = "2025-10-08T09:15:31.022Z" }, + { url = "https://files.pythonhosted.org/packages/72/4e/9390aed5db983a2310818cd7d3ec0aecad45e1f7007e0cda79c79507bb0d/msgpack-1.1.2-cp314-cp314-win32.whl", hash = "sha256:80a0ff7d4abf5fecb995fcf235d4064b9a9a8a40a3ab80999e6ac1e30b702717", size = 66391, upload-time = "2025-10-08T09:15:32.265Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f1/abd09c2ae91228c5f3998dbd7f41353def9eac64253de3c8105efa2082f7/msgpack-1.1.2-cp314-cp314-win_amd64.whl", hash = "sha256:9ade919fac6a3e7260b7f64cea89df6bec59104987cbea34d34a2fa15d74310b", size = 73787, upload-time = "2025-10-08T09:15:33.219Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9d9f667ab48b16ad4115c1935d94023b82b3198064cb84a123e97f7466c1/msgpack-1.1.2-cp314-cp314-win_arm64.whl", hash = "sha256:59415c6076b1e30e563eb732e23b994a61c159cec44deaf584e5cc1dd662f2af", size = 66453, upload-time = "2025-10-08T09:15:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/16/67/93f80545eb1792b61a217fa7f06d5e5cb9e0055bed867f43e2b8e012e137/msgpack-1.1.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:897c478140877e5307760b0ea66e0932738879e7aa68144d9b78ea4c8302a84a", size = 85264, upload-time = "2025-10-08T09:15:35.61Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/33c8a24959cf193966ef11a6f6a2995a65eb066bd681fd085afd519a57ce/msgpack-1.1.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a668204fa43e6d02f89dbe79a30b0d67238d9ec4c5bd8a940fc3a004a47b721b", size = 89076, upload-time = "2025-10-08T09:15:36.619Z" }, + { url = "https://files.pythonhosted.org/packages/fc/6b/62e85ff7193663fbea5c0254ef32f0c77134b4059f8da89b958beb7696f3/msgpack-1.1.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5559d03930d3aa0f3aacb4c42c776af1a2ace2611871c84a75afe436695e6245", size = 435242, upload-time = "2025-10-08T09:15:37.647Z" }, + { url = "https://files.pythonhosted.org/packages/c1/47/5c74ecb4cc277cf09f64e913947871682ffa82b3b93c8dad68083112f412/msgpack-1.1.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:70c5a7a9fea7f036b716191c29047374c10721c389c21e9ffafad04df8c52c90", size = 432509, upload-time = "2025-10-08T09:15:38.794Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/e98ccdb56dc4e98c929a3f150de1799831c0a800583cde9fa022fa90602d/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f2cb069d8b981abc72b41aea1c580ce92d57c673ec61af4c500153a626cb9e20", size = 415957, upload-time = "2025-10-08T09:15:40.238Z" }, + { url = "https://files.pythonhosted.org/packages/da/28/6951f7fb67bc0a4e184a6b38ab71a92d9ba58080b27a77d3e2fb0be5998f/msgpack-1.1.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d62ce1f483f355f61adb5433ebfd8868c5f078d1a52d042b0a998682b4fa8c27", size = 422910, upload-time = "2025-10-08T09:15:41.505Z" }, + { url = "https://files.pythonhosted.org/packages/f0/03/42106dcded51f0a0b5284d3ce30a671e7bd3f7318d122b2ead66ad289fed/msgpack-1.1.2-cp314-cp314t-win32.whl", hash = "sha256:1d1418482b1ee984625d88aa9585db570180c286d942da463533b238b98b812b", size = 75197, upload-time = "2025-10-08T09:15:42.954Z" }, + { url = "https://files.pythonhosted.org/packages/15/86/d0071e94987f8db59d4eeb386ddc64d0bb9b10820a8d82bcd3e53eeb2da6/msgpack-1.1.2-cp314-cp314t-win_amd64.whl", hash = "sha256:5a46bf7e831d09470ad92dff02b8b1ac92175ca36b087f904a0519857c6be3ff", size = 85772, upload-time = "2025-10-08T09:15:43.954Z" }, + { url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.9.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +] + +[[package]] +name = "textual" +version = "8.2.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/89/bec5709fb759f9c784bbcb30b2e3497df3f901691d13c2b864dbf6694a17/textual-8.2.4.tar.gz", hash = "sha256:d4e2b2ddd7157191d00b228592b7c739ea080b7d792fd410f23ca75f05ea76c4", size = 1848933, upload-time = "2026-04-19T04:20:45.845Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/32/02932f0d597cdbb34e34bf24266ff0f2cf292ccb3aafc37dd9efcb0cc416/textual-8.2.4-py3-none-any.whl", hash = "sha256:a83bd3f0cc7125ca203845af753f9d6b6be030025ecd1b05cc75ebe645b9c4ba", size = 724390, upload-time = "2026-04-19T04:20:49.968Z" }, +] + +[[package]] +name = "textual-dev" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "click" }, + { name = "msgpack" }, + { name = "textual" }, + { name = "textual-serve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/38/fd/fd5ad9527b536c306a5a860a33a45e13983a59804b48b91ea52b42ba030a/textual_dev-1.8.0.tar.gz", hash = "sha256:7e56867b0341405a95e938cac0647e6d2763d38d0df08710469ad6b6a8db76df", size = 25026, upload-time = "2025-10-11T09:47:01.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/c6/1dc08ceee6d0bdf219c500cb2fbd605b681b63507c4ff27a6477f12f8245/textual_dev-1.8.0-py3-none-any.whl", hash = "sha256:227b6d24a485fbbc77e302aa21f4fdf3083beb57eb45cd95bae082c81cbeddeb", size = 27541, upload-time = "2025-10-11T09:47:00.531Z" }, +] + +[[package]] +name = "textual-serve" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "aiohttp-jinja2" }, + { name = "jinja2" }, + { name = "rich" }, + { name = "textual" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/7e/62fecc552853ec6a178cb1faa2d6f73b34d5512924770e7b08b58ff14148/textual_serve-1.1.3.tar.gz", hash = "sha256:f8f636ae2f5fd651b79d965473c3e9383d3521cdf896f9bc289709185da3f683", size = 448340, upload-time = "2025-11-01T16:22:36.723Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/fe/108e7773349d500cf363328c3d0b7123e03feda51e310a3a5b136ac8ca71/textual_serve-1.1.3-py3-none-any.whl", hash = "sha256:207a472bc6604e725b1adab4ab8bf12f4c4dc25b04eea31e4d04731d8bf30f18", size = 447339, upload-time = "2025-11-01T16:22:35.209Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "uc-micro-py" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/67/9a363818028526e2d4579334460df777115bdec1bb77c08f9db88f6389f2/uc_micro_py-2.0.0.tar.gz", hash = "sha256:c53691e495c8db60e16ffc4861a35469b0ba0821fe409a8a7a0a71864d33a811", size = 6611, upload-time = "2026-03-01T06:31:27.526Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/73/d21edf5b204d1467e06500080a50f79d49ef2b997c79123a536d4a17d97c/uc_micro_py-2.0.0-py3-none-any.whl", hash = "sha256:3603a3859af53e5a39bc7677713c78ea6589ff188d70f4fee165db88e22b242c", size = 6383, upload-time = "2026-03-01T06:31:26.257Z" }, +] + +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +]