| """ |
| Service for managing Python environments and packages. |
| """ |
|
|
| import json |
| import logging |
| import os |
| import re |
| import shutil |
| import subprocess |
| import sys |
| import tempfile |
| from pathlib import Path |
| from typing import Optional |
|
|
| from django.conf import settings |
|
|
| logger = logging.getLogger(__name__) |
|
|
| |
| |
| PACKAGE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$") |
| |
| PACKAGE_SPEC_PATTERN = re.compile( |
| r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?" |
| r"(\[[\w,]+\])?" |
| r"(==|>=|<=|>|<|~=|!=|@)?" |
| r"[\d\w.*,<>=!~\[\]]*$" |
| ) |
|
|
|
|
| class EnvironmentService: |
| """ |
| Manages Python virtual environments and package operations. |
| All methods are platform-aware (Windows vs Unix). |
| """ |
|
|
| @classmethod |
| def _safe_environment_path(cls, base: str, user_path: str) -> str: |
| """ |
| Validate and return safe path within base directory. |
| |
| Args: |
| base: The base directory (ENVIRONMENTS_ROOT) |
| user_path: User-supplied relative path |
| |
| Returns: |
| The validated full path |
| |
| Raises: |
| ValueError: If path traversal is detected |
| """ |
| |
| full_path = os.path.normpath(os.path.join(base, user_path)) |
| base_resolved = os.path.normpath(base) |
|
|
| |
| if not (full_path.startswith(base_resolved + os.sep) or full_path == base_resolved): |
| raise ValueError("Invalid path: path traversal detected") |
|
|
| return full_path |
|
|
| @classmethod |
| def discover_python_versions(cls) -> list[dict]: |
| """ |
| Discover available Python installations on the system. |
| Returns list of dicts with 'path', 'version', 'display' keys. |
| """ |
| pythons = [] |
| seen_paths = set() |
|
|
| |
| if os.name == "nt": |
| pythons.extend(cls._discover_via_py_launcher()) |
|
|
| |
| current_python = sys.executable |
| if current_python and os.path.isfile(current_python): |
| version = cls._get_python_version(current_python) |
| if version and current_python not in seen_paths: |
| pythons.append( |
| { |
| "path": current_python, |
| "version": version, |
| "display": f"Python {version} (current)", |
| } |
| ) |
| seen_paths.add(current_python) |
|
|
| |
| path_pythons = cls._discover_in_path() |
| for p in path_pythons: |
| if p["path"] not in seen_paths: |
| pythons.append(p) |
| seen_paths.add(p["path"]) |
|
|
| return pythons |
|
|
| @classmethod |
| def _discover_via_py_launcher(cls) -> list[dict]: |
| """Use Windows py launcher to find Python versions.""" |
| pythons = [] |
| try: |
| |
| result = subprocess.run( |
| ["py", "-0p"], |
| capture_output=True, |
| text=True, |
| timeout=10, |
| creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, |
| ) |
| if result.returncode == 0: |
| for line in result.stdout.strip().split("\n"): |
| line = line.strip() |
| if not line: |
| continue |
| |
| |
| parts = line.split() |
| if len(parts) >= 2: |
| |
| path = None |
| for part in parts: |
| if len(part) > 2 and part[1] == ":": |
| path = part |
| break |
| if path and os.path.isfile(path): |
| version = cls._get_python_version(path) |
| if version: |
| is_default = "*" in line |
| display = f"Python {version}" |
| if is_default: |
| display += " (default)" |
| pythons.append( |
| { |
| "path": path, |
| "version": version, |
| "display": display, |
| } |
| ) |
| except (subprocess.TimeoutExpired, FileNotFoundError, Exception) as e: |
| logger.debug(f"py launcher discovery failed: {e}") |
| return pythons |
|
|
| @classmethod |
| def _discover_in_path(cls) -> list[dict]: |
| """Find Python executables in PATH.""" |
| pythons = [] |
| names = ["python3", "python"] if os.name != "nt" else ["python.exe"] |
|
|
| path_dirs = os.environ.get("PATH", "").split(os.pathsep) |
| for directory in path_dirs: |
| for name in names: |
| python_path = os.path.join(directory, name) |
| if os.path.isfile(python_path): |
| version = cls._get_python_version(python_path) |
| if version: |
| pythons.append( |
| { |
| "path": python_path, |
| "version": version, |
| "display": f"Python {version}", |
| } |
| ) |
| return pythons |
|
|
| @classmethod |
| def _get_python_version(cls, python_path: str) -> Optional[str]: |
| """Get Python version string from executable.""" |
| try: |
| result = subprocess.run( |
| [python_path, "--version"], |
| capture_output=True, |
| text=True, |
| timeout=10, |
| creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0, |
| ) |
| if result.returncode == 0: |
| |
| output = result.stdout.strip() or result.stderr.strip() |
| if output.startswith("Python "): |
| return output.replace("Python ", "") |
| except (subprocess.TimeoutExpired, FileNotFoundError, Exception) as e: |
| logger.debug(f"Failed to get version for {python_path}: {e}") |
| return None |
|
|
| @classmethod |
| def validate_package_spec(cls, package_spec: str) -> bool: |
| """ |
| Validate package specification to prevent command injection. |
| Returns True if valid, False otherwise. |
| """ |
| if not package_spec or not package_spec.strip(): |
| return False |
|
|
| spec = package_spec.strip() |
|
|
| |
| if spec.startswith("-"): |
| return False |
|
|
| |
| |
| dangerous_chars = [";", "&", "|", "`", "$", "(", ")", "{", "}", "\n", "\r", "\0"] |
| if any(char in spec for char in dangerous_chars): |
| return False |
|
|
| |
| |
| base_name = re.split(r"[=<>!~\[@]", spec)[0] |
| if not base_name or not PACKAGE_NAME_PATTERN.match(base_name): |
| return False |
|
|
| return True |
|
|
| @classmethod |
| def create_environment( |
| cls, |
| python_path: str, |
| env_path: str, |
| ) -> tuple[bool, str]: |
| """ |
| Create a new virtual environment. |
| |
| Args: |
| python_path: Path to Python executable to use |
| env_path: Relative path within ENVIRONMENTS_ROOT |
| |
| Returns: |
| Tuple of (success: bool, message: str) |
| """ |
| |
| try: |
| full_path = cls._safe_environment_path(settings.ENVIRONMENTS_ROOT, env_path) |
| except ValueError as e: |
| return False, str(e) |
|
|
| |
| if os.path.exists(full_path): |
| return False, f"Path already exists: {full_path}" |
|
|
| |
| os.makedirs(os.path.dirname(full_path), exist_ok=True) |
|
|
| try: |
| cmd = [python_path, "-m", "venv", full_path] |
| creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 |
|
|
| result = subprocess.run( |
| cmd, |
| capture_output=True, |
| text=True, |
| timeout=120, |
| creationflags=creationflags, |
| ) |
|
|
| if result.returncode != 0: |
| error_msg = result.stderr or result.stdout or "Unknown error" |
| return False, f"Failed to create venv: {error_msg}" |
|
|
| |
| if not os.path.isdir(full_path): |
| return False, "Environment directory not created" |
|
|
| logger.info(f"Created environment at {full_path}") |
| return True, "Environment created successfully" |
|
|
| except subprocess.TimeoutExpired: |
| return False, "Timeout creating environment" |
| except Exception as e: |
| return False, f"Error creating environment: {str(e)}" |
|
|
| @classmethod |
| def delete_environment(cls, environment) -> tuple[bool, str]: |
| """ |
| Delete an environment's venv folder. |
| |
| Args: |
| environment: Environment model instance |
| |
| Returns: |
| Tuple of (success: bool, message: str) |
| """ |
| full_path = environment.get_full_path() |
|
|
| if not os.path.exists(full_path): |
| return True, "Environment folder already deleted" |
|
|
| try: |
| shutil.rmtree(full_path) |
| logger.info(f"Deleted environment at {full_path}") |
| return True, "Environment deleted successfully" |
| except PermissionError: |
| return False, "Permission denied - cannot delete environment folder" |
| except Exception as e: |
| return False, f"Error deleting environment: {str(e)}" |
|
|
| @classmethod |
| def get_installed_packages(cls, environment) -> list[dict]: |
| """ |
| Get list of installed packages in an environment. |
| |
| Args: |
| environment: Environment model instance |
| |
| Returns: |
| List of dicts with 'name', 'version' keys |
| """ |
| pip_path = environment.get_pip_executable() |
|
|
| if not os.path.isfile(pip_path): |
| logger.warning(f"pip not found at {pip_path}") |
| return [] |
|
|
| try: |
| cmd = [pip_path, "list", "--format=json"] |
| creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 |
|
|
| result = subprocess.run( |
| cmd, |
| capture_output=True, |
| text=True, |
| timeout=60, |
| creationflags=creationflags, |
| ) |
|
|
| if result.returncode == 0: |
| packages = json.loads(result.stdout) |
| return [{"name": p["name"], "version": p["version"]} for p in packages] |
| else: |
| logger.warning(f"pip list failed: {result.stderr}") |
| return [] |
|
|
| except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception) as e: |
| logger.error(f"Failed to get installed packages: {e}") |
| return [] |
|
|
| @classmethod |
| def pip_freeze(cls, environment) -> str: |
| """ |
| Get pip freeze output for requirements.txt format. |
| |
| Args: |
| environment: Environment model instance |
| |
| Returns: |
| Requirements.txt content string |
| """ |
| pip_path = environment.get_pip_executable() |
|
|
| if not os.path.isfile(pip_path): |
| return "" |
|
|
| try: |
| cmd = [pip_path, "freeze"] |
| creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 |
|
|
| result = subprocess.run( |
| cmd, |
| capture_output=True, |
| text=True, |
| timeout=60, |
| creationflags=creationflags, |
| ) |
|
|
| if result.returncode == 0: |
| return result.stdout.strip() |
| return "" |
|
|
| except Exception as e: |
| logger.error(f"pip freeze failed: {e}") |
| return "" |
|
|
| @classmethod |
| def install_package( |
| cls, environment, package_spec: str |
| ) -> tuple[bool, str, str]: |
| """ |
| Install a package synchronously. |
| |
| Args: |
| environment: Environment model instance |
| package_spec: Package specification (e.g., "requests==2.31.0") |
| |
| Returns: |
| Tuple of (success: bool, stdout: str, stderr: str) |
| """ |
| if not cls.validate_package_spec(package_spec): |
| return False, "", "Invalid package specification" |
|
|
| pip_path = environment.get_pip_executable() |
|
|
| if not os.path.isfile(pip_path): |
| return False, "", f"pip not found at {pip_path}" |
|
|
| try: |
| cmd = [pip_path, "install", package_spec] |
| creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 |
|
|
| result = subprocess.run( |
| cmd, |
| capture_output=True, |
| text=True, |
| timeout=300, |
| creationflags=creationflags, |
| ) |
|
|
| success = result.returncode == 0 |
| if success: |
| logger.info(f"Installed {package_spec} in {environment.name}") |
| else: |
| logger.warning(f"Failed to install {package_spec}: {result.stderr}") |
|
|
| return success, result.stdout, result.stderr |
|
|
| except subprocess.TimeoutExpired: |
| return False, "", "Installation timed out after 5 minutes" |
| except Exception as e: |
| return False, "", f"Installation error: {str(e)}" |
|
|
| @classmethod |
| def uninstall_package( |
| cls, environment, package_name: str |
| ) -> tuple[bool, str, str]: |
| """ |
| Uninstall a package synchronously. |
| |
| Args: |
| environment: Environment model instance |
| package_name: Package name to uninstall |
| |
| Returns: |
| Tuple of (success: bool, stdout: str, stderr: str) |
| """ |
| if not cls.validate_package_spec(package_name): |
| return False, "", "Invalid package name" |
|
|
| pip_path = environment.get_pip_executable() |
|
|
| if not os.path.isfile(pip_path): |
| return False, "", f"pip not found at {pip_path}" |
|
|
| try: |
| cmd = [pip_path, "uninstall", "-y", package_name] |
| creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 |
|
|
| result = subprocess.run( |
| cmd, |
| capture_output=True, |
| text=True, |
| timeout=120, |
| creationflags=creationflags, |
| ) |
|
|
| success = result.returncode == 0 |
| if success: |
| logger.info(f"Uninstalled {package_name} from {environment.name}") |
| else: |
| logger.warning(f"Failed to uninstall {package_name}: {result.stderr}") |
|
|
| return success, result.stdout, result.stderr |
|
|
| except subprocess.TimeoutExpired: |
| return False, "", "Uninstallation timed out" |
| except Exception as e: |
| return False, "", f"Uninstallation error: {str(e)}" |
|
|
| @classmethod |
| def install_requirements( |
| cls, environment, requirements: str |
| ) -> tuple[bool, str, str]: |
| """ |
| Install packages from requirements.txt content. |
| |
| Args: |
| environment: Environment model instance |
| requirements: Content of requirements.txt |
| |
| Returns: |
| Tuple of (success: bool, stdout: str, stderr: str) |
| """ |
| pip_path = environment.get_pip_executable() |
|
|
| if not os.path.isfile(pip_path): |
| return False, "", f"pip not found at {pip_path}" |
|
|
| |
| for line in requirements.splitlines(): |
| line = line.strip() |
| if line and not line.startswith("#") and not line.startswith("-"): |
| |
| pkg_spec = line.split()[0] if line.split() else "" |
| if pkg_spec and not cls.validate_package_spec(pkg_spec): |
| return False, "", f"Invalid package specification: {line}" |
|
|
| |
| try: |
| with tempfile.NamedTemporaryFile( |
| mode="w", |
| suffix=".txt", |
| delete=False, |
| encoding="utf-8", |
| ) as f: |
| f.write(requirements) |
| temp_path = f.name |
|
|
| cmd = [pip_path, "install", "-r", temp_path] |
| creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 |
|
|
| result = subprocess.run( |
| cmd, |
| capture_output=True, |
| text=True, |
| timeout=600, |
| creationflags=creationflags, |
| ) |
|
|
| success = result.returncode == 0 |
| if success: |
| logger.info(f"Installed requirements in {environment.name}") |
| else: |
| logger.warning(f"Failed to install requirements: {result.stderr}") |
|
|
| return success, result.stdout, result.stderr |
|
|
| except subprocess.TimeoutExpired: |
| return False, "", "Installation timed out after 10 minutes" |
| except Exception as e: |
| return False, "", f"Installation error: {str(e)}" |
| finally: |
| |
| try: |
| os.unlink(temp_path) |
| except Exception: |
| pass |
|
|
| @classmethod |
| def get_disk_usage(cls, environment) -> int: |
| """ |
| Calculate disk usage of environment folder in bytes. |
| |
| Args: |
| environment: Environment model instance |
| |
| Returns: |
| Total size in bytes |
| """ |
| full_path = environment.get_full_path() |
|
|
| if not os.path.isdir(full_path): |
| return 0 |
|
|
| total = 0 |
| try: |
| for dirpath, dirnames, filenames in os.walk(full_path): |
| for f in filenames: |
| fp = os.path.join(dirpath, f) |
| try: |
| total += os.path.getsize(fp) |
| except (OSError, FileNotFoundError): |
| pass |
| except Exception as e: |
| logger.error(f"Failed to calculate disk usage: {e}") |
|
|
| return total |
|
|
| @classmethod |
| def format_disk_usage(cls, size_bytes: int) -> str: |
| """Format disk usage in human-readable format.""" |
| if size_bytes < 1024: |
| return f"{size_bytes} B" |
| elif size_bytes < 1024 * 1024: |
| return f"{size_bytes / 1024:.1f} KB" |
| elif size_bytes < 1024 * 1024 * 1024: |
| return f"{size_bytes / (1024 * 1024):.1f} MB" |
| else: |
| return f"{size_bytes / (1024 * 1024 * 1024):.2f} GB" |
|
|
| @classmethod |
| def get_python_version_from_env(cls, environment) -> Optional[str]: |
| """Get Python version from an environment's Python executable.""" |
| python_path = environment.get_python_executable() |
| return cls._get_python_version(python_path) |
|
|