Compare commits

...

2 commits

Author SHA1 Message Date
Funky Waddle d13d8ca6ab Milestones 4-5. UI is broken into individual toga ui elements. 2025-12-20 00:10:30 -06:00
Funky Waddle c8655e24e4 feat(db,models,dao,logic): nicotine inventory + flavor snapshots; drop legacy nic_pct; migrations, tests
• Migrations
	◦ Create nicotine table with NOCASE strings, base PG/VG check, strength_mg_per_ml (>0), soft-delete, and optional inventory metadata (bottle_size_ml, cost, purchase_date).
	◦ Alter recipe: add nic_target_mg_per_ml (NOT NULL), nic_strength_mg_per_ml (NOT NULL), nicotine_id (FK ON DELETE SET NULL); keep nic_base. Drop legacy nic_pct via rebuild migration.
	◦ Alter flavor: add bottle_size_ml, cost, purchase_date (nullable).
	◦ Alter recipe_flavor: add flavor snapshot columns (flavor_name_snapshot, flavor_company_snapshot NOCASE, flavor_base_snapshot PG/VG CHECK).
	◦ Ensure migration order runs “_created” before ALTERs.
• Models
	◦ Add Nicotine model.
	◦ Update Recipe with nic_target_mg_per_ml, nic_strength_mg_per_ml, nic_base, optional nicotine FK.
	◦ Update Flavor with optional inventory metadata.
	◦ Update RecipeFlavor with snapshot fields and unique index remains.
• DAOs
	◦ Add NicotineRepository (CRUD, list with soft-delete filters; hard_delete_when_safe blocked when referenced by recipes).
	◦ Update RecipeRepository:
		▪ create/update accept nic_target_mg_per_ml, nic_strength_mg_per_ml, nic_base, optional nicotine_id.
		▪ If nicotine_id provided, snapshot strength/base from inventory unless overridden.
		▪ replace_ingredients uses diff-based updates and populates flavor snapshots on insert.
	◦ Update FlavorRepository to edit inventory metadata; soft/undelete; hard delete only when safe.
• DB init
	◦ Bind Peewee models to runtime DB; tweak migration ordering logic.
• Logic
	◦ Add calculation engine (compute_breakdown, breakdown_for_view) supporting target nicotine mg/mL and stock strength mg/mL; rounding policy for display.
• Tests
	◦ Update/init DB tests to include nicotine table and flexible migration counts.
	◦ Add calculation tests (120 mL example, nic in VG, 50 mg/mL stock, validations).
	◦ Add DAO tests including flavor snapshots stability and replace_ingredients diff behavior.
	◦ All tests passing: 15 passed.
• Docs
	◦ Update SPECS and MILESTONES; strike through Milestones 3 and 4.

BREAKING CHANGE: remove Recipe.nic_pct field and any compatibility code. API changes in RecipeRepository.create/update now require mg/mL fields and use snapshot+FK model for nicotine.
2025-12-18 19:55:22 -06:00
55 changed files with 1947 additions and 62 deletions

View file

@ -0,0 +1 @@
/home/funky/projects/Python/FunkyJuiceRecipes/.venv/bin/python3

View file

@ -37,22 +37,22 @@ This document outlines the sequence of milestones to deliver the FunkyJuice Reci
---
### Milestone 4 — Calculation engine
- Implement pure functions in `logic/calculations.py` to compute PG/VG/Nic and per-flavor mL from inputs (percents + size).
- Include validation (non-negative PG/VG; base_pg + base_vg = 100; percent totals ≈ 100 within tolerance).
- Define rounding/display policy (e.g., 1 decimal for View screen).
### ~~Milestone 4 — Calculation engine~~
- ~~Implement pure functions in `logic/calculations.py` to compute PG/VG/Nic and per-flavor mL from inputs (percents + size).~~
- ~~Include validation (non-negative PG/VG; base_pg + base_vg = 100; percent totals ≈ 100 within tolerance).~~
- ~~Define rounding/display policy (e.g., 1 decimal for View screen).~~
Acceptance:
- Tests cover the README example (120 mL case) and edge cases (nic in PG vs VG, excessive flavor %).
~~Acceptance:~~
- ~~Tests cover the README example (120 mL case) and edge cases (nic in PG vs VG, excessive flavor %).~~
---
### Milestone 5 — Main window and navigation shell
- Build `MainWindow`: menu bar (File/New Recipe/Exit; Help/About), buttons for Inventory, recipe list placeholder.
- Wire basic screens/routes (View Recipe, Add/Edit Recipe, Inventory) with placeholders.
### ~~Milestone 5 — Main window and navigation shell~~
- ~~Build `MainWindow`: menu bar (File/New Recipe/Exit; Help/About), buttons for Inventory, recipe list placeholder.~~
- ~~Wire basic screens/routes (View Recipe, Add/Edit Recipe, Inventory) with placeholders.~~
Acceptance:
- App displays main list and can navigate to empty placeholder screens and back.
~~Acceptance:~~
- ~~App displays main list and can navigate to empty placeholder screens and back.~~
---

View file

@ -123,13 +123,15 @@ CREATE INDEX IF NOT EXISTS idx_flavor_name_company ON flavor(name, company);
* Recipe Table
```
recipe(
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
size_ml INTEGER NOT NULL,
base_pg_pct REAL NOT NULL,
base_vg_pct REAL NOT NULL,
nic_pct REAL CHECK(nic_pct >= 0 AND nic_pct <= 100) NOT NULL,
id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
size_ml INTEGER NOT NULL,
base_pg_pct REAL NOT NULL,
base_vg_pct REAL NOT NULL,
nic_target_mg_per_ml REAL CHECK(nic_target_mg_per_ml >= 0) NOT NULL,
nic_strength_mg_per_ml REAL CHECK(nic_strength_mg_per_ml > 0) NOT NULL,
nic_base TEXT CHECK(nic_base IN ('PG','VG')) NOT NULL,
nicotine_id INTEGER NULL REFERENCES nicotine(id) ON DELETE SET NULL,
CHECK (
base_pg_pct >= 0 AND
base_vg_pct >= 0 AND
@ -165,16 +167,18 @@ migrations(
## Recipes
* Recipes require
* Name,
* Size (ml),
* PG Base %,
* VG base %,
* Nic Base (PG/VG)
* Nic %,
* Name,
* Size (ml),
* PG Base %,
* VG base %,
* Nic target strength (mg/mL),
* Nic stock strength (mg/mL),
* Nic Base (PG/VG),
* Flavor List
* Recipe equations are:
* flavor_pg_pct = sum(pct for flavor in flavors if flavor.base == 'PG')
* flavor_vg_pct = sum(pct for flavor in flavors if flavor.base == 'VG')
* nic_pct = (nic_target_mg_per_ml / nic_strength_mg_per_ml) * 100 # nicotine volume percent of final mix
* pg_pct = base_pg_pct - flavor_pg_pct - (nic_pct if nic_base == 'PG' else 0)
* vg_pct = base_vg_pct - flavor_vg_pct - (nic_pct if nic_base == 'VG' else 0)
* PG_ml = pg_pct / 100 * size_ml

View file

@ -6,7 +6,9 @@ dependencies = [
"toga==0.5.3",
"peewee>=3.18.3",
"pytest>=9.0.2",
"briefcase>=0.3.26",
]
license = { file = "LICENSE" }
[tool.uv]
environments = [
@ -20,7 +22,6 @@ version = "0.1.0"
url = "https://example.com"
author = "Funky Waddle"
author_email = "you@example.com"
license = "MIT"
[tool.briefcase.app.funkyjuicerecipes]
formal_name = "FunkyJuice Recipes"

View file

@ -0,0 +1 @@
briefcase

View file

@ -0,0 +1,11 @@
Metadata-Version: 2.1
Briefcase-Version: 0.3.26
Name: funkyjuicerecipes
Formal-Name: FunkyJuice Recipes
App-ID: com.TargonProducts.funkyjuicerecipes
Version: 0.1.0
Home-page: https://example.com
Download-URL: https://example.com
Author: Funky Waddle
Author-email: you@example.com
Summary: A python application for managing e-juice recipes

View file

@ -0,0 +1,4 @@
Wheel-Version: 1.0
Root-Is-Purelib: true
Generator: briefcase (0.3.26)
Tag: py3-none-any

View file

@ -0,0 +1 @@
funkyjuicerecipes

View file

@ -0,0 +1,10 @@
from __future__ import annotations
from .app import main
if __name__ == "__main__":
print("__main__ starting")
app = main()
print(f"{app=}")
app.main_loop()

View file

@ -1,18 +1,47 @@
from __future__ import annotations
import toga
from .data.db import init_database
from .ui.main_window import build_main_window
class FunkyJuiceRecipesApp(toga.App):
def __init__(self, name: str, identifier: str):
# Debug prints to verify lifecycle entry
print("FunkyJuiceRecipesApp.__init__ starting", flush=True)
# Important: call super().__init__ so Toga can wire up lifecycle hooks
super().__init__(name, identifier)
print("FunkyJuiceRecipesApp.__init__ finished", flush=True)
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()
print("startup() entered", flush=True)
self._db = init_database(self)
print("db initialized; building window…", flush=True)
# Build the main window with a simple content-switching layout
self.main_window = build_main_window(self)
print("showing window…", flush=True)
self.main_window.show()
# def startup(self) -> None:
# # Initialize database and run migrations before showing UI
# # Keep a handle so we can close it on exit
# self._db = init_database(self)
#
# # Build the main window with a simple content-switching layout
# self.main_window = build_main_window(self)
# print("about to show main window")
# self.main_window.show()
def on_exit(self) -> None:
# Close DB connection cleanly to avoid resource warnings
db = getattr(self, "_db", None)
try:
if db is not None:
db.close()
except Exception:
pass
def main() -> FunkyJuiceRecipesApp:
@ -20,7 +49,8 @@ def main() -> FunkyJuiceRecipesApp:
Returns a constructed, not-yet-started app instance.
"""
return FunkyJuiceRecipesApp("funkyjuicerecipes", "com.TargonProducts")
print("main() starting")
return FunkyJuiceRecipesApp("funkyjuicerecipes", "com.targonproducts.funkyjuicerecipes")
if __name__ == "__main__":

View file

@ -36,7 +36,17 @@ class FlavorRepository:
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:
def update(
self,
flavor_id: int,
*,
name: Optional[str] = None,
company: Optional[str] = None,
base: Optional[str] = None,
bottle_size_ml: Optional[float] = None,
cost: Optional[float] = None,
purchase_date: Optional[dt.datetime] = None,
) -> Flavor:
f = self.get(flavor_id)
if name is not None:
f.name = name
@ -44,6 +54,12 @@ class FlavorRepository:
f.company = company
if base is not None:
f.base = base
if bottle_size_ml is not None:
f.bottle_size_ml = bottle_size_ml
if cost is not None:
f.cost = cost
if purchase_date is not None:
f.purchase_date = purchase_date
f.save()
return f

View file

@ -0,0 +1,115 @@
from __future__ import annotations
import datetime as dt
from typing import Iterable, List, Optional
from peewee import DoesNotExist, IntegrityError
from ..models.nicotine import Nicotine
from ..models.recipe import Recipe
class NicotineRepository:
def create(
self,
*,
name: str,
company: str,
base: str,
strength_mg_per_ml: float,
bottle_size_ml: Optional[float] = None,
cost: Optional[float] = None,
purchase_date: Optional[dt.datetime] = None,
) -> Nicotine:
try:
return Nicotine.create(
name=name,
company=company,
base=base,
strength_mg_per_ml=strength_mg_per_ml,
bottle_size_ml=bottle_size_ml,
cost=cost,
purchase_date=purchase_date,
)
except IntegrityError as e:
raise e
def list(
self,
*,
include_deleted: bool = False,
only_deleted: bool = False,
order_by: Iterable = (Nicotine.company, Nicotine.name),
) -> List[Nicotine]:
q = Nicotine.select()
if only_deleted:
q = q.where(Nicotine.is_deleted == True)
elif not include_deleted:
q = q.where(Nicotine.is_deleted == False)
if order_by:
q = q.order_by(*order_by)
return list(q)
def get(self, nicotine_id: int) -> Nicotine:
return Nicotine.get_by_id(nicotine_id)
def update(
self,
nicotine_id: int,
*,
name: Optional[str] = None,
company: Optional[str] = None,
base: Optional[str] = None,
strength_mg_per_ml: Optional[float] = None,
bottle_size_ml: Optional[float] = None,
cost: Optional[float] = None,
purchase_date: Optional[dt.datetime] = None,
) -> Nicotine:
n = self.get(nicotine_id)
if name is not None:
n.name = name
if company is not None:
n.company = company
if base is not None:
n.base = base
if strength_mg_per_ml is not None:
n.strength_mg_per_ml = strength_mg_per_ml
if bottle_size_ml is not None:
n.bottle_size_ml = bottle_size_ml
if cost is not None:
n.cost = cost
if purchase_date is not None:
n.purchase_date = purchase_date
n.save()
return n
def soft_delete(self, nicotine_id: int) -> bool:
n = self.get(nicotine_id)
if not n.is_deleted:
n.is_deleted = True
n.deleted_at = dt.datetime.now(dt.UTC)
n.save()
return True
def undelete(self, nicotine_id: int) -> bool:
n = self.get(nicotine_id)
if n.is_deleted:
n.is_deleted = False
n.deleted_at = None
n.save()
return True
def hard_delete_when_safe(self, nicotine_id: int) -> bool:
# If referenced by recipes (FK), block hard delete
ref_exists = Recipe.select().where(Recipe.nicotine == nicotine_id).exists()
if ref_exists:
return False
try:
n = self.get(nicotine_id)
except DoesNotExist:
return True
n.delete_instance()
return True
__all__ = ["NicotineRepository"]

View file

@ -7,6 +7,7 @@ from peewee import IntegrityError
from ..models.recipe import Recipe
from ..models.recipe_flavor import RecipeFlavor
from ..models.flavor import Flavor
from ..models.nicotine import Nicotine
Ingredient = Tuple[int, float] # (flavor_id, pct)
@ -20,17 +21,47 @@ class RecipeRepository:
size_ml: int,
base_pg_pct: float,
base_vg_pct: float,
nic_pct: float,
nic_base: str,
nic_target_mg_per_ml: float,
nic_strength_mg_per_ml: Optional[float] = None,
nic_base: Optional[str] = None,
nicotine_id: Optional[int] = None,
ingredients: Optional[Sequence[Ingredient]] = None,
) -> Recipe:
# If a nicotine inventory item is provided, use it to fill snapshot defaults
inv_strength: Optional[float] = None
inv_base: Optional[str] = None
inv_obj: Optional[Nicotine] = None
if nicotine_id is not None:
try:
inv_obj = Nicotine.get_by_id(nicotine_id)
inv_strength = float(inv_obj.strength_mg_per_ml)
inv_base = str(inv_obj.base)
except Exception:
inv_obj = None
inv_strength = None
inv_base = None
# Resolve snapshot values: explicit args take precedence, then inventory, then defaults
resolved_strength = (
float(nic_strength_mg_per_ml)
if nic_strength_mg_per_ml is not None
else (inv_strength if inv_strength is not None else 100.0)
)
resolved_base = (
str(nic_base)
if nic_base is not None
else (inv_base if inv_base is not None else "PG")
)
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,
nic_target_mg_per_ml=nic_target_mg_per_ml,
nic_strength_mg_per_ml=resolved_strength,
nic_base=resolved_base,
nicotine=inv_obj if inv_obj is not None else None,
)
if ingredients:
self.replace_ingredients(recipe.id, ingredients)
@ -60,8 +91,10 @@ class RecipeRepository:
size_ml: Optional[int] = None,
base_pg_pct: Optional[float] = None,
base_vg_pct: Optional[float] = None,
nic_pct: Optional[float] = None,
nic_target_mg_per_ml: Optional[float] = None,
nic_strength_mg_per_ml: Optional[float] = None,
nic_base: Optional[str] = None,
nicotine_id: Optional[int] = None,
) -> Recipe:
r = self.get(recipe_id)
if name is not None:
@ -72,10 +105,17 @@ class RecipeRepository:
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_target_mg_per_ml is not None:
r.nic_target_mg_per_ml = nic_target_mg_per_ml
if nic_strength_mg_per_ml is not None:
r.nic_strength_mg_per_ml = nic_strength_mg_per_ml
if nic_base is not None:
r.nic_base = nic_base
if nicotine_id is not None:
try:
r.nicotine = Nicotine.get_by_id(nicotine_id)
except Exception:
r.nicotine = None
r.save()
return r
@ -133,7 +173,20 @@ class RecipeRepository:
)
for fid in to_add:
RecipeFlavor.create(recipe=recipe_id, flavor=fid, pct=desired[fid])
# Populate flavor snapshots at time of insert
try:
fl = Flavor.get_by_id(fid)
RecipeFlavor.create(
recipe=recipe_id,
flavor=fid,
pct=desired[fid],
flavor_name_snapshot=fl.name,
flavor_company_snapshot=fl.company,
flavor_base_snapshot=fl.base,
)
except Exception:
# If flavor not found (shouldn't happen due to FK), fall back without snapshots
RecipeFlavor.create(recipe=recipe_id, flavor=fid, pct=desired[fid])
def delete(self, recipe_id: int) -> None:
r = self.get(recipe_id)

View file

@ -13,13 +13,18 @@ DB_FILENAME = "funkyjuicerecipes.data"
def _db_path_for_app(app) -> str:
"""Return the absolute path to the SQLite DB for the given app.
"""Return the absolute path to the SQLite DB next to this module.
Uses `app.paths.data` per SPECS. Ensures the directory exists.
New behavior (per user preference): store the DB file inside the
application source package, specifically under the same directory as
this module (src/funkyjuicerecipes/data/).
Note: This may not be writable in packaged installs on some platforms;
it is acceptable here because the app is single-user and developer-managed.
"""
data_dir = os.fspath(app.paths.data)
os.makedirs(data_dir, exist_ok=True)
return os.path.join(data_dir, DB_FILENAME)
here = os.path.dirname(__file__) # .../src/funkyjuicerecipes/data
os.makedirs(here, exist_ok=True)
return os.path.join(here, DB_FILENAME)
def create_database(path: str) -> SqliteDatabase:
@ -85,8 +90,9 @@ def _migration_modules() -> List[str]:
name = res.name
if name.endswith(".py") and not name.startswith("_"):
files.append(name)
# Sort by filename to define application order
files.sort()
# Sort by filename, but ensure "*_created*" migrations run before others
# so that any later ALTERs for the same table don't fail on fresh DBs.
files.sort(key=lambda n: (0 if "_created" in n else 1, n))
return files

Binary file not shown.

View file

@ -0,0 +1,14 @@
from __future__ import annotations
def apply(db):
# Add optional inventory metadata to flavor
db.execute_sql(
"ALTER TABLE flavor ADD COLUMN bottle_size_ml REAL NULL"
)
db.execute_sql(
"ALTER TABLE flavor ADD COLUMN cost REAL NULL"
)
db.execute_sql(
"ALTER TABLE flavor ADD COLUMN purchase_date DATETIME NULL"
)

View file

@ -0,0 +1,24 @@
from __future__ import annotations
def apply(db):
db.execute_sql(
(
"CREATE TABLE IF NOT EXISTS nicotine (\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"
" strength_mg_per_ml REAL CHECK(strength_mg_per_ml > 0) NOT NULL,\n"
" bottle_size_ml REAL NULL,\n"
" cost REAL NULL,\n"
" purchase_date DATETIME NULL,\n"
" is_deleted INTEGER NOT NULL DEFAULT 0,\n"
" deleted_at DATETIME NULL,\n"
" UNIQUE(company, name, strength_mg_per_ml, base)\n"
")"
)
)
db.execute_sql(
"CREATE INDEX IF NOT EXISTS idx_nicotine_company_name ON nicotine(company, name)"
)

View file

@ -0,0 +1,15 @@
from __future__ import annotations
def apply(db):
# Add snapshot columns for flavor identity at time of recipe creation
# Allow NULL for backward compatibility; application code will populate on insert.
db.execute_sql(
"ALTER TABLE recipe_flavor ADD COLUMN flavor_name_snapshot TEXT NULL"
)
db.execute_sql(
"ALTER TABLE recipe_flavor ADD COLUMN flavor_company_snapshot TEXT COLLATE NOCASE NULL"
)
db.execute_sql(
"ALTER TABLE recipe_flavor ADD COLUMN flavor_base_snapshot TEXT NULL CHECK(flavor_base_snapshot IN ('PG','VG'))"
)

View file

@ -0,0 +1,18 @@
from __future__ import annotations
def apply(db):
# Add columns for nicotine snapshot and optional inventory FK reference
db.execute_sql(
"ALTER TABLE recipe ADD COLUMN nic_target_mg_per_ml REAL NOT NULL DEFAULT 0"
)
db.execute_sql(
"ALTER TABLE recipe ADD COLUMN nic_strength_mg_per_ml REAL NOT NULL DEFAULT 100.0"
)
db.execute_sql(
"ALTER TABLE recipe ADD COLUMN nicotine_id INTEGER NULL"
)
# Optional index to speed up lookups by nicotine_id
db.execute_sql(
"CREATE INDEX IF NOT EXISTS idx_recipe_nicotine_id ON recipe(nicotine_id)"
)

View file

@ -0,0 +1,50 @@
from __future__ import annotations
def apply(db):
"""Remove legacy nic_pct from recipe by rebuilding the table.
This approach is compatible with older SQLite versions that don't support
ALTER TABLE ... DROP COLUMN.
"""
# Create new table without nic_pct, preserving constraints and FK
db.execute_sql(
(
"CREATE TABLE IF NOT EXISTS recipe__new (\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_target_mg_per_ml REAL CHECK(nic_target_mg_per_ml >= 0) NOT NULL,\n"
" nic_strength_mg_per_ml REAL CHECK(nic_strength_mg_per_ml > 0) NOT NULL,\n"
" nic_base TEXT CHECK(nic_base IN ('PG','VG')) NOT NULL,\n"
" nicotine_id INTEGER NULL REFERENCES nicotine(id) ON DELETE SET 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"
")"
)
)
# Copy data (exclude nic_pct)
db.execute_sql(
(
"INSERT INTO recipe__new (id, name, size_ml, base_pg_pct, base_vg_pct, "
"nic_target_mg_per_ml, nic_strength_mg_per_ml, nic_base, nicotine_id) "
"SELECT id, name, size_ml, base_pg_pct, base_vg_pct, "
" nic_target_mg_per_ml, nic_strength_mg_per_ml, nic_base, nicotine_id "
"FROM recipe"
)
)
# Drop old table and rename new
db.execute_sql("DROP TABLE recipe")
db.execute_sql("ALTER TABLE recipe__new RENAME TO recipe")
# Recreate any indexes that referenced the table (unique on name is inline; add index on nicotine_id)
db.execute_sql(
"CREATE INDEX IF NOT EXISTS idx_recipe_nicotine_id ON recipe(nicotine_id)"
)

View file

@ -5,6 +5,7 @@ from peewee import (
BooleanField,
DateTimeField,
Check,
FloatField,
)
from .base import BaseModel
@ -14,6 +15,10 @@ class Flavor(BaseModel):
name = CharField(collation="NOCASE")
company = CharField(collation="NOCASE")
base = CharField(constraints=[Check("base IN ('PG','VG')")])
# Optional inventory metadata
bottle_size_ml = FloatField(null=True)
cost = FloatField(null=True)
purchase_date = DateTimeField(null=True)
is_deleted = BooleanField(default=False)
deleted_at = DateTimeField(null=True)

View file

@ -0,0 +1,36 @@
from __future__ import annotations
from peewee import (
CharField,
BooleanField,
DateTimeField,
Check,
FloatField,
)
from .base import BaseModel
class Nicotine(BaseModel):
name = CharField(collation="NOCASE")
company = CharField(collation="NOCASE")
base = CharField(constraints=[Check("base IN ('PG','VG')")])
strength_mg_per_ml = FloatField(constraints=[Check("strength_mg_per_ml > 0")])
# Optional inventory metadata
bottle_size_ml = FloatField(null=True)
cost = FloatField(null=True)
purchase_date = DateTimeField(null=True)
# Soft delete
is_deleted = BooleanField(default=False)
deleted_at = DateTimeField(null=True)
class Meta:
table_name = "nicotine"
indexes = (
(("company", "name", "strength_mg_per_ml", "base"), True),
)
__all__ = ["Nicotine"]

View file

@ -6,9 +6,11 @@ from peewee import (
FloatField,
Check,
SQL,
ForeignKeyField,
)
from .base import BaseModel
from .nicotine import Nicotine
class Recipe(BaseModel):
@ -16,8 +18,10 @@ class Recipe(BaseModel):
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_target_mg_per_ml = FloatField(constraints=[Check("nic_target_mg_per_ml >= 0")])
nic_strength_mg_per_ml = FloatField(constraints=[Check("nic_strength_mg_per_ml > 0")])
nic_base = CharField(constraints=[Check("nic_base IN ('PG','VG')")])
nicotine = ForeignKeyField(Nicotine, null=True, backref="recipes", on_delete="SET NULL")
class Meta:
table_name = "recipe"

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from peewee import ForeignKeyField, FloatField, Check
from peewee import ForeignKeyField, FloatField, Check, CharField
from .base import BaseModel
from .flavor import Flavor
@ -11,6 +11,12 @@ 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")])
# Snapshots of flavor identity at the time the ingredient entry is created
flavor_name_snapshot = CharField(null=True)
flavor_company_snapshot = CharField(null=True, collation="NOCASE")
flavor_base_snapshot = CharField(
null=True, constraints=[Check("flavor_base_snapshot IN ('PG','VG')")]
)
class Meta:
table_name = "recipe_flavor"

View file

@ -0,0 +1,7 @@
"""Business logic utilities.
This package contains the calculation engine used to compute recipe component
amounts (PG, VG, Nicotine, and per-flavor volumes) from recipe inputs.
"""
__all__ = []

View file

@ -0,0 +1,204 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Iterable, List, Mapping, Sequence, Tuple, Union
FlavorInput = Union[
Mapping[str, Union[str, float]],
Tuple[str, str, float], # (name, base, pct)
]
@dataclass(frozen=True)
class FlavorAmount:
name: str
base: str # 'PG' or 'VG'
pct: float
ml: float
@dataclass(frozen=True)
class RecipeBreakdown:
size_ml: int
pg_pct: float
vg_pct: float
nic_pct: float # volume percent of nicotine stock used in final mix
nic_base: str
# Extra nicotine-related fields for clarity
nic_target_mg_per_ml: float # target final strength
nic_strength_mg_per_ml: float # stock/base concentration
pg_ml: float
vg_ml: float
nic_ml: float
flavor_prep_ml: float
flavors: Tuple[FlavorAmount, ...]
EPS = 1e-7
def _norm_flavor(item: FlavorInput) -> Tuple[str, str, float]:
if isinstance(item, tuple) and len(item) == 3:
name, base, pct = item
return str(name), str(base), float(pct)
elif isinstance(item, Mapping):
name = str(item.get("name", ""))
base = str(item.get("base"))
pct = float(item.get("pct"))
return name, base, pct
else:
raise TypeError("Flavor input must be a mapping with keys (name, base, pct) or a tuple (name, base, pct)")
def _validate_inputs(
*,
size_ml: int,
base_pg_pct: float,
base_vg_pct: float,
nic_target_mg_per_ml: float,
nic_strength_mg_per_ml: float,
nic_base: str,
flavors: Sequence[Tuple[str, str, float]],
) -> None:
if size_ml <= 0:
raise ValueError("size_ml must be positive")
if abs((base_pg_pct + base_vg_pct) - 100.0) > 1e-6:
raise ValueError("Base PG% + VG% must equal 100")
if nic_base not in ("PG", "VG"):
raise ValueError("nic_base must be 'PG' or 'VG'")
if nic_strength_mg_per_ml <= 0:
raise ValueError("nic_strength_mg_per_ml must be > 0")
if nic_target_mg_per_ml < 0:
raise ValueError("nic_target_mg_per_ml must be >= 0")
for _, base, pct in flavors:
if base not in ("PG", "VG"):
raise ValueError("Flavor base must be 'PG' or 'VG'")
if not (0.0 <= pct <= 100.0):
raise ValueError("Flavor pct must be between 0 and 100")
def compute_breakdown(
*,
size_ml: int,
base_pg_pct: float,
base_vg_pct: float,
nic_target_mg_per_ml: float,
nic_strength_mg_per_ml: float,
nic_base: str,
flavors: Sequence[FlavorInput],
) -> RecipeBreakdown:
"""Compute recipe component amounts.
Parameters are pure data; this function does not touch the DB or UI.
`flavors` can be a sequence of dicts with keys `name`, `base`, `pct`,
or tuples `(name, base, pct)`.
"""
normalized: List[Tuple[str, str, float]] = [_norm_flavor(f) for f in flavors]
_validate_inputs(
size_ml=size_ml,
base_pg_pct=base_pg_pct,
base_vg_pct=base_vg_pct,
nic_target_mg_per_ml=nic_target_mg_per_ml,
nic_strength_mg_per_ml=nic_strength_mg_per_ml,
nic_base=nic_base,
flavors=normalized,
)
# Compute required nicotine stock volume percent for the target strength
nic_pct = (float(nic_target_mg_per_ml) / float(nic_strength_mg_per_ml)) * 100.0
if nic_pct > 100.0 + 1e-9:
raise ValueError("Target nicotine exceeds stock strength (requires >100% nic volume)")
flavor_pg_pct = sum(pct for _, base, pct in normalized if base == "PG")
flavor_vg_pct = sum(pct for _, base, pct in normalized if base == "VG")
pg_pct = base_pg_pct - flavor_pg_pct - (nic_pct if nic_base == "PG" else 0.0)
vg_pct = base_vg_pct - flavor_vg_pct - (nic_pct if nic_base == "VG" else 0.0)
# Validation: PG/VG must not be negative (within tolerance)
if pg_pct < -EPS or vg_pct < -EPS:
raise ValueError("Computed PG/VG would be negative. Reduce flavor or nicotine percentages.")
# Clamp tiny negatives to zero to account for float rounding
if pg_pct < 0 and pg_pct > -EPS:
pg_pct = 0.0
if vg_pct < 0 and vg_pct > -EPS:
vg_pct = 0.0
# Validate total ~ 100
total = flavor_pg_pct + flavor_vg_pct + nic_pct + pg_pct + vg_pct
if abs(total - 100.0) > 1e-5:
raise ValueError("Component percentages must sum to ~100% within tolerance")
# Convert to mL
def pct_to_ml(p: float) -> float:
return p / 100.0 * float(size_ml)
pg_ml = pct_to_ml(pg_pct)
vg_ml = pct_to_ml(vg_pct)
nic_ml = pct_to_ml(nic_pct)
flavors_out: List[FlavorAmount] = []
for name, base, pct in normalized:
flavors_out.append(FlavorAmount(name=name, base=base, pct=pct, ml=pct_to_ml(pct)))
flavor_prep_ml = pct_to_ml(flavor_pg_pct + flavor_vg_pct)
return RecipeBreakdown(
size_ml=int(size_ml),
pg_pct=float(pg_pct),
vg_pct=float(vg_pct),
nic_pct=float(nic_pct),
nic_base=str(nic_base),
nic_target_mg_per_ml=float(nic_target_mg_per_ml),
nic_strength_mg_per_ml=float(nic_strength_mg_per_ml),
pg_ml=float(pg_ml),
vg_ml=float(vg_ml),
nic_ml=float(nic_ml),
flavor_prep_ml=float(flavor_prep_ml),
flavors=tuple(flavors_out),
)
def round_ml(value: float, decimals: int = 1) -> float:
"""Round a milliliter value for display. Default 1 decimal place.
Uses standard rounding behavior.
"""
return round(float(value), decimals)
def breakdown_for_view(breakdown: RecipeBreakdown, decimals: int = 1) -> RecipeBreakdown:
"""Return a copy of breakdown with mL values rounded for display.
Percentages are left unchanged; only mL fields are rounded.
"""
rounded_flavors = tuple(
FlavorAmount(name=f.name, base=f.base, pct=f.pct, ml=round_ml(f.ml, decimals))
for f in breakdown.flavors
)
return RecipeBreakdown(
size_ml=breakdown.size_ml,
pg_pct=breakdown.pg_pct,
vg_pct=breakdown.vg_pct,
nic_pct=breakdown.nic_pct,
nic_base=breakdown.nic_base,
nic_target_mg_per_ml=breakdown.nic_target_mg_per_ml,
nic_strength_mg_per_ml=breakdown.nic_strength_mg_per_ml,
pg_ml=round_ml(breakdown.pg_ml, decimals),
vg_ml=round_ml(breakdown.vg_ml, decimals),
nic_ml=round_ml(breakdown.nic_ml, decimals),
flavor_prep_ml=round_ml(breakdown.flavor_prep_ml, decimals),
flavors=rounded_flavors,
)
__all__ = [
"FlavorAmount",
"RecipeBreakdown",
"compute_breakdown",
"round_ml",
"breakdown_for_view",
]

View file

@ -0,0 +1,6 @@
"""UI package for FunkyJuice Recipes.
Contains Toga views and window builders.
"""
__all__ = []

View file

@ -0,0 +1,3 @@
"""Button wrapper classes for the UI."""
__all__: list[str] = []

View file

@ -0,0 +1,18 @@
from __future__ import annotations
import toga
class InventoryNavButton(toga.Button):
def __init__(self, app: toga.App, container: toga.Box):
super().__init__(on_press=self.on_press)
self.text = "Inventory"
self.container = container
self.app = app
def on_press(self, widget=None) :
from ..views.inventory import InventoryView
self.container.clear()
self.container.add(InventoryView(self.app))
__all__ = ["InventoryNavButton"]

View file

@ -0,0 +1,19 @@
from __future__ import annotations
import toga
class NewRecipeNavButton(toga.Button):
def __init__(self, app: toga.App, container: toga.Box):
super().__init__(on_press=self.on_press)
self.text = "New Recipe"
self.container = container
self.app = app
def on_press(self, widget=None):
from ..views.edit_recipe import EditRecipeView
self.container.clear()
self.container.add(EditRecipeView(self.app))
__all__ = ["NewRecipeNavButton"]

View file

@ -0,0 +1,19 @@
from __future__ import annotations
import toga
class RecipesNavButton(toga.Button):
def __init__(self, app: toga.App, container: toga.Box):
super().__init__(on_press=self.on_press)
self.text = "Recipes"
self.container = container
self.app = app
def on_press(self, widget=None):
from ..views.recipe_list import RecipeListView
self.container.clear()
self.container.add(RecipeListView(self.app))
__all__ = ["RecipesNavButton"]

View file

@ -0,0 +1,3 @@
"""Toga command classes for the application UI."""
__all__: list[str] = []

View file

@ -0,0 +1,21 @@
from __future__ import annotations
import toga
from ..dialogs.about import AboutDialog
class AboutCommand(toga.Command):
def __init__(self, app: toga.App, container: toga.Box):
async def handler(widget=None):
main_window = app.main_window
await main_window.dialog(AboutDialog.build())
super().__init__(
handler,
"About",
group=toga.Group.HELP,
)
__all__ = ["AboutCommand"]

View file

@ -0,0 +1,21 @@
from __future__ import annotations
import toga
class ExitCommand(toga.Command):
def __init__(self, app: toga.App, container: toga.Box):
async def handler(widget=None):
if app is not None:
app.exit()
super().__init__(
handler,
"Exit",
tooltip="Exit",
shortcut="q",
group=toga.Group.FILE,
)
__all__ = ["ExitCommand"]

View file

@ -0,0 +1,23 @@
from __future__ import annotations
import toga
class NewRecipeCommand(toga.Command):
def __init__(self, app: toga.App, container: toga.Box):
async def handler(widget=None):
from ..views.edit_recipe import EditRecipeView
if container is not None and app is not None:
container.clear()
container.add(EditRecipeView(app))
super().__init__(
handler,
"New Recipe",
tooltip="Create a new recipe",
shortcut="n",
group=toga.Group.FILE,
)
__all__ = ["NewRecipeCommand"]

View file

@ -0,0 +1,23 @@
from __future__ import annotations
import toga
class RecipesCommand(toga.Command):
def __init__(self, app: toga.App, container: toga.Box):
async def handler(widget=None):
from ..views.recipe_list import RecipeListView
if container is not None and app is not None:
container.clear()
container.add(RecipeListView(app))
super().__init__(
handler,
"Recipes",
tooltip="Show recipes list",
group=toga.Group.FILE,
)
__all__ = ["RecipesCommand"]

View file

@ -0,0 +1,3 @@
"""Dialog wrapper classes for the UI."""
__all__: list[str] = []

View file

@ -0,0 +1,18 @@
from __future__ import annotations
import toga
class AboutDialog:
"""Factory for the application's About dialog.
Usage:
await window.dialog(AboutDialog.build())
"""
@staticmethod
def build() -> toga.InfoDialog:
return toga.InfoDialog("About", "FunkyJuice Recipes\nVersion 0.1.0")
__all__ = ["AboutDialog"]

View file

@ -0,0 +1,3 @@
"""Form wrapper classes for placeholder UI forms."""
__all__: list[str] = []

View file

@ -0,0 +1,18 @@
from __future__ import annotations
import toga
class RecipeForm(toga.Box):
"""Minimal placeholder form for creating/editing a recipe."""
def __init__(self):
super().__init__(style=toga.style.Pack(direction="column", margin=8))
self._build()
def _build(self) -> None:
self.add(toga.TextInput(placeholder="Recipe name"))
self.add(toga.TextInput(placeholder="Size (mL)"))
__all__ = ["RecipeForm"]

View file

@ -0,0 +1,76 @@
from __future__ import annotations
import toga
class MainWindow(toga.MainWindow):
"""Main application window with content-switching layout.
Provides:
- Header buttons: Recipes, Inventory, New Recipe
- Menus: File/New Recipe, File/Exit, Help/About
- Placeholder content views swapped into the main content area
"""
def __init__(self, app: toga.App):
super().__init__(title="FunkyJuice Recipes")
# Keep a reference to the App without binding the Window to it yet.
# The actual association is done when the app assigns `app.main_window = ...`.
self._app_ref = app
# Lazy imports to avoid import cycles
from .views.recipe_list import RecipeListView
from .views.inventory import InventoryView
from .views.edit_recipe import EditRecipeView
self._RecipeListView = RecipeListView
self._InventoryView = InventoryView
self._EditRecipeView = EditRecipeView
# Container where we swap views
self.content_container = toga.Box(style=toga.style.Pack(direction="column", margin=8))
# Header nav using button wrapper classes
from .buttons.recipes_nav import RecipesNavButton
from .buttons.inventory_nav import InventoryNavButton
from .buttons.new_recipe_nav import NewRecipeNavButton
header = toga.Box(style=toga.style.Pack(direction="row", margin=(0, 0, 8, 0)))
header.add(RecipesNavButton(self._app_ref, self.content_container))
header.add(InventoryNavButton(self._app_ref, self.content_container))
header.add(NewRecipeNavButton(self._app_ref, self.content_container))
# Compose the window content
root = toga.Box(style=toga.style.Pack(direction="column", margin=12))
root.add(header)
root.add(self.content_container)
self.content = root
# Menus
async def do_about(widget=None): # pragma: no cover - trivial UI wiring
await self.dialog(toga.InfoDialog("About", "FunkyJuice Recipes\nVersion 0.1.0"))
# Commands for menu bar
from .commands.recipes import RecipesCommand
from .commands.new_recipe import NewRecipeCommand
from .commands.exit import ExitCommand
from .commands.about import AboutCommand
recipes_cmd = RecipesCommand(self._app_ref, self.content_container)
new_recipe_cmd = NewRecipeCommand(self._app_ref, self.content_container)
exit_cmd = ExitCommand(self._app_ref, self.content_container)
about_cmd = AboutCommand(self._app_ref, self.content_container)
self._app_ref.commands.add(recipes_cmd, new_recipe_cmd, exit_cmd, about_cmd)
# Initial content
# self.show_recipes()
self.content_container.add(self._RecipeListView(self._app_ref))
def build_main_window(app: toga.App) -> toga.MainWindow:
"""Backward-compatible factory to build the main window instance."""
return MainWindow(app)
__all__ = ["MainWindow", "build_main_window"]

View file

@ -0,0 +1,3 @@
"""Table wrapper classes for the UI."""
__all__: list[str] = []

View file

@ -0,0 +1,11 @@
from __future__ import annotations
import toga
class FlavorsTable(toga.Table):
def __init__(self):
super().__init__(headings=["Name", "Company", "Base"]) # placeholder
__all__ = ["FlavorsTable"]

View file

@ -0,0 +1,12 @@
from __future__ import annotations
import toga
class NicotineTable(toga.Table):
def __init__(self):
# Placeholder columns for inventory view
super().__init__(headings=["Name", "Company", "Base", "Strength (mg/mL)"])
__all__ = ["NicotineTable"]

View file

@ -0,0 +1,11 @@
from __future__ import annotations
import toga
class RecipesTable(toga.Table):
def __init__(self):
super().__init__(headings=["Name", "Size (mL)"])
__all__ = ["RecipesTable"]

View file

@ -0,0 +1,3 @@
"""Placeholder views for the simple content-switching shell."""
__all__ = []

View file

@ -0,0 +1,23 @@
from __future__ import annotations
import toga
from ..forms.recipe_form import RecipeForm
class EditRecipeView(toga.Box):
def __init__(self, app: toga.App, recipe_id: int | None = None):
super().__init__(style=toga.style.Pack(direction="column", margin=8))
self.recipe_id = recipe_id
self.app = app
self._setup()
def _setup(self):
label_text = (
f"Edit Recipe {self.recipe_id}" if self.recipe_id is not None else "New Recipe"
)
self.add(toga.Label(label_text, style=toga.style.Pack(font_size=16)))
# Embed the placeholder RecipeForm
self.add(RecipeForm())
__all__ = ["EditRecipeView"]

View file

@ -0,0 +1,22 @@
from __future__ import annotations
import toga
from ..tables.flavors_table import FlavorsTable
from ..tables.nicotine_table import NicotineTable
class InventoryView(toga.Box):
def __init__(self, app: toga.App):
super().__init__(style=toga.style.Pack(direction="column", margin=8))
self.app = app
self._setup()
def _setup(self) -> None:
self.add(toga.Label("Inventory", style=toga.style.Pack(font_size=16)))
# Placeholder tables for Flavors and Nicotine inventory
self.add(FlavorsTable())
self.add(NicotineTable())
__all__ = ["InventoryView"]

View file

@ -0,0 +1,20 @@
from __future__ import annotations
import toga
from ..tables.recipes_table import RecipesTable
class RecipeListView(toga.Box):
def __init__(self, app: toga.App):
super().__init__(style=toga.style.Pack(direction="column", margin=8))
self.app = app
self._setup()
def _setup(self) -> None:
self.add(toga.Label("Recipes", style=toga.style.Pack(font_size=16)))
# Placeholder table (no data binding yet)
table = RecipesTable()
self.add(table)
__all__ = ["RecipeListView"]

View file

@ -0,0 +1,19 @@
from __future__ import annotations
import toga
class ViewRecipeView(toga.Box):
def __init__(self, app: toga.App, recipe_id: int | None = None):
super().__init__(style=toga.style.Pack(direction="column", margin=8))
self.app = app
self.recipe_id = recipe_id
self._setup()
def _setup(self) -> None:
title = "View Recipe" if self.recipe_id is None else f"View Recipe {self.recipe_id}"
self.add(toga.Label(title, style=toga.style.Pack(font_size=16)))
self.add(toga.Label("(Recipe details will appear here)"))
__all__ = ["ViewRecipeView"]

158
tests/test_calculations.py Normal file
View file

@ -0,0 +1,158 @@
from __future__ import annotations
import math
import pytest
from funkyjuicerecipes.logic.calculations import (
compute_breakdown,
breakdown_for_view,
)
def test_readme_style_example_120ml_nic_in_pg():
bd = compute_breakdown(
size_ml=120,
base_pg_pct=30.0,
base_vg_pct=70.0,
nic_target_mg_per_ml=3.0,
nic_strength_mg_per_ml=100.0,
nic_base="PG",
flavors=[
{"name": "Vanilla", "base": "PG", "pct": 8.0},
("Strawberry", "VG", 4.0),
],
)
# Percentages
assert bd.pg_pct == pytest.approx(19.0)
assert bd.vg_pct == pytest.approx(66.0)
assert bd.nic_pct == pytest.approx(3.0)
# mL calculations
assert bd.pg_ml == pytest.approx(22.8)
assert bd.vg_ml == pytest.approx(79.2)
assert bd.nic_ml == pytest.approx(3.6)
names = [f.name for f in bd.flavors]
assert names == ["Vanilla", "Strawberry"]
amounts = {f.name: f.ml for f in bd.flavors}
assert amounts["Vanilla"] == pytest.approx(9.6)
assert amounts["Strawberry"] == pytest.approx(4.8)
assert bd.flavor_prep_ml == pytest.approx(14.4)
# Rounded view (1 decimal) should match obvious roundings
v = breakdown_for_view(bd)
assert v.pg_ml == pytest.approx(22.8)
assert v.vg_ml == pytest.approx(79.2)
assert v.nic_ml == pytest.approx(3.6)
assert {f.name: f.ml for f in v.flavors}["Vanilla"] == pytest.approx(9.6)
assert {f.name: f.ml for f in v.flavors}["Strawberry"] == pytest.approx(4.8)
assert v.flavor_prep_ml == pytest.approx(14.4)
def test_nic_in_vg_changes_pg_vg_split():
bd = compute_breakdown(
size_ml=120,
base_pg_pct=30.0,
base_vg_pct=70.0,
nic_target_mg_per_ml=3.0,
nic_strength_mg_per_ml=100.0,
nic_base="VG",
flavors=[
{"name": "Vanilla", "base": "PG", "pct": 8.0},
("Strawberry", "VG", 4.0),
],
)
assert bd.pg_pct == pytest.approx(22.0)
assert bd.vg_pct == pytest.approx(63.0)
assert bd.pg_ml == pytest.approx(26.4)
assert bd.vg_ml == pytest.approx(75.6)
@pytest.mark.parametrize(
"base_pg,base_vg",
[
(70.0, 30.0),
(70.0000001, 29.9999999), # tolerance
],
)
def test_base_pg_vg_sum_to_100_with_tolerance(base_pg, base_vg):
bd = compute_breakdown(
size_ml=60,
base_pg_pct=base_pg,
base_vg_pct=base_vg,
nic_target_mg_per_ml=0.0,
nic_strength_mg_per_ml=100.0,
nic_base="PG",
flavors=[],
)
assert bd.pg_pct + bd.vg_pct + bd.nic_pct + sum(f.pct for f in bd.flavors) == pytest.approx(100.0)
def test_excessive_flavor_or_negative_pg_raises():
# This combination would push PG negative (base PG 10, nic 10 PG, flavor PG 5 => PG becomes -5)
with pytest.raises(ValueError):
compute_breakdown(
size_ml=30,
base_pg_pct=10.0,
base_vg_pct=90.0,
nic_target_mg_per_ml=10.0,
nic_strength_mg_per_ml=100.0,
nic_base="PG",
flavors=[("Whatever", "PG", 5.0)],
)
# Flavors sum > 100 should also fail via negative VG/PG after deduction
with pytest.raises(ValueError):
compute_breakdown(
size_ml=30,
base_pg_pct=50.0,
base_vg_pct=50.0,
nic_target_mg_per_ml=0.0,
nic_strength_mg_per_ml=100.0,
nic_base="PG",
flavors=[("A", "PG", 60.0), ("B", "VG", 50.0)],
)
def test_input_variants_tuple_and_mapping_supported():
bd = compute_breakdown(
size_ml=10,
base_pg_pct=50.0,
base_vg_pct=50.0,
nic_target_mg_per_ml=0.0,
nic_strength_mg_per_ml=100.0,
nic_base="PG",
flavors=[
{"name": "X", "base": "PG", "pct": 5.0},
("Y", "VG", 5.0),
],
)
def test_50mg_stock_requires_more_nic_volume_and_changes_pg_vg_split():
# With 50 mg/mL stock, to reach 3 mg/mL target we need 6% nic volume.
bd = compute_breakdown(
size_ml=120,
base_pg_pct=30.0,
base_vg_pct=70.0,
nic_target_mg_per_ml=3.0,
nic_strength_mg_per_ml=50.0,
nic_base="PG",
flavors=[
{"name": "Vanilla", "base": "PG", "pct": 8.0},
("Strawberry", "VG", 4.0),
],
)
# Now nic_pct (volume) is 6%
assert bd.nic_pct == pytest.approx(6.0)
# PG is further reduced by extra 3% vs the 100 mg/mL case
# Base pg 30 - flavor PG 8 - nic 6 = 16
assert bd.pg_pct == pytest.approx(16.0)
# VG remains 70 - flavor VG 4 = 66
assert bd.vg_pct == pytest.approx(66.0)
# mL
assert bd.nic_ml == pytest.approx(7.2) # 6% of 120
assert bd.pg_ml == pytest.approx(19.2) # 16% of 120
assert bd.vg_ml == pytest.approx(79.2) # 66% of 120
assert {f.name for f in bd.flavors} == {"Vanilla", "Strawberry"}

View file

@ -12,6 +12,7 @@ from funkyjuicerecipes.data.db import (
get_db,
apply_migrations,
)
import funkyjuicerecipes.data.db as dbmod
class DummyPaths(SimpleNamespace):
@ -31,6 +32,20 @@ class DummyApp(SimpleNamespace):
self.paths.data = data_dir
def _pkg_db_path() -> Path:
# New behavior stores DB next to the data module; _db_path_for_app ignores the app argument
return Path(dbmod._db_path_for_app(None)) # type: ignore[arg-type]
def _cleanup_pkg_db() -> None:
p = _pkg_db_path()
try:
if p.exists():
p.unlink()
except Exception:
pass
def _tables(conn: sqlite3.Connection) -> set[str]:
cur = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
@ -39,35 +54,40 @@ def _tables(conn: sqlite3.Connection) -> set[str]:
def test_init_database_creates_file_and_tables(tmp_path: Path):
_cleanup_pkg_db()
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"
db_path = _pkg_db_path()
assert db_path.exists(), "DB file should be created next to the package (data folder)"
# Use sqlite3 to introspect tables
with sqlite3.connect(db_path) as conn:
with sqlite3.connect(str(db_path)) as conn:
names = _tables(conn)
# migrations + our 3 domain tables
assert {"migrations", "flavor", "recipe", "recipe_flavor"}.issubset(names)
# migrations + our domain tables (including nicotine)
assert {"migrations", "flavor", "recipe", "recipe_flavor", "nicotine"}.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
# We now have more migrations over time; ensure at least the base set were applied
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
# New nicotine/inventory migrations should also be present
assert any("nicotine_table_created" in f for f in filenames)
# All migrations applied in one run should share the same batch
batches = {r[1] for r in rows}
assert len(batches) == 1
def test_apply_migrations_is_idempotent(tmp_path: Path):
_cleanup_pkg_db()
app = DummyApp(tmp_path / "data2")
db = init_database(app)
@ -75,9 +95,9 @@ def test_apply_migrations_is_idempotent(tmp_path: Path):
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:
# migrations count remains stable after re-run
db_path = _pkg_db_path()
with sqlite3.connect(str(db_path)) as conn:
cur = conn.execute("SELECT COUNT(*) FROM migrations")
(count,) = cur.fetchone()
assert count == 3
assert count >= 3

View file

@ -8,10 +8,12 @@ import pytest
from peewee import IntegrityError, DoesNotExist
from funkyjuicerecipes.data.db import init_database, DB_FILENAME
import funkyjuicerecipes.data.db as dbmod
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
from funkyjuicerecipes.data.models.flavor import Flavor as FlavorModel
class DummyPaths(SimpleNamespace):
@ -31,7 +33,23 @@ class DummyApp(SimpleNamespace):
self.paths.data = data_dir
def _pkg_db_path() -> Path:
# New behavior stores DB next to the data module; _db_path_for_app ignores the app argument
return Path(dbmod._db_path_for_app(None)) # type: ignore[arg-type]
def _cleanup_pkg_db() -> None:
p = _pkg_db_path()
try:
if p.exists():
p.unlink()
except Exception:
pass
def _init_db(tmp_path: Path):
# Ensure a clean package-scoped DB for each test
_cleanup_pkg_db()
app = DummyApp(tmp_path / "data")
db = init_database(app)
return app, db
@ -84,7 +102,7 @@ def test_flavor_soft_delete_and_listing_filters(tmp_path: Path):
size_ml=30,
base_pg_pct=50,
base_vg_pct=50,
nic_pct=0,
nic_target_mg_per_ml=0,
nic_base="PG",
ingredients=[(f1.id, 5.0)],
)
@ -113,7 +131,7 @@ def test_recipe_creation_and_get_with_ingredients_includes_soft_deleted_flavors(
size_ml=60,
base_pg_pct=70,
base_vg_pct=30,
nic_pct=3,
nic_target_mg_per_ml=3,
nic_base="PG",
ingredients=[(fa.id, 4.0), (fb.id, 2.0)],
)
@ -142,7 +160,7 @@ def test_replace_ingredients_diff_behavior(tmp_path: Path):
size_ml=30,
base_pg_pct=50,
base_vg_pct=50,
nic_pct=0,
nic_target_mg_per_ml=0,
nic_base="PG",
ingredients=[(f1.id, 3.0), (f2.id, 2.0)],
)
@ -179,3 +197,55 @@ def test_replace_ingredients_diff_behavior(tmp_path: Path):
assert RecipeFlavor.select().where(
(RecipeFlavor.recipe == r.id) & (RecipeFlavor.flavor == f3.id)
).exists()
def test_flavor_snapshots_populated_and_stable(tmp_path: Path):
app, db = _init_db(tmp_path)
flavors = FlavorRepository()
recipes = RecipeRepository()
# Create a flavor and recipe using it
f = flavors.create(name="Vanilla", company="CAP", base="PG")
r = recipes.create(
name="Vanilla Mix",
size_ml=30,
base_pg_pct=50,
base_vg_pct=50,
nic_target_mg_per_ml=0,
nic_base="PG",
ingredients=[(f.id, 5.0)],
)
rf = RecipeFlavor.get(RecipeFlavor.recipe == r.id)
assert rf.flavor_id == f.id
# Snapshots should be populated at creation time
assert rf.flavor_name_snapshot == "Vanilla"
assert rf.flavor_company_snapshot == "CAP"
assert rf.flavor_base_snapshot == "PG"
# Change the live Flavor fields
flavors.update(f.id, name="Vanilla Bean", company="Capella", base="VG")
# Snapshots must remain unchanged
rf2 = RecipeFlavor.get_by_id(rf.id)
assert rf2.flavor_name_snapshot == "Vanilla"
assert rf2.flavor_company_snapshot == "CAP"
assert rf2.flavor_base_snapshot == "PG"
# Pct-only update should not touch snapshots
recipes.replace_ingredients(r.id, [(f.id, 7.5)])
rf3 = RecipeFlavor.get(RecipeFlavor.recipe == r.id)
assert rf3.pct == 7.5
assert rf3.flavor_name_snapshot == "Vanilla"
assert rf3.flavor_company_snapshot == "CAP"
assert rf3.flavor_base_snapshot == "PG"
# Replacing with a different flavor should capture new snapshots
f2 = flavors.create(name="Cookie", company="FA", base="VG")
recipes.replace_ingredients(r.id, [(f2.id, 2.0)])
rf_after = RecipeFlavor.get(RecipeFlavor.recipe == r.id)
assert rf_after.flavor_id == f2.id
assert rf_after.pct == 2.0
assert rf_after.flavor_name_snapshot == "Cookie"
assert rf_after.flavor_company_snapshot == "FA"
assert rf_after.flavor_base_snapshot == "VG"

View file

@ -6,6 +6,26 @@ toga = pytest.importorskip("toga")
from funkyjuicerecipes.app import main, FunkyJuiceRecipesApp
def test_ui_views_construct():
# Construct views without starting event loop
app = FunkyJuiceRecipesApp("funkyjuicerecipes", "com.targonproducts.funkyjuicerecipes")
from funkyjuicerecipes.ui.views.recipe_list import RecipeListView
from funkyjuicerecipes.ui.views.inventory import InventoryView
from funkyjuicerecipes.ui.views.edit_recipe import EditRecipeView
from funkyjuicerecipes.ui.views.view_recipe import ViewRecipeView
from funkyjuicerecipes.ui.main_window import build_main_window
assert RecipeListView(app) is not None
assert InventoryView(app) is not None
assert EditRecipeView(app) is not None
assert ViewRecipeView(app) is not None
# Build main window; do not show it
win = build_main_window(app)
assert isinstance(win, toga.MainWindow)
def test_main_returns_app_instance():
app = main()
assert isinstance(app, FunkyJuiceRecipesApp)

583
uv.lock
View file

@ -9,6 +9,201 @@ supported-markers = [
"sys_platform == 'linux'",
]
[[package]]
name = "anyio"
version = "4.12.0"
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 = "idna", marker = "sys_platform == 'linux'" },
{ 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/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
]
[[package]]
name = "arrow"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "python-dateutil", marker = "sys_platform == 'linux'" },
{ name = "tzdata", marker = "python_full_version >= '3.10' and sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b9/33/032cdc44182491aa708d06a68b62434140d8c50820a087fac7af37703357/arrow-1.4.0.tar.gz", hash = "sha256:ed0cc050e98001b8779e84d461b0098c4ac597e88704a655582b21d116e526d7", size = 152931, upload-time = "2025-10-18T17:46:46.761Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/c9/d7977eaacb9df673210491da99e6a247e93df98c715fc43fd136ce1d3d33/arrow-1.4.0-py3-none-any.whl", hash = "sha256:749f0769958ebdc79c173ff0b0670d59051a535fa26e8eba02953dc19eb43205", size = 68797, upload-time = "2025-10-18T17:46:45.663Z" },
]
[[package]]
name = "binaryornot"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "chardet", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a7/fe/7ebfec74d49f97fc55cd38240c7a7d08134002b1e14be8c3897c0dd5e49b/binaryornot-0.4.4.tar.gz", hash = "sha256:359501dfc9d40632edc9fac890e19542db1a287bbcfa58175b66658392018061", size = 371054, upload-time = "2017-08-03T15:55:25.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/7e/f7b6f453e6481d1e233540262ccbfcf89adcd43606f44a028d7f5fae5eb2/binaryornot-0.4.4-py2.py3-none-any.whl", hash = "sha256:b8b71173c917bddcd2c16070412e369c3ed7f0528926f70cac18a6c97fd563e4", size = 9006, upload-time = "2017-08-03T15:55:31.23Z" },
]
[[package]]
name = "briefcase"
version = "0.3.26"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "build", marker = "sys_platform == 'linux'" },
{ name = "cookiecutter", marker = "sys_platform == 'linux'" },
{ name = "gitpython", marker = "sys_platform == 'linux'" },
{ name = "httpx", marker = "sys_platform == 'linux'" },
{ name = "packaging", marker = "sys_platform == 'linux'" },
{ name = "pip", marker = "sys_platform == 'linux'" },
{ name = "platformdirs", marker = "sys_platform == 'linux'" },
{ name = "psutil", marker = "sys_platform == 'linux'" },
{ name = "python-dateutil", marker = "sys_platform == 'linux'" },
{ name = "rich", marker = "sys_platform == 'linux'" },
{ name = "setuptools", marker = "sys_platform == 'linux'" },
{ name = "tomli", marker = "python_full_version < '3.11' and python_full_version >= '3.10' and sys_platform == 'linux'" },
{ name = "tomli-w", marker = "sys_platform == 'linux'" },
{ name = "truststore", marker = "sys_platform == 'linux'" },
{ name = "wheel", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/c6/668fd69fcc982f8673d1324da034e3ef0c5f2eaa14feab25693dddccd563/briefcase-0.3.26.tar.gz", hash = "sha256:ebde1b0e899c5d1107737694f8d6070b60706c4260ce787915b72f29d6c8413d", size = 2608149, upload-time = "2025-12-04T08:06:27.348Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/03/d0/9d1199811cc088f4fb1e9faa8504e620e45e4502ac4ef5357cad369ff87f/briefcase-0.3.26-py3-none-any.whl", hash = "sha256:8c1e8b3c9006f730c9e4983d640cb0b52333c17f605d952b503cb6beae5fec38", size = 265200, upload-time = "2025-12-04T08:06:24.491Z" },
]
[[package]]
name = "build"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata", marker = "python_full_version < '3.10.2' and python_full_version >= '3.10' and sys_platform == 'linux'" },
{ name = "packaging", marker = "sys_platform == 'linux'" },
{ name = "pyproject-hooks", 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/25/1c/23e33405a7c9eac261dff640926b8b5adaed6a6eb3e1767d441ed611d0c0/build-1.3.0.tar.gz", hash = "sha256:698edd0ea270bde950f53aed21f3a0135672206f3911e0176261a31e0e07b397", size = 48544, upload-time = "2025-08-01T21:27:09.268Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/8c/2b30c12155ad8de0cf641d76a8b396a16d2c36bc6d50b621a62b7c4567c1/build-1.3.0-py3-none-any.whl", hash = "sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4", size = 23382, upload-time = "2025-08-01T21:27:07.844Z" },
]
[[package]]
name = "certifi"
version = "2025.11.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/8c/58f469717fa48465e4a50c014a0400602d3c437d7c0c468e17ada824da3a/certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316", size = 160538, upload-time = "2025-11-12T02:54:51.517Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/7d/9bc192684cea499815ff478dfcdc13835ddf401365057044fb721ec6bddb/certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b", size = 159438, upload-time = "2025-11-12T02:54:49.735Z" },
]
[[package]]
name = "chardet"
version = "5.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/f7b6ab21ec75897ed80c17d79b15951a719226b9fababf1e40ea74d69079/chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7", size = 2069618, upload-time = "2023-08-01T19:23:02.662Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/6f/f5fbc992a329ee4e0f288c1fe0e2ad9485ed064cac731ed2fe47dcc38cbf/chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970", size = 199385, upload-time = "2023-08-01T19:23:00.661Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" },
{ url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" },
{ url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" },
{ url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" },
{ url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" },
{ url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" },
{ url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" },
{ url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" },
{ url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" },
{ url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" },
{ url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" },
{ url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" },
{ url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" },
{ url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" },
{ url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" },
{ url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" },
{ url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" },
{ url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" },
{ url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" },
{ url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" },
{ url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" },
{ url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" },
{ url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[package]]
name = "cookiecutter"
version = "2.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "arrow", marker = "sys_platform == 'linux'" },
{ name = "binaryornot", marker = "sys_platform == 'linux'" },
{ name = "click", marker = "sys_platform == 'linux'" },
{ name = "jinja2", marker = "sys_platform == 'linux'" },
{ name = "python-slugify", marker = "sys_platform == 'linux'" },
{ name = "pyyaml", marker = "sys_platform == 'linux'" },
{ name = "requests", marker = "sys_platform == 'linux'" },
{ name = "rich", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/17/9f2cd228eb949a91915acd38d3eecdc9d8893dde353b603f0db7e9f6be55/cookiecutter-2.6.0.tar.gz", hash = "sha256:db21f8169ea4f4fdc2408d48ca44859349de2647fbe494a9d6c3edfc0542c21c", size = 158767, upload-time = "2024-02-21T18:02:41.949Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/d9/0137658a353168ffa9d0fc14b812d3834772040858ddd1cb6eeaf09f7a44/cookiecutter-2.6.0-py3-none-any.whl", hash = "sha256:a54a8e37995e4ed963b3e82831072d1ad4b005af736bb17b99c2cbd9d41b6e2d", size = 39177, upload-time = "2024-02-21T18:02:39.569Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
@ -26,6 +221,7 @@ name = "funkyjuicerecipes"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "briefcase", marker = "sys_platform == 'linux'" },
{ name = "peewee", marker = "sys_platform == 'linux'" },
{ name = "pytest", marker = "sys_platform == 'linux'" },
{ name = "toga", marker = "sys_platform == 'linux'" },
@ -33,11 +229,94 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "briefcase", specifier = ">=0.3.26" },
{ name = "peewee", specifier = ">=3.18.3" },
{ name = "pytest", specifier = ">=9.0.2" },
{ name = "toga", specifier = "==0.5.3" },
]
[[package]]
name = "gitdb"
version = "4.0.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "smmap", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" },
]
[[package]]
name = "gitpython"
version = "3.1.45"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "gitdb", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi", marker = "sys_platform == 'linux'" },
{ name = "h11", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform == 'linux'" },
{ name = "certifi", marker = "sys_platform == 'linux'" },
{ name = "httpcore", marker = "sys_platform == 'linux'" },
{ name = "idna", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "importlib-metadata"
version = "8.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "zipp", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
@ -47,6 +326,89 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" },
{ url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" },
{ url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" },
{ url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" },
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "25.0"
@ -62,6 +424,24 @@ 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 = "pip"
version = "25.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fe/6e/74a3f0179a4a73a53d66ce57fdb4de0080a8baa1de0063de206d6167acc2/pip-25.3.tar.gz", hash = "sha256:8d0538dbbd7babbd207f261ed969c65de439f6bc9e5dbd3b3b9a77f25d95f343", size = 1803014, upload-time = "2025-10-25T00:55:41.394Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/3c/d717024885424591d5376220b5e836c2d5293ce2011523c9de23ff7bf068/pip-25.3-py3-none-any.whl", hash = "sha256:9655943313a94722b7774661c21049070f6bbb0a1516bf02f7c8d5d9201514cd", size = 1778622, upload-time = "2025-10-25T00:55:39.247Z" },
]
[[package]]
name = "platformdirs"
version = "4.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
@ -71,6 +451,20 @@ 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 = "psutil"
version = "7.1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e1/88/bdd0a41e5857d5d703287598cbf08dad90aed56774ea52ae071bae9071b6/psutil-7.1.3.tar.gz", hash = "sha256:6c86281738d77335af7aec228328e944b30930899ea760ecf33a4dba66be5e74", size = 489059, upload-time = "2025-11-02T12:25:54.619Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/61/23fd4acc3c9eebbf6b6c78bcd89e5d020cfde4acf0a9233e9d4e3fa698b4/psutil-7.1.3-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95ef04cf2e5ba0ab9eaafc4a11eaae91b44f4ef5541acd2ee91d9108d00d59a7", size = 287134, upload-time = "2025-11-02T12:26:02.613Z" },
{ url = "https://files.pythonhosted.org/packages/30/1c/f921a009ea9ceb51aa355cb0cc118f68d354db36eae18174bab63affb3e6/psutil-7.1.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1068c303be3a72f8e18e412c5b2a8f6d31750fb152f9cb106b54090296c9d251", size = 289904, upload-time = "2025-11-02T12:26:05.207Z" },
{ url = "https://files.pythonhosted.org/packages/41/bd/313aba97cb5bfb26916dc29cf0646cbe4dd6a89ca69e8c6edce654876d39/psutil-7.1.3-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f33a3702e167783a9213db10ad29650ebf383946e91bc77f28a5eb083496bc9", size = 288210, upload-time = "2025-11-02T12:26:16.699Z" },
{ url = "https://files.pythonhosted.org/packages/c2/fa/76e3c06e760927a0cfb5705eb38164254de34e9bd86db656d4dbaa228b04/psutil-7.1.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fac9cd332c67f4422504297889da5ab7e05fd11e3c4392140f7370f4208ded1f", size = 291182, upload-time = "2025-11-02T12:26:18.848Z" },
{ url = "https://files.pythonhosted.org/packages/ce/b1/5f49af514f76431ba4eea935b8ad3725cdeb397e9245ab919dbc1d1dc20f/psutil-7.1.3-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb428f9f05c1225a558f53e30ccbad9930b11c3fc206836242de1091d3e7dd3", size = 263261, upload-time = "2025-11-02T12:26:29.48Z" },
{ url = "https://files.pythonhosted.org/packages/e0/95/992c8816a74016eb095e73585d747e0a8ea21a061ed3689474fabb29a395/psutil-7.1.3-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56d974e02ca2c8eb4812c3f76c30e28836fffc311d55d979f1465c1feeb2b68b", size = 264635, upload-time = "2025-11-02T12:26:31.74Z" },
]
[[package]]
name = "pycairo"
version = "1.29.0"
@ -95,6 +489,15 @@ 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 = "pyproject-hooks"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/82/28175b2414effca1cdac8dc99f76d660e7a4fb0ceefa4b4ab8f5f6742925/pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8", size = 19228, upload-time = "2024-09-29T09:24:13.293Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/24/12818598c362d7f300f18e74db45963dbcb85150324092410c8b49405e42/pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913", size = 10216, upload-time = "2024-09-29T09:24:11.978Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
@ -112,6 +515,132 @@ 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 = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-slugify"
version = "8.0.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "text-unidecode", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi", marker = "sys_platform == 'linux'" },
{ name = "charset-normalizer", marker = "sys_platform == 'linux'" },
{ name = "idna", marker = "sys_platform == 'linux'" },
{ name = "urllib3", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "rich"
version = "14.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py", marker = "sys_platform == 'linux'" },
{ name = "pygments", marker = "sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" },
]
[[package]]
name = "setuptools"
version = "80.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958, upload-time = "2025-05-27T00:56:51.443Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486, upload-time = "2025-05-27T00:56:49.664Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "smmap"
version = "5.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/44/cd/a040c4b3119bbe532e5b0732286f805445375489fceaec1f48306068ee3b/smmap-5.0.2.tar.gz", hash = "sha256:26ea65a03958fa0c8a1c7e8c7a58fdc77221b8910f6be2131affade476898ad5", size = 22329, upload-time = "2025-01-02T07:14:40.909Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/be/d09147ad1ec7934636ad912901c5fd7667e1c858e19d355237db0d0cd5e4/smmap-5.0.2-py3-none-any.whl", hash = "sha256:b30115f0def7d7531d22a0fb6502488d879e75b260a9db4d0819cfb25403af5e", size = 24303, upload-time = "2025-01-02T07:14:38.724Z" },
]
[[package]]
name = "text-unidecode"
version = "1.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" },
]
[[package]]
name = "toga"
version = "0.5.3"
@ -180,6 +709,15 @@ wheels = [
{ 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 = "tomli-w"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" },
]
[[package]]
name = "travertino"
version = "0.5.3"
@ -189,6 +727,15 @@ 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 = "truststore"
version = "0.10.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
@ -197,3 +744,39 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "tzdata"
version = "2025.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" },
]
[[package]]
name = "urllib3"
version = "2.6.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" },
]
[[package]]
name = "wheel"
version = "0.45.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/98/2d9906746cdc6a6ef809ae6338005b3f21bb568bea3165cfc6a243fdc25c/wheel-0.45.1.tar.gz", hash = "sha256:661e1abd9198507b1409a20c02106d9670b2576e916d58f520316666abca6729", size = 107545, upload-time = "2024-11-23T00:18:23.513Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/2c/87f3254fd8ffd29e4c02732eee68a83a1d3c346ae39bc6822dcbcb697f2b/wheel-0.45.1-py3-none-any.whl", hash = "sha256:708e7481cc80179af0e556bbf0cc00b8444c7321e2700b8d8580231d13017248", size = 72494, upload-time = "2024-11-23T00:18:21.207Z" },
]
[[package]]
name = "zipp"
version = "3.23.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" },
]