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.
@@ -0,0 +1,5 @@
1
+ """Chefkoch.de-Rezepte im Terminal."""
2
+
3
+ __version__ = "0.1.1"
4
+
5
+ __all__ = ["__version__"]
@@ -0,0 +1,8 @@
1
+ """Einstieg für ``python -m chefkoch_cli``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
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}")