Coverage for coffee_maker/utils/setup_isolated_venv.py: 93%

69 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-21 05:58 +0000

1import logging 

2import pathlib 

3import shutil 

4import subprocess 

5import sys 

6 

7logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s") 

8logger = logging.getLogger(__name__) 

9 

10 

11def get_venv_python_executable(venv_dir_path: pathlib.Path) -> str: 

12 """Gets the path to the Python executable in the virtual environment. 

13 Args: 

14 venv_dir_path (pathlib.Path): Path to the virtual environment directory. 

15 Returns: 

16 str: Path to the Python executable in the virtual environment. 

17 """ 

18 if sys.platform == "win32": 

19 return str(venv_dir_path / "Scripts" / "python.exe") 

20 else: 

21 return str(venv_dir_path / "bin" / "python") 

22 

23 

24def setup_uv_pip( 

25 venv_dir_path: pathlib.Path = None, 

26 packages_to_install: list[str] = None, 

27 python_version: str = None, 

28 overwrite_existing: bool = True, 

29) -> str: # Return type is str, not None 

30 """ 

31 Creates a virtual environment and installs a list of packages if not already set up. 

32 Uses 'uv' for environment and package management. 

33 Args: 

34 venv_dir_path (pathlib.Path): Path to the virtual environment directory. 

35 packages_to_install (list[str]): List of packages to install in the virtual environment. 

36 python_version (str): Python version to use for the virtual environment. 

37 overwrite_existing (bool): Whether to overwrite an existing virtual environment. 

38 Returns: 

39 str: Path to the Python executable in the virtual environment. 

40 Raises: 

41 RuntimeError: If setting up the environment or installing packages fails. 

42 FileNotFoundError: If 'uv' command is not found. 

43 """ 

44 

45 if venv_dir_path is None: 

46 raise ValueError("venv_dir_path must be provided") 

47 if packages_to_install is None: 

48 raise ValueError("packages_to_install must be provided") 

49 if python_version is None: 

50 raise ValueError("python_version must be provided") 

51 

52 venv_python_executable = get_venv_python_executable(venv_dir_path) 

53 needs_venv_creation = False 

54 

55 if venv_dir_path.exists(): 

56 if overwrite_existing: 

57 logger.info(f"Virtual environment '{venv_dir_path}' exists. Overwriting as requested.") 

58 try: 

59 shutil.rmtree(str(venv_dir_path)) 

60 except OSError as e: 

61 logger.error(f"Error removing existing venv '{venv_dir_path}': {e}") 

62 raise RuntimeError(f"Failed to remove existing venv '{venv_dir_path}'.") from e 

63 needs_venv_creation = True 

64 elif not pathlib.Path(venv_python_executable).exists(): 

65 logger.warning( 

66 f"Virtual environment '{venv_dir_path}' exists but is incomplete (Python executable missing). " 

67 f"Recreating." 

68 ) 

69 try: 

70 shutil.rmtree(str(venv_dir_path)) # Clean up potentially corrupted venv 

71 except OSError as e: 

72 logger.error(f"Error removing incomplete venv '{venv_dir_path}': {e}") 

73 raise RuntimeError(f"Failed to remove incomplete venv '{venv_dir_path}'.") from e 

74 needs_venv_creation = True 

75 else: 

76 logger.info( 

77 f"Virtual environment '{venv_dir_path}' already exists and Python executable found. " 

78 f"Skipping venv creation step." 

79 ) 

80 # venv exists and is complete, and not overwriting 

81 else: 

82 logger.info(f"Virtual environment '{venv_dir_path}' not found. Will create.") 

83 needs_venv_creation = True 

84 

85 try: 

86 if needs_venv_creation: 

87 logger.info(f"Creating virtual environment at '{venv_dir_path}' using Python {python_version}.") 

88 # Create venv 

89 subprocess.run( 

90 ["uv", "venv", str(venv_dir_path), f"--python={python_version}"], 

91 check=True, 

92 capture_output=True, 

93 text=True, 

94 ) 

95 logger.info(f"Virtual environment '{venv_dir_path}' created successfully with Python {python_version}.") 

96 else: 

97 logger.info(f"Using existing virtual environment at '{venv_dir_path}'.") 

98 

99 # Install packages in the venv 

100 # 'uv pip install' is generally idempotent, so it's okay to run this even if packages might exist. 

101 # It will ensure they are present. 

102 for package in packages_to_install: 

103 # Ensure venv_python_executable is valid before using it for install 

104 if not pathlib.Path(venv_python_executable).exists(): 

105 # This case should ideally be caught by the venv creation logic if needs_venv_creation was true 

106 # or by the initial check if needs_venv_creation was false. 

107 # Adding a safeguard here. 

108 logger.error( 

109 f"Python executable '{venv_python_executable}' not found before package installation. " 

110 f"Venv setup might have failed unexpectedly." 

111 ) 

112 raise RuntimeError(f"Venv Python executable not found at '{venv_python_executable}'.") 

113 

114 install_command = ["uv", "pip", "install", "--python", venv_python_executable, package] 

115 logger.info(f"Installing/verifying {package} using command: {' '.join(install_command)}") 

116 result = subprocess.run(install_command, check=True, capture_output=True, text=True) 

117 # uv might not produce much stdout for already satisfied requirements, which is fine. 

118 if result.stdout.strip(): 

119 logger.info(f"Output for {package} installation:\n{result.stdout.strip()}") 

120 else: 

121 logger.info(f"{package} is up to date or installed successfully in '{venv_dir_path}'.") 

122 

123 except subprocess.CalledProcessError as e: 

124 logger.error(f"Error during virtual environment operation for '{venv_dir_path}':") 

125 logger.error(f"Command: {' '.join(e.cmd)}") 

126 logger.error(f"Return code: {e.returncode}") 

127 if e.stdout: 

128 logger.error(f"Stdout: {e.stdout.strip()}") 

129 if e.stderr: 

130 logger.error(f"Stderr: {e.stderr.strip()}") 

131 raise RuntimeError(f"Failed to set up or update virtual environment '{venv_dir_path}'.") from e 

132 except FileNotFoundError: 

133 logger.error("`uv` command not found. Please ensure `uv` is installed and in your PATH.") 

134 # Re-raise the FileNotFoundError to be handled by the caller if needed, 

135 # or to clearly indicate 'uv' is the issue. 

136 raise 

137 

138 return venv_python_executable