chefkoch-cli 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
chefkoch_cli/errors.py ADDED
@@ -0,0 +1,63 @@
1
+ """Fehlerklassen mit festem Exit-Code und JSON-Typ."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CliError(Exception):
7
+ exit_code = 1
8
+ error_type = "internal"
9
+
10
+ def __init__(self, message: str, *, next_step: str | None = None) -> None:
11
+ super().__init__(message)
12
+ self.message = message
13
+ self.next_step = next_step
14
+
15
+ def full_message(self) -> str:
16
+ if self.next_step:
17
+ return f"{self.message} {self.next_step}".strip()
18
+ return self.message
19
+
20
+
21
+ class UsageError(CliError):
22
+ exit_code = 2
23
+ error_type = "usage"
24
+
25
+
26
+ class StateError(CliError):
27
+ exit_code = 1
28
+ error_type = "state"
29
+
30
+
31
+ class ConfigError(CliError):
32
+ exit_code = 1
33
+ error_type = "internal"
34
+
35
+
36
+ class UserAbortError(CliError):
37
+ exit_code = 1
38
+ error_type = "internal"
39
+
40
+
41
+ class NetworkError(CliError):
42
+ exit_code = 3
43
+ error_type = "network"
44
+
45
+
46
+ class HttpError(CliError):
47
+ exit_code = 3
48
+ error_type = "http"
49
+
50
+
51
+ class NotFoundError(CliError):
52
+ exit_code = 3
53
+ error_type = "not_found"
54
+
55
+
56
+ class NoResultsError(CliError):
57
+ exit_code = 3
58
+ error_type = "no_results"
59
+
60
+
61
+ class ParseError(CliError):
62
+ exit_code = 4
63
+ error_type = "parse"
@@ -0,0 +1,136 @@
1
+ """Lesbare Terminal-Ausgabe und Zahlenformatierung."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
7
+ from typing import Any
8
+
9
+
10
+ def format_amount(value: object) -> str:
11
+ if value is None or isinstance(value, bool):
12
+ return "0"
13
+ try:
14
+ number = float(value)
15
+ except (TypeError, ValueError):
16
+ return "0"
17
+ if not math.isfinite(number):
18
+ return "0"
19
+ try:
20
+ rounded = Decimal(str(number)).quantize(
21
+ Decimal("0.01"), rounding=ROUND_HALF_UP
22
+ )
23
+ except (InvalidOperation, ValueError):
24
+ return "0"
25
+ text = format(rounded, "f")
26
+ if "." in text:
27
+ text = text.rstrip("0").rstrip(".")
28
+ if text in {"", "-0"}:
29
+ text = "0"
30
+ return text.replace(".", ",")
31
+
32
+
33
+ def format_rating(value: object) -> str:
34
+ if value is None:
35
+ return "–"
36
+ try:
37
+ number = float(value)
38
+ except (TypeError, ValueError):
39
+ return "–"
40
+ if not math.isfinite(number):
41
+ return "–"
42
+ return f"{number:.1f}".replace(".", ",")
43
+
44
+
45
+ def format_minutes(value: object) -> str:
46
+ if value is None:
47
+ return "? Min."
48
+ try:
49
+ number = int(value)
50
+ except (TypeError, ValueError):
51
+ return "? Min."
52
+ return f"{number} Min."
53
+
54
+
55
+ def _difficulty(value: object) -> str:
56
+ text = str(value or "").strip()
57
+ return text or "?"
58
+
59
+
60
+ def format_search_line(index: int, item: dict[str, Any]) -> str:
61
+ title = str(item.get("title") or "Ohne Titel")
62
+ recipe_id = str(item.get("id") or "?")
63
+ rating = format_rating(item.get("rating"))
64
+ preparation = format_minutes(item.get("preparationTime"))
65
+ difficulty = _difficulty(item.get("difficultyLabel"))
66
+ return f"{index}) {title} (ID {recipe_id}) · {rating}★ · {preparation} · {difficulty}"
67
+
68
+
69
+ def _amount_text(value: object) -> str:
70
+ if value is None:
71
+ return ""
72
+ text = format_amount(value)
73
+ return "" if text == "0" else text
74
+
75
+
76
+ def _ingredient_line(name: str, unit: str, amount: object) -> str:
77
+ parts = (_amount_text(amount), unit, name)
78
+ return " ".join(part for part in parts if part).strip()
79
+
80
+
81
+ def format_ingredient(ingredient: dict[str, Any]) -> str:
82
+ name = str(ingredient.get("name") or "").strip()
83
+ unit = str(ingredient.get("unit") or "").strip()
84
+ return _ingredient_line(name, unit, ingredient.get("amount"))
85
+
86
+
87
+ def format_recipe_text(recipe: dict[str, Any]) -> str:
88
+ lines: list[str] = []
89
+ title = str(recipe.get("title") or "Ohne Titel").strip()
90
+ lines.append(title)
91
+ subtitle = str(recipe.get("subtitle") or "").strip()
92
+ if subtitle:
93
+ lines.append(subtitle)
94
+ lines.append(
95
+ f"Portionen: {recipe.get('requestedServings')} "
96
+ f"(Basis: {recipe.get('servings')})"
97
+ )
98
+ meta = [
99
+ f"Bewertung: {format_rating(recipe.get('rating'))}★",
100
+ f"Zubereitung: {format_minutes(recipe.get('preparationTime'))}",
101
+ f"Schwierigkeit: {_difficulty(recipe.get('difficultyLabel'))}",
102
+ ]
103
+ lines.append(" · ".join(meta))
104
+
105
+ groups = recipe.get("ingredientGroups") or []
106
+ if groups:
107
+ lines.append("")
108
+ for group in groups:
109
+ header = str(group.get("header") or "").strip()
110
+ if header:
111
+ lines.append(header)
112
+ for ingredient in group.get("ingredients") or []:
113
+ lines.append(format_ingredient(ingredient))
114
+
115
+ instructions = str(recipe.get("instructions") or "").strip()
116
+ if instructions:
117
+ lines.extend(["", "Zubereitung:", instructions])
118
+ return "\n".join(lines)
119
+
120
+
121
+ def format_shopping_line(item: dict[str, Any]) -> str:
122
+ name = str(item.get("name") or "").strip()
123
+ unit = str(item.get("unit") or "").strip()
124
+ return _ingredient_line(name, unit, item.get("amount"))
125
+
126
+
127
+ def format_shopping_text(
128
+ items: list[dict[str, Any]], portions: int | None
129
+ ) -> str:
130
+ if portions is None:
131
+ header = "Einkaufsliste"
132
+ else:
133
+ header = f"Einkaufsliste ({portions} Portionen je Rezept)"
134
+ lines = [header]
135
+ lines.extend(format_shopping_line(item) for item in items)
136
+ return "\n".join(lines)
@@ -0,0 +1,197 @@
1
+ """Normalisierung der Chefkoch-API-Antworten."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from typing import Any
7
+
8
+ from .errors import ParseError
9
+
10
+ DIFFICULTY_LABELS = {1: "simpel", 2: "normal", 3: "pfiffig"}
11
+ TAG_VEGETARISCH = 32
12
+ TAG_VEGAN = 57
13
+
14
+
15
+ def _text(value: object, default: str = "") -> str:
16
+ if value is None:
17
+ return default
18
+ return str(value)
19
+
20
+
21
+ def _number(value: object, default: float = 0.0) -> float:
22
+ if value is None or isinstance(value, bool):
23
+ return default
24
+ try:
25
+ number = float(value)
26
+ except (TypeError, ValueError):
27
+ return default
28
+ if not math.isfinite(number):
29
+ return default
30
+ return number
31
+
32
+
33
+ def _integer(value: object, default: int = 0) -> int:
34
+ number = _number(value, float(default))
35
+ return int(number)
36
+
37
+
38
+ def _optional_amount(value: object) -> float | None:
39
+ if value is None or isinstance(value, bool):
40
+ return None
41
+ try:
42
+ number = float(value)
43
+ except (TypeError, ValueError):
44
+ return None
45
+ if not math.isfinite(number) or number == 0:
46
+ return None
47
+ return number
48
+
49
+
50
+ def _rating_parts(value: object) -> tuple[float | None, int]:
51
+ if isinstance(value, dict):
52
+ rating = _number(value.get("rating"), 0.0)
53
+ votes = _integer(value.get("numVotes"), 0)
54
+ return (rating if value.get("rating") is not None else None), votes
55
+ if value is None:
56
+ return None, 0
57
+ return _number(value, 0.0), 0
58
+
59
+
60
+ def difficulty_label(value: object) -> str:
61
+ number = _integer(value, 0)
62
+ return DIFFICULTY_LABELS.get(number, "")
63
+
64
+
65
+ def normalize_search_result(item: object) -> dict[str, Any]:
66
+ if not isinstance(item, dict):
67
+ raise ParseError(
68
+ "Die Suche hat einen unerwarteten Treffer geliefert.",
69
+ next_step="Versuche es später erneut.",
70
+ )
71
+ recipe = item.get("recipe")
72
+ if not isinstance(recipe, dict):
73
+ recipe = item if "id" in item else {}
74
+ if not recipe:
75
+ raise ParseError(
76
+ "Die Suche hat einen Treffer ohne Rezeptdaten geliefert.",
77
+ next_step="Versuche es später erneut.",
78
+ )
79
+ rating, votes = _rating_parts(recipe.get("rating"))
80
+ preparation = recipe.get("preparationTime")
81
+ return {
82
+ "id": _text(recipe.get("id")),
83
+ "title": _text(recipe.get("title")),
84
+ "subtitle": _text(recipe.get("subtitle")),
85
+ "createdAt": _text(recipe.get("createdAt")),
86
+ "difficulty": _integer(recipe.get("difficulty"), 0) or None,
87
+ "difficultyLabel": difficulty_label(recipe.get("difficulty")),
88
+ "preparationTime": _integer(preparation, 0) if preparation is not None else None,
89
+ "rating": rating,
90
+ "numVotes": votes,
91
+ "siteUrl": _text(recipe.get("siteUrl")),
92
+ "slug": _text(recipe.get("slug")),
93
+ "vegetarian": None,
94
+ "vegan": None,
95
+ }
96
+
97
+
98
+ def normalize_search_payload(payload: dict[str, Any]) -> tuple[int, list[dict[str, Any]]]:
99
+ results = payload.get("results")
100
+ if not isinstance(results, list):
101
+ raise ParseError(
102
+ "Die Suchantwort der Chefkoch-API enthält keine Trefferliste.",
103
+ next_step="Versuche es später erneut.",
104
+ )
105
+ count = _integer(payload.get("count"), len(results))
106
+ return count, [normalize_search_result(item) for item in results]
107
+
108
+
109
+ def recipe_tag_ids(payload: dict[str, Any]) -> set[int]:
110
+ ids: set[int] = set()
111
+ full_tags = payload.get("fullTags")
112
+ if isinstance(full_tags, list):
113
+ for tag in full_tags:
114
+ if isinstance(tag, dict) and tag.get("id") is not None:
115
+ ids.add(_integer(tag.get("id"), 0))
116
+ if ids:
117
+ return ids
118
+ names = payload.get("tags")
119
+ if isinstance(names, list):
120
+ folded = {_text(name).casefold() for name in names}
121
+ if any("vegan" in name for name in folded):
122
+ ids.update({TAG_VEGAN, TAG_VEGETARISCH})
123
+ elif any("vegetarisch" in name for name in folded):
124
+ ids.add(TAG_VEGETARISCH)
125
+ return ids
126
+
127
+
128
+ def normalize_recipe(
129
+ payload: dict[str, Any], requested_servings: int | None = None
130
+ ) -> dict[str, Any]:
131
+ if not isinstance(payload, dict):
132
+ raise ParseError(
133
+ "Die Rezeptantwort der Chefkoch-API ist kein Objekt.",
134
+ next_step="Versuche es später erneut.",
135
+ )
136
+ servings = _integer(payload.get("servings"), 0)
137
+ if requested_servings is None:
138
+ requested = servings
139
+ else:
140
+ requested = int(requested_servings)
141
+ if servings > 0:
142
+ scale = requested / servings
143
+ else:
144
+ scale = 1.0
145
+
146
+ groups: list[dict[str, Any]] = []
147
+ raw_groups = payload.get("ingredientGroups")
148
+ if isinstance(raw_groups, list):
149
+ for group in raw_groups:
150
+ if not isinstance(group, dict):
151
+ continue
152
+ ingredients: list[dict[str, Any]] = []
153
+ raw_ingredients = group.get("ingredients")
154
+ if isinstance(raw_ingredients, list):
155
+ for ingredient in raw_ingredients:
156
+ if not isinstance(ingredient, dict):
157
+ continue
158
+ raw_amount = _optional_amount(ingredient.get("amount"))
159
+ amount = (
160
+ None if raw_amount is None else round(raw_amount * scale, 4)
161
+ )
162
+ ingredients.append(
163
+ {
164
+ "name": _text(ingredient.get("name")),
165
+ "amount": amount,
166
+ "unit": _text(ingredient.get("unit")),
167
+ }
168
+ )
169
+ groups.append(
170
+ {
171
+ "header": _text(group.get("header")),
172
+ "ingredients": ingredients,
173
+ }
174
+ )
175
+
176
+ rating, votes = _rating_parts(payload.get("rating"))
177
+ tags = payload.get("tags")
178
+ return {
179
+ "id": _text(payload.get("id")),
180
+ "title": _text(payload.get("title")),
181
+ "subtitle": _text(payload.get("subtitle")),
182
+ "servings": servings,
183
+ "requestedServings": requested,
184
+ "scale": round(scale, 6),
185
+ "rating": rating,
186
+ "numVotes": votes,
187
+ "difficulty": _integer(payload.get("difficulty"), 0) or None,
188
+ "difficultyLabel": difficulty_label(payload.get("difficulty")),
189
+ "preparationTime": _integer(payload.get("preparationTime"), 0),
190
+ "cookingTime": _integer(payload.get("cookingTime"), 0),
191
+ "restingTime": _integer(payload.get("restingTime"), 0),
192
+ "totalTime": _integer(payload.get("totalTime"), 0),
193
+ "siteUrl": _text(payload.get("siteUrl")),
194
+ "instructions": _text(payload.get("instructions")),
195
+ "tags": [str(tag) for tag in tags] if isinstance(tags, list) else [],
196
+ "ingredientGroups": groups,
197
+ }