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>
This commit is contained in:
John McCardle 2025-11-26 22:01:09 -05:00
parent 3f6ea4fe33
commit bbc744f8dc
2 changed files with 344 additions and 11 deletions

View File

@ -395,25 +395,49 @@ PyStatus init_python(const char *program_name)
PyConfig_SetString(&config, &config.stdio_errors, L"surrogateescape");
config.configure_c_stdio = 1;
PyConfig_SetBytesString(&config, &config.home,
// Set sys.executable to the McRogueFace binary path
auto exe_filename = executable_filename();
PyConfig_SetString(&config, &config.executable, exe_filename.c_str());
PyConfig_SetBytesString(&config, &config.home,
narrow_string(executable_path() + L"/lib/Python").c_str());
status = PyConfig_SetBytesString(&config, &config.program_name,
program_name);
// Check for sibling venv/ directory (self-contained deployment)
auto exe_dir = std::filesystem::path(executable_path());
auto sibling_venv = exe_dir / "venv";
if (std::filesystem::exists(sibling_venv)) {
// Platform-specific site-packages path
#ifdef _WIN32
auto site_packages = sibling_venv / "Lib" / "site-packages";
#else
auto site_packages = sibling_venv / "lib" / "python3.14" / "site-packages";
#endif
if (std::filesystem::exists(site_packages)) {
// Prepend so venv packages take priority over bundled
PyWideStringList_Insert(&config.module_search_paths, 0,
site_packages.wstring().c_str());
config.module_search_paths_set = 1;
}
}
// under Windows, the search paths are correct; under Linux, they need manual insertion
#if __PLATFORM_SET_PYTHON_SEARCH_PATHS == 1
config.module_search_paths_set = 1;
// search paths for python libs/modules/scripts
if (!config.module_search_paths_set) {
config.module_search_paths_set = 1;
}
// search paths for python libs/modules/scripts
const wchar_t* str_arr[] = {
L"/scripts",
L"/lib/Python/lib.linux-x86_64-3.14",
L"/lib/Python",
L"/lib/Python/Lib",
L"/venv/lib/python3.14/site-packages"
L"/lib/Python",
L"/lib/Python/Lib"
// Note: venv site-packages handled above via sibling_venv detection
};
for(auto s : str_arr) {
status = PyWideStringList_Append(&config.module_search_paths, (executable_path() + s).c_str());
@ -446,6 +470,10 @@ PyStatus McRFPy_API::init_python_with_config(const McRogueFaceConfig& config)
PyConfig_SetString(&pyconfig, &pyconfig.stdio_errors, L"surrogateescape");
pyconfig.configure_c_stdio = 1;
// Set sys.executable to the McRogueFace binary path
auto exe_path = executable_filename();
PyConfig_SetString(&pyconfig, &pyconfig.executable, exe_path.c_str());
// Set interactive mode (replaces deprecated Py_InspectFlag)
if (config.interactive_mode) {
pyconfig.inspect = 1;
@ -497,7 +525,7 @@ PyStatus McRFPy_API::init_python_with_config(const McRogueFaceConfig& config)
return status;
}
// Check if we're in a virtual environment
// Check if we're in a virtual environment (symlinked into a venv)
auto exe_wpath = executable_filename();
auto exe_path_fs = std::filesystem::path(exe_wpath);
auto exe_dir = exe_path_fs.parent_path();
@ -512,6 +540,23 @@ PyStatus McRFPy_API::init_python_with_config(const McRogueFaceConfig& config)
pyconfig.module_search_paths_set = 1;
}
// Check for sibling venv/ directory (self-contained deployment)
auto sibling_venv = exe_dir / "venv";
if (std::filesystem::exists(sibling_venv)) {
// Platform-specific site-packages path
#ifdef _WIN32
auto site_packages = sibling_venv / "Lib" / "site-packages";
#else
auto site_packages = sibling_venv / "lib" / "python3.14" / "site-packages";
#endif
if (std::filesystem::exists(site_packages)) {
// Prepend so venv packages take priority over bundled
PyWideStringList_Insert(&pyconfig.module_search_paths, 0,
site_packages.wstring().c_str());
pyconfig.module_search_paths_set = 1;
}
}
// Set Python home to our bundled Python
auto python_home = executable_path() + L"/lib/Python";
PyConfig_SetString(&pyconfig, &pyconfig.home, python_home.c_str());
@ -527,8 +572,8 @@ PyStatus McRFPy_API::init_python_with_config(const McRogueFaceConfig& config)
L"/scripts",
L"/lib/Python/lib.linux-x86_64-3.14",
L"/lib/Python",
L"/lib/Python/Lib",
L"/venv/lib/python3.14/site-packages"
L"/lib/Python/Lib"
// Note: venv site-packages handled above via sibling_venv detection
};
for(auto s : str_arr) {

288
src/scripts/mcrf_venv.py Normal file
View File

@ -0,0 +1,288 @@
"""
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.")