From 8b8b04539e8120d1d6ae7e04dc51fb22b457a40d Mon Sep 17 00:00:00 2001 From: Funky Waddle Date: Thu, 18 Dec 2025 14:30:09 -0600 Subject: [PATCH] Initial commit. Milestones 1-3 --- MILESTONES.md | 42 ++-- pyproject.toml | 5 +- src/funkyjuicerecipes/__init__.py | 8 + src/funkyjuicerecipes/app.py | 28 +++ src/funkyjuicerecipes/data/__init__.py | 3 + src/funkyjuicerecipes/data/dao/__init__.py | 3 + src/funkyjuicerecipes/data/dao/flavors.py | 82 ++++++++ src/funkyjuicerecipes/data/dao/recipes.py | 143 ++++++++++++++ src/funkyjuicerecipes/data/db.py | 150 +++++++++++++++ .../2025-12-18_flavor_table_created.py | 20 ++ .../2025-12-18_recipe_flavor_table_created.py | 23 +++ .../2025-12-18_recipe_table_created.py | 22 +++ .../data/migrations/__init__.py | 7 + src/funkyjuicerecipes/data/models/__init__.py | 17 ++ src/funkyjuicerecipes/data/models/base.py | 13 ++ src/funkyjuicerecipes/data/models/flavor.py | 27 +++ src/funkyjuicerecipes/data/models/recipe.py | 31 +++ .../data/models/recipe_flavor.py | 22 +++ tests/test_db_init.py | 83 ++++++++ tests/test_models_daos.py | 181 ++++++++++++++++++ tests/test_smoke.py | 11 ++ uv.lock | 96 ++++++++++ 22 files changed, 994 insertions(+), 23 deletions(-) create mode 100644 src/funkyjuicerecipes/__init__.py create mode 100644 src/funkyjuicerecipes/app.py create mode 100644 src/funkyjuicerecipes/data/__init__.py create mode 100644 src/funkyjuicerecipes/data/dao/__init__.py create mode 100644 src/funkyjuicerecipes/data/dao/flavors.py create mode 100644 src/funkyjuicerecipes/data/dao/recipes.py create mode 100644 src/funkyjuicerecipes/data/db.py create mode 100644 src/funkyjuicerecipes/data/migrations/2025-12-18_flavor_table_created.py create mode 100644 src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_flavor_table_created.py create mode 100644 src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_table_created.py create mode 100644 src/funkyjuicerecipes/data/migrations/__init__.py create mode 100644 src/funkyjuicerecipes/data/models/__init__.py create mode 100644 src/funkyjuicerecipes/data/models/base.py create mode 100644 src/funkyjuicerecipes/data/models/flavor.py create mode 100644 src/funkyjuicerecipes/data/models/recipe.py create mode 100644 src/funkyjuicerecipes/data/models/recipe_flavor.py create mode 100644 tests/test_db_init.py create mode 100644 tests/test_models_daos.py create mode 100644 tests/test_smoke.py diff --git a/MILESTONES.md b/MILESTONES.md index 9a67cf0..7d96765 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -4,36 +4,36 @@ This document outlines the sequence of milestones to deliver the FunkyJuice Reci --- -### Milestone 1 — Project skeleton and tooling -- Set up `src/funkyjuicerecipes/` package layout per README. -- Add minimal `app.py` with `FunkyJuiceRecipesApp` and `main()` returning the app. -- Configure `pyproject.toml` (already added) and ensure `briefcase dev` can start a blank window. -- Add `tests/` folder and test runner config. +### ~~Milestone 1 — Project skeleton and tooling~~ +- ~~Set up `src/funkyjuicerecipes/` package layout per SPECS.~~ +- ~~Add minimal `app.py` with `FunkyJuiceRecipesApp` and `main()` returning the app.~~ +- ~~Configure `pyproject.toml` (already added) and ensure `briefcase dev` can start a blank window.~~ +- ~~Add `tests/` folder and test runner config.~~ -Acceptance: -- `briefcase dev` launches an empty Toga window titled “FunkyJuice Recipes”. -- `pytest` runs (even with zero or smoke tests). +~~Acceptance:~~ +- ~~`briefcase dev` launches an empty Toga window titled “FunkyJuice Recipes”.~~ +- ~~`pytest` runs (even with zero or smoke tests).~~ --- -### Milestone 2 — Database initialization and migrations framework -- Implement `data/db.py` using `app.paths.data` and Peewee pragmas (`foreign_keys=1`, WAL). -- Create migrations table and a simple migration runner that detects unapplied migration files by filename. -- Implement initial migrations for `flavor`, `recipe`, and `recipe_flavor` tables matching README DDL. +### ~~Milestone 2 — Database initialization and migrations framework~~ +- ~~Implement `data/db.py` using `app.paths.data` and Peewee pragmas (`foreign_keys=1`, WAL).~~ +- ~~Create migrations table and a simple migration runner that detects unapplied migration files by filename.~~ +- ~~Implement initial migrations for `flavor`, `recipe`, and `recipe_flavor` tables matching SPECS DDL.~~ -Acceptance: -- On first launch, DB file is created in the per‑user data directory, tables exist, and migrations table is populated. -- Subsequent launches do not re-run applied migrations. +~~Acceptance:~~ +- ~~On first launch, DB file is created in the per‑user data directory, tables exist, and migrations table is populated.~~ +- ~~Subsequent launches do not re-run applied migrations.~~ --- -### Milestone 3 — Peewee models and DAOs -- Implement `Flavor`, `Recipe`, `RecipeFlavor` models reflecting constraints (UNIQUEs, FKs, CHECKs where applicable in logic). -- Provide DAO/helper functions for common ops (create/list/get/update/delete) with soft-delete semantics for flavors. -- Enable case-insensitive uniqueness for `Flavor(name, company)`. +### ~~Milestone 3 — Peewee models and DAOs~~ +- ~~Implement `Flavor`, `Recipe`, `RecipeFlavor` models reflecting constraints (UNIQUEs, FKs, CHECKs where applicable in logic).~~ +- ~~Provide DAO/helper functions for common ops (create/list/get/update/delete) with soft-delete semantics for flavors).~~ +- ~~Enable case-insensitive uniqueness for `Flavor(name, company)`.~~ -Acceptance: -- Unit tests can create flavors (no duplicates ignoring case), recipes, and link ingredients; soft-deleted flavors are excluded by default queries. +~~Acceptance:~~ +- ~~Unit tests can create flavors (no duplicates ignoring case), recipes, and link ingredients; soft-deleted flavors are excluded by default queries.~~ --- diff --git a/pyproject.toml b/pyproject.toml index 3f56041..29bfaf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,8 @@ version = "0.1.0" requires-python = ">=3.10" dependencies = [ "toga==0.5.3", - "peewee>=3.18.3" + "peewee>=3.18.3", + "pytest>=9.0.2", ] [tool.uv] @@ -32,4 +33,4 @@ icon = "src/funkyjuicerecipes/resources/icon" testpaths = ["tests"] [tool.setuptools.packages.find] -where = ["src"] \ No newline at end of file +where = ["src"] diff --git a/src/funkyjuicerecipes/__init__.py b/src/funkyjuicerecipes/__init__.py new file mode 100644 index 0000000..283082a --- /dev/null +++ b/src/funkyjuicerecipes/__init__.py @@ -0,0 +1,8 @@ +"""FunkyJuice Recipes application package. + +This module exposes `main` for Briefcase and CLI execution. +""" + +from .app import main # re-export for convenience + +__all__ = ["main"] diff --git a/src/funkyjuicerecipes/app.py b/src/funkyjuicerecipes/app.py new file mode 100644 index 0000000..b6e2811 --- /dev/null +++ b/src/funkyjuicerecipes/app.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import toga +from .data.db import init_database + + +class FunkyJuiceRecipesApp(toga.App): + def startup(self) -> None: + # Create the main window with the required title + # Initialize database and run migrations before showing UI + init_database(self) + self.main_window = toga.MainWindow(title="FunkyJuice Recipes") + # An empty content box for now (satisfies blank window acceptance) + self.main_window.content = toga.Box() + self.main_window.show() + + +def main() -> FunkyJuiceRecipesApp: + """Entry point for Briefcase and CLI execution. + + Returns a constructed, not-yet-started app instance. + """ + return FunkyJuiceRecipesApp("funkyjuicerecipes", "com.TargonProducts") + + +if __name__ == "__main__": + app = main() + app.main_loop() diff --git a/src/funkyjuicerecipes/data/__init__.py b/src/funkyjuicerecipes/data/__init__.py new file mode 100644 index 0000000..3eb6e1c --- /dev/null +++ b/src/funkyjuicerecipes/data/__init__.py @@ -0,0 +1,3 @@ +"""Data layer package: database and migrations.""" + +__all__ = [] diff --git a/src/funkyjuicerecipes/data/dao/__init__.py b/src/funkyjuicerecipes/data/dao/__init__.py new file mode 100644 index 0000000..075502a --- /dev/null +++ b/src/funkyjuicerecipes/data/dao/__init__.py @@ -0,0 +1,3 @@ +"""Data access repositories for FunkyJuice Recipes.""" + +__all__ = [] diff --git a/src/funkyjuicerecipes/data/dao/flavors.py b/src/funkyjuicerecipes/data/dao/flavors.py new file mode 100644 index 0000000..5175646 --- /dev/null +++ b/src/funkyjuicerecipes/data/dao/flavors.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +import datetime as dt +from typing import Iterable, List, Optional + +from peewee import DoesNotExist, IntegrityError + +from ..models.flavor import Flavor +from ..models.recipe_flavor import RecipeFlavor + + +class FlavorRepository: + def create(self, name: str, company: str, base: str) -> Flavor: + try: + return Flavor.create(name=name, company=company, base=base) + except IntegrityError as e: + # raised on unique(name, company) violation (case-insensitive due to collation) + raise e + + def list( + self, + *, + include_deleted: bool = False, + only_deleted: bool = False, + order_by: Iterable = (Flavor.company, Flavor.name), + ) -> List[Flavor]: + q = Flavor.select() + if only_deleted: + q = q.where(Flavor.is_deleted == True) + elif not include_deleted: + q = q.where(Flavor.is_deleted == False) + if order_by: + q = q.order_by(*order_by) + return list(q) + + def get(self, flavor_id: int) -> Flavor: + return Flavor.get_by_id(flavor_id) + + def update(self, flavor_id: int, *, name: Optional[str] = None, company: Optional[str] = None, base: Optional[str] = None) -> Flavor: + f = self.get(flavor_id) + if name is not None: + f.name = name + if company is not None: + f.company = company + if base is not None: + f.base = base + f.save() + return f + + def soft_delete(self, flavor_id: int) -> bool: + f = self.get(flavor_id) + if not f.is_deleted: + f.is_deleted = True + f.deleted_at = dt.datetime.now(dt.UTC) + f.save() + return True + + def undelete(self, flavor_id: int) -> bool: + f = self.get(flavor_id) + if f.is_deleted: + f.is_deleted = False + f.deleted_at = None + f.save() + return True + + def hard_delete_when_safe(self, flavor_id: int) -> bool: + # only allow when NOT EXISTS recipe_flavor referencing + ref_exists = ( + RecipeFlavor.select().where(RecipeFlavor.flavor == flavor_id).exists() + ) + if ref_exists: + return False + # Delete row + try: + f = self.get(flavor_id) + except DoesNotExist: + return True + f.delete_instance() + return True + + +__all__ = ["FlavorRepository"] diff --git a/src/funkyjuicerecipes/data/dao/recipes.py b/src/funkyjuicerecipes/data/dao/recipes.py new file mode 100644 index 0000000..d3133e2 --- /dev/null +++ b/src/funkyjuicerecipes/data/dao/recipes.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +from typing import Iterable, List, Optional, Sequence, Tuple + +from peewee import IntegrityError + +from ..models.recipe import Recipe +from ..models.recipe_flavor import RecipeFlavor +from ..models.flavor import Flavor + + +Ingredient = Tuple[int, float] # (flavor_id, pct) + + +class RecipeRepository: + def create( + self, + *, + name: str, + size_ml: int, + base_pg_pct: float, + base_vg_pct: float, + nic_pct: float, + nic_base: str, + ingredients: Optional[Sequence[Ingredient]] = None, + ) -> Recipe: + recipe = Recipe.create( + name=name, + size_ml=size_ml, + base_pg_pct=base_pg_pct, + base_vg_pct=base_vg_pct, + nic_pct=nic_pct, + nic_base=nic_base, + ) + if ingredients: + self.replace_ingredients(recipe.id, ingredients) + return recipe + + def get(self, recipe_id: int) -> Recipe: + return Recipe.get_by_id(recipe_id) + + def get_with_ingredients(self, recipe_id: int) -> Tuple[Recipe, List[Tuple[Flavor, float]]]: + recipe = self.get(recipe_id) + pairs: List[Tuple[Flavor, float]] = [] + for rf in RecipeFlavor.select().where(RecipeFlavor.recipe == recipe): + pairs.append((rf.flavor, rf.pct)) + return recipe, pairs + + def list(self, order_by: Iterable = (Recipe.name,)) -> List[Recipe]: + q = Recipe.select() + if order_by: + q = q.order_by(*order_by) + return list(q) + + def update( + self, + recipe_id: int, + *, + name: Optional[str] = None, + size_ml: Optional[int] = None, + base_pg_pct: Optional[float] = None, + base_vg_pct: Optional[float] = None, + nic_pct: Optional[float] = None, + nic_base: Optional[str] = None, + ) -> Recipe: + r = self.get(recipe_id) + if name is not None: + r.name = name + if size_ml is not None: + r.size_ml = size_ml + if base_pg_pct is not None: + r.base_pg_pct = base_pg_pct + if base_vg_pct is not None: + r.base_vg_pct = base_vg_pct + if nic_pct is not None: + r.nic_pct = nic_pct + if nic_base is not None: + r.nic_base = nic_base + r.save() + return r + + def replace_ingredients(self, recipe_id: int, ingredients: Sequence[Ingredient]) -> None: + # Diff-based replace to avoid deleting/reinserting unchanged rows + # Build current state: {flavor_id: pct} + current: dict[int, float] = {} + for rf in ( + RecipeFlavor.select(RecipeFlavor.flavor_id, RecipeFlavor.pct) + .where(RecipeFlavor.recipe == recipe_id) + ): + current[int(rf.flavor_id)] = float(rf.pct) + + # Normalize new state (last occurrence wins if duplicates provided) + desired: dict[int, float] = {} + for flavor_id, pct in ingredients: + desired[int(flavor_id)] = float(pct) + + # If empty desired set, remove all + if not desired: + RecipeFlavor.delete().where(RecipeFlavor.recipe == recipe_id).execute() + return + + current_ids = set(current.keys()) + desired_ids = set(desired.keys()) + + to_delete = current_ids - desired_ids + to_add = desired_ids - current_ids + to_consider_update = current_ids & desired_ids + + to_update = [ + fid for fid in to_consider_update if current[fid] != desired[fid] + ] + + db = RecipeFlavor._meta.database + with db.atomic(): + if to_delete: + ( + RecipeFlavor.delete() + .where( + (RecipeFlavor.recipe == recipe_id) + & (RecipeFlavor.flavor_id.in_(list(to_delete))) + ) + .execute() + ) + + for fid in to_update: + ( + RecipeFlavor.update(pct=desired[fid]) + .where( + (RecipeFlavor.recipe == recipe_id) + & (RecipeFlavor.flavor_id == fid) + ) + .execute() + ) + + for fid in to_add: + RecipeFlavor.create(recipe=recipe_id, flavor=fid, pct=desired[fid]) + + def delete(self, recipe_id: int) -> None: + r = self.get(recipe_id) + r.delete_instance(recursive=True) + + +__all__ = ["RecipeRepository", "Ingredient"] diff --git a/src/funkyjuicerecipes/data/db.py b/src/funkyjuicerecipes/data/db.py new file mode 100644 index 0000000..2de5c14 --- /dev/null +++ b/src/funkyjuicerecipes/data/db.py @@ -0,0 +1,150 @@ +from __future__ import annotations + +import datetime as _dt +import importlib +import importlib.resources as pkg_resources +import os +from typing import Iterable, List, Set + +from peewee import SqliteDatabase + + +DB_FILENAME = "funkyjuicerecipes.data" + + +def _db_path_for_app(app) -> str: + """Return the absolute path to the SQLite DB for the given app. + + Uses `app.paths.data` per SPECS. Ensures the directory exists. + """ + data_dir = os.fspath(app.paths.data) + os.makedirs(data_dir, exist_ok=True) + return os.path.join(data_dir, DB_FILENAME) + + +def create_database(path: str) -> SqliteDatabase: + """Create a Peewee SqliteDatabase with required pragmas. + + Pragmas: foreign_keys=1, journal_mode=wal, synchronous=1 + """ + return SqliteDatabase( + path, + pragmas={ + "foreign_keys": 1, + "journal_mode": "wal", + "synchronous": 1, + }, + ) + + +def get_db(app) -> SqliteDatabase: + """Construct and return the database instance for the app. + + Does not connect; caller can `connect()`/`close()` or use as context manager. + """ + return create_database(_db_path_for_app(app)) + + +def _ensure_migrations_table(db: SqliteDatabase) -> None: + with db: + db.execute_sql( + ( + "CREATE TABLE IF NOT EXISTS migrations (\n" + " id INTEGER PRIMARY KEY,\n" + " batch INTEGER NOT NULL,\n" + " filename TEXT NOT NULL UNIQUE,\n" + " date_run DATETIME NOT NULL\n" + ")" + ) + ) + + +def _get_applied_migrations(db: SqliteDatabase) -> Set[str]: + with db: + cur = db.execute_sql("SELECT filename FROM migrations") + return {row[0] for row in cur.fetchall()} + + +def _next_batch_number(db: SqliteDatabase) -> int: + with db: + cur = db.execute_sql("SELECT COALESCE(MAX(batch), 0) FROM migrations") + (max_batch,) = cur.fetchone() + return int(max_batch) + 1 + + +def _migration_modules() -> List[str]: + """List migration module names in the `data.migrations` package. + + Returns sorted filenames (module names) to ensure deterministic order. + Only Python modules with `.py` suffix and not starting with `_` are considered. + """ + from . import migrations as mig_pkg + + files = [] + for res in pkg_resources.files(mig_pkg).iterdir(): + name = res.name + if name.endswith(".py") and not name.startswith("_"): + files.append(name) + # Sort by filename to define application order + files.sort() + return files + + +def apply_migrations(db: SqliteDatabase) -> List[str]: + """Apply any unapplied migrations found in the package. + + Each migration module must define an `apply(db: SqliteDatabase) -> None` function. + Returns a list of filenames that were applied in this run (may be empty). + """ + _ensure_migrations_table(db) + + applied = _get_applied_migrations(db) + to_apply: List[str] = [] + modules = _migration_modules() + for filename in modules: + if filename not in applied: + to_apply.append(filename) + + if not to_apply: + return [] + + batch = _next_batch_number(db) + now = _dt.datetime.now(_dt.UTC).isoformat(timespec="seconds") + + # Apply each migration in a single transaction per file + from . import migrations as mig_pkg + + for filename in to_apply: + mod_name = filename[:-3] # strip .py + module = importlib.import_module(f"{mig_pkg.__name__}.{mod_name}") + with db.atomic(): + # Run the migration + module.apply(db) + # Record it in migrations table + db.execute_sql( + "INSERT INTO migrations(batch, filename, date_run) VALUES (?, ?, ?)", + (batch, filename, now), + ) + + return to_apply + + +def init_database(app) -> SqliteDatabase: + """Initialize the database for the app and run migrations. + + Returns a connected database instance. + """ + db = get_db(app) + db.connect(reuse_if_open=True) + apply_migrations(db) + # Bind Peewee models to this database so DAOs can use them + try: + from .models import bind_db as _bind_models + + _bind_models(db) + except Exception: + # Binding models is best-effort; tests and app import order may vary. + # If importing models fails due to optional components not present yet, + # callers can bind explicitly via models.bind_db(db). + pass + return db diff --git a/src/funkyjuicerecipes/data/migrations/2025-12-18_flavor_table_created.py b/src/funkyjuicerecipes/data/migrations/2025-12-18_flavor_table_created.py new file mode 100644 index 0000000..4cf2868 --- /dev/null +++ b/src/funkyjuicerecipes/data/migrations/2025-12-18_flavor_table_created.py @@ -0,0 +1,20 @@ +from __future__ import annotations + + +def apply(db): + db.execute_sql( + ( + "CREATE TABLE IF NOT EXISTS flavor (\n" + " id INTEGER PRIMARY KEY,\n" + " name TEXT COLLATE NOCASE NOT NULL,\n" + " company TEXT COLLATE NOCASE NOT NULL,\n" + " base TEXT CHECK(base IN ('PG','VG')) NOT NULL,\n" + " is_deleted INTEGER NOT NULL DEFAULT 0,\n" + " deleted_at DATETIME NULL,\n" + " UNIQUE(name, company)\n" + ")" + ) + ) + db.execute_sql( + "CREATE INDEX IF NOT EXISTS idx_flavor_name_company ON flavor(name, company)" + ) diff --git a/src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_flavor_table_created.py b/src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_flavor_table_created.py new file mode 100644 index 0000000..2d07355 --- /dev/null +++ b/src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_flavor_table_created.py @@ -0,0 +1,23 @@ +from __future__ import annotations + + +def apply(db): + db.execute_sql( + ( + "CREATE TABLE IF NOT EXISTS recipe_flavor (\n" + " id INTEGER PRIMARY KEY,\n" + " recipe_id INTEGER NOT NULL,\n" + " flavor_id INTEGER NOT NULL,\n" + " pct REAL CHECK(pct >= 0 AND pct <= 100) NOT NULL,\n" + " FOREIGN KEY(recipe_id) REFERENCES recipe(id) ON DELETE CASCADE,\n" + " FOREIGN KEY(flavor_id) REFERENCES flavor(id) ON DELETE RESTRICT,\n" + " UNIQUE(recipe_id, flavor_id)\n" + ")" + ) + ) + db.execute_sql( + "CREATE INDEX IF NOT EXISTS idx_recipe_flavor_recipe ON recipe_flavor(recipe_id)" + ) + db.execute_sql( + "CREATE INDEX IF NOT EXISTS idx_recipe_flavor_flavor ON recipe_flavor(flavor_id)" + ) diff --git a/src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_table_created.py b/src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_table_created.py new file mode 100644 index 0000000..80f82a5 --- /dev/null +++ b/src/funkyjuicerecipes/data/migrations/2025-12-18_recipe_table_created.py @@ -0,0 +1,22 @@ +from __future__ import annotations + + +def apply(db): + db.execute_sql( + ( + "CREATE TABLE IF NOT EXISTS recipe (\n" + " id INTEGER PRIMARY KEY,\n" + " name TEXT NOT NULL UNIQUE,\n" + " size_ml INTEGER NOT NULL,\n" + " base_pg_pct REAL NOT NULL,\n" + " base_vg_pct REAL NOT NULL,\n" + " nic_pct REAL CHECK(nic_pct >= 0 AND nic_pct <= 100) NOT NULL,\n" + " nic_base TEXT CHECK(nic_base IN ('PG','VG')) NOT NULL,\n" + " CHECK (\n" + " base_pg_pct >= 0 AND\n" + " base_vg_pct >= 0 AND\n" + " base_pg_pct + base_vg_pct = 100\n" + " )\n" + ")" + ) + ) diff --git a/src/funkyjuicerecipes/data/migrations/__init__.py b/src/funkyjuicerecipes/data/migrations/__init__.py new file mode 100644 index 0000000..38b81b3 --- /dev/null +++ b/src/funkyjuicerecipes/data/migrations/__init__.py @@ -0,0 +1,7 @@ +"""Database migrations package. + +Each module defines `apply(db)` which executes schema changes. +Modules are applied in filename-sorted order. +""" + +__all__ = [] diff --git a/src/funkyjuicerecipes/data/models/__init__.py b/src/funkyjuicerecipes/data/models/__init__.py new file mode 100644 index 0000000..a111c15 --- /dev/null +++ b/src/funkyjuicerecipes/data/models/__init__.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from peewee import Proxy + + +db_proxy: Proxy = Proxy() + + +def bind_db(db) -> None: + """Bind Peewee models to the provided database at runtime. + + Call this after database initialization/migrations. + """ + db_proxy.initialize(db) + + +__all__ = ["db_proxy", "bind_db"] diff --git a/src/funkyjuicerecipes/data/models/base.py b/src/funkyjuicerecipes/data/models/base.py new file mode 100644 index 0000000..fe575bf --- /dev/null +++ b/src/funkyjuicerecipes/data/models/base.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from peewee import Model + +from . import db_proxy + + +class BaseModel(Model): + class Meta: + database = db_proxy + + +__all__ = ["BaseModel"] diff --git a/src/funkyjuicerecipes/data/models/flavor.py b/src/funkyjuicerecipes/data/models/flavor.py new file mode 100644 index 0000000..8b10315 --- /dev/null +++ b/src/funkyjuicerecipes/data/models/flavor.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from peewee import ( + CharField, + BooleanField, + DateTimeField, + Check, +) + +from .base import BaseModel + + +class Flavor(BaseModel): + name = CharField(collation="NOCASE") + company = CharField(collation="NOCASE") + base = CharField(constraints=[Check("base IN ('PG','VG')")]) + is_deleted = BooleanField(default=False) + deleted_at = DateTimeField(null=True) + + class Meta: + table_name = "flavor" + indexes = ( + (("name", "company"), True), # unique composite index + ) + + +__all__ = ["Flavor"] diff --git a/src/funkyjuicerecipes/data/models/recipe.py b/src/funkyjuicerecipes/data/models/recipe.py new file mode 100644 index 0000000..26a8276 --- /dev/null +++ b/src/funkyjuicerecipes/data/models/recipe.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from peewee import ( + CharField, + IntegerField, + FloatField, + Check, + SQL, +) + +from .base import BaseModel + + +class Recipe(BaseModel): + name = CharField(unique=True) + size_ml = IntegerField(constraints=[Check("size_ml > 0")]) + base_pg_pct = FloatField(constraints=[Check("base_pg_pct >= 0")]) + base_vg_pct = FloatField(constraints=[Check("base_vg_pct >= 0")]) + nic_pct = FloatField(constraints=[Check("nic_pct >= 0 AND nic_pct <= 100")]) + nic_base = CharField(constraints=[Check("nic_base IN ('PG','VG')")]) + + class Meta: + table_name = "recipe" + constraints = [ + SQL( + "CHECK (base_pg_pct >= 0 AND base_vg_pct >= 0 AND (base_pg_pct + base_vg_pct) = 100)" + ) + ] + + +__all__ = ["Recipe"] diff --git a/src/funkyjuicerecipes/data/models/recipe_flavor.py b/src/funkyjuicerecipes/data/models/recipe_flavor.py new file mode 100644 index 0000000..14925f8 --- /dev/null +++ b/src/funkyjuicerecipes/data/models/recipe_flavor.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from peewee import ForeignKeyField, FloatField, Check + +from .base import BaseModel +from .flavor import Flavor +from .recipe import Recipe + + +class RecipeFlavor(BaseModel): + recipe = ForeignKeyField(Recipe, backref="ingredients", on_delete="CASCADE") + flavor = ForeignKeyField(Flavor, backref="recipes", on_delete="RESTRICT") + pct = FloatField(constraints=[Check("pct >= 0 AND pct <= 100")]) + + class Meta: + table_name = "recipe_flavor" + indexes = ( + (("recipe", "flavor"), True), # unique composite index + ) + + +__all__ = ["RecipeFlavor"] diff --git a/tests/test_db_init.py b/tests/test_db_init.py new file mode 100644 index 0000000..9b82f41 --- /dev/null +++ b/tests/test_db_init.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import os +from types import SimpleNamespace +from pathlib import Path + +import sqlite3 + +from funkyjuicerecipes.data.db import ( + DB_FILENAME, + init_database, + get_db, + apply_migrations, +) + + +class DummyPaths(SimpleNamespace): + @property + def data(self) -> Path: # type: ignore[override] + return self._data + + @data.setter + def data(self, value: Path) -> None: + self._data = Path(value) + + +class DummyApp(SimpleNamespace): + def __init__(self, data_dir: Path) -> None: + super().__init__() + self.paths = DummyPaths() + self.paths.data = data_dir + + +def _tables(conn: sqlite3.Connection) -> set[str]: + cur = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" + ) + return {row[0] for row in cur.fetchall()} + + +def test_init_database_creates_file_and_tables(tmp_path: Path): + app = DummyApp(tmp_path / "data") + + db = init_database(app) + + db_path = Path(app.paths.data) / DB_FILENAME + assert db_path.exists(), "DB file should be created in app.paths.data" + + # Use sqlite3 to introspect tables + with sqlite3.connect(db_path) as conn: + names = _tables(conn) + # migrations + our 3 domain tables + assert {"migrations", "flavor", "recipe", "recipe_flavor"}.issubset(names) + + # migrations table should have entries for all applied migrations + cur = conn.execute("SELECT filename, batch FROM migrations ORDER BY filename") + rows = cur.fetchall() + assert len(rows) == 3 + filenames = [r[0] for r in rows] + # Ensure expected filenames are present + assert filenames == sorted(filenames) + assert any("flavor_table_created" in f for f in filenames) + assert any("recipe_table_created" in f for f in filenames) + assert any("recipe_flavor_table_created" in f for f in filenames) + # All three should be recorded in the same batch + batches = {r[1] for r in rows} + assert len(batches) == 1 + + +def test_apply_migrations_is_idempotent(tmp_path: Path): + app = DummyApp(tmp_path / "data2") + db = init_database(app) + + # Second application should detect none pending + pending = apply_migrations(db) + assert pending == [] + + # migrations count remains 3 + db_path = Path(app.paths.data) / DB_FILENAME + with sqlite3.connect(db_path) as conn: + cur = conn.execute("SELECT COUNT(*) FROM migrations") + (count,) = cur.fetchone() + assert count == 3 diff --git a/tests/test_models_daos.py b/tests/test_models_daos.py new file mode 100644 index 0000000..9f704b4 --- /dev/null +++ b/tests/test_models_daos.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from types import SimpleNamespace + +import pytest +from peewee import IntegrityError, DoesNotExist + +from funkyjuicerecipes.data.db import init_database, DB_FILENAME +from funkyjuicerecipes.data.dao.flavors import FlavorRepository +from funkyjuicerecipes.data.dao.recipes import RecipeRepository +from funkyjuicerecipes.data.models.flavor import Flavor +from funkyjuicerecipes.data.models.recipe_flavor import RecipeFlavor + + +class DummyPaths(SimpleNamespace): + @property + def data(self) -> Path: # type: ignore[override] + return self._data + + @data.setter + def data(self, value: Path) -> None: + self._data = Path(value) + + +class DummyApp(SimpleNamespace): + def __init__(self, data_dir: Path) -> None: + super().__init__() + self.paths = DummyPaths() + self.paths.data = data_dir + + +def _init_db(tmp_path: Path): + app = DummyApp(tmp_path / "data") + db = init_database(app) + return app, db + + +def test_flavor_uniqueness_nocase(tmp_path: Path): + app, db = _init_db(tmp_path) + flavors = FlavorRepository() + + f1 = flavors.create(name="Strawberry", company="TPA", base="PG") + assert f1.id is not None + + with pytest.raises(IntegrityError): + flavors.create(name="strawberry", company="tpa", base="PG") + + +def test_flavor_soft_delete_and_listing_filters(tmp_path: Path): + app, db = _init_db(tmp_path) + flavors = FlavorRepository() + + f1 = flavors.create(name="Vanilla", company="CAP", base="PG") + f2 = flavors.create(name="Cookie", company="FA", base="PG") + + # default list excludes deleted + rows = flavors.list() + assert [f.name for f in rows] == ["Cookie", "Vanilla"] or [f.name for f in rows] == ["Vanilla", "Cookie"] + + # soft delete f2 + assert flavors.soft_delete(f2.id) is True + + rows_default = flavors.list() + assert all(not r.is_deleted for r in rows_default) + assert {r.id for r in rows_default} == {f1.id} + + rows_only_deleted = flavors.list(only_deleted=True) + assert {r.id for r in rows_only_deleted} == {f2.id} + + rows_include_deleted = flavors.list(include_deleted=True) + assert {r.id for r in rows_include_deleted} == {f1.id, f2.id} + + # undelete brings it back to default list + assert flavors.undelete(f2.id) is True + rows_default2 = flavors.list() + assert {r.id for r in rows_default2} == {f1.id, f2.id} + + # hard delete safety: referenced flavor cannot be deleted + recipes = RecipeRepository() + r = recipes.create( + name="Test Mix", + size_ml=30, + base_pg_pct=50, + base_vg_pct=50, + nic_pct=0, + nic_base="PG", + ingredients=[(f1.id, 5.0)], + ) + + # now f1 is referenced; hard delete should be blocked + assert flavors.hard_delete_when_safe(f1.id) is False + # f2 is not referenced; can be hard-deleted + assert flavors.hard_delete_when_safe(f2.id) is True + with pytest.raises(DoesNotExist): + Flavor.get_by_id(f2.id) + + +def test_recipe_creation_and_get_with_ingredients_includes_soft_deleted_flavors(tmp_path: Path): + app, db = _init_db(tmp_path) + flavors = FlavorRepository() + recipes = RecipeRepository() + + fa = flavors.create(name="Apple", company="FA", base="PG") + fb = flavors.create(name="Berry", company="CAP", base="VG") + + # Soft delete one flavor + flavors.soft_delete(fb.id) + + r = recipes.create( + name="Fruit", + size_ml=60, + base_pg_pct=70, + base_vg_pct=30, + nic_pct=3, + nic_base="PG", + ingredients=[(fa.id, 4.0), (fb.id, 2.0)], + ) + + r2, pairs = recipes.get_with_ingredients(r.id) + assert r2.id == r.id + # Both flavors should be present, including the soft-deleted one + got = {fl.id: pct for (fl, pct) in pairs} + assert got == {fa.id: 4.0, fb.id: 2.0} + # Verify the deleted flag is visible on the soft-deleted flavor + deleted_flavor = next(fl for (fl, pct) in pairs if fl.id == fb.id) + assert deleted_flavor.is_deleted is True + + +def test_replace_ingredients_diff_behavior(tmp_path: Path): + app, db = _init_db(tmp_path) + flavors = FlavorRepository() + recipes = RecipeRepository() + + f1 = flavors.create(name="Lemon", company="FA", base="PG") + f2 = flavors.create(name="Lime", company="FA", base="PG") + f3 = flavors.create(name="Orange", company="FA", base="PG") + + r = recipes.create( + name="Citrus", + size_ml=30, + base_pg_pct=50, + base_vg_pct=50, + nic_pct=0, + nic_base="PG", + ingredients=[(f1.id, 3.0), (f2.id, 2.0)], + ) + + # Capture initial rows + rf_rows = list( + RecipeFlavor.select().where(RecipeFlavor.recipe == r.id).order_by(RecipeFlavor.id) + ) + assert len(rf_rows) == 2 + rf_f1 = next(x for x in rf_rows if x.flavor_id == f1.id) + rf_f2 = next(x for x in rf_rows if x.flavor_id == f2.id) + + # Now: remove f1, update f2 pct, add f3 + recipes.replace_ingredients(r.id, [(f2.id, 5.5), (f3.id, 1.0)]) + + rf_rows_after = list( + RecipeFlavor.select().where(RecipeFlavor.recipe == r.id).order_by(RecipeFlavor.id) + ) + assert len(rf_rows_after) == 2 + flavors_after = {x.flavor_id for x in rf_rows_after} + assert flavors_after == {f2.id, f3.id} + + # f2 row should be updated in-place (same id) with new pct + rf_f2_after = next(x for x in rf_rows_after if x.flavor_id == f2.id) + assert rf_f2_after.id == rf_f2.id + assert pytest.approx(rf_f2_after.pct, rel=1e-9) == 5.5 + + # f1 row should be gone + assert not RecipeFlavor.select().where( + (RecipeFlavor.recipe == r.id) & (RecipeFlavor.flavor == f1.id) + ).exists() + + # f3 should be newly present + assert RecipeFlavor.select().where( + (RecipeFlavor.recipe == r.id) & (RecipeFlavor.flavor == f3.id) + ).exists() diff --git a/tests/test_smoke.py b/tests/test_smoke.py new file mode 100644 index 0000000..a28ecc0 --- /dev/null +++ b/tests/test_smoke.py @@ -0,0 +1,11 @@ +import pytest + + +toga = pytest.importorskip("toga") + +from funkyjuicerecipes.app import main, FunkyJuiceRecipesApp + + +def test_main_returns_app_instance(): + app = main() + assert isinstance(app, FunkyJuiceRecipesApp) diff --git a/uv.lock b/uv.lock index ce654a2..bf3b2f9 100644 --- a/uv.lock +++ b/uv.lock @@ -9,21 +9,44 @@ supported-markers = [ "sys_platform == 'linux'", ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13' and python_full_version >= '3.10' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "funkyjuicerecipes" version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "peewee", marker = "sys_platform == 'linux'" }, + { name = "pytest", marker = "sys_platform == 'linux'" }, { name = "toga", marker = "sys_platform == 'linux'" }, ] [package.metadata] requires-dist = [ { name = "peewee", specifier = ">=3.18.3" }, + { name = "pytest", specifier = ">=9.0.2" }, { name = "toga", specifier = "==0.5.3" }, ] +[[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 = "packaging" version = "25.0" @@ -39,12 +62,30 @@ version = "3.18.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/6f/60/58e7a307a24044e0e982b99042fcd5a58d0cd928d9c01829574d7553ee8d/peewee-3.18.3.tar.gz", hash = "sha256:62c3d93315b1a909360c4b43c3a573b47557a1ec7a4583a71286df2a28d4b72e", size = 3026296, upload-time = "2025-11-03T16:43:46.678Z" } +[[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 = "pycairo" version = "1.29.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" } +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + [[package]] name = "pygobject" version = "3.54.5" @@ -54,6 +95,23 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/d3/a5/68f883df1d8442e3b267cb92105a4b2f0de819bd64ac9981c2d680d3f49f/pygobject-3.54.5.tar.gz", hash = "sha256:b6656f6348f5245606cf15ea48c384c7f05156c75ead206c1b246c80a22fb585", size = 1274658, upload-time = "2025-10-18T13:45:03.121Z" } +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11' and python_full_version >= '3.10' and sys_platform == 'linux'" }, + { name = "iniconfig", marker = "sys_platform == 'linux'" }, + { name = "packaging", marker = "sys_platform == 'linux'" }, + { name = "pluggy", marker = "sys_platform == 'linux'" }, + { name = "pygments", marker = "sys_platform == 'linux'" }, + { name = "tomli", marker = "python_full_version < '3.11' and python_full_version >= '3.10' and sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + [[package]] name = "toga" version = "0.5.3" @@ -93,6 +151,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ab/ca/1f65e926c4369b9a2d366498418b5d5e76b1dfe7b2448448869dc15de86e/toga_gtk-0.5.3-py3-none-any.whl", hash = "sha256:4e48a01b4aace7cd7e653bf82b2c31780e5b7f1d138130bb5ab73bc3f2bb5fa5", size = 75458, upload-time = "2025-12-03T06:50:05.718Z" }, ] +[[package]] +name = "tomli" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/ed/3f73f72945444548f33eba9a87fc7a6e969915e7b1acc8260b30e1f76a2f/tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549", size = 17392, upload-time = "2025-10-08T22:01:47.119Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/5c/24935fb6a2ee63e86d80e4d3b58b222dafaf438c416752c8b58537c8b89a/tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf", size = 234832, upload-time = "2025-10-08T22:01:02.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/da/75dfd804fc11e6612846758a23f13271b76d577e299592b4371a4ca4cd09/tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441", size = 242052, upload-time = "2025-10-08T22:01:03.836Z" }, + { url = "https://files.pythonhosted.org/packages/70/8c/f48ac899f7b3ca7eb13af73bacbc93aec37f9c954df3c08ad96991c8c373/tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845", size = 239555, upload-time = "2025-10-08T22:01:04.834Z" }, + { url = "https://files.pythonhosted.org/packages/ba/28/72f8afd73f1d0e7829bfc093f4cb98ce0a40ffc0cc997009ee1ed94ba705/tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c", size = 245128, upload-time = "2025-10-08T22:01:05.84Z" }, + { url = "https://files.pythonhosted.org/packages/60/83/59bff4996c2cf9f9387a0f5a3394629c7efa5ef16142076a23a90f1955fa/tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f", size = 242121, upload-time = "2025-10-08T22:01:11.332Z" }, + { url = "https://files.pythonhosted.org/packages/45/e5/7c5119ff39de8693d6baab6c0b6dcb556d192c165596e9fc231ea1052041/tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52", size = 250070, upload-time = "2025-10-08T22:01:12.498Z" }, + { url = "https://files.pythonhosted.org/packages/45/12/ad5126d3a278f27e6701abde51d342aa78d06e27ce2bb596a01f7709a5a2/tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8", size = 245859, upload-time = "2025-10-08T22:01:13.551Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a1/4d6865da6a71c603cfe6ad0e6556c73c76548557a8d658f9e3b142df245f/tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6", size = 250296, upload-time = "2025-10-08T22:01:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/42/17/5e2c956f0144b812e7e107f94f1cc54af734eb17b5191c0bbfb72de5e93e/tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b", size = 240771, upload-time = "2025-10-08T22:01:20.106Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f4/0fbd014909748706c01d16824eadb0307115f9562a15cbb012cd9b3512c5/tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf", size = 248586, upload-time = "2025-10-08T22:01:21.164Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/fed85e114bde5e81ecf9bc5da0cc69f2914b38f4708c80ae67d0c10180c5/tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f", size = 244792, upload-time = "2025-10-08T22:01:22.417Z" }, + { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, +] + [[package]] name = "travertino" version = "0.5.3" @@ -101,3 +188,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/c8/59/0940b241c3af6939a wheels = [ { url = "https://files.pythonhosted.org/packages/8f/f0/dd7f32b7cb6e2102d5247150a57d74dc31fc09fea8a9212b7c2507e7df77/travertino-0.5.3-py3-none-any.whl", hash = "sha256:9f6f6840760af28295bba76aa175f35678aba33782ff29e78053b3cbdafafabf", size = 26118, upload-time = "2025-12-03T06:50:02.426Z" }, ] + +[[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" }, +]