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
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""
|
|
2
|
+
converters/xlsx_to_csv.py — xlsx -> csv, a fully self-contained converter.
|
|
3
|
+
|
|
4
|
+
Sheet handling, merged-cell flattening, and the register() call with its
|
|
5
|
+
full OptionSpec flag surface all live in this one file.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import csv
|
|
11
|
+
import warnings
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional, Union
|
|
14
|
+
|
|
15
|
+
import pandas as pd
|
|
16
|
+
from openpyxl import load_workbook
|
|
17
|
+
|
|
18
|
+
from ..registry import ConversionResult, OptionSpec, register
|
|
19
|
+
|
|
20
|
+
# ─────────────────────────── helpers ────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
def _get_sheet_names(path: Path) -> list[str]:
|
|
23
|
+
wb = load_workbook(path, read_only=True, data_only=True)
|
|
24
|
+
names = wb.sheetnames
|
|
25
|
+
wb.close()
|
|
26
|
+
return names
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_visible_sheet_names(path: Path) -> list[str]:
|
|
30
|
+
wb = load_workbook(path, read_only=False, data_only=True)
|
|
31
|
+
visible = [ws.title for ws in wb.worksheets if ws.sheet_state == "visible"]
|
|
32
|
+
wb.close()
|
|
33
|
+
return visible
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _flatten_merged_cells(path: Path, sheet: Union[str, int]) -> pd.DataFrame:
|
|
37
|
+
"""Read a sheet and fill every cell in a merged region with the
|
|
38
|
+
top-left (anchor) value, then return as a DataFrame."""
|
|
39
|
+
wb = load_workbook(path, data_only=True)
|
|
40
|
+
ws = wb[sheet] if isinstance(sheet, str) else wb.worksheets[sheet]
|
|
41
|
+
|
|
42
|
+
merge_values: dict[tuple[int, int], object] = {}
|
|
43
|
+
for merge_range in ws.merged_cells.ranges:
|
|
44
|
+
anchor_val = ws.cell(merge_range.min_row, merge_range.min_col).value
|
|
45
|
+
for r in range(merge_range.min_row, merge_range.max_row + 1):
|
|
46
|
+
for c in range(merge_range.min_col, merge_range.max_col + 1):
|
|
47
|
+
merge_values[(r, c)] = anchor_val
|
|
48
|
+
|
|
49
|
+
rows: list[list] = []
|
|
50
|
+
for row in ws.iter_rows():
|
|
51
|
+
row_data = []
|
|
52
|
+
for cell in row:
|
|
53
|
+
key = (cell.row, cell.column) # type: ignore[arg-type]
|
|
54
|
+
row_data.append(merge_values[key] if key in merge_values else cell.value)
|
|
55
|
+
rows.append(row_data)
|
|
56
|
+
|
|
57
|
+
wb.close()
|
|
58
|
+
if not rows:
|
|
59
|
+
return pd.DataFrame()
|
|
60
|
+
|
|
61
|
+
header = [str(v) if v is not None else f"col_{i}" for i, v in enumerate(rows[0])]
|
|
62
|
+
return pd.DataFrame(rows[1:], columns=header)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_sheet(
|
|
66
|
+
path: Path, sheet: Union[str, int], *, header_row: Optional[int], skiprows: int,
|
|
67
|
+
skipfooter: int, usecols: Optional[list], nrows: Optional[int],
|
|
68
|
+
strip_whitespace: bool, flatten_merges: bool,
|
|
69
|
+
) -> pd.DataFrame:
|
|
70
|
+
if flatten_merges:
|
|
71
|
+
df = _flatten_merged_cells(path, sheet)
|
|
72
|
+
if strip_whitespace:
|
|
73
|
+
for col in df.select_dtypes(include="object").columns:
|
|
74
|
+
df[col] = df[col].str.strip()
|
|
75
|
+
return df
|
|
76
|
+
|
|
77
|
+
engine = "openpyxl"
|
|
78
|
+
suffix = path.suffix.lower()
|
|
79
|
+
if suffix == ".xls":
|
|
80
|
+
engine = "xlrd"
|
|
81
|
+
elif suffix == ".ods":
|
|
82
|
+
engine = "odf"
|
|
83
|
+
|
|
84
|
+
with warnings.catch_warnings():
|
|
85
|
+
warnings.simplefilter("ignore")
|
|
86
|
+
df = pd.read_excel(
|
|
87
|
+
path, sheet_name=sheet, header=header_row,
|
|
88
|
+
skiprows=skiprows if skiprows else None, skipfooter=skipfooter,
|
|
89
|
+
usecols=usecols, nrows=nrows, dtype=str, engine=engine, keep_default_na=True,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if strip_whitespace:
|
|
93
|
+
for col in df.select_dtypes(include="object").columns:
|
|
94
|
+
df[col] = df[col].str.strip()
|
|
95
|
+
|
|
96
|
+
return df
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _df_to_csv(
|
|
100
|
+
df: pd.DataFrame, output_path: Path, *, delimiter: str, encoding: str,
|
|
101
|
+
line_terminator: str, quoting: int, na_rep: str, include_index: bool,
|
|
102
|
+
date_format: Optional[str] = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
105
|
+
df.to_csv(output_path, sep=delimiter, encoding=encoding, lineterminator=line_terminator,
|
|
106
|
+
quoting=quoting, na_rep=na_rep, index=include_index,
|
|
107
|
+
date_format=date_format or "%Y-%m-%d %H:%M:%S")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _safe_filename(name: str) -> str:
|
|
111
|
+
import re
|
|
112
|
+
return re.sub(r'[\\/*?:"<>|]', "_", name)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def preview(
|
|
116
|
+
input_path: Path, sheet: Union[str, int] = 0, *, nrows: int = 10,
|
|
117
|
+
header_row: Optional[int] = 0, flatten_merges: bool = False, strip_whitespace: bool = True,
|
|
118
|
+
) -> pd.DataFrame:
|
|
119
|
+
"""First `nrows` rows of a sheet as a DataFrame — no file written."""
|
|
120
|
+
return _read_sheet(input_path, sheet, header_row=header_row, skiprows=0, skipfooter=0,
|
|
121
|
+
usecols=None, nrows=nrows, strip_whitespace=strip_whitespace,
|
|
122
|
+
flatten_merges=flatten_merges)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _convert_workbook(
|
|
126
|
+
input_path: Path, output_path: Path, *, sheets: Optional[list] = None,
|
|
127
|
+
skip_hidden: bool = True, all_sheets_separate: bool = False, combine_sheets: bool = False,
|
|
128
|
+
combine_sheet_col: Optional[str] = "__sheet", delimiter: str = ",", encoding: str = "utf-8-sig",
|
|
129
|
+
line_terminator: str = "\r\n", quoting: int = csv.QUOTE_MINIMAL, na_rep: str = "",
|
|
130
|
+
date_format: Optional[str] = None, header_row: Optional[int] = 0, skiprows: int = 0,
|
|
131
|
+
skipfooter: int = 0, usecols: Optional[str] = None, nrows: Optional[int] = None,
|
|
132
|
+
strip_whitespace: bool = True, include_index: bool = False, flatten_merges: bool = False,
|
|
133
|
+
) -> list[dict]:
|
|
134
|
+
"""Convert an Excel workbook to one or more CSV files. Returns [{rows, cols, sheet, output}, ...]."""
|
|
135
|
+
input_path = Path(input_path)
|
|
136
|
+
output_path = Path(output_path)
|
|
137
|
+
|
|
138
|
+
suffix = input_path.suffix.lower()
|
|
139
|
+
if suffix == ".xls":
|
|
140
|
+
all_sheet_names: list[str] = pd.ExcelFile(input_path, engine="xlrd").sheet_names
|
|
141
|
+
elif suffix == ".ods":
|
|
142
|
+
all_sheet_names = pd.ExcelFile(input_path, engine="odf").sheet_names
|
|
143
|
+
else:
|
|
144
|
+
all_sheet_names = _get_sheet_names(input_path)
|
|
145
|
+
|
|
146
|
+
if skip_hidden and suffix not in (".xls", ".ods"):
|
|
147
|
+
visible = _get_visible_sheet_names(input_path)
|
|
148
|
+
else:
|
|
149
|
+
visible = all_sheet_names
|
|
150
|
+
|
|
151
|
+
if sheets:
|
|
152
|
+
resolved: list[str] = []
|
|
153
|
+
for s in sheets:
|
|
154
|
+
if isinstance(s, int):
|
|
155
|
+
if 0 <= s < len(all_sheet_names):
|
|
156
|
+
resolved.append(all_sheet_names[s])
|
|
157
|
+
else:
|
|
158
|
+
if s in all_sheet_names:
|
|
159
|
+
resolved.append(s)
|
|
160
|
+
target_sheets = [s for s in resolved if s in visible]
|
|
161
|
+
else:
|
|
162
|
+
target_sheets = list(visible)
|
|
163
|
+
|
|
164
|
+
if not target_sheets:
|
|
165
|
+
raise ValueError("No matching visible sheets found in workbook.")
|
|
166
|
+
|
|
167
|
+
parsed_usecols: Optional[list] = None
|
|
168
|
+
if usecols:
|
|
169
|
+
cols = [c.strip() for c in usecols.split(",")]
|
|
170
|
+
try:
|
|
171
|
+
parsed_usecols = [int(c) for c in cols]
|
|
172
|
+
except ValueError:
|
|
173
|
+
parsed_usecols = cols
|
|
174
|
+
|
|
175
|
+
read_kw = dict(header_row=header_row, skiprows=skiprows, skipfooter=skipfooter,
|
|
176
|
+
usecols=parsed_usecols, nrows=nrows, strip_whitespace=strip_whitespace,
|
|
177
|
+
flatten_merges=flatten_merges)
|
|
178
|
+
csv_kw = dict(delimiter=delimiter, encoding=encoding, line_terminator=line_terminator,
|
|
179
|
+
quoting=quoting, na_rep=na_rep, include_index=include_index, date_format=date_format)
|
|
180
|
+
|
|
181
|
+
results: list[dict] = []
|
|
182
|
+
|
|
183
|
+
if combine_sheets:
|
|
184
|
+
frames: list[pd.DataFrame] = []
|
|
185
|
+
for sheet in target_sheets:
|
|
186
|
+
df = _read_sheet(input_path, sheet, **read_kw) # type: ignore[arg-type]
|
|
187
|
+
if combine_sheet_col:
|
|
188
|
+
df.insert(0, combine_sheet_col, str(sheet))
|
|
189
|
+
frames.append(df)
|
|
190
|
+
combined = pd.concat(frames, ignore_index=True)
|
|
191
|
+
_df_to_csv(combined, output_path, **csv_kw) # type: ignore[arg-type]
|
|
192
|
+
results.append({"rows": len(combined), "cols": len(combined.columns),
|
|
193
|
+
"sheet": f"{len(target_sheets)} sheets combined", "output": output_path})
|
|
194
|
+
return results
|
|
195
|
+
|
|
196
|
+
if all_sheets_separate or len(target_sheets) > 1:
|
|
197
|
+
stem = output_path.stem
|
|
198
|
+
parent = output_path.parent
|
|
199
|
+
for sheet in target_sheets:
|
|
200
|
+
safe = _safe_filename(str(sheet))
|
|
201
|
+
out = parent / f"{stem}__{safe}.csv"
|
|
202
|
+
df = _read_sheet(input_path, sheet, **read_kw) # type: ignore[arg-type]
|
|
203
|
+
_df_to_csv(df, out, **csv_kw) # type: ignore[arg-type]
|
|
204
|
+
results.append({"rows": len(df), "cols": len(df.columns), "sheet": sheet, "output": out})
|
|
205
|
+
return results
|
|
206
|
+
|
|
207
|
+
sheet = target_sheets[0]
|
|
208
|
+
df = _read_sheet(input_path, sheet, **read_kw) # type: ignore[arg-type]
|
|
209
|
+
_df_to_csv(df, output_path, **csv_kw) # type: ignore[arg-type]
|
|
210
|
+
results.append({"rows": len(df), "cols": len(df.columns), "sheet": sheet, "output": output_path})
|
|
211
|
+
return results
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ─────────────────────────── registry wiring ────────────────────────────────
|
|
215
|
+
|
|
216
|
+
OPTIONS = [
|
|
217
|
+
OptionSpec("sheets", ("-s", "--sheets"), "Comma-separated sheet names/indices. Default: all visible."),
|
|
218
|
+
OptionSpec("skip_hidden", ("--include-hidden",), "Include hidden sheets.", default=True, action="store_false"),
|
|
219
|
+
OptionSpec("all_sheets_separate", ("--all-separate",), "One CSV per sheet even for a single match.",
|
|
220
|
+
default=False, action="store_true"),
|
|
221
|
+
OptionSpec("combine_sheets", ("--combine",), "Merge all sheets into one CSV.", default=False, action="store_true"),
|
|
222
|
+
OptionSpec("combine_sheet_col", ("--combine-col",), "Sheet-identifier column name when combining.", default="__sheet"),
|
|
223
|
+
OptionSpec("no_combine_col", ("--no-combine-col",), "Omit the sheet column when combining.",
|
|
224
|
+
default=False, action="store_true"),
|
|
225
|
+
OptionSpec("header_row", ("--header-row",), "0-based header row index. -1 for none.", default=0, type=int),
|
|
226
|
+
OptionSpec("skiprows", ("--skip-rows",), "Rows to skip before the header.", default=0, type=int),
|
|
227
|
+
OptionSpec("skipfooter", ("--skip-footer",), "Rows to skip at the bottom.", default=0, type=int),
|
|
228
|
+
OptionSpec("nrows", ("--nrows",), "Maximum data rows to read per sheet.", type=int),
|
|
229
|
+
OptionSpec("usecols", ("--cols",), "Comma-separated column names/indices to include."),
|
|
230
|
+
OptionSpec("flatten_merges", ("--flatten-merges",), "Fill merged cells with the anchor value.",
|
|
231
|
+
default=False, action="store_true"),
|
|
232
|
+
OptionSpec("delimiter", ("-d", "--delimiter"), "Output field delimiter.", default=","),
|
|
233
|
+
OptionSpec("encoding", ("-e", "--encoding"), "Output CSV encoding.", default="utf-8-sig"),
|
|
234
|
+
OptionSpec("line_terminator", ("--line-term",), r"Line terminator (\r\n or \n).", default="\\r\\n"),
|
|
235
|
+
OptionSpec("quoting", ("--quoting",), "minimal | all | nonnumeric | none", default="minimal"),
|
|
236
|
+
OptionSpec("na_rep", ("--na",), "String to represent NaN / empty cells.", default=""),
|
|
237
|
+
OptionSpec("date_format", ("--date-fmt",), "strftime format for date columns."),
|
|
238
|
+
OptionSpec("strip_whitespace", ("--no-strip",), "Disable stripping whitespace from cells.",
|
|
239
|
+
default=True, action="store_false"),
|
|
240
|
+
OptionSpec("include_index", ("--index",), "Write row index as first column.", default=False, action="store_true"),
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
_QUOTING_MAP = {"minimal": csv.QUOTE_MINIMAL, "all": csv.QUOTE_ALL,
|
|
244
|
+
"nonnumeric": csv.QUOTE_NONNUMERIC, "none": csv.QUOTE_NONE}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@register("xlsx", "csv", backend="native", family="data",
|
|
248
|
+
description="xlsx → csv", lossy=True, options=OPTIONS)
|
|
249
|
+
def xlsx_to_csv(input_path: Path, output_path: Path, **options) -> ConversionResult:
|
|
250
|
+
# small flag translations live right next to the flags they belong to
|
|
251
|
+
if options.pop("no_combine_col", False):
|
|
252
|
+
options["combine_sheet_col"] = None
|
|
253
|
+
|
|
254
|
+
sheets = options.get("sheets")
|
|
255
|
+
if sheets:
|
|
256
|
+
parsed = []
|
|
257
|
+
for p in [s.strip() for s in sheets.split(",")]:
|
|
258
|
+
try:
|
|
259
|
+
parsed.append(int(p))
|
|
260
|
+
except ValueError:
|
|
261
|
+
parsed.append(p)
|
|
262
|
+
options["sheets"] = parsed
|
|
263
|
+
|
|
264
|
+
header_row = options.get("header_row", 0)
|
|
265
|
+
options["header_row"] = None if header_row == -1 else header_row
|
|
266
|
+
|
|
267
|
+
options["line_terminator"] = options.get("line_terminator", "\\r\\n").replace("\\r\\n", "\r\n").replace("\\n", "\n")
|
|
268
|
+
options["quoting"] = _QUOTING_MAP.get(str(options.get("quoting", "minimal")).lower(), csv.QUOTE_MINIMAL)
|
|
269
|
+
|
|
270
|
+
results = _convert_workbook(input_path=input_path, output_path=output_path, **options)
|
|
271
|
+
r = results[0]
|
|
272
|
+
return ConversionResult(output=r["output"], rows=r["rows"])
|
morph/deps.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""
|
|
2
|
+
deps.py — detects and (with explicit confirmation) installs external binaries
|
|
3
|
+
that converters shell out to, like pandoc and ffmpeg.
|
|
4
|
+
|
|
5
|
+
Design rules:
|
|
6
|
+
• morph NEVER installs anything silently. Every install shows the exact
|
|
7
|
+
command first and asks for a yes.
|
|
8
|
+
• morph NEVER auto-elevates. If a command needs sudo, it's part of the
|
|
9
|
+
printed command — the user sees it and approves it explicitly.
|
|
10
|
+
• Package manager + package name are resolved per-OS, since binary names
|
|
11
|
+
differ (e.g. ffmpeg on winget is "Gyan.FFmpeg", not "ffmpeg").
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import platform
|
|
17
|
+
import shlex
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
from rich.panel import Panel
|
|
25
|
+
from rich.prompt import Confirm
|
|
26
|
+
|
|
27
|
+
# ── package manager registry ──────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
# order matters: first found wins on a given OS
|
|
30
|
+
_MANAGERS_BY_OS = {
|
|
31
|
+
"Darwin": ["brew", "port"],
|
|
32
|
+
"Linux": ["apt", "dnf", "pacman", "zypper", "apk"],
|
|
33
|
+
"Windows": ["winget", "choco", "scoop"],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_INSTALL_CMD = {
|
|
37
|
+
"brew": "brew install {pkg}",
|
|
38
|
+
"port": "sudo port install {pkg}",
|
|
39
|
+
"apt": "sudo apt install -y {pkg}",
|
|
40
|
+
"dnf": "sudo dnf install -y {pkg}",
|
|
41
|
+
"pacman": "sudo pacman -S --noconfirm {pkg}",
|
|
42
|
+
"zypper": "sudo zypper install -y {pkg}",
|
|
43
|
+
"apk": "sudo apk add {pkg}",
|
|
44
|
+
"winget": "winget install --id {pkg} -e",
|
|
45
|
+
"choco": "choco install {pkg} -y",
|
|
46
|
+
"scoop": "scoop install {pkg}",
|
|
47
|
+
"pip": "playwright install {pkg}", # special case for playwright browsers
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# binary -> {package_manager: package_name}, only where it differs from the binary name
|
|
51
|
+
_PACKAGE_NAMES: dict[str, dict[str, str]] = {
|
|
52
|
+
"ffmpeg": {"winget": "Gyan.FFmpeg", "scoop": "ffmpeg"},
|
|
53
|
+
"pandoc": {"winget": "JohnMacFarlane.Pandoc"},
|
|
54
|
+
"tesseract": {"winget": "UB-Mannheim.TesseractOCR"},
|
|
55
|
+
"ebook-convert": {
|
|
56
|
+
"apt": "calibre", "dnf": "calibre", "pacman": "calibre",
|
|
57
|
+
"brew": "calibre", "winget": "calibre.calibre", "choco": "calibre",
|
|
58
|
+
},
|
|
59
|
+
"soffice": { # LibreOffice headless binary
|
|
60
|
+
"apt": "libreoffice", "dnf": "libreoffice", "pacman": "libreoffice-fresh",
|
|
61
|
+
"brew": "libreoffice", "winget": "TheDocumentFoundation.LibreOffice",
|
|
62
|
+
"choco": "libreoffice-fresh",
|
|
63
|
+
},
|
|
64
|
+
"playwright": {"winget": "chromium", "apt": "chromium", "brew": "chromium", "dnf": "chromium", "pacman": "chromium", "scoop": "chromium"},
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# extra one-line context shown to the user about *why* they need this
|
|
68
|
+
_WHY = {
|
|
69
|
+
"pandoc": "converts between document formats (docx, md, html, epub, ...)",
|
|
70
|
+
"ffmpeg": "converts audio and video files",
|
|
71
|
+
"soffice": "converts legacy office formats pandoc can't read directly",
|
|
72
|
+
"wkhtmltopdf": "renders documents to PDF (a lightweight PDF engine)",
|
|
73
|
+
"xelatex": "renders documents to PDF with full LaTeX/typography support",
|
|
74
|
+
"ebook-convert": "converts ebook formats (epub, mobi, azw3) — ships as part of Calibre",
|
|
75
|
+
"playwright": "installs a headless Chromium browser to render Javascript (for --js)",
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class DependencyStatus:
|
|
81
|
+
binary: str
|
|
82
|
+
installed: bool
|
|
83
|
+
manager: Optional[str]
|
|
84
|
+
install_cmd: Optional[str]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def detect_package_manager() -> Optional[str]:
|
|
88
|
+
system = platform.system()
|
|
89
|
+
for mgr in _MANAGERS_BY_OS.get(system, []):
|
|
90
|
+
if shutil.which(mgr):
|
|
91
|
+
return mgr
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_path_refreshed = False
|
|
96
|
+
|
|
97
|
+
def is_installed(binary: str) -> bool:
|
|
98
|
+
global _path_refreshed
|
|
99
|
+
if shutil.which(binary):
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
if platform.system() == "Windows" and not _path_refreshed:
|
|
103
|
+
_refresh_windows_path()
|
|
104
|
+
_path_refreshed = True
|
|
105
|
+
return shutil.which(binary) is not None
|
|
106
|
+
|
|
107
|
+
return False
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def check(binary: str) -> DependencyStatus:
|
|
111
|
+
if binary == "playwright":
|
|
112
|
+
# Check if the chromium browser folder exists inside playwright's cache.
|
|
113
|
+
# But a simple proxy is to check if `playwright install chromium` needs to run.
|
|
114
|
+
# We'll just assume `playwright` is in PATH (installed via pip) and construct the install command.
|
|
115
|
+
# Since we can't easily detect if the browser is downloaded, we'll try to run the install command.
|
|
116
|
+
# Actually, let's just make sure the `playwright` CLI exists.
|
|
117
|
+
# Wait, if we want to install chromium, we'll return false if we want it to run.
|
|
118
|
+
# Let's just return True if `playwright` exists, but actually we need the browsers.
|
|
119
|
+
# If `playwright` is checked, we just return the pip command.
|
|
120
|
+
return DependencyStatus(binary, is_installed("playwright"), "pip", "playwright install chromium")
|
|
121
|
+
|
|
122
|
+
if is_installed(binary):
|
|
123
|
+
return DependencyStatus(binary, True, None, None)
|
|
124
|
+
mgr = detect_package_manager()
|
|
125
|
+
if not mgr:
|
|
126
|
+
return DependencyStatus(binary, False, None, None)
|
|
127
|
+
pkg = _PACKAGE_NAMES.get(binary, {}).get(mgr, binary)
|
|
128
|
+
cmd = _INSTALL_CMD[mgr].format(pkg=pkg)
|
|
129
|
+
return DependencyStatus(binary, False, mgr, cmd)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def ensure(binary: str, console: Optional[Console] = None, *, assume_yes: bool = False) -> bool:
|
|
133
|
+
"""
|
|
134
|
+
Make sure `binary` is available, prompting to install it if not.
|
|
135
|
+
Returns True if the binary is usable by the time this returns.
|
|
136
|
+
|
|
137
|
+
assume_yes=True skips the confirmation prompt (e.g. `morph convert -y`)
|
|
138
|
+
but morph still ALWAYS shows the exact command before running it — the
|
|
139
|
+
only thing -y skips is the interactive "are you sure?", never visibility.
|
|
140
|
+
"""
|
|
141
|
+
console = console or Console()
|
|
142
|
+
status = check(binary)
|
|
143
|
+
if status.installed:
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
why = _WHY.get(binary, "is required for this conversion")
|
|
147
|
+
if not status.manager:
|
|
148
|
+
console.print(Panel.fit(
|
|
149
|
+
f"[warning]{binary}[/warning] {why}, but it isn't installed and "
|
|
150
|
+
f"morph couldn't find a supported package manager on this system.\n\n"
|
|
151
|
+
f"Install it manually, then re-run this command.",
|
|
152
|
+
title="Missing dependency", border_style="yellow",
|
|
153
|
+
))
|
|
154
|
+
return False
|
|
155
|
+
|
|
156
|
+
console.print(Panel.fit(
|
|
157
|
+
f"[bold white]{binary}[/bold white] {why}, but it isn't installed.\n\n"
|
|
158
|
+
f"morph can install it with:\n [accent]{status.install_cmd}[/accent]",
|
|
159
|
+
title="Missing dependency", border_style="yellow",
|
|
160
|
+
))
|
|
161
|
+
|
|
162
|
+
if not assume_yes and not Confirm.ask("Run this now?", default=True):
|
|
163
|
+
console.print(f"[muted]Skipped. Install it yourself with:[/muted] {status.install_cmd}")
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
console.print(f"[info]→ running:[/info] {status.install_cmd}")
|
|
167
|
+
try:
|
|
168
|
+
result = subprocess.run(shlex.split(status.install_cmd))
|
|
169
|
+
except FileNotFoundError as e:
|
|
170
|
+
console.print(f"[error]✗ Could not run installer:[/error] {e}")
|
|
171
|
+
return False
|
|
172
|
+
|
|
173
|
+
if result.returncode != 0:
|
|
174
|
+
console.print(f"[error]✗ Install command exited with status {result.returncode}[/error]")
|
|
175
|
+
return False
|
|
176
|
+
|
|
177
|
+
# The installer might have updated the system PATH registry, but the current terminal
|
|
178
|
+
# process won't see it until restarted. Let's force a refresh in the current process.
|
|
179
|
+
if not is_installed(binary):
|
|
180
|
+
_refresh_windows_path()
|
|
181
|
+
|
|
182
|
+
if is_installed(binary):
|
|
183
|
+
console.print(f"[success]✓ {binary} installed successfully[/success]")
|
|
184
|
+
return True
|
|
185
|
+
|
|
186
|
+
console.print(f"[error]✗ Install finished but {binary} still isn't on PATH.[/error]")
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _refresh_windows_path() -> None:
|
|
191
|
+
"""Forces the current Python process to pull the latest PATH from the Windows Registry."""
|
|
192
|
+
if platform.system() != "Windows":
|
|
193
|
+
return
|
|
194
|
+
import os
|
|
195
|
+
import winreg
|
|
196
|
+
|
|
197
|
+
new_paths = []
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"System\CurrentControlSet\Control\Session Manager\Environment") as key:
|
|
201
|
+
sys_path, _ = winreg.QueryValueEx(key, "Path")
|
|
202
|
+
new_paths.extend(sys_path.split(os.pathsep))
|
|
203
|
+
except Exception:
|
|
204
|
+
pass
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Environment") as key:
|
|
208
|
+
user_path, _ = winreg.QueryValueEx(key, "Path")
|
|
209
|
+
new_paths.extend(user_path.split(os.pathsep))
|
|
210
|
+
except Exception:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
# Hardcode well-known locations for badly behaved installers that don't add to PATH at all
|
|
214
|
+
well_known = [
|
|
215
|
+
r"C:\Program Files\Tesseract-OCR",
|
|
216
|
+
r"C:\Program Files (x86)\Tesseract-OCR",
|
|
217
|
+
]
|
|
218
|
+
for p in well_known:
|
|
219
|
+
if os.path.exists(p) and p not in new_paths:
|
|
220
|
+
new_paths.append(p)
|
|
221
|
+
|
|
222
|
+
if new_paths:
|
|
223
|
+
os.environ["PATH"] = os.environ.get("PATH", "") + os.pathsep + os.pathsep.join(new_paths)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def ensure_all(binaries: list[str], console: Optional[Console] = None, *, assume_yes: bool = False) -> bool:
|
|
227
|
+
"""Ensure every binary in the list is available. Stops at first failure."""
|
|
228
|
+
return all(ensure(b, console, assume_yes=assume_yes) for b in binaries)
|
morph/ffmpeg_utils.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ffmpeg_utils.py — shared ffmpeg execution with live progress reporting.
|
|
3
|
+
|
|
4
|
+
ffmpeg supports `-progress pipe:1`, which streams machine-readable
|
|
5
|
+
`key=value` lines as it works (out_time_ms=..., speed=..., progress=end).
|
|
6
|
+
Combined with the total duration from ffprobe, that's enough to compute a
|
|
7
|
+
real fractional progress — not just a spinner — for every audio/video hop.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Callable, Optional
|
|
15
|
+
|
|
16
|
+
ProgressCallback = Callable[[float, str], None]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def probe_duration(path: Path) -> Optional[float]:
|
|
20
|
+
"""Total duration in seconds, via ffprobe. None if it can't be determined
|
|
21
|
+
(e.g. some live/streamed inputs) — callers fall back to a spinner."""
|
|
22
|
+
try:
|
|
23
|
+
result = subprocess.run(
|
|
24
|
+
["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", str(path)],
|
|
25
|
+
capture_output=True, text=True, timeout=15,
|
|
26
|
+
)
|
|
27
|
+
return float(result.stdout.strip())
|
|
28
|
+
except Exception:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def run_ffmpeg(cmd: list[str], *, input_path: Optional[Path] = None,
|
|
33
|
+
progress: Optional[ProgressCallback] = None) -> None:
|
|
34
|
+
"""Run an ffmpeg command. `cmd[0]` must be 'ffmpeg'.
|
|
35
|
+
|
|
36
|
+
If `progress` is given, streams -progress output and calls
|
|
37
|
+
`progress(fraction_0_to_1, status_text)` as it becomes known. Falls back
|
|
38
|
+
to a single call at frac=None-equivalent (never called) if duration is
|
|
39
|
+
unavailable — the caller should already be showing an indeterminate
|
|
40
|
+
spinner in that case, this just upgrades it when it can.
|
|
41
|
+
"""
|
|
42
|
+
if progress is None:
|
|
43
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
44
|
+
if result.returncode != 0:
|
|
45
|
+
tail = "\n".join(result.stderr.strip().splitlines()[-6:])
|
|
46
|
+
raise RuntimeError(f"ffmpeg failed:\n{tail}")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
total = probe_duration(input_path) if input_path else None
|
|
50
|
+
live_cmd = [cmd[0], "-progress", "pipe:1", "-nostats", *cmd[1:]]
|
|
51
|
+
|
|
52
|
+
proc = subprocess.Popen(live_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
53
|
+
text=True, bufsize=1)
|
|
54
|
+
speed = ""
|
|
55
|
+
assert proc.stdout is not None
|
|
56
|
+
try:
|
|
57
|
+
for line in proc.stdout:
|
|
58
|
+
line = line.strip()
|
|
59
|
+
if not line or "=" not in line:
|
|
60
|
+
continue
|
|
61
|
+
key, _, value = line.partition("=")
|
|
62
|
+
if key == "speed":
|
|
63
|
+
speed = value.strip()
|
|
64
|
+
elif key == "out_time_ms" and total:
|
|
65
|
+
try:
|
|
66
|
+
seconds = int(value) / 1_000_000
|
|
67
|
+
frac = max(0.0, min(seconds / total, 1.0))
|
|
68
|
+
progress(frac, speed)
|
|
69
|
+
except ValueError:
|
|
70
|
+
pass
|
|
71
|
+
elif key == "progress" and value == "end":
|
|
72
|
+
progress(1.0, speed)
|
|
73
|
+
finally:
|
|
74
|
+
stderr_output = proc.stderr.read() if proc.stderr else ""
|
|
75
|
+
proc.wait()
|
|
76
|
+
|
|
77
|
+
if proc.returncode != 0:
|
|
78
|
+
tail = "\n".join(stderr_output.strip().splitlines()[-6:])
|
|
79
|
+
raise RuntimeError(f"ffmpeg failed:\n{tail}")
|