Spaces:
Runtime error
Runtime error
| """ | |
| cv_converter.py | |
| =============== | |
| Converts CV / rΓ©sumΓ© files (PDF, DOCX, DOC) to clean Markdown. | |
| Pipeline | |
| -------- | |
| PDF β pdfplumber (scan detection) β Marker (with or without OCR) | |
| DOCX β pypandoc β GitHub-Flavoured Markdown | |
| DOC β LibreOffice (β DOCX) β pypandoc β GFM | |
| Why Markdown as the intermediate format? | |
| β’ LLMs understand it natively β better job-match prompts | |
| β’ Sentence-transformers get cleaner text β better embeddings | |
| β’ Renders directly in the browser with zero extra work | |
| Install | |
| ------- | |
| pip install marker-pdf pdfplumber pypandoc | |
| python -c "import pypandoc; pypandoc.download_pandoc()" | |
| # For legacy .doc support: | |
| # Ubuntu/Debian : sudo apt-get install libreoffice | |
| # macOS : brew install --cask libreoffice | |
| Usage | |
| ----- | |
| from cv_converter import CVConverter | |
| converter = CVConverter() | |
| result = converter.convert("john_doe_cv.pdf") | |
| if result: # bool(result) == result.success | |
| print(result.markdown) | |
| converter.save(result, "john_doe_cv.md") | |
| else: | |
| print("Failed:", result.error) | |
| for w in result.warnings: | |
| print("Warning:", w) | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import re | |
| import subprocess | |
| import tempfile | |
| from dataclasses import dataclass, field | |
| from enum import Enum | |
| from pathlib import Path | |
| from typing import Optional | |
| logger = logging.getLogger(__name__) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Value types | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class ConversionMethod(str, Enum): | |
| MARKER = "marker" # Marker β text-based PDF | |
| MARKER_OCR = "marker_ocr" # Marker β forced OCR (scanned PDF) | |
| PANDOC = "pandoc" # Pandoc β DOCX | |
| PANDOC_VIA_LO = "pandoc_via_lo" # LibreOffice β Pandoc β legacy DOC | |
| class FileType(str, Enum): | |
| PDF = "pdf" | |
| DOCX = "docx" | |
| DOC = "doc" | |
| UNKNOWN = "unknown" | |
| class ConversionResult: | |
| """ | |
| Returned by :py:meth:`CVConverter.convert`. | |
| Always check ``.success`` (or ``bool(result)``) before reading | |
| ``.markdown``. | |
| Attributes | |
| ---------- | |
| success : bool β True when conversion produced output. | |
| markdown : str | None β The Markdown text (None on failure). | |
| method_used : str | None β Which pipeline was used. | |
| file_type : str | None β Detected file type ("pdf", "docx", β¦). | |
| is_scanned : bool β True when OCR was required. | |
| page_count : int β Page count (0 when unknown). | |
| warnings : list[str] β Non-fatal notes (mixed PDF, OCR qualityβ¦). | |
| error : str | None β Human-readable error message on failure. | |
| """ | |
| success: bool | |
| markdown: Optional[str] = None | |
| method_used: Optional[str] = None | |
| file_type: Optional[str] = None | |
| is_scanned: bool = False | |
| page_count: int = 0 | |
| warnings: list[str] = field(default_factory=list) | |
| error: Optional[str] = None | |
| def __bool__(self) -> bool: | |
| return self.success | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Main class | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class CVConverter: | |
| """ | |
| Converts CV files (PDF / DOCX / DOC) to clean Markdown. | |
| Marker ML models are loaded lazily on the first PDF call and then | |
| cached, so all subsequent conversions in the same process reuse them. | |
| Parameters | |
| ---------- | |
| temp_dir : str | None | |
| Where to place intermediate files (e.g. the .docx produced when | |
| converting a legacy .doc). Defaults to the OS temp directory. | |
| marker_device : str | |
| ``"cpu"`` or ``"cuda"``. Passed to Marker when loading models. | |
| Examples | |
| -------- | |
| >>> converter = CVConverter() | |
| >>> result = converter.convert("resume.pdf") | |
| >>> if result: | |
| ... converter.save(result, "resume.md") | |
| """ | |
| # ββ tuneable thresholds βββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Characters needed on a page before we call it "text bearing" | |
| MIN_TEXT_CHARS_PER_PAGE: int = 50 | |
| # Fraction of pages that must carry text to skip OCR | |
| SCANNED_PAGE_RATIO: float = 0.30 | |
| # Hard timeout for the LibreOffice subprocess (seconds) | |
| LIBREOFFICE_TIMEOUT: int = 60 | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def __init__( | |
| self, | |
| temp_dir: Optional[str] = None, | |
| marker_device: str = "cpu", | |
| ) -> None: | |
| self.temp_dir = Path(temp_dir or tempfile.gettempdir()) | |
| self.marker_device = marker_device | |
| self._marker_models = None # populated on first use | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Public API | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def convert(self, file_path: str | Path) -> ConversionResult: | |
| """ | |
| Convert a CV file to Markdown. | |
| Automatically detects the file type and delegates to the correct | |
| sub-pipeline. Never raises; all errors are captured in the | |
| returned :class:`ConversionResult`. | |
| Parameters | |
| ---------- | |
| file_path : str | Path | |
| Path to the CV file (.pdf, .docx, or .doc). | |
| Returns | |
| ------- | |
| ConversionResult | |
| """ | |
| path = Path(file_path) | |
| # ββ pre-flight checks βββββββββββββββββββββββββββββββββββββββββββββ | |
| if not path.exists(): | |
| return ConversionResult( | |
| success=False, error=f"File not found: {path}" | |
| ) | |
| if not path.is_file(): | |
| return ConversionResult( | |
| success=False, error=f"Path is not a regular file: {path}" | |
| ) | |
| if path.stat().st_size == 0: | |
| return ConversionResult( | |
| success=False, error=f"File is empty: {path.name}" | |
| ) | |
| file_type = self._detect_file_type(path) | |
| if file_type == FileType.PDF: | |
| return self._convert_pdf(path) | |
| if file_type in (FileType.DOCX, FileType.DOC): | |
| return self._convert_word(path, file_type) | |
| return ConversionResult( | |
| success=False, | |
| error=( | |
| f"Unsupported file type '{path.suffix}'. " | |
| "Accepted: .pdf, .docx, .doc" | |
| ), | |
| ) | |
| def save( | |
| self, | |
| result: ConversionResult, | |
| output_path: str | Path, | |
| ) -> None: | |
| """ | |
| Write ``result.markdown`` to *output_path* (UTF-8). | |
| Raises | |
| ------ | |
| ValueError | |
| When ``result.success`` is False. | |
| """ | |
| if not result.success: | |
| raise ValueError("Cannot save a failed ConversionResult.") | |
| out = Path(output_path) | |
| out.parent.mkdir(parents=True, exist_ok=True) | |
| out.write_text(result.markdown, encoding="utf-8") | |
| logger.info("Markdown written β %s", out) | |
| def convert_and_save( | |
| self, | |
| file_path: str | Path, | |
| output_path: str | Path, | |
| ) -> ConversionResult: | |
| """Convenience wrapper: convert then save if successful.""" | |
| result = self.convert(file_path) | |
| if result.success: | |
| self.save(result, output_path) | |
| return result | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # File-type detection | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _detect_file_type(path: Path) -> FileType: | |
| return { | |
| ".pdf": FileType.PDF, | |
| ".docx": FileType.DOCX, | |
| ".doc": FileType.DOC, | |
| }.get(path.suffix.lower(), FileType.UNKNOWN) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # PDF pipeline | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _check_pdf_text_layer( | |
| self, path: Path | |
| ) -> tuple[bool, int, list[bool]]: | |
| """ | |
| Use pdfplumber to probe each page for an embedded text layer. | |
| Returns | |
| ------- | |
| (has_text_layer, page_count, per_page_flags) | |
| * has_text_layer β True when enough pages carry real text. | |
| * page_count β Total pages. | |
| * per_page_flags β Per-page bool list (True = text found). | |
| Raises | |
| ------ | |
| ValueError | |
| For password-protected or zero-page PDFs. | |
| """ | |
| try: | |
| import pdfplumber | |
| except ImportError: | |
| logger.warning( | |
| "pdfplumber not installed β OCR detection skipped. " | |
| "Run: pip install pdfplumber" | |
| ) | |
| return True, 0, [] | |
| try: | |
| with pdfplumber.open(str(path)) as pdf: | |
| page_count = len(pdf.pages) | |
| if page_count == 0: | |
| raise ValueError( | |
| f"'{path.name}' has zero pages and cannot be converted." | |
| ) | |
| per_page: list[bool] = [] | |
| for page in pdf.pages: | |
| raw_text = page.extract_text() or "" | |
| per_page.append( | |
| len(raw_text.strip()) >= self.MIN_TEXT_CHARS_PER_PAGE | |
| ) | |
| pages_with_text = sum(per_page) | |
| ratio = pages_with_text / page_count | |
| has_text_layer = ratio >= self.SCANNED_PAGE_RATIO | |
| logger.debug( | |
| "Text-layer check: %d/%d pages have text (%.0f%%)", | |
| pages_with_text, page_count, ratio * 100, | |
| ) | |
| return has_text_layer, page_count, per_page | |
| except Exception as exc: | |
| msg = str(exc).lower() | |
| if any(k in msg for k in ("password", "encrypted", "decrypt")): | |
| raise ValueError( | |
| f"'{path.name}' is password-protected. " | |
| "Please provide an unlocked copy." | |
| ) from exc | |
| # Unknown pdfplumber error β assume text-based; Marker will cope. | |
| logger.warning( | |
| "pdfplumber probe failed (%s) β assuming text-based PDF.", exc | |
| ) | |
| return True, 0, [] | |
| def _load_marker_models(self): | |
| """Lazy-load and cache Marker model dict (once per process). | |
| PyTorch lazy / meta-device initialization can cause: | |
| 'Cannot copy out of meta tensor; no data!' | |
| when Marker calls model.to(device) on a model that was built on the | |
| meta device. We patch torch.nn.Module.to to fall back to to_empty() | |
| in that case, then restore the original after loading completes. | |
| """ | |
| if self._marker_models is None: | |
| logger.info( | |
| "Loading Marker models for the first time " | |
| "(this may take ~10β30 s)β¦" | |
| ) | |
| try: | |
| import torch | |
| _original_to = torch.nn.Module.to | |
| def _safe_to(module, *args, **kwargs): | |
| try: | |
| return _original_to(module, *args, **kwargs) | |
| except RuntimeError as exc: | |
| if "Cannot copy out of meta tensor" in str(exc): | |
| device = args[0] if args else kwargs.get("device", "cpu") | |
| logger.debug( | |
| "Meta-tensor detected β using to_empty(device=%s)", device | |
| ) | |
| return module.to_empty(device=device) | |
| raise | |
| torch.nn.Module.to = _safe_to | |
| try: | |
| from marker.models import create_model_dict | |
| self._marker_models = create_model_dict() | |
| logger.info("Marker models loaded and cached.") | |
| finally: | |
| # Always restore the original .to even if loading fails | |
| torch.nn.Module.to = _original_to | |
| except ImportError as exc: | |
| raise ImportError( | |
| "Marker is not installed. Fix: pip install marker-pdf" | |
| ) from exc | |
| return self._marker_models | |
| def _run_marker(self, path: Path, force_ocr: bool = False) -> str: | |
| """ | |
| Execute Marker and return the raw Markdown string. | |
| Parameters | |
| ---------- | |
| force_ocr : bool | |
| When True, Marker is told to OCR every page regardless of | |
| whether it detects an embedded text layer. | |
| Raises | |
| ------ | |
| RuntimeError | |
| When Marker raises or returns empty output. | |
| """ | |
| model_dict = self._load_marker_models() | |
| try: | |
| from marker.converters.pdf import PdfConverter # v1.x API | |
| from marker.output import text_from_rendered # v1.x API | |
| config = {"force_ocr": force_ocr} if force_ocr else {} | |
| converter = PdfConverter(model_dict, config=config) | |
| rendered = converter(str(path)) | |
| full_text, _images, _meta = text_from_rendered(rendered) | |
| except Exception as exc: | |
| raise RuntimeError( | |
| f"Marker raised an exception on '{path.name}': {exc}" | |
| ) from exc | |
| if not full_text or not full_text.strip(): | |
| raise RuntimeError( | |
| f"Marker produced empty output for '{path.name}'. " | |
| "The file may be image-only or severely corrupted." | |
| ) | |
| return full_text | |
| def _convert_pdf(self, path: Path) -> ConversionResult: | |
| """ | |
| Full PDF β Markdown pipeline. | |
| Decision tree | |
| ------------- | |
| 1. pdfplumber inspects every page for a text layer. | |
| 2a. Fully scanned (< SCANNED_PAGE_RATIO text pages) | |
| β Marker + OCR. | |
| 2b. Mixed pages (some pages lack text) | |
| β Marker + OCR for consistency across all pages. | |
| 2c. Fully digital β Marker without OCR. | |
| 3. If step 2c fails, automatically retry with OCR as a fallback | |
| (handles PDFs that report a text layer but are actually images). | |
| """ | |
| warnings: list[str] = [] | |
| # ββ 1. detect text layer βββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| has_text, page_count, per_page = self._check_pdf_text_layer(path) | |
| except ValueError as exc: | |
| return ConversionResult(success=False, error=str(exc)) | |
| # ββ 2. decide OCR strategy βββββββββββββββββββββββββββββββββββββββ | |
| scanned_page_nums: list[int] = [ | |
| i + 1 for i, flag in enumerate(per_page) if not flag | |
| ] | |
| is_mixed = bool(scanned_page_nums) and has_text | |
| is_scanned = not has_text | |
| if is_scanned: | |
| force_ocr = True | |
| method = ConversionMethod.MARKER_OCR | |
| warnings.append( | |
| "Document appears to be fully scanned. OCR was applied β " | |
| "accuracy depends on scan quality." | |
| ) | |
| elif is_mixed: | |
| force_ocr = True | |
| is_scanned = True | |
| method = ConversionMethod.MARKER_OCR | |
| warnings.append( | |
| f"Mixed PDF: pages {scanned_page_nums} appear scanned. " | |
| "OCR applied to the entire document for consistency." | |
| ) | |
| else: | |
| force_ocr = False | |
| method = ConversionMethod.MARKER | |
| # ββ 3. convert βββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| markdown = self._run_marker(path, force_ocr=force_ocr) | |
| except RuntimeError as exc: | |
| if not force_ocr: | |
| # Rare edge case: PDF claims a text layer but extraction | |
| # fails (e.g. custom fonts, badly embedded text). | |
| logger.warning( | |
| "Marker (no OCR) failed on '%s': %s β retrying with OCRβ¦", | |
| path.name, exc, | |
| ) | |
| try: | |
| markdown = self._run_marker(path, force_ocr=True) | |
| method = ConversionMethod.MARKER_OCR | |
| is_scanned = True | |
| warnings.append( | |
| "Standard text extraction failed; OCR fallback was used." | |
| ) | |
| except RuntimeError as fallback_exc: | |
| return ConversionResult( | |
| success=False, | |
| error=( | |
| f"All extraction methods failed for '{path.name}'. " | |
| f"Last error: {fallback_exc}" | |
| ), | |
| ) | |
| else: | |
| return ConversionResult(success=False, error=str(exc)) | |
| return ConversionResult( | |
| success = True, | |
| markdown = self._clean_markdown(markdown), | |
| method_used = method.value, | |
| file_type = FileType.PDF.value, | |
| is_scanned = is_scanned, | |
| page_count = page_count, | |
| warnings = warnings, | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Word pipeline | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _convert_word( | |
| self, path: Path, file_type: FileType | |
| ) -> ConversionResult: | |
| """ | |
| Convert DOCX (or legacy DOC) to GitHub-Flavoured Markdown via Pandoc. | |
| For ``.doc`` files LibreOffice is used first to produce a ``.docx`` | |
| intermediate in ``self.temp_dir``, which Pandoc then converts. | |
| Pandoc flags used | |
| ----------------- | |
| ``--wrap=none`` No hard line-wrapping. | |
| ``--strip-comments`` Drop Word tracked-change comments. | |
| ``--markdown-headings=atx`` Use ``#`` style, not underline style. | |
| """ | |
| try: | |
| import pypandoc # noqa: F401 β just check it is installed | |
| except ImportError: | |
| return ConversionResult( | |
| success=False, | |
| error=( | |
| "pypandoc is not installed.\n" | |
| " pip install pypandoc\n" | |
| " python -c \"import pypandoc; pypandoc.download_pandoc()\"" | |
| ), | |
| ) | |
| warnings: list[str] = [] | |
| method = ConversionMethod.PANDOC | |
| # ββ .doc β .docx βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if file_type == FileType.DOC: | |
| try: | |
| path, warnings = self._doc_to_docx(path, warnings) | |
| method = ConversionMethod.PANDOC_VIA_LO | |
| except RuntimeError as exc: | |
| return ConversionResult(success=False, error=str(exc)) | |
| # ββ DOCX β GFM Markdown ββββββββββββββββββββββββββββββββββββββββββ | |
| try: | |
| import pypandoc | |
| markdown = pypandoc.convert_file( | |
| str(path), | |
| to="gfm", | |
| extra_args=[ | |
| "--wrap=none", | |
| "--strip-comments", | |
| "--markdown-headings=atx", | |
| ], | |
| ) | |
| except Exception as exc: | |
| return ConversionResult( | |
| success=False, | |
| error=f"Pandoc conversion failed: {exc}", | |
| ) | |
| if not markdown or not markdown.strip(): | |
| return ConversionResult( | |
| success=False, | |
| error=( | |
| f"Pandoc returned empty output for '{path.name}'. " | |
| "The document may be blank." | |
| ), | |
| ) | |
| return ConversionResult( | |
| success = True, | |
| markdown = self._clean_markdown(markdown), | |
| method_used = method.value, | |
| file_type = file_type.value, | |
| is_scanned = False, | |
| warnings = warnings, | |
| ) | |
| def _doc_to_docx( | |
| self, path: Path, warnings: list[str] | |
| ) -> tuple[Path, list[str]]: | |
| """ | |
| Convert a legacy ``.doc`` file to ``.docx`` via LibreOffice (headless). | |
| Returns | |
| ------- | |
| (docx_path, updated_warnings) | |
| Raises | |
| ------ | |
| RuntimeError | |
| If LibreOffice is missing, exits non-zero, times out, or | |
| produces no output file. | |
| """ | |
| docx_path = self.temp_dir / (path.stem + ".docx") | |
| try: | |
| proc = subprocess.run( | |
| [ | |
| "libreoffice", | |
| "--headless", | |
| "--convert-to", "docx", | |
| "--outdir", str(self.temp_dir), | |
| str(path), | |
| ], | |
| capture_output=True, | |
| text=True, | |
| timeout=self.LIBREOFFICE_TIMEOUT, | |
| ) | |
| except FileNotFoundError: | |
| raise RuntimeError( | |
| "LibreOffice is required to convert legacy .doc files but " | |
| "was not found on PATH.\n" | |
| " Ubuntu/Debian : sudo apt-get install libreoffice\n" | |
| " macOS : brew install --cask libreoffice" | |
| ) | |
| except subprocess.TimeoutExpired: | |
| raise RuntimeError( | |
| f"LibreOffice timed out after {self.LIBREOFFICE_TIMEOUT} s " | |
| f"converting '{path.name}'." | |
| ) | |
| if proc.returncode != 0: | |
| raise RuntimeError( | |
| f"LibreOffice exited with code {proc.returncode}:\n" | |
| f"{proc.stderr.strip()}" | |
| ) | |
| if not docx_path.exists(): | |
| raise RuntimeError( | |
| f"LibreOffice ran successfully but no .docx was produced. " | |
| f"Expected: {docx_path}" | |
| ) | |
| warnings.append( | |
| "Legacy .doc file was automatically converted to .docx via " | |
| "LibreOffice before Markdown extraction." | |
| ) | |
| return docx_path, warnings | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # Post-processing helpers | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _clean_markdown(text: str) -> str: | |
| """ | |
| Remove converter artefacts and normalise whitespace. | |
| Handles | |
| ------- | |
| * Windows / mixed line endings (CRLF β LF) | |
| * Null bytes and Word private-use bullet characters | |
| * Trailing whitespace per line | |
| * Runs of 3+ blank lines β single blank line | |
| * Marker page-break separators (standalone ``---`` / ``===`` lines) | |
| * Pandoc footnote-like wrapping artefacts | |
| """ | |
| # 1. Normalise line endings | |
| text = text.replace("\r\n", "\n").replace("\r", "\n") | |
| # 2. Remove null bytes and common Word private-use characters | |
| replacements = { | |
| "\x00": "", # null byte | |
| "\uf0b7": "-", # Word solid bullet β’ | |
| "\uf0a7": "-", # Word hollow bullet β¦ | |
| "\uf020": " ", # Word private space | |
| "\uf0fc": "-", # Word checkmark bullet | |
| } | |
| for bad, good in replacements.items(): | |
| text = text.replace(bad, good) | |
| # 3. Strip trailing whitespace on every line | |
| text = "\n".join(line.rstrip() for line in text.split("\n")) | |
| # 4. Remove Marker's standalone page-break / rule lines | |
| text = re.sub(r"(?m)^[-=]{3,}\s*$", "", text) | |
| # 5. Collapse 3+ consecutive blank lines to one blank line | |
| text = re.sub(r"\n{3,}", "\n\n", text) | |
| return text.strip() | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| # CLI convenience (python cv_converter.py <input> [output]) | |
| # βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _cli() -> None: | |
| import sys | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(levelname)s %(message)s", | |
| ) | |
| args = sys.argv[1:] | |
| if not args: | |
| print( | |
| "Usage: python cv_converter.py <input.(pdf|docx|doc)> [output.md]" | |
| ) | |
| sys.exit(1) | |
| input_path = Path(args[0]) | |
| output_path = Path(args[1]) if len(args) > 1 else input_path.with_suffix(".md") | |
| converter = CVConverter() | |
| print(f"Converting '{input_path}' β¦") | |
| result = converter.convert(input_path) | |
| if result.warnings: | |
| for w in result.warnings: | |
| print(f" β {w}") | |
| if not result: | |
| print(f"β Conversion failed: {result.error}") | |
| sys.exit(1) | |
| converter.save(result, output_path) | |
| print( | |
| f"β Done [{result.method_used}] " | |
| f"{'(scanned) ' if result.is_scanned else ''}" | |
| f"β {output_path}" | |
| ) | |
| if result.page_count: | |
| print(f" Pages : {result.page_count}") | |
| print(f" Size : {len(result.markdown):,} characters") | |
| if __name__ == "__main__": | |
| _cli() |