from typing import Dict, List, Any, Optional, Set import os import yaml import jinja2 class Context: def __init__(self) -> None: self.units = Units() self.ingredients = Ingredients(self) class Unit: def __init__(self, name: str) -> None: self.name = name class Units: def __init__(self) -> None: self.units: List[Unit] = [] def load(self, lst: List[Any]) -> List[str]: assert_list(lst) for unit in lst: assert_type(unit, "", str) self.units.append(Unit(unit)) return [] def get(self, name: str) -> Optional[Unit]: for unit in self.units: if unit.name == name: return unit raise RuntimeError(f"unit {name} not found") class Ingredient: def __init__(self, ctx: Context) -> None: self.ctx = ctx def load(self, dct: Dict[str, Any]) -> List[str]: issues = [] issues += assert_dict(dct, ["name"], ["wdid", "pricedb", "aliases"]) assert_type(dct, "name", str) self.name = dct["name"] self.wdid = -1 if "wdid" in dct: assert_type(dct, "wdid", int) self.wdid = dct["wdid"] self.pricedb = None if "pricedb" in dct: assert_list(dct["pricedb"]) self.pricedb = PriceDBs(self.ctx) issues += self.pricedb.load(dct["pricedb"]) self.aliases = [] if "aliases" in dct: assert_list(dct["aliases"]) for elem in dct["aliases"]: assert_type(elem, "", str) self.aliases.append(elem) return issues class Ingredients: def __init__(self, ctx: Context) -> None: self.ctx = ctx self.ingredients: List[Ingredient] = [] def load(self, lst: List[Any]) -> List[str]: issues = [] assert_list(lst) for ingdct in lst: ing = Ingredient(self.ctx) issues += ing.load(ingdct) self.ingredients.append(ing) return issues def get(self, name: str) -> Optional[Ingredient]: for ing in self.ingredients: if ing.name == name or name in ing.aliases: return ing raise RuntimeError(f"ingredient {name} not found") class PriceDBs: def __init__(self, ctx: Context) -> None: self.ctx = ctx self.pricedbs: List[PriceDB] = [] def load(self, lst: List[Any]) -> List[str]: issues = [] assert_list(lst) for elem in lst: pricedb = PriceDB(self.ctx) issues += pricedb.load(elem) self.pricedbs.append(pricedb) return issues class PriceDB: def __init__(self, ctx: Context) -> None: self.ctx = ctx def load(self, dct: Dict[str, Any]) -> List[str]: issues = [] issues += assert_dict(dct, ["price"], ["amount", "unit"]) if isinstance(dct["price"], float): self.price = dct["price"] elif isinstance(dct["price"], int): self.price = float(dct["price"]) else: raise RuntimeError(f"{dct['price']} has to be int or float") self.amount = 1.0 if "amount" in dct: if isinstance(dct["amount"], float): self.amount = dct["amount"] elif isinstance(dct["amount"], int): self.amount = float(dct["amount"]) else: raise RuntimeError(f"{dct['amount']} has to be int or float") self.unit = None if "unit" in dct: assert_type(dct, "unit", str) try: self.unit = self.ctx.units.get(dct["unit"]) except RuntimeError as e: issues.append(str(e)) return issues class IngredientInstance: def __init__( self, ctx: Context, defaultamount: float = 1, defaultunit: Optional[Unit] = None ) -> None: self.ctx = ctx self.defaultamount = float(defaultamount) self.defaultunit: Optional[Unit] = defaultunit def load(self, dct: Dict[str, Any]) -> List[str]: issues = [] issues += assert_dict(dct, ["name"], ["amount", "unit", "note", "or"]) assert_type(dct, "name", str) self.name = dct["name"] try: self.ingredient = self.ctx.ingredients.get(self.name) except RuntimeError as e: issues.append(str(e)) self.amount = self.defaultamount if "amount" in dct: if isinstance(dct["amount"], float): self.amount = dct["amount"] elif isinstance(dct["amount"], int): self.amount = float(dct["amount"]) else: raise RuntimeError(f"{dct['amount']} has to be int or float") self.unit = self.defaultunit if "unit" in dct: assert_type(dct, "unit", str) try: self.unit = self.ctx.units.get(dct["unit"]) except RuntimeError as e: issues.append(str(e)) self.note = "" if "note" in dct: assert_type(dct, "note", str) self.note = dct["note"] self.alternatives = [] if "or" in dct: assert_list(dct["or"]) for ingdct in dct["or"]: ingredient = IngredientInstance( self.ctx, defaultamount=self.amount, defaultunit=self.unit ) ingredient.load(ingdct) self.alternatives.append(ingredient) return issues class RecipePart: def __init__(self, ctx: Context) -> None: self.ctx = ctx def load(self, dct: Dict[str, Any]) -> List[str]: issues = [] issues += assert_dict(dct, ["title", "ingredients", "steps"], []) assert_type(dct, "title", str) self.title = dct["title"] assert_type(dct, "ingredients", list) self.ingredients: List[IngredientInstance] = [] for ing in dct["ingredients"]: ingredient = IngredientInstance(self.ctx) issues += ingredient.load(ing) self.ingredients.append(ingredient) assert_type(dct, "steps", list) for elem in dct["steps"]: assert_type(elem, "", str) self.steps: List[str] = dct["steps"] return issues class Recipe: def __init__(self, ctx: Context) -> None: self.ctx = ctx self.parts: List[RecipePart] = [] self.srcpath = "" self.outpath = "" self.title = "" def load(self, dct: Dict[str, Any]) -> List[str]: issues: List[str] = [] if "parts" in dct: assert_dict(dct, ["title"], []) assert_type(dct, "title", str) self.title = dct["title"] assert_list(dct["parts"]) for partdct in dct["parts"]: rp = RecipePart(self.ctx) issues += rp.load(partdct) self.parts.append(rp) else: rp = RecipePart(self.ctx) issues = rp.load(dct) self.parts = [rp] self.title = rp.title return issues def assert_dict( dct: Dict[str, Any], required_keys: List[str], optional_keys: List[str] ) -> List[str]: issues = [] if not isinstance(dct, dict): raise RuntimeError(f"{dct} has to be a dict") for reqkey in required_keys: if reqkey not in dct: issues.append(f"{reqkey} is required") extraelems = [x for x in dct.keys() if x not in required_keys + optional_keys] if len(extraelems) != 0: issues.append(f"{extraelems} not allowed") return issues def assert_type(dct: Dict[str, Any], key: str, type: type) -> None: if key == "": if not isinstance(dct, type): raise RuntimeError(f"{key} has to be a {type}") elif key in dct and not isinstance(dct[key], type): raise RuntimeError(f"{key} has to be a {type}") def assert_list(lst: List[Any]) -> None: if not isinstance(lst, list): raise RuntimeError(f"{lst} has to be a {list}") class Builder: def __init__(self) -> None: self.jinjaenv = jinja2.Environment( loader=jinja2.FileSystemLoader("templates"), autoescape=jinja2.select_autoescape(), ) def numprint(input: int) -> str: out = str(input) if out.endswith(".0"): out = out.split(".")[0] if out == "0.5": return "1/2" elif out == "0.25": return "1/4" elif out == "0.75": return "3/4" return out self.jinjaenv.filters['numprint'] = numprint self.ctx = Context() # list of output files that will be built self.outfiles: Set[str] = set() def load_file(self, file: str) -> Any: print(f"loading {file}") with open(file, encoding="utf-8") as f: ymltxt = f.read() return yaml.safe_load(ymltxt) def rendertemplate( self, templatepath: str, format: str, file: str, args: Any ) -> None: template = self.jinjaenv.get_template(templatepath) print(f"rendering {file}") outstr = template.render(args) os.makedirs(f"out/{format}", exist_ok=True) with open(f"out/{format}/{file}", "w", encoding="utf-8") as f: f.write(outstr) self.outfiles.add(file) def load(self) -> None: issues: List[str] = [] unitsdct = self.load_file("recipes/units.yaml") issues += self.ctx.units.load(unitsdct) ingredientsdct = self.load_file("recipes/ingredients.yaml") issues += self.ctx.ingredients.load(ingredientsdct) if len(issues) != 0: for issue in issues: print("ERROR", issue) return def run(self) -> None: issues = [] files = [] for _, _, filesx in os.walk("recipes/recipes"): files = filesx files.sort() recipes: List[Recipe] = [] for file in files: if not file.endswith(".yaml"): print(f"unknown extension of {file}") continue recipe = Recipe(self.ctx) recipedct = self.load_file("recipes/recipes/" + file) issues += recipe.load(recipedct) recipe.srcpath = file recipe.outpath = file[:-5] + ".html" recipes.append(recipe) if len(issues) != 0: for issue in issues: print("ERROR", issue) return self.rendertemplate("index.html", "html", "index.html", {"recipes": recipes}) for recipe in recipes: self.rendertemplate( "recipe.html", "html", recipe.outpath, {"recipe": recipe} ) def finish(self) -> None: files = set() for _, _, filesx in os.walk("out/html"): files = set(filesx) # files we did not generate, probably left by a previous run, but not valid anymore extra_files = files - self.outfiles for file in extra_files: print(f"removing obsolete {file}") os.remove(f"out/html/{file}") def main(self) -> None: self.load() self.run() self.finish() if __name__ == "__main__": builder = Builder() builder.main()