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");
|
PyConfig_SetString(&config, &config.stdio_errors, L"surrogateescape");
|
||||||
config.configure_c_stdio = 1;
|
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,
|
PyConfig_SetBytesString(&config, &config.home,
|
||||||
narrow_string(executable_path() + L"/lib/Python").c_str());
|
narrow_string(executable_path() + L"/lib/Python").c_str());
|
||||||
|
|
||||||
status = PyConfig_SetBytesString(&config, &config.program_name,
|
status = PyConfig_SetBytesString(&config, &config.program_name,
|
||||||
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
|
// under Windows, the search paths are correct; under Linux, they need manual insertion
|
||||||
#if __PLATFORM_SET_PYTHON_SEARCH_PATHS == 1
|
#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[] = {
|
const wchar_t* str_arr[] = {
|
||||||
L"/scripts",
|
L"/scripts",
|
||||||
L"/lib/Python/lib.linux-x86_64-3.14",
|
L"/lib/Python/lib.linux-x86_64-3.14",
|
||||||
L"/lib/Python",
|
L"/lib/Python",
|
||||||
L"/lib/Python/Lib",
|
L"/lib/Python/Lib"
|
||||||
L"/venv/lib/python3.14/site-packages"
|
// 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_SetString(&pyconfig, &pyconfig.stdio_errors, L"surrogateescape");
|
||||||
pyconfig.configure_c_stdio = 1;
|
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)
|
// Set interactive mode (replaces deprecated Py_InspectFlag)
|
||||||
if (config.interactive_mode) {
|
if (config.interactive_mode) {
|
||||||
pyconfig.inspect = 1;
|
pyconfig.inspect = 1;
|
||||||
|
|
@ -497,7 +525,7 @@ PyStatus McRFPy_API::init_python_with_config(const McRogueFaceConfig& config)
|
||||||
return status;
|
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_wpath = executable_filename();
|
||||||
auto exe_path_fs = std::filesystem::path(exe_wpath);
|
auto exe_path_fs = std::filesystem::path(exe_wpath);
|
||||||
auto exe_dir = exe_path_fs.parent_path();
|
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;
|
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
|
// Set Python home to our bundled Python
|
||||||
auto python_home = executable_path() + L"/lib/Python";
|
auto python_home = executable_path() + L"/lib/Python";
|
||||||
PyConfig_SetString(&pyconfig, &pyconfig.home, python_home.c_str());
|
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"/scripts",
|
||||||
L"/lib/Python/lib.linux-x86_64-3.14",
|
L"/lib/Python/lib.linux-x86_64-3.14",
|
||||||
L"/lib/Python",
|
L"/lib/Python",
|
||||||
L"/lib/Python/Lib",
|
L"/lib/Python/Lib"
|
||||||
L"/venv/lib/python3.14/site-packages"
|
// Note: venv site-packages handled above via sibling_venv detection
|
||||||
};
|
};
|
||||||
|
|
||||||
for(auto s : str_arr) {
|
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