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.
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
- INSTALLER_PATH = MDIFY_HOME / "install.sh"
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/tiroq/mdify-runtime:latest"
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 GitHub API.
44
-
46
+ Fetch the latest version from PyPI.
47
+
45
48
  Returns:
46
- Version string (e.g., "0.2.0") or None if fetch failed.
49
+ Version string (e.g., "1.1.0") or None if fetch failed.
47
50
  """
48
51
  try:
49
- with urlopen(GITHUB_API_URL, timeout=timeout) as response:
52
+ with urlopen(PYPI_API_URL, timeout=timeout) as response:
50
53
  data = json.loads(response.read().decode("utf-8"))
51
- tag = data.get("tag_name", "")
52
- return tag.lstrip("v") if tag else None
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}\n")
169
-
170
- try:
171
- response = input("Run upgrade now? [y/N] ").strip().lower()
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
- def detect_runtime(preferred: str) -> Optional[str]:
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
- print(f"Warning: {preferred} not found, using {alternative}", file=sys.stderr)
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 ['B', 'KB', 'MB', 'GB']:
275
+ for unit in ["B", "KB", "MB", "GB"]:
269
276
  if size_bytes < 1024:
270
- return f"{size_bytes:.1f} {unit}" if unit != 'B' else f"{size_bytes} {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(f"\r{self.prefix} {frame} ({format_duration(elapsed)})", end="", flush=True)
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
- '.pdf', '.docx', '.pptx', '.html', '.htm',
393
- '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif', # images
394
- '.asciidoc', '.adoc', '.asc', # asciidoc
395
- '.md', '.markdown', # markdown
396
- '.csv', '.xlsx', # spreadsheets
397
- '.xml', # XML formats
398
- '.json', # JSON docling
399
- '.mp3', '.wav', '.m4a', '.flac', # audio
400
- '.vtt', # subtitles
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 for f in files
424
- if not f.name.startswith('.')
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", "--out-dir",
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", "--glob",
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", "--recursive",
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", "--quiet",
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
- "-m", "--mask",
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="docker",
540
- help="Container runtime to use (default: docker)",
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
- runtime = detect_runtime(args.runtime)
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 = args.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: {args.runtime} pull {image}")
755
+ print(f"Run with --pull=missing or pull manually: {preferred} pull {image}")
618
756
  return 1
619
-
620
- # Resolve paths
621
- input_path = Path(args.input).resolve()
622
- output_dir = Path(args.out_dir).resolve()
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
- conversion_start = time.time()
660
- spinner = Spinner()
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
- spinner.start(f"{progress} Processing: {input_file.name} ({format_size(file_size)})")
677
-
678
- success, result, elapsed = run_container(
679
- runtime, image, input_file, output_file, args.mask
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
- spinner.stop()
684
-
685
- if success:
686
- success_count += 1
687
- if not args.quiet:
688
- print(f"{progress} {input_file.name} ✓ ({format_duration(elapsed)})")
689
- else:
690
- failed_count += 1
691
- if not args.quiet:
692
- print(f"{progress} {input_file.name} ✗ ({format_duration(elapsed)})")
693
- print(f" Error: {result}", file=sys.stderr)
694
-
695
- total_elapsed = time.time() - conversion_start
696
-
697
- # Print summary
698
- if not args.quiet:
699
- print()
700
- print("=" * 50)
701
- print("Conversion Summary:")
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