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/__init__.py
ADDED
chefkoch_cli/__main__.py
ADDED
chefkoch_cli/cli.py
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
"""Kommandozeilenschnittstelle für chefkoch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import sys
|
|
9
|
+
from typing import Any, Sequence
|
|
10
|
+
|
|
11
|
+
from . import __version__, service
|
|
12
|
+
from .client import ApiClient
|
|
13
|
+
from .errors import (
|
|
14
|
+
CliError,
|
|
15
|
+
NoResultsError,
|
|
16
|
+
UsageError,
|
|
17
|
+
UserAbortError,
|
|
18
|
+
)
|
|
19
|
+
from .formatting import format_recipe_text, format_search_line, format_shopping_text
|
|
20
|
+
from .parsing import normalize_recipe
|
|
21
|
+
from .watch import run_watch
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Parser(argparse.ArgumentParser):
|
|
25
|
+
def error(self, message: str) -> None: # type: ignore[override]
|
|
26
|
+
raise UsageError(
|
|
27
|
+
message,
|
|
28
|
+
next_step="Nutze `chefkoch --help` für die Übersicht.",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _common_options() -> argparse.ArgumentParser:
|
|
33
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
34
|
+
common.add_argument(
|
|
35
|
+
"--json",
|
|
36
|
+
action="store_true",
|
|
37
|
+
default=argparse.SUPPRESS,
|
|
38
|
+
help="JSON auf stdout ausgeben",
|
|
39
|
+
)
|
|
40
|
+
common.add_argument(
|
|
41
|
+
"--timeout",
|
|
42
|
+
type=float,
|
|
43
|
+
default=argparse.SUPPRESS,
|
|
44
|
+
metavar="SEK",
|
|
45
|
+
help="Timeout pro Request in Sekunden (Default 15)",
|
|
46
|
+
)
|
|
47
|
+
return common
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _add_search_filters(parser: argparse.ArgumentParser) -> None:
|
|
51
|
+
group = parser.add_mutually_exclusive_group()
|
|
52
|
+
group.add_argument(
|
|
53
|
+
"--vegetarisch",
|
|
54
|
+
action="store_true",
|
|
55
|
+
help="nur vegetarische Rezepte",
|
|
56
|
+
)
|
|
57
|
+
group.add_argument(
|
|
58
|
+
"--vegan",
|
|
59
|
+
action="store_true",
|
|
60
|
+
help="nur vegane Rezepte",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--max-zeit",
|
|
64
|
+
type=int,
|
|
65
|
+
default=None,
|
|
66
|
+
metavar="MIN",
|
|
67
|
+
help="maximale Zubereitungszeit in Minuten",
|
|
68
|
+
)
|
|
69
|
+
parser.add_argument(
|
|
70
|
+
"--min-bewertung",
|
|
71
|
+
type=float,
|
|
72
|
+
default=None,
|
|
73
|
+
metavar="X",
|
|
74
|
+
help="minimale Bewertung (0 bis 5)",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--schwierigkeit",
|
|
78
|
+
choices=("simpel", "normal", "pfiffig"),
|
|
79
|
+
default=None,
|
|
80
|
+
help="Schwierigkeitsgrad",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
85
|
+
common = _common_options()
|
|
86
|
+
parser = _Parser(
|
|
87
|
+
prog="chefkoch",
|
|
88
|
+
description="Chefkoch.de-Rezepte im Terminal: Suchen, Anzeigen, Einkaufsliste, Watch.",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--version",
|
|
92
|
+
action="version",
|
|
93
|
+
version=f"chefkoch-cli {__version__}",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--json",
|
|
97
|
+
action="store_true",
|
|
98
|
+
default=False,
|
|
99
|
+
help="JSON auf stdout ausgeben",
|
|
100
|
+
)
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
"--timeout",
|
|
103
|
+
type=float,
|
|
104
|
+
default=15.0,
|
|
105
|
+
metavar="SEK",
|
|
106
|
+
help="Timeout pro Request in Sekunden (Default 15)",
|
|
107
|
+
)
|
|
108
|
+
subparsers = parser.add_subparsers(dest="command", metavar="BEFEHL")
|
|
109
|
+
|
|
110
|
+
suche = subparsers.add_parser(
|
|
111
|
+
"suche",
|
|
112
|
+
parents=[common],
|
|
113
|
+
help="Rezepte nach einem Begriff suchen",
|
|
114
|
+
description="Rezepte nach einem Begriff suchen und filtern.",
|
|
115
|
+
)
|
|
116
|
+
suche.add_argument("begriff", help="Suchbegriff")
|
|
117
|
+
_add_search_filters(suche)
|
|
118
|
+
suche.add_argument(
|
|
119
|
+
"--sort",
|
|
120
|
+
choices=service.SORT_CHOICES,
|
|
121
|
+
default="bewertung",
|
|
122
|
+
help="Sortierung (Default bewertung)",
|
|
123
|
+
)
|
|
124
|
+
suche.add_argument(
|
|
125
|
+
"--limit",
|
|
126
|
+
type=int,
|
|
127
|
+
default=service.DEFAULT_LIMIT,
|
|
128
|
+
metavar="N",
|
|
129
|
+
help="Treffer pro Seite (Default 10)",
|
|
130
|
+
)
|
|
131
|
+
suche.add_argument(
|
|
132
|
+
"--seite",
|
|
133
|
+
type=int,
|
|
134
|
+
default=1,
|
|
135
|
+
metavar="N",
|
|
136
|
+
help="Seite (Default 1)",
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
rezept = subparsers.add_parser(
|
|
140
|
+
"rezept",
|
|
141
|
+
parents=[common],
|
|
142
|
+
help="Ein Rezept per Name, ID oder URL anzeigen",
|
|
143
|
+
description="Ein Rezept anzeigen und Zutaten auf Portionen skalieren.",
|
|
144
|
+
)
|
|
145
|
+
rezept.add_argument("referenz", metavar="NAME|ID|URL", help="Rezeptname, ID oder URL")
|
|
146
|
+
rezept.add_argument(
|
|
147
|
+
"--portionen",
|
|
148
|
+
type=int,
|
|
149
|
+
default=None,
|
|
150
|
+
metavar="N",
|
|
151
|
+
help="Zielportionen (Default: Portionen des Rezepts)",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
einkauf = subparsers.add_parser(
|
|
155
|
+
"einkaufsliste",
|
|
156
|
+
parents=[common],
|
|
157
|
+
help="Einkaufsliste aus mehreren Rezepten erzeugen",
|
|
158
|
+
description="Zutaten mehrerer Rezepte skalieren und zusammenfassen.",
|
|
159
|
+
)
|
|
160
|
+
einkauf.add_argument(
|
|
161
|
+
"referenzen",
|
|
162
|
+
nargs="+",
|
|
163
|
+
metavar="NAME|ID|URL",
|
|
164
|
+
help="Rezeptnamen, IDs oder URLs",
|
|
165
|
+
)
|
|
166
|
+
einkauf.add_argument(
|
|
167
|
+
"--portionen",
|
|
168
|
+
type=int,
|
|
169
|
+
default=None,
|
|
170
|
+
metavar="N",
|
|
171
|
+
help="Zielportionen für jedes Rezept",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
watch = subparsers.add_parser(
|
|
175
|
+
"watch",
|
|
176
|
+
parents=[common],
|
|
177
|
+
help="Neue Treffer zu einem Suchbegriff verfolgen",
|
|
178
|
+
description="Treffer merken und beim nächsten Lauf neue Rezepte zeigen.",
|
|
179
|
+
)
|
|
180
|
+
watch.add_argument("begriff", help="Suchbegriff")
|
|
181
|
+
_add_search_filters(watch)
|
|
182
|
+
watch.add_argument(
|
|
183
|
+
"--limit",
|
|
184
|
+
type=int,
|
|
185
|
+
default=service.DEFAULT_WATCH_LIMIT,
|
|
186
|
+
metavar="N",
|
|
187
|
+
help="höchstens N Treffer betrachten (Default 50)",
|
|
188
|
+
)
|
|
189
|
+
return parser
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _validated_timeout(value: object) -> float:
|
|
193
|
+
try:
|
|
194
|
+
timeout = float(value)
|
|
195
|
+
except (TypeError, ValueError):
|
|
196
|
+
raise UsageError(
|
|
197
|
+
f"--timeout ist keine Zahl: {value!r}.",
|
|
198
|
+
next_step="Beispiel: --timeout 15",
|
|
199
|
+
) from None
|
|
200
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
201
|
+
raise UsageError(
|
|
202
|
+
f"--timeout muss eine endliche Zahl > 0 sein: {value!r}.",
|
|
203
|
+
next_step="Beispiel: --timeout 15",
|
|
204
|
+
)
|
|
205
|
+
return timeout
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _emit_success(
|
|
209
|
+
command: str, data: dict[str, Any], text: str, json_mode: bool
|
|
210
|
+
) -> None:
|
|
211
|
+
if json_mode:
|
|
212
|
+
print(
|
|
213
|
+
json.dumps(
|
|
214
|
+
{"ok": True, "command": command, "data": data},
|
|
215
|
+
ensure_ascii=False,
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
print(text)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _emit_error(error: CliError, json_mode: bool) -> int:
|
|
223
|
+
message = error.full_message()
|
|
224
|
+
if json_mode:
|
|
225
|
+
print(
|
|
226
|
+
json.dumps(
|
|
227
|
+
{"ok": False, "error": {"type": error.error_type, "message": message}},
|
|
228
|
+
ensure_ascii=False,
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
else:
|
|
232
|
+
print(f"Fehler: {message}", file=sys.stderr)
|
|
233
|
+
return error.exit_code
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _search_text(data: dict[str, Any]) -> str:
|
|
237
|
+
return "\n".join(
|
|
238
|
+
format_search_line(index, item)
|
|
239
|
+
for index, item in enumerate(data.get("results") or [], start=1)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _watch_text(data: dict[str, Any]) -> str:
|
|
244
|
+
query = data.get("query") or ""
|
|
245
|
+
results = list(data.get("new") or [])
|
|
246
|
+
if not results:
|
|
247
|
+
return f"Keine neuen Rezepte für {query!r}."
|
|
248
|
+
return "\n".join(
|
|
249
|
+
[f"Neue Rezepte für {query!r}:", _result_list_text(results)]
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _result_list_text(results: Sequence[dict[str, Any]]) -> str:
|
|
254
|
+
return "\n".join(
|
|
255
|
+
format_search_line(index, item)
|
|
256
|
+
for index, item in enumerate(results, start=1)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def choose_result(
|
|
261
|
+
results: list[dict[str, Any]],
|
|
262
|
+
*,
|
|
263
|
+
json_mode: bool,
|
|
264
|
+
query: str,
|
|
265
|
+
) -> dict[str, Any]:
|
|
266
|
+
if not results:
|
|
267
|
+
raise NoResultsError(
|
|
268
|
+
f"Kein Rezept zu {query!r} gefunden.",
|
|
269
|
+
next_step="Nutze einen anderen Namen oder `chefkoch suche ...`.",
|
|
270
|
+
)
|
|
271
|
+
if len(results) == 1:
|
|
272
|
+
return results[0]
|
|
273
|
+
|
|
274
|
+
listing = _result_list_text(results)
|
|
275
|
+
interactive = sys.stdin.isatty() and sys.stdout.isatty()
|
|
276
|
+
if not interactive:
|
|
277
|
+
print(listing, file=sys.stderr)
|
|
278
|
+
raise UsageError(
|
|
279
|
+
f"Der Name {query!r} ist mehrdeutig ({len(results)} Treffer).",
|
|
280
|
+
next_step="Nutze eine ID/URL oder starte den Befehl im Terminal.",
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
print(listing, file=sys.stderr if json_mode else sys.stdout)
|
|
284
|
+
while True:
|
|
285
|
+
print(
|
|
286
|
+
f"Auswahl [1-{len(results)}]: ",
|
|
287
|
+
end="",
|
|
288
|
+
file=sys.stderr,
|
|
289
|
+
flush=True,
|
|
290
|
+
)
|
|
291
|
+
try:
|
|
292
|
+
answer = sys.stdin.readline()
|
|
293
|
+
except KeyboardInterrupt:
|
|
294
|
+
raise UserAbortError(
|
|
295
|
+
"Auswahl abgebrochen.",
|
|
296
|
+
next_step="Starte den Befehl erneut und wähle eine Nummer.",
|
|
297
|
+
) from None
|
|
298
|
+
if answer == "":
|
|
299
|
+
raise UserAbortError(
|
|
300
|
+
"Auswahl abgebrochen (EOF).",
|
|
301
|
+
next_step="Starte den Befehl erneut und wähle eine Nummer.",
|
|
302
|
+
)
|
|
303
|
+
answer = answer.strip()
|
|
304
|
+
if answer.isdigit() and 1 <= int(answer) <= len(results):
|
|
305
|
+
return results[int(answer) - 1]
|
|
306
|
+
print(
|
|
307
|
+
f"Bitte eine Zahl von 1 bis {len(results)} eingeben.",
|
|
308
|
+
file=sys.stderr,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _fetch_recipe(
|
|
313
|
+
client: ApiClient,
|
|
314
|
+
reference: str,
|
|
315
|
+
requested_servings: int | None,
|
|
316
|
+
*,
|
|
317
|
+
json_mode: bool,
|
|
318
|
+
) -> dict[str, Any]:
|
|
319
|
+
kind, value = service.parse_recipe_reference(reference)
|
|
320
|
+
if kind == "name":
|
|
321
|
+
results = service.search_candidates(client, value)
|
|
322
|
+
chosen = choose_result(results, json_mode=json_mode, query=value)
|
|
323
|
+
recipe_id = str(chosen.get("id"))
|
|
324
|
+
else:
|
|
325
|
+
recipe_id = value
|
|
326
|
+
payload = client.recipe(recipe_id)
|
|
327
|
+
return normalize_recipe(payload, requested_servings)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _run(args: argparse.Namespace, timeout: float, json_mode: bool) -> None:
|
|
331
|
+
if args.command == "suche":
|
|
332
|
+
with ApiClient(timeout=timeout) as client:
|
|
333
|
+
data = service.search_recipes(
|
|
334
|
+
client,
|
|
335
|
+
args.begriff,
|
|
336
|
+
vegetarisch=args.vegetarisch,
|
|
337
|
+
vegan=args.vegan,
|
|
338
|
+
max_zeit=args.max_zeit,
|
|
339
|
+
min_bewertung=args.min_bewertung,
|
|
340
|
+
schwierigkeit=args.schwierigkeit,
|
|
341
|
+
sort=args.sort,
|
|
342
|
+
limit=args.limit,
|
|
343
|
+
seite=args.seite,
|
|
344
|
+
)
|
|
345
|
+
_emit_success("suche", data, _search_text(data), json_mode)
|
|
346
|
+
return
|
|
347
|
+
if args.command == "rezept":
|
|
348
|
+
portions = service.validate_servings(args.portionen)
|
|
349
|
+
with ApiClient(timeout=timeout) as client:
|
|
350
|
+
recipe = _fetch_recipe(
|
|
351
|
+
client,
|
|
352
|
+
args.referenz,
|
|
353
|
+
portions,
|
|
354
|
+
json_mode=json_mode,
|
|
355
|
+
)
|
|
356
|
+
_emit_success("rezept", recipe, format_recipe_text(recipe), json_mode)
|
|
357
|
+
return
|
|
358
|
+
if args.command == "einkaufsliste":
|
|
359
|
+
portions = service.validate_servings(args.portionen)
|
|
360
|
+
recipes: list[dict[str, Any]] = []
|
|
361
|
+
with ApiClient(timeout=timeout) as client:
|
|
362
|
+
for reference in args.referenzen:
|
|
363
|
+
recipes.append(
|
|
364
|
+
_fetch_recipe(
|
|
365
|
+
client,
|
|
366
|
+
reference,
|
|
367
|
+
portions,
|
|
368
|
+
json_mode=json_mode,
|
|
369
|
+
)
|
|
370
|
+
)
|
|
371
|
+
data = service.build_shopping_list(recipes, portions)
|
|
372
|
+
_emit_success(
|
|
373
|
+
"einkaufsliste",
|
|
374
|
+
data,
|
|
375
|
+
format_shopping_text(data["items"], portions),
|
|
376
|
+
json_mode,
|
|
377
|
+
)
|
|
378
|
+
return
|
|
379
|
+
if args.command == "watch":
|
|
380
|
+
with ApiClient(timeout=timeout) as client:
|
|
381
|
+
data = run_watch(
|
|
382
|
+
client,
|
|
383
|
+
args.begriff,
|
|
384
|
+
vegetarisch=args.vegetarisch,
|
|
385
|
+
vegan=args.vegan,
|
|
386
|
+
max_zeit=args.max_zeit,
|
|
387
|
+
min_bewertung=args.min_bewertung,
|
|
388
|
+
schwierigkeit=args.schwierigkeit,
|
|
389
|
+
limit=args.limit,
|
|
390
|
+
)
|
|
391
|
+
_emit_success("watch", data, _watch_text(data), json_mode)
|
|
392
|
+
return
|
|
393
|
+
raise UsageError(
|
|
394
|
+
"Unbekanntes Unterkommando.",
|
|
395
|
+
next_step="Nutze `chefkoch --help` für die Übersicht.",
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
400
|
+
arguments = list(sys.argv[1:] if argv is None else argv)
|
|
401
|
+
json_mode = "--json" in arguments or not sys.stdout.isatty()
|
|
402
|
+
try:
|
|
403
|
+
parser = build_parser()
|
|
404
|
+
args = parser.parse_args(arguments)
|
|
405
|
+
json_mode = bool(getattr(args, "json", False)) or not sys.stdout.isatty()
|
|
406
|
+
if not getattr(args, "command", None):
|
|
407
|
+
raise UsageError(
|
|
408
|
+
"Es fehlt ein Unterkommando.",
|
|
409
|
+
next_step="Nutze `chefkoch --help` für die Übersicht.",
|
|
410
|
+
)
|
|
411
|
+
timeout = _validated_timeout(getattr(args, "timeout", 15.0))
|
|
412
|
+
_run(args, timeout, json_mode)
|
|
413
|
+
return 0
|
|
414
|
+
except SystemExit as exc:
|
|
415
|
+
code = exc.code
|
|
416
|
+
if code is None:
|
|
417
|
+
return 0
|
|
418
|
+
if isinstance(code, int):
|
|
419
|
+
return code
|
|
420
|
+
return 2
|
|
421
|
+
except KeyboardInterrupt:
|
|
422
|
+
return _emit_error(
|
|
423
|
+
UserAbortError(
|
|
424
|
+
"Abgebrochen.",
|
|
425
|
+
next_step="Starte den Befehl erneut.",
|
|
426
|
+
),
|
|
427
|
+
json_mode,
|
|
428
|
+
)
|
|
429
|
+
except CliError as exc:
|
|
430
|
+
return _emit_error(exc, json_mode)
|
|
431
|
+
except BrokenPipeError:
|
|
432
|
+
try:
|
|
433
|
+
sys.stdout.close()
|
|
434
|
+
except OSError:
|
|
435
|
+
pass
|
|
436
|
+
return 1
|
|
437
|
+
except Exception as exc: # noqa: BLE001 - nie Tracebacks nach außen
|
|
438
|
+
return _emit_error(
|
|
439
|
+
CliError(
|
|
440
|
+
f"Unerwarteter Fehler: {exc}",
|
|
441
|
+
next_step="Versuche es erneut oder prüfe `chefkoch --help`.",
|
|
442
|
+
),
|
|
443
|
+
json_mode,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
__all__ = ["main", "build_parser", "choose_result", "format_recipe_text", "format_shopping_text"]
|
chefkoch_cli/client.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""HTTP-Client für die inoffizielle Chefkoch-API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .errors import ConfigError, HttpError, NetworkError, NotFoundError, ParseError
|
|
14
|
+
|
|
15
|
+
DEFAULT_BASE_URL = "https://api.chefkoch.de"
|
|
16
|
+
DEFAULT_TIMEOUT = 15.0
|
|
17
|
+
DEFAULT_MIN_INTERVAL = 1.0
|
|
18
|
+
USER_AGENT = "chefkoch-cli/0.1 (+https://github.com/smlfg/chefkoch-cli)"
|
|
19
|
+
ACCEPT = "application/json"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def validate_base_url(value: str) -> str:
|
|
23
|
+
text = str(value or "").strip()
|
|
24
|
+
if not text:
|
|
25
|
+
raise ConfigError(
|
|
26
|
+
"CHEFKOCH_BASE_URL ist leer.",
|
|
27
|
+
next_step="Setze eine gültige http(s)-URL oder entferne die Variable.",
|
|
28
|
+
)
|
|
29
|
+
parts = urlsplit(text)
|
|
30
|
+
if parts.scheme not in {"http", "https"} or not parts.netloc:
|
|
31
|
+
raise ConfigError(
|
|
32
|
+
f"CHEFKOCH_BASE_URL ist keine gültige http(s)-URL: {text!r}.",
|
|
33
|
+
next_step="Beispiel: https://api.chefkoch.de",
|
|
34
|
+
)
|
|
35
|
+
return text.rstrip("/")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def validate_min_interval(value: object) -> float:
|
|
39
|
+
try:
|
|
40
|
+
interval = float(value)
|
|
41
|
+
except (TypeError, ValueError):
|
|
42
|
+
raise ConfigError(
|
|
43
|
+
f"CHEFKOCH_MIN_INTERVAL ist keine Zahl: {value!r}.",
|
|
44
|
+
next_step="Nutze eine Zahl >= 0, z. B. 0 für Tests.",
|
|
45
|
+
) from None
|
|
46
|
+
if not math.isfinite(interval) or interval < 0:
|
|
47
|
+
raise ConfigError(
|
|
48
|
+
f"CHEFKOCH_MIN_INTERVAL muss eine endliche Zahl >= 0 sein: {value!r}.",
|
|
49
|
+
next_step="Nutze eine Zahl >= 0, z. B. 0 für Tests.",
|
|
50
|
+
)
|
|
51
|
+
return interval
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def validate_timeout(value: object) -> float:
|
|
55
|
+
try:
|
|
56
|
+
timeout = float(value)
|
|
57
|
+
except (TypeError, ValueError):
|
|
58
|
+
raise ConfigError(
|
|
59
|
+
f"Timeout ist keine Zahl: {value!r}.",
|
|
60
|
+
next_step="Nutze eine Zahl > 0, z. B. --timeout 15.",
|
|
61
|
+
) from None
|
|
62
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
63
|
+
raise ConfigError(
|
|
64
|
+
f"Timeout muss eine endliche Zahl > 0 sein: {value!r}.",
|
|
65
|
+
next_step="Nutze eine Zahl > 0, z. B. --timeout 15.",
|
|
66
|
+
)
|
|
67
|
+
return timeout
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ApiClient:
|
|
71
|
+
"""Kapselt Basis-URL, Timeout, User-Agent und Mindestabstand."""
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
base_url: str | None = None,
|
|
76
|
+
*,
|
|
77
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
78
|
+
min_interval: float | None = None,
|
|
79
|
+
transport: httpx.BaseTransport | None = None,
|
|
80
|
+
) -> None:
|
|
81
|
+
raw_base = base_url if base_url is not None else os.environ.get("CHEFKOCH_BASE_URL", DEFAULT_BASE_URL)
|
|
82
|
+
raw_interval = (
|
|
83
|
+
min_interval
|
|
84
|
+
if min_interval is not None
|
|
85
|
+
else os.environ.get("CHEFKOCH_MIN_INTERVAL", str(DEFAULT_MIN_INTERVAL))
|
|
86
|
+
)
|
|
87
|
+
self.base_url = validate_base_url(raw_base or DEFAULT_BASE_URL)
|
|
88
|
+
self.timeout = validate_timeout(timeout)
|
|
89
|
+
self.min_interval = validate_min_interval(raw_interval)
|
|
90
|
+
self._last_request: float | None = None
|
|
91
|
+
self._client = httpx.Client(
|
|
92
|
+
base_url=self.base_url,
|
|
93
|
+
headers={"User-Agent": USER_AGENT, "Accept": ACCEPT},
|
|
94
|
+
timeout=self.timeout,
|
|
95
|
+
transport=transport,
|
|
96
|
+
follow_redirects=True,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
def __enter__(self) -> "ApiClient":
|
|
100
|
+
return self
|
|
101
|
+
|
|
102
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
103
|
+
self.close()
|
|
104
|
+
|
|
105
|
+
def close(self) -> None:
|
|
106
|
+
self._client.close()
|
|
107
|
+
|
|
108
|
+
def _wait(self) -> None:
|
|
109
|
+
if self.min_interval <= 0 or self._last_request is None:
|
|
110
|
+
return
|
|
111
|
+
remaining = self.min_interval - (time.monotonic() - self._last_request)
|
|
112
|
+
if remaining > 0:
|
|
113
|
+
time.sleep(remaining)
|
|
114
|
+
|
|
115
|
+
def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
116
|
+
self._wait()
|
|
117
|
+
try:
|
|
118
|
+
response = self._client.get(path, params=params)
|
|
119
|
+
except httpx.TimeoutException:
|
|
120
|
+
raise NetworkError(
|
|
121
|
+
"Zeitüberschreitung beim Abruf der Chefkoch-API.",
|
|
122
|
+
next_step="Prüfe die Verbindung oder erhöhe --timeout.",
|
|
123
|
+
) from None
|
|
124
|
+
except httpx.HTTPError as exc:
|
|
125
|
+
raise NetworkError(
|
|
126
|
+
f"Netzwerkfehler beim Abruf der Chefkoch-API: {exc}.",
|
|
127
|
+
next_step="Prüfe die Verbindung und versuche es erneut.",
|
|
128
|
+
) from None
|
|
129
|
+
finally:
|
|
130
|
+
self._last_request = time.monotonic()
|
|
131
|
+
|
|
132
|
+
status = response.status_code
|
|
133
|
+
if status == 404:
|
|
134
|
+
if path.startswith("/v2/recipes/") and path.count("/") == 3:
|
|
135
|
+
raise NotFoundError(
|
|
136
|
+
f"Rezept {path.rsplit('/', 1)[-1]} wurde nicht gefunden.",
|
|
137
|
+
next_step="Prüfe die ID/URL oder suche mit `chefkoch suche ...`.",
|
|
138
|
+
)
|
|
139
|
+
raise HttpError(
|
|
140
|
+
"Die Chefkoch-API hat die Suche nicht gefunden (HTTP 404).",
|
|
141
|
+
next_step="Prüfe CHEFKOCH_BASE_URL oder versuche es später erneut.",
|
|
142
|
+
)
|
|
143
|
+
if status == 429:
|
|
144
|
+
raise HttpError(
|
|
145
|
+
"Die Chefkoch-API begrenzt die Anfragen (HTTP 429).",
|
|
146
|
+
next_step="Warte einen Moment und versuche es erneut.",
|
|
147
|
+
)
|
|
148
|
+
if 500 <= status <= 599:
|
|
149
|
+
raise HttpError(
|
|
150
|
+
f"Die Chefkoch-API meldet einen Serverfehler (HTTP {status}).",
|
|
151
|
+
next_step="Versuche es später erneut.",
|
|
152
|
+
)
|
|
153
|
+
if status != 200:
|
|
154
|
+
raise HttpError(
|
|
155
|
+
f"Unerwartete Antwort der Chefkoch-API (HTTP {status}).",
|
|
156
|
+
next_step="Prüfe CHEFKOCH_BASE_URL und versuche es erneut.",
|
|
157
|
+
)
|
|
158
|
+
try:
|
|
159
|
+
payload = response.json()
|
|
160
|
+
except ValueError:
|
|
161
|
+
raise ParseError(
|
|
162
|
+
"Die Chefkoch-API hat kein gültiges JSON geliefert.",
|
|
163
|
+
next_step="Versuche es später erneut.",
|
|
164
|
+
) from None
|
|
165
|
+
if not isinstance(payload, dict):
|
|
166
|
+
raise ParseError(
|
|
167
|
+
"Die Chefkoch-API hat ein unerwartetes JSON-Format geliefert.",
|
|
168
|
+
next_step="Versuche es später erneut.",
|
|
169
|
+
)
|
|
170
|
+
return payload
|
|
171
|
+
|
|
172
|
+
def search(
|
|
173
|
+
self,
|
|
174
|
+
query: str,
|
|
175
|
+
*,
|
|
176
|
+
limit: int,
|
|
177
|
+
offset: int = 0,
|
|
178
|
+
minimum_rating: float | None = None,
|
|
179
|
+
maximum_time: int | None = None,
|
|
180
|
+
tags: int | None = None,
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
params: dict[str, Any] = {"query": query, "limit": limit, "offset": offset}
|
|
183
|
+
if minimum_rating is not None:
|
|
184
|
+
params["minimumRating"] = minimum_rating
|
|
185
|
+
if maximum_time is not None:
|
|
186
|
+
params["maximumTime"] = maximum_time
|
|
187
|
+
if tags is not None:
|
|
188
|
+
params["tags"] = tags
|
|
189
|
+
return self._get("/v2/recipes", params=params)
|
|
190
|
+
|
|
191
|
+
def recipe(self, recipe_id: str) -> dict[str, Any]:
|
|
192
|
+
return self._get(f"/v2/recipes/{recipe_id}")
|