diff --git a/recipes.py b/recipes.py index ebaa963..37ef45f 100644 --- a/recipes.py +++ b/recipes.py @@ -2,7 +2,7 @@ from abc import abstractmethod from collections import defaultdict import collections import sys -from typing import Dict, List, Any, Optional, Set +from typing import Dict, List, Any, Optional, Self, Set import os import json @@ -51,7 +51,7 @@ class Context: def __init__(self) -> None: self.settings = Settings() self.units: AUnits = FakeUnits(self) - self.default_unit = Unit(self, {"name": "piece"}) + self.default_unit = Unit(self, "piece", [], []) self.ingredients: AIngredients = FakeIngredients(self) self.issues = Issues() @@ -87,68 +87,76 @@ class Settings: class Element: - def __init__(self, ctx: Context, dct: Dict[str, Any]) -> None: + def __init__(self, ctx: Context) -> None: self.ctx = ctx - self.dct = dct - self.load(dct) - for elem in self.dct.values(): - if isinstance(elem, dict): - raise RuntimeError("Something wasn't processed properly") - def __contains__(self, item: Any) -> bool: - return item in self.dct - - def __getitem__(self, key: str) -> Any: - return self.dct[key] - - def __setitem__(self, key: str, val: Any) -> None: - self.dct[key] = val - - def __repr__(self) -> str: - return self.dct.__repr__() - - @abstractmethod - def load(self, dct: Dict[str, Any]) -> None: - ... + @classmethod + def from_dict(cls, ctx: Context, dct: Dict[str, Any]) -> Self: + return cls(ctx) class Conversion(Element): - def load(self, dct: Dict[str, Any]) -> None: - fromunit = self.ctx.units.get(dct["from"]) + def __init__( + self, ctx: Context, fromunit: "Unit", tounit: "Unit", ratio: float + ) -> None: + super().__init__(ctx) + self.fromunit = fromunit + self.tounit = tounit + self.ratio = ratio + + @classmethod + def from_dict(cls, ctx: Context, dct: Dict[str, Any]) -> Self: + fromunit = ctx.units.get(dct["from"]) if fromunit is None: raise RuntimeError(f"unit {dct['from']} doesn't exist") - self["from"] = fromunit - tounit = self.ctx.units.get(dct["to"]) + tounit = ctx.units.get(dct["to"]) if tounit is None: raise RuntimeError(f"unit {dct['to']} doesn't exist") - self["to"] = tounit + return cls(ctx, fromunit, tounit, dct["ratio"]) class Unit(Element): - def load(self, dct: Dict[str, Any]) -> None: - oldunits = self.ctx.units.units[:] - self.ctx.units.units.append(self) + def __init__( + self, + ctx: Context, + name: str, + conversions: List[Conversion], + aliases: List[str], + dct: Optional[Dict[str, Any]] = None, + ) -> None: + super().__init__(ctx) + self.name = name + self.conversions = conversions + self.aliases = aliases + self.dct = dct + @classmethod + def from_dict(cls, ctx: Context, dct: Dict[str, Any]) -> Self: + # conversions are an empty list which is populated in finish_load + # which should be ran when all units are registered aliases: List[str] = [] if "aliases" in dct: - for alias in dct["aliases"]: - aliases.append(alias) - self["aliases"] = aliases - self.ctx.units.units = oldunits + aliases = dct["aliases"] + + return cls(ctx, name=dct["name"], conversions=[], aliases=aliases, dct=dct) def finish_load(self) -> None: - conversions: List[Conversion] = [] - if "conversions" in self.dct: - for convdct in self.dct["conversions"]: - if "from" in self.dct["conversions"]: - raise RuntimeError( - "conversions in units.yaml cannot have a from field, it is automatically assigned from the unit name" - ) - convdct["from"] = self["name"] - conversion = Conversion(self.ctx, convdct) - conversions.append(conversion) - self["conversions"] = conversions + if self.dct is None: + return + if "conversions" not in self.dct: + return + conversions = [] + for conv in self.dct["conversions"]: + if "from" in conv: + raise RuntimeError( + "conversions in units.yaml cannot have a from field, it is automatically assigned from the unit name" + ) + conv["from"] = self.dct["name"] + conv = Conversion.from_dict(self.ctx, conv) + conversions.append(conv) + + self.conversions = conversions class AUnits: @@ -170,9 +178,9 @@ class AUnits: class FakeUnits(AUnits): def get(self, name: str) -> Optional[Unit]: for unit in self.units: - if unit["name"] == name: + if unit.name == name: return unit - unit = Unit(self.ctx, {"name": name}) + unit = Unit(self.ctx, name, [], []) self.units.append(unit) return unit @@ -180,21 +188,21 @@ class FakeUnits(AUnits): class Units(AUnits): def load(self, lst: List[Any]) -> None: for unitdct in lst: - unit = Unit(self.ctx, unitdct) + unit = Unit.from_dict(self.ctx, unitdct) self.units.append(unit) for unit in self.units: unit.finish_load() def get(self, name: str) -> Optional[Unit]: for unit in self.units: - if unit["name"] == name or "aliases" in unit and name in unit["aliases"]: + if unit.name == name or name in unit.aliases: return unit return None def validate(self) -> None: unitnames = [] for unit in self.units: - unitnames.append(unit["name"]) + unitnames.append(unit.name) for unitname, num in collections.Counter(unitnames).items(): if num > 1: self.ctx.issues.error( @@ -204,18 +212,51 @@ class Units(AUnits): class Ingredient(Element): - def load(self, dct: Dict[str, Any]) -> None: - if "prices" in dct: - pricedb = PriceDBs(self.ctx) - pricedb.load(dct["prices"]) - self["prices"] = pricedb + def __init__( + self, + ctx: Context, + name: str, + wdid: Optional[int], + prices: List["PriceDB"], + conversions: List[Conversion], + aliases: List[str], + ) -> None: + super().__init__(ctx) + self.name = name + self.wdid = wdid + self.prices = prices + self.conversions = conversions + self.aliases = aliases - conversions = [] + @classmethod + def from_dict(cls, ctx: Context, dct: Dict[str, Any]) -> Self: + wdid = None + if "wdid" in dct: + wdid = dct["wdid"] + + prices: List[PriceDB] = [] + if "prices" in dct: + for pricedct in dct["prices"]: + prices.append(PriceDB.from_dict(ctx, pricedct)) + + conversions: List[Conversion] = [] if "conversions" in dct: for convdct in dct["conversions"]: - conversion = Conversion(self.ctx, convdct) + conversion = Conversion.from_dict(ctx, convdct) conversions.append(conversion) - self["conversions"] = conversions + + aliases: List[str] = [] + if "aliases" in dct: + aliases = dct["aliases"] + + return cls( + ctx, + name=dct["name"], + wdid=wdid, + prices=prices, + conversions=conversions, + aliases=aliases, + ) def dfs( self, @@ -240,21 +281,21 @@ class Ingredient(Element): def convert(self, amount: float, unitfrom: Unit, unitto: Unit) -> Optional[float]: conversions: Dict[str, Dict[str, float]] = defaultdict(dict) # construct node tree - convs = self["conversions"] + convs = self.conversions for unit in self.ctx.units.units: - convs += unit["conversions"] + convs += unit.conversions for conv in convs: - fromname = conv["from"]["name"] - toname = conv["to"]["name"] - conversions[fromname][toname] = conv["ratio"] - conversions[toname][fromname] = 1 / conv["ratio"] + fromname = conv.fromunit.name + toname = conv.tounit.name + conversions[fromname][toname] = conv.ratio + conversions[toname][fromname] = 1 / conv.ratio # find path between conversions - path = self.dfs(conversions, unitfrom["name"], unitto["name"]) + path = self.dfs(conversions, unitfrom.name, unitto.name) if path is None: self.ctx.issues.warn( ISSUE_KNOWN_PRICE_UNKNOWN_CONVERSION, - f'{self["name"]} has a known price, but conversion {unitfrom["name"]} -> {unitto["name"]} not known', + f"{self.name} has a known price, but conversion {unitfrom.name} -> {unitto.name} not known", ) return None assert len(path) != 0 @@ -265,15 +306,13 @@ class Ingredient(Element): return amount def getprice(self, amount: float, unit: Unit) -> Optional["PriceDB"]: - if "prices" not in self.dct: - return None prices: List[PriceDB] = [] - pricedbs: PriceDBs = self["prices"] - for entry in pricedbs.pricedbs: + pricedbs: List[PriceDB] = self.prices + for entry in pricedbs: assert isinstance(entry, PriceDB) - entryamount: float = entry["amount"] - entryprice: float = entry["price"] - entryunit: Unit = entry["unit"] + entryamount: float = entry.amount + entryprice: float = entry.price + entryunit: Unit = entry.unit price = 0.0 if entryunit == unit: price = (amount / entryamount) * entryprice @@ -283,12 +322,10 @@ class Ingredient(Element): price = (pricex / entryamount) * entryprice newentry = PriceDB( self.ctx, - { - "price": price, - "amount": amount, - "unit": unit["name"], - "currency": entry["currency"], - }, + price=price, + amount=amount, + unit=unit, + currency=entry.currency, ) prices.append(newentry) if len(prices) == 0: @@ -313,9 +350,9 @@ class AIngredients: class FakeIngredients(AIngredients): def get(self, name: str) -> Optional[Ingredient]: for ing in self.ingredients: - if ing["name"] == name: + if ing.name == name: return ing - ing = Ingredient(self.ctx, {"name": name}) + ing = Ingredient(self.ctx, name, None, [], [], []) self.ingredients.append(ing) return ing @@ -323,113 +360,164 @@ class FakeIngredients(AIngredients): class Ingredients(AIngredients): def load(self, lst: List[Any]) -> None: for ingdct in lst: - ing = Ingredient(self.ctx, ingdct) + ing = Ingredient.from_dict(self.ctx, ingdct) self.ingredients.append(ing) def get(self, name: str) -> Optional[Ingredient]: for ing in self.ingredients: - if ing["name"] == name or "aliases" in ing and name in ing["aliases"]: + if ing.name == name or name in ing.aliases: return ing return None -class PriceDBs: - def __init__(self, ctx: Context) -> None: - self.ctx = ctx - self.pricedbs: List[PriceDB] = [] - - def load(self, lst: List[Any]) -> None: - for elem in lst: - pricedb = PriceDB(self.ctx, elem) - self.pricedbs.append(pricedb) - - def __repr__(self) -> str: - return self.pricedbs.__repr__() - - class PriceDB(Element): - def load(self, dct: Dict[str, Any]) -> None: - if "amount" not in dct: - self["amount"] = 1.0 + def __init__( + self, + ctx: Context, + price: float, + amount: float, + unit: Unit, + currency: Optional[str], + ) -> None: + super().__init__(ctx) + self.price = price + self.amount = amount + self.unit = unit + self.currency = currency - if "unit" in dct: - unitstr = dct["unit"] - self["unit"] = self.ctx.units.get(unitstr) - if self["unit"] is None: - self.ctx.issues.error(ISSUE_UNKNOWN_UNIT, f"unknown unit {unitstr}") + @classmethod + def from_dict(cls, ctx: Context, dct: Dict[str, Any]) -> Self: + price = dct["price"] + amount = dct["amount"] + + unitstr = dct["unit"] + unitx = ctx.units.get(unitstr) + # uses the default unit if unit is not known (this won't be + # visible to the user since rendering will stop after + # everything is evaluated) + unit = ctx.default_unit + if unitx is None: + ctx.issues.error(ISSUE_UNKNOWN_UNIT, f"unknown unit {unitstr}") else: - self["unit"] = self.ctx.default_unit + unit = unitx - if "currency" not in dct: - self["currency"] = self.ctx.settings.default_currency + currency = ctx.settings.default_currency + if "currency" in dct: + currency = dct["currency"] + return cls(ctx=ctx, price=price, amount=amount, unit=unit, currency=currency) class IngredientInstance(Element): - def load(self, dct: Dict[str, Any]) -> None: - ingredient = self.ctx.ingredients.get(dct["name"]) + def __init__( + self, + ctx: Context, + name: str, + amount: float, + unit: Unit, + alternatives: List["IngredientInstance"], + note: str, + price: Optional[PriceDB], + ) -> None: + super().__init__(ctx) + self.name = name + self.amount = amount + self.unit = unit + self.alternatives = alternatives + self.note = note + self.price = price + + @classmethod + def from_dict(cls, ctx: Context, dct: Dict[str, Any]) -> Self: + name = dct["name"] + + ingredient = ctx.ingredients.get(name) if ingredient is None: - self.ctx.issues.error( + ctx.issues.error( ISSUE_UNKNOWN_INGREDIENT, f"unknown ingredient {dct['name']}" ) - self["ingredient"] = ingredient - if "amount" not in dct: - self["amount"] = 1.0 + amount = 1.0 + if "amount" in dct: + amount = dct["amount"] + unit = ctx.default_unit if "unit" in dct: unitstr = dct["unit"] - self["unit"] = self.ctx.units.get(unitstr) - if self["unit"] is None: - self.ctx.issues.error(ISSUE_UNKNOWN_UNIT, "unknown unit {unitstr}") - else: - self["unit"] = self.ctx.default_unit + unitx = ctx.units.get(unitstr) + if unitx is None: + ctx.issues.error(ISSUE_UNKNOWN_UNIT, "unknown unit {unitstr}") + else: + unit = unitx - if "note" not in dct: - self["note"] = "" + note = "" + if "note" in dct: + note = dct["note"] alternatives = [] if "or" in dct: for ingdct in dct["or"]: - ing = IngredientInstance(self.ctx, ingdct) + ing = IngredientInstance.from_dict(ctx, ingdct) alternatives.append(ing) - self["alternatives"] = alternatives + price = None if ingredient is not None: - self["price"] = ingredient.getprice(self["amount"], self["unit"]) + price = ingredient.getprice(amount, unit) + return cls( + ctx=ctx, + name=name, + amount=amount, + unit=unit, + alternatives=alternatives, + note=note, + price=price, + ) class Recipe(Element): - def __init__(self, ctx: Context, dct: Dict[str, Any]) -> None: - super().__init__(ctx, dct) + def __init__( + self, + ctx: Context, + title: str, + ingredients: List[IngredientInstance], + subrecipes: List["Recipe"], + price: Optional[PriceDB], + steps: List[str], + ) -> None: + super().__init__(ctx) self.srcpath = "" self.outpath = "" + self.title = title + self.ingredients = ingredients + self.subrecipes = subrecipes + self.price = price + self.steps = steps - def load(self, dct: Dict[str, Any]) -> None: + @classmethod + def from_dict(cls, ctx: Context, dct: Dict[str, Any]) -> Self: ingredients: List[IngredientInstance] = [] if "ingredients" in dct: - for ing in dct["ingredients"]: - ingredient = IngredientInstance(self.ctx, ing) + for ingdct in dct["ingredients"]: + ingredient = IngredientInstance.from_dict(ctx, ingdct) ingredients.append(ingredient) - self["ingredients"] = ingredients subrecipes: List[Recipe] = [] if "subrecipes" in dct: for partdct in dct["subrecipes"]: - rp = Recipe(self.ctx, partdct) + rp = Recipe.from_dict(ctx, partdct) subrecipes.append(rp) - self["subrecipes"] = subrecipes - price: Optional[int] = 0 + price: Optional[PriceDB] = None + pricex: float = 0 ingswithprice = 0 ingswithoutprice = 0 currency = None for ing in ingredients: - if ing["price"] is None: + if ing.price is None: ingswithoutprice += 1 continue ingswithprice += 1 - price += ing["price"]["price"] - cur_currency = ing["price"]["currency"] + pricex += ing.price.price + cur_currency = ing.price.currency if currency is None: currency = cur_currency elif currency != cur_currency: @@ -437,9 +525,27 @@ class Recipe(Element): currency = None break if currency is None or ingswithoutprice != 0 or len(ingredients) == 0: - self["price"] = None + price = None else: - self["price"] = PriceDB(self.ctx, {"price": price, "currency": currency}) + price = PriceDB( + ctx=ctx, + price=pricex, + amount=1, + unit=ctx.default_unit, + currency=currency, + ) + + steps = [] + if "steps" in dct: + steps = dct["steps"] + return cls( + ctx=ctx, + title=dct["title"], + ingredients=ingredients, + subrecipes=subrecipes, + price=price, + steps=steps, + ) class Builder: @@ -533,7 +639,7 @@ class Builder: continue recipedct = self.load_file(dir + "/recipes/" + file) jsonschema.validate(instance=recipedct, schema=recipeschema) - recipe = Recipe(self.ctx, recipedct) + recipe = Recipe.from_dict(self.ctx, recipedct) recipe.srcpath = file recipe.outpath = file[:-5] + ".html" if self.ctx.issues.check() != 0: diff --git a/schemas/ingredients.json b/schemas/ingredients.json index c94c6af..48d6f60 100644 --- a/schemas/ingredients.json +++ b/schemas/ingredients.json @@ -19,7 +19,7 @@ "required": [ "price", "amount", "unit" ], "properties": { "price": { "type": "number" }, - "amount": { "type": "integer" }, + "amount": { "type": "number" }, "unit": { "type": "string" }, "currency": { "type": "string" } }