mela-cli 1.0.0__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.
mela_cli/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ __all__ = ["__version__"]
2
+
3
+ __version__ = "1.0.0"
mela_cli/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from mela_cli.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
mela_cli/cli.py ADDED
@@ -0,0 +1,405 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import io
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from mela_cli import __version__
10
+ from mela_cli.discovery import DiscoveryResult, discover_mela
11
+ from mela_cli.formatters import (
12
+ render_doctor_report,
13
+ render_recipe_markdown,
14
+ render_recipe_text,
15
+ render_stats_table,
16
+ render_summary_csv,
17
+ render_summary_table,
18
+ render_tag_table,
19
+ )
20
+ from mela_cli.store import (
21
+ AmbiguousRecipeError,
22
+ MelaError,
23
+ MelaStore,
24
+ Recipe,
25
+ RecipeNotFoundError,
26
+ RecipeSummary,
27
+ )
28
+ from mela_cli.utils import json_dumps, slugify
29
+
30
+ ENV_EPILOG = """
31
+ Environment variables:
32
+ MELA_APP_PATH
33
+ MELA_DB_PATH
34
+ MELA_SUPPORT_DIR
35
+ MELA_COMPRESSION_TOOL
36
+ """
37
+
38
+
39
+ def build_parser() -> argparse.ArgumentParser:
40
+ parser = argparse.ArgumentParser(
41
+ description="Read-only CLI for browsing and exporting recipes from the Mela macOS app.",
42
+ epilog=ENV_EPILOG,
43
+ formatter_class=argparse.RawDescriptionHelpFormatter,
44
+ )
45
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
46
+ parser.add_argument("--app-path", type=Path, help="Path to the installed Mela.app bundle.")
47
+ parser.add_argument("--db-path", type=Path, help="Path to Curcuma.sqlite.")
48
+ parser.add_argument(
49
+ "--support-dir",
50
+ type=Path,
51
+ help="Path to Core Data external blob storage (_EXTERNAL_DATA).",
52
+ )
53
+ parser.add_argument(
54
+ "--compression-tool",
55
+ help="Path or command name for macOS compression_tool.",
56
+ )
57
+
58
+ subparsers = parser.add_subparsers(dest="command", required=True)
59
+
60
+ list_parser = subparsers.add_parser("list", help="List recipes in the catalog.")
61
+ list_parser.add_argument("-q", "--query", help="Case-insensitive text search.")
62
+ add_recipe_filters(list_parser)
63
+ add_summary_output_option(list_parser)
64
+ list_parser.set_defaults(handler=handle_list)
65
+
66
+ search_parser = subparsers.add_parser("search", help="Alias for 'list --query'.")
67
+ search_parser.add_argument("query", help="Case-insensitive query string.")
68
+ add_recipe_filters(search_parser)
69
+ add_summary_output_option(search_parser)
70
+ search_parser.set_defaults(handler=handle_list)
71
+
72
+ show_parser = subparsers.add_parser("show", help="Show one recipe.")
73
+ show_parser.add_argument(
74
+ "selector",
75
+ help="Recipe PK, exact record ID, exact title, unique record-ID prefix, or unique title fragment.",
76
+ )
77
+ show_parser.add_argument(
78
+ "--format",
79
+ choices=("text", "markdown", "json"),
80
+ default="text",
81
+ help="Output format.",
82
+ )
83
+ show_parser.set_defaults(handler=handle_show)
84
+
85
+ export_parser = subparsers.add_parser("export", help="Export one recipe.")
86
+ export_parser.add_argument(
87
+ "selector",
88
+ help="Recipe PK, exact record ID, exact title, unique record-ID prefix, or unique title fragment.",
89
+ )
90
+ export_parser.add_argument(
91
+ "--format",
92
+ choices=("melarecipe", "json", "markdown"),
93
+ default="melarecipe",
94
+ help="Export format.",
95
+ )
96
+ export_parser.add_argument(
97
+ "-o",
98
+ "--output",
99
+ type=Path,
100
+ default=Path("."),
101
+ help="Directory to write exported files (default: current directory).",
102
+ )
103
+ export_parser.add_argument(
104
+ "--filename-style",
105
+ choices=("slug", "id", "id-slug"),
106
+ default="slug",
107
+ dest="filename_style",
108
+ help="Filename style: slug (title-based, default), id (record UUID), or id-slug.",
109
+ )
110
+ export_parser.add_argument(
111
+ "--compact",
112
+ action="store_true",
113
+ help="Minify JSON output for melarecipe/json exports; ignored for markdown.",
114
+ )
115
+ export_parser.set_defaults(handler=handle_export)
116
+
117
+ export_all_parser = subparsers.add_parser("export-all", help="Export multiple recipes.")
118
+ export_all_parser.add_argument("-q", "--query", help="Text search to filter exported recipes.")
119
+ add_recipe_filters(export_all_parser)
120
+ export_all_parser.add_argument(
121
+ "--format",
122
+ choices=("melarecipe", "json", "markdown"),
123
+ default="melarecipe",
124
+ help="Export format.",
125
+ )
126
+ export_all_parser.add_argument(
127
+ "-o",
128
+ "--output",
129
+ type=Path,
130
+ default=Path("."),
131
+ help="Directory to write exported files (default: current directory).",
132
+ )
133
+ export_all_parser.add_argument(
134
+ "--compact",
135
+ action="store_true",
136
+ help="Minify JSON output for melarecipe/json exports; ignored for markdown.",
137
+ )
138
+ export_all_parser.add_argument(
139
+ "--filename-style",
140
+ choices=("slug", "id", "id-slug"),
141
+ default="slug",
142
+ dest="filename_style",
143
+ help="Filename style: slug (title-based, default), id (record UUID), or id-slug (UUID + title).",
144
+ )
145
+ export_all_parser.set_defaults(handler=handle_export_all)
146
+
147
+ tags_parser = subparsers.add_parser("tags", help="List tags and usage counts.")
148
+ add_table_json_output_option(tags_parser)
149
+ tags_parser.set_defaults(handler=handle_tags)
150
+
151
+ stats_parser = subparsers.add_parser("stats", help="Show catalog statistics.")
152
+ add_table_json_output_option(stats_parser)
153
+ stats_parser.set_defaults(handler=handle_stats)
154
+
155
+ doctor_parser = subparsers.add_parser("doctor", help="Inspect discovery and runtime prerequisites.")
156
+ add_table_json_output_option(doctor_parser)
157
+ doctor_parser.set_defaults(handler=handle_doctor)
158
+
159
+ return parser
160
+
161
+
162
+ def add_recipe_filters(parser: argparse.ArgumentParser) -> None:
163
+ parser.add_argument("-f", "--favorite", action="store_true", help="Only include favorite recipes.")
164
+ parser.add_argument(
165
+ "-w",
166
+ "--want-to-cook",
167
+ action="store_true",
168
+ help="Only include recipes marked want-to-cook.",
169
+ )
170
+ parser.add_argument(
171
+ "-t",
172
+ "--tag",
173
+ action="append",
174
+ default=[],
175
+ dest="tags",
176
+ help="Filter by tag. Can be passed multiple times.",
177
+ )
178
+ parser.add_argument("-n", "--limit", type=int, help="Maximum number of recipes to return.")
179
+
180
+
181
+ def add_summary_output_option(parser: argparse.ArgumentParser) -> None:
182
+ parser.add_argument(
183
+ "--format",
184
+ choices=("table", "json", "csv"),
185
+ default="table",
186
+ help="Output format.",
187
+ )
188
+
189
+
190
+ def add_table_json_output_option(parser: argparse.ArgumentParser) -> None:
191
+ parser.add_argument(
192
+ "--format",
193
+ choices=("table", "json"),
194
+ default="table",
195
+ help="Output format.",
196
+ )
197
+
198
+
199
+ def main(argv: list[str] | None = None) -> int:
200
+ parser = build_parser()
201
+ if not argv and len(sys.argv) == 1:
202
+ parser.print_help()
203
+ return 0
204
+ args = parser.parse_args(argv)
205
+ discovery = discover_mela(
206
+ app_path=args.app_path,
207
+ db_path=args.db_path,
208
+ support_dir=args.support_dir,
209
+ compression_tool=args.compression_tool,
210
+ env=os.environ,
211
+ )
212
+
213
+ try:
214
+ return int(args.handler(args, discovery) or 0)
215
+ except (RecipeNotFoundError, AmbiguousRecipeError, MelaError) as exc:
216
+ print(f"error: {exc}", file=sys.stderr)
217
+ return 1
218
+
219
+
220
+ def handle_list(args: argparse.Namespace, discovery: DiscoveryResult) -> int:
221
+ store = open_store(discovery)
222
+ try:
223
+ recipes = store.list_recipes(
224
+ query=getattr(args, "query", None),
225
+ favorite=args.favorite,
226
+ want_to_cook=args.want_to_cook,
227
+ tags=args.tags,
228
+ limit=args.limit,
229
+ )
230
+ finally:
231
+ store.close()
232
+
233
+ write_summary_output(recipes, args.format)
234
+ return 0
235
+
236
+
237
+ def handle_show(args: argparse.Namespace, discovery: DiscoveryResult) -> int:
238
+ store = open_store(discovery)
239
+ try:
240
+ recipe = store.get_recipe(args.selector)
241
+ finally:
242
+ store.close()
243
+
244
+ if args.format == "json":
245
+ sys.stdout.write(json_dumps(recipe.to_json_dict()))
246
+ elif args.format == "markdown":
247
+ sys.stdout.write(render_recipe_markdown(recipe))
248
+ else:
249
+ sys.stdout.write(render_recipe_text(recipe))
250
+ return 0
251
+
252
+
253
+ def handle_export(args: argparse.Namespace, discovery: DiscoveryResult) -> int:
254
+ store = open_store(discovery)
255
+ try:
256
+ recipe = store.get_recipe(args.selector)
257
+ finally:
258
+ store.close()
259
+
260
+ args.output.mkdir(parents=True, exist_ok=True)
261
+ destination = default_export_path(recipe, args.format, args.output, args.filename_style)
262
+ destination.write_text(render_export(recipe, args.format, compact=args.compact), encoding="utf-8")
263
+ print(destination)
264
+ return 0
265
+
266
+
267
+ def handle_export_all(args: argparse.Namespace, discovery: DiscoveryResult) -> int:
268
+ store = open_store(discovery)
269
+ try:
270
+ summaries = store.list_recipes(
271
+ query=args.query,
272
+ favorite=args.favorite,
273
+ want_to_cook=args.want_to_cook,
274
+ tags=args.tags,
275
+ limit=args.limit,
276
+ )
277
+ args.output.mkdir(parents=True, exist_ok=True)
278
+ used_paths: set[Path] = set()
279
+ for summary in summaries:
280
+ recipe = store.get_recipe(str(summary.pk))
281
+ path = default_export_path(recipe, args.format, args.output, args.filename_style)
282
+ destination = path if args.filename_style != "slug" else unique_export_path(path, used_paths)
283
+ destination.write_text(
284
+ render_export(recipe, args.format, compact=args.compact),
285
+ encoding="utf-8",
286
+ )
287
+ used_paths.add(destination)
288
+ finally:
289
+ store.close()
290
+ return 0
291
+
292
+
293
+ def handle_tags(args: argparse.Namespace, discovery: DiscoveryResult) -> int:
294
+ store = open_store(discovery)
295
+ try:
296
+ tags = store.list_tags()
297
+ finally:
298
+ store.close()
299
+
300
+ if args.format == "json":
301
+ sys.stdout.write(json_dumps([tag.to_json_dict() for tag in tags]))
302
+ else:
303
+ sys.stdout.write(render_tag_table(tags))
304
+ return 0
305
+
306
+
307
+ def handle_stats(args: argparse.Namespace, discovery: DiscoveryResult) -> int:
308
+ store = open_store(discovery)
309
+ try:
310
+ stats = store.get_stats()
311
+ finally:
312
+ store.close()
313
+
314
+ if args.format == "json":
315
+ sys.stdout.write(json_dumps(stats.to_json_dict()))
316
+ else:
317
+ sys.stdout.write(render_stats_table(stats))
318
+ return 0
319
+
320
+
321
+ def handle_doctor(args: argparse.Namespace, discovery: DiscoveryResult) -> int:
322
+ sys.stdout.write(render_doctor_report(discovery, output_format=args.format))
323
+ return 0
324
+
325
+
326
+ def write_summary_output(recipes: list[RecipeSummary], output_format: str) -> None:
327
+ if output_format == "json":
328
+ sys.stdout.write(json_dumps([recipe.to_json_dict() for recipe in recipes]))
329
+ elif output_format == "csv":
330
+ sys.stdout.write(render_summary_csv(recipes))
331
+ else:
332
+ sys.stdout.write(render_summary_table(recipes))
333
+
334
+
335
+ def open_store(discovery: DiscoveryResult) -> MelaStore:
336
+ if not discovery.supported_platform:
337
+ raise MelaError("Mela CLI currently supports macOS only.")
338
+ if discovery.db_path is None:
339
+ raise MelaError(
340
+ "Could not locate Curcuma.sqlite. Run `mela doctor` for discovery details."
341
+ )
342
+ if not discovery.db_path.exists():
343
+ raise MelaError(
344
+ f"Database path does not exist: {discovery.db_path}. Run `mela doctor` for details."
345
+ )
346
+ return MelaStore(
347
+ db_path=discovery.db_path,
348
+ support_dir=discovery.support_dir,
349
+ compression_tool=discovery.compression_tool,
350
+ )
351
+
352
+
353
+ def render_export(recipe: Recipe, export_format: str, compact: bool) -> str:
354
+ if export_format == "melarecipe":
355
+ return json_dumps(recipe.to_melarecipe_dict(), pretty=not compact)
356
+ if export_format == "json":
357
+ return json_dumps(recipe.to_json_dict(), pretty=not compact)
358
+ if export_format == "markdown":
359
+ return render_recipe_markdown(recipe)
360
+ raise ValueError(f"Unsupported export format {export_format!r}.")
361
+
362
+
363
+ def default_export_path(
364
+ recipe: Recipe, export_format: str, base_dir: Path, filename_style: str = "slug"
365
+ ) -> Path:
366
+ suffix = {
367
+ "melarecipe": ".melarecipe",
368
+ "json": ".json",
369
+ "markdown": ".md",
370
+ }[export_format]
371
+ if filename_style == "id":
372
+ stem = recipe.identifier
373
+ elif filename_style == "id-slug":
374
+ stem = f"{recipe.identifier}-{slugify(recipe.title)}"
375
+ else:
376
+ stem = slugify(recipe.title)
377
+ return base_dir / f"{stem}{suffix}"
378
+
379
+
380
+ def unique_export_path(path: Path, used_paths: set[Path]) -> Path:
381
+ if path not in used_paths and not path.exists():
382
+ return path
383
+ stem = path.stem
384
+ suffix = path.suffix
385
+ counter = 2
386
+ while True:
387
+ candidate = path.with_name(f"{stem}-{counter}{suffix}")
388
+ if candidate not in used_paths and not candidate.exists():
389
+ return candidate
390
+ counter += 1
391
+
392
+
393
+ def capture_help_output(argv: list[str]) -> tuple[int, str]:
394
+ stdout = io.StringIO()
395
+ stderr = io.StringIO()
396
+ old_stdout, old_stderr = sys.stdout, sys.stderr
397
+ sys.stdout, sys.stderr = stdout, stderr
398
+ try:
399
+ try:
400
+ code = main(argv)
401
+ except SystemExit as exc:
402
+ code = int(exc.code or 0)
403
+ finally:
404
+ sys.stdout, sys.stderr = old_stdout, old_stderr
405
+ return code, stdout.getvalue() + stderr.getvalue()