milieux.env
1from collections.abc import Sequence 2from dataclasses import dataclass 3from datetime import datetime 4import json 5import os 6from pathlib import Path 7import re 8import shutil 9from subprocess import CalledProcessError, CompletedProcess 10from typing import Annotated, Any, Optional 11 12import jinja2 13from typing_extensions import Doc, Self 14 15from milieux import PROG, logger 16from milieux.config import get_config, update_command_with_index_url 17from milieux.distro import get_requirements 18from milieux.errors import EnvError, EnvironmentExistsError, MilieuxError, NoPackagesError, NoSuchEnvironmentError, TemplateError 19from milieux.utils import AnyPath, ensure_path, env_sty, eprint, run_command 20 21 22def get_env_base_dir() -> Path: 23 """Checks if the configured environment directory exists, and if not, creates it.""" 24 cfg = get_config() 25 return ensure_path(cfg.env_dir_path) 26 27# defines the available environment variables for jinja templates 28TEMPLATE_ENV_VARS = { 29 'ENV_NAME': 'environment name', 30 'ENV_DIR': 'environment directory', 31 'ENV_BASE_DIR': 'base directory for all environments', 32 'ENV_CONFIG_PATH': 'path to environment config file', 33 'ENV_BIN_DIR': 'environment bin directory', 34 'ENV_LIB_DIR': 'environment lib directory', 35 'ENV_SITE_PACKAGES_DIR': 'environment site_packages directory', 36 'ENV_ACTIVATE_PATH': 'path to environment activation script', 37 'ENV_PYVERSION': 'Python version for the environment (e.g. 3.11.2)', 38 'ENV_PYVERSION_MINOR': 'Minor Python version for the environment (e.g. 3.11)', 39} 40 41 42@dataclass 43class Environment: 44 """Class for interacting with a virtual environment.""" 45 name: Annotated[str, Doc('Name of environment')] 46 dir_path: Annotated[Path, Doc('Path to environment directory')] 47 48 def __init__(self, name: str, dir_path: Optional[Path] = None) -> None: 49 self.name = name 50 self.dir_path = dir_path or get_env_base_dir() 51 52 @property 53 def env_dir(self) -> Path: 54 """Gets the path to the environment. 55 If no such environment exists, raises a NoSuchEnvironmentError.""" 56 env_dir = self.dir_path / self.name 57 if not env_dir.is_dir(): 58 raise NoSuchEnvironmentError(self.name) 59 return env_dir 60 61 @property 62 def config_path(self) -> Path: 63 """Gets the path to the environment config file.""" 64 return self.env_dir / 'pyvenv.cfg' 65 66 @property 67 def bin_dir(self) -> Path: 68 """Gets the path to the environment's bin directory.""" 69 return self.env_dir / 'bin' 70 71 @property 72 def lib_dir(self) -> Path: 73 """Gets the path to the environment's lib directory.""" 74 return self.env_dir / 'lib' 75 76 @property 77 def site_packages_dir(self) -> Path: 78 """Gets the path to the environment's site_packages directory.""" 79 minor_version = '.'.join(self.python_version.split('.')[:2]) 80 return self.env_dir / 'lib' / f'python{minor_version}' / 'site-packages' 81 82 @property 83 def activate_path(self) -> Path: 84 """Gets the path to the environment's activation script.""" 85 return self.bin_dir / 'activate' 86 87 @property 88 def python_version(self) -> str: 89 """Gets the Python version for the environment.""" 90 with open(self.config_path) as f: 91 s = f.read() 92 match = re.search(r'version_info\s*=\s*(\d+\.\d+\.\d+)', s) 93 if not match: 94 raise MilieuxError(f'Could not get Python version info from {self.config_path}') 95 (version,) = match.groups(0) 96 assert isinstance(version, str) 97 return version 98 99 @property 100 def template_env_vars(self) -> dict[str, Any]: 101 """Gets a mapping from template environment variables to their values for this environment. 102 NOTE: the values may be strings or Path objects (the latter make it easier to perform path operations within a jinja template).""" 103 pyversion = self.python_version 104 pyversion_minor = '.'.join(pyversion.split('.')[:2]) 105 env_vars = { 106 'ENV_NAME': self.name, 107 'ENV_DIR': self.env_dir, 108 'ENV_BASE_DIR': self.dir_path, 109 'ENV_CONFIG_PATH': self.config_path, 110 'ENV_BIN_DIR': self.bin_dir, 111 'ENV_LIB_DIR': self.lib_dir, 112 'ENV_SITE_PACKAGES_DIR': self.site_packages_dir, 113 'ENV_ACTIVATE_PATH': self.activate_path, 114 'ENV_PYVERSION': pyversion, 115 'ENV_PYVERSION_MINOR': pyversion_minor, 116 } 117 assert set(env_vars) == set(TEMPLATE_ENV_VARS) 118 return env_vars 119 120 def run_command(self, cmd: list[str], **kwargs: Any) -> CompletedProcess[str]: 121 """Runs a command with the VIRTUAL_ENV environment variable set.""" 122 cmd_env = {**os.environ, 'VIRTUAL_ENV': str(self.env_dir)} 123 return run_command(cmd, env=cmd_env, check=True, **kwargs) 124 125 def get_installed_packages(self) -> list[str]: 126 """Gets a list of installed packages in the environment.""" 127 cmd = ['uv', 'pip', 'freeze'] 128 res = self.run_command(cmd, text=True, capture_output=True) 129 return res.stdout.splitlines() 130 131 def get_info(self, list_packages: bool = False) -> dict[str, Any]: 132 """Gets details about the environment, as a JSON-compatible dict.""" 133 path = self.env_dir 134 created_at = datetime.fromtimestamp(path.stat().st_ctime).isoformat() 135 info: dict[str, Any] = {'name': self.name, 'path': str(path), 'created_at': created_at} 136 if list_packages: 137 info['packages'] = self.get_installed_packages() 138 return info 139 140 def _install_or_uninstall_cmd( 141 self, 142 install: bool, 143 packages: Optional[list[str]] = None, 144 requirements: Optional[Sequence[AnyPath]] = None, 145 distros: Optional[list[str]] = None, 146 editable: Optional[str] = None, 147 ) -> list[str]: 148 """Installs one or more packages into the environment.""" 149 operation = 'install' if install else 'uninstall' 150 reqs = get_requirements(requirements, distros) 151 if (not editable) and (not packages) and (not reqs): 152 raise NoPackagesError(f'Must specify packages to {operation}') 153 cmd = ['uv', 'pip', operation] 154 if install: 155 update_command_with_index_url(cmd) 156 # TODO: extra index URLs? 157 if packages: 158 cmd.extend(packages) 159 if reqs: 160 cmd.extend(['-r'] + reqs) 161 return cmd 162 163 def activate(self) -> None: 164 """Prints info about how to activate the environment.""" 165 activate_path = self.activate_path 166 if not activate_path.exists(): 167 raise FileNotFoundError(activate_path) 168 # NOTE: no easy way to activate new shell and "source" a file in Python 169 # instead, we just print out the command 170 print(f'source {activate_path}') 171 eprint('\nTo activate the environment, run the following shell command:\n') 172 eprint(f'source {activate_path}', highlight=False) 173 eprint('\nAlternatively, you can run (with backticks):\n', highlight=False) 174 eprint(f'`{PROG} env activate {self.name}`') 175 eprint('\nTo deactivate the environment, run:\n') 176 eprint('deactivate\n') 177 178 @classmethod 179 def new( 180 cls, 181 name: str, 182 seed: bool = False, 183 python: Optional[str] = None, 184 force: bool = False, 185 ) -> Self: 186 """Creates a new environment. 187 Uses the version of Python currently on the user's PATH.""" 188 env_base_dir = get_env_base_dir() 189 new_env_dir = env_base_dir / name 190 if new_env_dir.exists(): 191 msg = f'Environment {env_sty(name)} already exists' 192 if force: 193 logger.warning(f'{msg} -- overwriting') 194 shutil.rmtree(new_env_dir) 195 else: 196 raise EnvironmentExistsError(msg) 197 logger.info(f'Creating environment {env_sty(name)} in {new_env_dir}') 198 new_env_dir.mkdir() 199 cmd = ['uv', 'venv', str(new_env_dir)] 200 update_command_with_index_url(cmd) 201 if seed: 202 cmd.append('--seed') 203 if python: 204 cmd += ['--python', python] 205 try: 206 res = run_command(cmd, capture_output=True, check=True) 207 except CalledProcessError as e: 208 shutil.rmtree(new_env_dir) 209 raise EnvError(e.stderr.rstrip()) from e 210 lines = [line for line in res.stderr.splitlines() if not line.startswith('Activate')] 211 eprint('\n'.join(lines), highlight=False) 212 env = cls(name, env_base_dir) 213 logger.info(f'Activate with either of these commands:\n\tsource {env.activate_path}\n\t{PROG} env activate {name}', extra={'highlighter': None}) 214 return env 215 216 def freeze(self) -> None: 217 """Prints out the packages currently installed in the environment.""" 218 packages = self.get_installed_packages() 219 for pkg in packages: 220 print(pkg) 221 222 def install( 223 self, 224 packages: Optional[list[str]] = None, 225 requirements: Optional[Sequence[AnyPath]] = None, 226 distros: Optional[list[str]] = None, 227 upgrade: bool = False, 228 editable: Optional[str] = None, 229 ) -> None: 230 """Installs one or more packages into the environment.""" 231 _ = self.env_dir # ensure environment exists 232 logger.info(f'Installing dependencies into {env_sty(self.name)} environment') 233 cmd = self._install_or_uninstall_cmd(True, packages=packages, requirements=requirements, distros=distros, editable=editable) 234 if upgrade: 235 cmd.append('--upgrade') 236 if editable: 237 cmd += ['--editable', editable] 238 self.run_command(cmd) 239 240 def remove(self) -> None: 241 """Deletes the environment.""" 242 env_dir = self.env_dir 243 logger.info(f'Deleting {env_sty(self.name)} environment') 244 shutil.rmtree(env_dir) 245 logger.info(f'Deleted {env_dir}') 246 247 def render_template(self, template: Path, suffix: Optional[str] = None, extra_vars: Optional[dict[str, Any]] = None) -> None: 248 """Renders a jinja template, filling in variables from the environment. 249 If suffix is None, prints the output to stdout. 250 Otherwise, saves a new file with the original file extension replaced by this suffix. 251 extra_vars is an optional mapping from extra variables to values.""" 252 # error if unknown variables are present 253 env = jinja2.Environment(undefined=jinja2.StrictUndefined) 254 try: 255 input_template = env.from_string(template.read_text()) 256 kwargs = {**self.template_env_vars, **(extra_vars or {})} 257 output = input_template.render(**kwargs) 258 except jinja2.exceptions.TemplateError as e: 259 msg = f'Error rendering template {template} - {e}' 260 raise TemplateError(msg) from e 261 if suffix is None: 262 print(output) 263 else: 264 suffix = suffix if suffix.startswith('.') else ('.' + suffix) 265 output_path = template.with_suffix(suffix) 266 output_path.write_text(output) 267 logger.info(f'Rendered template {template} to {output_path}') 268 269 def show(self, list_packages: bool = False) -> None: 270 """Shows details about the environment.""" 271 info = self.get_info(list_packages=list_packages) 272 print(json.dumps(info, indent=2)) 273 274 def sync(self, requirements: Optional[Sequence[AnyPath]] = None, distros: Optional[list[str]] = None) -> None: 275 """Syncs dependencies in a distro or requirements files to the environment. 276 NOTE: unlike 'install', this ensures the environment exactly matches the dependencies afterward.""" 277 reqs = get_requirements(requirements, distros) 278 if not reqs: 279 raise NoPackagesError('Must specify dependencies to sync') 280 logger.info(f'Syncing dependencies in {env_sty(self.name)} environment') 281 cmd = ['uv', 'pip', 'sync'] + reqs 282 update_command_with_index_url(cmd) 283 self.run_command(cmd) 284 285 def uninstall( 286 self, 287 packages: Optional[list[str]] = None, 288 requirements: Optional[Sequence[AnyPath]] = None, 289 distros: Optional[list[str]] = None 290 ) -> None: 291 """Uninstalls one or more packages from the environment.""" 292 _ = self.env_dir # ensure environment exists 293 logger.info(f'Uninstalling dependencies from {env_sty(self.name)} environment') 294 cmd = self._install_or_uninstall_cmd(False, packages=packages, requirements=requirements, distros=distros) 295 self.run_command(cmd) 296 297 # NOTE: due to a bug in mypy (https://github.com/python/mypy/issues/15047), this method must come last 298 @classmethod 299 def list(cls) -> None: 300 """Prints the list of existing environments.""" 301 env_base_dir = get_env_base_dir() 302 eprint(f'Environment directory: {env_base_dir}') 303 envs = sorted([p.name for p in env_base_dir.glob('*') if p.is_dir()]) 304 if envs: 305 eprint('──────────────\n [bold]Environments[/]\n──────────────') 306 for env in envs: 307 print(env) 308 else: 309 eprint('No environments exist.')
23def get_env_base_dir() -> Path: 24 """Checks if the configured environment directory exists, and if not, creates it.""" 25 cfg = get_config() 26 return ensure_path(cfg.env_dir_path)
Checks if the configured environment directory exists, and if not, creates it.
43@dataclass 44class Environment: 45 """Class for interacting with a virtual environment.""" 46 name: Annotated[str, Doc('Name of environment')] 47 dir_path: Annotated[Path, Doc('Path to environment directory')] 48 49 def __init__(self, name: str, dir_path: Optional[Path] = None) -> None: 50 self.name = name 51 self.dir_path = dir_path or get_env_base_dir() 52 53 @property 54 def env_dir(self) -> Path: 55 """Gets the path to the environment. 56 If no such environment exists, raises a NoSuchEnvironmentError.""" 57 env_dir = self.dir_path / self.name 58 if not env_dir.is_dir(): 59 raise NoSuchEnvironmentError(self.name) 60 return env_dir 61 62 @property 63 def config_path(self) -> Path: 64 """Gets the path to the environment config file.""" 65 return self.env_dir / 'pyvenv.cfg' 66 67 @property 68 def bin_dir(self) -> Path: 69 """Gets the path to the environment's bin directory.""" 70 return self.env_dir / 'bin' 71 72 @property 73 def lib_dir(self) -> Path: 74 """Gets the path to the environment's lib directory.""" 75 return self.env_dir / 'lib' 76 77 @property 78 def site_packages_dir(self) -> Path: 79 """Gets the path to the environment's site_packages directory.""" 80 minor_version = '.'.join(self.python_version.split('.')[:2]) 81 return self.env_dir / 'lib' / f'python{minor_version}' / 'site-packages' 82 83 @property 84 def activate_path(self) -> Path: 85 """Gets the path to the environment's activation script.""" 86 return self.bin_dir / 'activate' 87 88 @property 89 def python_version(self) -> str: 90 """Gets the Python version for the environment.""" 91 with open(self.config_path) as f: 92 s = f.read() 93 match = re.search(r'version_info\s*=\s*(\d+\.\d+\.\d+)', s) 94 if not match: 95 raise MilieuxError(f'Could not get Python version info from {self.config_path}') 96 (version,) = match.groups(0) 97 assert isinstance(version, str) 98 return version 99 100 @property 101 def template_env_vars(self) -> dict[str, Any]: 102 """Gets a mapping from template environment variables to their values for this environment. 103 NOTE: the values may be strings or Path objects (the latter make it easier to perform path operations within a jinja template).""" 104 pyversion = self.python_version 105 pyversion_minor = '.'.join(pyversion.split('.')[:2]) 106 env_vars = { 107 'ENV_NAME': self.name, 108 'ENV_DIR': self.env_dir, 109 'ENV_BASE_DIR': self.dir_path, 110 'ENV_CONFIG_PATH': self.config_path, 111 'ENV_BIN_DIR': self.bin_dir, 112 'ENV_LIB_DIR': self.lib_dir, 113 'ENV_SITE_PACKAGES_DIR': self.site_packages_dir, 114 'ENV_ACTIVATE_PATH': self.activate_path, 115 'ENV_PYVERSION': pyversion, 116 'ENV_PYVERSION_MINOR': pyversion_minor, 117 } 118 assert set(env_vars) == set(TEMPLATE_ENV_VARS) 119 return env_vars 120 121 def run_command(self, cmd: list[str], **kwargs: Any) -> CompletedProcess[str]: 122 """Runs a command with the VIRTUAL_ENV environment variable set.""" 123 cmd_env = {**os.environ, 'VIRTUAL_ENV': str(self.env_dir)} 124 return run_command(cmd, env=cmd_env, check=True, **kwargs) 125 126 def get_installed_packages(self) -> list[str]: 127 """Gets a list of installed packages in the environment.""" 128 cmd = ['uv', 'pip', 'freeze'] 129 res = self.run_command(cmd, text=True, capture_output=True) 130 return res.stdout.splitlines() 131 132 def get_info(self, list_packages: bool = False) -> dict[str, Any]: 133 """Gets details about the environment, as a JSON-compatible dict.""" 134 path = self.env_dir 135 created_at = datetime.fromtimestamp(path.stat().st_ctime).isoformat() 136 info: dict[str, Any] = {'name': self.name, 'path': str(path), 'created_at': created_at} 137 if list_packages: 138 info['packages'] = self.get_installed_packages() 139 return info 140 141 def _install_or_uninstall_cmd( 142 self, 143 install: bool, 144 packages: Optional[list[str]] = None, 145 requirements: Optional[Sequence[AnyPath]] = None, 146 distros: Optional[list[str]] = None, 147 editable: Optional[str] = None, 148 ) -> list[str]: 149 """Installs one or more packages into the environment.""" 150 operation = 'install' if install else 'uninstall' 151 reqs = get_requirements(requirements, distros) 152 if (not editable) and (not packages) and (not reqs): 153 raise NoPackagesError(f'Must specify packages to {operation}') 154 cmd = ['uv', 'pip', operation] 155 if install: 156 update_command_with_index_url(cmd) 157 # TODO: extra index URLs? 158 if packages: 159 cmd.extend(packages) 160 if reqs: 161 cmd.extend(['-r'] + reqs) 162 return cmd 163 164 def activate(self) -> None: 165 """Prints info about how to activate the environment.""" 166 activate_path = self.activate_path 167 if not activate_path.exists(): 168 raise FileNotFoundError(activate_path) 169 # NOTE: no easy way to activate new shell and "source" a file in Python 170 # instead, we just print out the command 171 print(f'source {activate_path}') 172 eprint('\nTo activate the environment, run the following shell command:\n') 173 eprint(f'source {activate_path}', highlight=False) 174 eprint('\nAlternatively, you can run (with backticks):\n', highlight=False) 175 eprint(f'`{PROG} env activate {self.name}`') 176 eprint('\nTo deactivate the environment, run:\n') 177 eprint('deactivate\n') 178 179 @classmethod 180 def new( 181 cls, 182 name: str, 183 seed: bool = False, 184 python: Optional[str] = None, 185 force: bool = False, 186 ) -> Self: 187 """Creates a new environment. 188 Uses the version of Python currently on the user's PATH.""" 189 env_base_dir = get_env_base_dir() 190 new_env_dir = env_base_dir / name 191 if new_env_dir.exists(): 192 msg = f'Environment {env_sty(name)} already exists' 193 if force: 194 logger.warning(f'{msg} -- overwriting') 195 shutil.rmtree(new_env_dir) 196 else: 197 raise EnvironmentExistsError(msg) 198 logger.info(f'Creating environment {env_sty(name)} in {new_env_dir}') 199 new_env_dir.mkdir() 200 cmd = ['uv', 'venv', str(new_env_dir)] 201 update_command_with_index_url(cmd) 202 if seed: 203 cmd.append('--seed') 204 if python: 205 cmd += ['--python', python] 206 try: 207 res = run_command(cmd, capture_output=True, check=True) 208 except CalledProcessError as e: 209 shutil.rmtree(new_env_dir) 210 raise EnvError(e.stderr.rstrip()) from e 211 lines = [line for line in res.stderr.splitlines() if not line.startswith('Activate')] 212 eprint('\n'.join(lines), highlight=False) 213 env = cls(name, env_base_dir) 214 logger.info(f'Activate with either of these commands:\n\tsource {env.activate_path}\n\t{PROG} env activate {name}', extra={'highlighter': None}) 215 return env 216 217 def freeze(self) -> None: 218 """Prints out the packages currently installed in the environment.""" 219 packages = self.get_installed_packages() 220 for pkg in packages: 221 print(pkg) 222 223 def install( 224 self, 225 packages: Optional[list[str]] = None, 226 requirements: Optional[Sequence[AnyPath]] = None, 227 distros: Optional[list[str]] = None, 228 upgrade: bool = False, 229 editable: Optional[str] = None, 230 ) -> None: 231 """Installs one or more packages into the environment.""" 232 _ = self.env_dir # ensure environment exists 233 logger.info(f'Installing dependencies into {env_sty(self.name)} environment') 234 cmd = self._install_or_uninstall_cmd(True, packages=packages, requirements=requirements, distros=distros, editable=editable) 235 if upgrade: 236 cmd.append('--upgrade') 237 if editable: 238 cmd += ['--editable', editable] 239 self.run_command(cmd) 240 241 def remove(self) -> None: 242 """Deletes the environment.""" 243 env_dir = self.env_dir 244 logger.info(f'Deleting {env_sty(self.name)} environment') 245 shutil.rmtree(env_dir) 246 logger.info(f'Deleted {env_dir}') 247 248 def render_template(self, template: Path, suffix: Optional[str] = None, extra_vars: Optional[dict[str, Any]] = None) -> None: 249 """Renders a jinja template, filling in variables from the environment. 250 If suffix is None, prints the output to stdout. 251 Otherwise, saves a new file with the original file extension replaced by this suffix. 252 extra_vars is an optional mapping from extra variables to values.""" 253 # error if unknown variables are present 254 env = jinja2.Environment(undefined=jinja2.StrictUndefined) 255 try: 256 input_template = env.from_string(template.read_text()) 257 kwargs = {**self.template_env_vars, **(extra_vars or {})} 258 output = input_template.render(**kwargs) 259 except jinja2.exceptions.TemplateError as e: 260 msg = f'Error rendering template {template} - {e}' 261 raise TemplateError(msg) from e 262 if suffix is None: 263 print(output) 264 else: 265 suffix = suffix if suffix.startswith('.') else ('.' + suffix) 266 output_path = template.with_suffix(suffix) 267 output_path.write_text(output) 268 logger.info(f'Rendered template {template} to {output_path}') 269 270 def show(self, list_packages: bool = False) -> None: 271 """Shows details about the environment.""" 272 info = self.get_info(list_packages=list_packages) 273 print(json.dumps(info, indent=2)) 274 275 def sync(self, requirements: Optional[Sequence[AnyPath]] = None, distros: Optional[list[str]] = None) -> None: 276 """Syncs dependencies in a distro or requirements files to the environment. 277 NOTE: unlike 'install', this ensures the environment exactly matches the dependencies afterward.""" 278 reqs = get_requirements(requirements, distros) 279 if not reqs: 280 raise NoPackagesError('Must specify dependencies to sync') 281 logger.info(f'Syncing dependencies in {env_sty(self.name)} environment') 282 cmd = ['uv', 'pip', 'sync'] + reqs 283 update_command_with_index_url(cmd) 284 self.run_command(cmd) 285 286 def uninstall( 287 self, 288 packages: Optional[list[str]] = None, 289 requirements: Optional[Sequence[AnyPath]] = None, 290 distros: Optional[list[str]] = None 291 ) -> None: 292 """Uninstalls one or more packages from the environment.""" 293 _ = self.env_dir # ensure environment exists 294 logger.info(f'Uninstalling dependencies from {env_sty(self.name)} environment') 295 cmd = self._install_or_uninstall_cmd(False, packages=packages, requirements=requirements, distros=distros) 296 self.run_command(cmd) 297 298 # NOTE: due to a bug in mypy (https://github.com/python/mypy/issues/15047), this method must come last 299 @classmethod 300 def list(cls) -> None: 301 """Prints the list of existing environments.""" 302 env_base_dir = get_env_base_dir() 303 eprint(f'Environment directory: {env_base_dir}') 304 envs = sorted([p.name for p in env_base_dir.glob('*') if p.is_dir()]) 305 if envs: 306 eprint('──────────────\n [bold]Environments[/]\n──────────────') 307 for env in envs: 308 print(env) 309 else: 310 eprint('No environments exist.')
Class for interacting with a virtual environment.
53 @property 54 def env_dir(self) -> Path: 55 """Gets the path to the environment. 56 If no such environment exists, raises a NoSuchEnvironmentError.""" 57 env_dir = self.dir_path / self.name 58 if not env_dir.is_dir(): 59 raise NoSuchEnvironmentError(self.name) 60 return env_dir
Gets the path to the environment. If no such environment exists, raises a NoSuchEnvironmentError.
62 @property 63 def config_path(self) -> Path: 64 """Gets the path to the environment config file.""" 65 return self.env_dir / 'pyvenv.cfg'
Gets the path to the environment config file.
67 @property 68 def bin_dir(self) -> Path: 69 """Gets the path to the environment's bin directory.""" 70 return self.env_dir / 'bin'
Gets the path to the environment's bin directory.
72 @property 73 def lib_dir(self) -> Path: 74 """Gets the path to the environment's lib directory.""" 75 return self.env_dir / 'lib'
Gets the path to the environment's lib directory.
77 @property 78 def site_packages_dir(self) -> Path: 79 """Gets the path to the environment's site_packages directory.""" 80 minor_version = '.'.join(self.python_version.split('.')[:2]) 81 return self.env_dir / 'lib' / f'python{minor_version}' / 'site-packages'
Gets the path to the environment's site_packages directory.
83 @property 84 def activate_path(self) -> Path: 85 """Gets the path to the environment's activation script.""" 86 return self.bin_dir / 'activate'
Gets the path to the environment's activation script.
88 @property 89 def python_version(self) -> str: 90 """Gets the Python version for the environment.""" 91 with open(self.config_path) as f: 92 s = f.read() 93 match = re.search(r'version_info\s*=\s*(\d+\.\d+\.\d+)', s) 94 if not match: 95 raise MilieuxError(f'Could not get Python version info from {self.config_path}') 96 (version,) = match.groups(0) 97 assert isinstance(version, str) 98 return version
Gets the Python version for the environment.
100 @property 101 def template_env_vars(self) -> dict[str, Any]: 102 """Gets a mapping from template environment variables to their values for this environment. 103 NOTE: the values may be strings or Path objects (the latter make it easier to perform path operations within a jinja template).""" 104 pyversion = self.python_version 105 pyversion_minor = '.'.join(pyversion.split('.')[:2]) 106 env_vars = { 107 'ENV_NAME': self.name, 108 'ENV_DIR': self.env_dir, 109 'ENV_BASE_DIR': self.dir_path, 110 'ENV_CONFIG_PATH': self.config_path, 111 'ENV_BIN_DIR': self.bin_dir, 112 'ENV_LIB_DIR': self.lib_dir, 113 'ENV_SITE_PACKAGES_DIR': self.site_packages_dir, 114 'ENV_ACTIVATE_PATH': self.activate_path, 115 'ENV_PYVERSION': pyversion, 116 'ENV_PYVERSION_MINOR': pyversion_minor, 117 } 118 assert set(env_vars) == set(TEMPLATE_ENV_VARS) 119 return env_vars
Gets a mapping from template environment variables to their values for this environment. NOTE: the values may be strings or Path objects (the latter make it easier to perform path operations within a jinja template).
121 def run_command(self, cmd: list[str], **kwargs: Any) -> CompletedProcess[str]: 122 """Runs a command with the VIRTUAL_ENV environment variable set.""" 123 cmd_env = {**os.environ, 'VIRTUAL_ENV': str(self.env_dir)} 124 return run_command(cmd, env=cmd_env, check=True, **kwargs)
Runs a command with the VIRTUAL_ENV environment variable set.
126 def get_installed_packages(self) -> list[str]: 127 """Gets a list of installed packages in the environment.""" 128 cmd = ['uv', 'pip', 'freeze'] 129 res = self.run_command(cmd, text=True, capture_output=True) 130 return res.stdout.splitlines()
Gets a list of installed packages in the environment.
132 def get_info(self, list_packages: bool = False) -> dict[str, Any]: 133 """Gets details about the environment, as a JSON-compatible dict.""" 134 path = self.env_dir 135 created_at = datetime.fromtimestamp(path.stat().st_ctime).isoformat() 136 info: dict[str, Any] = {'name': self.name, 'path': str(path), 'created_at': created_at} 137 if list_packages: 138 info['packages'] = self.get_installed_packages() 139 return info
Gets details about the environment, as a JSON-compatible dict.
164 def activate(self) -> None: 165 """Prints info about how to activate the environment.""" 166 activate_path = self.activate_path 167 if not activate_path.exists(): 168 raise FileNotFoundError(activate_path) 169 # NOTE: no easy way to activate new shell and "source" a file in Python 170 # instead, we just print out the command 171 print(f'source {activate_path}') 172 eprint('\nTo activate the environment, run the following shell command:\n') 173 eprint(f'source {activate_path}', highlight=False) 174 eprint('\nAlternatively, you can run (with backticks):\n', highlight=False) 175 eprint(f'`{PROG} env activate {self.name}`') 176 eprint('\nTo deactivate the environment, run:\n') 177 eprint('deactivate\n')
Prints info about how to activate the environment.
179 @classmethod 180 def new( 181 cls, 182 name: str, 183 seed: bool = False, 184 python: Optional[str] = None, 185 force: bool = False, 186 ) -> Self: 187 """Creates a new environment. 188 Uses the version of Python currently on the user's PATH.""" 189 env_base_dir = get_env_base_dir() 190 new_env_dir = env_base_dir / name 191 if new_env_dir.exists(): 192 msg = f'Environment {env_sty(name)} already exists' 193 if force: 194 logger.warning(f'{msg} -- overwriting') 195 shutil.rmtree(new_env_dir) 196 else: 197 raise EnvironmentExistsError(msg) 198 logger.info(f'Creating environment {env_sty(name)} in {new_env_dir}') 199 new_env_dir.mkdir() 200 cmd = ['uv', 'venv', str(new_env_dir)] 201 update_command_with_index_url(cmd) 202 if seed: 203 cmd.append('--seed') 204 if python: 205 cmd += ['--python', python] 206 try: 207 res = run_command(cmd, capture_output=True, check=True) 208 except CalledProcessError as e: 209 shutil.rmtree(new_env_dir) 210 raise EnvError(e.stderr.rstrip()) from e 211 lines = [line for line in res.stderr.splitlines() if not line.startswith('Activate')] 212 eprint('\n'.join(lines), highlight=False) 213 env = cls(name, env_base_dir) 214 logger.info(f'Activate with either of these commands:\n\tsource {env.activate_path}\n\t{PROG} env activate {name}', extra={'highlighter': None}) 215 return env
Creates a new environment. Uses the version of Python currently on the user's PATH.
217 def freeze(self) -> None: 218 """Prints out the packages currently installed in the environment.""" 219 packages = self.get_installed_packages() 220 for pkg in packages: 221 print(pkg)
Prints out the packages currently installed in the environment.
223 def install( 224 self, 225 packages: Optional[list[str]] = None, 226 requirements: Optional[Sequence[AnyPath]] = None, 227 distros: Optional[list[str]] = None, 228 upgrade: bool = False, 229 editable: Optional[str] = None, 230 ) -> None: 231 """Installs one or more packages into the environment.""" 232 _ = self.env_dir # ensure environment exists 233 logger.info(f'Installing dependencies into {env_sty(self.name)} environment') 234 cmd = self._install_or_uninstall_cmd(True, packages=packages, requirements=requirements, distros=distros, editable=editable) 235 if upgrade: 236 cmd.append('--upgrade') 237 if editable: 238 cmd += ['--editable', editable] 239 self.run_command(cmd)
Installs one or more packages into the environment.
241 def remove(self) -> None: 242 """Deletes the environment.""" 243 env_dir = self.env_dir 244 logger.info(f'Deleting {env_sty(self.name)} environment') 245 shutil.rmtree(env_dir) 246 logger.info(f'Deleted {env_dir}')
Deletes the environment.
248 def render_template(self, template: Path, suffix: Optional[str] = None, extra_vars: Optional[dict[str, Any]] = None) -> None: 249 """Renders a jinja template, filling in variables from the environment. 250 If suffix is None, prints the output to stdout. 251 Otherwise, saves a new file with the original file extension replaced by this suffix. 252 extra_vars is an optional mapping from extra variables to values.""" 253 # error if unknown variables are present 254 env = jinja2.Environment(undefined=jinja2.StrictUndefined) 255 try: 256 input_template = env.from_string(template.read_text()) 257 kwargs = {**self.template_env_vars, **(extra_vars or {})} 258 output = input_template.render(**kwargs) 259 except jinja2.exceptions.TemplateError as e: 260 msg = f'Error rendering template {template} - {e}' 261 raise TemplateError(msg) from e 262 if suffix is None: 263 print(output) 264 else: 265 suffix = suffix if suffix.startswith('.') else ('.' + suffix) 266 output_path = template.with_suffix(suffix) 267 output_path.write_text(output) 268 logger.info(f'Rendered template {template} to {output_path}')
Renders a jinja template, filling in variables from the environment. If suffix is None, prints the output to stdout. Otherwise, saves a new file with the original file extension replaced by this suffix. extra_vars is an optional mapping from extra variables to values.
270 def show(self, list_packages: bool = False) -> None: 271 """Shows details about the environment.""" 272 info = self.get_info(list_packages=list_packages) 273 print(json.dumps(info, indent=2))
Shows details about the environment.
275 def sync(self, requirements: Optional[Sequence[AnyPath]] = None, distros: Optional[list[str]] = None) -> None: 276 """Syncs dependencies in a distro or requirements files to the environment. 277 NOTE: unlike 'install', this ensures the environment exactly matches the dependencies afterward.""" 278 reqs = get_requirements(requirements, distros) 279 if not reqs: 280 raise NoPackagesError('Must specify dependencies to sync') 281 logger.info(f'Syncing dependencies in {env_sty(self.name)} environment') 282 cmd = ['uv', 'pip', 'sync'] + reqs 283 update_command_with_index_url(cmd) 284 self.run_command(cmd)
Syncs dependencies in a distro or requirements files to the environment. NOTE: unlike 'install', this ensures the environment exactly matches the dependencies afterward.
286 def uninstall( 287 self, 288 packages: Optional[list[str]] = None, 289 requirements: Optional[Sequence[AnyPath]] = None, 290 distros: Optional[list[str]] = None 291 ) -> None: 292 """Uninstalls one or more packages from the environment.""" 293 _ = self.env_dir # ensure environment exists 294 logger.info(f'Uninstalling dependencies from {env_sty(self.name)} environment') 295 cmd = self._install_or_uninstall_cmd(False, packages=packages, requirements=requirements, distros=distros) 296 self.run_command(cmd)
Uninstalls one or more packages from the environment.
299 @classmethod 300 def list(cls) -> None: 301 """Prints the list of existing environments.""" 302 env_base_dir = get_env_base_dir() 303 eprint(f'Environment directory: {env_base_dir}') 304 envs = sorted([p.name for p in env_base_dir.glob('*') if p.is_dir()]) 305 if envs: 306 eprint('──────────────\n [bold]Environments[/]\n──────────────') 307 for env in envs: 308 print(env) 309 else: 310 eprint('No environments exist.')
Prints the list of existing environments.