Update api.py
This commit is contained in:
parent
1156722b4d
commit
53fc14dbef
|
|
@ -1,18 +1,53 @@
|
|||
"""Client API pour le serveur CookCLI.
|
||||
|
||||
Endpoints CookCLI utilisés :
|
||||
- GET /api/recipes -> liste/arborescence des recettes
|
||||
- GET /api/recipes -> arborescence complète des recettes
|
||||
- GET /api/recipes/{path} -> détail d'une recette (ingredients, steps, metadata)
|
||||
|
||||
Le endpoint de liste n'a pas pu être vérifié à 100% depuis les sources au
|
||||
moment de l'écriture de ce client : le format exact (liste plate vs
|
||||
arborescence de dossiers) doit être confirmé avec un `curl` réel contre le
|
||||
serveur, ex :
|
||||
Format réel confirmé pour GET /api/recipes (arborescence récursive) :
|
||||
|
||||
curl http://<ip-addon>:9081/api/recipes | jq
|
||||
{
|
||||
"name": "recipes", "path": "/config/recipes", "recipe": null,
|
||||
"children": {
|
||||
"Chocolat chaud": {
|
||||
"name": "Chocolat chaud",
|
||||
"path": "/config/recipes/chocolat-chaud.cook",
|
||||
"recipe": {"metadata": {...}, "source": {...}},
|
||||
"children": {}
|
||||
},
|
||||
"example": {
|
||||
"name": "example", "path": "/config/recipes/example", "recipe": null,
|
||||
"children": { ... sous-dossiers/recettes imbriqués ... }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
`_normalize_recipe_list` ci-dessous encaisse les deux formes les plus
|
||||
probables ; ajuste-le si le format réel diffère.
|
||||
Un nœud est une recette si sa clé "recipe" n'est pas null ; sinon c'est un
|
||||
dossier à parcourir récursivement via "children". `_normalize_recipe_list`
|
||||
aplatit cette arborescence en une liste de `RecipeSummary`, avec un `path`
|
||||
relatif au dossier racine (ex: "chocolat-chaud.cook",
|
||||
"example/sauces/Hollandaise.cook") pour correspondre à ce qu'attend
|
||||
GET /api/recipes/{path}.
|
||||
|
||||
Hypothèse à vérifier : ce path relatif inclut l'extension ".cook", ce qui
|
||||
correspond au path filesystem brut renvoyé par le serveur. Si
|
||||
GET /api/recipes/{path} attend un format différent (sans extension, slugifié,
|
||||
etc.), ajuste `async_get_recipe` en conséquence.
|
||||
|
||||
Format réel confirmé pour GET /api/recipes/{path} (détail d'une recette) :
|
||||
la réponse encapsule un objet `recipe` du parser cooklang-rs, où les listes
|
||||
`ingredients`/`cookware`/`timers` sont à plat, et les étapes
|
||||
(`recipe.sections[].content[].value.items[]`) référencent ces objets **par
|
||||
index** plutôt que de les inclure directement (ex: `{"type": "ingredient",
|
||||
"index": 0}`). `async_get_recipe` résout ces références côté intégration
|
||||
avant de renvoyer les données, pour éviter que la carte Lovelace (ou tout
|
||||
autre consommateur) ait à recroiser les index elle-même.
|
||||
|
||||
Les valeurs numériques suivent aussi un format imbriqué
|
||||
(`{"type": "number", "value": {"type": "regular", "value": 1.0}}`). Seul le
|
||||
sous-type "regular" a été observé en pratique ; les sous-types "fraction" ou
|
||||
similaires (non confirmés) sont retournés bruts par `_resolve_value` en cas
|
||||
de format inconnu, plutôt que de faire planter la résolution.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -67,8 +102,15 @@ class CookCliApiClient:
|
|||
return self._normalize_recipe_list(data)
|
||||
|
||||
async def async_get_recipe(self, path: str) -> dict[str, Any]:
|
||||
"""Récupère le détail complet d'une recette (ingredients + steps)."""
|
||||
return await self._request("GET", f"/recipes/{path}")
|
||||
"""Récupère le détail d'une recette, avec étapes déjà résolues.
|
||||
|
||||
La réponse brute de CookCLI référence les ingrédients/ustensiles/
|
||||
minuteurs par index dans les étapes ; ce qui est renvoyé ici a déjà
|
||||
remplacé ces index par les objets correspondants (voir
|
||||
`_resolve_recipe`).
|
||||
"""
|
||||
data = await self._request("GET", f"/recipes/{path}")
|
||||
return _resolve_recipe(data)
|
||||
|
||||
async def _request(self, method: str, endpoint: str) -> Any:
|
||||
url = f"{self._base_url}{endpoint}"
|
||||
|
|
@ -93,35 +135,177 @@ class CookCliApiClient:
|
|||
|
||||
@staticmethod
|
||||
def _normalize_recipe_list(data: Any) -> list[RecipeSummary]:
|
||||
"""Normalise la réponse de listing en une liste plate de RecipeSummary.
|
||||
|
||||
Encaisse plusieurs formes possibles :
|
||||
- une liste de chemins/objets directement
|
||||
- un dict avec une clé "recipes" ou "index"
|
||||
- une arborescence imbriquée par dossier
|
||||
"""
|
||||
items: list[Any]
|
||||
if isinstance(data, list):
|
||||
items = data
|
||||
elif isinstance(data, dict):
|
||||
items = data.get("recipes") or data.get("index") or []
|
||||
else:
|
||||
items = []
|
||||
"""Aplatit l'arborescence renvoyée par GET /api/recipes en liste plate."""
|
||||
if not isinstance(data, dict):
|
||||
_LOGGER.warning(
|
||||
"Format de /api/recipes inattendu (attendu un objet, reçu %s)",
|
||||
type(data),
|
||||
)
|
||||
return []
|
||||
|
||||
root_path = data.get("path", "")
|
||||
summaries: list[RecipeSummary] = []
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
summaries.append(RecipeSummary(path=item, name=_name_from_path(item)))
|
||||
elif isinstance(item, dict):
|
||||
path = item.get("path") or item.get("slug") or item.get("name", "")
|
||||
name = item.get("name") or _name_from_path(path)
|
||||
summaries.append(RecipeSummary(path=path, name=name, raw=item))
|
||||
else:
|
||||
_LOGGER.debug("Entrée de recette ignorée (format inattendu): %s", item)
|
||||
|
||||
CookCliApiClient._walk_recipe_tree(data, root_path, summaries)
|
||||
return summaries
|
||||
|
||||
@staticmethod
|
||||
def _walk_recipe_tree(
|
||||
node: dict[str, Any], root_path: str, out: list[RecipeSummary]
|
||||
) -> None:
|
||||
"""Parcourt récursivement un nœud de l'arborescence de recettes."""
|
||||
recipe = node.get("recipe")
|
||||
if recipe is not None:
|
||||
abs_path = node.get("path", "")
|
||||
rel_path = abs_path
|
||||
if root_path and abs_path.startswith(root_path):
|
||||
rel_path = abs_path[len(root_path) :].lstrip("/")
|
||||
name = node.get("name") or _name_from_path(rel_path)
|
||||
out.append(RecipeSummary(path=rel_path, name=name, raw=node))
|
||||
|
||||
for child in (node.get("children") or {}).values():
|
||||
CookCliApiClient._walk_recipe_tree(child, root_path, out)
|
||||
|
||||
|
||||
def _name_from_path(path: str) -> str:
|
||||
"""Dérive un nom lisible depuis un chemin de fichier .cook."""
|
||||
return path.rsplit("/", 1)[-1].removesuffix(".cook")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Résolution du détail d'une recette : remplace les références par index
|
||||
# (ingredient/cookware/timer) par les objets correspondants, et aplatit les
|
||||
# valeurs numériques imbriquées du parser cooklang-rs.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_number(number_obj: Any) -> Any:
|
||||
"""Résout un objet 'number' cooklang-rs en valeur simple.
|
||||
|
||||
Seul le sous-type "regular" (nombre décimal simple) a été observé en
|
||||
pratique. Les autres sous-types (fractions, valeurs mixtes...) ne sont
|
||||
pas confirmés : on renvoie l'objet brut plutôt que de deviner un format.
|
||||
"""
|
||||
if not isinstance(number_obj, dict):
|
||||
return number_obj
|
||||
if number_obj.get("type") == "regular":
|
||||
return number_obj.get("value")
|
||||
_LOGGER.debug("Sous-type de nombre non géré, valeur brute conservée: %s", number_obj)
|
||||
return number_obj
|
||||
|
||||
|
||||
def _resolve_value(value_obj: Any) -> Any:
|
||||
"""Résout un objet 'value' générique (nombre, texte...) en valeur simple."""
|
||||
if not isinstance(value_obj, dict):
|
||||
return value_obj
|
||||
|
||||
value_type = value_obj.get("type")
|
||||
inner = value_obj.get("value")
|
||||
|
||||
if value_type == "number":
|
||||
return _resolve_number(inner)
|
||||
if value_type == "text":
|
||||
return inner
|
||||
|
||||
_LOGGER.debug("Type de valeur non géré, valeur brute conservée: %s", value_obj)
|
||||
return inner if inner is not None else value_obj
|
||||
|
||||
|
||||
def _resolve_quantity(quantity_obj: Any) -> dict[str, Any] | None:
|
||||
"""Résout une quantité (ingredient/cookware/timer) en {value, unit}."""
|
||||
if not isinstance(quantity_obj, dict):
|
||||
return None
|
||||
return {
|
||||
"value": _resolve_value(quantity_obj.get("value")),
|
||||
"unit": quantity_obj.get("unit"),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_ingredient(ingredient: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"name": ingredient.get("name"),
|
||||
"quantity": _resolve_quantity(ingredient.get("quantity")),
|
||||
"note": ingredient.get("note"),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_cookware(cookware: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"name": cookware.get("name"),
|
||||
"quantity": _resolve_quantity(cookware.get("quantity")),
|
||||
"note": cookware.get("note"),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_timer(timer: dict[str, Any]) -> dict[str, Any]:
|
||||
quantity = timer.get("quantity") or {}
|
||||
return {
|
||||
"name": timer.get("name"),
|
||||
"duration": _resolve_value(quantity.get("value")),
|
||||
"unit": quantity.get("unit"),
|
||||
}
|
||||
|
||||
|
||||
def _resolve_step_item(
|
||||
item: dict[str, Any],
|
||||
ingredients: list[dict[str, Any]],
|
||||
cookware: list[dict[str, Any]],
|
||||
timers: list[dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
"""Remplace un item d'étape référencé par index par l'objet résolu."""
|
||||
item_type = item.get("type")
|
||||
|
||||
if item_type == "text":
|
||||
return {"type": "text", "value": item.get("value", "")}
|
||||
|
||||
lookup = {"ingredient": ingredients, "cookware": cookware, "timer": timers}
|
||||
if item_type in lookup:
|
||||
index = item.get("index")
|
||||
objects = lookup[item_type]
|
||||
resolved = objects[index] if index is not None and 0 <= index < len(objects) else {}
|
||||
if not resolved:
|
||||
_LOGGER.debug(
|
||||
"Index %s introuvable pour un item de type %s", index, item_type
|
||||
)
|
||||
return {"type": item_type, **resolved}
|
||||
|
||||
_LOGGER.debug("Type d'item d'étape non géré, conservé brut: %s", item_type)
|
||||
return {"type": item_type, "raw": item}
|
||||
|
||||
|
||||
def _resolve_recipe(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Transforme la réponse brute de GET /api/recipes/{path} en structure
|
||||
directement exploitable (titre, ingrédients, étapes avec items résolus).
|
||||
"""
|
||||
recipe = data.get("recipe") or {}
|
||||
metadata = (recipe.get("metadata") or {}).get("map") or {}
|
||||
|
||||
ingredients = [_resolve_ingredient(i) for i in recipe.get("ingredients", [])]
|
||||
cookware = [_resolve_cookware(c) for c in recipe.get("cookware", [])]
|
||||
timers = [_resolve_timer(t) for t in recipe.get("timers", [])]
|
||||
|
||||
sections: list[dict[str, Any]] = []
|
||||
for section in recipe.get("sections", []):
|
||||
steps: list[dict[str, Any]] = []
|
||||
for content_item in section.get("content", []):
|
||||
if content_item.get("type") != "step":
|
||||
_LOGGER.debug(
|
||||
"Type de contenu de section ignoré: %s", content_item.get("type")
|
||||
)
|
||||
continue
|
||||
step_value = content_item.get("value") or {}
|
||||
items = [
|
||||
_resolve_step_item(it, ingredients, cookware, timers)
|
||||
for it in step_value.get("items", [])
|
||||
]
|
||||
steps.append({"number": step_value.get("number"), "items": items})
|
||||
sections.append({"name": section.get("name"), "steps": steps})
|
||||
|
||||
return {
|
||||
"title": metadata.get("title"),
|
||||
"image": data.get("image"),
|
||||
"scale": data.get("scale"),
|
||||
"metadata": metadata,
|
||||
"ingredients": ingredients,
|
||||
"cookware": cookware,
|
||||
"sections": sections,
|
||||
}
|
||||
Loading…
Reference in a new issue