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:
parent
3f6ea4fe33
commit
bbc744f8dc
|
|
@ -395,23 +395,47 @@ PyStatus init_python(const char *program_name)
|
|||
PyConfig_SetString(&config, &config.stdio_errors, L"surrogateescape");
|
||||
config.configure_c_stdio = 1;
|
||||
|
||||
// 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;
|
||||
if (!config.module_search_paths_set) {
|
||||
config.module_search_paths_set = 1;
|
||||
}
|
||||
|
||||
// search paths for python libs/modules/scripts
|
||||
// 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
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
Loading…
Reference in New Issue