This commit is contained in:
Joe Arndt 2026-05-06 18:25:17 -05:00
commit ac93e6074b
38 changed files with 7162 additions and 0 deletions

19
.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
# IDE
.idea/
# Development
.venv/
__pycache__/
*.pyc
*.pyo
*.egg-info
build/
dist/
.ruff_cache
# AI
.claude/
# App
*.db
spending/

111
CLAUDE.md Normal file
View file

@ -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`.

70
README.md Normal file
View file

@ -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).

47
pyproject.toml Normal file
View file

@ -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"]

930
sample_spending.csv Normal file
View file

@ -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,
1 DATE CENTS CATEGORY MERCHANT NOTES TAGS
2 2024-05-01 150101 Household:Mortgage US Bank
3 2024-05-03 999 Subscriptions:Apps Apple iCloud storage
4 2024-05-05 8570 Utilities:Internet Spectrum
5 2024-05-05 3697 Personal:Haircut Great Clips
6 2024-05-07 10539 Utilities:Electric Duke Energy
7 2024-05-07 3829 Entertainment:Food LaRosa's Pizza night
8 2024-05-08 6358 Automotive:Gasoline Casey's Van
9 2024-05-10 14810 Utilities:Phone Phone Company Family plan
10 2024-05-11 2642 Entertainment:Food Chipotle
11 2024-05-11 4000 Healthcare:Chiropractic City Chiro Joann adjustment
12 2024-05-12 6114 Utilities:Water City Water
13 2024-05-14 2599 Entertainment:Food Chipotle
14 2024-05-15 2899 Utilities:Trash Rumpke
15 2024-05-15 23435 Groceries:Food Costco
16 2024-05-15 7357 Clothing:Shoes Famous Footwear
17 2024-05-18 3236 Utilities:Gas Duke Energy Heating
18 2024-05-18 5117 Groceries:Supplies Costco
19 2024-05-19 3275 Entertainment:Food Skyline Chili
20 2024-05-20 14500 Automotive:Insurance Progressive
21 2024-05-20 4167 Clothing:Apparel Kohl's
22 2024-05-21 2636 Clothing:Accessories Macy's
23 2024-05-22 1799 Subscriptions:Streaming:Video Netflix
24 2024-05-22 3628 Entertainment:Movies AMC Theatres Family movie
25 2024-05-23 5562 Household:Improvement Home Depot
26 2024-05-24 7464 Automotive:Gasoline Shell Truck
27 2024-05-25 1199 Subscriptions:Streaming:Music Spotify Family plan
28 2024-05-25 13961 Groceries:Food Aldi
29 2024-05-25 3303 Groceries:Supplies Target Paper towels & detergent
30 2024-05-25 6224 Automotive:Gasoline Shell Van
31 2024-05-26 11651 Groceries:Food Trader Bob's
32 2024-05-26 8564 Automotive:Gasoline Casey's Truck
33 2024-05-27 999 Subscriptions:Streaming:Video Disney+
34 2024-05-27 2811 Entertainment:Activities Cincinnati Zoo
35 2024-05-28 1499 Subscriptions:Apps 1Password
36 2024-05-28 8597 Clothing:Apparel Old Navy
37 2024-05-29 5977 Automotive:Gasoline Marathon Van
38 2024-05-29 1863 Entertainment:Food Graeter's Ice cream
39 2024-06-01 150101 Household:Mortgage US Bank
40 2024-06-03 999 Subscriptions:Apps Apple iCloud storage
41 2024-06-04 6299 Automotive:Maintenance Valvoline Oil change Truck
42 2024-06-05 8450 Utilities:Internet Spectrum
43 2024-06-05 4218 Entertainment:Movies AMC Theatres Family movie
44 2024-06-06 3319 Groceries:Supplies Target Paper towels & detergent
45 2024-06-06 762 Entertainment:Food Casey's
46 2024-06-07 14140 Utilities:Electric Duke Energy
47 2024-06-07 6455 Automotive:Gasoline Kroger Van
48 2024-06-08 1024 Entertainment:Food Dairy Queen Date Night
49 2024-06-09 22597 Groceries:Food Costco
50 2024-06-09 5512 Automotive:Gasoline Kroger Van
51 2024-06-09 3973 Clothing:Shoes Famous Footwear
52 2024-06-10 14299 Utilities:Phone Phone Company Family plan
53 2024-06-10 2687 Groceries:Supplies Amazon Cleaning supplies
54 2024-06-11 4000 Healthcare:Chiropractic City Chiro Joann adjustment
55 2024-06-12 5096 Utilities:Water City Water
56 2024-06-12 9345 Automotive:Gasoline Kroger Truck
57 2024-06-13 2906 Entertainment:Activities Cincinnati Zoo
58 2024-06-15 2899 Utilities:Trash Rumpke
59 2024-06-16 14188 Groceries:Food Trader Bob's
60 2024-06-16 1375 Entertainment:Food Dairy Queen Date Night
61 2024-06-17 4842 Entertainment:Food El Corral Family dinner
62 2024-06-18 2500 Utilities:Gas Duke Energy Heating
63 2024-06-18 9743 Automotive:Gasoline Casey's Truck
64 2024-06-18 8970 Clothing:Apparel Kohl's
65 2024-06-20 14500 Automotive:Insurance Progressive
66 2024-06-21 10396 Groceries:Food Trader Bob's
67 2024-06-21 3044 Clothing:Accessories Macy's
68 2024-06-22 1799 Subscriptions:Streaming:Video Netflix
69 2024-06-24 11218 Groceries:Food Kroger
70 2024-06-25 1199 Subscriptions:Streaming:Music Spotify Family plan
71 2024-06-27 999 Subscriptions:Streaming:Video Disney+
72 2024-06-27 5394 Pets:Food Chewy Dog food
73 2024-06-28 1499 Subscriptions:Apps 1Password
74 2024-06-28 6409 Clothing:Shoes Famous Footwear Kids
75 2024-07-01 150101 Household:Mortgage US Bank
76 2024-07-02 14145 Groceries:Food Trader Bob's
77 2024-07-03 999 Subscriptions:Apps Apple iCloud storage
78 2024-07-05 8512 Utilities:Internet Spectrum
79 2024-07-06 1443 Entertainment:Food Graeter's Ice cream
80 2024-07-06 6000 Entertainment:Movies AMC Theatres Family movie
81 2024-07-07 17101 Utilities:Electric Duke Energy
82 2024-07-08 4213 Clothing:Apparel Kohl's
83 2024-07-09 10504 Groceries:Food Kroger
84 2024-07-10 14768 Utilities:Phone Phone Company Family plan
85 2024-07-10 9700 Automotive:Gasoline Casey's Truck
86 2024-07-11 47800 Automotive:Repairs Tire Discounters Set of 4 tires Van
87 2024-07-12 4960 Utilities:Water City Water
88 2024-07-12 4898 Entertainment:Food El Corral Family dinner
89 2024-07-14 1146 Entertainment:Food Dairy Queen Date Night
90 2024-07-15 2899 Utilities:Trash Rumpke
91 2024-07-15 2261 Groceries:Supplies Target
92 2024-07-15 4663 Entertainment:Food LaRosa's Pizza night
93 2024-07-16 938 Entertainment:Food Casey's
94 2024-07-18 2437 Utilities:Gas Duke Energy Heating
95 2024-07-18 7896 Clothing:Apparel Old Navy
96 2024-07-18 4000 Healthcare:Chiropractic City Chiro Bob adjustment
97 2024-07-18 12790 Household:Improvement Home Depot
98 2024-07-19 7501 Automotive:Gasoline Shell Truck
99 2024-07-19 4483 Clothing:Shoes Famous Footwear
100 2024-07-20 14500 Automotive:Insurance Progressive
101 2024-07-20 987 Entertainment:Food Dairy Queen Date Night
102 2024-07-22 1799 Subscriptions:Streaming:Video Netflix
103 2024-07-23 13292 Groceries:Food Trader Bob's
104 2024-07-23 7328 Automotive:Gasoline Casey's Truck
105 2024-07-25 1199 Subscriptions:Streaming:Music Spotify Family plan
106 2024-07-25 5786 Automotive:Gasoline Marathon Van
107 2024-07-25 3397 Personal:Haircut Great Clips
108 2024-07-26 13232 Groceries:Food Trader Bob's
109 2024-07-27 999 Subscriptions:Streaming:Video Disney+
110 2024-07-27 3413 Entertainment:Food LaRosa's Pizza night
111 2024-07-28 1499 Subscriptions:Apps 1Password
112 2024-08-01 150101 Household:Mortgage US Bank
113 2024-08-01 1346 Entertainment:Food Dairy Queen Date Night
114 2024-08-03 999 Subscriptions:Apps Apple iCloud storage
115 2024-08-04 3895 Entertainment:Movies AMC Theatres Family movie
116 2024-08-05 8471 Utilities:Internet Spectrum
117 2024-08-06 6679 Automotive:Gasoline Marathon Van
118 2024-08-07 16535 Utilities:Electric Duke Energy
119 2024-08-08 9294 Automotive:Gasoline Kroger Truck
120 2024-08-08 5430 Pets:Food Chewy Dog food
121 2024-08-10 14525 Utilities:Phone Phone Company Family plan
122 2024-08-10 9713 Automotive:Gasoline Shell Truck
123 2024-08-11 10700 Groceries:Food Aldi
124 2024-08-11 5768 Groceries:Supplies Costco
125 2024-08-11 4679 Entertainment:Activities Cincinnati Zoo
126 2024-08-12 6235 Utilities:Water City Water
127 2024-08-12 18417 Groceries:Food Costco
128 2024-08-12 731 Entertainment:Food Casey's
129 2024-08-13 4613 Entertainment:Food LaRosa's Pizza night
130 2024-08-14 15211 Groceries:Food Trader Bob's
131 2024-08-15 2899 Utilities:Trash Rumpke
132 2024-08-15 5776 Automotive:Gasoline Marathon Van
133 2024-08-16 7083 Clothing:Shoes Amazon
134 2024-08-18 2431 Utilities:Gas Duke Energy Heating
135 2024-08-18 5646 Automotive:Gasoline Marathon Van
136 2024-08-19 4947 Clothing:Shoes Famous Footwear Kids
137 2024-08-20 14500 Automotive:Insurance Progressive
138 2024-08-20 1475 Entertainment:Food Graeter's Ice cream
139 2024-08-21 1348 Entertainment:Food Dairy Queen Date Night
140 2024-08-21 8256 Pets:Vet All Creatures Vet
141 2024-08-22 1799 Subscriptions:Streaming:Video Netflix
142 2024-08-22 1500 Automotive:Maintenance Mike's Car Wash Van
143 2024-08-23 2756 Groceries:Supplies Kroger Toilet paper bulk
144 2024-08-23 2307 Entertainment:Food Skyline Chili
145 2024-08-25 1199 Subscriptions:Streaming:Music Spotify Family plan
146 2024-08-26 13444 Groceries:Food Trader Bob's
147 2024-08-26 3306 Entertainment:Food Skyline Chili
148 2024-08-27 999 Subscriptions:Streaming:Video Disney+
149 2024-08-28 1499 Subscriptions:Apps 1Password
150 2024-08-31 6806 Clothing:Apparel Old Navy
151 2024-09-01 150101 Household:Mortgage US Bank
152 2024-09-01 4643 Entertainment:Activities Cincinnati Zoo
153 2024-09-03 999 Subscriptions:Apps Apple iCloud storage
154 2024-09-05 8610 Utilities:Internet Spectrum
155 2024-09-05 6243 Automotive:Gasoline Kroger Van
156 2024-09-06 18271 Groceries:Food Costco
157 2024-09-07 13578 Utilities:Electric Duke Energy
158 2024-09-07 14506 Groceries:Food Aldi
159 2024-09-07 6483 Clothing:Shoes Amazon
160 2024-09-10 14085 Utilities:Phone Phone Company Family plan
161 2024-09-10 14817 Groceries:Food Trader Bob's
162 2024-09-11 3200 Personal:Haircut Great Clips
163 2024-09-12 6164 Utilities:Water City Water
164 2024-09-12 713 Entertainment:Food Casey's
165 2024-09-12 3978 Entertainment:Movies AMC Theatres Family movie
166 2024-09-15 2899 Utilities:Trash Rumpke
167 2024-09-15 8697 Automotive:Gasoline Marathon Truck
168 2024-09-16 2643 Entertainment:Food Skyline Chili
169 2024-09-17 905 Entertainment:Food Casey's
170 2024-09-18 3210 Utilities:Gas Duke Energy Heating
171 2024-09-18 5866 Automotive:Gasoline Marathon Van
172 2024-09-20 14500 Automotive:Insurance Progressive
173 2024-09-20 9778 Groceries:Food Aldi
174 2024-09-20 12176 Household:Improvement Home Depot
175 2024-09-21 6187 Automotive:Gasoline Circle K Van
176 2024-09-21 2254 Entertainment:Food Panera Lunch
177 2024-09-21 5592 Pets:Food Chewy Dog food
178 2024-09-22 1799 Subscriptions:Streaming:Video Netflix
179 2024-09-22 3478 Clothing:Accessories Macy's
180 2024-09-23 4885 Groceries:Supplies Costco Paper towels & detergent
181 2024-09-23 7991 Automotive:Gasoline Marathon Truck
182 2024-09-23 3922 Clothing:Apparel Amazon
183 2024-09-25 1199 Subscriptions:Streaming:Music Spotify Family plan
184 2024-09-26 4977 Entertainment:Food El Corral Family dinner
185 2024-09-27 999 Subscriptions:Streaming:Video Disney+
186 2024-09-28 1499 Subscriptions:Apps 1Password
187 2024-09-28 1176 Entertainment:Food Dairy Queen Date Night
188 2024-09-29 5553 Entertainment:Food El Corral Family dinner
189 2024-10-01 150101 Household:Mortgage US Bank
190 2024-10-03 999 Subscriptions:Apps Apple iCloud storage
191 2024-10-03 5930 Clothing:Apparel Amazon
192 2024-10-04 4374 Entertainment:Food LaRosa's Pizza night
193 2024-10-05 8425 Utilities:Internet Spectrum
194 2024-10-05 13980 Groceries:Food Trader Bob's
195 2024-10-05 4377 Entertainment:Food LaRosa's Pizza night
196 2024-10-07 9784 Utilities:Electric Duke Energy
197 2024-10-07 21144 Groceries:Food Costco
198 2024-10-07 2664 Entertainment:Food Panera Lunch
199 2024-10-10 14891 Utilities:Phone Phone Company Family plan
200 2024-10-12 5859 Utilities:Water City Water
201 2024-10-13 5525 Automotive:Gasoline Casey's Van
202 2024-10-13 5975 Pets:Food Chewy Dog food
203 2024-10-15 2899 Utilities:Trash Rumpke
204 2024-10-15 2650 Entertainment:Food Chipotle
205 2024-10-15 6850 Automotive:Maintenance Valvoline Oil change Van
206 2024-10-16 6192 Automotive:Gasoline Circle K Van
207 2024-10-18 4855 Utilities:Gas Duke Energy Heating
208 2024-10-20 14500 Automotive:Insurance Progressive
209 2024-10-20 8106 Clothing:Apparel Amazon
210 2024-10-21 2780 Entertainment:Food Panera Lunch
211 2024-10-22 1799 Subscriptions:Streaming:Video Netflix
212 2024-10-23 4400 Groceries:Supplies Costco Trash bags & foil
213 2024-10-23 1097 Entertainment:Food Dairy Queen Date Night
214 2024-10-25 1199 Subscriptions:Streaming:Music Spotify Family plan
215 2024-10-25 1149 Entertainment:Food Dairy Queen Date Night
216 2024-10-26 6776 Automotive:Gasoline Kroger Van
217 2024-10-27 999 Subscriptions:Streaming:Video Disney+
218 2024-10-27 12128 Groceries:Food Kroger
219 2024-10-28 1499 Subscriptions:Apps 1Password
220 2024-10-28 16093 Household:Repairs Lowe's
221 2024-11-01 150101 Household:Mortgage US Bank
222 2024-11-03 999 Subscriptions:Apps Apple iCloud storage
223 2024-11-03 10701 Groceries:Food Aldi
224 2024-11-04 3384 Groceries:Supplies Target Cleaning supplies
225 2024-11-05 8413 Utilities:Internet Spectrum
226 2024-11-05 15943 Groceries:Food Costco
227 2024-11-07 10421 Utilities:Electric Duke Energy
228 2024-11-07 15574 Groceries:Food Aldi
229 2024-11-08 1979 Entertainment:Food Panera Lunch
230 2024-11-10 14258 Utilities:Phone Phone Company Family plan
231 2024-11-12 4960 Utilities:Water City Water
232 2024-11-12 10800 Groceries:Food Kroger
233 2024-11-12 787 Entertainment:Food Casey's
234 2024-11-14 4000 Healthcare:Chiropractic City Chiro Jimmy adjustment
235 2024-11-14 6031 Pets:Food Chewy Dog food
236 2024-11-15 2899 Utilities:Trash Rumpke
237 2024-11-15 1349 Entertainment:Food Graeter's Ice cream
238 2024-11-18 6469 Utilities:Gas Duke Energy Heating
239 2024-11-18 2977 Groceries:Supplies Kroger Trash bags & foil
240 2024-11-18 6541 Automotive:Gasoline Shell Van
241 2024-11-20 14500 Automotive:Insurance Progressive
242 2024-11-20 7806 Automotive:Gasoline Kroger Truck
243 2024-11-22 1799 Subscriptions:Streaming:Video Netflix
244 2024-11-23 2573 Clothing:Accessories Macy's
245 2024-11-25 1199 Subscriptions:Streaming:Music Spotify Family plan
246 2024-11-25 5281 Clothing:Shoes Famous Footwear
247 2024-11-26 9178 Clothing:Apparel Target
248 2024-11-27 999 Subscriptions:Streaming:Video Disney+
249 2024-11-27 6389 Automotive:Gasoline Marathon Van
250 2024-11-28 1499 Subscriptions:Apps 1Password
251 2024-11-29 1195 Entertainment:Food Dairy Queen Date Night
252 2024-11-30 7400 Automotive:Maintenance Tire Discounters Tire rotation & alignment Truck
253 2024-12-01 150101 Household:Mortgage US Bank
254 2024-12-02 21459 Groceries:Food Costco
255 2024-12-02 5753 Clothing:Apparel Old Navy
256 2024-12-03 999 Subscriptions:Apps Apple iCloud storage
257 2024-12-05 8454 Utilities:Internet Spectrum
258 2024-12-05 9246 Automotive:Gasoline Marathon Truck
259 2024-12-05 8629 Gifts:Holiday Amazon Christmas
260 2024-12-05 10518 Gifts:Holiday Amazon Christmas
261 2024-12-06 14343 Groceries:Food Aldi
262 2024-12-06 6801 Gifts:Holiday Target Christmas
263 2024-12-07 13602 Utilities:Electric Duke Energy
264 2024-12-09 5417 Entertainment:Movies AMC Theatres Family movie
265 2024-12-10 14824 Utilities:Phone Phone Company Family plan
266 2024-12-12 5331 Utilities:Water City Water
267 2024-12-13 8009 Automotive:Gasoline Circle K Truck
268 2024-12-13 3969 Gifts:Holiday Best Buy Christmas
269 2024-12-14 2839 Entertainment:Food Panera Lunch
270 2024-12-15 2899 Utilities:Trash Rumpke
271 2024-12-15 2250 Entertainment:Food Panera Lunch
272 2024-12-15 3315 Clothing:Accessories Macy's
273 2024-12-16 3730 Groceries:Supplies Kroger Trash bags & foil
274 2024-12-16 5366 Pets:Food Chewy Dog food
275 2024-12-16 4705 Gifts:Holiday Amazon Christmas
276 2024-12-17 4766 Household:Improvement Home Depot
277 2024-12-18 9250 Utilities:Gas Duke Energy Heating
278 2024-12-18 7712 Gifts:Holiday Amazon Christmas
279 2024-12-18 4769 Gifts:Holiday Amazon Christmas
280 2024-12-19 1910 Entertainment:Food Graeter's Ice cream
281 2024-12-19 12978 Clothing:Shoes Amazon
282 2024-12-20 14500 Automotive:Insurance Progressive
283 2024-12-20 3498 Groceries:Supplies Target Cleaning supplies
284 2024-12-21 16655 Household:Repairs Lowe's
285 2024-12-21 3781 Gifts:Holiday Best Buy Christmas
286 2024-12-22 1799 Subscriptions:Streaming:Video Netflix
287 2024-12-22 7800 Automotive:Gasoline Circle K Truck
288 2024-12-23 6513 Automotive:Gasoline Kroger Van
289 2024-12-23 7976 Clothing:Apparel Kohl's
290 2024-12-24 12016 Groceries:Food Trader Bob's
291 2024-12-24 2914 Entertainment:Food Chipotle
292 2024-12-25 1199 Subscriptions:Streaming:Music Spotify Family plan
293 2024-12-25 11908 Groceries:Food Aldi
294 2024-12-25 3816 Entertainment:Food Chipotle
295 2024-12-27 999 Subscriptions:Streaming:Video Disney+
296 2024-12-27 3415 Entertainment:Food LaRosa's Pizza night
297 2024-12-28 1499 Subscriptions:Apps 1Password
298 2024-12-29 2681 Entertainment:Food Panera Lunch
299 2025-01-01 150101 Household:Mortgage US Bank
300 2025-01-03 999 Subscriptions:Apps Apple iCloud storage
301 2025-01-03 1012 Entertainment:Food Dairy Queen Date Night
302 2025-01-05 8408 Utilities:Internet Spectrum
303 2025-01-06 2877 Groceries:Supplies Target Trash bags & foil
304 2025-01-06 1296 Entertainment:Food Graeter's Ice cream
305 2025-01-07 13902 Utilities:Electric Duke Energy
306 2025-01-07 3345 Clothing:Apparel Kohl's
307 2025-01-08 3614 Personal:Haircut Great Clips
308 2025-01-09 7402 Automotive:Gasoline Circle K Truck
309 2025-01-10 14150 Utilities:Phone Phone Company Family plan
310 2025-01-12 6036 Utilities:Water City Water
311 2025-01-14 8392 Automotive:Gasoline Shell Truck
312 2025-01-15 2899 Utilities:Trash Rumpke
313 2025-01-15 1805 Healthcare:Pharmacy CVS
314 2025-01-16 7411 Automotive:Gasoline Casey's Truck
315 2025-01-17 15721 Groceries:Food Aldi
316 2025-01-18 12139 Utilities:Gas Duke Energy Heating
317 2025-01-19 5500 Automotive:Maintenance BMV Registration renewal Van
318 2025-01-20 14500 Automotive:Insurance Progressive
319 2025-01-20 10033 Groceries:Food Aldi
320 2025-01-22 1799 Subscriptions:Streaming:Video Netflix
321 2025-01-23 10861 Groceries:Food Kroger
322 2025-01-24 6139 Clothing:Apparel Amazon
323 2025-01-24 5205 Pets:Food Chewy Dog food
324 2025-01-25 1199 Subscriptions:Streaming:Music Spotify Family plan
325 2025-01-26 3247 Groceries:Supplies Target Cleaning supplies
326 2025-01-26 7799 Automotive:Gasoline Circle K Truck
327 2025-01-26 1245 Entertainment:Food Dairy Queen Date Night
328 2025-01-26 5643 Clothing:Accessories Macy's
329 2025-01-27 999 Subscriptions:Streaming:Video Disney+
330 2025-01-27 3779 Entertainment:Food Chipotle
331 2025-01-28 1499 Subscriptions:Apps 1Password
332 2025-01-30 2116 Entertainment:Food Panera Lunch
333 2025-01-31 4000 Healthcare:Chiropractic City Chiro Jimmy adjustment
334 2025-02-01 150101 Household:Mortgage US Bank
335 2025-02-02 9413 Automotive:Gasoline Kroger Truck
336 2025-02-02 4013 Entertainment:Food LaRosa's Pizza night
337 2025-02-02 4872 Clothing:Accessories Macy's
338 2025-02-03 999 Subscriptions:Apps Apple iCloud storage
339 2025-02-04 15620 Groceries:Food Aldi
340 2025-02-05 8415 Utilities:Internet Spectrum
341 2025-02-07 13568 Utilities:Electric Duke Energy
342 2025-02-07 11206 Groceries:Food Kroger
343 2025-02-08 8264 Automotive:Gasoline Shell Truck
344 2025-02-08 4379 Clothing:Shoes Famous Footwear
345 2025-02-10 14836 Utilities:Phone Phone Company Family plan
346 2025-02-12 5371 Utilities:Water City Water
347 2025-02-14 3662 Personal:Haircut Great Clips
348 2025-02-15 2899 Utilities:Trash Rumpke
349 2025-02-15 4053 Groceries:Supplies Costco Paper towels & detergent
350 2025-02-15 4166 Entertainment:Food LaRosa's Pizza night
351 2025-02-18 9178 Utilities:Gas Duke Energy Heating
352 2025-02-18 1001 Entertainment:Food Dairy Queen Date Night
353 2025-02-18 8068 Clothing:Apparel Kohl's
354 2025-02-20 14500 Automotive:Insurance Progressive
355 2025-02-21 7044 Automotive:Gasoline Circle K Van
356 2025-02-22 1799 Subscriptions:Streaming:Video Netflix
357 2025-02-22 8729 Automotive:Gasoline Circle K Truck
358 2025-02-22 4494 Entertainment:Movies AMC Theatres Family movie
359 2025-02-22 6499 Automotive:Maintenance Valvoline Oil change Truck
360 2025-02-23 14462 Groceries:Food Kroger
361 2025-02-23 2684 Groceries:Supplies Target Trash bags & foil
362 2025-02-24 12821 Groceries:Food Aldi
363 2025-02-24 2128 Entertainment:Activities Cincinnati Zoo
364 2025-02-25 1199 Subscriptions:Streaming:Music Spotify Family plan
365 2025-02-25 4321 Clothing:Apparel Target
366 2025-02-26 840 Entertainment:Food Casey's
367 2025-02-27 999 Subscriptions:Streaming:Video Disney+
368 2025-02-28 1499 Subscriptions:Apps 1Password
369 2025-03-01 150101 Household:Mortgage US Bank
370 2025-03-01 5961 Pets:Food Chewy Dog food
371 2025-03-01 8090 Household:Improvement Home Depot
372 2025-03-01 2615 Entertainment:Activities Cincinnati Zoo
373 2025-03-02 1298 Entertainment:Food Dairy Queen Date Night
374 2025-03-03 999 Subscriptions:Apps Apple iCloud storage
375 2025-03-03 13773 Groceries:Food Aldi
376 2025-03-03 6511 Automotive:Gasoline Shell Van
377 2025-03-04 21500 Automotive:Repairs Tire Discounters Alternator replacement Truck
378 2025-03-05 8513 Utilities:Internet Spectrum
379 2025-03-05 2473 Groceries:Supplies Kroger Toilet paper bulk
380 2025-03-05 4858 Entertainment:Movies AMC Theatres Family movie
381 2025-03-07 10031 Utilities:Electric Duke Energy
382 2025-03-08 12889 Groceries:Food Aldi
383 2025-03-09 1371 Entertainment:Food Dairy Queen Date Night
384 2025-03-10 14079 Utilities:Phone Phone Company Family plan
385 2025-03-10 3431 Clothing:Accessories Macy's
386 2025-03-11 7380 Automotive:Gasoline Circle K Truck
387 2025-03-12 5501 Utilities:Water City Water
388 2025-03-13 7485 Clothing:Apparel Old Navy
389 2025-03-13 7245 Clothing:Shoes Famous Footwear
390 2025-03-14 4392 Entertainment:Food LaRosa's Pizza night
391 2025-03-15 2899 Utilities:Trash Rumpke
392 2025-03-15 4148 Clothing:Apparel Amazon
393 2025-03-15 142500 Household:Insurance State Farm Annual premium
394 2025-03-18 6527 Utilities:Gas Duke Energy Heating
395 2025-03-20 14500 Automotive:Insurance Progressive
396 2025-03-21 9564 Automotive:Gasoline Casey's Truck
397 2025-03-22 1799 Subscriptions:Streaming:Video Netflix
398 2025-03-25 1199 Subscriptions:Streaming:Music Spotify Family plan
399 2025-03-25 6365 Entertainment:Food El Corral Family dinner
400 2025-03-27 999 Subscriptions:Streaming:Video Disney+
401 2025-03-27 5384 Automotive:Gasoline Kroger Van
402 2025-03-28 1499 Subscriptions:Apps 1Password
403 2025-03-28 10237 Groceries:Food Trader Bob's
404 2025-03-29 14822 Groceries:Food Kroger
405 2025-03-29 7566 Automotive:Gasoline Marathon Truck
406 2025-03-31 3874 Personal:Haircut Great Clips
407 2025-04-01 150101 Household:Mortgage US Bank
408 2025-04-02 5700 Clothing:Shoes Amazon Kids
409 2025-04-02 4000 Healthcare:Chiropractic City Chiro Joann adjustment
410 2025-04-03 999 Subscriptions:Apps Apple iCloud storage
411 2025-04-04 698 Entertainment:Food Casey's
412 2025-04-05 8431 Utilities:Internet Spectrum
413 2025-04-07 10779 Utilities:Electric Duke Energy
414 2025-04-07 5796 Automotive:Gasoline Shell Van
415 2025-04-07 4013 Entertainment:Food LaRosa's Pizza night
416 2025-04-07 1416 Clothing:Accessories Macy's
417 2025-04-09 14913 Groceries:Food Kroger
418 2025-04-10 14340 Utilities:Phone Phone Company Family plan
419 2025-04-10 7398 Automotive:Gasoline Shell Truck
420 2025-04-10 12031 Clothing:Shoes Amazon
421 2025-04-11 6012 Pets:Food Chewy Dog food
422 2025-04-12 5373 Utilities:Water City Water
423 2025-04-12 1127 Entertainment:Food Dairy Queen Date Night
424 2025-04-12 2860 Personal:Haircut Great Clips
425 2025-04-12 2899 Automotive:Maintenance AutoZone Wipers & coolant Van
426 2025-04-14 1716 Entertainment:Food Graeter's Ice cream
427 2025-04-15 2899 Utilities:Trash Rumpke
428 2025-04-15 9542 Automotive:Gasoline Circle K Truck
429 2025-04-16 3061 Entertainment:Food Skyline Chili
430 2025-04-17 9600 Groceries:Food Kroger
431 2025-04-18 4734 Utilities:Gas Duke Energy Heating
432 2025-04-19 1438 Entertainment:Food Dairy Queen Date Night
433 2025-04-20 14500 Automotive:Insurance Progressive
434 2025-04-22 1799 Subscriptions:Streaming:Video Netflix
435 2025-04-23 3949 Groceries:Supplies Kroger Toilet paper bulk
436 2025-04-25 1199 Subscriptions:Streaming:Music Spotify Family plan
437 2025-04-25 13674 Groceries:Food Trader Bob's
438 2025-04-26 6122 Clothing:Apparel Amazon
439 2025-04-27 999 Subscriptions:Streaming:Video Disney+
440 2025-04-27 3201 Entertainment:Food Skyline Chili
441 2025-04-27 7544 Household:Repairs Lowe's
442 2025-04-28 1499 Subscriptions:Apps 1Password
443 2025-05-01 150101 Household:Mortgage US Bank
444 2025-05-02 14141 Groceries:Food Kroger
445 2025-05-02 7396 Automotive:Gasoline Marathon Truck
446 2025-05-03 999 Subscriptions:Apps Apple iCloud storage
447 2025-05-05 8624 Utilities:Internet Spectrum
448 2025-05-07 10035 Utilities:Electric Duke Energy
449 2025-05-07 7232 Automotive:Gasoline Shell Truck
450 2025-05-07 4705 Entertainment:Food LaRosa's Pizza night
451 2025-05-08 7337 Automotive:Gasoline Shell Truck
452 2025-05-08 8711 Clothing:Apparel Old Navy
453 2025-05-09 686 Entertainment:Food Casey's
454 2025-05-10 14260 Utilities:Phone Phone Company Family plan
455 2025-05-10 4302 Clothing:Accessories Macy's
456 2025-05-10 5142 Pets:Food Chewy Dog food
457 2025-05-12 6140 Utilities:Water City Water
458 2025-05-12 988 Entertainment:Food Casey's
459 2025-05-13 4201 Clothing:Shoes Amazon
460 2025-05-14 3298 Entertainment:Food Skyline Chili
461 2025-05-15 2899 Utilities:Trash Rumpke
462 2025-05-15 12602 Groceries:Food Trader Bob's
463 2025-05-15 3151 Groceries:Supplies Kroger Paper towels & detergent
464 2025-05-16 3000 Entertainment:Food Skyline Chili
465 2025-05-17 26494 Groceries:Food Costco
466 2025-05-18 3209 Utilities:Gas Duke Energy Heating
467 2025-05-19 4000 Healthcare:Chiropractic City Chiro Jimmy adjustment
468 2025-05-20 14500 Automotive:Insurance Progressive
469 2025-05-20 8972 Clothing:Apparel Kohl's
470 2025-05-22 1799 Subscriptions:Streaming:Video Netflix
471 2025-05-23 3205 Groceries:Supplies Kroger Paper towels & detergent
472 2025-05-23 5989 Automotive:Gasoline Kroger Van
473 2025-05-23 2645 Entertainment:Food Panera Lunch
474 2025-05-25 1199 Subscriptions:Streaming:Music Spotify Family plan
475 2025-05-25 18619 Groceries:Food Costco
476 2025-05-26 6658 Pets:Vet All Creatures Vet
477 2025-05-26 3537 Personal:Haircut Great Clips
478 2025-05-27 999 Subscriptions:Streaming:Video Disney+
479 2025-05-28 1499 Subscriptions:Apps 1Password
480 2025-05-30 12467 Household:Improvement Home Depot
481 2025-05-31 3488 Healthcare:Pharmacy CVS
482 2025-06-01 150101 Household:Mortgage US Bank
483 2025-06-01 2292 Entertainment:Food Panera Lunch
484 2025-06-02 3017 Personal:Haircut Great Clips
485 2025-06-03 999 Subscriptions:Apps Apple iCloud storage
486 2025-06-03 8236 Clothing:Apparel Target
487 2025-06-05 8552 Utilities:Internet Spectrum
488 2025-06-05 839 Entertainment:Food Casey's
489 2025-06-06 6885 Groceries:Supplies Costco Toilet paper bulk
490 2025-06-06 8093 Automotive:Gasoline Shell Truck
491 2025-06-07 14323 Utilities:Electric Duke Energy
492 2025-06-08 17795 Groceries:Food Costco
493 2025-06-08 5813 Entertainment:Food El Corral Family dinner
494 2025-06-08 6750 Automotive:Maintenance Valvoline Oil change Van
495 2025-06-09 8029 Automotive:Gasoline Marathon Truck
496 2025-06-10 14455 Utilities:Phone Phone Company Family plan
497 2025-06-12 5853 Utilities:Water City Water
498 2025-06-12 8674 Clothing:Shoes Amazon
499 2025-06-12 5084 Clothing:Shoes Famous Footwear Kids
500 2025-06-13 8505 Automotive:Gasoline Shell Truck
501 2025-06-13 2813 Entertainment:Food Skyline Chili
502 2025-06-14 1491 Clothing:Accessories Macy's
503 2025-06-14 9476 Household:Repairs Lowe's
504 2025-06-15 2899 Utilities:Trash Rumpke
505 2025-06-15 15644 Groceries:Food Aldi
506 2025-06-18 2628 Utilities:Gas Duke Energy Heating
507 2025-06-18 5600 Pets:Food Chewy Dog food
508 2025-06-20 14500 Automotive:Insurance Progressive
509 2025-06-20 4000 Healthcare:Chiropractic City Chiro Jimmy adjustment
510 2025-06-21 8076 Clothing:Apparel Target
511 2025-06-22 1799 Subscriptions:Streaming:Video Netflix
512 2025-06-22 6190 Automotive:Gasoline Kroger Van
513 2025-06-22 3175 Healthcare:Pharmacy CVS
514 2025-06-22 5045 Household:Improvement Home Depot
515 2025-06-23 772 Entertainment:Food Casey's
516 2025-06-25 1199 Subscriptions:Streaming:Music Spotify Family plan
517 2025-06-25 20726 Groceries:Food Costco
518 2025-06-25 6695 Automotive:Gasoline Circle K Van
519 2025-06-25 6073 Entertainment:Movies AMC Theatres Family movie
520 2025-06-26 1651 Entertainment:Food Graeter's Ice cream
521 2025-06-27 999 Subscriptions:Streaming:Video Disney+
522 2025-06-28 1499 Subscriptions:Apps 1Password
523 2025-06-28 2885 Entertainment:Food Chipotle
524 2025-07-01 150101 Household:Mortgage US Bank
525 2025-07-03 999 Subscriptions:Apps Apple iCloud storage
526 2025-07-03 14471 Groceries:Food Kroger
527 2025-07-04 11156 Groceries:Food Kroger
528 2025-07-04 8348 Automotive:Gasoline Marathon Truck
529 2025-07-05 8475 Utilities:Internet Spectrum
530 2025-07-06 6701 Automotive:Gasoline Circle K Van
531 2025-07-07 14956 Utilities:Electric Duke Energy
532 2025-07-07 1287 Entertainment:Food Dairy Queen Date Night
533 2025-07-10 14309 Utilities:Phone Phone Company Family plan
534 2025-07-11 6365 Automotive:Gasoline Kroger Van
535 2025-07-11 3282 Entertainment:Food Skyline Chili
536 2025-07-12 6261 Utilities:Water City Water
537 2025-07-12 4000 Healthcare:Chiropractic City Chiro Joann adjustment
538 2025-07-15 2899 Utilities:Trash Rumpke
539 2025-07-17 5929 Groceries:Supplies Costco Paper towels & detergent
540 2025-07-18 2617 Utilities:Gas Duke Energy Heating
541 2025-07-20 14500 Automotive:Insurance Progressive
542 2025-07-20 9835 Groceries:Food Kroger
543 2025-07-20 1500 Automotive:Maintenance Mike's Car Wash Truck
544 2025-07-21 3832 Groceries:Supplies Target Paper towels & detergent
545 2025-07-21 16185 Pets:Vet All Creatures Vet
546 2025-07-22 1799 Subscriptions:Streaming:Video Netflix
547 2025-07-22 12960 Groceries:Food Kroger
548 2025-07-22 4409 Clothing:Apparel Target
549 2025-07-23 6185 Entertainment:Movies AMC Theatres Family movie
550 2025-07-25 1199 Subscriptions:Streaming:Music Spotify Family plan
551 2025-07-25 9279 Clothing:Apparel Kohl's
552 2025-07-27 999 Subscriptions:Streaming:Video Disney+
553 2025-07-27 1197 Entertainment:Food Dairy Queen Date Night
554 2025-07-27 21832 Household:Repairs Lowe's
555 2025-07-28 1499 Subscriptions:Apps 1Password
556 2025-07-28 1218 Entertainment:Food Dairy Queen Date Night
557 2025-07-28 4931 Clothing:Shoes Amazon Kids
558 2025-07-30 6014 Pets:Food Chewy Dog food
559 2025-08-01 150101 Household:Mortgage US Bank
560 2025-08-02 1444 Clothing:Accessories Macy's
561 2025-08-03 999 Subscriptions:Apps Apple iCloud storage
562 2025-08-04 12555 Pets:Vet All Creatures Vet
563 2025-08-05 8646 Utilities:Internet Spectrum
564 2025-08-05 19022 Groceries:Food Costco
565 2025-08-05 4155 Entertainment:Movies AMC Theatres Family movie
566 2025-08-06 10431 Groceries:Food Kroger
567 2025-08-06 6489 Automotive:Gasoline Casey's Van
568 2025-08-06 2437 Entertainment:Food Skyline Chili
569 2025-08-07 17266 Utilities:Electric Duke Energy
570 2025-08-08 2243 Entertainment:Food Panera Lunch
571 2025-08-09 6163 Automotive:Gasoline Kroger Van
572 2025-08-09 5295 Pets:Food Chewy Dog food
573 2025-08-10 14534 Utilities:Phone Phone Company Family plan
574 2025-08-12 6075 Utilities:Water City Water
575 2025-08-13 2381 Entertainment:Food Skyline Chili
576 2025-08-14 2267 Groceries:Supplies Kroger Paper towels & detergent
577 2025-08-14 3214 Healthcare:Pharmacy CVS
578 2025-08-15 2899 Utilities:Trash Rumpke
579 2025-08-17 7652 Automotive:Gasoline Circle K Truck
580 2025-08-17 8627 Clothing:Apparel Kohl's
581 2025-08-18 2796 Utilities:Gas Duke Energy Heating
582 2025-08-18 10338 Groceries:Food Kroger
583 2025-08-19 3930 Groceries:Supplies Amazon Trash bags & foil
584 2025-08-20 14500 Automotive:Insurance Progressive
585 2025-08-20 4940 Clothing:Apparel Old Navy
586 2025-08-21 1497 Entertainment:Food Graeter's Ice cream
587 2025-08-21 4000 Healthcare:Chiropractic City Chiro Joann adjustment
588 2025-08-22 1799 Subscriptions:Streaming:Video Netflix
589 2025-08-23 3525 Entertainment:Food Chipotle
590 2025-08-25 1199 Subscriptions:Streaming:Music Spotify Family plan
591 2025-08-26 9308 Automotive:Gasoline Kroger Truck
592 2025-08-27 999 Subscriptions:Streaming:Video Disney+
593 2025-08-27 13919 Groceries:Food Trader Bob's
594 2025-08-28 1499 Subscriptions:Apps 1Password
595 2025-08-29 12400 Automotive:Repairs AutoZone Battery & starter check Van
596 2025-09-01 150101 Household:Mortgage US Bank
597 2025-09-01 2963 Entertainment:Food Skyline Chili
598 2025-09-03 999 Subscriptions:Apps Apple iCloud storage
599 2025-09-03 6604 Automotive:Gasoline Casey's Van
600 2025-09-04 6614 Clothing:Apparel Kohl's
601 2025-09-04 7013 Clothing:Apparel Old Navy
602 2025-09-05 8451 Utilities:Internet Spectrum
603 2025-09-05 11528 Household:Repairs Lowe's
604 2025-09-06 15737 Groceries:Food Aldi
605 2025-09-07 14050 Utilities:Electric Duke Energy
606 2025-09-09 7929 Automotive:Gasoline Kroger Truck
607 2025-09-10 14386 Utilities:Phone Phone Company Family plan
608 2025-09-10 2304 Groceries:Supplies Target Toilet paper bulk
609 2025-09-10 1800 Entertainment:Food Graeter's Ice cream
610 2025-09-11 7285 Automotive:Gasoline Kroger Truck
611 2025-09-12 5143 Utilities:Water City Water
612 2025-09-12 14249 Groceries:Food Trader Bob's
613 2025-09-14 6299 Automotive:Maintenance Valvoline Oil change Truck
614 2025-09-15 2899 Utilities:Trash Rumpke
615 2025-09-17 11521 Groceries:Food Kroger
616 2025-09-18 3259 Utilities:Gas Duke Energy Heating
617 2025-09-18 1249 Entertainment:Food Dairy Queen Date Night
618 2025-09-19 7633 Automotive:Gasoline Shell Truck
619 2025-09-19 1102 Entertainment:Food Dairy Queen Date Night
620 2025-09-20 14500 Automotive:Insurance Progressive
621 2025-09-22 1799 Subscriptions:Streaming:Video Netflix
622 2025-09-24 1183 Entertainment:Food Dairy Queen Date Night
623 2025-09-25 1199 Subscriptions:Streaming:Music Spotify Family plan
624 2025-09-27 999 Subscriptions:Streaming:Video Disney+
625 2025-09-28 1499 Subscriptions:Apps 1Password
626 2025-10-01 150101 Household:Mortgage US Bank
627 2025-10-01 4056 Entertainment:Food LaRosa's Pizza night
628 2025-10-02 5416 Pets:Food Chewy Dog food
629 2025-10-03 999 Subscriptions:Apps Apple iCloud storage
630 2025-10-03 8399 Clothing:Apparel Kohl's
631 2025-10-03 7241 Clothing:Shoes Amazon
632 2025-10-04 9523 Automotive:Gasoline Kroger Truck
633 2025-10-05 8657 Utilities:Internet Spectrum
634 2025-10-06 14293 Groceries:Food Kroger
635 2025-10-06 2558 Entertainment:Food Skyline Chili
636 2025-10-07 9185 Utilities:Electric Duke Energy
637 2025-10-08 9457 Automotive:Gasoline Kroger Truck
638 2025-10-09 13587 Groceries:Food Kroger
639 2025-10-09 5721 Automotive:Gasoline Casey's Van
640 2025-10-09 3249 Entertainment:Food Chipotle
641 2025-10-10 14090 Utilities:Phone Phone Company Family plan
642 2025-10-11 6499 Automotive:Maintenance Valvoline Oil change Truck
643 2025-10-12 6448 Utilities:Water City Water
644 2025-10-12 3985 Groceries:Supplies Target Paper towels & detergent
645 2025-10-15 2899 Utilities:Trash Rumpke
646 2025-10-15 4703 Entertainment:Activities Cincinnati Zoo
647 2025-10-18 4489 Utilities:Gas Duke Energy Heating
648 2025-10-18 6350 Clothing:Apparel Target
649 2025-10-19 1007 Entertainment:Food Dairy Queen Date Night
650 2025-10-19 4403 Groceries:Supplies Costco Trash bags & foil
651 2025-10-20 14500 Automotive:Insurance Progressive
652 2025-10-22 1799 Subscriptions:Streaming:Video Netflix
653 2025-10-23 3558 Entertainment:Food LaRosa's Pizza night
654 2025-10-25 1199 Subscriptions:Streaming:Music Spotify Family plan
655 2025-10-25 9010 Groceries:Food Trader Bob's
656 2025-10-25 1632 Entertainment:Food Graeter's Ice cream
657 2025-10-26 2328 Entertainment:Food Skyline Chili
658 2025-10-27 999 Subscriptions:Streaming:Video Disney+
659 2025-10-28 1499 Subscriptions:Apps 1Password
660 2025-10-28 4000 Healthcare:Chiropractic City Chiro Bob adjustment
661 2025-11-01 150101 Household:Mortgage US Bank
662 2025-11-01 3339 Personal:Haircut Great Clips
663 2025-11-02 3739 Groceries:Supplies Target Toilet paper bulk
664 2025-11-03 999 Subscriptions:Apps Apple iCloud storage
665 2025-11-04 4094 Groceries:Supplies Kroger Paper towels & detergent
666 2025-11-05 8413 Utilities:Internet Spectrum
667 2025-11-05 3117 Entertainment:Food Skyline Chili
668 2025-11-07 11272 Utilities:Electric Duke Energy
669 2025-11-07 10088 Groceries:Food Aldi
670 2025-11-08 4101 Entertainment:Food LaRosa's Pizza night
671 2025-11-08 1500 Automotive:Maintenance Mike's Car Wash Van
672 2025-11-09 8163 Automotive:Gasoline Marathon Truck
673 2025-11-09 2843 Entertainment:Food Chipotle
674 2025-11-09 5985 Entertainment:Movies AMC Theatres Family movie
675 2025-11-10 14428 Utilities:Phone Phone Company Family plan
676 2025-11-10 3880 Entertainment:Activities Cincinnati Zoo
677 2025-11-12 5359 Utilities:Water City Water
678 2025-11-12 8517 Automotive:Gasoline Kroger Truck
679 2025-11-13 2013 Entertainment:Food Panera Lunch
680 2025-11-13 8131 Clothing:Apparel Amazon
681 2025-11-15 2899 Utilities:Trash Rumpke
682 2025-11-15 4849 Entertainment:Food El Corral Family dinner
683 2025-11-18 7035 Utilities:Gas Duke Energy Heating
684 2025-11-19 5818 Automotive:Gasoline Kroger Van
685 2025-11-20 14500 Automotive:Insurance Progressive
686 2025-11-21 11858 Groceries:Food Trader Bob's
687 2025-11-21 1001 Entertainment:Food Casey's
688 2025-11-22 1799 Subscriptions:Streaming:Video Netflix
689 2025-11-22 25185 Groceries:Food Costco
690 2025-11-23 8089 Automotive:Gasoline Circle K Truck
691 2025-11-24 5276 Household:Improvement Home Depot
692 2025-11-25 1199 Subscriptions:Streaming:Music Spotify Family plan
693 2025-11-26 896 Entertainment:Food Casey's
694 2025-11-27 999 Subscriptions:Streaming:Video Disney+
695 2025-11-28 1499 Subscriptions:Apps 1Password
696 2025-11-28 5705 Automotive:Gasoline Kroger Van
697 2025-11-29 2249 Clothing:Accessories Macy's
698 2025-12-01 150101 Household:Mortgage US Bank
699 2025-12-01 5095 Pets:Food Chewy Dog food
700 2025-12-02 5760 Automotive:Gasoline Shell Van
701 2025-12-03 999 Subscriptions:Apps Apple iCloud storage
702 2025-12-03 4000 Healthcare:Chiropractic City Chiro Bob adjustment
703 2025-12-03 3914 Household:Improvement Home Depot
704 2025-12-03 10454 Gifts:Holiday Amazon Christmas
705 2025-12-05 8586 Utilities:Internet Spectrum
706 2025-12-05 8211 Automotive:Gasoline Kroger Truck
707 2025-12-05 2482 Entertainment:Food Panera Lunch
708 2025-12-05 3969 Personal:Haircut Great Clips
709 2025-12-05 4197 Clothing:Shoes Famous Footwear
710 2025-12-06 9147 Gifts:Holiday Amazon Christmas
711 2025-12-07 12743 Utilities:Electric Duke Energy
712 2025-12-07 4957 Gifts:Holiday Amazon Christmas
713 2025-12-08 4500 Entertainment:Movies AMC Theatres Family movie
714 2025-12-09 2137 Clothing:Accessories Macy's
715 2025-12-10 14584 Utilities:Phone Phone Company Family plan
716 2025-12-10 7972 Gifts:Holiday Best Buy Christmas
717 2025-12-12 5147 Utilities:Water City Water
718 2025-12-12 2663 Groceries:Supplies Amazon
719 2025-12-13 7575 Automotive:Gasoline Kroger Truck
720 2025-12-13 6999 Automotive:Maintenance Valvoline Oil change Van
721 2025-12-15 2899 Utilities:Trash Rumpke
722 2025-12-16 10252 Gifts:Holiday Best Buy Christmas
723 2025-12-17 6941 Automotive:Gasoline Kroger Van
724 2025-12-18 10695 Utilities:Gas Duke Energy Heating
725 2025-12-18 10002 Groceries:Food Aldi
726 2025-12-18 815 Entertainment:Food Casey's
727 2025-12-18 1971 Healthcare:Pharmacy CVS
728 2025-12-18 2265 Groceries:Supplies Target Toilet paper bulk
729 2025-12-20 14500 Automotive:Insurance Progressive
730 2025-12-21 27192 Groceries:Food Costco
731 2025-12-21 8822 Gifts:Holiday Target Christmas
732 2025-12-22 1799 Subscriptions:Streaming:Video Netflix
733 2025-12-22 13168 Groceries:Food Aldi
734 2025-12-22 3793 Entertainment:Food LaRosa's Pizza night
735 2025-12-22 6838 Clothing:Apparel Kohl's
736 2025-12-22 5500 Gifts:Holiday Best Buy Christmas
737 2025-12-22 6292 Gifts:Holiday Amazon Christmas
738 2025-12-25 1199 Subscriptions:Streaming:Music Spotify Family plan
739 2025-12-25 3198 Entertainment:Food Chipotle
740 2025-12-27 999 Subscriptions:Streaming:Video Disney+
741 2025-12-28 1499 Subscriptions:Apps 1Password
742 2025-12-28 13656 Groceries:Food Kroger
743 2025-12-29 4241 Clothing:Apparel Amazon
744 2026-01-01 150101 Household:Mortgage US Bank
745 2026-01-03 999 Subscriptions:Apps Apple iCloud storage
746 2026-01-03 985 Entertainment:Food Dairy Queen Date Night
747 2026-01-05 8625 Utilities:Internet Spectrum
748 2026-01-05 21639 Groceries:Food Costco
749 2026-01-07 15357 Utilities:Electric Duke Energy
750 2026-01-08 12575 Groceries:Food Trader Bob's
751 2026-01-08 4782 Entertainment:Food El Corral Family dinner
752 2026-01-10 14313 Utilities:Phone Phone Company Family plan
753 2026-01-10 9566 Pets:Vet All Creatures Vet
754 2026-01-12 6536 Utilities:Water City Water
755 2026-01-12 8243 Automotive:Gasoline Kroger Truck
756 2026-01-13 6448 Clothing:Apparel Target
757 2026-01-15 2899 Utilities:Trash Rumpke
758 2026-01-15 14266 Groceries:Food Kroger
759 2026-01-15 2900 Entertainment:Food Chipotle
760 2026-01-18 11590 Utilities:Gas Duke Energy Heating
761 2026-01-18 4354 Entertainment:Food LaRosa's Pizza night
762 2026-01-18 5659 Clothing:Accessories Macy's
763 2026-01-19 4052 Entertainment:Activities Cincinnati Zoo
764 2026-01-20 14500 Automotive:Insurance Progressive
765 2026-01-21 5500 Automotive:Maintenance BMV Registration renewal Truck
766 2026-01-22 1799 Subscriptions:Streaming:Video Netflix
767 2026-01-23 9246 Groceries:Food Kroger
768 2026-01-23 3460 Healthcare:Pharmacy CVS
769 2026-01-24 4644 Entertainment:Movies AMC Theatres Family movie
770 2026-01-25 1199 Subscriptions:Streaming:Music Spotify Family plan
771 2026-01-25 8004 Automotive:Gasoline Kroger Truck
772 2026-01-26 4000 Healthcare:Chiropractic City Chiro Joann adjustment
773 2026-01-26 5946 Pets:Food Chewy Dog food
774 2026-01-27 999 Subscriptions:Streaming:Video Disney+
775 2026-01-27 5557 Automotive:Gasoline Kroger Van
776 2026-01-28 1499 Subscriptions:Apps 1Password
777 2026-01-28 2138 Entertainment:Food Panera Lunch
778 2026-01-28 3887 Personal:Haircut Great Clips
779 2026-01-29 5645 Automotive:Gasoline Kroger Van
780 2026-01-29 6822 Groceries:Supplies Costco Paper towels & detergent
781 2026-01-29 32400 Automotive:Repairs Tire Discounters Front brake pads & rotors Van
782 2026-01-30 3480 Entertainment:Food LaRosa's Pizza night
783 2026-02-01 150101 Household:Mortgage US Bank
784 2026-02-01 997 Entertainment:Food Dairy Queen Date Night
785 2026-02-03 999 Subscriptions:Apps Apple iCloud storage
786 2026-02-03 5804 Automotive:Gasoline Kroger Van
787 2026-02-03 3596 Entertainment:Food Chipotle
788 2026-02-04 12057 Groceries:Food Aldi
789 2026-02-04 8511 Automotive:Gasoline Kroger Truck
790 2026-02-04 7753 Pets:Vet All Creatures Vet
791 2026-02-05 8410 Utilities:Internet Spectrum
792 2026-02-07 12169 Utilities:Electric Duke Energy
793 2026-02-10 14585 Utilities:Phone Phone Company Family plan
794 2026-02-10 17409 Household:Repairs Lowe's
795 2026-02-11 4193 Entertainment:Movies AMC Theatres Family movie
796 2026-02-11 2090 Clothing:Accessories Macy's
797 2026-02-12 5094 Utilities:Water City Water
798 2026-02-14 2273 Groceries:Supplies Target Cleaning supplies
799 2026-02-14 3499 Automotive:Maintenance AutoZone Air filter & wipers Van
800 2026-02-15 2899 Utilities:Trash Rumpke
801 2026-02-15 8312 Automotive:Gasoline Kroger Truck
802 2026-02-15 2511 Entertainment:Food Skyline Chili
803 2026-02-18 9345 Utilities:Gas Duke Energy Heating
804 2026-02-19 1350 Entertainment:Food Graeter's Ice cream
805 2026-02-20 14500 Automotive:Insurance Progressive
806 2026-02-20 3316 Entertainment:Food Chipotle
807 2026-02-20 3943 Groceries:Supplies Kroger Trash bags & foil
808 2026-02-21 16043 Groceries:Food Kroger
809 2026-02-21 5538 Automotive:Gasoline Kroger Van
810 2026-02-22 1799 Subscriptions:Streaming:Video Netflix
811 2026-02-22 1712 Healthcare:Pharmacy CVS
812 2026-02-22 18599 Automotive:Repairs AutoZone Battery replacement Truck
813 2026-02-23 13492 Groceries:Food Aldi
814 2026-02-23 6206 Automotive:Gasoline Kroger Van
815 2026-02-25 1199 Subscriptions:Streaming:Music Spotify Family plan
816 2026-02-25 2563 Entertainment:Food Skyline Chili
817 2026-02-26 8721 Clothing:Apparel Target
818 2026-02-27 999 Subscriptions:Streaming:Video Disney+
819 2026-02-27 8260 Clothing:Apparel Kohl's
820 2026-02-28 1499 Subscriptions:Apps 1Password
821 2026-03-01 150101 Household:Mortgage US Bank
822 2026-03-02 5865 Entertainment:Food El Corral Family dinner
823 2026-03-03 999 Subscriptions:Apps Apple iCloud storage
824 2026-03-05 8588 Utilities:Internet Spectrum
825 2026-03-05 3071 Entertainment:Food Chipotle
826 2026-03-07 11733 Utilities:Electric Duke Energy
827 2026-03-07 6418 Automotive:Gasoline Kroger Van
828 2026-03-07 6750 Automotive:Maintenance Valvoline Oil change Truck
829 2026-03-10 14322 Utilities:Phone Phone Company Family plan
830 2026-03-10 1141 Entertainment:Food Dairy Queen Date Night
831 2026-03-12 6662 Utilities:Water City Water
832 2026-03-12 4631 Entertainment:Food LaRosa's Pizza night
833 2026-03-12 13558 Clothing:Shoes Famous Footwear
834 2026-03-14 801 Entertainment:Food Casey's
835 2026-03-15 2899 Utilities:Trash Rumpke
836 2026-03-15 15422 Groceries:Food Costco
837 2026-03-15 142500 Household:Insurance State Farm Annual premium
838 2026-03-16 7683 Automotive:Gasoline Kroger Truck
839 2026-03-18 6549 Utilities:Gas Duke Energy Heating
840 2026-03-19 7835 Automotive:Gasoline Kroger Truck
841 2026-03-20 14500 Automotive:Insurance Progressive
842 2026-03-22 1799 Subscriptions:Streaming:Video Netflix
843 2026-03-22 11482 Groceries:Food Kroger
844 2026-03-22 5458 Pets:Food Chewy Dog food
845 2026-03-22 3253 Groceries:Supplies Target Cleaning supplies
846 2026-03-24 4000 Healthcare:Chiropractic City Chiro Bob adjustment
847 2026-03-25 1199 Subscriptions:Streaming:Music Spotify Family plan
848 2026-03-25 8737 Automotive:Gasoline Kroger Truck
849 2026-03-26 6627 Clothing:Apparel Old Navy
850 2026-03-27 999 Subscriptions:Streaming:Video Disney+
851 2026-03-27 3985 Entertainment:Activities Cincinnati Zoo
852 2026-03-27 3266 Personal:Haircut Great Clips
853 2026-03-28 1499 Subscriptions:Apps 1Password
854 2026-03-28 13969 Groceries:Food Aldi
855 2026-03-28 8134 Household:Repairs Lowe's
856 2026-04-01 150101 Household:Mortgage US Bank
857 2026-04-03 999 Subscriptions:Apps Apple iCloud storage
858 2026-04-03 6762 Automotive:Gasoline Kroger Van
859 2026-04-04 14316 Groceries:Food Trader Bob's
860 2026-04-05 8444 Utilities:Internet Spectrum
861 2026-04-05 6163 Automotive:Gasoline Kroger Van
862 2026-04-05 2143 Clothing:Accessories Macy's
863 2026-04-06 12301 Clothing:Shoes Famous Footwear
864 2026-04-07 9401 Utilities:Electric Duke Energy
865 2026-04-10 14740 Utilities:Phone Phone Company Family plan
866 2026-04-10 15152 Groceries:Food Costco
867 2026-04-10 8802 Automotive:Gasoline Kroger Truck
868 2026-04-12 5325 Utilities:Water City Water
869 2026-04-13 15934 Groceries:Food Kroger
870 2026-04-13 3129 Groceries:Supplies Target Toilet paper bulk
871 2026-04-14 6546 Clothing:Apparel Kohl's
872 2026-04-15 2899 Utilities:Trash Rumpke
873 2026-04-15 8268 Automotive:Gasoline Kroger Truck
874 2026-04-15 3705 Personal:Haircut Great Clips
875 2026-04-17 3305 Entertainment:Food Chipotle
876 2026-04-17 8713 Clothing:Apparel Kohl's
877 2026-04-18 4666 Utilities:Gas Duke Energy Heating
878 2026-04-18 3503 Entertainment:Food LaRosa's Pizza night
879 2026-04-18 4000 Healthcare:Chiropractic City Chiro Jimmy adjustment
880 2026-04-18 3406 Household:Improvement Home Depot
881 2026-04-19 9499 Automotive:Maintenance Tire Discounters Oil change & tire rotation Van
882 2026-04-20 14500 Automotive:Insurance Progressive
883 2026-04-21 9011 Automotive:Gasoline Kroger Truck
884 2026-04-22 1799 Subscriptions:Streaming:Video Netflix
885 2026-04-22 1918 Entertainment:Food Graeter's Ice cream
886 2026-04-22 3425 Healthcare:Pharmacy CVS
887 2026-04-24 5306 Pets:Food Chewy Dog food
888 2026-04-25 1199 Subscriptions:Streaming:Music Spotify Family plan
889 2026-04-26 17625 Groceries:Food Costco
890 2026-04-26 1407 Entertainment:Food Graeter's Ice cream
891 2026-04-26 5735 Entertainment:Movies AMC Theatres Family movie
892 2026-04-27 999 Subscriptions:Streaming:Video Disney+
893 2026-04-27 2462 Groceries:Supplies Kroger
894 2026-04-28 1499 Subscriptions:Apps 1Password
895 2026-05-01 150101 Household:Mortgage US Bank
896 2026-05-03 999 Subscriptions:Apps Apple iCloud storage
897 2026-05-03 7188 Automotive:Gasoline Kroger Van
898 2026-05-04 12745 Groceries:Food Trader Bob's
899 2026-05-05 8570 Utilities:Internet Spectrum
900 2026-05-05 3697 Personal:Haircut Great Clips
901 2026-05-05 6500 Automotive:Gasoline Kroger Truck
902 2026-05-06 15487 Groceries:Food Kroger
903 2026-05-07 9655 Utilities:Electric Duke Energy
904 2026-05-08 6849 Automotive:Gasoline Kroger Van
905 2026-05-09 3845 Entertainment:Food LaRosa's Pizza night
906 2026-05-10 14740 Utilities:Phone Phone Company Family plan
907 2026-05-11 16203 Groceries:Food Costco
908 2026-05-12 5325 Utilities:Water City Water
909 2026-05-13 7912 Automotive:Gasoline Kroger Truck
910 2026-05-14 8254 Clothing:Apparel Kohl's
911 2026-05-15 2899 Utilities:Trash Rumpke
912 2026-05-15 2856 Healthcare:Pharmacy CVS
913 2026-05-16 13492 Groceries:Food Kroger
914 2026-05-17 3499 Entertainment:Food Chipotle
915 2026-05-18 4523 Utilities:Gas Duke Energy Heating
916 2026-05-19 5306 Pets:Food Chewy Dog food
917 2026-05-20 14500 Automotive:Insurance Progressive
918 2026-05-21 7245 Automotive:Gasoline Kroger Van
919 2026-05-22 1799 Subscriptions:Streaming:Video Netflix
920 2026-05-23 5982 Entertainment:Movies AMC Theatres Family movie
921 2026-05-24 11876 Groceries:Food Trader Bob's
922 2026-05-25 1199 Subscriptions:Streaming:Music Spotify Family plan
923 2026-05-26 17234 Groceries:Food Costco
924 2026-05-26 1556 Entertainment:Food Graeter's Ice cream
925 2026-05-27 999 Subscriptions:Streaming:Video Disney+
926 2026-05-28 1499 Subscriptions:Apps 1Password
927 2026-05-28 8124 Automotive:Gasoline Kroger Truck
928 2026-05-29 4000 Healthcare:Chiropractic City Chiro Jimmy adjustment
929 2026-05-30 3845 Household:Improvement Home Depot
930 2026-05-31 2895 Entertainment:Food LaRosa's Sunday dinner

View file

View file

@ -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()

78
src/common_cents/app.py Normal file
View file

@ -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()

View file

@ -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())

View file

@ -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)

View file

@ -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"]),
]
)

View file

@ -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

644
src/common_cents/db.py Normal file
View file

@ -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()

79
src/common_cents/money.py Normal file
View file

@ -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 80100%,
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,
)

View file

@ -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;

View file

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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")

View file

@ -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))

View file

@ -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))

View file

@ -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,
)

View file

@ -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,
)

View file

@ -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()

View file

@ -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
)

View file

@ -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.")

View file

View file

@ -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()

View file

@ -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)

124
tests/test_bulk_edit.py Normal file
View file

@ -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"

View file

@ -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

57
tests/test_config.py Normal file
View file

@ -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"

82
tests/test_csv.py Normal file
View file

@ -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

240
tests/test_db.py Normal file
View file

@ -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"}

77
tests/test_money.py Normal file
View file

@ -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

97
tests/test_reports.py Normal file
View file

@ -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"]

793
uv.lock generated Normal file
View file

@ -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" },
]