mcli-framework 7.9.2__py3-none-any.whl → 7.9.6__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.

Potentially problematic release.


This version of mcli-framework might be problematic. Click here for more details.

Files changed (72) hide show
  1. mcli/workflow/doc_convert.py +794 -0
  2. {mcli_framework-7.9.2.dist-info → mcli_framework-7.9.6.dist-info}/METADATA +1 -1
  3. {mcli_framework-7.9.2.dist-info → mcli_framework-7.9.6.dist-info}/RECORD +7 -71
  4. mcli/__init__.py +0 -160
  5. mcli/__main__.py +0 -14
  6. mcli/app/__init__.py +0 -23
  7. mcli/app/model/__init__.py +0 -0
  8. mcli/app/video/__init__.py +0 -5
  9. mcli/chat/__init__.py +0 -34
  10. mcli/lib/__init__.py +0 -0
  11. mcli/lib/api/__init__.py +0 -0
  12. mcli/lib/auth/__init__.py +0 -1
  13. mcli/lib/config/__init__.py +0 -1
  14. mcli/lib/erd/__init__.py +0 -25
  15. mcli/lib/files/__init__.py +0 -0
  16. mcli/lib/fs/__init__.py +0 -1
  17. mcli/lib/logger/__init__.py +0 -3
  18. mcli/lib/performance/__init__.py +0 -17
  19. mcli/lib/pickles/__init__.py +0 -1
  20. mcli/lib/secrets/__init__.py +0 -10
  21. mcli/lib/shell/__init__.py +0 -0
  22. mcli/lib/toml/__init__.py +0 -1
  23. mcli/lib/watcher/__init__.py +0 -0
  24. mcli/ml/__init__.py +0 -16
  25. mcli/ml/api/__init__.py +0 -30
  26. mcli/ml/api/routers/__init__.py +0 -27
  27. mcli/ml/auth/__init__.py +0 -41
  28. mcli/ml/backtesting/__init__.py +0 -33
  29. mcli/ml/cli/__init__.py +0 -5
  30. mcli/ml/config/__init__.py +0 -33
  31. mcli/ml/configs/__init__.py +0 -16
  32. mcli/ml/dashboard/__init__.py +0 -12
  33. mcli/ml/dashboard/components/__init__.py +0 -7
  34. mcli/ml/dashboard/pages/__init__.py +0 -6
  35. mcli/ml/data_ingestion/__init__.py +0 -29
  36. mcli/ml/database/__init__.py +0 -40
  37. mcli/ml/experimentation/__init__.py +0 -29
  38. mcli/ml/features/__init__.py +0 -39
  39. mcli/ml/mlops/__init__.py +0 -19
  40. mcli/ml/models/__init__.py +0 -90
  41. mcli/ml/monitoring/__init__.py +0 -25
  42. mcli/ml/optimization/__init__.py +0 -27
  43. mcli/ml/predictions/__init__.py +0 -5
  44. mcli/ml/preprocessing/__init__.py +0 -24
  45. mcli/ml/scripts/__init__.py +0 -1
  46. mcli/ml/serving/__init__.py +0 -1
  47. mcli/ml/trading/__init__.py +0 -63
  48. mcli/ml/training/__init__.py +0 -7
  49. mcli/mygroup/__init__.py +0 -3
  50. mcli/public/__init__.py +0 -1
  51. mcli/public/commands/__init__.py +0 -2
  52. mcli/self/__init__.py +0 -3
  53. mcli/workflow/__init__.py +0 -0
  54. mcli/workflow/daemon/__init__.py +0 -15
  55. mcli/workflow/dashboard/__init__.py +0 -5
  56. mcli/workflow/docker/__init__.py +0 -0
  57. mcli/workflow/file/__init__.py +0 -0
  58. mcli/workflow/gcloud/__init__.py +0 -1
  59. mcli/workflow/git_commit/__init__.py +0 -0
  60. mcli/workflow/interview/__init__.py +0 -0
  61. mcli/workflow/politician_trading/__init__.py +0 -4
  62. mcli/workflow/registry/__init__.py +0 -0
  63. mcli/workflow/repo/__init__.py +0 -0
  64. mcli/workflow/scheduler/__init__.py +0 -25
  65. mcli/workflow/search/__init__.py +0 -0
  66. mcli/workflow/sync/__init__.py +0 -5
  67. mcli/workflow/videos/__init__.py +0 -1
  68. mcli/workflow/wakatime/__init__.py +0 -80
  69. {mcli_framework-7.9.2.dist-info → mcli_framework-7.9.6.dist-info}/WHEEL +0 -0
  70. {mcli_framework-7.9.2.dist-info → mcli_framework-7.9.6.dist-info}/entry_points.txt +0 -0
  71. {mcli_framework-7.9.2.dist-info → mcli_framework-7.9.6.dist-info}/licenses/LICENSE +0 -0
  72. {mcli_framework-7.9.2.dist-info → mcli_framework-7.9.6.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,794 @@
1
+ """
2
+ Document conversion workflow with multiple fallback strategies and temp directory isolation.
3
+
4
+ A robust wrapper around pandoc, nbconvert, and other conversion tools
5
+ with automatic fallback to alternative methods when the primary method fails.
6
+ Uses temporary directory with hard links to avoid path issues.
7
+ """
8
+
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import tempfile
13
+ from dataclasses import dataclass
14
+ from enum import Enum
15
+ from glob import glob as file_glob
16
+ from pathlib import Path
17
+ from typing import List, Optional, Tuple
18
+
19
+ import click
20
+
21
+ from mcli.lib.logger.logger import get_logger
22
+ from mcli.lib.paths import get_custom_commands_dir
23
+ from mcli.lib.ui.styling import error, info, success, warning
24
+
25
+ logger = get_logger()
26
+
27
+ # Format aliases to handle common abbreviations and file extensions
28
+ FORMAT_ALIASES = {
29
+ # Markdown variants
30
+ "md": "markdown",
31
+ "markdown": "markdown",
32
+ "gfm": "gfm", # GitHub Flavored Markdown
33
+ # Document formats
34
+ "doc": "docx",
35
+ "docx": "docx",
36
+ "odt": "odt",
37
+ # Markup formats
38
+ "html": "html",
39
+ "htm": "html",
40
+ "xhtml": "html",
41
+ # PDF
42
+ "pdf": "pdf",
43
+ # LaTeX
44
+ "tex": "latex",
45
+ "latex": "latex",
46
+ # Jupyter
47
+ "ipynb": "ipynb",
48
+ "notebook": "ipynb",
49
+ # Text formats
50
+ "txt": "plain",
51
+ "text": "plain",
52
+ "plain": "plain",
53
+ # Presentation formats
54
+ "pptx": "pptx",
55
+ "ppt": "pptx",
56
+ # Other formats
57
+ "rst": "rst",
58
+ "org": "org",
59
+ "mediawiki": "mediawiki",
60
+ "textile": "textile",
61
+ "rtf": "rtf",
62
+ "epub": "epub",
63
+ }
64
+
65
+
66
+ class ConversionMethod(Enum):
67
+ """Available conversion methods"""
68
+ PANDOC = "pandoc"
69
+ NBCONVERT = "nbconvert"
70
+ PANDOC_LATEX = "pandoc_latex"
71
+ PANDOC_HTML_INTERMEDIATE = "pandoc_html_intermediate"
72
+
73
+
74
+ @dataclass
75
+ class ConversionStrategy:
76
+ """Represents a conversion strategy with command and description"""
77
+ method: ConversionMethod
78
+ description: str
79
+ check_command: Optional[str] = None
80
+
81
+
82
+ def get_temp_conversion_dir() -> Path:
83
+ """Get or create temporary conversion directory in ~/.mcli/commands/temp/"""
84
+ commands_dir = get_custom_commands_dir()
85
+ temp_dir = commands_dir / "temp" / "conversions"
86
+ temp_dir.mkdir(parents=True, exist_ok=True)
87
+ return temp_dir
88
+
89
+
90
+ def create_temp_hardlink(source_path: Path) -> Tuple[Path, Path]:
91
+ """
92
+ Create a hard link to the source file in temp directory.
93
+
94
+ Returns: (temp_file_path, temp_dir_path)
95
+ """
96
+ temp_base = get_temp_conversion_dir()
97
+
98
+ # Create unique temp directory for this conversion
99
+ temp_dir = temp_base / f"conv_{os.getpid()}_{source_path.stem}"
100
+ temp_dir.mkdir(parents=True, exist_ok=True)
101
+
102
+ # Create hard link with simple name (avoids path issues)
103
+ temp_file = temp_dir / source_path.name
104
+
105
+ try:
106
+ # Try hard link first (most efficient)
107
+ os.link(source_path, temp_file)
108
+ logger.debug(f"Created hard link: {temp_file}")
109
+ except (OSError, NotImplementedError):
110
+ # Fall back to copy if hard link not supported (e.g., across filesystems)
111
+ shutil.copy2(source_path, temp_file)
112
+ logger.debug(f"Created copy (hard link not available): {temp_file}")
113
+
114
+ return temp_file, temp_dir
115
+
116
+
117
+ def cleanup_temp_conversion(temp_dir: Path, preserve_output: Optional[Path] = None):
118
+ """
119
+ Clean up temporary conversion directory.
120
+
121
+ Args:
122
+ temp_dir: Temporary directory to clean up
123
+ preserve_output: If specified, copy this file out before cleanup
124
+ """
125
+ try:
126
+ if preserve_output and preserve_output.exists():
127
+ # Output file is already in temp_dir, we'll handle it separately
128
+ pass
129
+
130
+ # Remove the entire temp directory
131
+ if temp_dir.exists():
132
+ shutil.rmtree(temp_dir)
133
+ logger.debug(f"Cleaned up temp directory: {temp_dir}")
134
+ except Exception as e:
135
+ logger.warning(f"Failed to clean up temp directory {temp_dir}: {e}")
136
+
137
+
138
+ def get_conversion_strategies(
139
+ input_path: Path,
140
+ output_path: Path,
141
+ from_format: str,
142
+ to_format: str,
143
+ pandoc_args: str = ""
144
+ ) -> List[ConversionStrategy]:
145
+ """
146
+ Get ordered list of conversion strategies to try based on input/output formats.
147
+
148
+ Returns strategies in priority order (most likely to succeed first).
149
+ """
150
+ strategies = []
151
+
152
+ # Special handling for Jupyter notebook to PDF (notoriously problematic)
153
+ if from_format == "ipynb" and to_format == "pdf":
154
+ # Strategy 1: nbconvert (most reliable for notebooks)
155
+ strategies.append(ConversionStrategy(
156
+ method=ConversionMethod.NBCONVERT,
157
+ description="jupyter nbconvert (best for notebooks)",
158
+ check_command="jupyter-nbconvert"
159
+ ))
160
+
161
+ # Strategy 2: pandoc with pdflatex
162
+ strategies.append(ConversionStrategy(
163
+ method=ConversionMethod.PANDOC_LATEX,
164
+ description="pandoc with pdflatex engine"
165
+ ))
166
+
167
+ # Strategy 3: pandoc via HTML intermediate
168
+ strategies.append(ConversionStrategy(
169
+ method=ConversionMethod.PANDOC_HTML_INTERMEDIATE,
170
+ description="pandoc via HTML intermediate (wkhtmltopdf)"
171
+ ))
172
+
173
+ # Strategy 4: standard pandoc
174
+ strategies.append(ConversionStrategy(
175
+ method=ConversionMethod.PANDOC,
176
+ description="pandoc default method"
177
+ ))
178
+
179
+ # Jupyter to other formats
180
+ elif from_format == "ipynb":
181
+ # Try nbconvert first for notebooks
182
+ strategies.append(ConversionStrategy(
183
+ method=ConversionMethod.NBCONVERT,
184
+ description="jupyter nbconvert",
185
+ check_command="jupyter-nbconvert"
186
+ ))
187
+ strategies.append(ConversionStrategy(
188
+ method=ConversionMethod.PANDOC,
189
+ description="pandoc"
190
+ ))
191
+
192
+ # PDF output (general)
193
+ elif to_format == "pdf":
194
+ strategies.append(ConversionStrategy(
195
+ method=ConversionMethod.PANDOC_LATEX,
196
+ description="pandoc with LaTeX"
197
+ ))
198
+ strategies.append(ConversionStrategy(
199
+ method=ConversionMethod.PANDOC,
200
+ description="pandoc default"
201
+ ))
202
+
203
+ # Default: just use pandoc
204
+ else:
205
+ strategies.append(ConversionStrategy(
206
+ method=ConversionMethod.PANDOC,
207
+ description="pandoc"
208
+ ))
209
+
210
+ return strategies
211
+
212
+
213
+ def execute_conversion_strategy(
214
+ strategy: ConversionStrategy,
215
+ input_path: Path,
216
+ output_path: Path,
217
+ from_format: str,
218
+ to_format: str,
219
+ pandoc_args: str = ""
220
+ ) -> Tuple[bool, str]:
221
+ """
222
+ Execute a specific conversion strategy in a temp directory.
223
+
224
+ Returns: (success: bool, error_message: str)
225
+ """
226
+ # Create temp hard link for conversion
227
+ temp_input, temp_dir = create_temp_hardlink(input_path)
228
+ temp_output = temp_dir / f"{input_path.stem}.{to_format.lower()}"
229
+
230
+ try:
231
+ if strategy.method == ConversionMethod.NBCONVERT:
232
+ # Check if nbconvert is available
233
+ check = subprocess.run(
234
+ ["jupyter", "nbconvert", "--version"],
235
+ capture_output=True,
236
+ timeout=5
237
+ )
238
+ if check.returncode != 0:
239
+ return False, "jupyter nbconvert not available"
240
+
241
+ # Build nbconvert command (run in temp directory)
242
+ cmd = [
243
+ "jupyter", "nbconvert",
244
+ "--to", to_format,
245
+ "--output", str(temp_output),
246
+ str(temp_input)
247
+ ]
248
+
249
+ # Run in temp directory
250
+ result = subprocess.run(
251
+ cmd,
252
+ capture_output=True,
253
+ text=True,
254
+ check=True,
255
+ timeout=120,
256
+ cwd=str(temp_dir)
257
+ )
258
+
259
+ elif strategy.method == ConversionMethod.PANDOC_LATEX:
260
+ # Pandoc with explicit LaTeX engine (xelatex for better Unicode support)
261
+ cmd = [
262
+ "pandoc",
263
+ str(temp_input),
264
+ "-f", from_format,
265
+ "-o", str(temp_output),
266
+ "--pdf-engine=xelatex"
267
+ ]
268
+ if pandoc_args:
269
+ cmd.extend(pandoc_args.split())
270
+
271
+ result = subprocess.run(
272
+ cmd,
273
+ capture_output=True,
274
+ text=True,
275
+ check=True,
276
+ timeout=120,
277
+ cwd=str(temp_dir)
278
+ )
279
+
280
+ elif strategy.method == ConversionMethod.PANDOC_HTML_INTERMEDIATE:
281
+ # Convert to HTML first, then to PDF
282
+ html_temp = temp_dir / f"{input_path.stem}_temp.html"
283
+
284
+ # Step 1: Convert to HTML
285
+ cmd_html = [
286
+ "pandoc",
287
+ str(temp_input),
288
+ "-f", from_format,
289
+ "-t", "html",
290
+ "-o", str(html_temp),
291
+ "--standalone"
292
+ ]
293
+ result = subprocess.run(
294
+ cmd_html,
295
+ capture_output=True,
296
+ text=True,
297
+ timeout=120,
298
+ cwd=str(temp_dir)
299
+ )
300
+ if result.returncode != 0:
301
+ return False, f"HTML intermediate failed: {result.stderr}"
302
+
303
+ # Step 2: Convert HTML to PDF
304
+ cmd = [
305
+ "pandoc",
306
+ str(html_temp),
307
+ "-f", "html",
308
+ "-t", "pdf",
309
+ "-o", str(temp_output)
310
+ ]
311
+
312
+ result = subprocess.run(
313
+ cmd,
314
+ capture_output=True,
315
+ text=True,
316
+ check=True,
317
+ timeout=120,
318
+ cwd=str(temp_dir)
319
+ )
320
+
321
+ else: # PANDOC
322
+ # Standard pandoc conversion
323
+ cmd = [
324
+ "pandoc",
325
+ str(temp_input),
326
+ "-f", from_format,
327
+ "-o", str(temp_output)
328
+ ]
329
+ # Use xelatex for PDF conversions (better Unicode support)
330
+ if to_format == "pdf":
331
+ cmd.append("--pdf-engine=xelatex")
332
+ if pandoc_args:
333
+ cmd.extend(pandoc_args.split())
334
+
335
+ result = subprocess.run(
336
+ cmd,
337
+ capture_output=True,
338
+ text=True,
339
+ check=True,
340
+ timeout=120,
341
+ cwd=str(temp_dir)
342
+ )
343
+
344
+ # Copy output file to final destination
345
+ if temp_output.exists():
346
+ shutil.copy2(temp_output, output_path)
347
+ return True, ""
348
+ else:
349
+ return False, "Output file not created"
350
+
351
+ except subprocess.TimeoutExpired:
352
+ return False, "Conversion timed out (>120s)"
353
+ except subprocess.CalledProcessError as e:
354
+ return False, e.stderr or str(e)
355
+ except Exception as e:
356
+ return False, str(e)
357
+ finally:
358
+ # Always clean up temp directory
359
+ cleanup_temp_conversion(temp_dir)
360
+
361
+
362
+ @click.group(name="doc-convert")
363
+ def doc_convert():
364
+ """Document conversion with automatic fallback strategies"""
365
+ pass
366
+
367
+
368
+ @doc_convert.command()
369
+ def init():
370
+ """
371
+ Install all necessary dependencies for document conversion via Homebrew.
372
+
373
+ This will install:
374
+ - pandoc: Universal document converter
375
+ - basictex: LaTeX distribution for PDF generation
376
+ - jupyter & nbconvert: Best for converting Jupyter notebooks
377
+ """
378
+ info("=" * 60)
379
+ info("📦 Installing doc-convert dependencies")
380
+ info("=" * 60)
381
+ info("")
382
+
383
+ # Check if Homebrew is installed
384
+ try:
385
+ subprocess.run(["brew", "--version"], capture_output=True, check=True)
386
+ success("✅ Homebrew is installed")
387
+ except (subprocess.CalledProcessError, FileNotFoundError):
388
+ error("❌ Homebrew is not installed. Install it from https://brew.sh")
389
+ return
390
+
391
+ info("")
392
+
393
+ # Install pandoc
394
+ info("📥 Installing pandoc...")
395
+ try:
396
+ result = subprocess.run(["brew", "install", "pandoc"], capture_output=True, text=True)
397
+ if result.returncode == 0:
398
+ success(" ✅ pandoc installed successfully")
399
+ else:
400
+ check = subprocess.run(["which", "pandoc"], capture_output=True)
401
+ if check.returncode == 0:
402
+ info(" ℹ️ pandoc is already installed")
403
+ else:
404
+ error(f" ❌ Failed to install pandoc: {result.stderr}")
405
+ except Exception as e:
406
+ error(f" ❌ Error installing pandoc: {e}")
407
+
408
+ info("")
409
+
410
+ # Install Jupyter and nbconvert
411
+ info("📥 Installing Jupyter & nbconvert (for notebook conversion)...")
412
+ try:
413
+ # Check if already installed
414
+ check = subprocess.run(["jupyter", "nbconvert", "--version"], capture_output=True)
415
+ if check.returncode == 0:
416
+ info(" ℹ️ jupyter nbconvert is already installed")
417
+ else:
418
+ # Try installing via pip
419
+ result = subprocess.run(
420
+ ["pip3", "install", "jupyter", "nbconvert"],
421
+ capture_output=True,
422
+ text=True
423
+ )
424
+ if result.returncode == 0:
425
+ success(" ✅ jupyter & nbconvert installed successfully")
426
+ else:
427
+ warning(" ⚠️ Could not install jupyter via pip")
428
+ info(" ℹ️ You can install manually: pip3 install jupyter nbconvert")
429
+ except Exception as e:
430
+ warning(f" ⚠️ Error installing jupyter: {e}")
431
+ info(" ℹ️ Jupyter is optional but recommended for .ipynb conversions")
432
+
433
+ info("")
434
+
435
+ # Install BasicTeX (lightweight LaTeX for PDF support)
436
+ info("📥 Installing BasicTeX (for PDF generation)...")
437
+ info(" ℹ️ This is a large download (~100MB) and may take a few minutes")
438
+ try:
439
+ result = subprocess.run(
440
+ ["brew", "install", "--cask", "basictex"],
441
+ capture_output=True,
442
+ text=True
443
+ )
444
+ if result.returncode == 0:
445
+ success(" ✅ BasicTeX installed successfully")
446
+ info(" ℹ️ You may need to restart your terminal or run: eval $(/usr/libexec/path_helper)")
447
+ info("")
448
+ info(" 📦 Installing LaTeX packages for document conversion...")
449
+ info(" ℹ️ This requires sudo access and may take a few minutes")
450
+ info("")
451
+ info(" RECOMMENDED (installs all common packages + fonts):")
452
+ info(" sudo tlmgr install collection-latexextra collection-fontsrecommended")
453
+ info(" sudo mktexlsr")
454
+ info("")
455
+ info(" OR install individual packages:")
456
+ info(" sudo tlmgr install tcolorbox environ pgf tools pdfcol \\")
457
+ info(" adjustbox collectbox xkeyval \\")
458
+ info(" booktabs ulem titling enumitem soul \\")
459
+ info(" jknapltx rsfs")
460
+ info(" sudo tlmgr install collection-fontsrecommended")
461
+ info(" sudo mktexlsr")
462
+ else:
463
+ check = subprocess.run(["which", "pdflatex"], capture_output=True)
464
+ if check.returncode == 0:
465
+ info(" ℹ️ LaTeX is already installed")
466
+ else:
467
+ warning(" ⚠️ BasicTeX installation may have failed")
468
+ info(" ℹ️ You can skip this for non-PDF conversions")
469
+ except Exception as e:
470
+ warning(f" ⚠️ Error installing BasicTeX: {e}")
471
+ info(" ℹ️ BasicTeX is only needed for PDF conversions")
472
+
473
+ info("")
474
+ info("=" * 60)
475
+ success("✨ Installation complete!")
476
+ info("=" * 60)
477
+ info("")
478
+ info("Installed tools:")
479
+ info(" • pandoc - Universal document converter (with XeLaTeX for Unicode support)")
480
+ info(" • jupyter nbconvert - Best for Jupyter notebooks")
481
+ info(" • basictex - LaTeX for PDF generation")
482
+ info("")
483
+ info("⚠️ IMPORTANT: For Jupyter notebook → PDF conversions:")
484
+ info(" Install required LaTeX packages (requires sudo):")
485
+ info("")
486
+ info(" RECOMMENDED (installs all common packages + fonts):")
487
+ info(" sudo tlmgr install collection-latexextra collection-fontsrecommended")
488
+ info(" sudo mktexlsr")
489
+ info("")
490
+ info(" OR install individual packages:")
491
+ info(" sudo tlmgr install tcolorbox environ pgf tools pdfcol \\")
492
+ info(" adjustbox collectbox xkeyval \\")
493
+ info(" booktabs ulem titling enumitem soul \\")
494
+ info(" jknapltx rsfs")
495
+ info(" sudo tlmgr install collection-fontsrecommended")
496
+ info(" sudo mktexlsr")
497
+ info("")
498
+ info("💡 NOTE: The converter uses XeLaTeX for better Unicode/emoji support")
499
+ info(" in documents. Fallback strategies handle most edge cases.")
500
+ info("")
501
+ info("You can now use: mcli workflow doc-convert convert <from> <to> <file>")
502
+ info("Example: mcli workflow doc-convert convert ipynb pdf notebook.ipynb")
503
+ info("")
504
+ info("To uninstall dependencies later:")
505
+ info(" mcli workflow doc-convert cleanup")
506
+ info("")
507
+
508
+
509
+ @doc_convert.command()
510
+ @click.argument("from_format")
511
+ @click.argument("to_format")
512
+ @click.argument("path")
513
+ @click.option("--output-dir", "-o", help="Output directory (defaults to same directory as input)")
514
+ @click.option("--pandoc-args", "-a", help="Additional pandoc arguments", default="")
515
+ @click.option("--no-fallback", is_flag=True, help="Disable fallback strategies (use only primary method)")
516
+ def convert(from_format, to_format, path, output_dir, pandoc_args, no_fallback):
517
+ """
518
+ Convert documents with automatic fallback strategies.
519
+
520
+ FROM_FORMAT: Source format (e.g., ipynb, md, docx, html)
521
+
522
+ TO_FORMAT: Target format (e.g., pdf, html, md, docx)
523
+
524
+ PATH: File path or glob pattern (e.g., "*.ipynb" or "./notebook.ipynb")
525
+
526
+ The converter will try multiple conversion methods automatically:
527
+ - For Jupyter notebooks: tries nbconvert, then pandoc with various engines
528
+ - For PDF output: tries LaTeX engine, then alternative methods
529
+ - Falls back gracefully when primary method fails
530
+
531
+ All conversions are performed in a temporary directory to avoid path issues
532
+ with spaces or special characters.
533
+
534
+ Examples:
535
+
536
+ # Convert Jupyter notebook to PDF (tries nbconvert first)
537
+ mcli workflow doc-convert convert ipynb pdf notebook.ipynb
538
+
539
+ # Convert markdown to HTML
540
+ mcli workflow doc-convert convert md html README.md
541
+
542
+ # Convert all markdown files with custom output directory
543
+ mcli workflow doc-convert convert md pdf "*.md" -o ./pdfs
544
+ """
545
+ # Check if pandoc is installed (primary tool)
546
+ has_pandoc = False
547
+ try:
548
+ subprocess.run(["pandoc", "--version"], capture_output=True, check=True)
549
+ has_pandoc = True
550
+ except (subprocess.CalledProcessError, FileNotFoundError):
551
+ pass
552
+
553
+ # Check if nbconvert is available
554
+ has_nbconvert = False
555
+ try:
556
+ subprocess.run(["jupyter", "nbconvert", "--version"], capture_output=True, check=True)
557
+ has_nbconvert = True
558
+ except (subprocess.CalledProcessError, FileNotFoundError):
559
+ pass
560
+
561
+ # Require at least one conversion tool
562
+ if not has_pandoc and not has_nbconvert:
563
+ error("❌ No conversion tools found!")
564
+ error(" Install with: mcli workflow doc-convert init")
565
+ error(" Or: brew install pandoc")
566
+ return
567
+
568
+ # Map format aliases
569
+ from_format_mapped = FORMAT_ALIASES.get(from_format.lower(), from_format)
570
+ to_format_mapped = FORMAT_ALIASES.get(to_format.lower(), to_format)
571
+ output_ext = to_format.lower()
572
+
573
+ # Expand path
574
+ expanded_path = os.path.expanduser(path)
575
+
576
+ # Handle glob patterns
577
+ if "*" in expanded_path or "?" in expanded_path or "[" in expanded_path:
578
+ files = file_glob(expanded_path, recursive=True)
579
+ if not files:
580
+ error(f"❌ No files found matching pattern: {path}")
581
+ return
582
+ info(f"📁 Found {len(files)} file(s) matching pattern: {path}")
583
+ else:
584
+ files = [expanded_path]
585
+
586
+ # Process each file
587
+ success_count = 0
588
+ error_count = 0
589
+ conversion_methods_used = {}
590
+
591
+ for input_file in files:
592
+ input_path = Path(input_file).resolve() # Get absolute path
593
+
594
+ if not input_path.exists():
595
+ warning(f"⚠️ File not found: {input_file}")
596
+ error_count += 1
597
+ continue
598
+
599
+ # Determine output path
600
+ if output_dir:
601
+ output_path = Path(output_dir) / f"{input_path.stem}.{output_ext}"
602
+ os.makedirs(output_dir, exist_ok=True)
603
+ else:
604
+ output_path = input_path.parent / f"{input_path.stem}.{output_ext}"
605
+
606
+ info(f"🔄 Converting: {input_path.name} → {output_path.name}")
607
+ info(f" 📁 Using temp directory: ~/.mcli/commands/temp/conversions/")
608
+
609
+ # Get conversion strategies
610
+ strategies = get_conversion_strategies(
611
+ input_path, output_path, from_format_mapped, to_format_mapped, pandoc_args
612
+ )
613
+
614
+ # Limit to first strategy if no-fallback is set
615
+ if no_fallback:
616
+ strategies = strategies[:1]
617
+
618
+ # Try each strategy in order
619
+ conversion_succeeded = False
620
+ last_error = ""
621
+
622
+ for i, strategy in enumerate(strategies):
623
+ if i > 0:
624
+ info(f" ⚙️ Trying fallback method: {strategy.description}")
625
+ else:
626
+ info(f" ⚙️ Using: {strategy.description}")
627
+
628
+ success_flag, error_msg = execute_conversion_strategy(
629
+ strategy, input_path, output_path,
630
+ from_format_mapped, to_format_mapped, pandoc_args
631
+ )
632
+
633
+ if success_flag:
634
+ conversion_succeeded = True
635
+ method_name = strategy.description
636
+ conversion_methods_used[method_name] = conversion_methods_used.get(method_name, 0) + 1
637
+ success(f" ✅ Created: {output_path}")
638
+ if i > 0:
639
+ info(f" ℹ️ Succeeded with fallback method #{i + 1}")
640
+ break
641
+ else:
642
+ last_error = error_msg
643
+ if i < len(strategies) - 1:
644
+ warning(f" ⚠️ {strategy.description} failed, trying next method...")
645
+
646
+ if conversion_succeeded:
647
+ success_count += 1
648
+ else:
649
+ error(f" ❌ All conversion methods failed")
650
+ if last_error:
651
+ error(f" ℹ️ Last error: {last_error[:200]}")
652
+ error_count += 1
653
+
654
+ # Summary
655
+ info("")
656
+ info("=" * 60)
657
+ success("✨ Conversion complete!")
658
+ info(f" ✅ Successful: {success_count}")
659
+ if error_count > 0:
660
+ error(f" ❌ Failed: {error_count}")
661
+
662
+ # Show which methods were used
663
+ if conversion_methods_used:
664
+ info("")
665
+ info("Methods used:")
666
+ for method, count in conversion_methods_used.items():
667
+ info(f" • {method}: {count} file(s)")
668
+
669
+ info("=" * 60)
670
+
671
+
672
+ @doc_convert.command()
673
+ def cleanup():
674
+ """
675
+ Generate a cleanup script to uninstall doc-convert dependencies.
676
+
677
+ This command creates a shell script that you can review and run to
678
+ uninstall all the dependencies installed by the init command.
679
+
680
+ The script will be created at: ~/.mcli/commands/doc-convert-cleanup.sh
681
+ """
682
+ import os
683
+
684
+ info("=" * 60)
685
+ info("📦 Generating cleanup script")
686
+ info("=" * 60)
687
+ info("")
688
+
689
+ cleanup_script = """#!/bin/bash
690
+ # doc-convert Cleanup Script
691
+ # This script uninstalls dependencies installed by 'mcli workflow doc-convert init'
692
+ #
693
+ # WARNING: Review this script before running it!
694
+ # Some of these tools may be used by other applications.
695
+
696
+ set -e
697
+
698
+ echo "================================"
699
+ echo "doc-convert Dependency Cleanup"
700
+ echo "================================"
701
+ echo ""
702
+ echo "This will uninstall the following:"
703
+ echo " • pandoc (universal document converter)"
704
+ echo " • basictex (LaTeX distribution)"
705
+ echo " • jupyter & nbconvert (Jupyter tools)"
706
+ echo " • LaTeX packages (collection-latexextra, collection-fontsrecommended)"
707
+ echo ""
708
+ read -p "Continue with uninstall? (y/N) " -n 1 -r
709
+ echo
710
+ if [[ ! $REPLY =~ ^[Yy]$ ]]; then
711
+ echo "Cancelled"
712
+ exit 0
713
+ fi
714
+
715
+ echo ""
716
+ echo "Uninstalling Homebrew packages..."
717
+
718
+ # Uninstall pandoc
719
+ if brew list pandoc &>/dev/null; then
720
+ echo " Uninstalling pandoc..."
721
+ brew uninstall pandoc
722
+ else
723
+ echo " pandoc not installed via Homebrew"
724
+ fi
725
+
726
+ # Uninstall BasicTeX
727
+ if brew list basictex &>/dev/null; then
728
+ echo " Uninstalling basictex..."
729
+ brew uninstall --cask basictex
730
+
731
+ # Remove LaTeX distribution directory
732
+ if [ -d "/usr/local/texlive/2025basic" ]; then
733
+ echo " Removing LaTeX directory..."
734
+ sudo rm -rf /usr/local/texlive/2025basic
735
+ fi
736
+ else
737
+ echo " basictex not installed via Homebrew"
738
+ fi
739
+
740
+ echo ""
741
+ echo "Uninstalling Python packages..."
742
+
743
+ # Uninstall jupyter and nbconvert from pyenv Python
744
+ PYENV_VERSION=$(pyenv version-name 2>/dev/null || echo "")
745
+ if [ -n "$PYENV_VERSION" ]; then
746
+ echo " Current pyenv version: $PYENV_VERSION"
747
+ if command -v pip &> /dev/null; then
748
+ echo " Uninstalling jupyter..."
749
+ pip uninstall -y jupyter jupyter-core jupyterlab nbconvert 2>/dev/null || true
750
+ fi
751
+ else
752
+ echo " pyenv not active or not installed"
753
+ fi
754
+
755
+ echo ""
756
+ echo "================================"
757
+ echo "Cleanup Complete!"
758
+ echo "================================"
759
+ echo ""
760
+ echo "The following may still exist:"
761
+ echo " • ~/.mcli/commands/temp/ (conversion temp directory)"
762
+ echo " • Other LaTeX installations (if installed separately)"
763
+ echo ""
764
+ echo "To remove the temp directory:"
765
+ echo " rm -rf ~/.mcli/commands/temp/"
766
+ echo ""
767
+ """
768
+
769
+ # Write cleanup script
770
+ commands_dir = get_custom_commands_dir()
771
+ cleanup_path = commands_dir / "doc-convert-cleanup.sh"
772
+
773
+ with open(cleanup_path, 'w') as f:
774
+ f.write(cleanup_script)
775
+
776
+ # Make it executable
777
+ os.chmod(cleanup_path, 0o755)
778
+
779
+ success(f"✅ Cleanup script created: {cleanup_path}")
780
+ info("")
781
+ info("To review the script:")
782
+ info(f" cat {cleanup_path}")
783
+ info("")
784
+ info("To run the cleanup:")
785
+ info(f" bash {cleanup_path}")
786
+ info("")
787
+ warning("⚠️ IMPORTANT: Review the script before running it!")
788
+ warning(" Some dependencies may be used by other applications.")
789
+ info("")
790
+ info("=" * 60)
791
+
792
+
793
+ if __name__ == "__main__":
794
+ doc_convert()