mdify-cli 1.3.1__py3-none-any.whl → 2.5.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.
- assets/mdify.png +0 -0
- mdify/__init__.py +1 -1
- mdify/cli.py +454 -260
- mdify/container.py +132 -0
- mdify/docling_client.py +224 -0
- {mdify_cli-1.3.1.dist-info → mdify_cli-2.5.0.dist-info}/METADATA +43 -18
- mdify_cli-2.5.0.dist-info/RECORD +12 -0
- {mdify_cli-1.3.1.dist-info → mdify_cli-2.5.0.dist-info}/WHEEL +1 -1
- mdify_cli-1.3.1.dist-info/RECORD +0 -9
- {mdify_cli-1.3.1.dist-info → mdify_cli-2.5.0.dist-info}/entry_points.txt +0 -0
- {mdify_cli-1.3.1.dist-info → mdify_cli-2.5.0.dist-info}/licenses/LICENSE +0 -0
- {mdify_cli-1.3.1.dist-info → mdify_cli-2.5.0.dist-info}/top_level.txt +0 -0
mdify/cli.py
CHANGED
|
@@ -21,16 +21,18 @@ from urllib.error import URLError
|
|
|
21
21
|
from urllib.request import urlopen
|
|
22
22
|
|
|
23
23
|
from . import __version__
|
|
24
|
+
from mdify.container import DoclingContainer
|
|
25
|
+
from mdify.docling_client import convert_file
|
|
24
26
|
|
|
25
27
|
# Configuration
|
|
26
28
|
MDIFY_HOME = Path.home() / ".mdify"
|
|
27
29
|
LAST_CHECK_FILE = MDIFY_HOME / ".last_check"
|
|
28
|
-
|
|
29
|
-
GITHUB_API_URL = "https://api.github.com/repos/tiroq/mdify/releases/latest"
|
|
30
|
+
PYPI_API_URL = "https://pypi.org/pypi/mdify-cli/json"
|
|
30
31
|
CHECK_INTERVAL_SECONDS = 86400 # 24 hours
|
|
31
32
|
|
|
32
33
|
# Container configuration
|
|
33
|
-
DEFAULT_IMAGE = "ghcr.io/
|
|
34
|
+
DEFAULT_IMAGE = "ghcr.io/docling-project/docling-serve-cpu:main"
|
|
35
|
+
GPU_IMAGE = "ghcr.io/docling-project/docling-serve-cu126:main"
|
|
34
36
|
SUPPORTED_RUNTIMES = ("docker", "podman")
|
|
35
37
|
|
|
36
38
|
|
|
@@ -38,18 +40,19 @@ SUPPORTED_RUNTIMES = ("docker", "podman")
|
|
|
38
40
|
# Update checking functions
|
|
39
41
|
# =============================================================================
|
|
40
42
|
|
|
43
|
+
|
|
41
44
|
def _get_remote_version(timeout: int = 5) -> Optional[str]:
|
|
42
45
|
"""
|
|
43
|
-
Fetch the latest version from
|
|
44
|
-
|
|
46
|
+
Fetch the latest version from PyPI.
|
|
47
|
+
|
|
45
48
|
Returns:
|
|
46
|
-
Version string (e.g., "
|
|
49
|
+
Version string (e.g., "1.1.0") or None if fetch failed.
|
|
47
50
|
"""
|
|
48
51
|
try:
|
|
49
|
-
with urlopen(
|
|
52
|
+
with urlopen(PYPI_API_URL, timeout=timeout) as response:
|
|
50
53
|
data = json.loads(response.read().decode("utf-8"))
|
|
51
|
-
|
|
52
|
-
return
|
|
54
|
+
version = data.get("info", {}).get("version", "")
|
|
55
|
+
return version if version else None
|
|
53
56
|
except (URLError, json.JSONDecodeError, KeyError, TimeoutError):
|
|
54
57
|
return None
|
|
55
58
|
|
|
@@ -57,16 +60,16 @@ def _get_remote_version(timeout: int = 5) -> Optional[str]:
|
|
|
57
60
|
def _should_check_for_update() -> bool:
|
|
58
61
|
"""
|
|
59
62
|
Determine if we should check for updates based on last check time.
|
|
60
|
-
|
|
63
|
+
|
|
61
64
|
Returns:
|
|
62
65
|
True if check should be performed, False otherwise.
|
|
63
66
|
"""
|
|
64
67
|
if os.environ.get("MDIFY_NO_UPDATE_CHECK", "").lower() in ("1", "true", "yes"):
|
|
65
68
|
return False
|
|
66
|
-
|
|
69
|
+
|
|
67
70
|
if not LAST_CHECK_FILE.exists():
|
|
68
71
|
return True
|
|
69
|
-
|
|
72
|
+
|
|
70
73
|
try:
|
|
71
74
|
last_check = float(LAST_CHECK_FILE.read_text().strip())
|
|
72
75
|
elapsed = time.time() - last_check
|
|
@@ -87,63 +90,35 @@ def _update_last_check_time() -> None:
|
|
|
87
90
|
def _compare_versions(current: str, remote: str) -> bool:
|
|
88
91
|
"""
|
|
89
92
|
Compare version strings.
|
|
90
|
-
|
|
93
|
+
|
|
91
94
|
Returns:
|
|
92
95
|
True if remote version is newer than current.
|
|
93
96
|
"""
|
|
94
97
|
try:
|
|
95
98
|
current_parts = [int(x) for x in current.split(".")]
|
|
96
99
|
remote_parts = [int(x) for x in remote.split(".")]
|
|
97
|
-
|
|
100
|
+
|
|
98
101
|
max_len = max(len(current_parts), len(remote_parts))
|
|
99
102
|
current_parts.extend([0] * (max_len - len(current_parts)))
|
|
100
103
|
remote_parts.extend([0] * (max_len - len(remote_parts)))
|
|
101
|
-
|
|
104
|
+
|
|
102
105
|
return remote_parts > current_parts
|
|
103
106
|
except (ValueError, AttributeError):
|
|
104
107
|
return False
|
|
105
108
|
|
|
106
109
|
|
|
107
|
-
def _run_upgrade() -> bool:
|
|
108
|
-
"""
|
|
109
|
-
Run the upgrade installer.
|
|
110
|
-
|
|
111
|
-
Returns:
|
|
112
|
-
True if upgrade was successful, False otherwise.
|
|
113
|
-
"""
|
|
114
|
-
if not INSTALLER_PATH.exists():
|
|
115
|
-
print(
|
|
116
|
-
f"Installer not found at {INSTALLER_PATH}. "
|
|
117
|
-
"Please reinstall mdify manually.",
|
|
118
|
-
file=sys.stderr,
|
|
119
|
-
)
|
|
120
|
-
return False
|
|
121
|
-
|
|
122
|
-
try:
|
|
123
|
-
result = subprocess.run(
|
|
124
|
-
[str(INSTALLER_PATH), "--upgrade", "-y"],
|
|
125
|
-
check=True,
|
|
126
|
-
)
|
|
127
|
-
return result.returncode == 0
|
|
128
|
-
except subprocess.CalledProcessError:
|
|
129
|
-
return False
|
|
130
|
-
except OSError as e:
|
|
131
|
-
print(f"Failed to run installer: {e}", file=sys.stderr)
|
|
132
|
-
return False
|
|
133
|
-
|
|
134
|
-
|
|
135
110
|
def check_for_update(force: bool = False) -> None:
|
|
136
111
|
"""
|
|
137
112
|
Check for updates and prompt user to upgrade if available.
|
|
138
|
-
|
|
113
|
+
|
|
139
114
|
Args:
|
|
140
115
|
force: If True, check regardless of last check time and show errors.
|
|
141
116
|
"""
|
|
142
117
|
if not force and not _should_check_for_update():
|
|
143
118
|
return
|
|
144
|
-
|
|
119
|
+
|
|
145
120
|
remote_version = _get_remote_version()
|
|
146
|
-
|
|
121
|
+
|
|
147
122
|
if remote_version is None:
|
|
148
123
|
if force:
|
|
149
124
|
print(
|
|
@@ -153,49 +128,40 @@ def check_for_update(force: bool = False) -> None:
|
|
|
153
128
|
)
|
|
154
129
|
sys.exit(1)
|
|
155
130
|
return
|
|
156
|
-
|
|
131
|
+
|
|
157
132
|
_update_last_check_time()
|
|
158
|
-
|
|
133
|
+
|
|
159
134
|
if not _compare_versions(__version__, remote_version):
|
|
160
135
|
if force:
|
|
161
136
|
print(f"mdify is up to date (version {__version__})")
|
|
162
137
|
return
|
|
163
|
-
|
|
164
|
-
print(f"\n{'='*50}")
|
|
165
|
-
print(f"A new version of mdify is available!")
|
|
138
|
+
|
|
139
|
+
print(f"\n{'=' * 50}")
|
|
140
|
+
print(f"A new version of mdify-cli is available!")
|
|
166
141
|
print(f" Current version: {__version__}")
|
|
167
142
|
print(f" Latest version: {remote_version}")
|
|
168
|
-
print(f"{'='*50}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
except (EOFError, KeyboardInterrupt):
|
|
173
|
-
print()
|
|
174
|
-
return
|
|
175
|
-
|
|
176
|
-
if response in ("y", "yes"):
|
|
177
|
-
print("\nStarting upgrade...\n")
|
|
178
|
-
if _run_upgrade():
|
|
179
|
-
print("\nUpgrade completed! Please restart mdify.")
|
|
180
|
-
sys.exit(0)
|
|
181
|
-
else:
|
|
182
|
-
print("\nUpgrade failed. You can try manually with:")
|
|
183
|
-
print(f" {INSTALLER_PATH} --upgrade")
|
|
184
|
-
else:
|
|
185
|
-
print(f"\nTo upgrade later, run: {INSTALLER_PATH} --upgrade\n")
|
|
143
|
+
print(f"{'=' * 50}")
|
|
144
|
+
print(f"\nTo upgrade, run:")
|
|
145
|
+
print(f" pipx upgrade mdify-cli")
|
|
146
|
+
print(f" # or: pip install --upgrade mdify-cli\n")
|
|
186
147
|
|
|
187
148
|
|
|
188
149
|
# =============================================================================
|
|
189
150
|
# Container runtime functions
|
|
190
151
|
# =============================================================================
|
|
191
152
|
|
|
192
|
-
|
|
153
|
+
|
|
154
|
+
def detect_runtime(preferred: str, explicit: bool = True) -> Optional[str]:
|
|
193
155
|
"""
|
|
194
156
|
Detect available container runtime.
|
|
195
|
-
|
|
157
|
+
|
|
196
158
|
Args:
|
|
197
159
|
preferred: Preferred runtime ('docker' or 'podman')
|
|
198
|
-
|
|
160
|
+
explicit: If True, warn when falling back to alternative.
|
|
161
|
+
If False, silently use alternative without warning.
|
|
162
|
+
Note: This only controls warning emission; selection order
|
|
163
|
+
is always preferred → alternative regardless of this flag.
|
|
164
|
+
|
|
199
165
|
Returns:
|
|
200
166
|
Path to runtime executable, or None if not found.
|
|
201
167
|
"""
|
|
@@ -203,25 +169,28 @@ def detect_runtime(preferred: str) -> Optional[str]:
|
|
|
203
169
|
runtime_path = shutil.which(preferred)
|
|
204
170
|
if runtime_path:
|
|
205
171
|
return runtime_path
|
|
206
|
-
|
|
172
|
+
|
|
207
173
|
# Try alternative
|
|
208
174
|
alternative = "podman" if preferred == "docker" else "docker"
|
|
209
175
|
runtime_path = shutil.which(alternative)
|
|
210
176
|
if runtime_path:
|
|
211
|
-
|
|
177
|
+
if explicit:
|
|
178
|
+
print(
|
|
179
|
+
f"Warning: {preferred} not found, using {alternative}", file=sys.stderr
|
|
180
|
+
)
|
|
212
181
|
return runtime_path
|
|
213
|
-
|
|
182
|
+
|
|
214
183
|
return None
|
|
215
184
|
|
|
216
185
|
|
|
217
186
|
def check_image_exists(runtime: str, image: str) -> bool:
|
|
218
187
|
"""
|
|
219
188
|
Check if container image exists locally.
|
|
220
|
-
|
|
189
|
+
|
|
221
190
|
Args:
|
|
222
191
|
runtime: Path to container runtime
|
|
223
192
|
image: Image name/tag
|
|
224
|
-
|
|
193
|
+
|
|
225
194
|
Returns:
|
|
226
195
|
True if image exists locally.
|
|
227
196
|
"""
|
|
@@ -239,18 +208,18 @@ def check_image_exists(runtime: str, image: str) -> bool:
|
|
|
239
208
|
def pull_image(runtime: str, image: str, quiet: bool = False) -> bool:
|
|
240
209
|
"""
|
|
241
210
|
Pull container image.
|
|
242
|
-
|
|
211
|
+
|
|
243
212
|
Args:
|
|
244
213
|
runtime: Path to container runtime
|
|
245
214
|
image: Image name/tag
|
|
246
215
|
quiet: Suppress progress output
|
|
247
|
-
|
|
216
|
+
|
|
248
217
|
Returns:
|
|
249
218
|
True if pull succeeded.
|
|
250
219
|
"""
|
|
251
220
|
if not quiet:
|
|
252
221
|
print(f"Pulling image: {image}")
|
|
253
|
-
|
|
222
|
+
|
|
254
223
|
try:
|
|
255
224
|
result = subprocess.run(
|
|
256
225
|
[runtime, "pull", image],
|
|
@@ -263,11 +232,49 @@ def pull_image(runtime: str, image: str, quiet: bool = False) -> bool:
|
|
|
263
232
|
return False
|
|
264
233
|
|
|
265
234
|
|
|
235
|
+
def get_image_size_estimate(runtime: str, image: str) -> Optional[int]:
|
|
236
|
+
"""
|
|
237
|
+
Estimate image size by querying registry manifest.
|
|
238
|
+
|
|
239
|
+
Runs `<runtime> manifest inspect --verbose <image>` and sums all layer sizes
|
|
240
|
+
across all architectures, then applies 50% buffer for decompression.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
runtime: Path to container runtime
|
|
244
|
+
image: Image name/tag
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Estimated size in bytes with 50% buffer, or None if command fails.
|
|
248
|
+
"""
|
|
249
|
+
try:
|
|
250
|
+
result = subprocess.run(
|
|
251
|
+
[runtime, "manifest", "inspect", "--verbose", image],
|
|
252
|
+
capture_output=True,
|
|
253
|
+
check=False,
|
|
254
|
+
)
|
|
255
|
+
if result.returncode != 0:
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
manifest_data = json.loads(result.stdout.decode())
|
|
259
|
+
|
|
260
|
+
# Sum all layer sizes across all architectures
|
|
261
|
+
total_size = 0
|
|
262
|
+
for manifest in manifest_data.get("Manifests", []):
|
|
263
|
+
oci_manifest = manifest.get("OCIManifest", {})
|
|
264
|
+
for layer in oci_manifest.get("layers", []):
|
|
265
|
+
total_size += layer.get("size", 0)
|
|
266
|
+
|
|
267
|
+
# Apply 50% buffer for decompression (compressed -> uncompressed)
|
|
268
|
+
return int(total_size * 1.5)
|
|
269
|
+
except (json.JSONDecodeError, KeyError, ValueError, OSError):
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
266
273
|
def format_size(size_bytes: int) -> str:
|
|
267
274
|
"""Format file size in human-readable format."""
|
|
268
|
-
for unit in [
|
|
275
|
+
for unit in ["B", "KB", "MB", "GB"]:
|
|
269
276
|
if size_bytes < 1024:
|
|
270
|
-
return f"{size_bytes:.1f} {unit}" if unit !=
|
|
277
|
+
return f"{size_bytes:.1f} {unit}" if unit != "B" else f"{size_bytes} {unit}"
|
|
271
278
|
size_bytes /= 1024
|
|
272
279
|
return f"{size_bytes:.1f} TB"
|
|
273
280
|
|
|
@@ -285,31 +292,100 @@ def format_duration(seconds: float) -> str:
|
|
|
285
292
|
return f"{hours}h {mins}m {secs:.0f}s"
|
|
286
293
|
|
|
287
294
|
|
|
295
|
+
def get_free_space(path: str) -> int:
|
|
296
|
+
"""Get free disk space for the given path in bytes."""
|
|
297
|
+
try:
|
|
298
|
+
return shutil.disk_usage(path).free
|
|
299
|
+
except (FileNotFoundError, OSError):
|
|
300
|
+
return 0
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def get_storage_root(runtime: str) -> Optional[str]:
|
|
304
|
+
"""
|
|
305
|
+
Get the storage root directory for Docker or Podman.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
runtime: Path to container runtime executable
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Storage root path as string, or None if command fails.
|
|
312
|
+
"""
|
|
313
|
+
try:
|
|
314
|
+
# Extract runtime name from path (e.g., /usr/bin/docker -> docker)
|
|
315
|
+
runtime_name = os.path.basename(runtime)
|
|
316
|
+
|
|
317
|
+
if runtime_name == "docker":
|
|
318
|
+
result = subprocess.run(
|
|
319
|
+
[runtime, "system", "info", "--format", "{{.DockerRootDir}}"],
|
|
320
|
+
capture_output=True,
|
|
321
|
+
check=False,
|
|
322
|
+
)
|
|
323
|
+
if result.stdout:
|
|
324
|
+
return result.stdout.decode().strip()
|
|
325
|
+
elif runtime_name == "podman":
|
|
326
|
+
result = subprocess.run(
|
|
327
|
+
[runtime, "info", "--format", "json"],
|
|
328
|
+
capture_output=True,
|
|
329
|
+
check=False,
|
|
330
|
+
)
|
|
331
|
+
if result.stdout:
|
|
332
|
+
info = json.loads(result.stdout.decode())
|
|
333
|
+
return info.get("store", {}).get("graphRoot")
|
|
334
|
+
return None
|
|
335
|
+
except (OSError, json.JSONDecodeError):
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def confirm_proceed(message: str, default_no: bool = True) -> bool:
|
|
340
|
+
"""
|
|
341
|
+
Prompt user for confirmation with a y/N prompt.
|
|
342
|
+
|
|
343
|
+
Args:
|
|
344
|
+
message: The confirmation message to display
|
|
345
|
+
default_no: If True, shows [y/N] (default no). If False, shows [Y/n] (default yes)
|
|
346
|
+
|
|
347
|
+
Returns:
|
|
348
|
+
True if user entered 'y' or 'Y', False otherwise.
|
|
349
|
+
Returns False immediately if stdin is not a TTY (non-interactive).
|
|
350
|
+
"""
|
|
351
|
+
if not sys.stdin.isatty():
|
|
352
|
+
return False
|
|
353
|
+
|
|
354
|
+
prompt = "[y/N]" if default_no else "[Y/n]"
|
|
355
|
+
print(f"{message} {prompt}", file=sys.stderr)
|
|
356
|
+
response = input()
|
|
357
|
+
return response.lower() == "y"
|
|
358
|
+
|
|
359
|
+
|
|
288
360
|
class Spinner:
|
|
289
361
|
"""A simple spinner to show progress during long operations."""
|
|
290
|
-
|
|
362
|
+
|
|
291
363
|
def __init__(self):
|
|
292
|
-
self.frames = [
|
|
364
|
+
self.frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
293
365
|
self.running = False
|
|
294
366
|
self.thread = None
|
|
295
367
|
self.start_time = None
|
|
296
|
-
|
|
368
|
+
|
|
297
369
|
def _spin(self):
|
|
298
370
|
idx = 0
|
|
299
371
|
while self.running:
|
|
300
372
|
elapsed = time.time() - self.start_time
|
|
301
373
|
frame = self.frames[idx % len(self.frames)]
|
|
302
|
-
print(
|
|
374
|
+
print(
|
|
375
|
+
f"\r{self.prefix} {frame} ({format_duration(elapsed)})",
|
|
376
|
+
end="",
|
|
377
|
+
flush=True,
|
|
378
|
+
)
|
|
303
379
|
idx += 1
|
|
304
380
|
time.sleep(0.1)
|
|
305
|
-
|
|
381
|
+
|
|
306
382
|
def start(self, prefix: str = ""):
|
|
307
383
|
self.prefix = prefix
|
|
308
384
|
self.running = True
|
|
309
385
|
self.start_time = time.time()
|
|
310
386
|
self.thread = threading.Thread(target=self._spin, daemon=True)
|
|
311
387
|
self.thread.start()
|
|
312
|
-
|
|
388
|
+
|
|
313
389
|
def stop(self):
|
|
314
390
|
self.running = False
|
|
315
391
|
if self.thread:
|
|
@@ -318,93 +394,45 @@ class Spinner:
|
|
|
318
394
|
print(f"\r{' ' * 80}\r", end="", flush=True)
|
|
319
395
|
|
|
320
396
|
|
|
321
|
-
def run_container(
|
|
322
|
-
runtime: str,
|
|
323
|
-
image: str,
|
|
324
|
-
input_file: Path,
|
|
325
|
-
output_file: Path,
|
|
326
|
-
mask_pii: bool = False,
|
|
327
|
-
) -> Tuple[bool, str, float]:
|
|
328
|
-
"""
|
|
329
|
-
Run container to convert a single file.
|
|
330
|
-
|
|
331
|
-
Args:
|
|
332
|
-
runtime: Path to container runtime
|
|
333
|
-
image: Image name/tag
|
|
334
|
-
input_file: Absolute path to input file
|
|
335
|
-
output_file: Absolute path to output file
|
|
336
|
-
mask_pii: Whether to mask PII in images
|
|
337
|
-
|
|
338
|
-
Returns:
|
|
339
|
-
Tuple of (success: bool, message: str, elapsed_seconds: float)
|
|
340
|
-
"""
|
|
341
|
-
start_time = time.time()
|
|
342
|
-
|
|
343
|
-
# Ensure output directory exists
|
|
344
|
-
output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
345
|
-
|
|
346
|
-
# Mount directories
|
|
347
|
-
input_dir = input_file.parent
|
|
348
|
-
output_dir = output_file.parent
|
|
349
|
-
|
|
350
|
-
# Container paths
|
|
351
|
-
container_in = f"/work/in/{input_file.name}"
|
|
352
|
-
container_out = f"/work/out/{output_file.name}"
|
|
353
|
-
|
|
354
|
-
cmd = [
|
|
355
|
-
runtime, "run", "--rm",
|
|
356
|
-
"-v", f"{input_dir}:/work/in:ro",
|
|
357
|
-
"-v", f"{output_dir}:/work/out",
|
|
358
|
-
image,
|
|
359
|
-
"--in", container_in,
|
|
360
|
-
"--out", container_out,
|
|
361
|
-
]
|
|
362
|
-
|
|
363
|
-
if mask_pii:
|
|
364
|
-
cmd.append("--mask")
|
|
365
|
-
|
|
366
|
-
try:
|
|
367
|
-
result = subprocess.run(
|
|
368
|
-
cmd,
|
|
369
|
-
capture_output=True,
|
|
370
|
-
text=True,
|
|
371
|
-
check=False,
|
|
372
|
-
)
|
|
373
|
-
elapsed = time.time() - start_time
|
|
374
|
-
|
|
375
|
-
if result.returncode == 0:
|
|
376
|
-
return True, "success", elapsed
|
|
377
|
-
else:
|
|
378
|
-
error_msg = result.stderr.strip() or result.stdout.strip() or "Unknown error"
|
|
379
|
-
return False, error_msg, elapsed
|
|
380
|
-
|
|
381
|
-
except OSError as e:
|
|
382
|
-
elapsed = time.time() - start_time
|
|
383
|
-
return False, str(e), elapsed
|
|
384
|
-
|
|
385
|
-
|
|
386
397
|
# =============================================================================
|
|
387
398
|
# File handling functions
|
|
388
399
|
# =============================================================================
|
|
389
400
|
|
|
390
401
|
# Supported file extensions (based on Docling InputFormat)
|
|
391
402
|
SUPPORTED_EXTENSIONS = {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
403
|
+
".pdf",
|
|
404
|
+
".docx",
|
|
405
|
+
".pptx",
|
|
406
|
+
".html",
|
|
407
|
+
".htm",
|
|
408
|
+
".png",
|
|
409
|
+
".jpg",
|
|
410
|
+
".jpeg",
|
|
411
|
+
".gif",
|
|
412
|
+
".bmp",
|
|
413
|
+
".tiff",
|
|
414
|
+
".tif", # images
|
|
415
|
+
".asciidoc",
|
|
416
|
+
".adoc",
|
|
417
|
+
".asc", # asciidoc
|
|
418
|
+
".md",
|
|
419
|
+
".markdown", # markdown
|
|
420
|
+
".csv",
|
|
421
|
+
".xlsx", # spreadsheets
|
|
422
|
+
".xml", # XML formats
|
|
423
|
+
".json", # JSON docling
|
|
424
|
+
".mp3",
|
|
425
|
+
".wav",
|
|
426
|
+
".m4a",
|
|
427
|
+
".flac", # audio
|
|
428
|
+
".vtt", # subtitles
|
|
401
429
|
}
|
|
402
430
|
|
|
403
431
|
|
|
404
432
|
def get_files_to_convert(input_path: Path, mask: str, recursive: bool) -> List[Path]:
|
|
405
433
|
"""Get list of files to convert based on input path and options."""
|
|
406
434
|
files = []
|
|
407
|
-
|
|
435
|
+
|
|
408
436
|
if input_path.is_file():
|
|
409
437
|
files.append(input_path)
|
|
410
438
|
elif input_path.is_dir():
|
|
@@ -412,19 +440,19 @@ def get_files_to_convert(input_path: Path, mask: str, recursive: bool) -> List[P
|
|
|
412
440
|
files = list(input_path.rglob(mask))
|
|
413
441
|
else:
|
|
414
442
|
files = list(input_path.glob(mask))
|
|
415
|
-
|
|
443
|
+
|
|
416
444
|
# Filter to only files
|
|
417
445
|
files = [f for f in files if f.is_file()]
|
|
418
446
|
else:
|
|
419
447
|
raise FileNotFoundError(f"Input path does not exist: {input_path}")
|
|
420
|
-
|
|
448
|
+
|
|
421
449
|
# Filter out hidden files and unsupported formats
|
|
422
450
|
files = [
|
|
423
|
-
f
|
|
424
|
-
|
|
425
|
-
and f.suffix.lower() in SUPPORTED_EXTENSIONS
|
|
451
|
+
f
|
|
452
|
+
for f in files
|
|
453
|
+
if not f.name.startswith(".") and f.suffix.lower() in SUPPORTED_EXTENSIONS
|
|
426
454
|
]
|
|
427
|
-
|
|
455
|
+
|
|
428
456
|
return files
|
|
429
457
|
|
|
430
458
|
|
|
@@ -457,7 +485,7 @@ def get_output_path(
|
|
|
457
485
|
output_path = output_dir / relative_path.parent / output_name
|
|
458
486
|
except ValueError:
|
|
459
487
|
output_path = output_dir / output_name
|
|
460
|
-
|
|
488
|
+
|
|
461
489
|
return output_path
|
|
462
490
|
|
|
463
491
|
|
|
@@ -465,6 +493,7 @@ def get_output_path(
|
|
|
465
493
|
# CLI argument parsing
|
|
466
494
|
# =============================================================================
|
|
467
495
|
|
|
496
|
+
|
|
468
497
|
def parse_args() -> argparse.Namespace:
|
|
469
498
|
"""Parse command line arguments."""
|
|
470
499
|
parser = argparse.ArgumentParser(
|
|
@@ -479,74 +508,99 @@ Examples:
|
|
|
479
508
|
mdify ./docs --runtime podman Use Podman instead of Docker
|
|
480
509
|
""",
|
|
481
510
|
)
|
|
482
|
-
|
|
511
|
+
|
|
483
512
|
parser.add_argument(
|
|
484
513
|
"input",
|
|
485
514
|
type=str,
|
|
486
515
|
nargs="?",
|
|
487
516
|
help="Input file or directory to convert",
|
|
488
517
|
)
|
|
489
|
-
|
|
518
|
+
|
|
490
519
|
parser.add_argument(
|
|
491
|
-
"-o",
|
|
520
|
+
"-o",
|
|
521
|
+
"--out-dir",
|
|
492
522
|
type=str,
|
|
493
523
|
default="output",
|
|
494
524
|
help="Output directory for converted files (default: output)",
|
|
495
525
|
)
|
|
496
|
-
|
|
526
|
+
|
|
497
527
|
parser.add_argument(
|
|
498
|
-
"-g",
|
|
528
|
+
"-g",
|
|
529
|
+
"--glob",
|
|
499
530
|
type=str,
|
|
500
531
|
default="*",
|
|
501
532
|
help="Glob pattern for filtering files in directory (default: *)",
|
|
502
533
|
)
|
|
503
|
-
|
|
534
|
+
|
|
504
535
|
parser.add_argument(
|
|
505
|
-
"-r",
|
|
536
|
+
"-r",
|
|
537
|
+
"--recursive",
|
|
506
538
|
action="store_true",
|
|
507
539
|
help="Recursively scan directories",
|
|
508
540
|
)
|
|
509
|
-
|
|
541
|
+
|
|
510
542
|
parser.add_argument(
|
|
511
543
|
"--flat",
|
|
512
544
|
action="store_true",
|
|
513
545
|
help="Disable directory structure preservation in output",
|
|
514
546
|
)
|
|
515
|
-
|
|
547
|
+
|
|
516
548
|
parser.add_argument(
|
|
517
549
|
"--overwrite",
|
|
518
550
|
action="store_true",
|
|
519
551
|
help="Overwrite existing output files",
|
|
520
552
|
)
|
|
521
|
-
|
|
553
|
+
|
|
522
554
|
parser.add_argument(
|
|
523
|
-
"-q",
|
|
555
|
+
"-q",
|
|
556
|
+
"--quiet",
|
|
524
557
|
action="store_true",
|
|
525
558
|
help="Suppress progress messages",
|
|
526
559
|
)
|
|
527
|
-
|
|
560
|
+
|
|
528
561
|
parser.add_argument(
|
|
529
|
-
"-
|
|
562
|
+
"-y",
|
|
563
|
+
"--yes",
|
|
564
|
+
action="store_true",
|
|
565
|
+
help="Skip confirmation prompts (for scripts/CI)",
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
parser.add_argument(
|
|
569
|
+
"-m",
|
|
570
|
+
"--mask",
|
|
530
571
|
action="store_true",
|
|
531
572
|
help="Mask PII and sensitive content in document images",
|
|
532
573
|
)
|
|
533
|
-
|
|
574
|
+
|
|
575
|
+
parser.add_argument(
|
|
576
|
+
"--gpu",
|
|
577
|
+
action="store_true",
|
|
578
|
+
help="Use GPU-accelerated container image (docling-serve-cu126)",
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
parser.add_argument(
|
|
582
|
+
"--port",
|
|
583
|
+
type=int,
|
|
584
|
+
default=5001,
|
|
585
|
+
help="Port for docling-serve container (default: 5001)",
|
|
586
|
+
)
|
|
587
|
+
|
|
534
588
|
# Container options
|
|
535
589
|
parser.add_argument(
|
|
536
590
|
"--runtime",
|
|
537
591
|
type=str,
|
|
538
592
|
choices=SUPPORTED_RUNTIMES,
|
|
539
|
-
default=
|
|
540
|
-
help="Container runtime to use (
|
|
593
|
+
default=None,
|
|
594
|
+
help="Container runtime to use (auto-detects docker or podman if not specified)",
|
|
541
595
|
)
|
|
542
|
-
|
|
596
|
+
|
|
543
597
|
parser.add_argument(
|
|
544
598
|
"--image",
|
|
545
599
|
type=str,
|
|
546
600
|
default=DEFAULT_IMAGE,
|
|
547
601
|
help=f"Container image to use (default: {DEFAULT_IMAGE})",
|
|
548
602
|
)
|
|
549
|
-
|
|
603
|
+
|
|
550
604
|
parser.add_argument(
|
|
551
605
|
"--pull",
|
|
552
606
|
type=str,
|
|
@@ -554,20 +608,27 @@ Examples:
|
|
|
554
608
|
default="missing",
|
|
555
609
|
help="Image pull policy: always, missing, never (default: missing)",
|
|
556
610
|
)
|
|
557
|
-
|
|
611
|
+
|
|
612
|
+
parser.add_argument(
|
|
613
|
+
"--timeout",
|
|
614
|
+
type=int,
|
|
615
|
+
default=None,
|
|
616
|
+
help="Conversion timeout in seconds (default: 1200, can be set via MDIFY_TIMEOUT env var)",
|
|
617
|
+
)
|
|
618
|
+
|
|
558
619
|
# Utility options
|
|
559
620
|
parser.add_argument(
|
|
560
621
|
"--check-update",
|
|
561
622
|
action="store_true",
|
|
562
623
|
help="Check for available updates and exit",
|
|
563
624
|
)
|
|
564
|
-
|
|
625
|
+
|
|
565
626
|
parser.add_argument(
|
|
566
627
|
"--version",
|
|
567
628
|
action="version",
|
|
568
629
|
version=f"mdify {__version__}",
|
|
569
630
|
)
|
|
570
|
-
|
|
631
|
+
|
|
571
632
|
return parser.parse_args()
|
|
572
633
|
|
|
573
634
|
|
|
@@ -575,27 +636,33 @@ Examples:
|
|
|
575
636
|
# Main entry point
|
|
576
637
|
# =============================================================================
|
|
577
638
|
|
|
639
|
+
|
|
578
640
|
def main() -> int:
|
|
579
641
|
"""Main entry point for the CLI."""
|
|
580
642
|
args = parse_args()
|
|
581
|
-
|
|
643
|
+
|
|
582
644
|
# Handle --check-update flag
|
|
583
645
|
if args.check_update:
|
|
584
646
|
check_for_update(force=True)
|
|
585
647
|
return 0
|
|
586
|
-
|
|
648
|
+
|
|
587
649
|
# Check for updates (daily, silent on errors)
|
|
588
650
|
check_for_update(force=False)
|
|
589
|
-
|
|
651
|
+
|
|
652
|
+
# Resolve timeout value: CLI > env > default 1200
|
|
653
|
+
timeout = args.timeout or int(os.environ.get("MDIFY_TIMEOUT", 1200))
|
|
654
|
+
|
|
590
655
|
# Validate input is provided
|
|
591
656
|
if args.input is None:
|
|
592
657
|
print("Error: Input file or directory is required", file=sys.stderr)
|
|
593
658
|
print("Usage: mdify <input> [options]", file=sys.stderr)
|
|
594
659
|
print(" mdify --help for more information", file=sys.stderr)
|
|
595
660
|
return 1
|
|
596
|
-
|
|
661
|
+
|
|
597
662
|
# Detect container runtime
|
|
598
|
-
|
|
663
|
+
preferred = args.runtime if args.runtime else "docker"
|
|
664
|
+
explicit = args.runtime is not None
|
|
665
|
+
runtime = detect_runtime(preferred, explicit=explicit)
|
|
599
666
|
if runtime is None:
|
|
600
667
|
print(
|
|
601
668
|
f"Error: Container runtime not found ({', '.join(SUPPORTED_RUNTIMES)})",
|
|
@@ -603,109 +670,236 @@ def main() -> int:
|
|
|
603
670
|
)
|
|
604
671
|
print("Please install Docker or Podman to use mdify.", file=sys.stderr)
|
|
605
672
|
return 2
|
|
606
|
-
|
|
673
|
+
|
|
607
674
|
# Handle image pull policy
|
|
608
|
-
image
|
|
675
|
+
# Determine image based on --gpu flag
|
|
676
|
+
if args.gpu:
|
|
677
|
+
image = GPU_IMAGE
|
|
678
|
+
elif args.image:
|
|
679
|
+
image = args.image
|
|
680
|
+
else:
|
|
681
|
+
image = DEFAULT_IMAGE
|
|
682
|
+
|
|
609
683
|
image_exists = check_image_exists(runtime, image)
|
|
610
|
-
|
|
684
|
+
|
|
685
|
+
# NOTE: Docker Desktop on macOS/Windows uses a VM, so disk space checks may not
|
|
686
|
+
# accurately reflect available space in the container's filesystem. Remote Docker
|
|
687
|
+
# daemons (DOCKER_HOST) are also not supported. In these cases, the check will
|
|
688
|
+
# gracefully degrade (warn and proceed).
|
|
689
|
+
|
|
690
|
+
# Check disk space before pulling image (skip if pull=never or image exists with pull=missing)
|
|
691
|
+
will_pull = args.pull == "always" or (args.pull == "missing" and not image_exists)
|
|
692
|
+
if will_pull:
|
|
693
|
+
storage_root = get_storage_root(runtime)
|
|
694
|
+
if storage_root:
|
|
695
|
+
image_size = get_image_size_estimate(runtime, image)
|
|
696
|
+
if image_size:
|
|
697
|
+
free_space = get_free_space(storage_root)
|
|
698
|
+
if free_space < image_size:
|
|
699
|
+
print(
|
|
700
|
+
f"Warning: Not enough free disk space on {storage_root}",
|
|
701
|
+
file=sys.stderr,
|
|
702
|
+
)
|
|
703
|
+
print(
|
|
704
|
+
f" Available: {format_size(free_space)}",
|
|
705
|
+
file=sys.stderr,
|
|
706
|
+
)
|
|
707
|
+
print(
|
|
708
|
+
f" Required: {format_size(image_size)} (estimated)",
|
|
709
|
+
file=sys.stderr,
|
|
710
|
+
)
|
|
711
|
+
if args.yes:
|
|
712
|
+
print(" Proceeding anyway (--yes flag set)", file=sys.stderr)
|
|
713
|
+
elif not sys.stdin.isatty():
|
|
714
|
+
print(
|
|
715
|
+
" Run with --yes to proceed anyway, or free up disk space",
|
|
716
|
+
file=sys.stderr,
|
|
717
|
+
)
|
|
718
|
+
return 1
|
|
719
|
+
elif not confirm_proceed("Continue anyway?"):
|
|
720
|
+
return 130
|
|
721
|
+
elif free_space - image_size < 1024 * 1024 * 1024:
|
|
722
|
+
print(
|
|
723
|
+
f"Warning: Less than 1 GB would remain after pulling image on {storage_root}",
|
|
724
|
+
file=sys.stderr,
|
|
725
|
+
)
|
|
726
|
+
print(
|
|
727
|
+
f" Available: {format_size(free_space)}",
|
|
728
|
+
file=sys.stderr,
|
|
729
|
+
)
|
|
730
|
+
print(
|
|
731
|
+
f" Required: {format_size(image_size)} (estimated)",
|
|
732
|
+
file=sys.stderr,
|
|
733
|
+
)
|
|
734
|
+
print(
|
|
735
|
+
f" Remaining: {format_size(free_space - image_size)}",
|
|
736
|
+
file=sys.stderr,
|
|
737
|
+
)
|
|
738
|
+
if args.yes:
|
|
739
|
+
print(" Proceeding anyway (--yes flag set)", file=sys.stderr)
|
|
740
|
+
elif not sys.stdin.isatty():
|
|
741
|
+
print(
|
|
742
|
+
" Run with --yes to proceed anyway, or free up disk space",
|
|
743
|
+
file=sys.stderr,
|
|
744
|
+
)
|
|
745
|
+
return 1
|
|
746
|
+
elif not confirm_proceed("Continue anyway?"):
|
|
747
|
+
return 130
|
|
748
|
+
|
|
611
749
|
if args.pull == "always" or (args.pull == "missing" and not image_exists):
|
|
612
750
|
if not pull_image(runtime, image, args.quiet):
|
|
613
751
|
print(f"Error: Failed to pull image: {image}", file=sys.stderr)
|
|
614
752
|
return 1
|
|
615
753
|
elif args.pull == "never" and not image_exists:
|
|
616
754
|
print(f"Error: Image not found locally: {image}", file=sys.stderr)
|
|
617
|
-
print(f"Run with --pull=missing or pull manually: {
|
|
755
|
+
print(f"Run with --pull=missing or pull manually: {preferred} pull {image}")
|
|
618
756
|
return 1
|
|
619
|
-
|
|
620
|
-
# Resolve paths
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
757
|
+
|
|
758
|
+
# Resolve paths (use absolute() as fallback if resolve() fails due to permissions)
|
|
759
|
+
try:
|
|
760
|
+
input_path = Path(args.input).resolve()
|
|
761
|
+
except PermissionError:
|
|
762
|
+
input_path = Path(args.input).absolute()
|
|
763
|
+
try:
|
|
764
|
+
output_dir = Path(args.out_dir).resolve()
|
|
765
|
+
except PermissionError:
|
|
766
|
+
output_dir = Path(args.out_dir).absolute()
|
|
767
|
+
|
|
624
768
|
# Validate input
|
|
625
769
|
if not input_path.exists():
|
|
626
770
|
print(f"Error: Input path does not exist: {input_path}", file=sys.stderr)
|
|
627
771
|
return 1
|
|
628
|
-
|
|
772
|
+
|
|
629
773
|
# Get files to convert
|
|
630
774
|
try:
|
|
631
775
|
files_to_convert = get_files_to_convert(input_path, args.glob, args.recursive)
|
|
632
776
|
except Exception as e:
|
|
633
777
|
print(f"Error: {e}", file=sys.stderr)
|
|
634
778
|
return 1
|
|
635
|
-
|
|
779
|
+
|
|
636
780
|
if not files_to_convert:
|
|
637
781
|
print(f"No files found to convert in: {input_path}", file=sys.stderr)
|
|
638
782
|
return 1
|
|
639
|
-
|
|
783
|
+
|
|
640
784
|
total_files = len(files_to_convert)
|
|
641
785
|
total_size = sum(f.stat().st_size for f in files_to_convert)
|
|
642
|
-
|
|
786
|
+
|
|
643
787
|
if not args.quiet:
|
|
644
788
|
print(f"Found {total_files} file(s) to convert ({format_size(total_size)})")
|
|
645
789
|
print(f"Using runtime: {runtime}")
|
|
646
790
|
print(f"Using image: {image}")
|
|
647
791
|
print()
|
|
648
|
-
|
|
792
|
+
|
|
793
|
+
if args.mask:
|
|
794
|
+
print(
|
|
795
|
+
"Warning: --mask is not supported with docling-serve and will be ignored",
|
|
796
|
+
file=sys.stderr,
|
|
797
|
+
)
|
|
798
|
+
|
|
649
799
|
# Determine input base for directory structure preservation
|
|
650
800
|
if input_path.is_file():
|
|
651
801
|
input_base = input_path.parent
|
|
652
802
|
else:
|
|
653
803
|
input_base = input_path
|
|
654
|
-
|
|
655
|
-
# Convert files
|
|
804
|
+
|
|
656
805
|
success_count = 0
|
|
657
806
|
skipped_count = 0
|
|
658
807
|
failed_count = 0
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
for idx, input_file in enumerate(files_to_convert, 1):
|
|
663
|
-
output_file = get_output_path(input_file, input_base, output_dir, args.flat)
|
|
664
|
-
file_size = input_file.stat().st_size
|
|
665
|
-
progress = f"[{idx}/{total_files}]"
|
|
666
|
-
|
|
667
|
-
# Check if output exists and skip if not overwriting
|
|
668
|
-
if output_file.exists() and not args.overwrite:
|
|
669
|
-
if not args.quiet:
|
|
670
|
-
print(f"{progress} Skipped (exists): {input_file.name}")
|
|
671
|
-
skipped_count += 1
|
|
672
|
-
continue
|
|
673
|
-
|
|
674
|
-
# Show spinner while processing
|
|
808
|
+
total_elapsed = 0.0
|
|
809
|
+
|
|
810
|
+
try:
|
|
675
811
|
if not args.quiet:
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
812
|
+
print(f"Starting docling-serve container...")
|
|
813
|
+
print()
|
|
814
|
+
|
|
815
|
+
with DoclingContainer(runtime, image, args.port, timeout=timeout) as container:
|
|
816
|
+
# Convert files
|
|
817
|
+
conversion_start = time.time()
|
|
818
|
+
spinner = Spinner()
|
|
819
|
+
|
|
820
|
+
for idx, input_file in enumerate(files_to_convert, 1):
|
|
821
|
+
output_file = get_output_path(
|
|
822
|
+
input_file, input_base, output_dir, args.flat
|
|
823
|
+
)
|
|
824
|
+
file_size = input_file.stat().st_size
|
|
825
|
+
progress = f"[{idx}/{total_files}]"
|
|
826
|
+
|
|
827
|
+
# Check if output exists and skip if not overwriting
|
|
828
|
+
if output_file.exists() and not args.overwrite:
|
|
829
|
+
if not args.quiet:
|
|
830
|
+
print(f"{progress} Skipped (exists): {input_file.name}")
|
|
831
|
+
skipped_count += 1
|
|
832
|
+
continue
|
|
833
|
+
|
|
834
|
+
# Ensure output directory exists
|
|
835
|
+
output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
836
|
+
|
|
837
|
+
# Show spinner while processing
|
|
838
|
+
if not args.quiet:
|
|
839
|
+
spinner.start(
|
|
840
|
+
f"{progress} Processing: {input_file.name} ({format_size(file_size)})"
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
start_time = time.time()
|
|
844
|
+
try:
|
|
845
|
+
# Convert via HTTP API
|
|
846
|
+
result = convert_file(
|
|
847
|
+
container.base_url, input_file, to_format="md"
|
|
848
|
+
)
|
|
849
|
+
elapsed = time.time() - start_time
|
|
850
|
+
|
|
851
|
+
if not args.quiet:
|
|
852
|
+
spinner.stop()
|
|
853
|
+
|
|
854
|
+
if result.success:
|
|
855
|
+
# Write result to output file
|
|
856
|
+
output_file.write_text(result.content)
|
|
857
|
+
success_count += 1
|
|
858
|
+
if not args.quiet:
|
|
859
|
+
print(
|
|
860
|
+
f"{progress} {input_file.name} ✓ ({format_duration(elapsed)})"
|
|
861
|
+
)
|
|
862
|
+
else:
|
|
863
|
+
failed_count += 1
|
|
864
|
+
error_msg = result.error or "Unknown error"
|
|
865
|
+
if not args.quiet:
|
|
866
|
+
print(
|
|
867
|
+
f"{progress} {input_file.name} ✗ ({format_duration(elapsed)})"
|
|
868
|
+
)
|
|
869
|
+
print(f" Error: {error_msg}", file=sys.stderr)
|
|
870
|
+
except Exception as e:
|
|
871
|
+
elapsed = time.time() - start_time
|
|
872
|
+
failed_count += 1
|
|
873
|
+
if not args.quiet:
|
|
874
|
+
spinner.stop()
|
|
875
|
+
print(
|
|
876
|
+
f"{progress} {input_file.name} ✗ ({format_duration(elapsed)})"
|
|
877
|
+
)
|
|
878
|
+
print(f" Error: {str(e)}", file=sys.stderr)
|
|
879
|
+
|
|
880
|
+
total_elapsed = time.time() - conversion_start
|
|
881
|
+
|
|
882
|
+
# Print summary
|
|
682
883
|
if not args.quiet:
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
print(f" Total files: {total_files}")
|
|
703
|
-
print(f" Successful: {success_count}")
|
|
704
|
-
print(f" Skipped: {skipped_count}")
|
|
705
|
-
print(f" Failed: {failed_count}")
|
|
706
|
-
print(f" Total time: {format_duration(total_elapsed)}")
|
|
707
|
-
print("=" * 50)
|
|
708
|
-
|
|
884
|
+
print()
|
|
885
|
+
print("=" * 50)
|
|
886
|
+
print("Conversion Summary:")
|
|
887
|
+
print(f" Total files: {total_files}")
|
|
888
|
+
print(f" Successful: {success_count}")
|
|
889
|
+
print(f" Skipped: {skipped_count}")
|
|
890
|
+
print(f" Failed: {failed_count}")
|
|
891
|
+
print(f" Total time: {format_duration(total_elapsed)}")
|
|
892
|
+
print("=" * 50)
|
|
893
|
+
|
|
894
|
+
except KeyboardInterrupt:
|
|
895
|
+
if not args.quiet:
|
|
896
|
+
print("\n\nInterrupted by user. Container stopped.")
|
|
897
|
+
if success_count > 0 or skipped_count > 0 or failed_count > 0:
|
|
898
|
+
print(
|
|
899
|
+
f"Partial progress: {success_count} successful, {failed_count} failed, {skipped_count} skipped"
|
|
900
|
+
)
|
|
901
|
+
return 130
|
|
902
|
+
|
|
709
903
|
# Return appropriate exit code
|
|
710
904
|
if failed_count > 0:
|
|
711
905
|
return 1
|