mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-05-24 05:26:42 +00:00
372 lines
12 KiB
Python
372 lines
12 KiB
Python
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
|
|
"""
|
|
Standard Python Environment Management
|
|
|
|
This module provides utilities for managing a pre-configured Python environment
|
|
with common packages (pandas, numpy, matplotlib, requests, openpyxl).
|
|
|
|
The standard environment is created once at ~/.eigent/standard_env/ and copied
|
|
to working directories as needed. This avoids race conditions when multiple
|
|
agents try to create environments simultaneously.
|
|
"""
|
|
|
|
import os
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
|
|
from utils import traceroot_wrapper as traceroot
|
|
|
|
logger = traceroot.get_logger("standard_env")
|
|
|
|
# Standard packages to install in the environment
|
|
STANDARD_PACKAGES = [
|
|
"pandas",
|
|
"numpy",
|
|
"matplotlib",
|
|
"requests",
|
|
"openpyxl",
|
|
]
|
|
|
|
# Lock for thread-safe environment operations
|
|
_env_lock = threading.Lock()
|
|
|
|
# Cache for environment paths to avoid repeated checks
|
|
_env_cache: dict[str, str] = {}
|
|
|
|
|
|
def get_standard_env_base_path() -> Path:
|
|
"""Get the base path for the standard environment."""
|
|
return Path.home() / ".eigent" / "standard_env"
|
|
|
|
|
|
def get_standard_env_marker_file(env_path: Path) -> Path:
|
|
"""Get the path to the marker file that indicates a complete environment."""
|
|
return env_path / ".env_complete"
|
|
|
|
|
|
def _get_python_executable(env_path: Path) -> str:
|
|
"""Get the Python executable path for an environment."""
|
|
if platform.system() == "Windows":
|
|
return str(env_path / "Scripts" / "python.exe")
|
|
return str(env_path / "bin" / "python")
|
|
|
|
|
|
def _get_uv_path() -> str | None:
|
|
"""Get the path to uv if available."""
|
|
try:
|
|
result = subprocess.run(
|
|
["which", "uv"]
|
|
if platform.system() != "Windows"
|
|
else ["where", "uv"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
if result.returncode == 0:
|
|
return result.stdout.strip().split("\n")[0]
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def ensure_standard_environment(
|
|
update_callback: Callable[[str], None] | None = None,
|
|
) -> str | None:
|
|
"""
|
|
Ensure the standard environment exists with all required packages.
|
|
|
|
Creates the environment at ~/.eigent/standard_env/ if it doesn't exist.
|
|
This is a one-time setup that can be done at application startup.
|
|
|
|
Args:
|
|
update_callback: Optional callback for progress updates.
|
|
|
|
Returns:
|
|
Path to the standard environment, or None if creation failed.
|
|
"""
|
|
env_path = get_standard_env_base_path()
|
|
marker_file = get_standard_env_marker_file(env_path)
|
|
|
|
# Fast path: if marker exists, environment is ready
|
|
if marker_file.exists():
|
|
logger.debug(f"Standard environment already exists at {env_path}")
|
|
return str(env_path)
|
|
|
|
with _env_lock:
|
|
# Double-check after acquiring lock
|
|
if marker_file.exists():
|
|
return str(env_path)
|
|
|
|
if update_callback:
|
|
update_callback(f"Creating standard environment at {env_path}\n")
|
|
|
|
logger.info(f"Creating standard environment at {env_path}")
|
|
|
|
try:
|
|
# Remove incomplete environment if exists
|
|
if env_path.exists():
|
|
logger.info("Removing incomplete standard environment")
|
|
shutil.rmtree(env_path)
|
|
|
|
env_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
current_version = (
|
|
f"{sys.version_info.major}.{sys.version_info.minor}"
|
|
)
|
|
uv_path = _get_uv_path()
|
|
|
|
if uv_path:
|
|
# Use uv for faster environment creation
|
|
if update_callback:
|
|
update_callback("Using uv to create environment...\n")
|
|
|
|
# Create venv with uv
|
|
subprocess.run(
|
|
[
|
|
uv_path,
|
|
"venv",
|
|
"--python",
|
|
current_version,
|
|
str(env_path),
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
timeout=300,
|
|
)
|
|
|
|
python_path = _get_python_executable(env_path)
|
|
|
|
# Install pip, setuptools, wheel
|
|
subprocess.run(
|
|
[
|
|
uv_path,
|
|
"pip",
|
|
"install",
|
|
"--python",
|
|
python_path,
|
|
"pip",
|
|
"setuptools",
|
|
"wheel",
|
|
],
|
|
check=True,
|
|
capture_output=True,
|
|
timeout=300,
|
|
)
|
|
|
|
# Install standard packages
|
|
if update_callback:
|
|
update_callback(
|
|
f"Installing packages: {', '.join(STANDARD_PACKAGES)}\n"
|
|
)
|
|
|
|
subprocess.run(
|
|
[uv_path, "pip", "install", "--python", python_path]
|
|
+ STANDARD_PACKAGES,
|
|
check=True,
|
|
capture_output=True,
|
|
timeout=600,
|
|
)
|
|
else:
|
|
# Fallback to standard venv
|
|
if update_callback:
|
|
update_callback(
|
|
"Using standard venv to create environment...\n"
|
|
)
|
|
|
|
import venv
|
|
|
|
venv.create(str(env_path), with_pip=True)
|
|
|
|
python_path = _get_python_executable(env_path)
|
|
|
|
# Upgrade pip
|
|
subprocess.run(
|
|
[python_path, "-m", "pip", "install", "--upgrade", "pip"],
|
|
check=True,
|
|
capture_output=True,
|
|
timeout=300,
|
|
)
|
|
|
|
# Install standard packages
|
|
if update_callback:
|
|
update_callback(
|
|
f"Installing packages: {', '.join(STANDARD_PACKAGES)}\n"
|
|
)
|
|
|
|
subprocess.run(
|
|
[python_path, "-m", "pip", "install", "--upgrade"]
|
|
+ STANDARD_PACKAGES,
|
|
check=True,
|
|
capture_output=True,
|
|
timeout=600,
|
|
)
|
|
|
|
# Create marker file to indicate completion
|
|
marker_file.touch()
|
|
|
|
if update_callback:
|
|
update_callback("Standard environment created successfully!\n")
|
|
|
|
logger.info(
|
|
f"Standard environment created successfully at {env_path}"
|
|
)
|
|
return str(env_path)
|
|
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Failed to create standard environment: {e}", exc_info=True
|
|
)
|
|
# Clean up on failure
|
|
if env_path.exists():
|
|
try:
|
|
shutil.rmtree(env_path)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def copy_standard_env_to_working_dir(
|
|
working_dir: str, update_callback: Callable[[str], None] | None = None
|
|
) -> str | None:
|
|
"""
|
|
Copy the standard environment to a working directory.
|
|
|
|
This function is thread-safe and will only copy once per working directory.
|
|
If the environment already exists in the working directory, it returns
|
|
immediately.
|
|
|
|
Args:
|
|
working_dir: The working directory to copy the environment to.
|
|
update_callback: Optional callback for progress updates.
|
|
|
|
Returns:
|
|
Path to the copied environment's Python executable, or None if failed.
|
|
"""
|
|
target_env_path = Path(working_dir) / ".venv"
|
|
target_marker = get_standard_env_marker_file(target_env_path)
|
|
|
|
# Fast path: if environment already exists and is complete
|
|
if target_marker.exists():
|
|
python_path = _get_python_executable(target_env_path)
|
|
if os.path.exists(python_path):
|
|
logger.debug(f"Using existing environment at {target_env_path}")
|
|
return python_path
|
|
|
|
# Check cache
|
|
cache_key = str(target_env_path)
|
|
if cache_key in _env_cache:
|
|
cached_path = _env_cache[cache_key]
|
|
if os.path.exists(cached_path):
|
|
return cached_path
|
|
|
|
with _env_lock:
|
|
# Double-check after acquiring lock
|
|
if target_marker.exists():
|
|
python_path = _get_python_executable(target_env_path)
|
|
if os.path.exists(python_path):
|
|
_env_cache[cache_key] = python_path
|
|
return python_path
|
|
|
|
# Ensure standard environment exists
|
|
standard_env_path = ensure_standard_environment(update_callback)
|
|
if not standard_env_path:
|
|
logger.error("Failed to ensure standard environment exists")
|
|
return None
|
|
|
|
if update_callback:
|
|
update_callback(f"Copying environment to {working_dir}\n")
|
|
|
|
logger.info(f"Copying standard environment to {target_env_path}")
|
|
|
|
try:
|
|
# Ensure working directory exists
|
|
Path(working_dir).mkdir(parents=True, exist_ok=True)
|
|
|
|
# Remove incomplete target if exists
|
|
if target_env_path.exists():
|
|
shutil.rmtree(target_env_path)
|
|
|
|
# Copy the environment
|
|
# Use copytree with symlinks=True to preserve symlinks (faster and smaller)
|
|
shutil.copytree(
|
|
standard_env_path,
|
|
target_env_path,
|
|
symlinks=True,
|
|
ignore_dangling_symlinks=True,
|
|
)
|
|
|
|
# Update pyvenv.cfg if it exists (important for venv relocatability)
|
|
pyvenv_cfg = target_env_path / "pyvenv.cfg"
|
|
if pyvenv_cfg.exists():
|
|
# For most use cases, symlinked venvs work fine after copy
|
|
# But we can update the home path if needed
|
|
pass
|
|
|
|
# Create marker file
|
|
target_marker.touch()
|
|
|
|
python_path = _get_python_executable(target_env_path)
|
|
_env_cache[cache_key] = python_path
|
|
|
|
if update_callback:
|
|
update_callback("Environment copied successfully!\n")
|
|
|
|
logger.info(f"Environment copied to {target_env_path}")
|
|
return python_path
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to copy environment: {e}", exc_info=True)
|
|
# Clean up on failure
|
|
if target_env_path.exists():
|
|
try:
|
|
shutil.rmtree(target_env_path)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def get_env_python_executable(working_dir: str) -> str | None:
|
|
"""
|
|
Get the Python executable for a working directory's environment.
|
|
|
|
This assumes the environment has already been set up via
|
|
copy_standard_env_to_working_dir().
|
|
|
|
Args:
|
|
working_dir: The working directory.
|
|
|
|
Returns:
|
|
Path to the Python executable, or None if not found.
|
|
"""
|
|
env_path = Path(working_dir) / ".venv"
|
|
python_path = _get_python_executable(env_path)
|
|
|
|
if os.path.exists(python_path):
|
|
return python_path
|
|
|
|
return None
|
|
|
|
|
|
def cleanup_env_cache():
|
|
"""Clear the environment path cache."""
|
|
global _env_cache
|
|
with _env_lock:
|
|
_env_cache.clear()
|