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 ### ~~Milestone 4 — Calculation engine~~
- Implement pure functions in `logic/calculations.py` to compute PG/VG/Nic and per-flavor mL from inputs (percents + size). - ~~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). - ~~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). - ~~Define rounding/display policy (e.g., 1 decimal for View screen).~~
Acceptance: ~~Acceptance:~~
- Tests cover the README example (120 mL case) and edge cases (nic in PG vs VG, excessive flavor %). - ~~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 ### ~~Milestone 5 — Main window and navigation shell~~
- Build `MainWindow`: menu bar (File/New Recipe/Exit; Help/About), buttons for Inventory, recipe list placeholder. - ~~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. - ~~Wire basic screens/routes (View Recipe, Add/Edit Recipe, Inventory) with placeholders.~~
Acceptance: ~~Acceptance:~~
- App displays main list and can navigate to empty placeholder screens and back. - ~~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 Table
``` ```
recipe( recipe(
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
name TEXT NOT NULL UNIQUE, name TEXT NOT NULL UNIQUE,
size_ml INTEGER NOT NULL, size_ml INTEGER NOT NULL,
base_pg_pct REAL NOT NULL, base_pg_pct REAL NOT NULL,
base_vg_pct REAL NOT NULL, base_vg_pct REAL NOT NULL,
nic_pct REAL CHECK(nic_pct >= 0 AND nic_pct <= 100) 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, nic_base TEXT CHECK(nic_base IN ('PG','VG')) NOT NULL,
nicotine_id INTEGER NULL REFERENCES nicotine(id) ON DELETE SET NULL,
CHECK ( CHECK (
base_pg_pct >= 0 AND base_pg_pct >= 0 AND
base_vg_pct >= 0 AND base_vg_pct >= 0 AND
@ -165,16 +167,18 @@ migrations(
## Recipes ## Recipes
* Recipes require * Recipes require
* Name, * Name,
* Size (ml), * Size (ml),
* PG Base %, * PG Base %,
* VG base %, * VG base %,
* Nic Base (PG/VG) * Nic target strength (mg/mL),
* Nic %, * Nic stock strength (mg/mL),
* Nic Base (PG/VG),
* Flavor List * Flavor List
* Recipe equations are: * Recipe equations are:
* flavor_pg_pct = sum(pct for flavor in flavors if flavor.base == 'PG') * 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') * 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) * 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) * vg_pct = base_vg_pct - flavor_vg_pct - (nic_pct if nic_base == 'VG' else 0)
* PG_ml = pg_pct / 100 * size_ml * PG_ml = pg_pct / 100 * size_ml

View file

@ -6,7 +6,9 @@ dependencies = [
"toga==0.5.3", "toga==0.5.3",
"peewee>=3.18.3", "peewee>=3.18.3",
"pytest>=9.0.2", "pytest>=9.0.2",
"briefcase>=0.3.26",
] ]
license = { file = "LICENSE" }
[tool.uv] [tool.uv]
environments = [ environments = [
@ -20,7 +22,6 @@ version = "0.1.0"
url = "https://example.com" url = "https://example.com"
author = "Funky Waddle" author = "Funky Waddle"
author_email = "you@example.com" author_email = "you@example.com"
license = "MIT"
[tool.briefcase.app.funkyjuicerecipes] [tool.briefcase.app.funkyjuicerecipes]
formal_name = "FunkyJuice Recipes" 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 from __future__ import annotations
import toga import toga
from .data.db import init_database from .data.db import init_database
from .ui.main_window import build_main_window
class FunkyJuiceRecipesApp(toga.App): 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: def startup(self) -> None:
# Create the main window with the required title
# Initialize database and run migrations before showing UI # Initialize database and run migrations before showing UI
init_database(self) print("startup() entered", flush=True)
self.main_window = toga.MainWindow(title="FunkyJuice Recipes") self._db = init_database(self)
# An empty content box for now (satisfies blank window acceptance) print("db initialized; building window…", flush=True)
self.main_window.content = toga.Box()
# 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() 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: def main() -> FunkyJuiceRecipesApp:
@ -20,7 +49,8 @@ def main() -> FunkyJuiceRecipesApp:
Returns a constructed, not-yet-started app instance. 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__": if __name__ == "__main__":

View file

@ -36,7 +36,17 @@ class FlavorRepository:
def get(self, flavor_id: int) -> Flavor: def get(self, flavor_id: int) -> Flavor:
return Flavor.get_by_id(flavor_id) 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) f = self.get(flavor_id)
if name is not None: if name is not None:
f.name = name f.name = name
@ -44,6 +54,12 @@ class FlavorRepository:
f.company = company f.company = company
if base is not None: if base is not None:
f.base = base 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() f.save()
return f 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 import Recipe
from ..models.recipe_flavor import RecipeFlavor from ..models.recipe_flavor import RecipeFlavor
from ..models.flavor import Flavor from ..models.flavor import Flavor
from ..models.nicotine import Nicotine
Ingredient = Tuple[int, float] # (flavor_id, pct) Ingredient = Tuple[int, float] # (flavor_id, pct)
@ -20,17 +21,47 @@ class RecipeRepository:
size_ml: int, size_ml: int,
base_pg_pct: float, base_pg_pct: float,
base_vg_pct: float, base_vg_pct: float,
nic_pct: float, nic_target_mg_per_ml: float,
nic_base: str, nic_strength_mg_per_ml: Optional[float] = None,
nic_base: Optional[str] = None,
nicotine_id: Optional[int] = None,
ingredients: Optional[Sequence[Ingredient]] = None, ingredients: Optional[Sequence[Ingredient]] = None,
) -> Recipe: ) -> 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( recipe = Recipe.create(
name=name, name=name,
size_ml=size_ml, size_ml=size_ml,
base_pg_pct=base_pg_pct, base_pg_pct=base_pg_pct,
base_vg_pct=base_vg_pct, base_vg_pct=base_vg_pct,
nic_pct=nic_pct, nic_target_mg_per_ml=nic_target_mg_per_ml,
nic_base=nic_base, nic_strength_mg_per_ml=resolved_strength,
nic_base=resolved_base,
nicotine=inv_obj if inv_obj is not None else None,
) )
if ingredients: if ingredients:
self.replace_ingredients(recipe.id, ingredients) self.replace_ingredients(recipe.id, ingredients)
@ -60,8 +91,10 @@ class RecipeRepository:
size_ml: Optional[int] = None, size_ml: Optional[int] = None,
base_pg_pct: Optional[float] = None, base_pg_pct: Optional[float] = None,
base_vg_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, nic_base: Optional[str] = None,
nicotine_id: Optional[int] = None,
) -> Recipe: ) -> Recipe:
r = self.get(recipe_id) r = self.get(recipe_id)
if name is not None: if name is not None:
@ -72,10 +105,17 @@ class RecipeRepository:
r.base_pg_pct = base_pg_pct r.base_pg_pct = base_pg_pct
if base_vg_pct is not None: if base_vg_pct is not None:
r.base_vg_pct = base_vg_pct r.base_vg_pct = base_vg_pct
if nic_pct is not None: if nic_target_mg_per_ml is not None:
r.nic_pct = nic_pct 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: if nic_base is not None:
r.nic_base = nic_base 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() r.save()
return r return r
@ -133,7 +173,20 @@ class RecipeRepository:
) )
for fid in to_add: 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: def delete(self, recipe_id: int) -> None:
r = self.get(recipe_id) r = self.get(recipe_id)

View file

@ -13,13 +13,18 @@ DB_FILENAME = "funkyjuicerecipes.data"
def _db_path_for_app(app) -> str: 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) here = os.path.dirname(__file__) # .../src/funkyjuicerecipes/data
os.makedirs(data_dir, exist_ok=True) os.makedirs(here, exist_ok=True)
return os.path.join(data_dir, DB_FILENAME) return os.path.join(here, DB_FILENAME)
def create_database(path: str) -> SqliteDatabase: def create_database(path: str) -> SqliteDatabase:
@ -85,8 +90,9 @@ def _migration_modules() -> List[str]:
name = res.name name = res.name
if name.endswith(".py") and not name.startswith("_"): if name.endswith(".py") and not name.startswith("_"):
files.append(name) files.append(name)
# Sort by filename to define application order # Sort by filename, but ensure "*_created*" migrations run before others
files.sort() # 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 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, BooleanField,
DateTimeField, DateTimeField,
Check, Check,
FloatField,
) )
from .base import BaseModel from .base import BaseModel
@ -14,6 +15,10 @@ class Flavor(BaseModel):
name = CharField(collation="NOCASE") name = CharField(collation="NOCASE")
company = CharField(collation="NOCASE") company = CharField(collation="NOCASE")
base = CharField(constraints=[Check("base IN ('PG','VG')")]) 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) is_deleted = BooleanField(default=False)
deleted_at = DateTimeField(null=True) 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, FloatField,
Check, Check,
SQL, SQL,
ForeignKeyField,
) )
from .base import BaseModel from .base import BaseModel
from .nicotine import Nicotine
class Recipe(BaseModel): class Recipe(BaseModel):
@ -16,8 +18,10 @@ class Recipe(BaseModel):
size_ml = IntegerField(constraints=[Check("size_ml > 0")]) size_ml = IntegerField(constraints=[Check("size_ml > 0")])
base_pg_pct = FloatField(constraints=[Check("base_pg_pct >= 0")]) base_pg_pct = FloatField(constraints=[Check("base_pg_pct >= 0")])
base_vg_pct = FloatField(constraints=[Check("base_vg_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')")]) nic_base = CharField(constraints=[Check("nic_base IN ('PG','VG')")])
nicotine = ForeignKeyField(Nicotine, null=True, backref="recipes", on_delete="SET NULL")
class Meta: class Meta:
table_name = "recipe" table_name = "recipe"

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from peewee import ForeignKeyField, FloatField, Check from peewee import ForeignKeyField, FloatField, Check, CharField
from .base import BaseModel from .base import BaseModel
from .flavor import Flavor from .flavor import Flavor
@ -11,6 +11,12 @@ class RecipeFlavor(BaseModel):
recipe = ForeignKeyField(Recipe, backref="ingredients", on_delete="CASCADE") recipe = ForeignKeyField(Recipe, backref="ingredients", on_delete="CASCADE")
flavor = ForeignKeyField(Flavor, backref="recipes", on_delete="RESTRICT") flavor = ForeignKeyField(Flavor, backref="recipes", on_delete="RESTRICT")
pct = FloatField(constraints=[Check("pct >= 0 AND pct <= 100")]) 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: class Meta:
table_name = "recipe_flavor" 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, get_db,
apply_migrations, apply_migrations,
) )
import funkyjuicerecipes.data.db as dbmod
class DummyPaths(SimpleNamespace): class DummyPaths(SimpleNamespace):
@ -31,6 +32,20 @@ class DummyApp(SimpleNamespace):
self.paths.data = data_dir 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]: def _tables(conn: sqlite3.Connection) -> set[str]:
cur = conn.execute( cur = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" "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): def test_init_database_creates_file_and_tables(tmp_path: Path):
_cleanup_pkg_db()
app = DummyApp(tmp_path / "data") app = DummyApp(tmp_path / "data")
db = init_database(app) db = init_database(app)
db_path = Path(app.paths.data) / DB_FILENAME db_path = _pkg_db_path()
assert db_path.exists(), "DB file should be created in app.paths.data" assert db_path.exists(), "DB file should be created next to the package (data folder)"
# Use sqlite3 to introspect tables # Use sqlite3 to introspect tables
with sqlite3.connect(db_path) as conn: with sqlite3.connect(str(db_path)) as conn:
names = _tables(conn) names = _tables(conn)
# migrations + our 3 domain tables # migrations + our domain tables (including nicotine)
assert {"migrations", "flavor", "recipe", "recipe_flavor"}.issubset(names) assert {"migrations", "flavor", "recipe", "recipe_flavor", "nicotine"}.issubset(names)
# migrations table should have entries for all applied migrations # migrations table should have entries for all applied migrations
cur = conn.execute("SELECT filename, batch FROM migrations ORDER BY filename") cur = conn.execute("SELECT filename, batch FROM migrations ORDER BY filename")
rows = cur.fetchall() 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] filenames = [r[0] for r in rows]
# Ensure expected filenames are present # Ensure expected filenames are present
assert filenames == sorted(filenames) assert filenames == sorted(filenames)
assert any("flavor_table_created" in f for f in 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_table_created" in f for f in filenames)
assert any("recipe_flavor_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} batches = {r[1] for r in rows}
assert len(batches) == 1 assert len(batches) == 1
def test_apply_migrations_is_idempotent(tmp_path: Path): def test_apply_migrations_is_idempotent(tmp_path: Path):
_cleanup_pkg_db()
app = DummyApp(tmp_path / "data2") app = DummyApp(tmp_path / "data2")
db = init_database(app) db = init_database(app)
@ -75,9 +95,9 @@ def test_apply_migrations_is_idempotent(tmp_path: Path):
pending = apply_migrations(db) pending = apply_migrations(db)
assert pending == [] assert pending == []
# migrations count remains 3 # migrations count remains stable after re-run
db_path = Path(app.paths.data) / DB_FILENAME db_path = _pkg_db_path()
with sqlite3.connect(db_path) as conn: with sqlite3.connect(str(db_path)) as conn:
cur = conn.execute("SELECT COUNT(*) FROM migrations") cur = conn.execute("SELECT COUNT(*) FROM migrations")
(count,) = cur.fetchone() (count,) = cur.fetchone()
assert count == 3 assert count >= 3

View file

@ -8,10 +8,12 @@ import pytest
from peewee import IntegrityError, DoesNotExist from peewee import IntegrityError, DoesNotExist
from funkyjuicerecipes.data.db import init_database, DB_FILENAME 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.flavors import FlavorRepository
from funkyjuicerecipes.data.dao.recipes import RecipeRepository from funkyjuicerecipes.data.dao.recipes import RecipeRepository
from funkyjuicerecipes.data.models.flavor import Flavor from funkyjuicerecipes.data.models.flavor import Flavor
from funkyjuicerecipes.data.models.recipe_flavor import RecipeFlavor from funkyjuicerecipes.data.models.recipe_flavor import RecipeFlavor
from funkyjuicerecipes.data.models.flavor import Flavor as FlavorModel
class DummyPaths(SimpleNamespace): class DummyPaths(SimpleNamespace):
@ -31,7 +33,23 @@ class DummyApp(SimpleNamespace):
self.paths.data = data_dir 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): def _init_db(tmp_path: Path):
# Ensure a clean package-scoped DB for each test
_cleanup_pkg_db()
app = DummyApp(tmp_path / "data") app = DummyApp(tmp_path / "data")
db = init_database(app) db = init_database(app)
return app, db return app, db
@ -84,7 +102,7 @@ def test_flavor_soft_delete_and_listing_filters(tmp_path: Path):
size_ml=30, size_ml=30,
base_pg_pct=50, base_pg_pct=50,
base_vg_pct=50, base_vg_pct=50,
nic_pct=0, nic_target_mg_per_ml=0,
nic_base="PG", nic_base="PG",
ingredients=[(f1.id, 5.0)], ingredients=[(f1.id, 5.0)],
) )
@ -113,7 +131,7 @@ def test_recipe_creation_and_get_with_ingredients_includes_soft_deleted_flavors(
size_ml=60, size_ml=60,
base_pg_pct=70, base_pg_pct=70,
base_vg_pct=30, base_vg_pct=30,
nic_pct=3, nic_target_mg_per_ml=3,
nic_base="PG", nic_base="PG",
ingredients=[(fa.id, 4.0), (fb.id, 2.0)], 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, size_ml=30,
base_pg_pct=50, base_pg_pct=50,
base_vg_pct=50, base_vg_pct=50,
nic_pct=0, nic_target_mg_per_ml=0,
nic_base="PG", nic_base="PG",
ingredients=[(f1.id, 3.0), (f2.id, 2.0)], 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( assert RecipeFlavor.select().where(
(RecipeFlavor.recipe == r.id) & (RecipeFlavor.flavor == f3.id) (RecipeFlavor.recipe == r.id) & (RecipeFlavor.flavor == f3.id)
).exists() ).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 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(): def test_main_returns_app_instance():
app = main() app = main()
assert isinstance(app, FunkyJuiceRecipesApp) assert isinstance(app, FunkyJuiceRecipesApp)

583
uv.lock
View file

@ -9,6 +9,201 @@ supported-markers = [
"sys_platform == 'linux'", "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]] [[package]]
name = "exceptiongroup" name = "exceptiongroup"
version = "1.3.1" version = "1.3.1"
@ -26,6 +221,7 @@ name = "funkyjuicerecipes"
version = "0.1.0" version = "0.1.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "briefcase", marker = "sys_platform == 'linux'" },
{ name = "peewee", marker = "sys_platform == 'linux'" }, { name = "peewee", marker = "sys_platform == 'linux'" },
{ name = "pytest", marker = "sys_platform == 'linux'" }, { name = "pytest", marker = "sys_platform == 'linux'" },
{ name = "toga", marker = "sys_platform == 'linux'" }, { name = "toga", marker = "sys_platform == 'linux'" },
@ -33,11 +229,94 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "briefcase", specifier = ">=0.3.26" },
{ name = "peewee", specifier = ">=3.18.3" }, { name = "peewee", specifier = ">=3.18.3" },
{ name = "pytest", specifier = ">=9.0.2" }, { name = "pytest", specifier = ">=9.0.2" },
{ name = "toga", specifier = "==0.5.3" }, { 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]] [[package]]
name = "iniconfig" name = "iniconfig"
version = "2.3.0" 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" }, { 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]] [[package]]
name = "packaging" name = "packaging"
version = "25.0" version = "25.0"
@ -62,6 +424,24 @@ version = "3.18.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/60/58e7a307a24044e0e982b99042fcd5a58d0cd928d9c01829574d7553ee8d/peewee-3.18.3.tar.gz", hash = "sha256:62c3d93315b1a909360c4b43c3a573b47557a1ec7a4583a71286df2a28d4b72e", size = 3026296, upload-time = "2025-11-03T16:43:46.678Z" } sdist = { url = "https://files.pythonhosted.org/packages/6f/60/58e7a307a24044e0e982b99042fcd5a58d0cd928d9c01829574d7553ee8d/peewee-3.18.3.tar.gz", hash = "sha256:62c3d93315b1a909360c4b43c3a573b47557a1ec7a4583a71286df2a28d4b72e", size = 3026296, upload-time = "2025-11-03T16:43:46.678Z" }
[[package]]
name = "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]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" 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" }, { 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]] [[package]]
name = "pycairo" name = "pycairo"
version = "1.29.0" 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" } 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]] [[package]]
name = "pytest" name = "pytest"
version = "9.0.2" 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" }, { 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]] [[package]]
name = "toga" name = "toga"
version = "0.5.3" 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" }, { 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]] [[package]]
name = "travertino" name = "travertino"
version = "0.5.3" 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" }, { 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]] [[package]]
name = "typing-extensions" name = "typing-extensions"
version = "4.15.0" version = "4.15.0"
@ -197,3 +744,39 @@ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac8
wheels = [ 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" }, { 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" },
]