diff --git a/src/poetry/console/commands/env/remove.py b/src/poetry/console/commands/env/remove.py index d23fafe5526..4325f045b39 100644 --- a/src/poetry/console/commands/env/remove.py +++ b/src/poetry/console/commands/env/remove.py @@ -45,5 +45,10 @@ def handle(self) -> int: for venv in manager.list(): manager.remove_venv(venv.path) self.line(f"Deleted virtualenv: {venv.path}") + # Since we remove all the virtualenvs, we can also remove the entry + # in the envs file. (Strictly speaking, we should do this explicitly, + # in case it points to a virtualenv that had been removed manually before.) + if manager.envs_file.exists(): + manager.envs_file.remove_section(manager.base_env_name) return 0 diff --git a/src/poetry/utils/env/env_manager.py b/src/poetry/utils/env/env_manager.py index e7b0cc751d9..68a0ff02a3a 100644 --- a/src/poetry/utils/env/env_manager.py +++ b/src/poetry/utils/env/env_manager.py @@ -9,6 +9,7 @@ import subprocess import sys +from functools import cached_property from pathlib import Path from subprocess import CalledProcessError from typing import TYPE_CHECKING @@ -45,6 +46,44 @@ from poetry.utils.env.base_env import Env +class EnvsFile(TOMLFile): + """ + This file contains one section per project with the project's base env name + as section name. Each section contains the minor and patch version of the + python executable used to create the currently active virtualenv. + + Example: + + [poetry-QRErDmmj] + minor = "3.9" + patch = "3.9.13" + + [poetry-core-m5r7DkRA] + minor = "3.11" + patch = "3.11.6" + """ + + def remove_section(self, name: str, minor: str | None = None) -> str | None: + """ + Remove a section from the envs file. + + If "minor" is given, the section is only removed if its minor value + matches "minor". + + Returns the "minor" value of the removed section. + """ + envs = self.read() + current_env = envs.get(name) + if current_env is not None and (not minor or current_env["minor"] == minor): + del envs[name] + self.write(envs) + minor = current_env["minor"] + assert isinstance(minor, str) + return minor + + return None + + class EnvManager: """ Environments manager @@ -121,11 +160,20 @@ def in_project_venv(self) -> Path: venv: Path = self._poetry.file.path.parent / ".venv" return venv - def activate(self, python: str) -> Env: + @cached_property + def envs_file(self) -> EnvsFile: venv_path = self._poetry.config.virtualenvs_path - cwd = self._poetry.file.path.parent + return EnvsFile(venv_path / self.ENVS_FILE) - envs_file = TOMLFile(venv_path / self.ENVS_FILE) + @cached_property + def base_env_name(self) -> str: + return self.generate_env_name( + self._poetry.package.name, + str(self._poetry.file.path.parent), + ) + + def activate(self, python: str) -> Env: + venv_path = self._poetry.config.virtualenvs_path try: python_version = Version.parse(python) @@ -170,10 +218,9 @@ def activate(self, python: str) -> Env: return self.get(reload=True) envs = tomlkit.document() - base_env_name = self.generate_env_name(self._poetry.package.name, str(cwd)) - if envs_file.exists(): - envs = envs_file.read() - current_env = envs.get(base_env_name) + if self.envs_file.exists(): + envs = self.envs_file.read() + current_env = envs.get(self.base_env_name) if current_env is not None: current_minor = current_env["minor"] current_patch = current_env["patch"] @@ -182,7 +229,7 @@ def activate(self, python: str) -> Env: # We need to recreate create = True - name = f"{base_env_name}-py{minor}" + name = f"{self.base_env_name}-py{minor}" venv = venv_path / name # Create if needed @@ -202,29 +249,21 @@ def activate(self, python: str) -> Env: self.create_venv(executable=python_path, force=create) # Activate - envs[base_env_name] = {"minor": minor, "patch": patch} - envs_file.write(envs) + envs[self.base_env_name] = {"minor": minor, "patch": patch} + self.envs_file.write(envs) return self.get(reload=True) def deactivate(self) -> None: venv_path = self._poetry.config.virtualenvs_path - name = self.generate_env_name( - self._poetry.package.name, str(self._poetry.file.path.parent) - ) - envs_file = TOMLFile(venv_path / self.ENVS_FILE) - if envs_file.exists(): - envs = envs_file.read() - env = envs.get(name) - if env is not None: - venv = venv_path / f"{name}-py{env['minor']}" - self._io.write_error_line( - f"Deactivating virtualenv: {venv}" - ) - del envs[name] - - envs_file.write(envs) + if self.envs_file.exists() and ( + minor := self.envs_file.remove_section(self.base_env_name) + ): + venv = venv_path / f"{self.base_env_name}-py{minor}" + self._io.write_error_line( + f"Deactivating virtualenv: {venv}" + ) def get(self, reload: bool = False) -> Env: if self._env is not None and not reload: @@ -237,15 +276,10 @@ def get(self, reload: bool = False) -> Env: precision=2, prefer_active_python=prefer_active_python, io=self._io ).to_string() - venv_path = self._poetry.config.virtualenvs_path - - cwd = self._poetry.file.path.parent - envs_file = TOMLFile(venv_path / self.ENVS_FILE) env = None - base_env_name = self.generate_env_name(self._poetry.package.name, str(cwd)) - if envs_file.exists(): - envs = envs_file.read() - env = envs.get(base_env_name) + if self.envs_file.exists(): + envs = self.envs_file.read() + env = envs.get(self.base_env_name) if env: python_minor = env["minor"] @@ -272,7 +306,7 @@ def get(self, reload: bool = False) -> Env: venv_path = self._poetry.config.virtualenvs_path - name = f"{base_env_name}-py{python_minor.strip()}" + name = f"{self.base_env_name}-py{python_minor.strip()}" venv = venv_path / name @@ -313,12 +347,6 @@ def check_env_is_for_current_project(env: str, base_env_name: str) -> bool: return env.startswith(base_env_name) def remove(self, python: str) -> Env: - venv_path = self._poetry.config.virtualenvs_path - - cwd = self._poetry.file.path.parent - envs_file = TOMLFile(venv_path / self.ENVS_FILE) - base_env_name = self.generate_env_name(self._poetry.package.name, str(cwd)) - python_path = Path(python) if python_path.is_file(): # Validate env name if provided env is a full path to python @@ -327,34 +355,21 @@ def remove(self, python: str) -> Env: [python, "-c", GET_ENV_PATH_ONELINER], text=True ).strip("\n") env_name = Path(env_dir).name - if not self.check_env_is_for_current_project(env_name, base_env_name): + if not self.check_env_is_for_current_project( + env_name, self.base_env_name + ): raise IncorrectEnvError(env_name) except CalledProcessError as e: raise EnvCommandError(e) - if self.check_env_is_for_current_project(python, base_env_name): + if self.check_env_is_for_current_project(python, self.base_env_name): venvs = self.list() for venv in venvs: if venv.path.name == python: # Exact virtualenv name - if not envs_file.exists(): - self.remove_venv(venv.path) - - return venv - - venv_minor = ".".join(str(v) for v in venv.version_info[:2]) - base_env_name = self.generate_env_name(cwd.name, str(cwd)) - envs = envs_file.read() - - current_env = envs.get(base_env_name) - if not current_env: - self.remove_venv(venv.path) - - return venv - - if current_env["minor"] == venv_minor: - del envs[base_env_name] - envs_file.write(envs) + if self.envs_file.exists(): + venv_minor = ".".join(str(v) for v in venv.version_info[:2]) + self.envs_file.remove_section(self.base_env_name, venv_minor) self.remove_venv(venv.path) @@ -389,21 +404,14 @@ def remove(self, python: str) -> Env: python_version = Version.parse(python_version_string.strip()) minor = f"{python_version.major}.{python_version.minor}" - name = f"{base_env_name}-py{minor}" + name = f"{self.base_env_name}-py{minor}" venv_path = venv_path / name if not venv_path.exists(): raise ValueError(f'Environment "{name}" does not exist.') - if envs_file.exists(): - envs = envs_file.read() - current_env = envs.get(base_env_name) - if current_env is not None: - current_minor = current_env["minor"] - - if current_minor == minor: - del envs[base_env_name] - envs_file.write(envs) + if self.envs_file.exists(): + self.envs_file.remove_section(self.base_env_name, minor) self.remove_venv(venv_path) diff --git a/tests/console/commands/env/test_remove.py b/tests/console/commands/env/test_remove.py index 9586be90ea1..38998f92634 100644 --- a/tests/console/commands/env/test_remove.py +++ b/tests/console/commands/env/test_remove.py @@ -62,12 +62,32 @@ def test_remove_by_name( assert tester.io.fetch_output() == expected +@pytest.mark.parametrize( + "envs_file", [None, "empty", "self", "other", "self_and_other"] +) def test_remove_all( tester: CommandTester, venvs_in_cache_dirs: list[str], venv_name: str, venv_cache: Path, + envs_file: str | None, ) -> None: + envs_file_path = venv_cache / "envs.toml" + if envs_file == "empty": + envs_file_path.touch() + elif envs_file == "self": + envs_file_path.write_text(f'[{venv_name}]\nminor = "3.9"\npatch = "3.9.1"\n') + elif envs_file == "other": + envs_file_path.write_text('[other-abcdefgh]\nminor = "3.9"\npatch = "3.9.1"\n') + elif envs_file == "self_and_other": + envs_file_path.write_text( + f'[{venv_name}]\nminor = "3.9"\npatch = "3.9.1"\n' + '[other-abcdefgh]\nminor = "3.9"\npatch = "3.9.1"\n' + ) + else: + # no envs file -> nothing to prepare + assert envs_file is None + expected = {""} tester.execute("--all") for name in venvs_in_cache_dirs: @@ -75,6 +95,17 @@ def test_remove_all( expected.add(f"Deleted virtualenv: {venv_cache / name}") assert set(tester.io.fetch_output().split("\n")) == expected + if envs_file is not None: + assert envs_file_path.exists() + envs_file_content = envs_file_path.read_text() + assert venv_name not in envs_file_content + if "other" in envs_file: + assert "other-abcdefgh" in envs_file_content + else: + assert envs_file_content == "" + else: + assert not envs_file_path.exists() + def test_remove_all_and_version( tester: CommandTester, diff --git a/tests/utils/env/test_env_manager.py b/tests/utils/env/test_env_manager.py index 349333fc7de..995d99ab4e3 100644 --- a/tests/utils/env/test_env_manager.py +++ b/tests/utils/env/test_env_manager.py @@ -19,6 +19,7 @@ from poetry.utils.env import InvalidCurrentPythonVersionError from poetry.utils.env import NoCompatiblePythonVersionFound from poetry.utils.env import PythonVersionNotFound +from poetry.utils.env.env_manager import EnvsFile from poetry.utils.helpers import remove_directory @@ -84,6 +85,41 @@ def in_project_venv_dir(poetry: Poetry) -> Iterator[Path]: venv_dir.rmdir() +@pytest.mark.parametrize( + ("section", "version", "expected"), + [ + ("foo", None, "3.10"), + ("bar", None, "3.11"), + ("baz", None, "3.12"), + ("bar", "3.11", "3.11"), + ("bar", "3.10", None), + ], +) +def test_envs_file_remove_section( + tmp_path: Path, section: str, version: str | None, expected: str | None +) -> None: + envs_file_path = tmp_path / "envs.toml" + + envs_file = TOMLFile(envs_file_path) + doc = tomlkit.document() + doc["foo"] = {"minor": "3.10", "patch": "3.10.13"} + doc["bar"] = {"minor": "3.11", "patch": "3.11.7"} + doc["baz"] = {"minor": "3.12", "patch": "3.12.1"} + envs_file.write(doc) + + minor = EnvsFile(envs_file_path).remove_section(section, version) + + assert minor == expected + + envs = TOMLFile(envs_file_path).read() + if expected is None: + assert section in envs + else: + assert section not in envs + for other_section in {"foo", "bar", "baz"} - {section}: + assert other_section in envs + + def test_activate_in_project_venv_no_explicit_config( tmp_path: Path, manager: EnvManager,