morphconv 1.0.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.
- morph/__init__.py +0 -0
- morph/batch.py +492 -0
- morph/cli.py +703 -0
- morph/converters/__init__.py +19 -0
- morph/converters/archives.py +85 -0
- morph/converters/audio.py +58 -0
- morph/converters/csv_json.py +28 -0
- morph/converters/csv_to_xlsx.py +290 -0
- morph/converters/documents.py +252 -0
- morph/converters/ebooks.py +36 -0
- morph/converters/fonts.py +72 -0
- morph/converters/images.py +119 -0
- morph/converters/json_yaml.py +26 -0
- morph/converters/models_3d.py +149 -0
- morph/converters/ocr.py +49 -0
- morph/converters/pandas_extra.py +127 -0
- morph/converters/svg.py +63 -0
- morph/converters/video.py +153 -0
- morph/converters/vtracer_converter.py +84 -0
- morph/converters/web.py +92 -0
- morph/converters/xlsx_to_csv.py +272 -0
- morph/deps.py +228 -0
- morph/ffmpeg_utils.py +79 -0
- morph/history.py +136 -0
- morph/progress.py +78 -0
- morph/registry.py +236 -0
- morph/tui.py +792 -0
- morphconv-1.0.1.dist-info/METADATA +396 -0
- morphconv-1.0.1.dist-info/RECORD +33 -0
- morphconv-1.0.1.dist-info/WHEEL +5 -0
- morphconv-1.0.1.dist-info/entry_points.txt +2 -0
- morphconv-1.0.1.dist-info/licenses/LICENSE +21 -0
- morphconv-1.0.1.dist-info/top_level.txt +1 -0
morph/cli.py
ADDED
|
@@ -0,0 +1,703 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
morph — convert anything to anything, from the CLI.
|
|
4
|
+
|
|
5
|
+
morph report.docx report.pdf
|
|
6
|
+
morph data.csv data.xlsx --table-style TableStyleMedium2 --header-bg 2E7D32
|
|
7
|
+
morph data.csv data.xlsx --help (shows flags for THIS pair only)
|
|
8
|
+
morph batch '*.mp4' mp3 --workers 4
|
|
9
|
+
morph formats docx
|
|
10
|
+
morph history
|
|
11
|
+
morph deps
|
|
12
|
+
morph (no args -> launches the interactive TUI)
|
|
13
|
+
|
|
14
|
+
There is no "convert" subcommand — morph already means convert. Anything
|
|
15
|
+
that isn't a recognized subcommand (formats, deps, batch, history) is
|
|
16
|
+
treated as a conversion job and routed through the engine.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
import time
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import List, Optional
|
|
27
|
+
import yaml
|
|
28
|
+
|
|
29
|
+
# Ensure Unicode output works on Windows regardless of terminal encoding
|
|
30
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
31
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
32
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
33
|
+
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
|
34
|
+
|
|
35
|
+
import typer
|
|
36
|
+
from rich import box
|
|
37
|
+
from rich.console import Console
|
|
38
|
+
from rich.panel import Panel
|
|
39
|
+
from rich.table import Table as RichTable
|
|
40
|
+
from rich.theme import Theme
|
|
41
|
+
from rich.tree import Tree
|
|
42
|
+
from typer.core import TyperGroup
|
|
43
|
+
|
|
44
|
+
from . import converters # noqa: F401 (auto-discovers & registers every converter)
|
|
45
|
+
from . import deps
|
|
46
|
+
from .progress import run_hop
|
|
47
|
+
from .registry import detect_format, registry
|
|
48
|
+
|
|
49
|
+
THEME = Theme({
|
|
50
|
+
"info": "bold cyan", "success": "bold green", "warning": "bold yellow",
|
|
51
|
+
"error": "bold red", "muted": "dim white", "accent": "bold blue",
|
|
52
|
+
})
|
|
53
|
+
console = Console(theme=THEME)
|
|
54
|
+
err_console = Console(stderr=True, theme=THEME)
|
|
55
|
+
|
|
56
|
+
# Family display order and labels
|
|
57
|
+
_FAMILY_ORDER = ["document", "audio", "video", "image", "data", "archive", "font", "ebook"]
|
|
58
|
+
_FAMILY_LABELS = {
|
|
59
|
+
"document": "document",
|
|
60
|
+
"audio": "audio",
|
|
61
|
+
"video": "video",
|
|
62
|
+
"image": "image",
|
|
63
|
+
"data": "data",
|
|
64
|
+
"archive": "archive",
|
|
65
|
+
"font": "font",
|
|
66
|
+
"ebook": "ebook",
|
|
67
|
+
}
|
|
68
|
+
_FAMILY_ICONS = {
|
|
69
|
+
"document": "[cyan]*[/cyan]",
|
|
70
|
+
"audio": "[green]*[/green]",
|
|
71
|
+
"video": "[magenta]*[/magenta]",
|
|
72
|
+
"image": "[yellow]*[/yellow]",
|
|
73
|
+
"data": "[blue]*[/blue]",
|
|
74
|
+
"archive": "[red]*[/red]",
|
|
75
|
+
"font": "[cyan]*[/cyan]",
|
|
76
|
+
"ebook": "[green]*[/green]",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MorphGroup(TyperGroup):
|
|
81
|
+
"""Makes `morph <file> <file> [flags]` work without a literal 'convert'
|
|
82
|
+
subcommand: if the first token isn't a known subcommand name, it's
|
|
83
|
+
forwarded to the hidden `run` command, which does the actual routing."""
|
|
84
|
+
|
|
85
|
+
def resolve_command(self, ctx, args):
|
|
86
|
+
# We explicitly allow "config" alongside whatever is in self.commands
|
|
87
|
+
# just in case Typer hasn't fully populated the commands dict yet.
|
|
88
|
+
known_commands = list(self.commands.keys()) + ["config", "init"]
|
|
89
|
+
if args and args[0] not in known_commands and not args[0].startswith("-"):
|
|
90
|
+
args = ["run", *args]
|
|
91
|
+
return super().resolve_command(ctx, args)
|
|
92
|
+
|
|
93
|
+
def parse_args(self, ctx, args):
|
|
94
|
+
if "--version" in args or "-v" in args:
|
|
95
|
+
import importlib.metadata
|
|
96
|
+
from rich import print
|
|
97
|
+
try:
|
|
98
|
+
version = importlib.metadata.version("morphcli")
|
|
99
|
+
except importlib.metadata.PackageNotFoundError:
|
|
100
|
+
version = "unknown"
|
|
101
|
+
print(f"[bold cyan]morph[/bold cyan] version [green]{version}[/green]")
|
|
102
|
+
import sys
|
|
103
|
+
sys.exit(0)
|
|
104
|
+
return super().parse_args(ctx, args)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
app = typer.Typer(
|
|
108
|
+
name="morph",
|
|
109
|
+
cls=MorphGroup,
|
|
110
|
+
help="[bold cyan]morph[/bold cyan] — convert anything to anything, from the CLI.\n\n[bold]Global Options:[/bold]\n [cyan]-v, --version[/cyan] Show the application's version and exit.",
|
|
111
|
+
rich_markup_mode="rich",
|
|
112
|
+
invoke_without_command=True,
|
|
113
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
114
|
+
pretty_exceptions_show_locals=False,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _fmt_of(path: str | Path) -> str:
|
|
119
|
+
path_str = str(path).lower()
|
|
120
|
+
if path_str.startswith("http://") or path_str.startswith("https://"):
|
|
121
|
+
import urllib.parse
|
|
122
|
+
parsed = urllib.parse.urlparse(path_str)
|
|
123
|
+
ext = Path(parsed.path).suffix.lstrip(".")
|
|
124
|
+
if ext and ext in registry.all_formats():
|
|
125
|
+
return ext
|
|
126
|
+
return "url"
|
|
127
|
+
if isinstance(path, str):
|
|
128
|
+
path = Path(path)
|
|
129
|
+
return detect_format(path)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _print_route_help(input_file: str | Path, output_file: Path, path) -> None:
|
|
133
|
+
src, dst = _fmt_of(input_file), _fmt_of(output_file)
|
|
134
|
+
hop_str = " → ".join([src] + [s.dst for s in path])
|
|
135
|
+
console.print(Panel.fit(f"[bold]{hop_str}[/bold]", title="morph — conversion route", border_style="cyan"))
|
|
136
|
+
|
|
137
|
+
combined = registry.combined_options(path)
|
|
138
|
+
console.print(f"\n[bold]Usage:[/bold] morph {input_file} {output_file} [OPTIONS]\n")
|
|
139
|
+
console.print("[bold]Global options:[/bold]")
|
|
140
|
+
console.print(" -y, --yes Auto-confirm dependency installs")
|
|
141
|
+
console.print(" --dry-run Show the plan without running it")
|
|
142
|
+
console.print(" -q, --quiet Suppress banners and tables")
|
|
143
|
+
|
|
144
|
+
if combined:
|
|
145
|
+
console.print(f"\n[bold]Options for this conversion ({hop_str}):[/bold]")
|
|
146
|
+
tbl = RichTable(box=box.SIMPLE, show_header=False, border_style="dim", pad_edge=False)
|
|
147
|
+
tbl.add_column(style="accent", no_wrap=True)
|
|
148
|
+
tbl.add_column(style="white")
|
|
149
|
+
for opt in combined:
|
|
150
|
+
flag_str = ", ".join(opt.flags)
|
|
151
|
+
default_str = f" [muted](default: {opt.default})[/muted]" if opt.default not in (None, False) else ""
|
|
152
|
+
tbl.add_row(flag_str, f"{opt.help}{default_str}")
|
|
153
|
+
console.print(tbl)
|
|
154
|
+
else:
|
|
155
|
+
console.print(f"\n[muted]No extra options for {hop_str} — just run it.[/muted]")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _build_parser(options) -> argparse.ArgumentParser:
|
|
159
|
+
parser = argparse.ArgumentParser(add_help=False)
|
|
160
|
+
for opt in options:
|
|
161
|
+
kwargs = {"dest": opt.name, "default": opt.default, "help": opt.help}
|
|
162
|
+
if opt.action:
|
|
163
|
+
kwargs["action"] = opt.action
|
|
164
|
+
else:
|
|
165
|
+
kwargs["type"] = opt.type
|
|
166
|
+
parser.add_argument(*opt.flags, **kwargs)
|
|
167
|
+
return parser
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def get_config_path() -> Path:
|
|
171
|
+
return Path.home() / ".morphrc"
|
|
172
|
+
|
|
173
|
+
def load_config() -> dict:
|
|
174
|
+
config_path = get_config_path()
|
|
175
|
+
if not config_path.exists():
|
|
176
|
+
return {}
|
|
177
|
+
try:
|
|
178
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
179
|
+
data = yaml.safe_load(f)
|
|
180
|
+
if not isinstance(data, dict):
|
|
181
|
+
return {}
|
|
182
|
+
|
|
183
|
+
flat_config = {}
|
|
184
|
+
for k, v in data.items():
|
|
185
|
+
if isinstance(v, dict):
|
|
186
|
+
flat_config.update(v)
|
|
187
|
+
else:
|
|
188
|
+
flat_config[k] = v
|
|
189
|
+
return flat_config
|
|
190
|
+
except Exception as e:
|
|
191
|
+
err_console.print(f"[warning]⚠ Failed to load {config_path}: {e}[/warning]")
|
|
192
|
+
return {}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@app.command("config")
|
|
196
|
+
def init_config():
|
|
197
|
+
"""Generate a fully populated ~/.morphrc with best default values."""
|
|
198
|
+
config_path = get_config_path()
|
|
199
|
+
if config_path.exists():
|
|
200
|
+
console.print(f"[warning]⚠ {config_path} already exists. Overwriting is not supported.[/warning]")
|
|
201
|
+
raise typer.Exit(1)
|
|
202
|
+
|
|
203
|
+
options_by_family = registry.all_options()
|
|
204
|
+
|
|
205
|
+
yaml_lines = [
|
|
206
|
+
"# Morph Configuration File",
|
|
207
|
+
"# ------------------------",
|
|
208
|
+
"# This file provides default options for all conversions.",
|
|
209
|
+
"# Command line flags will always override these settings.",
|
|
210
|
+
""
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
yaml_lines.extend([
|
|
214
|
+
"global:",
|
|
215
|
+
" # Use headless browser to render JavaScript on URLs.",
|
|
216
|
+
" # js: false",
|
|
217
|
+
" # Auto-confirm dependency installs.",
|
|
218
|
+
" # yes: false",
|
|
219
|
+
""
|
|
220
|
+
])
|
|
221
|
+
|
|
222
|
+
for family, options in sorted(options_by_family.items()):
|
|
223
|
+
if not options:
|
|
224
|
+
continue
|
|
225
|
+
yaml_lines.append(f"{family}:")
|
|
226
|
+
for opt in sorted(options, key=lambda x: x.name):
|
|
227
|
+
if opt.help:
|
|
228
|
+
yaml_lines.append(f" # {opt.help}")
|
|
229
|
+
|
|
230
|
+
default_val = opt.default
|
|
231
|
+
if default_val is None:
|
|
232
|
+
yaml_val = "null"
|
|
233
|
+
elif isinstance(default_val, bool):
|
|
234
|
+
yaml_val = str(default_val).lower()
|
|
235
|
+
elif isinstance(default_val, str):
|
|
236
|
+
yaml_val = repr(default_val)
|
|
237
|
+
else:
|
|
238
|
+
yaml_val = str(default_val)
|
|
239
|
+
|
|
240
|
+
yaml_lines.append(f" # {opt.name}: {yaml_val}")
|
|
241
|
+
yaml_lines.append("")
|
|
242
|
+
|
|
243
|
+
config_path.write_text("\\n".join(yaml_lines), encoding="utf-8")
|
|
244
|
+
console.print(f"[success]✓ Generated full configuration file at {config_path}[/success]")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ── run (hidden, handles direct file→file conversion) ────────────────────────
|
|
248
|
+
|
|
249
|
+
@app.command("run", hidden=True, context_settings={"ignore_unknown_options": True, "allow_extra_args": True, "help_option_names": []})
|
|
250
|
+
def run_cmd(
|
|
251
|
+
ctx: typer.Context,
|
|
252
|
+
input_file: str = typer.Argument(..., help="Source file or URL (http/https)."),
|
|
253
|
+
output_file: Path = typer.Argument(..., help="Destination file (extension picks the target format)."),
|
|
254
|
+
js: bool = typer.Option(False, "--js", help="Use headless browser to render JavaScript on URLs."),
|
|
255
|
+
yes: bool = typer.Option(False, "-y", "--yes", help="Auto-confirm dependency installs."),
|
|
256
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show the conversion plan without running it."),
|
|
257
|
+
quiet: bool = typer.Option(False, "-q", "--quiet", help="Suppress banners and tables."),
|
|
258
|
+
) -> None:
|
|
259
|
+
from . import history as hist
|
|
260
|
+
|
|
261
|
+
if not quiet:
|
|
262
|
+
console.print("\n[bold cyan]⚡ MORPH[/bold cyan] [dim]— Convert anything to anything.[/dim]\n")
|
|
263
|
+
|
|
264
|
+
src, dst = _fmt_of(input_file), _fmt_of(output_file)
|
|
265
|
+
path = registry.find_path(src, dst)
|
|
266
|
+
|
|
267
|
+
if path is None:
|
|
268
|
+
err_console.print(f"[error]✗ No conversion path found from[/error] .{src} [error]to[/error] .{dst}")
|
|
269
|
+
reachable = sorted(registry.reachable_targets(src).keys())
|
|
270
|
+
if reachable:
|
|
271
|
+
err_console.print(f"[muted]From .{src} morph can currently reach:[/muted] {', '.join(reachable)}")
|
|
272
|
+
raise typer.Exit(1)
|
|
273
|
+
|
|
274
|
+
if "--help" in ctx.args or "-h" in ctx.args:
|
|
275
|
+
_print_route_help(input_file, output_file, path)
|
|
276
|
+
raise typer.Exit(0)
|
|
277
|
+
|
|
278
|
+
combined_opts = registry.combined_options(path)
|
|
279
|
+
parser = _build_parser(combined_opts)
|
|
280
|
+
|
|
281
|
+
# Merge ~/.morphrc values as defaults before parsing CLI arguments
|
|
282
|
+
parser.set_defaults(**load_config())
|
|
283
|
+
|
|
284
|
+
parsed, unknown = parser.parse_known_args(ctx.args)
|
|
285
|
+
if unknown:
|
|
286
|
+
err_console.print(f"[warning]⚠ Ignoring unrecognized option(s):[/warning] {' '.join(unknown)}")
|
|
287
|
+
options_dict = vars(parsed)
|
|
288
|
+
options_dict["js"] = js
|
|
289
|
+
|
|
290
|
+
if js and path and path[0].src == "url":
|
|
291
|
+
import dataclasses
|
|
292
|
+
path[0] = dataclasses.replace(path[0], backend="crawl4ai")
|
|
293
|
+
|
|
294
|
+
is_webpage_scrape = src == "url"
|
|
295
|
+
is_remote_file = str(input_file).lower().startswith("http") and not is_webpage_scrape
|
|
296
|
+
|
|
297
|
+
temp_download = None
|
|
298
|
+
if is_remote_file:
|
|
299
|
+
import urllib.request
|
|
300
|
+
from rich.progress import Progress
|
|
301
|
+
|
|
302
|
+
err_console.print(f"[info]↓ Downloading remote file...[/info]")
|
|
303
|
+
temp_download = tempfile.NamedTemporaryFile(suffix="." + src, delete=False)
|
|
304
|
+
input_path = Path(temp_download.name)
|
|
305
|
+
|
|
306
|
+
try:
|
|
307
|
+
with Progress(console=err_console, transient=True) as progress:
|
|
308
|
+
task = progress.add_task("[cyan]Downloading...", total=None)
|
|
309
|
+
|
|
310
|
+
def report(blocknum, blocksize, totalsize):
|
|
311
|
+
if totalsize > 0 and progress.tasks[task].total is None:
|
|
312
|
+
progress.update(task, total=totalsize)
|
|
313
|
+
progress.update(task, advance=blocksize)
|
|
314
|
+
|
|
315
|
+
urllib.request.urlretrieve(input_file, temp_download.name, reporthook=report)
|
|
316
|
+
except Exception as e:
|
|
317
|
+
err_console.print(f"[error]✗ Download failed:[/error] {e}")
|
|
318
|
+
temp_download.close()
|
|
319
|
+
Path(temp_download.name).unlink(missing_ok=True)
|
|
320
|
+
raise typer.Exit(1)
|
|
321
|
+
else:
|
|
322
|
+
input_path = input_file if is_webpage_scrape else Path(input_file)
|
|
323
|
+
|
|
324
|
+
if not is_webpage_scrape and not is_remote_file and not input_path.exists():
|
|
325
|
+
err_console.print(f"[error]✗ File not found:[/error] {input_file}")
|
|
326
|
+
raise typer.Exit(1)
|
|
327
|
+
|
|
328
|
+
hop_str = " → ".join([src] + [s.dst for s in path])
|
|
329
|
+
display_name = input_file if (is_webpage_scrape or is_remote_file) else input_path.name
|
|
330
|
+
if not quiet:
|
|
331
|
+
console.print(Panel.fit(
|
|
332
|
+
f"[bold]{display_name}[/bold] → [bold]{output_file.name}[/bold]\n"
|
|
333
|
+
f"[muted]route:[/muted] {hop_str}"
|
|
334
|
+
+ (" [warning](lossy step involved)[/warning]" if any(s.lossy for s in path) else ""),
|
|
335
|
+
title="morph", border_style="cyan",
|
|
336
|
+
))
|
|
337
|
+
|
|
338
|
+
if dry_run:
|
|
339
|
+
for i, spec in enumerate(path, 1):
|
|
340
|
+
dep = f" [muted](requires {spec.requires_binary})[/muted]" if spec.requires_binary else ""
|
|
341
|
+
console.print(f" {i}. {spec.src} → {spec.dst} [muted]via {spec.backend}[/muted]{dep}")
|
|
342
|
+
raise typer.Exit(0)
|
|
343
|
+
|
|
344
|
+
needed = registry.required_binaries(path)
|
|
345
|
+
if needed and not deps.ensure_all(needed, console, assume_yes=yes):
|
|
346
|
+
err_console.print("[error]✗ Required dependency not available — aborting.[/error]")
|
|
347
|
+
raise typer.Exit(1)
|
|
348
|
+
|
|
349
|
+
current_input = input_path
|
|
350
|
+
result = None
|
|
351
|
+
t_start = time.perf_counter()
|
|
352
|
+
success = False
|
|
353
|
+
error_msg: Optional[str] = None
|
|
354
|
+
|
|
355
|
+
try:
|
|
356
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
357
|
+
for i, spec in enumerate(path):
|
|
358
|
+
is_last = i == len(path) - 1
|
|
359
|
+
hop_out = output_file if is_last else Path(tmpdir) / f"hop{i}.{spec.dst}"
|
|
360
|
+
hop_options = {opt.name: options_dict[opt.name] for opt in spec.options}
|
|
361
|
+
result = run_hop(spec, current_input, hop_out, hop_options, console, quiet=quiet)
|
|
362
|
+
current_input = result.output
|
|
363
|
+
success = True
|
|
364
|
+
except Exception as exc:
|
|
365
|
+
error_msg = str(exc)
|
|
366
|
+
err_console.print(f"\n[error]✗ Conversion failed at step '{spec.src} → {spec.dst}':[/error] {exc}")
|
|
367
|
+
raise typer.Exit(1)
|
|
368
|
+
finally:
|
|
369
|
+
elapsed = time.perf_counter() - t_start
|
|
370
|
+
backends = ", ".join(dict.fromkeys(s.backend for s in path))
|
|
371
|
+
entry = hist.make_entry(
|
|
372
|
+
input_path, src, output_file, dst,
|
|
373
|
+
hop_str, backends, success, elapsed,
|
|
374
|
+
error=error_msg,
|
|
375
|
+
)
|
|
376
|
+
hist.append_entry(entry)
|
|
377
|
+
|
|
378
|
+
if temp_download is not None:
|
|
379
|
+
temp_download.close()
|
|
380
|
+
Path(temp_download.name).unlink(missing_ok=True)
|
|
381
|
+
if not quiet and result is not None:
|
|
382
|
+
extras = []
|
|
383
|
+
if result.rows is not None:
|
|
384
|
+
extras.append(f"{result.rows:,} rows")
|
|
385
|
+
if result.pages is not None:
|
|
386
|
+
extras.append(f"{result.pages} pages")
|
|
387
|
+
extra_str = f" ({', '.join(extras)})" if extras else ""
|
|
388
|
+
console.print(f"[success]✓ Done![/success] → [accent]{output_file}[/accent]{extra_str}")
|
|
389
|
+
console.print("\n[dim]Built with 💖 by [link=https://hariharen.site]Hariharen[/link][/dim]\n")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
# ── batch ─────────────────────────────────────────────────────────────────────
|
|
393
|
+
|
|
394
|
+
@app.command(
|
|
395
|
+
"batch",
|
|
396
|
+
help="Convert multiple files in parallel. Last argument is the target format.",
|
|
397
|
+
context_settings={"ignore_unknown_options": True, "allow_extra_args": True},
|
|
398
|
+
)
|
|
399
|
+
def batch_cmd(
|
|
400
|
+
ctx: typer.Context,
|
|
401
|
+
inputs: List[str] = typer.Argument(..., help="Glob patterns or directories. Last entry is the target format."),
|
|
402
|
+
out_dir: Optional[Path] = typer.Option(None, "--out-dir", "-o", help="Output directory (default: alongside input)."),
|
|
403
|
+
mirror: bool = typer.Option(False, "--mirror", help="Mirror input directory structure inside --out-dir."),
|
|
404
|
+
rename: Optional[str] = typer.Option(None, "--rename", help="Filename template, e.g. '{stem}_audio'. Variables: {stem} {fmt} {src_fmt}."),
|
|
405
|
+
workers: int = typer.Option(4, "-w", "--workers", help="Parallel worker threads.", min=1, max=32),
|
|
406
|
+
recursive: bool = typer.Option(False, "-r", "--recursive", help="Walk subdirectories."),
|
|
407
|
+
skip_existing: bool = typer.Option(False, "--skip-existing", help="Skip files whose output already exists."),
|
|
408
|
+
newer_only: bool = typer.Option(False, "--newer-only", help="Skip files whose output is already newer than the input."),
|
|
409
|
+
fail_fast: bool = typer.Option(False, "--fail-fast", help="Abort on first failure."),
|
|
410
|
+
error_log: Optional[Path] = typer.Option(None, "--error-log", help="Write failed paths to this file."),
|
|
411
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Show what would be converted without running."),
|
|
412
|
+
yes: bool = typer.Option(False, "-y", "--yes", help="Auto-confirm dependency installs."),
|
|
413
|
+
quiet: bool = typer.Option(False, "-q", "--quiet", help="No live display — just print summary."),
|
|
414
|
+
) -> None:
|
|
415
|
+
from .batch import collect_inputs, run_batch
|
|
416
|
+
|
|
417
|
+
if not quiet:
|
|
418
|
+
console.print("\n[bold cyan]⚡ MORPH[/bold cyan] [dim]— Convert anything to anything.[/dim]\n")
|
|
419
|
+
|
|
420
|
+
if len(inputs) < 2:
|
|
421
|
+
err_console.print("[error]✗ Provide at least one input pattern and a target format.[/error]")
|
|
422
|
+
err_console.print(" [muted]Example: morph batch '*.mp4' mp3[/muted]")
|
|
423
|
+
raise typer.Exit(1)
|
|
424
|
+
|
|
425
|
+
# Last argument is always the target format
|
|
426
|
+
*patterns, target_fmt = inputs
|
|
427
|
+
target_fmt = target_fmt.lower().lstrip(".")
|
|
428
|
+
|
|
429
|
+
# Validate target format is known
|
|
430
|
+
if not registry.all_formats() or target_fmt not in registry.all_formats():
|
|
431
|
+
err_console.print(f"[error]✗ Unknown target format:[/error] [bold]{target_fmt}[/bold]")
|
|
432
|
+
known = sorted(registry.all_formats())
|
|
433
|
+
err_console.print(f"[muted]Known formats: {', '.join(known)}[/muted]")
|
|
434
|
+
raise typer.Exit(1)
|
|
435
|
+
|
|
436
|
+
# Determine base_dir for --mirror (use common parent of patterns if dir)
|
|
437
|
+
base_dir: Optional[Path] = None
|
|
438
|
+
if mirror and out_dir:
|
|
439
|
+
candidate = Path(patterns[0])
|
|
440
|
+
if candidate.is_dir():
|
|
441
|
+
base_dir = candidate.resolve()
|
|
442
|
+
|
|
443
|
+
# Collect jobs
|
|
444
|
+
jobs = collect_inputs(
|
|
445
|
+
patterns, target_fmt,
|
|
446
|
+
recursive=recursive,
|
|
447
|
+
out_dir=out_dir,
|
|
448
|
+
mirror=mirror,
|
|
449
|
+
rename_template=rename,
|
|
450
|
+
skip_existing=skip_existing,
|
|
451
|
+
newer_only=newer_only,
|
|
452
|
+
base_dir=base_dir,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
if not jobs:
|
|
456
|
+
console.print("[warning]No matching files found.[/warning]")
|
|
457
|
+
raise typer.Exit(0)
|
|
458
|
+
|
|
459
|
+
# Gather any extra options from ctx.args (passthrough to converters)
|
|
460
|
+
# Parse known converter options for the target format's routes
|
|
461
|
+
from .batch import JobState
|
|
462
|
+
convertible_jobs = [j for j in jobs if j.state == JobState.WAITING and j.conv_path]
|
|
463
|
+
options_dict: dict = {}
|
|
464
|
+
if convertible_jobs:
|
|
465
|
+
sample_path = convertible_jobs[0].conv_path
|
|
466
|
+
combined_opts = registry.combined_options(sample_path)
|
|
467
|
+
if combined_opts and ctx.args:
|
|
468
|
+
parser = _build_parser(combined_opts)
|
|
469
|
+
parsed, _ = parser.parse_known_args(ctx.args)
|
|
470
|
+
options_dict = vars(parsed)
|
|
471
|
+
|
|
472
|
+
if dry_run:
|
|
473
|
+
console.print(Panel.fit(
|
|
474
|
+
f"[bold]morph batch — dry run[/bold]\n"
|
|
475
|
+
f"Target: [cyan]{target_fmt}[/cyan] Workers: {workers}",
|
|
476
|
+
border_style="cyan",
|
|
477
|
+
))
|
|
478
|
+
from .batch import JobState, _SYMBOLS
|
|
479
|
+
for j in jobs:
|
|
480
|
+
sym = _SYMBOLS.get(j.state, "·")
|
|
481
|
+
if j.state == JobState.WAITING:
|
|
482
|
+
route = " → ".join([j.src_fmt] + [s.dst for s in j.conv_path])
|
|
483
|
+
console.print(f" {sym} [white]{j.input_path}[/white] [dim]→ {j.output_path.name} ({route})[/dim]")
|
|
484
|
+
else:
|
|
485
|
+
console.print(f" {sym} [dim]{j.input_path} ({j.error})[/dim]")
|
|
486
|
+
raise typer.Exit(0)
|
|
487
|
+
|
|
488
|
+
result = run_batch(
|
|
489
|
+
jobs, target_fmt, options_dict, console,
|
|
490
|
+
workers=workers,
|
|
491
|
+
fail_fast=fail_fast,
|
|
492
|
+
error_log=error_log,
|
|
493
|
+
quiet=quiet,
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
console.print("\n[dim]Built with 💖 by [link=https://hariharen.site]Hariharen[/link][/dim]\n")
|
|
497
|
+
raise typer.Exit(0 if result.failed == 0 else 1)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# ── formats ───────────────────────────────────────────────────────────────────
|
|
501
|
+
|
|
502
|
+
@app.command("formats", help="List formats morph can convert, optionally filtered by source format.")
|
|
503
|
+
def formats_cmd(
|
|
504
|
+
source: Optional[str] = typer.Argument(None, help="e.g. 'docx' to see everything reachable from docx."),
|
|
505
|
+
) -> None:
|
|
506
|
+
if source:
|
|
507
|
+
# Per-format table (existing behaviour — already good)
|
|
508
|
+
targets = registry.reachable_targets(source)
|
|
509
|
+
if not targets:
|
|
510
|
+
console.print(f"[warning]No known conversions from .{source}[/warning]")
|
|
511
|
+
raise typer.Exit(0)
|
|
512
|
+
tbl = RichTable(box=box.ROUNDED, border_style="cyan")
|
|
513
|
+
tbl.add_column("Target", style="bold white")
|
|
514
|
+
tbl.add_column("Route", style="muted")
|
|
515
|
+
tbl.add_column("Hops", justify="right", style="green")
|
|
516
|
+
for dst, hop_path in sorted(targets.items()):
|
|
517
|
+
hop_str = " → ".join([source] + [s.dst for s in hop_path])
|
|
518
|
+
tbl.add_row(dst, hop_str, str(len(hop_path)))
|
|
519
|
+
console.print(tbl)
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
# Tree view grouped by family
|
|
523
|
+
all_formats = registry.all_formats()
|
|
524
|
+
by_family = registry.formats_by_family()
|
|
525
|
+
by_edges = registry.edges_by_family()
|
|
526
|
+
backends = registry.family_backends()
|
|
527
|
+
|
|
528
|
+
total_fmts = len(all_formats)
|
|
529
|
+
total_families = len(by_family)
|
|
530
|
+
|
|
531
|
+
console.print()
|
|
532
|
+
console.print(f" [bold cyan]morph[/bold cyan] — [bold]{total_fmts}[/bold] formats across [bold]{total_families}[/bold] families\n")
|
|
533
|
+
|
|
534
|
+
# Render each family in defined order, then any unknown ones
|
|
535
|
+
order = [f for f in _FAMILY_ORDER if f in by_family]
|
|
536
|
+
extras = [f for f in sorted(by_family) if f not in order]
|
|
537
|
+
|
|
538
|
+
for family in order + extras:
|
|
539
|
+
fmts = sorted(by_family[family])
|
|
540
|
+
edges = by_edges.get(family, [])
|
|
541
|
+
be = sorted(backends.get(family, set()))
|
|
542
|
+
icon = _FAMILY_ICONS.get(family, "▸")
|
|
543
|
+
label = _FAMILY_LABELS.get(family, family)
|
|
544
|
+
be_str = f"via {', '.join(be)}" if be else ""
|
|
545
|
+
|
|
546
|
+
tree = Tree(
|
|
547
|
+
f"{icon} [bold]{label}[/bold] [dim]({len(fmts)} formats"
|
|
548
|
+
+ (f", {be_str}" if be_str else "")
|
|
549
|
+
+ f", {len(edges)} direct routes)[/dim]",
|
|
550
|
+
guide_style="dim cyan",
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
# Per-format: show top direct targets
|
|
554
|
+
src_targets: dict[str, list[str]] = {}
|
|
555
|
+
for spec in edges:
|
|
556
|
+
src_targets.setdefault(spec.src, []).append(spec.dst)
|
|
557
|
+
|
|
558
|
+
fmt_lines = []
|
|
559
|
+
for fmt in fmts:
|
|
560
|
+
targets_for = sorted(src_targets.get(fmt, []))
|
|
561
|
+
if targets_for:
|
|
562
|
+
t_str = " [dim]→ " + " ".join(targets_for) + "[/dim]"
|
|
563
|
+
else:
|
|
564
|
+
t_str = ""
|
|
565
|
+
fmt_lines.append(f"[bold white]{fmt}[/bold white]{t_str}")
|
|
566
|
+
|
|
567
|
+
for line in fmt_lines:
|
|
568
|
+
tree.add(line)
|
|
569
|
+
|
|
570
|
+
console.print(tree)
|
|
571
|
+
|
|
572
|
+
console.print()
|
|
573
|
+
console.print(" [muted]Run [bold]morph formats <format>[/bold] to see all reachable targets from any format.[/muted]")
|
|
574
|
+
console.print()
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
# ── history ───────────────────────────────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
@app.command("history", help="Show recent conversion history.")
|
|
580
|
+
def history_cmd(
|
|
581
|
+
n: int = typer.Option(20, "-n", help="Number of entries to show."),
|
|
582
|
+
failed: bool = typer.Option(False, "--failed", help="Show only failed conversions."),
|
|
583
|
+
fmt: Optional[str] = typer.Option(None, "--fmt", help="Filter by source or target format (e.g. mp4)."),
|
|
584
|
+
clear: bool = typer.Option(False, "--clear", help="Delete the history file."),
|
|
585
|
+
json_out: bool = typer.Option(False, "--json", help="Print raw JSON Lines."),
|
|
586
|
+
) -> None:
|
|
587
|
+
from . import history as hist
|
|
588
|
+
import json
|
|
589
|
+
|
|
590
|
+
if clear:
|
|
591
|
+
if typer.confirm("Delete all conversion history?"):
|
|
592
|
+
existed = hist.clear()
|
|
593
|
+
console.print("[success]✓ History cleared.[/success]" if existed else "[muted]History was already empty.[/muted]")
|
|
594
|
+
return
|
|
595
|
+
|
|
596
|
+
entries = hist.read_entries(limit=n, failed_only=failed, fmt_filter=fmt)
|
|
597
|
+
|
|
598
|
+
if not entries:
|
|
599
|
+
console.print("[muted]No history entries found.[/muted]")
|
|
600
|
+
if not hist.HISTORY_FILE.exists():
|
|
601
|
+
console.print(f"[muted]History file: {hist.HISTORY_FILE}[/muted]")
|
|
602
|
+
return
|
|
603
|
+
|
|
604
|
+
if json_out:
|
|
605
|
+
from dataclasses import asdict
|
|
606
|
+
for e in entries:
|
|
607
|
+
console.print(json.dumps(asdict(e), ensure_ascii=False))
|
|
608
|
+
return
|
|
609
|
+
|
|
610
|
+
# Pretty table
|
|
611
|
+
from datetime import datetime
|
|
612
|
+
|
|
613
|
+
def _fmt_time(ts: str) -> str:
|
|
614
|
+
try:
|
|
615
|
+
dt = datetime.fromisoformat(ts)
|
|
616
|
+
now = datetime.now()
|
|
617
|
+
diff = (now - dt).total_seconds()
|
|
618
|
+
if diff < 3600:
|
|
619
|
+
return f"{int(diff // 60)}m ago"
|
|
620
|
+
if diff < 86400:
|
|
621
|
+
return f"{int(diff // 3600)}h ago"
|
|
622
|
+
if diff < 172800:
|
|
623
|
+
return "yesterday"
|
|
624
|
+
return dt.strftime("%b %d")
|
|
625
|
+
except Exception:
|
|
626
|
+
return ts[:16]
|
|
627
|
+
|
|
628
|
+
tbl = RichTable(box=box.ROUNDED, border_style="cyan", show_header=True)
|
|
629
|
+
tbl.add_column("When", style="dim", width=11, no_wrap=True)
|
|
630
|
+
tbl.add_column("From", style="bold white", width=7, no_wrap=True)
|
|
631
|
+
tbl.add_column("To", style="bold cyan", width=7, no_wrap=True)
|
|
632
|
+
tbl.add_column("File", style="white", ratio=1, no_wrap=True)
|
|
633
|
+
tbl.add_column("Mode", style="dim", width=7, no_wrap=True)
|
|
634
|
+
tbl.add_column("Status", width=14, no_wrap=True)
|
|
635
|
+
|
|
636
|
+
for e in reversed(entries):
|
|
637
|
+
name = Path(e.src_path).name
|
|
638
|
+
if len(name) > 40:
|
|
639
|
+
name = name[:37] + "..."
|
|
640
|
+
|
|
641
|
+
if e.success:
|
|
642
|
+
status = f"[green]✓ {e.elapsed_s:.1f}s[/green]"
|
|
643
|
+
else:
|
|
644
|
+
err_short = (e.error or "error")[:20]
|
|
645
|
+
status = f"[red]✗ {err_short}[/red]"
|
|
646
|
+
|
|
647
|
+
tbl.add_row(
|
|
648
|
+
_fmt_time(e.ts),
|
|
649
|
+
e.src_fmt, e.dst_fmt,
|
|
650
|
+
name,
|
|
651
|
+
e.mode,
|
|
652
|
+
status,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
title_parts = [f"last {len(entries)}"]
|
|
656
|
+
if failed:
|
|
657
|
+
title_parts.append("failures")
|
|
658
|
+
if fmt:
|
|
659
|
+
title_parts.append(f"format={fmt}")
|
|
660
|
+
title = "morph history — " + ", ".join(title_parts)
|
|
661
|
+
|
|
662
|
+
console.print()
|
|
663
|
+
console.print(tbl)
|
|
664
|
+
|
|
665
|
+
total = len(entries)
|
|
666
|
+
ok = sum(1 for e in entries if e.success)
|
|
667
|
+
bad = total - ok
|
|
668
|
+
console.print(
|
|
669
|
+
f" [dim]{total} shown "
|
|
670
|
+
f"[green]{ok} successful[/green] "
|
|
671
|
+
+ (f"[red]{bad} failed[/red]" if bad else "[dim]0 failed[/dim]")
|
|
672
|
+
+ f" history: {hist.HISTORY_FILE}[/dim]"
|
|
673
|
+
)
|
|
674
|
+
console.print()
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
# ── deps ──────────────────────────────────────────────────────────────────────
|
|
678
|
+
|
|
679
|
+
@app.command("deps", help="Check status of external tools morph relies on.")
|
|
680
|
+
def deps_cmd() -> None:
|
|
681
|
+
binaries = sorted(registry.known_binaries())
|
|
682
|
+
tbl = RichTable(box=box.ROUNDED, border_style="cyan")
|
|
683
|
+
tbl.add_column("Tool", style="bold white")
|
|
684
|
+
tbl.add_column("Status")
|
|
685
|
+
tbl.add_column("Install command", style="muted")
|
|
686
|
+
for b in binaries:
|
|
687
|
+
status = deps.check(b)
|
|
688
|
+
state = "[success]✓ installed[/success]" if status.installed else "[error]✗ missing[/error]"
|
|
689
|
+
tbl.add_row(b, state, status.install_cmd or "[muted]no package manager detected[/muted]")
|
|
690
|
+
console.print(tbl)
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
# ── entry point ───────────────────────────────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
@app.callback(invoke_without_command=True)
|
|
696
|
+
def main(ctx: typer.Context) -> None:
|
|
697
|
+
if ctx.invoked_subcommand is None:
|
|
698
|
+
from .tui import run_tui
|
|
699
|
+
run_tui()
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
if __name__ == "__main__":
|
|
703
|
+
app()
|