diff --git a/recipes.py b/recipes.py index ec60273..f5f8b10 100644 --- a/recipes.py +++ b/recipes.py @@ -1,349 +1,349 @@ -from abc import abstractmethod -import sys -from typing import Dict, List, Any, Optional, Set -import os -import json - -import yaml -import jinja2 -import jsonschema -import jsonschema.exceptions - - -class Context: - def __init__(self) -> None: - self.units = Units(self) - self.ingredients = Ingredients(self) - self.issues: List[str] = [] - - -class Element: - def __init__(self, ctx: Context, dct: Dict[str, Any]) -> 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: - ... - - -class Conversion(Element): - def load(self, dct: Dict[str, Any]) -> None: - def find_unit(name: str) -> Optional[Unit]: - for unit in self.ctx.units.units: - if unit["name"] == name: - return unit - return None - - fromunit = find_unit(dct["from"]) - if fromunit is None: - raise RuntimeError(f"unit {fromunit} doesn't exist") - self["from"] = fromunit - - tounit = find_unit(dct["to"]) - if tounit is None: - raise RuntimeError(f"unit {tounit} doesn't exist") - self["to"] = tounit - - -class Unit(Element): - def load(self, dct: Dict[str, Any]) -> None: - oldunits = self.ctx.units.units[:] - self.ctx.units.units.append(self) - - conversions: List[Conversion] = [] - if "conversions" in dct: - for convdct in dct["conversions"]: - if "from" in 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 - - aliases: List[str] = [] - if "aliases" in dct: - for alias in dct["aliases"]: - aliases.append(alias) - self["aliases"] = aliases - self.ctx.units.units = oldunits - - -class Units: - def __init__(self, ctx: Context) -> None: - self.ctx = ctx - self.units: List[Unit] = [] - - def load(self, lst: List[Any]) -> None: - for unitdct in lst: - unit = Unit(self.ctx, unitdct) - self.units.append(unit) - - 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"]: - return unit - return None - - -class Ingredient(Element): - def load(self, dct: Dict[str, Any]) -> None: - if "pricedb" in dct: - pricedb = PriceDBs(self.ctx) - self.ctx.issues += pricedb.load(dct["pricedb"]) - self["pricedb"] = pricedb - - conversions = [] - if "conversions" in dct: - for convdct in dct["conversions"]: - conversion = Conversion(self.ctx, convdct) - conversions.append(conversion) - self["conversions"] = conversions - - -class Ingredients: - def __init__(self, ctx: Context) -> None: - self.ctx = ctx - self.ingredients: List[Ingredient] = [] - - def load(self, lst: List[Any]) -> None: - for ingdct in lst: - ing = Ingredient(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"]: - 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]) -> List[str]: - for elem in lst: - pricedb = PriceDB(self.ctx, elem) - self.pricedbs.append(pricedb) - return self.ctx.issues - - -class PriceDB(Element): - def load(self, dct: Dict[str, Any]) -> None: - if "amount" not in dct: - self["amount"] = 1.0 - - if "unit" in dct: - unitstr = dct["unit"] - self["unit"] = self.ctx.units.get(unitstr) - if self["unit"] is None: - self.ctx.issues.append(f"unknown unit {unitstr}") - else: - self["unit"] = None - - -class IngredientInstance(Element): - def load(self, dct: Dict[str, Any]) -> None: - self["ingredient"] = self.ctx.ingredients.get(dct["name"]) - if self["ingredient"] is None: - self.ctx.issues.append(f"unknown ingredient {dct['name']}") - - if "amount" not in dct: - self["amount"] = 1.0 - - if "unit" in dct: - unitstr = dct["unit"] - self["unit"] = self.ctx.units.get(unitstr) - if self["unit"] is None: - self.ctx.issues.append("unknown unit {unitstr}") - else: - self["unit"] = None - - if "note" not in dct: - self["note"] = "" - - alternatives = [] - if "or" in dct: - for ingdct in dct["or"]: - ingredient = IngredientInstance(self.ctx, ingdct) - alternatives.append(ingredient) - self["alternatives"] = alternatives - - -class Recipe(Element): - def __init__(self, ctx: Context, dct: Dict[str, Any]) -> None: - super().__init__(ctx, dct) - self.srcpath = "" - self.outpath = "" - - def load(self, dct: Dict[str, Any]) -> None: - ingredients: List[IngredientInstance] = [] - if "ingredients" in dct: - for ing in dct["ingredients"]: - ingredient = IngredientInstance(self.ctx, ing) - ingredients.append(ingredient) - self["ingredients"] = ingredients - - subrecipes: List[Recipe] = [] - if "subrecipes" in dct: - for partdct in dct["subrecipes"]: - rp = Recipe(self.ctx, partdct) - subrecipes.append(rp) - self["subrecipes"] = subrecipes - - -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"): - return out.split(".", maxsplit=1)[0] - if out == "0.5": - return "1/2" - if out == "0.25": - return "1/4" - if 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: - txt = f.read() - if file.endswith(".json"): - return json.loads(txt) - return yaml.safe_load(txt) - - def rendertemplate( - self, templatepath: str, format: str, file: str, dir: str, args: Any - ) -> None: - template = self.jinjaenv.get_template(templatepath) - print(f"rendering {file}") - outstr = template.render(args) - - os.makedirs(f"{dir}/out/{format}", exist_ok=True) - - with open(f"{dir}/out/{format}/{file}", "w", encoding="utf-8") as f: - f.write(outstr) - self.outfiles.add(file) - - def load(self, dir: str) -> int: - unitsdct = self.load_file(dir + "/units.yaml") - unitsschema = self.load_file("schemas/units.json") - jsonschema.validate(instance=unitsdct, schema=unitsschema) - self.ctx.units.load(unitsdct) - if len(self.ctx.issues) != 0: - for issue in self.ctx.issues: - print("ERROR in units.yaml:", issue) - return 1 - - ingredientsdct = self.load_file(dir + "/ingredients.yaml") - ingredientsschema = self.load_file("schemas/ingredients.json") - jsonschema.validate(instance=ingredientsdct, schema=ingredientsschema) - self.ctx.ingredients.load(ingredientsdct) - if len(self.ctx.issues) != 0: - for issue in self.ctx.issues: - print("ERROR in ingredients.yaml:", issue) - return 1 - - return 0 - - def run(self, dir: str) -> int: - files = [] - for _, _, filesx in os.walk(dir + "/recipes"): - files = filesx - files.sort() - - recipes: List[Recipe] = [] - recipeschema = self.load_file("schemas/recipe.json") - for file in files: - if not file.endswith(".yaml"): - print(f"unknown extension of {file}") - continue - recipedct = self.load_file(dir + "/recipes/" + file) - jsonschema.validate(instance=recipedct, schema=recipeschema) - recipe = Recipe(self.ctx, recipedct) - recipe.srcpath = file - recipe.outpath = file[:-5] + ".html" - recipes.append(recipe) - - if len(self.ctx.issues) != 0: - for issue in self.ctx.issues: - print("ERROR", issue) - return 1 - - self.rendertemplate( - templatepath="index.html", - format="html", - file="index.html", - dir=dir, - args={"recipes": recipes}, - ) - for recipe in recipes: - self.rendertemplate( - templatepath="recipe.html", - format="html", - file=recipe.outpath, - dir=dir, - args={"recipe": recipe}, - ) - return 0 - - def finish(self, dir: str) -> int: - files = set() - for _, _, filesx in os.walk(f"{dir}/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"{dir}/out/html/{file}") - return 0 - - def build(self, path: str) -> int: - fcs = [self.load, self.run, self.finish] - for func in fcs: - try: - ret = func(path) - if ret != 0: - return ret - except jsonschema.exceptions.ValidationError as e: - print("ERROR:", e) - return 1 - return 0 - - -if __name__ == "__main__": - builder = Builder() - sys.exit(builder.build("recipes")) +from abc import abstractmethod +import sys +from typing import Dict, List, Any, Optional, Set +import os +import json + +import yaml +import jinja2 +import jsonschema +import jsonschema.exceptions + + +class Context: + def __init__(self) -> None: + self.units = Units(self) + self.ingredients = Ingredients(self) + self.issues: List[str] = [] + + +class Element: + def __init__(self, ctx: Context, dct: Dict[str, Any]) -> 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: + ... + + +class Conversion(Element): + def load(self, dct: Dict[str, Any]) -> None: + def find_unit(name: str) -> Optional[Unit]: + for unit in self.ctx.units.units: + if unit["name"] == name: + return unit + return None + + fromunit = find_unit(dct["from"]) + if fromunit is None: + raise RuntimeError(f"unit {fromunit} doesn't exist") + self["from"] = fromunit + + tounit = find_unit(dct["to"]) + if tounit is None: + raise RuntimeError(f"unit {tounit} doesn't exist") + self["to"] = tounit + + +class Unit(Element): + def load(self, dct: Dict[str, Any]) -> None: + oldunits = self.ctx.units.units[:] + self.ctx.units.units.append(self) + + conversions: List[Conversion] = [] + if "conversions" in dct: + for convdct in dct["conversions"]: + if "from" in 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 + + aliases: List[str] = [] + if "aliases" in dct: + for alias in dct["aliases"]: + aliases.append(alias) + self["aliases"] = aliases + self.ctx.units.units = oldunits + + +class Units: + def __init__(self, ctx: Context) -> None: + self.ctx = ctx + self.units: List[Unit] = [] + + def load(self, lst: List[Any]) -> None: + for unitdct in lst: + unit = Unit(self.ctx, unitdct) + self.units.append(unit) + + 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"]: + return unit + return None + + +class Ingredient(Element): + def load(self, dct: Dict[str, Any]) -> None: + if "pricedb" in dct: + pricedb = PriceDBs(self.ctx) + self.ctx.issues += pricedb.load(dct["pricedb"]) + self["pricedb"] = pricedb + + conversions = [] + if "conversions" in dct: + for convdct in dct["conversions"]: + conversion = Conversion(self.ctx, convdct) + conversions.append(conversion) + self["conversions"] = conversions + + +class Ingredients: + def __init__(self, ctx: Context) -> None: + self.ctx = ctx + self.ingredients: List[Ingredient] = [] + + def load(self, lst: List[Any]) -> None: + for ingdct in lst: + ing = Ingredient(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"]: + 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]) -> List[str]: + for elem in lst: + pricedb = PriceDB(self.ctx, elem) + self.pricedbs.append(pricedb) + return self.ctx.issues + + +class PriceDB(Element): + def load(self, dct: Dict[str, Any]) -> None: + if "amount" not in dct: + self["amount"] = 1.0 + + if "unit" in dct: + unitstr = dct["unit"] + self["unit"] = self.ctx.units.get(unitstr) + if self["unit"] is None: + self.ctx.issues.append(f"unknown unit {unitstr}") + else: + self["unit"] = None + + +class IngredientInstance(Element): + def load(self, dct: Dict[str, Any]) -> None: + self["ingredient"] = self.ctx.ingredients.get(dct["name"]) + if self["ingredient"] is None: + self.ctx.issues.append(f"unknown ingredient {dct['name']}") + + if "amount" not in dct: + self["amount"] = 1.0 + + if "unit" in dct: + unitstr = dct["unit"] + self["unit"] = self.ctx.units.get(unitstr) + if self["unit"] is None: + self.ctx.issues.append("unknown unit {unitstr}") + else: + self["unit"] = None + + if "note" not in dct: + self["note"] = "" + + alternatives = [] + if "or" in dct: + for ingdct in dct["or"]: + ingredient = IngredientInstance(self.ctx, ingdct) + alternatives.append(ingredient) + self["alternatives"] = alternatives + + +class Recipe(Element): + def __init__(self, ctx: Context, dct: Dict[str, Any]) -> None: + super().__init__(ctx, dct) + self.srcpath = "" + self.outpath = "" + + def load(self, dct: Dict[str, Any]) -> None: + ingredients: List[IngredientInstance] = [] + if "ingredients" in dct: + for ing in dct["ingredients"]: + ingredient = IngredientInstance(self.ctx, ing) + ingredients.append(ingredient) + self["ingredients"] = ingredients + + subrecipes: List[Recipe] = [] + if "subrecipes" in dct: + for partdct in dct["subrecipes"]: + rp = Recipe(self.ctx, partdct) + subrecipes.append(rp) + self["subrecipes"] = subrecipes + + +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"): + return out.split(".", maxsplit=1)[0] + if out == "0.5": + return "1/2" + if out == "0.25": + return "1/4" + if 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: + txt = f.read() + if file.endswith(".json"): + return json.loads(txt) + return yaml.safe_load(txt) + + def rendertemplate( + self, templatepath: str, format: str, file: str, dir: str, args: Any + ) -> None: + template = self.jinjaenv.get_template(templatepath) + print(f"rendering {file}") + outstr = template.render(args) + + os.makedirs(f"{dir}/out/{format}", exist_ok=True) + + with open(f"{dir}/out/{format}/{file}", "w", encoding="utf-8") as f: + f.write(outstr) + self.outfiles.add(file) + + def load(self, dir: str) -> int: + unitsdct = self.load_file(dir + "/units.yaml") + unitsschema = self.load_file("schemas/units.json") + jsonschema.validate(instance=unitsdct, schema=unitsschema) + self.ctx.units.load(unitsdct) + if len(self.ctx.issues) != 0: + for issue in self.ctx.issues: + print("ERROR in units.yaml:", issue) + return 1 + + ingredientsdct = self.load_file(dir + "/ingredients.yaml") + ingredientsschema = self.load_file("schemas/ingredients.json") + jsonschema.validate(instance=ingredientsdct, schema=ingredientsschema) + self.ctx.ingredients.load(ingredientsdct) + if len(self.ctx.issues) != 0: + for issue in self.ctx.issues: + print("ERROR in ingredients.yaml:", issue) + return 1 + + return 0 + + def run(self, dir: str) -> int: + files = [] + for _, _, filesx in os.walk(dir + "/recipes"): + files = filesx + files.sort() + + recipes: List[Recipe] = [] + recipeschema = self.load_file("schemas/recipe.json") + for file in files: + if not file.endswith(".yaml"): + print(f"unknown extension of {file}") + continue + recipedct = self.load_file(dir + "/recipes/" + file) + jsonschema.validate(instance=recipedct, schema=recipeschema) + recipe = Recipe(self.ctx, recipedct) + recipe.srcpath = file + recipe.outpath = file[:-5] + ".html" + recipes.append(recipe) + + if len(self.ctx.issues) != 0: + for issue in self.ctx.issues: + print("ERROR", issue) + return 1 + + self.rendertemplate( + templatepath="index.html", + format="html", + file="index.html", + dir=dir, + args={"recipes": recipes}, + ) + for recipe in recipes: + self.rendertemplate( + templatepath="recipe.html", + format="html", + file=recipe.outpath, + dir=dir, + args={"recipe": recipe}, + ) + return 0 + + def finish(self, dir: str) -> int: + files = set() + for _, _, filesx in os.walk(f"{dir}/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"{dir}/out/html/{file}") + return 0 + + def build(self, path: str) -> int: + fcs = [self.load, self.run, self.finish] + for func in fcs: + try: + ret = func(path) + if ret != 0: + return ret + except jsonschema.exceptions.ValidationError as e: + print("ERROR:", e) + return 1 + return 0 + + +if __name__ == "__main__": + builder = Builder() + sys.exit(builder.build("recipes")) diff --git a/templates/index.html b/templates/index.html index 48bd517..847803e 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,8 +1,8 @@ -{% extends "base.html" %} -{%block title%}Recipes{%endblock%} -{%block body %} -

Recipes

- {% for recipe in recipes %} -
  • {{ recipe.title }}
  • - {% endfor %} +{% extends "base.html" %} +{%block title%}Recipes{%endblock%} +{%block body %} +

    Recipes

    + {% for recipe in recipes %} +
  • {{ recipe.title }}
  • + {% endfor %} {%endblock%} \ No newline at end of file diff --git a/templates/recipe.html b/templates/recipe.html index fb130d7..911687b 100644 --- a/templates/recipe.html +++ b/templates/recipe.html @@ -1,39 +1,39 @@ -{% extends "base.html" %} -{% macro ingredientpart(ing) -%} -{{ing.amount|numprint}} {{ing["unit"].name}} {{ ing.name }} -{%- endmacro %} - -{% macro ingredient(ing) -%} - {{ingredientpart(ing)}} - {% if ing.alternatives|length != 0 %} - {% for alting in ing.alternatives %} - or {{ingredientpart(alting)}} - {% endfor %} - {% endif %} -{%- endmacro %} - -{% macro getrecipe(rec) -%} -{% for subrecipe in rec.subrecipes %} -{{getrecipe(subrecipe)}} -{% endfor %} -

    {{rec.title}}

    -{% if rec.ingredients|length != 0 %} -

    Ingredients

    -{% for ing in rec.ingredients %} -
  • {{ingredient(ing)}}
  • -{% endfor %} -{% endif %} -{% if rec.steps|length != 0 %} -

    Steps

    -{% for step in rec.steps %} -
  • {{ step }}
  • -{% endfor %} -{% endif %} -
    -{%- endmacro %} - -{%block title%}{{recipe.title}}{%endblock%} -{%block body %} -back -{{getrecipe(recipe)}} +{% extends "base.html" %} +{% macro ingredientpart(ing) -%} +{{ing.amount|numprint}} {{ing["unit"].name}} {{ ing.name }} +{%- endmacro %} + +{% macro ingredient(ing) -%} + {{ingredientpart(ing)}} + {% if ing.alternatives|length != 0 %} + {% for alting in ing.alternatives %} + or {{ingredientpart(alting)}} + {% endfor %} + {% endif %} +{%- endmacro %} + +{% macro getrecipe(rec) -%} +{% for subrecipe in rec.subrecipes %} +{{getrecipe(subrecipe)}} +{% endfor %} +

    {{rec.title}}

    +{% if rec.ingredients|length != 0 %} +

    Ingredients

    +{% for ing in rec.ingredients %} +
  • {{ingredient(ing)}}
  • +{% endfor %} +{% endif %} +{% if rec.steps|length != 0 %} +

    Steps

    +{% for step in rec.steps %} +
  • {{ step }}
  • +{% endfor %} +{% endif %} +
    +{%- endmacro %} + +{%block title%}{{recipe.title}}{%endblock%} +{%block body %} +back +{{getrecipe(recipe)}} {%endblock%} \ No newline at end of file