restructure

This commit is contained in:
Emi Vasilek 2023-11-19 00:18:12 +01:00
parent 9e44070fe7
commit 81fdafb907
13 changed files with 376 additions and 366 deletions

2
comfyrecipes/__main__.py Normal file
View file

@ -0,0 +1,2 @@
import comfyrecipes.cli
comfyrecipes.cli.main()

157
comfyrecipes/builder.py Normal file
View file

@ -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

View file

@ -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(

36
comfyrecipes/issues.py Normal file
View file

@ -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

View file

@ -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

9
comfyrecipes/settings.py Normal file
View file

@ -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"]