diff --git a/comfyrecipes/__main__.py b/comfyrecipes/__main__.py new file mode 100644 index 0000000..aac9305 --- /dev/null +++ b/comfyrecipes/__main__.py @@ -0,0 +1,2 @@ +import comfyrecipes.cli +comfyrecipes.cli.main() diff --git a/comfyrecipes/builder.py b/comfyrecipes/builder.py new file mode 100644 index 0000000..c5f1f29 --- /dev/null +++ b/comfyrecipes/builder.py @@ -0,0 +1,157 @@ +from typing import Any, List, Set +import os +import json + +import yaml +import jinja2 +import jsonschema +import jsonschema.exceptions + +import comfyrecipes.parsing as parsing + + +class Builder: + def __init__(self) -> None: + self.jinjaenv = jinja2.Environment( + loader=jinja2.FileSystemLoader(f"{os.path.dirname(__file__)}/templates"), + autoescape=jinja2.select_autoescape(), + ) + + def numprint(input: int) -> str: + out = str(input) + if out.endswith(".0"): + return out.split(".", maxsplit=1)[0] + return out + + def amountprint(input: int) -> str: + out = numprint(input) + 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.jinjaenv.filters["amountprint"] = amountprint + self.ctx = parsing.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 load_pkgfile(self, file: str) -> Any: + return self.load_file(f"{os.path.dirname(__file__)}/{file}") + + 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: + if os.path.isfile(dir + "/settings.yaml"): + settingsdct = self.load_file(dir + "/settings.yaml") + settingsschema = self.load_pkgfile("schemas/settings.json") + self.ctx.load_settings(settingsdct, settingsschema) + retcode = self.ctx.issues.check() + if retcode != 0: + return 1 + + if os.path.isfile(dir + "/units.yaml"): + unitsdct = self.load_file(dir + "/units.yaml") + unitsschema = self.load_pkgfile("schemas/units.json") + self.ctx.load_units(unitsdct, unitsschema) + retcode = self.ctx.issues.check() + if retcode != 0: + return 1 + + if os.path.isfile(dir + "/ingredients.yaml"): + ingredientsdct = self.load_file(dir + "/ingredients.yaml") + ingredientsschema = self.load_pkgfile("schemas/ingredients.json") + self.ctx.load_ingredients(ingredientsdct, ingredientsschema) + retcode = self.ctx.issues.check() + if retcode != 0: + return 1 + + return 0 + + def run(self, dir: str) -> int: + files = [] + for _, _, filesx in os.walk(dir + "/recipes"): + files = filesx + files.sort() + + recipes: List[parsing.Recipe] = [] + recipeschema = self.load_pkgfile("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 = parsing.Recipe.from_dict(self.ctx, recipedct) + recipe.srcpath = file + recipe.outpath = file[:-5] + ".html" + if self.ctx.issues.check() != 0: + continue + recipes.append(recipe) + + retcode = self.ctx.issues.check() + if retcode != 0: + 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 diff --git a/cli.py b/comfyrecipes/cli.py similarity index 92% rename from cli.py rename to comfyrecipes/cli.py index 8ad24e6..0240f09 100644 --- a/cli.py +++ b/comfyrecipes/cli.py @@ -4,7 +4,7 @@ import socketserver import sys import os -import recipes +import comfyrecipes.builder as builder def main() -> None: @@ -23,7 +23,7 @@ def main() -> None: ret = 0 if args.subcommand == "build": - ret = recipes.Builder().build(args.directory) + ret = builder.Builder().build(args.directory) elif args.subcommand == "serve": os.chdir(f"{args.directory}/out/html") httpd = socketserver.TCPServer( diff --git a/comfyrecipes/issues.py b/comfyrecipes/issues.py new file mode 100644 index 0000000..1ca2bbb --- /dev/null +++ b/comfyrecipes/issues.py @@ -0,0 +1,36 @@ +from typing import List + + +ISSUE_UNKNOWN_INGREDIENT = "unknown-ingredient" +ISSUE_DUPLICATE_UNITS = "duplicate-units" +ISSUE_KNOWN_PRICE_UNKNOWN_CONVERSION = "known-price-unknown-conversion" +ISSUE_UNKNOWN_UNIT = "unknown-unit" + +class Issue: + def __init__(self, id: str, msg: str) -> None: + self.id = id + self.msg = msg + + +class Issues: + def __init__(self) -> None: + self.errors: List[Issue] = [] + self.warnings: List[Issue] = [] + + def error(self, id: str, msg: str) -> None: + self.errors.append(Issue(id, msg)) + + def warn(self, id: str, msg: str) -> None: + self.warnings.append(Issue(id, msg)) + + def check(self) -> int: + retcode = len(self.errors) != 0 + + for msg in self.errors: + print(f"ERROR {msg.id}: {msg.msg}") + for msg in self.warnings: + print(f"WARNING {msg.id}: {msg.msg}") + + self.errors.clear() + self.warnings.clear() + return retcode diff --git a/recipes.py b/comfyrecipes/parsing.py similarity index 68% rename from recipes.py rename to comfyrecipes/parsing.py index 08293fe..a044c43 100644 --- a/recipes.py +++ b/comfyrecipes/parsing.py @@ -1,57 +1,19 @@ from abc import abstractmethod import collections -from typing import Dict, List, Any, Optional, Self, Set -import os -import json +from typing import Any, Dict, List, Optional, Self + +import comfyrecipes.settings as settings +import comfyrecipes.issues as issues -import yaml -import jinja2 import jsonschema -import jsonschema.exceptions - -ISSUE_UNKNOWN_UNIT = "unknown-unit" -ISSUE_UNKNOWN_INGREDIENT = "unknown-ingredient" -ISSUE_DUPLICATE_UNITS = "duplicate-units" -ISSUE_KNOWN_PRICE_UNKNOWN_CONVERSION = "known-price-unknown-conversion" - - -class Issue: - def __init__(self, id: str, msg: str) -> None: - self.id = id - self.msg = msg - - -class Issues: - def __init__(self) -> None: - self.errors: List[Issue] = [] - self.warnings: List[Issue] = [] - - def error(self, id: str, msg: str) -> None: - self.errors.append(Issue(id, msg)) - - def warn(self, id: str, msg: str) -> None: - self.warnings.append(Issue(id, msg)) - - def check(self) -> int: - retcode = len(self.errors) != 0 - - for msg in self.errors: - print(f"ERROR {msg.id}: {msg.msg}") - for msg in self.warnings: - print(f"WARNING {msg.id}: {msg.msg}") - - self.errors.clear() - self.warnings.clear() - return retcode - class Context: def __init__(self) -> None: - self.settings = Settings() + self.settings = settings.Settings() self.units: AUnits = FakeUnits(self) self.default_unit = Unit(self, "piece", [], []) self.ingredients: AIngredients = FakeIngredients(self) - self.issues = Issues() + self.issues = issues.Issues() def load_units( self, unitsdct: List[Dict[str, Any]], unitsschema: Dict[str, Any] @@ -75,15 +37,6 @@ class Context: jsonschema.validate(instance=settingsdct, schema=settingsschema) self.settings.load(settingsdct) - -class Settings: - def __init__(self) -> None: - self.default_currency: Optional[str] = None - - def load(self, settingsdct: Dict[str, Any]) -> None: - self.default_currency = settingsdct["default_currency"] - - class Element: def __init__(self, ctx: Context) -> None: self.ctx = ctx @@ -92,34 +45,12 @@ class Element: def from_dict(cls, ctx: Context, dct: Dict[str, Any]) -> Self: return cls(ctx) - -class Conversion(Element): - 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") - - tounit = ctx.units.get(dct["to"]) - if tounit is None: - raise RuntimeError(f"unit {dct['to']} doesn't exist") - return cls(ctx, fromunit, tounit, dct["ratio"]) - - class Unit(Element): def __init__( self, ctx: Context, name: str, - conversions: List[Conversion], + conversions: List["Conversion"], aliases: List[str], dct: Optional[Dict[str, Any]] = None, ) -> None: @@ -157,6 +88,7 @@ class Unit(Element): self.conversions = conversions + class AUnits: def __init__(self, ctx: Context) -> None: self.ctx = ctx @@ -204,10 +136,169 @@ class Units(AUnits): for unitname, num in collections.Counter(unitnames).items(): if num > 1: self.ctx.issues.error( - ISSUE_DUPLICATE_UNITS, + issues.ISSUE_DUPLICATE_UNITS, f"units.yaml: {unitname} should only have one entry, found {num}", ) +class IngredientInstance(Element): + 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: + ctx.issues.error( + issues.ISSUE_UNKNOWN_INGREDIENT, f"unknown ingredient {dct['name']}" + ) + + amount = 1.0 + if "amount" in dct: + amount = dct["amount"] + + unit = ctx.default_unit + if "unit" in dct: + unitstr = dct["unit"] + unitx = ctx.units.get(unitstr) + if unitx is None: + ctx.issues.error(issues.ISSUE_UNKNOWN_UNIT, "unknown unit {unitstr}") + else: + unit = unitx + + note = "" + if "note" in dct: + note = dct["note"] + + alternatives = [] + if "or" in dct: + for ingdct in dct["or"]: + ing = IngredientInstance.from_dict(ctx, ingdct) + alternatives.append(ing) + + price = None + if ingredient is not None: + 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, + 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 + + @classmethod + def from_dict(cls, ctx: Context, dct: Dict[str, Any]) -> Self: + ingredients: List[IngredientInstance] = [] + if "ingredients" in dct: + for ingdct in dct["ingredients"]: + ingredient = IngredientInstance.from_dict(ctx, ingdct) + ingredients.append(ingredient) + + subrecipes: List[Recipe] = [] + if "subrecipes" in dct: + for partdct in dct["subrecipes"]: + rp = Recipe.from_dict(ctx, partdct) + subrecipes.append(rp) + + price: Optional[PriceDB] = None + pricex: float = 0 + ingswithprice = 0 + ingswithoutprice = 0 + currency = None + for ing in ingredients: + if ing.price is None: + ingswithoutprice += 1 + continue + ingswithprice += 1 + pricex += ing.price.price + cur_currency = ing.price.currency + if currency is None: + currency = cur_currency + elif currency != cur_currency: + # we don't know how to convert currencies yet + currency = None + break + if currency is None or ingswithoutprice != 0 or len(ingredients) == 0: + price = None + else: + 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 Conversion(Element): + 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") + + tounit = ctx.units.get(dct["to"]) + if tounit is None: + raise RuntimeError(f"unit {dct['to']} doesn't exist") + return cls(ctx, fromunit, tounit, dct["ratio"]) class Ingredient(Element): def __init__( @@ -292,7 +383,7 @@ class Ingredient(Element): path = self.dfs(conversions, unitfrom.name, unitto.name) if path is None: self.ctx.issues.warn( - ISSUE_KNOWN_PRICE_UNKNOWN_CONVERSION, + issues.ISSUE_KNOWN_PRICE_UNKNOWN_CONVERSION, f"{self.name} has a known price, but conversion {unitfrom.name} -> {unitto.name} not known", ) return None @@ -395,7 +486,7 @@ class PriceDB(Element): # everything is evaluated) unit = ctx.default_unit if unitx is None: - ctx.issues.error(ISSUE_UNKNOWN_UNIT, f"unknown unit {unitstr}") + ctx.issues.error(issues.ISSUE_UNKNOWN_UNIT, f"unknown unit {unitstr}") else: unit = unitx @@ -403,288 +494,3 @@ class PriceDB(Element): if "currency" in dct: currency = dct["currency"] return cls(ctx=ctx, price=price, amount=amount, unit=unit, currency=currency) - - -class IngredientInstance(Element): - 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: - ctx.issues.error( - ISSUE_UNKNOWN_INGREDIENT, f"unknown ingredient {dct['name']}" - ) - - amount = 1.0 - if "amount" in dct: - amount = dct["amount"] - - unit = ctx.default_unit - if "unit" in dct: - unitstr = dct["unit"] - unitx = ctx.units.get(unitstr) - if unitx is None: - ctx.issues.error(ISSUE_UNKNOWN_UNIT, "unknown unit {unitstr}") - else: - unit = unitx - - note = "" - if "note" in dct: - note = dct["note"] - - alternatives = [] - if "or" in dct: - for ingdct in dct["or"]: - ing = IngredientInstance.from_dict(ctx, ingdct) - alternatives.append(ing) - - price = None - if ingredient is not None: - 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, - 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 - - @classmethod - def from_dict(cls, ctx: Context, dct: Dict[str, Any]) -> Self: - ingredients: List[IngredientInstance] = [] - if "ingredients" in dct: - for ingdct in dct["ingredients"]: - ingredient = IngredientInstance.from_dict(ctx, ingdct) - ingredients.append(ingredient) - - subrecipes: List[Recipe] = [] - if "subrecipes" in dct: - for partdct in dct["subrecipes"]: - rp = Recipe.from_dict(ctx, partdct) - subrecipes.append(rp) - - price: Optional[PriceDB] = None - pricex: float = 0 - ingswithprice = 0 - ingswithoutprice = 0 - currency = None - for ing in ingredients: - if ing.price is None: - ingswithoutprice += 1 - continue - ingswithprice += 1 - pricex += ing.price.price - cur_currency = ing.price.currency - if currency is None: - currency = cur_currency - elif currency != cur_currency: - # we don't know how to convert currencies yet - currency = None - break - if currency is None or ingswithoutprice != 0 or len(ingredients) == 0: - price = None - else: - 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: - 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] - return out - - def amountprint(input: int) -> str: - out = numprint(input) - 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.jinjaenv.filters["amountprint"] = amountprint - 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: - if os.path.isfile(dir + "/settings.yaml"): - settingsdct = self.load_file(dir + "/settings.yaml") - settingsschema = self.load_file("schemas/settings.json") - self.ctx.load_settings(settingsdct, settingsschema) - retcode = self.ctx.issues.check() - if retcode != 0: - return 1 - - if os.path.isfile(dir + "/units.yaml"): - unitsschema = self.load_file("schemas/units.json") - unitsdct = self.load_file(dir + "/units.yaml") - self.ctx.load_units(unitsdct, unitsschema) - retcode = self.ctx.issues.check() - if retcode != 0: - return 1 - - if os.path.isfile(dir + "/ingredients.yaml"): - ingredientsdct = self.load_file(dir + "/ingredients.yaml") - ingredientsschema = self.load_file("schemas/ingredients.json") - self.ctx.load_ingredients(ingredientsdct, ingredientsschema) - retcode = self.ctx.issues.check() - if retcode != 0: - 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.from_dict(self.ctx, recipedct) - recipe.srcpath = file - recipe.outpath = file[:-5] + ".html" - if self.ctx.issues.check() != 0: - continue - recipes.append(recipe) - - retcode = self.ctx.issues.check() - if retcode != 0: - 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 diff --git a/schemas/ingredients.json b/comfyrecipes/schemas/ingredients.json similarity index 100% rename from schemas/ingredients.json rename to comfyrecipes/schemas/ingredients.json diff --git a/schemas/recipe.json b/comfyrecipes/schemas/recipe.json similarity index 100% rename from schemas/recipe.json rename to comfyrecipes/schemas/recipe.json diff --git a/schemas/settings.json b/comfyrecipes/schemas/settings.json similarity index 100% rename from schemas/settings.json rename to comfyrecipes/schemas/settings.json diff --git a/schemas/units.json b/comfyrecipes/schemas/units.json similarity index 100% rename from schemas/units.json rename to comfyrecipes/schemas/units.json diff --git a/comfyrecipes/settings.py b/comfyrecipes/settings.py new file mode 100644 index 0000000..a6bf829 --- /dev/null +++ b/comfyrecipes/settings.py @@ -0,0 +1,9 @@ +from typing import Any, Dict, Optional + + +class Settings: + def __init__(self) -> None: + self.default_currency: Optional[str] = None + + def load(self, settingsdct: Dict[str, Any]) -> None: + self.default_currency = settingsdct["default_currency"] diff --git a/templates/base.html b/comfyrecipes/templates/base.html similarity index 100% rename from templates/base.html rename to comfyrecipes/templates/base.html diff --git a/templates/index.html b/comfyrecipes/templates/index.html similarity index 100% rename from templates/index.html rename to comfyrecipes/templates/index.html diff --git a/templates/recipe.html b/comfyrecipes/templates/recipe.html similarity index 100% rename from templates/recipe.html rename to comfyrecipes/templates/recipe.html