Update api.py

This commit is contained in:
TheThomaas 2026-09-13 21:04:34 +02:00
parent 1156722b4d
commit 53fc14dbef

View file

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