From bbc744f8dc97df99a12824225fc3009c4b44972f Mon Sep 17 00:00:00 2001 From: John McCardle Date: Wed, 26 Nov 2025 22:01:09 -0500 Subject: [PATCH] feat: Add self-contained venv support for pip packages (closes #137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/McRFPy_API.cpp | 67 +++++++-- src/scripts/mcrf_venv.py | 288 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 344 insertions(+), 11 deletions(-) create mode 100644 src/scripts/mcrf_venv.py diff --git a/src/McRFPy_API.cpp b/src/McRFPy_API.cpp index d817028..2082c39 100644 --- a/src/McRFPy_API.cpp +++ b/src/McRFPy_API.cpp @@ -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) { diff --git a/src/scripts/mcrf_venv.py b/src/scripts/mcrf_venv.py new file mode 100644 index 0000000..0ede2b1 --- /dev/null +++ b/src/scripts/mcrf_venv.py @@ -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.")