Initial commit. Milestones 1-3

This commit is contained in:
Funky Waddle 2025-12-18 14:30:09 -06:00
parent 05aad26e21
commit 8b8b04539e
22 changed files with 994 additions and 23 deletions

View file

@ -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 peruser 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 peruser 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.~~
---

View file

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

View file

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

View file

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

View file

@ -0,0 +1,3 @@
"""Data layer package: database and migrations."""
__all__ = []

View file

@ -0,0 +1,3 @@
"""Data access repositories for FunkyJuice Recipes."""
__all__ = []

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,7 @@
"""Database migrations package.
Each module defines `apply(db)` which executes schema changes.
Modules are applied in filename-sorted order.
"""
__all__ = []

View file

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

View file

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

View file

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

View file

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

View file

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

83
tests/test_db_init.py Normal file
View file

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

181
tests/test_models_daos.py Normal file
View file

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

11
tests/test_smoke.py Normal file
View file

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

96
uv.lock
View file

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