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/__init__.py +5 -0
- chefkoch_cli/__main__.py +8 -0
- chefkoch_cli/cli.py +447 -0
- chefkoch_cli/client.py +192 -0
- chefkoch_cli/errors.py +63 -0
- chefkoch_cli/formatting.py +136 -0
- chefkoch_cli/parsing.py +197 -0
- chefkoch_cli/service.py +360 -0
- chefkoch_cli/watch.py +188 -0
- chefkoch_cli-0.1.1.dist-info/METADATA +136 -0
- chefkoch_cli-0.1.1.dist-info/RECORD +15 -0
- chefkoch_cli-0.1.1.dist-info/WHEEL +5 -0
- chefkoch_cli-0.1.1.dist-info/entry_points.txt +2 -0
- chefkoch_cli-0.1.1.dist-info/licenses/LICENSE +21 -0
- chefkoch_cli-0.1.1.dist-info/top_level.txt +1 -0
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)
|
chefkoch_cli/parsing.py
ADDED
|
@@ -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
|
+
}
|