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 sys
import os import os
import recipes import comfyrecipes.builder as builder
def main() -> None: def main() -> None:
@ -23,7 +23,7 @@ def main() -> None:
ret = 0 ret = 0
if args.subcommand == "build": if args.subcommand == "build":
ret = recipes.Builder().build(args.directory) ret = builder.Builder().build(args.directory)
elif args.subcommand == "serve": elif args.subcommand == "serve":
os.chdir(f"{args.directory}/out/html") os.chdir(f"{args.directory}/out/html")
httpd = socketserver.TCPServer( 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 from abc import abstractmethod
import collections import collections
from typing import Dict, List, Any, Optional, Self, Set from typing import Any, Dict, List, Optional, Self
import os
import json import comfyrecipes.settings as settings
import comfyrecipes.issues as issues
import yaml
import jinja2
import jsonschema 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: class Context:
def __init__(self) -> None: def __init__(self) -> None:
self.settings = Settings() self.settings = settings.Settings()
self.units: AUnits = FakeUnits(self) self.units: AUnits = FakeUnits(self)
self.default_unit = Unit(self, "piece", [], []) self.default_unit = Unit(self, "piece", [], [])
self.ingredients: AIngredients = FakeIngredients(self) self.ingredients: AIngredients = FakeIngredients(self)
self.issues = Issues() self.issues = issues.Issues()
def load_units( def load_units(
self, unitsdct: List[Dict[str, Any]], unitsschema: Dict[str, Any] self, unitsdct: List[Dict[str, Any]], unitsschema: Dict[str, Any]
@ -75,15 +37,6 @@ class Context:
jsonschema.validate(instance=settingsdct, schema=settingsschema) jsonschema.validate(instance=settingsdct, schema=settingsschema)
self.settings.load(settingsdct) 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: class Element:
def __init__(self, ctx: Context) -> None: def __init__(self, ctx: Context) -> None:
self.ctx = ctx self.ctx = ctx
@ -92,34 +45,12 @@ class Element:
def from_dict(cls, ctx: Context, dct: Dict[str, Any]) -> Self: def from_dict(cls, ctx: Context, dct: Dict[str, Any]) -> Self:
return cls(ctx) 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): class Unit(Element):
def __init__( def __init__(
self, self,
ctx: Context, ctx: Context,
name: str, name: str,
conversions: List[Conversion], conversions: List["Conversion"],
aliases: List[str], aliases: List[str],
dct: Optional[Dict[str, Any]] = None, dct: Optional[Dict[str, Any]] = None,
) -> None: ) -> None:
@ -157,6 +88,7 @@ class Unit(Element):
self.conversions = conversions self.conversions = conversions
class AUnits: class AUnits:
def __init__(self, ctx: Context) -> None: def __init__(self, ctx: Context) -> None:
self.ctx = ctx self.ctx = ctx
@ -204,10 +136,169 @@ class Units(AUnits):
for unitname, num in collections.Counter(unitnames).items(): for unitname, num in collections.Counter(unitnames).items():
if num > 1: if num > 1:
self.ctx.issues.error( self.ctx.issues.error(
ISSUE_DUPLICATE_UNITS, issues.ISSUE_DUPLICATE_UNITS,
f"units.yaml: {unitname} should only have one entry, found {num}", 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): class Ingredient(Element):
def __init__( def __init__(
@ -292,7 +383,7 @@ class Ingredient(Element):
path = self.dfs(conversions, unitfrom.name, unitto.name) path = self.dfs(conversions, unitfrom.name, unitto.name)
if path is None: if path is None:
self.ctx.issues.warn( 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", f"{self.name} has a known price, but conversion {unitfrom.name} -> {unitto.name} not known",
) )
return None return None
@ -395,7 +486,7 @@ class PriceDB(Element):
# everything is evaluated) # everything is evaluated)
unit = ctx.default_unit unit = ctx.default_unit
if unitx is None: 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: else:
unit = unitx unit = unitx
@ -403,288 +494,3 @@ class PriceDB(Element):
if "currency" in dct: if "currency" in dct:
currency = dct["currency"] currency = dct["currency"]
return cls(ctx=ctx, price=price, amount=amount, unit=unit, currency=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"]