Skip to content

Commit da0f383

Browse files
committed
env: delete entry in envs.toml when running poetry env remove --all
1 parent 69717d8 commit da0f383

File tree

4 files changed

+149
-70
lines changed

4 files changed

+149
-70
lines changed

src/poetry/console/commands/env/remove.py

+5
Original file line numberDiff line numberDiff line change
@@ -45,5 +45,10 @@ def handle(self) -> int:
4545
for venv in manager.list():
4646
manager.remove_venv(venv.path)
4747
self.line(f"Deleted virtualenv: <comment>{venv.path}</comment>")
48+
# Since we remove all the virtualenvs, we can also remove the entry
49+
# in the envs file. (Strictly speaking, we should do this explicitly,
50+
# in case it points to a virtualenv that had been removed manually before.)
51+
if manager.envs_file.exists():
52+
manager.envs_file.remove_section(manager.base_env_name)
4853

4954
return 0

src/poetry/utils/env/env_manager.py

+77-70
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import subprocess
1010
import sys
1111

12+
from functools import cached_property
1213
from pathlib import Path
1314
from subprocess import CalledProcessError
1415
from typing import TYPE_CHECKING
@@ -45,6 +46,44 @@
4546
from poetry.utils.env.base_env import Env
4647

4748

49+
class EnvsFile(TOMLFile):
50+
"""
51+
This file contains one section per project with the project's base env name
52+
as section name. Each section contains the minor and patch version of the
53+
python executable used to create the currently active virtualenv.
54+
55+
Example:
56+
57+
[poetry-QRErDmmj]
58+
minor = "3.9"
59+
patch = "3.9.13"
60+
61+
[poetry-core-m5r7DkRA]
62+
minor = "3.11"
63+
patch = "3.11.6"
64+
"""
65+
66+
def remove_section(self, name: str, minor: str | None = None) -> str | None:
67+
"""
68+
Remove a section from the envs file.
69+
70+
If "minor" is given, the section is only removed if its minor value
71+
matches "minor".
72+
73+
Returns the "minor" value of the removed section.
74+
"""
75+
envs = self.read()
76+
current_env = envs.get(name)
77+
if current_env is not None and (not minor or current_env["minor"] == minor):
78+
del envs[name]
79+
self.write(envs)
80+
minor = current_env["minor"]
81+
assert isinstance(minor, str)
82+
return minor
83+
84+
return None
85+
86+
4887
class EnvManager:
4988
"""
5089
Environments manager
@@ -121,11 +160,19 @@ def in_project_venv(self) -> Path:
121160
venv: Path = self._poetry.file.path.parent / ".venv"
122161
return venv
123162

163+
@cached_property
164+
def envs_file(self) -> EnvsFile:
165+
return EnvsFile(self._poetry.config.virtualenvs_path / self.ENVS_FILE)
166+
167+
@cached_property
168+
def base_env_name(self) -> str:
169+
return self.generate_env_name(
170+
self._poetry.package.name,
171+
str(self._poetry.file.path.parent),
172+
)
173+
124174
def activate(self, python: str) -> Env:
125175
venv_path = self._poetry.config.virtualenvs_path
126-
cwd = self._poetry.file.path.parent
127-
128-
envs_file = TOMLFile(venv_path / self.ENVS_FILE)
129176

130177
try:
131178
python_version = Version.parse(python)
@@ -170,10 +217,9 @@ def activate(self, python: str) -> Env:
170217
return self.get(reload=True)
171218

172219
envs = tomlkit.document()
173-
base_env_name = self.generate_env_name(self._poetry.package.name, str(cwd))
174-
if envs_file.exists():
175-
envs = envs_file.read()
176-
current_env = envs.get(base_env_name)
220+
if self.envs_file.exists():
221+
envs = self.envs_file.read()
222+
current_env = envs.get(self.base_env_name)
177223
if current_env is not None:
178224
current_minor = current_env["minor"]
179225
current_patch = current_env["patch"]
@@ -182,7 +228,7 @@ def activate(self, python: str) -> Env:
182228
# We need to recreate
183229
create = True
184230

185-
name = f"{base_env_name}-py{minor}"
231+
name = f"{self.base_env_name}-py{minor}"
186232
venv = venv_path / name
187233

188234
# Create if needed
@@ -202,29 +248,21 @@ def activate(self, python: str) -> Env:
202248
self.create_venv(executable=python_path, force=create)
203249

204250
# Activate
205-
envs[base_env_name] = {"minor": minor, "patch": patch}
206-
envs_file.write(envs)
251+
envs[self.base_env_name] = {"minor": minor, "patch": patch}
252+
self.envs_file.write(envs)
207253

208254
return self.get(reload=True)
209255

210256
def deactivate(self) -> None:
211257
venv_path = self._poetry.config.virtualenvs_path
212-
name = self.generate_env_name(
213-
self._poetry.package.name, str(self._poetry.file.path.parent)
214-
)
215258

216-
envs_file = TOMLFile(venv_path / self.ENVS_FILE)
217-
if envs_file.exists():
218-
envs = envs_file.read()
219-
env = envs.get(name)
220-
if env is not None:
221-
venv = venv_path / f"{name}-py{env['minor']}"
222-
self._io.write_error_line(
223-
f"Deactivating virtualenv: <comment>{venv}</comment>"
224-
)
225-
del envs[name]
226-
227-
envs_file.write(envs)
259+
if self.envs_file.exists() and (
260+
minor := self.envs_file.remove_section(self.base_env_name)
261+
):
262+
venv = venv_path / f"{self.base_env_name}-py{minor}"
263+
self._io.write_error_line(
264+
f"Deactivating virtualenv: <comment>{venv}</comment>"
265+
)
228266

229267
def get(self, reload: bool = False) -> Env:
230268
if self._env is not None and not reload:
@@ -237,15 +275,10 @@ def get(self, reload: bool = False) -> Env:
237275
precision=2, prefer_active_python=prefer_active_python, io=self._io
238276
).to_string()
239277

240-
venv_path = self._poetry.config.virtualenvs_path
241-
242-
cwd = self._poetry.file.path.parent
243-
envs_file = TOMLFile(venv_path / self.ENVS_FILE)
244278
env = None
245-
base_env_name = self.generate_env_name(self._poetry.package.name, str(cwd))
246-
if envs_file.exists():
247-
envs = envs_file.read()
248-
env = envs.get(base_env_name)
279+
if self.envs_file.exists():
280+
envs = self.envs_file.read()
281+
env = envs.get(self.base_env_name)
249282
if env:
250283
python_minor = env["minor"]
251284

@@ -272,7 +305,7 @@ def get(self, reload: bool = False) -> Env:
272305

273306
venv_path = self._poetry.config.virtualenvs_path
274307

275-
name = f"{base_env_name}-py{python_minor.strip()}"
308+
name = f"{self.base_env_name}-py{python_minor.strip()}"
276309

277310
venv = venv_path / name
278311

@@ -313,12 +346,6 @@ def check_env_is_for_current_project(env: str, base_env_name: str) -> bool:
313346
return env.startswith(base_env_name)
314347

315348
def remove(self, python: str) -> Env:
316-
venv_path = self._poetry.config.virtualenvs_path
317-
318-
cwd = self._poetry.file.path.parent
319-
envs_file = TOMLFile(venv_path / self.ENVS_FILE)
320-
base_env_name = self.generate_env_name(self._poetry.package.name, str(cwd))
321-
322349
python_path = Path(python)
323350
if python_path.is_file():
324351
# Validate env name if provided env is a full path to python
@@ -327,34 +354,21 @@ def remove(self, python: str) -> Env:
327354
[python, "-c", GET_ENV_PATH_ONELINER], text=True
328355
).strip("\n")
329356
env_name = Path(env_dir).name
330-
if not self.check_env_is_for_current_project(env_name, base_env_name):
357+
if not self.check_env_is_for_current_project(
358+
env_name, self.base_env_name
359+
):
331360
raise IncorrectEnvError(env_name)
332361
except CalledProcessError as e:
333362
raise EnvCommandError(e)
334363

335-
if self.check_env_is_for_current_project(python, base_env_name):
364+
if self.check_env_is_for_current_project(python, self.base_env_name):
336365
venvs = self.list()
337366
for venv in venvs:
338367
if venv.path.name == python:
339368
# Exact virtualenv name
340-
if not envs_file.exists():
341-
self.remove_venv(venv.path)
342-
343-
return venv
344-
345-
venv_minor = ".".join(str(v) for v in venv.version_info[:2])
346-
base_env_name = self.generate_env_name(cwd.name, str(cwd))
347-
envs = envs_file.read()
348-
349-
current_env = envs.get(base_env_name)
350-
if not current_env:
351-
self.remove_venv(venv.path)
352-
353-
return venv
354-
355-
if current_env["minor"] == venv_minor:
356-
del envs[base_env_name]
357-
envs_file.write(envs)
369+
if self.envs_file.exists():
370+
venv_minor = ".".join(str(v) for v in venv.version_info[:2])
371+
self.envs_file.remove_section(self.base_env_name, venv_minor)
358372

359373
self.remove_venv(venv.path)
360374

@@ -389,21 +403,14 @@ def remove(self, python: str) -> Env:
389403
python_version = Version.parse(python_version_string.strip())
390404
minor = f"{python_version.major}.{python_version.minor}"
391405

392-
name = f"{base_env_name}-py{minor}"
406+
name = f"{self.base_env_name}-py{minor}"
393407
venv_path = venv_path / name
394408

395409
if not venv_path.exists():
396410
raise ValueError(f'<warning>Environment "{name}" does not exist.</warning>')
397411

398-
if envs_file.exists():
399-
envs = envs_file.read()
400-
current_env = envs.get(base_env_name)
401-
if current_env is not None:
402-
current_minor = current_env["minor"]
403-
404-
if current_minor == minor:
405-
del envs[base_env_name]
406-
envs_file.write(envs)
412+
if self.envs_file.exists():
413+
self.envs_file.remove_section(self.base_env_name, minor)
407414

408415
self.remove_venv(venv_path)
409416

tests/console/commands/env/test_remove.py

+31
Original file line numberDiff line numberDiff line change
@@ -62,19 +62,50 @@ def test_remove_by_name(
6262
assert tester.io.fetch_output() == expected
6363

6464

65+
@pytest.mark.parametrize(
66+
"envs_file", [None, "empty", "self", "other", "self_and_other"]
67+
)
6568
def test_remove_all(
6669
tester: CommandTester,
6770
venvs_in_cache_dirs: list[str],
6871
venv_name: str,
6972
venv_cache: Path,
73+
envs_file: str | None,
7074
) -> None:
75+
envs_file_path = venv_cache / "envs.toml"
76+
if envs_file == "empty":
77+
envs_file_path.touch()
78+
elif envs_file == "self":
79+
envs_file_path.write_text(f'[{venv_name}]\nminor = "3.9"\npatch = "3.9.1"\n')
80+
elif envs_file == "other":
81+
envs_file_path.write_text('[other-abcdefgh]\nminor = "3.9"\npatch = "3.9.1"\n')
82+
elif envs_file == "self_and_other":
83+
envs_file_path.write_text(
84+
f'[{venv_name}]\nminor = "3.9"\npatch = "3.9.1"\n'
85+
'[other-abcdefgh]\nminor = "3.9"\npatch = "3.9.1"\n'
86+
)
87+
else:
88+
# no envs file -> nothing to prepare
89+
assert envs_file is None
90+
7191
expected = {""}
7292
tester.execute("--all")
7393
for name in venvs_in_cache_dirs:
7494
assert not (venv_cache / name).exists()
7595
expected.add(f"Deleted virtualenv: {venv_cache / name}")
7696
assert set(tester.io.fetch_output().split("\n")) == expected
7797

98+
if envs_file is not None:
99+
assert envs_file_path.exists()
100+
envs_file_content = envs_file_path.read_text()
101+
assert venv_name not in envs_file_content
102+
if "other" in envs_file:
103+
assert "other-abcdefgh" in envs_file_content
104+
else:
105+
assert envs_file_content == ""
106+
else:
107+
assert not envs_file_path.exists()
108+
78109

79110
def test_remove_all_and_version(
80111
tester: CommandTester,

tests/utils/env/test_env_manager.py

+36
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from poetry.utils.env import InvalidCurrentPythonVersionError
2020
from poetry.utils.env import NoCompatiblePythonVersionFound
2121
from poetry.utils.env import PythonVersionNotFound
22+
from poetry.utils.env.env_manager import EnvsFile
2223
from poetry.utils.helpers import remove_directory
2324

2425

@@ -84,6 +85,41 @@ def in_project_venv_dir(poetry: Poetry) -> Iterator[Path]:
8485
venv_dir.rmdir()
8586

8687

88+
@pytest.mark.parametrize(
89+
("section", "version", "expected"),
90+
[
91+
("foo", None, "3.10"),
92+
("bar", None, "3.11"),
93+
("baz", None, "3.12"),
94+
("bar", "3.11", "3.11"),
95+
("bar", "3.10", None),
96+
],
97+
)
98+
def test_envs_file_remove_section(
99+
tmp_path: Path, section: str, version: str | None, expected: str | None
100+
) -> None:
101+
envs_file_path = tmp_path / "envs.toml"
102+
103+
envs_file = TOMLFile(envs_file_path)
104+
doc = tomlkit.document()
105+
doc["foo"] = {"minor": "3.10", "patch": "3.10.13"}
106+
doc["bar"] = {"minor": "3.11", "patch": "3.11.7"}
107+
doc["baz"] = {"minor": "3.12", "patch": "3.12.1"}
108+
envs_file.write(doc)
109+
110+
minor = EnvsFile(envs_file_path).remove_section(section, version)
111+
112+
assert minor == expected
113+
114+
envs = TOMLFile(envs_file_path).read()
115+
if expected is None:
116+
assert section in envs
117+
else:
118+
assert section not in envs
119+
for other_section in {"foo", "bar", "baz"} - {section}:
120+
assert other_section in envs
121+
122+
87123
def test_activate_in_project_venv_no_explicit_config(
88124
tmp_path: Path,
89125
manager: EnvManager,

0 commit comments

Comments
 (0)