McRogueFace/src/scripts/mcrf_venv.py
John McCardle bbc744f8dc feat: Add self-contained venv support for pip packages (closes #137)
- Set sys.executable in PyConfig for subprocess/pip calls
- Detect sibling venv/ directory and prepend site-packages to sys.path
- Add mcrf_venv.py reference implementation for bootstrapping pip
- Supports both Linux (lib/python3.14/site-packages) and Windows (Lib/site-packages)

Usage: ./mcrogueface -m pip install numpy
Or via Python: mcrf_venv.pip_install("numpy")

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 22:01:09 -05:00

288 lines
7.9 KiB
Python

"""
McRogueFace Virtual Environment Bootstrap
Reference implementation for self-contained deployment with pip-installed packages.
Usage:
import mcrf_venv
# Check if venv needs setup
if not mcrf_venv.venv_exists():
mcrf_venv.create_venv()
mcrf_venv.install_requirements("requirements.txt")
# Now safe to import third-party packages
import numpy
This module provides utilities for creating and managing a venv/ directory
alongside the McRogueFace executable. The C++ runtime automatically adds
venv/lib/python3.14/site-packages (or venv/Lib/site-packages on Windows)
to sys.path if it exists.
Pip Support:
Option A (recommended): Bundle pip in your distribution
- Copy pip to lib/Python/Lib/pip/
- pip will be available via `python -m pip`
Option B: Bootstrap pip from ensurepip wheel
- Call mcrf_venv.bootstrap_pip() to extract pip from bundled wheel
- Slower first run, but no extra distribution size
"""
import os
import sys
import subprocess
import zipfile
import glob
from pathlib import Path
def get_executable_dir() -> Path:
"""Get the directory containing the McRogueFace executable."""
return Path(sys.executable).parent
def get_venv_path() -> Path:
"""Get the path to the venv directory (sibling to executable)."""
return get_executable_dir() / "venv"
def get_site_packages() -> Path:
"""Get the platform-appropriate site-packages path."""
venv = get_venv_path()
if sys.platform == "win32":
return venv / "Lib" / "site-packages"
else:
return venv / "lib" / f"python{sys.version_info.major}.{sys.version_info.minor}" / "site-packages"
def venv_exists() -> bool:
"""Check if the venv directory exists with site-packages."""
return get_site_packages().exists()
def create_venv() -> None:
"""
Create the venv directory structure.
This creates the minimal directory structure needed for pip to install
packages. It does NOT copy the Python interpreter or create activation
scripts - McRogueFace IS the interpreter.
"""
site_packages = get_site_packages()
site_packages.mkdir(parents=True, exist_ok=True)
def pip_available() -> bool:
"""Check if pip is available for import."""
try:
import pip
return True
except ImportError:
return False
def get_ensurepip_wheel() -> Path | None:
"""
Find the pip wheel bundled with ensurepip.
Returns:
Path to pip wheel, or None if not found
"""
exe_dir = get_executable_dir()
bundled_dir = exe_dir / "lib" / "Python" / "Lib" / "ensurepip" / "_bundled"
if not bundled_dir.exists():
return None
# Find pip wheel (pattern: pip-*.whl)
wheels = list(bundled_dir.glob("pip-*.whl"))
if wheels:
return wheels[0]
return None
def bootstrap_pip() -> bool:
"""
Bootstrap pip by extracting from ensurepip's bundled wheel.
This extracts pip to the venv's site-packages directory, making it
available for subsequent pip_install() calls.
Returns:
True if pip was successfully bootstrapped, False otherwise
Note:
This modifies sys.path to include the newly extracted pip.
After calling this, `import pip` will work.
"""
wheel_path = get_ensurepip_wheel()
if not wheel_path:
return False
site_packages = get_site_packages()
if not site_packages.exists():
create_venv()
# Extract pip from the wheel
with zipfile.ZipFile(wheel_path, 'r') as whl:
whl.extractall(site_packages)
# Add to sys.path so pip is immediately importable
site_str = str(site_packages)
if site_str not in sys.path:
sys.path.insert(0, site_str)
return pip_available()
def pip_install(*packages: str, requirements: str = None, quiet: bool = True) -> int:
"""
Install packages using pip.
If pip is not available, attempts to bootstrap it from ensurepip.
Args:
*packages: Package names to install (e.g., "numpy", "pygame-ce")
requirements: Path to requirements.txt file
quiet: Suppress pip output (default True)
Returns:
pip exit code (0 = success)
Raises:
RuntimeError: If pip cannot be bootstrapped
Example:
pip_install("numpy", "requests")
pip_install(requirements="requirements.txt")
"""
# Ensure venv exists
if not venv_exists():
create_venv()
# Bootstrap pip if not available
if not pip_available():
if not bootstrap_pip():
raise RuntimeError(
"pip is not available and could not be bootstrapped. "
"Ensure ensurepip/_bundled/pip-*.whl exists in your distribution."
)
site_packages = get_site_packages()
# Build pip command
cmd = [sys.executable, "-m", "pip", "install", "--target", str(site_packages)]
if quiet:
cmd.append("--quiet")
if requirements:
req_path = Path(requirements)
if not req_path.is_absolute():
req_path = get_executable_dir() / req_path
if req_path.exists():
cmd.extend(["-r", str(req_path)])
else:
# No requirements file, nothing to install
return 0
cmd.extend(packages)
# Only run if we have something to install
if not packages and not requirements:
return 0
result = subprocess.run(cmd, check=False)
return result.returncode
def install_requirements(requirements_path: str = "requirements.txt") -> int:
"""
Install packages from a requirements.txt file.
Args:
requirements_path: Path to requirements file (relative to executable or absolute)
Returns:
pip exit code (0 = success), or 0 if file doesn't exist
"""
return pip_install(requirements=requirements_path)
def ensure_environment(requirements: str = None) -> bool:
"""
Ensure venv exists and optionally install requirements.
This is the high-level convenience function for simple use cases.
Call at the start of your game.py before importing dependencies.
Args:
requirements: Optional path to requirements.txt
Returns:
True if venv was created (first run), False if it already existed
Example:
import mcrf_venv
created = mcrf_venv.ensure_environment("requirements.txt")
if created:
print("Environment initialized!")
# Now safe to import
import numpy
"""
created = False
if not venv_exists():
create_venv()
created = True
if requirements:
install_requirements(requirements)
return created
def list_installed() -> list:
"""
List installed packages in the venv.
Returns:
List of (name, version) tuples
"""
site_packages = get_site_packages()
if not site_packages.exists():
return []
packages = []
for item in site_packages.iterdir():
if item.suffix == ".dist-info":
# Parse package name and version from dist-info directory name
# Format: package_name-version.dist-info
name_version = item.stem
if "-" in name_version:
parts = name_version.rsplit("-", 1)
packages.append((parts[0].replace("_", "-"), parts[1]))
return sorted(packages)
# Convenience: if run directly, show status
if __name__ == "__main__":
print(f"McRogueFace venv utility")
print(f" Executable: {sys.executable}")
print(f" Venv path: {get_venv_path()}")
print(f" Site-packages: {get_site_packages()}")
print(f" Venv exists: {venv_exists()}")
if venv_exists():
installed = list_installed()
if installed:
print(f"\nInstalled packages:")
for name, version in installed:
print(f" {name} ({version})")
else:
print(f"\nNo packages installed in venv.")
else:
print(f"\nVenv not created. Run create_venv() to initialize.")