validate using jsonschema, not manually

This commit is contained in:
Emi Vasilek 2023-11-06 05:18:39 +01:00
parent a2cbbb2068
commit 2f183c758f
4 changed files with 125 additions and 70 deletions

View file

@ -1,8 +1,10 @@
from typing import Dict, List, Any, Optional, Set from typing import Dict, List, Any, Optional, Set
import os import os
import json
import yaml import yaml
import jinja2 import jinja2
import jsonschema
class Context: class Context:
@ -18,29 +20,20 @@ class Conversion:
return unit return unit
return None return None
assert_dict(dct, ["from", "to", "ratio"], [])
assert_type(dct, "from", str)
fromunit = find_unit(dct["from"]) fromunit = find_unit(dct["from"])
if fromunit is None: if fromunit is None:
raise RuntimeError(f"unit {dct['from']} doesn't exist") raise RuntimeError(f"unit {dct['from']} doesn't exist")
self.fromunit = fromunit self.fromunit = fromunit
assert_type(dct, "to", str)
tounit = find_unit(dct["to"]) tounit = find_unit(dct["to"])
if tounit is None: if tounit is None:
raise RuntimeError(f"unit {tounit} doesn't exist") raise RuntimeError(f"unit {tounit} doesn't exist")
self.tounit = tounit self.tounit = tounit
# TODO: or float
assert_type(dct, "ratio", int)
self.ratio = dct["ratio"] self.ratio = dct["ratio"]
class Unit: class Unit:
def load(self, units: List["Unit"], dct: Dict[str, Any]) -> None: def load(self, units: List["Unit"], dct: Dict[str, Any]) -> None:
assert_dict(dct, ["name"], ["conversions", "aliases"])
assert_type(dct, "name", str)
self.name = dct["name"] self.name = dct["name"]
unitsx = units[:] unitsx = units[:]
@ -48,7 +41,6 @@ class Unit:
self.conversions: List[Conversion] = [] self.conversions: List[Conversion] = []
if "conversions" in dct: if "conversions" in dct:
assert_list(dct["conversions"])
for convdct in dct["conversions"]: for convdct in dct["conversions"]:
if "from" in dct["conversions"]: if "from" in dct["conversions"]:
raise RuntimeError(f"conversions in units.yaml cannot have a from field, it is automatically assigned from the unit name") raise RuntimeError(f"conversions in units.yaml cannot have a from field, it is automatically assigned from the unit name")
@ -59,9 +51,7 @@ class Unit:
self.aliases: List[str] = [] self.aliases: List[str] = []
if "aliases" in dct: if "aliases" in dct:
assert_list(dct["aliases"])
for alias in dct["aliases"]: for alias in dct["aliases"]:
assert_type(alias, "", str)
self.aliases.append(alias) self.aliases.append(alias)
@ -70,7 +60,6 @@ class Units:
self.units: List[Unit] = [] self.units: List[Unit] = []
def load(self, lst: List[Any]) -> List[str]: def load(self, lst: List[Any]) -> List[str]:
assert_list(lst)
for unitdct in lst: for unitdct in lst:
unit = Unit() unit = Unit()
unit.load(self.units, unitdct) unit.load(self.units, unitdct)
@ -90,32 +79,24 @@ class Ingredient:
def load(self, dct: Dict[str, Any]) -> List[str]: def load(self, dct: Dict[str, Any]) -> List[str]:
issues = [] issues = []
assert_dict(dct, ["name"], ["wdid", "pricedb", "aliases", "conversions"])
assert_type(dct, "name", str)
self.name = dct["name"] self.name = dct["name"]
self.wdid = -1 self.wdid = -1
if "wdid" in dct: if "wdid" in dct:
assert_type(dct, "wdid", int)
self.wdid = dct["wdid"] self.wdid = dct["wdid"]
self.pricedb = None self.pricedb = None
if "pricedb" in dct: if "pricedb" in dct:
assert_list(dct["pricedb"])
self.pricedb = PriceDBs(self.ctx) self.pricedb = PriceDBs(self.ctx)
issues += self.pricedb.load(dct["pricedb"]) issues += self.pricedb.load(dct["pricedb"])
self.aliases = [] self.aliases = []
if "aliases" in dct: if "aliases" in dct:
assert_list(dct["aliases"])
for elem in dct["aliases"]: for elem in dct["aliases"]:
assert_type(elem, "", str)
self.aliases.append(elem) self.aliases.append(elem)
self.conversions = [] self.conversions = []
if "conversions" in dct: if "conversions" in dct:
assert_list(dct["conversions"])
for dct in dct["conversions"]: for dct in dct["conversions"]:
conversion = Conversion() conversion = Conversion()
conversion.load(self.ctx.units.units, dct) conversion.load(self.ctx.units.units, dct)
@ -130,7 +111,6 @@ class Ingredients:
def load(self, lst: List[Any]) -> List[str]: def load(self, lst: List[Any]) -> List[str]:
issues = [] issues = []
assert_list(lst)
for ingdct in lst: for ingdct in lst:
ing = Ingredient(self.ctx) ing = Ingredient(self.ctx)
issues += ing.load(ingdct) issues += ing.load(ingdct)
@ -151,7 +131,6 @@ class PriceDBs:
def load(self, lst: List[Any]) -> List[str]: def load(self, lst: List[Any]) -> List[str]:
issues = [] issues = []
assert_list(lst)
for elem in lst: for elem in lst:
pricedb = PriceDB(self.ctx) pricedb = PriceDB(self.ctx)
issues += pricedb.load(elem) issues += pricedb.load(elem)
@ -165,8 +144,6 @@ class PriceDB:
def load(self, dct: Dict[str, Any]) -> List[str]: def load(self, dct: Dict[str, Any]) -> List[str]:
issues = [] issues = []
assert_dict(dct, ["price"], ["amount", "unit"])
if isinstance(dct["price"], float): if isinstance(dct["price"], float):
self.price = dct["price"] self.price = dct["price"]
elif isinstance(dct["price"], int): elif isinstance(dct["price"], int):
@ -185,7 +162,6 @@ class PriceDB:
self.unit = None self.unit = None
if "unit" in dct: if "unit" in dct:
assert_type(dct, "unit", str)
try: try:
self.unit = self.ctx.units.get(dct["unit"]) self.unit = self.ctx.units.get(dct["unit"])
except RuntimeError as e: except RuntimeError as e:
@ -203,9 +179,6 @@ class IngredientInstance:
def load(self, dct: Dict[str, Any]) -> List[str]: 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"] self.name = dct["name"]
try: try:
@ -224,19 +197,16 @@ class IngredientInstance:
self.unit = self.defaultunit self.unit = self.defaultunit
if "unit" in dct: if "unit" in dct:
assert_type(dct, "unit", str)
try: try:
self.unit = self.ctx.units.get(dct["unit"]) self.unit = self.ctx.units.get(dct["unit"])
except RuntimeError as e: except RuntimeError as e:
issues.append(str(e)) issues.append(str(e))
self.note = "" self.note = ""
if "note" in dct: if "note" in dct:
assert_type(dct, "note", str)
self.note = dct["note"] self.note = dct["note"]
self.alternatives = [] self.alternatives = []
if "or" in dct: if "or" in dct:
assert_list(dct["or"])
for ingdct in dct["or"]: for ingdct in dct["or"]:
ingredient = IngredientInstance( ingredient = IngredientInstance(
self.ctx, defaultamount=self.amount, defaultunit=self.unit self.ctx, defaultamount=self.amount, defaultunit=self.unit
@ -253,21 +223,14 @@ class RecipePart:
def load(self, dct: Dict[str, Any]) -> List[str]: 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"] self.title = dct["title"]
assert_type(dct, "ingredients", list)
self.ingredients: List[IngredientInstance] = [] self.ingredients: List[IngredientInstance] = []
for ing in dct["ingredients"]: for ing in dct["ingredients"]:
ingredient = IngredientInstance(self.ctx) ingredient = IngredientInstance(self.ctx)
issues += ingredient.load(ing) issues += ingredient.load(ing)
self.ingredients.append(ingredient) self.ingredients.append(ingredient)
assert_type(dct, "steps", list)
for elem in dct["steps"]:
assert_type(elem, "", str)
self.steps: List[str] = dct["steps"] self.steps: List[str] = dct["steps"]
return issues return issues
@ -284,11 +247,8 @@ class Recipe:
def load(self, dct: Dict[str, Any]) -> List[str]: def load(self, dct: Dict[str, Any]) -> List[str]:
issues: List[str] = [] issues: List[str] = []
if "parts" in dct: if "parts" in dct:
assert_dict(dct, ["title"], ["parts"])
assert_type(dct, "title", str)
self.title = dct["title"] self.title = dct["title"]
assert_list(dct["parts"])
for partdct in dct["parts"]: for partdct in dct["parts"]:
rp = RecipePart(self.ctx) rp = RecipePart(self.ctx)
issues += rp.load(partdct) issues += rp.load(partdct)
@ -301,32 +261,6 @@ class Recipe:
return issues return issues
def assert_dict(
dct: Dict[str, Any], required_keys: List[str], optional_keys: List[str]
) -> None:
if not isinstance(dct, dict):
raise RuntimeError(f"{dct} has to be a dict")
for reqkey in required_keys:
if reqkey not in dct:
raise RuntimeError(f"{reqkey} is required")
extraelems = [x for x in dct.keys() if x not in required_keys + optional_keys]
if len(extraelems) != 0:
raise RuntimeError(f"{extraelems} not allowed")
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: class Builder:
def __init__(self) -> None: def __init__(self) -> None:
self.jinjaenv = jinja2.Environment( self.jinjaenv = jinja2.Environment(
@ -353,8 +287,10 @@ class Builder:
def load_file(self, file: str) -> Any: def load_file(self, file: str) -> Any:
print(f"loading {file}") print(f"loading {file}")
with open(file, encoding="utf-8") as f: with open(file, encoding="utf-8") as f:
ymltxt = f.read() txt = f.read()
return yaml.safe_load(ymltxt) if file.endswith(".json"):
return json.loads(txt)
return yaml.safe_load(txt)
def rendertemplate( def rendertemplate(
self, templatepath: str, format: str, file: str, args: Any self, templatepath: str, format: str, file: str, args: Any
@ -373,9 +309,13 @@ class Builder:
issues: List[str] = [] issues: List[str] = []
unitsdct = self.load_file("recipes/units.yaml") unitsdct = self.load_file("recipes/units.yaml")
unitsschema = self.load_file("schemas/units.json")
jsonschema.validate(instance=unitsdct, schema=unitsschema)
issues += self.ctx.units.load(unitsdct) issues += self.ctx.units.load(unitsdct)
ingredientsdct = self.load_file("recipes/ingredients.yaml") ingredientsdct = self.load_file("recipes/ingredients.yaml")
ingredientsschema = self.load_file("schemas/ingredients.json")
jsonschema.validate(instance=ingredientsdct, schema=ingredientsschema)
issues += self.ctx.ingredients.load(ingredientsdct) issues += self.ctx.ingredients.load(ingredientsdct)
if len(issues) != 0: if len(issues) != 0:
@ -391,12 +331,14 @@ class Builder:
files.sort() files.sort()
recipes: List[Recipe] = [] recipes: List[Recipe] = []
recipeschema = self.load_file("schemas/recipe.json")
for file in files: for file in files:
if not file.endswith(".yaml"): if not file.endswith(".yaml"):
print(f"unknown extension of {file}") print(f"unknown extension of {file}")
continue continue
recipe = Recipe(self.ctx) recipe = Recipe(self.ctx)
recipedct = self.load_file("recipes/recipes/" + file) recipedct = self.load_file("recipes/recipes/" + file)
jsonschema.validate(instance=recipedct, schema=recipeschema)
issues += recipe.load(recipedct) issues += recipe.load(recipedct)
recipe.srcpath = file recipe.srcpath = file
recipe.outpath = file[:-5] + ".html" recipe.outpath = file[:-5] + ".html"

46
schemas/ingredients.json Normal file
View file

@ -0,0 +1,46 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/ingredients.json",
"title": "Ingredients",
"description": "Ingredients",
"type": "array",
"items": {
"type": "object",
"required": [ "name" ],
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"wdid": { "type": "integer" },
"pricedb": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [ "price", "amount", "unit" ],
"properties": {
"price": { "type": "integer" },
"amount": { "type": "integer" },
"unit": { "type": "string" }
}
}
},
"conversions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [ "from", "to", "ratio" ],
"properties": {
"from": { "type": "string" },
"to": { "type": "string" },
"ratio": { "type": "integer" }
}
}
},
"aliases": {
"type": "array",
"items": { "type": "string" }
}
}
}
}

36
schemas/recipe.json Normal file
View file

@ -0,0 +1,36 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/recipe.json",
"title": "Recipe",
"description": "Recipe",
"type": "object",
"required": [ "title" ],
"additionalProperties": false,
"properties": {
"title": { "type": "string" },
"ingredients": {
"type": "array",
"items": {
"$id": "https://example.com/ingredient.json",
"type": "object",
"additionalProperties": false,
"required": [ "name" ],
"properties": {
"name": { "type": "string" },
"amount": { "type": "number" },
"unit": { "type": "string" },
"or": { "items": { "$ref": "/ingredient.json" } },
"note": { "type": "string" }
}
}
},
"steps": {
"type": "array",
"items": { "type": "string" }
},
"parts": {
"type": "array",
"items": { "$ref": "/recipe.json" }
}
}
}

31
schemas/units.json Normal file
View file

@ -0,0 +1,31 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/units.json",
"title": "Units",
"description": "Units",
"type": "array",
"items": {
"type": "object",
"required": [ "name" ],
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"conversions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [ "to", "ratio" ],
"properties": {
"to": { "type": "string" },
"ratio": { "type": "integer" }
}
}
},
"aliases": {
"type": "array",
"items": { "type": "string" }
}
}
}
}