"""Convert standalone `pip install` code blocks to CodeGroup with both pip and uv. Searches for standalone `pip install` commands in MDX files and converts them to the CodeGroup format that shows both pip and uv installation options side-by-side. Usage: # Convert all MDX files in current directory (recursively) python3 convert_pip_to_codegroup.py # using uv uv run convert_pip_to_codegroup.py # Preview changes without modifying files python3 convert_pip_to_codegroup.py --dry-run # Convert files in a specific directory python3 convert_pip_to_codegroup.py /path/to/docs # Convert a single file python3 convert_pip_to_codegroup.py --file path/to/file.mdx Example conversion: Before: ```bash pip install langchain ``` After: ```bash pip pip install langchain ``` ```bash uv uv add langchain ``` """ import argparse import re from pathlib import Path from re import Match def pip_to_uv(pip_cmd: str) -> str: """Convert a `pip install` command to equivalent `uv add` command.""" # Remove 'pip install' and clean up cmd = pip_cmd.replace("pip install", "").strip() # Handle pip flags - need to handle combined flags like -qU carefully # Remove upgrade flags (uv add upgrades by default) cmd = re.sub(r"-U\b", "", cmd) cmd = re.sub(r"--upgrade\b", "", cmd) # Remove quiet flags cmd = re.sub(r"-q\b", "", cmd) cmd = re.sub(r"--quiet\b", "", cmd) # Remove editable flags (uv add handles local paths as editable by default) cmd = re.sub(r"-e\b", "", cmd) cmd = re.sub(r"--editable\b", "", cmd) # Remove requirements file flags (different handling in uv) cmd = re.sub(r"-r\b", "", cmd) cmd = re.sub(r"--requirement\b", "", cmd) # Handle pre-release flag cmd = re.sub(r"--pre\b", "@pre", cmd) # Clean up extra spaces first cmd = " ".join(cmd.split()) # Handle quotes around package names with version specifiers # If package has version specifiers (>=, <=, ==, !=, ~=, >, <), keep quotes packages = [] for part in cmd.split(): if any(op in part for op in [">=", "<=", "==", "!=", "~=", ">", "<"]): # Add quotes if not already quoted and contains version specifiers if not (part.startswith('"') and part.endswith('"')) and not ( part.startswith("'") and part.endswith("'") ): packages.append(f'"{part}"') else: packages.append(part) else: # Remove quotes from packages without version specifiers packages.append(part.replace('"', "").replace("'", "")) cmd = " ".join(packages) # Handle special cases if "[" in cmd and "]" in cmd: # Handle extras like "package[extra]" -> "package[extra]" pass # uv handles extras the same way if "@pre" in cmd: # Move @pre to end of package name parts = cmd.split() if len(parts) == 1: return f"uv add {parts[0]}" # Multiple packages with @pre - apply to all packages = [p + "@pre" if "@pre" not in p else p for p in parts if p != "@pre"] return f"uv add {' '.join(packages)}" return f"uv add {cmd}" if cmd else "uv add" def convert_pip_block_to_codegroup(content: str) -> str: """Convert standalone pip install code blocks to CodeGroup format.""" # Match only bash blocks where pip install appears within the first few lines # This prevents matching across unrelated code blocks pip_pattern = re.compile( r"```(?:bash|shell|sh)?\s*\n((?:[^\n]*\n){0,3}[^\n]*pip install[^\n]*(?:\n(?!```)[^\n]*)*)\n```", re.MULTILINE, ) def replace_pip_block(match: Match[str]) -> str: # Check if we're already inside a CodeGroup by examining the full context start_pos = match.start() # Look backwards for the nearest or text_before = content[:start_pos] last_codegroup_start = text_before.rfind("") last_codegroup_end = text_before.rfind("") # If the most recent CodeGroup tag before this match is an opening tag, # then we're inside a CodeGroup if last_codegroup_start > last_codegroup_end: return match.group(0) # Already in CodeGroup block_content = match.group(1).strip() lines = block_content.split("\n") # Check if this is a simple pip install command (not part of a larger script) pip_lines = [] other_lines = [] for current_line in lines: line = current_line.strip() if line.startswith("pip install") and not line.startswith("#"): pip_lines.append(line) elif line and not line.startswith("#"): other_lines.append(line) # Only convert if we have pip install commands and minimal other content if pip_lines and len(other_lines) <= 2: # Create the CodeGroup replacement pip_block_lines = [] uv_block_lines = [] for line in lines: if line.strip().startswith("pip install"): pip_block_lines.append(line) uv_line = pip_to_uv(line.strip()) uv_block_lines.append(uv_line) elif line.strip() and not line.strip().startswith("#"): # Keep non-pip commands in both blocks pip_block_lines.append(line) uv_block_lines.append(line) elif line.strip().startswith("#"): # Keep comments in both blocks pip_block_lines.append(line) uv_block_lines.append(line) pip_content = "\n".join(pip_block_lines) uv_content = "\n".join(uv_block_lines) return f""" ```bash pip {pip_content} ``` ```bash uv {uv_content} ``` """ # Return original if it's a complex script return match.group(0) return pip_pattern.sub(replace_pip_block, content) def convert_file(file_path: str, *, dry_run: bool = False) -> bool: """Convert a single MDX file. Returns True if changes were made.""" try: file_obj = Path(file_path) original_content = file_obj.read_text(encoding="utf-8") converted_content = convert_pip_block_to_codegroup(original_content) if original_content != converted_content: if not dry_run: file_obj.write_text(converted_content, encoding="utf-8") return True except Exception: # noqa: BLE001, S110 pass return False def main() -> None: """Convert pip install commands to CodeGroup format in MDX files.""" parser = argparse.ArgumentParser( description=( "Convert pip install code blocks to CodeGroup format with uv alternatives" ) ) parser.add_argument( "path", nargs="?", default=".", help="Path to search for MDX files (default: current directory)", ) parser.add_argument( "--dry-run", action="store_true", help="Preview changes without modifying files" ) parser.add_argument( "--file", help="Convert a specific file instead of searching directory" ) args = parser.parse_args() if args.file: files = [args.file] else: # Find all MDX files search_path = Path(args.path) files = [str(f) for f in search_path.rglob("*.mdx")] if not files: return if args.dry_run: pass converted_count = 0 for file_path in files: if convert_file(file_path, dry_run=args.dry_run): converted_count += 1 if __name__ == "__main__": main()