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 +3 -0
- mela_cli/__main__.py +4 -0
- mela_cli/cli.py +405 -0
- mela_cli/discovery.py +353 -0
- mela_cli/formatters.py +195 -0
- mela_cli/store.py +696 -0
- mela_cli/utils.py +26 -0
- mela_cli-1.0.0.dist-info/METADATA +359 -0
- mela_cli-1.0.0.dist-info/RECORD +13 -0
- mela_cli-1.0.0.dist-info/WHEEL +5 -0
- mela_cli-1.0.0.dist-info/entry_points.txt +2 -0
- mela_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
- mela_cli-1.0.0.dist-info/top_level.txt +1 -0
mela_cli/__init__.py
ADDED
mela_cli/__main__.py
ADDED
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()
|