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.
@@ -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}")