Initial commit. Milestones 1-3
This commit is contained in:
parent
05aad26e21
commit
8b8b04539e
|
|
@ -4,36 +4,36 @@ This document outlines the sequence of milestones to deliver the FunkyJuice Reci
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Milestone 1 — Project skeleton and tooling
|
### ~~Milestone 1 — Project skeleton and tooling~~
|
||||||
- Set up `src/funkyjuicerecipes/` package layout per README.
|
- ~~Set up `src/funkyjuicerecipes/` package layout per SPECS.~~
|
||||||
- Add minimal `app.py` with `FunkyJuiceRecipesApp` and `main()` returning the app.
|
- ~~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.
|
- ~~Configure `pyproject.toml` (already added) and ensure `briefcase dev` can start a blank window.~~
|
||||||
- Add `tests/` folder and test runner config.
|
- ~~Add `tests/` folder and test runner config.~~
|
||||||
|
|
||||||
Acceptance:
|
~~Acceptance:~~
|
||||||
- `briefcase dev` launches an empty Toga window titled “FunkyJuice Recipes”.
|
- ~~`briefcase dev` launches an empty Toga window titled “FunkyJuice Recipes”.~~
|
||||||
- `pytest` runs (even with zero or smoke tests).
|
- ~~`pytest` runs (even with zero or smoke tests).~~
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Milestone 2 — Database initialization and migrations framework
|
### ~~Milestone 2 — Database initialization and migrations framework~~
|
||||||
- Implement `data/db.py` using `app.paths.data` and Peewee pragmas (`foreign_keys=1`, WAL).
|
- ~~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.
|
- ~~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.
|
- ~~Implement initial migrations for `flavor`, `recipe`, and `recipe_flavor` tables matching SPECS DDL.~~
|
||||||
|
|
||||||
Acceptance:
|
~~Acceptance:~~
|
||||||
- On first launch, DB file is created in the per‑user data directory, tables exist, and migrations table is populated.
|
- ~~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.
|
- ~~Subsequent launches do not re-run applied migrations.~~
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Milestone 3 — Peewee models and DAOs
|
### ~~Milestone 3 — Peewee models and DAOs~~
|
||||||
- Implement `Flavor`, `Recipe`, `RecipeFlavor` models reflecting constraints (UNIQUEs, FKs, CHECKs where applicable in logic).
|
- ~~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.
|
- ~~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)`.
|
- ~~Enable case-insensitive uniqueness for `Flavor(name, company)`.~~
|
||||||
|
|
||||||
Acceptance:
|
~~Acceptance:~~
|
||||||
- Unit tests can create flavors (no duplicates ignoring case), recipes, and link ingredients; soft-deleted flavors are excluded by default queries.
|
- ~~Unit tests can create flavors (no duplicates ignoring case), recipes, and link ingredients; soft-deleted flavors are excluded by default queries.~~
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ version = "0.1.0"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"toga==0.5.3",
|
"toga==0.5.3",
|
||||||
"peewee>=3.18.3"
|
"peewee>=3.18.3",
|
||||||
|
"pytest>=9.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|
@ -32,4 +33,4 @@ icon = "src/funkyjuicerecipes/resources/icon"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
|
|
|
||||||
8
src/funkyjuicerecipes/__init__.py
Normal file
8
src/funkyjuicerecipes/__init__.py
Normal 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"]
|
||||||
28
src/funkyjuicerecipes/app.py
Normal file
28
src/funkyjuicerecipes/app.py
Normal 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()
|
||||||
3
src/funkyjuicerecipes/data/__init__.py
Normal file
3
src/funkyjuicerecipes/data/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Data layer package: database and migrations."""
|
||||||
|
|
||||||
|
__all__ = []
|
||||||
3
src/funkyjuicerecipes/data/dao/__init__.py
Normal file
3
src/funkyjuicerecipes/data/dao/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Data access repositories for FunkyJuice Recipes."""
|
||||||
|
|
||||||
|
__all__ = []
|
||||||
82
src/funkyjuicerecipes/data/dao/flavors.py
Normal file
82
src/funkyjuicerecipes/data/dao/flavors.py
Normal 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"]
|
||||||
143
src/funkyjuicerecipes/data/dao/recipes.py
Normal file
143
src/funkyjuicerecipes/data/dao/recipes.py
Normal 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"]
|
||||||
150
src/funkyjuicerecipes/data/db.py
Normal file
150
src/funkyjuicerecipes/data/db.py
Normal 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
|
||||||
|
|
@ -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)"
|
||||||
|
)
|
||||||
|
|
@ -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)"
|
||||||
|
)
|
||||||
|
|
@ -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"
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
)
|
||||||
7
src/funkyjuicerecipes/data/migrations/__init__.py
Normal file
7
src/funkyjuicerecipes/data/migrations/__init__.py
Normal 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__ = []
|
||||||
17
src/funkyjuicerecipes/data/models/__init__.py
Normal file
17
src/funkyjuicerecipes/data/models/__init__.py
Normal 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"]
|
||||||
13
src/funkyjuicerecipes/data/models/base.py
Normal file
13
src/funkyjuicerecipes/data/models/base.py
Normal 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"]
|
||||||
27
src/funkyjuicerecipes/data/models/flavor.py
Normal file
27
src/funkyjuicerecipes/data/models/flavor.py
Normal 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"]
|
||||||
31
src/funkyjuicerecipes/data/models/recipe.py
Normal file
31
src/funkyjuicerecipes/data/models/recipe.py
Normal 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"]
|
||||||
22
src/funkyjuicerecipes/data/models/recipe_flavor.py
Normal file
22
src/funkyjuicerecipes/data/models/recipe_flavor.py
Normal 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
83
tests/test_db_init.py
Normal 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
181
tests/test_models_daos.py
Normal 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
11
tests/test_smoke.py
Normal 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
96
uv.lock
|
|
@ -9,21 +9,44 @@ supported-markers = [
|
||||||
"sys_platform == 'linux'",
|
"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]]
|
[[package]]
|
||||||
name = "funkyjuicerecipes"
|
name = "funkyjuicerecipes"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "peewee", marker = "sys_platform == 'linux'" },
|
{ name = "peewee", marker = "sys_platform == 'linux'" },
|
||||||
|
{ name = "pytest", marker = "sys_platform == 'linux'" },
|
||||||
{ name = "toga", marker = "sys_platform == 'linux'" },
|
{ name = "toga", marker = "sys_platform == 'linux'" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "peewee", specifier = ">=3.18.3" },
|
{ name = "peewee", specifier = ">=3.18.3" },
|
||||||
|
{ name = "pytest", specifier = ">=9.0.2" },
|
||||||
{ name = "toga", specifier = "==0.5.3" },
|
{ 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]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "25.0"
|
version = "25.0"
|
||||||
|
|
@ -39,12 +62,30 @@ version = "3.18.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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]]
|
[[package]]
|
||||||
name = "pycairo"
|
name = "pycairo"
|
||||||
version = "1.29.0"
|
version = "1.29.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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" }
|
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]]
|
[[package]]
|
||||||
name = "pygobject"
|
name = "pygobject"
|
||||||
version = "3.54.5"
|
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" }
|
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]]
|
[[package]]
|
||||||
name = "toga"
|
name = "toga"
|
||||||
version = "0.5.3"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "travertino"
|
name = "travertino"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
|
|
@ -101,3 +188,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/c8/59/0940b241c3af6939a
|
||||||
wheels = [
|
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" },
|
{ 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" },
|
||||||
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue