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.')
def get_env_base_dir() -> pathlib.Path:
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.

TEMPLATE_ENV_VARS = {'ENV_NAME': 'environment name', 'ENV_DIR': 'environment directory', 'ENV_BASE_DIR': 'base directory for all environments', 'ENV_CONFIG_PATH': 'path to environment config file', 'ENV_BIN_DIR': 'environment bin directory', 'ENV_LIB_DIR': 'environment lib directory', 'ENV_SITE_PACKAGES_DIR': 'environment site_packages directory', 'ENV_ACTIVATE_PATH': 'path to environment activation script', 'ENV_PYVERSION': 'Python version for the environment (e.g. 3.11.2)', 'ENV_PYVERSION_MINOR': 'Minor Python version for the environment (e.g. 3.11)'}
@dataclass
class Environment:
 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.

Environment(name: str, dir_path: Optional[pathlib.Path] = None)
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()
name: typing.Annotated[str, Doc('Name of environment')]
dir_path: typing.Annotated[pathlib.Path, Doc('Path to environment directory')]
env_dir: pathlib.Path
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.

config_path: pathlib.Path
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.

bin_dir: pathlib.Path
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.

lib_dir: pathlib.Path
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.

site_packages_dir: pathlib.Path
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.

activate_path: pathlib.Path
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.

python_version: str
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.

template_env_vars: dict[str, typing.Any]
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).

def run_command(self, cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
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.

def get_installed_packages(self) -> list[str]:
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.

def get_info(self, list_packages: bool = False) -> dict[str, typing.Any]:
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.

def activate(self) -> None:
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.

@classmethod
def new( cls, name: str, seed: bool = False, python: Optional[str] = None, force: bool = False) -> Self:
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.

def freeze(self) -> None:
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.

def install( self, packages: Optional[list[str]] = None, requirements: Optional[Sequence[Union[str, pathlib.Path]]] = None, distros: Optional[list[str]] = None, upgrade: bool = False, editable: Optional[str] = None) -> None:
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.

def remove(self) -> None:
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.

def render_template( self, template: pathlib.Path, suffix: Optional[str] = None, extra_vars: Optional[dict[str, Any]] = None) -> None:
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.

def show(self, list_packages: bool = False) -> None:
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.

def sync( self, requirements: Optional[Sequence[Union[str, pathlib.Path]]] = None, distros: Optional[list[str]] = None) -> None:
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.

def uninstall( self, packages: Optional[list[str]] = None, requirements: Optional[Sequence[Union[str, pathlib.Path]]] = None, distros: Optional[list[str]] = None) -> None:
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.

@classmethod
def list(cls) -> None:
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.