diff --git a/docs/configuration.md b/docs/configuration.md index 71e5d4e8b07..f1fac414dbe 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -307,6 +307,21 @@ values, usage instructions and warnings. Use parallel execution when using the new (`>=1.1.0`) installer. +### `installer.builtin-uninstall` + +**Type**: `boolean` + +**Default**: `false` + +**Environment Variable**: `POETRY_INSTALLER_BUILTIN_UNINSTALL` + +*Introduced in 2.5.0* + +If set to `true`, Poetry uninstalls packages using its own built-in routine instead of +invoking `pip uninstall` as a subprocess. This avoids the overhead of spawning pip. +Behavior is otherwise equivalent: confirmation is automatic, and files outside the +target environment's prefix are never removed. + ### `installer.build-config-settings.` **Type**: `Serialised JSON with string or list of string properties` diff --git a/src/poetry/config/config.py b/src/poetry/config/config.py index e2c0b4ac56f..d265450a882 100644 --- a/src/poetry/config/config.py +++ b/src/poetry/config/config.py @@ -174,6 +174,7 @@ class Config: "no-binary": None, "only-binary": None, "build-config-settings": {}, + "builtin-uninstall": False, }, "python": {"installation-dir": os.path.join("{data-dir}", "python")}, "solver": { @@ -396,6 +397,7 @@ def _get_normalizer(name: str | Sequence[str]) -> Callable[[str], Any]: "virtualenvs.use-poetry-python", "installer.re-resolve", "installer.parallel", + "installer.builtin-uninstall", "solver.lazy-wheel", "system-git-client", "keyring.enabled", diff --git a/src/poetry/console/commands/config.py b/src/poetry/console/commands/config.py index 6195b8b5f7d..deb501642b5 100644 --- a/src/poetry/console/commands/config.py +++ b/src/poetry/console/commands/config.py @@ -93,6 +93,7 @@ def unique_config_values(self) -> dict[str, tuple[Any, Any]]: "requests.max-retries": (lambda val: int(val) >= 0, int_normalizer), "installer.re-resolve": (boolean_validator, boolean_normalizer), "installer.parallel": (boolean_validator, boolean_normalizer), + "installer.builtin-uninstall": (boolean_validator, boolean_normalizer), "installer.max-workers": (lambda val: int(val) > 0, int_normalizer), "installer.no-binary": ( PackageFilterPolicy.validator, diff --git a/src/poetry/installation/executor.py b/src/poetry/installation/executor.py index af9cf784a1e..c163da19a23 100644 --- a/src/poetry/installation/executor.py +++ b/src/poetry/installation/executor.py @@ -21,6 +21,7 @@ from poetry.installation.operations import Install from poetry.installation.operations import Uninstall from poetry.installation.operations import Update +from poetry.installation.uninstaller import uninstall_distribution from poetry.installation.wheel_installer import WheelInstaller from poetry.puzzle.exceptions import SolverProblemError from poetry.utils._compat import decode @@ -78,6 +79,9 @@ def __init__( self._verbose = False self._wheel_installer = WheelInstaller(self._env) self._build_constraints = build_constraints or {} + self._use_builtin_uninstall: bool = config.get( + "installer.builtin-uninstall", False + ) if parallel is None: parallel = config.get("installer.parallel", True) @@ -605,11 +609,12 @@ def _install(self, operation: Install | Update) -> int: try: if operation.job_type == "update": - # Uninstall first - # TODO: Make an uninstaller and find a way to rollback in case - # the new package can't be installed + # Uninstall the old version first. If this fails, the install is + # aborted so the new version is not installed over the old one. assert isinstance(operation, Update) - self._remove(operation.initial_package) + result_code = self._remove(operation.initial_package) + if result_code != 0: + return result_code self._wheel_installer.install(archive) finally: @@ -628,6 +633,14 @@ def _remove(self, package: Package) -> int: if src_dir.exists(): remove_directory(src_dir, force=True) + if self._use_builtin_uninstall: + # The builtin uninstaller removes the files directly and raises if a + # file cannot be removed. uninstall_distribution returns None when + # there is nothing to uninstall (not installed, outside env, in + # stdlib, missing RECORD); treat that as a successful no-op. + uninstall_distribution(self._env, package.name) + return 0 + try: return self.run_pip("uninstall", package.name, "-y") except EnvCommandError as e: diff --git a/src/poetry/installation/uninstaller.py b/src/poetry/installation/uninstaller.py new file mode 100644 index 00000000000..f099b6d352a --- /dev/null +++ b/src/poetry/installation/uninstaller.py @@ -0,0 +1,346 @@ +"""Builtin package uninstaller. + +Entry point is the ``uninstall_distribution`` function. + +Adapted from pip's ``pip._internal.req.req_uninstall`` so Poetry can uninstall +packages without invoking ``pip uninstall`` as a subprocess. The module is +self-contained and does not import from pip. + +Most methods and classes are borrowed from pip with minimal adaptations. +The env-prefix and stdlib guards that pip applies in +``UninstallPathSet.from_dist`` have been moved to ``uninstall_distribution``, +along with all legacy-install branches (setuptools flat installs, +easy_install eggs, develop-egg links) being dropped entirely - Poetry should +only see modern ``.dist-info`` installs. + +Unlike pip, this uninstaller removes files directly instead of stashing them +for a possible rollback: it raises if a file cannot be removed (a file that is +already missing is fine) and removes the ``.dist-info`` directory last, so that +a failed, aborted uninstall can simply be triggered again - the ``RECORD`` file +inside ``.dist-info`` is what tells us which files to remove. + +Files are deleted one by one and the directories they leave empty are removed +afterwards (deepest first), stopping at the install-scheme roots (site-packages, +scripts, ...) which are never removed even when they end up empty. The +``.dist-info`` directory belongs solely to one distribution, so it is removed +whole with a single ``rmtree`` at the very end. + +ATTENTION: Do not convert os.path to pathlib lightly in this module! + pathlib is often slower and some path operations + are called for each file that has to be removed. +""" + +from __future__ import annotations + +import contextlib +import functools +import logging +import os + +from pathlib import Path +from typing import TYPE_CHECKING + +from poetry.utils.helpers import remove_directory + + +if TYPE_CHECKING: + from collections.abc import Iterable + from collections.abc import Iterator + from importlib import metadata + + from poetry.utils.env import Env + + +logger = logging.getLogger(__name__) + + +def _normalize_path(path: str | Path, resolve_symlinks: bool = True) -> str: + path = os.path.expanduser(path) + path = os.path.realpath(path) if resolve_symlinks else os.path.abspath(path) + return os.path.normcase(path) + + +def _safe_listdir(path: str) -> tuple[str, ...]: + """Return directory entries (empty if it is missing or not a directory).""" + try: + return tuple(os.listdir(path)) + except OSError: + return () + + +class UninstallPathSet: + """A set of file paths to be removed when uninstalling a distribution.""" + + def __init__( + self, + dist: metadata.Distribution, + env_path: Path, + protected_dirs: set[str] | None = None, + ) -> None: + self._paths: set[str] = set() + self._refuse: set[str] = set() + # Read identifying metadata eagerly so log messages still work after + # remove() deletes the .dist-info directory. + self._dist_name = dist.name + self._dist_version = dist.metadata["Version"] + # Append os.sep so the startswith() check in _permitted() does not + # spuriously match a sibling directory whose name starts with env_path + # (e.g. env_path="/tmp/.venv" would otherwise match "/tmp/.venv-other"). + self._env_prefix = _normalize_path(env_path) + os.sep + # Create local cache of normalize_path results. Creating an UninstallPathSet + # can result in hundreds/thousands of redundant calls to normalize_path with + # the same args, which hurts performance. + self._normalize_path_cached = functools.lru_cache(maxsize=None)(_normalize_path) + # Cache __pycache__ listings so a directory is scanned only once even + # though add() is called for every .py file it contains. This is safe + # because we are robust against files that may have been deleted + # in the meantime. + self._listdir_cached = functools.lru_cache(maxsize=None)(_safe_listdir) + # Normalized path of the .dist-info directory, so remove() can delete it + # last (see remove() for why). + dist_info_path: Path = dist._path # type: ignore[attr-defined] + self._dist_info = self._normalize_path_cached(str(dist_info_path)) + # Directories that must never be removed even when they end up empty. + # Always protect the directory holding the .dist-info (the site-packages + # dir); callers add the remaining install-scheme roots (scripts, data, + # ...). Values are expected already normalized, like self._dist_info. + self._protected: set[str] = {os.path.dirname(self._dist_info)} + self._protected.update(protected_dirs or ()) + + @property + def paths(self) -> set[str]: + return self._paths + + @property + def refused(self) -> set[str]: + return self._refuse + + def _permitted(self, path: str) -> bool: + """Return True if ``path`` is inside the env prefix.""" + return path.startswith(self._env_prefix) + + def add(self, path: str | Path) -> None: + head, tail = os.path.split(path) + + # We normalize the head to resolve parent directory symlinks, but not + # the tail, since we only want to uninstall symlinks, not their targets. + norm_path = os.path.join( + self._normalize_path_cached(head), os.path.normcase(tail) + ) + + if not os.path.exists(norm_path): + return + if self._permitted(norm_path): + self._paths.add(norm_path) + else: + self._refuse.add(norm_path) + + # ``__pycache__`` bytecode may not be listed in RECORD (it is often + # compiled lazily on first import). Discover it for every source file. + if os.path.splitext(norm_path)[1] == ".py": + for pyc in self._pycache_files(norm_path): + self.add(pyc) + + def _pycache_files(self, py_path: str) -> Iterator[str]: + """Yield bytecode cache files for a source ``.py`` file. + + Bytecode lives in a sibling ``__pycache__`` directory named + ``..pyc`` (PEP 3147), where ```` identifies the Python + version/implementation that compiled it. The target environment may run + a different Python version than the interpreter executing Poetry, so we + match bytecode compiled for *any* version - not just the current + interpreter's ``cache_from_source`` tag. + + The directory listing is memoized (``_listdir_cached``) so that a + ``__pycache__`` is scanned only once even when many ``.py`` files in the + same directory each trigger this lookup. + """ + head, tail = os.path.split(py_path) + pycache = os.path.join(head, "__pycache__") + # ``tail`` is already normcased by the caller; match case-insensitively + # against the on-disk names for Windows. + prefix = os.path.normcase(tail[: -len(".py")]) + "." + for name in self._listdir_cached(pycache): + norm_name = os.path.normcase(name) + if norm_name.startswith(prefix): + yield os.path.join(pycache, name) + + def remove(self) -> None: + """Remove every path, deleting the .dist-info directory last. + + The package's files are removed one by one and the directories they + leave empty are pruned (deepest first), stopping at the protected + install-scheme roots. The .dist-info directory is then removed whole in a + single rmtree. A path that cannot be removed raises; a path that is + already missing is ignored. The .dist-info directory is removed last so + that, if removal fails partway through, the uninstall can simply be + triggered again - its RECORD is what tells us which files to remove. + """ + if not self._paths: + logger.warning( + "Cannot uninstall '%s'. No files were found to uninstall.", + self._dist_name, + ) + return + + logger.debug("Uninstalling %s %s.", self._dist_name, self._dist_version) + + parents: set[str] = set() + deferred_dirs: list[str] = [] + # Remove the package's files first, so the .dist-info directory (and its + # RECORD) survives a partial failure and the uninstall stays re-runnable. + package_paths = {p for p in self._paths if not self._is_dist_info(p)} + self._remove_files(sorted(package_paths), parents, deferred_dirs) + + # Remove any directory entries RECORD listed explicitly, deepest + # first; a non-empty one is left in place. Actually, this should + # not be possible, but it should not hurt for robustness. + for path in sorted(deferred_dirs, key=len, reverse=True): + with contextlib.suppress(OSError): + os.rmdir(path) + parents.add(os.path.dirname(path)) + + # Remove the directories our files left empty. + self._prune_empty_dirs(parents) + + # Finally remove the whole .dist-info directory in one call. It belongs + # solely to this distribution, so nothing else lives there; this also + # clears files an installer added without listing them in RECORD + # (INSTALLER, REQUESTED, direct_url.json, licenses/, ...). Done last so a + # failure above leaves RECORD intact for a re-run. The _permitted guard + # keeps the "never remove anything outside the env prefix" invariant. + # force=True so removal tolerates entries that vanish mid-walk (already + # gone is fine) and read-only files, while still raising on real errors. + if self._permitted(self._dist_info): + remove_directory(Path(self._dist_info), force=True) + + logger.debug( + "Successfully uninstalled %s %s", self._dist_name, self._dist_version + ) + + def _is_dist_info(self, path: str) -> bool: + """Return True if ``path`` is the .dist-info directory or inside it.""" + norm = os.path.normcase(path.rstrip(os.sep)) + return norm == self._dist_info or norm.startswith(self._dist_info + os.sep) + + @staticmethod + def _remove_files( + paths: Iterable[str], parents: set[str], deferred_dirs: list[str] + ) -> None: + """Remove each file, recording the parent directory for later pruning. + + An already-missing path is ignored; a real directory (should not happen) + is deferred for ``rmdir``; any other failure propagates so the + caller can abort the operation. ``os.remove`` on a symlink-to-directory + unlinks the link, not its target. + """ + for path in paths: + logger.debug("Removing file %s", path) + try: + os.remove(path) + except FileNotFoundError: + pass # already gone - still prune its parent below + except IsADirectoryError: + # should not happen under normal circumstances + deferred_dirs.append(path) + continue + except PermissionError: + # Windows raises a PermissionError instead of a IsDirectoryError. + if os.path.isdir(path): + # should not happen, just in case + deferred_dirs.append(path) + continue + raise + parents.add(os.path.dirname(path)) + + def _prune_empty_dirs(self, dirs: set[str]) -> None: + """Remove directories left empty, deepest first, climbing upward. + + Stops at a protected install-scheme directory and never climbs above the + environment prefix, so site-packages / scripts are never removed even + when they end up empty. ``dirs`` are normalized (parents of normalized + ``_paths``), so they compare directly against ``_env_prefix`` and + ``_protected``. + """ + for d in sorted(dirs, key=len, reverse=True): + while d.startswith(self._env_prefix) and d not in self._protected: + try: + os.rmdir(d) + except OSError: + break # not empty (or already gone) - stop this chain + d = os.path.dirname(d) + + +def uninstall_distribution(env: Env, package_name: str) -> UninstallPathSet | None: + """Uninstall ``package_name`` from ``env``. + + Removes the package's files immediately (the .dist-info directory last). + Raises if a file cannot be removed. Returns the pathset (useful for + inspection/logging), or ``None`` if there was nothing to uninstall (package + not installed, located outside the env, in the stdlib, or missing RECORD). + """ + dist = next(iter(env.site_packages.distributions(name=package_name)), None) + + if dist is None: + logger.warning("Skipping %s as it is not installed.", package_name) + return None + + logger.debug("Found existing installation: %s", package_name) + + dist_info_path: Path = dist._path # type: ignore[attr-defined] + dist_parent = dist_info_path.parent + + # Normalize through _normalize_path (realpath + normcase) so these guards + # resolve symlinks and case exactly the way UninstallPathSet._permitted() + # does - otherwise a symlinked venv prefix could pass one check but fail the + # other. + norm_dist_parent = _normalize_path(dist_parent) + norm_env_prefix = _normalize_path(env.path) + + # Append os.sep so a sibling directory whose name merely starts with the env + # prefix (e.g. "/tmp/.venv-other" vs "/tmp/.venv") is not treated as inside. + if not ( + norm_dist_parent == norm_env_prefix + or norm_dist_parent.startswith(norm_env_prefix + os.sep) + ): + logger.error( + "Not uninstalling %s at %s, outside environment %s", + dist.name, + dist_parent, + env.path, + ) + return None + + stdlib_paths = { + _normalize_path(p) + for p in {env.paths.get("stdlib"), env.paths.get("platstdlib")} + if p + } + if norm_dist_parent in stdlib_paths: + logger.error( + "Not uninstalling %s at %s, as it is in the standard library.", + dist.name, + dist_parent, + ) + return None + + dist_files = dist.files + if dist_files is None: + logger.error( + "Cannot uninstall %s: RECORD file is missing or unreadable.", + dist.name, + ) + return None + + # Protect the env's install-scheme roots (site-packages, scripts, data, ...) + # so the empty-directory pruning never removes them, even when emptied. + protected_dirs = {_normalize_path(p) for p in env.paths.values() if p} + protected_dirs.add(_normalize_path(env.path)) + + path_set = UninstallPathSet(dist, env.path, protected_dirs=protected_dirs) + for entry in dist_files: + path_set.add(os.path.join(dist_parent, str(entry))) + + path_set.remove() + + return path_set diff --git a/tests/console/commands/test_config.py b/tests/console/commands/test_config.py index a6537482058..24df8215d02 100644 --- a/tests/console/commands/test_config.py +++ b/tests/console/commands/test_config.py @@ -66,6 +66,7 @@ def test_list_displays_default_value_if_not_set( venv_path = json.dumps(os.path.join("{cache-dir}", "virtualenvs")) expected = f"""cache-dir = {cache_dir} data-dir = {data_dir} +installer.builtin-uninstall = false installer.max-workers = null installer.no-binary = null installer.only-binary = null @@ -104,6 +105,7 @@ def test_list_displays_set_get_setting( venv_path = json.dumps(os.path.join("{cache-dir}", "virtualenvs")) expected = f"""cache-dir = {cache_dir} data-dir = {data_dir} +installer.builtin-uninstall = false installer.max-workers = null installer.no-binary = null installer.only-binary = null @@ -163,6 +165,7 @@ def test_unset_setting( venv_path = json.dumps(os.path.join("{cache-dir}", "virtualenvs")) expected = f"""cache-dir = {cache_dir} data-dir = {data_dir} +installer.builtin-uninstall = false installer.max-workers = null installer.no-binary = null installer.only-binary = null @@ -200,6 +203,7 @@ def test_unset_repo_setting( venv_path = json.dumps(os.path.join("{cache-dir}", "virtualenvs")) expected = f"""cache-dir = {cache_dir} data-dir = {data_dir} +installer.builtin-uninstall = false installer.max-workers = null installer.no-binary = null installer.only-binary = null @@ -384,6 +388,7 @@ def test_list_displays_set_get_local_setting( venv_path = json.dumps(os.path.join("{cache-dir}", "virtualenvs")) expected = f"""cache-dir = {cache_dir} data-dir = {data_dir} +installer.builtin-uninstall = false installer.max-workers = null installer.no-binary = null installer.only-binary = null @@ -430,6 +435,7 @@ def test_list_must_not_display_sources_from_pyproject_toml( venv_path = json.dumps(os.path.join("{cache-dir}", "virtualenvs")) expected = f"""cache-dir = {cache_dir} data-dir = {data_dir} +installer.builtin-uninstall = false installer.max-workers = null installer.no-binary = null installer.only-binary = null diff --git a/tests/installation/test_executor.py b/tests/installation/test_executor.py index 08aa1bf294b..b7d6fc09a42 100644 --- a/tests/installation/test_executor.py +++ b/tests/installation/test_executor.py @@ -1960,3 +1960,272 @@ def test_executor_no_supported_hash_types( assert return_code == 1, f"\noutput: {output}\nerror: {error}\n" assert "No usable hash type(s) for demo" in output assert "hash_blah:1234567890abcdefghijklmnopqrstyzwxyz" in output + + +def test_executor_remove_uses_run_pip_by_default( + config: Config, + pool: RepositoryPool, + io: BufferedIO, + env: MockEnv, +) -> None: + executor = Executor(env, pool, config, io) + package = Package("attrs", "17.4.0") + + assert executor._remove(package) == 0 + assert len(env.executed) == 1 + assert env.executed[0][-3:] == ["uninstall", "attrs", "-y"] + + +def test_executor_remove_uses_builtin_when_enabled( + config: Config, + pool: RepositoryPool, + io: BufferedIO, + env: MockEnv, + mocker: MockerFixture, +) -> None: + config.merge({"installer": {"builtin-uninstall": True}}) + fake_pathset = mocker.MagicMock() + uninstall_mock = mocker.patch( + "poetry.installation.executor.uninstall_distribution", + return_value=fake_pathset, + ) + + executor = Executor(env, pool, config, io) + package = Package("attrs", "17.4.0") + + assert executor._remove(package) == 0 + assert env.executed == [] + uninstall_mock.assert_called_once_with(env, "attrs") + + +def test_executor_remove_builtin_skips_when_not_installed( + config: Config, + pool: RepositoryPool, + io: BufferedIO, + env: MockEnv, + mocker: MockerFixture, +) -> None: + config.merge({"installer": {"builtin-uninstall": True}}) + mocker.patch( + "poetry.installation.executor.uninstall_distribution", + return_value=None, + ) + + executor = Executor(env, pool, config, io) + package = Package("ghost", "1.0.0") + + assert executor._remove(package) == 0 + assert env.executed == [] + + +def test_executor_remove_builtin_end_to_end_removes_files( + config: Config, + pool: RepositoryPool, + io: BufferedIO, + env: MockEnv, +) -> None: + # End-to-end: with the builtin uninstaller enabled, Executor._remove must + # actually delete an installed distribution's files from disk. Unlike the + # wiring tests above, uninstall_distribution is NOT mocked here. + config.merge({"installer": {"builtin-uninstall": True}}) + + purelib = Path(env.paths["purelib"]) + purelib.mkdir(parents=True, exist_ok=True) + + pkg_dir = purelib / "demo" + pkg_dir.mkdir() + init_py = pkg_dir / "__init__.py" + init_py.write_text("# demo\n", encoding="utf-8") + + dist_info = purelib / "demo-1.0.0.dist-info" + dist_info.mkdir() + (dist_info / "METADATA").write_text( + "Metadata-Version: 2.1\nName: demo\nVersion: 1.0.0\n", encoding="utf-8" + ) + with (dist_info / "RECORD").open("w", newline="", encoding="utf-8") as fh: + writer = csv.writer(fh) + writer.writerow(("demo/__init__.py", "", "")) + writer.writerow(("demo-1.0.0.dist-info/METADATA", "", "")) + writer.writerow(("demo-1.0.0.dist-info/RECORD", "", "")) + + executor = Executor(env, pool, config, io) + + assert executor._remove(Package("demo", "1.0.0")) == 0 + # No pip subprocess was spawned ... + assert env.executed == [] + # ... and the distribution's files (and .dist-info) are gone from disk. + assert not init_py.exists() + assert not pkg_dir.exists() + assert not dist_info.exists() + + +def test_uninstall_builtin_failure_propagates( + config: Config, + pool: RepositoryPool, + io: BufferedIO, + env: MockEnv, + mocker: MockerFixture, +) -> None: + # The builtin uninstaller raises when a file cannot be removed. On a + # standalone uninstall operation that error must propagate (so the operation + # is reported as Failed) rather than being swallowed into a success. + config.merge({"installer": {"builtin-uninstall": True}}) + mocker.patch( + "poetry.installation.executor.uninstall_distribution", + side_effect=OSError("cannot remove file"), + ) + + executor = Executor(env, pool, config, io) + operation = Uninstall(Package("demo", "1.0.0")) + + with pytest.raises(OSError, match="cannot remove file"): + executor._do_execute_operation(operation) + + # No pip subprocess is involved on the builtin path. + assert env.executed == [] + + +def _make_update_op(tmp_path: Path) -> Update: + initial = Package( + "demo", + "1.0.0", + source_type="file", + source_url=str(tmp_path / "demo-1.0.0-py3-none-any.whl"), + ) + target = Package( + "demo", + "2.0.0", + source_type="file", + source_url=str(tmp_path / "demo-2.0.0-py3-none-any.whl"), + ) + return Update(initial, target) + + +def test_update_uninstalls_then_installs_when_uninstall_succeeds( + config: Config, + pool: RepositoryPool, + io: BufferedIO, + env: MockEnv, + mocker: MockerFixture, + tmp_path: Path, +) -> None: + config.merge({"installer": {"builtin-uninstall": True}}) + uninstall_mock = mocker.patch( + "poetry.installation.executor.uninstall_distribution", + return_value=mocker.Mock(), + ) + wheel_install = mocker.patch.object(WheelInstaller, "install") + fake_archive = tmp_path / "demo-2.0.0-py3-none-any.whl" + fake_archive.write_bytes(b"") + mocker.patch.object(Executor, "_prepare_archive", return_value=fake_archive) + + executor = Executor(env, pool, config, io) + assert executor._install(_make_update_op(tmp_path)) == 0 + + # The old version is uninstalled and the new version is then installed. + uninstall_mock.assert_called_once_with(env, "demo") + wheel_install.assert_called_once() + + +def test_update_continues_when_builtin_uninstall_returns_none( + config: Config, + pool: RepositoryPool, + io: BufferedIO, + env: MockEnv, + mocker: MockerFixture, + tmp_path: Path, +) -> None: + config.merge({"installer": {"builtin-uninstall": True}}) + uninstall_mock = mocker.patch( + "poetry.installation.executor.uninstall_distribution", + return_value=None, + ) + wheel_install = mocker.patch.object(WheelInstaller, "install") + fake_archive = tmp_path / "demo-2.0.0-py3-none-any.whl" + fake_archive.write_bytes(b"") + mocker.patch.object(Executor, "_prepare_archive", return_value=fake_archive) + + executor = Executor(env, pool, config, io) + assert executor._install(_make_update_op(tmp_path)) == 0 + + # A no-op builtin uninstall must not block installing the new wheel. + uninstall_mock.assert_called_once_with(env, "demo") + wheel_install.assert_called_once() + assert env.executed == [] + + +def test_update_aborts_install_when_builtin_uninstall_fails( + config: Config, + pool: RepositoryPool, + io: BufferedIO, + env: MockEnv, + mocker: MockerFixture, + tmp_path: Path, +) -> None: + # The builtin uninstaller raises when a file cannot be removed. The update + # must be aborted so the new version is not installed over the old one. + config.merge({"installer": {"builtin-uninstall": True}}) + mocker.patch( + "poetry.installation.executor.uninstall_distribution", + side_effect=OSError("cannot remove file"), + ) + wheel_install = mocker.patch.object(WheelInstaller, "install") + fake_archive = tmp_path / "demo-2.0.0-py3-none-any.whl" + fake_archive.write_bytes(b"") + mocker.patch.object(Executor, "_prepare_archive", return_value=fake_archive) + + executor = Executor(env, pool, config, io) + with pytest.raises(OSError, match="cannot remove file"): + executor._install(_make_update_op(tmp_path)) + + # The new version must not be installed over the old one. + wheel_install.assert_not_called() + + +def test_update_propagates_pip_uninstall_interrupt( + config: Config, + pool: RepositoryPool, + io: BufferedIO, + env: MockEnv, + mocker: MockerFixture, + tmp_path: Path, +) -> None: + # pip path; pip uninstall is interrupted (-2). _install must surface + # that code without attempting the subsequent wheel install. + mocker.patch.object(Executor, "run_pip", return_value=-2) + wheel_install = mocker.patch.object(WheelInstaller, "install") + fake_archive = tmp_path / "demo-2.0.0-py3-none-any.whl" + fake_archive.write_bytes(b"") + mocker.patch.object(Executor, "_prepare_archive", return_value=fake_archive) + + executor = Executor(env, pool, config, io) + assert executor._install(_make_update_op(tmp_path)) == -2 + wheel_install.assert_not_called() + + +def test_update_install_failure_propagates_with_pip_path( + config: Config, + pool: RepositoryPool, + io: BufferedIO, + env: MockEnv, + mocker: MockerFixture, + tmp_path: Path, +) -> None: + # Default config: builtin-uninstall is False, so the pip path is used and the + # builtin uninstaller is not involved. An install failure must still propagate. + uninstall_mock = mocker.patch("poetry.installation.executor.uninstall_distribution") + mocker.patch.object( + WheelInstaller, "install", side_effect=RuntimeError("install blew up") + ) + fake_archive = tmp_path / "demo-2.0.0-py3-none-any.whl" + fake_archive.write_bytes(b"") + mocker.patch.object(Executor, "_prepare_archive", return_value=fake_archive) + + executor = Executor(env, pool, config, io) + with pytest.raises(RuntimeError, match="install blew up"): + executor._install(_make_update_op(tmp_path)) + + # builtin uninstaller must not be involved on the pip path + uninstall_mock.assert_not_called() + # pip uninstall was invoked for the initial version + assert any(cmd[-3:] == ["uninstall", "demo", "-y"] for cmd in env.executed) diff --git a/tests/installation/test_uninstaller.py b/tests/installation/test_uninstaller.py new file mode 100644 index 00000000000..f4e60410c72 --- /dev/null +++ b/tests/installation/test_uninstaller.py @@ -0,0 +1,607 @@ +from __future__ import annotations + +import csv +import os +import platform +import shutil + +from importlib.util import cache_from_source +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +from poetry.installation.uninstaller import UninstallPathSet +from poetry.installation.uninstaller import _normalize_path +from poetry.installation.uninstaller import uninstall_distribution +from poetry.utils._compat import WINDOWS +from poetry.utils.env import MockEnv + + +if TYPE_CHECKING: + from pytest_mock import MockerFixture + + +def _make_env(tmp_path: Path) -> MockEnv: + env_path = tmp_path / "env" + env_path.mkdir() + purelib = env_path / "purelib" + purelib.mkdir() + scripts = env_path / "scripts" + scripts.mkdir() + env = MockEnv(path=env_path, is_venv=True, sys_path=[str(purelib)]) + env.paths["purelib"] = str(purelib) + env.paths["platlib"] = str(purelib) + env.paths["scripts"] = str(scripts) + env.set_paths() + return env + + +def _install_fake_distribution( + env: MockEnv, + name: str = "demo", + version: str = "1.0.0", + *, + with_script: bool = True, + extra_files: list[tuple[str, str]] | None = None, + extra_symlinked_dirs: list[str] | None = None, +) -> tuple[Path, list[Path]]: + """Create a fake installed distribution under env's purelib. + + Returns ``(dist_info_dir, installed_paths)``. + """ + purelib = Path(env.paths["purelib"]) + scripts = Path(env.paths["scripts"]) + + pkg_dir = purelib / name + pkg_dir.mkdir() + init_py = pkg_dir / "__init__.py" + init_py.write_text(f"# {name}\n", encoding="utf-8") + module_py = pkg_dir / "module.py" + module_py.write_text("x = 1\n", encoding="utf-8") + + dist_info = purelib / f"{name}-{version}.dist-info" + dist_info.mkdir() + (dist_info / "METADATA").write_text( + f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n", encoding="utf-8" + ) + (dist_info / "INSTALLER").write_text("Poetry\n", encoding="utf-8") + + installed: list[Path] = [init_py, module_py] + record_rows = [ + (f"{name}/__init__.py", "", ""), + (f"{name}/module.py", "", ""), + (f"{name}-{version}.dist-info/METADATA", "", ""), + (f"{name}-{version}.dist-info/INSTALLER", "", ""), + ] + + if with_script: + # create another file to ensure the directory is not empty (more realistic) + (scripts / "python").touch() + script_path = scripts / f"{name}-cli" + script_path.write_text("#!/usr/bin/env python\nprint('hi')\n", encoding="utf-8") + installed.append(script_path) + entry_points = dist_info / "entry_points.txt" + entry_points.write_text( + f"[console_scripts]\n{name}-cli = {name}.module:main\n", encoding="utf-8" + ) + record_rows.append((f"{name}-{version}.dist-info/entry_points.txt", "", "")) + record_rows.append((f"../scripts/{name}-cli", "", "")) + + if extra_files: + for relpath, content in extra_files: + target = purelib / relpath + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content, encoding="utf-8") + installed.append(target) + record_rows.append((relpath, "", "")) + + if extra_symlinked_dirs: + link_target = purelib / f"{name}-symlink-target" + link_target.mkdir(exist_ok=True) + for relpath in extra_symlinked_dirs: + link = purelib / relpath + link.parent.mkdir(parents=True, exist_ok=True) + try: + link.symlink_to(link_target, target_is_directory=True) + except OSError: + if WINDOWS: + pytest.skip( + "Symlink creation requires privileges or developer mode" + " on Windows." + ) + raise + installed.append(link) + record_rows.append((relpath, "", "")) + + record_path = dist_info / "RECORD" + with record_path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.writer(fh) + for row in record_rows: + writer.writerow(row) + writer.writerow((f"{name}-{version}.dist-info/RECORD", "", "")) + + installed.append(dist_info) + return dist_info, installed + + +def test_uninstall_distribution_removes_files(tmp_path: Path) -> None: + env = _make_env(tmp_path) + dist_info, installed = _install_fake_distribution(env) + + pathset = uninstall_distribution(env, "demo") + assert pathset is not None + + for path in installed: + assert not path.exists(), f"{path} should have been removed" + assert not dist_info.exists() + + +def test_uninstall_distribution_removes_entire_dist_info(tmp_path: Path) -> None: + # The .dist-info directory belongs solely to one distribution, so it is + # removed whole - including files an installer added without listing them in + # RECORD (e.g. direct_url.json), which the per-file removal would leave + # behind (orphaning the .dist-info directory). + env = _make_env(tmp_path) + dist_info, installed = _install_fake_distribution(env, with_script=False) + unlisted = dist_info / "direct_url.json" + unlisted.write_text("{}", encoding="utf-8") + + pathset = uninstall_distribution(env, "demo") + assert pathset is not None + + # The unlisted file is not discovered for removal ... + assert not any("direct_url.json" in p for p in pathset.paths) + # ... but the whole .dist-info directory is gone anyway. + assert not unlisted.exists() + assert not dist_info.exists() + for path in installed: + assert not path.exists(), f"{path} should have been removed" + + +def test_uninstall_distribution_tolerates_already_removed_dist_info( + tmp_path: Path, +) -> None: + # If the .dist-info directory has vanished by the time it is removed (e.g. a + # concurrent removal, or an entry disappearing mid-walk), the uninstall must + # complete without error rather than leaving things half-done. + env = _make_env(tmp_path) + dist_info, installed = _install_fake_distribution(env, with_script=False) + + dist = next(iter(env.site_packages.distributions(name="demo"))) + dist_parent = dist._path.parent # type: ignore[attr-defined] + protected = {_normalize_path(p) for p in env.paths.values() if p} + protected.add(_normalize_path(env.path)) + pathset = UninstallPathSet(dist, env.path, protected_dirs=protected) + assert dist.files + for entry in dist.files: + pathset.add(os.path.join(dist_parent, entry)) + + # Drop the .dist-info directory out from under remove(). + shutil.rmtree(dist_info) + + pathset.remove() # must not raise + + for path in installed: + assert not path.exists(), f"{path} should have been removed" + + +def test_uninstall_distribution_returns_none_when_missing( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + env = _make_env(tmp_path) + pathset = uninstall_distribution(env, "ghost") + assert pathset is None + assert any("ghost" in record.message for record in caplog.records) + + +def test_uninstall_distribution_refuses_dist_outside_env( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + # Build a normal env, then point purelib at a location outside env.path so + # the discovered dist fails the env-prefix guard in uninstall_distribution. + env = _make_env(tmp_path) + elsewhere = tmp_path / "elsewhere" / "site-packages" + elsewhere.mkdir(parents=True) + env.paths["purelib"] = str(elsewhere) + env.paths["platlib"] = str(elsewhere) + + dist_info, installed = _install_fake_distribution(env, with_script=False) + + pathset = uninstall_distribution(env, "demo") + + assert pathset is None + assert dist_info.exists() + for path in installed: + assert path.exists() + assert any("outside environment" in r.message for r in caplog.records) + + +def test_uninstall_distribution_refuses_dist_in_stdlib( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + env = _make_env(tmp_path) + dist_info, installed = _install_fake_distribution(env, with_script=False) + # Make the stdlib guard trip by claiming purelib IS the stdlib. + env.paths["stdlib"] = env.paths["purelib"] + + pathset = uninstall_distribution(env, "demo") + + assert pathset is None + assert dist_info.exists() + for path in installed: + assert path.exists() + assert any("standard library" in r.message for r in caplog.records) + + +def test_uninstall_distribution_refuses_dist_without_record( + tmp_path: Path, caplog: pytest.LogCaptureFixture +) -> None: + env = _make_env(tmp_path) + dist_info, installed = _install_fake_distribution(env, with_script=False) + # Remove RECORD so importlib.metadata reports dist.files as None. + (dist_info / "RECORD").unlink() + + pathset = uninstall_distribution(env, "demo") + + assert pathset is None + assert dist_info.exists() + for path in installed: + assert path.exists() + assert any("RECORD file is missing" in r.message for r in caplog.records) + + +def test_uninstall_distribution_does_not_match_prefix_sibling( + tmp_path: Path, +) -> None: + # env.path = "/env"; a sibling directory "/env-sibling/..." + # must not be treated as inside the env just because its absolute path + # startswith env.path. Exercises the os.sep guard in _permitted(). + env = _make_env(tmp_path) + _install_fake_distribution(env, with_script=False) + + # Create a sibling directory next to env.path that shares a name prefix. + sibling_root = env.path.with_name(env.path.name + "-sibling") + sibling_root.mkdir() + stray = sibling_root / "stray.py" + stray.write_text("# outside, but env.path is a prefix string\n", encoding="utf-8") + + dist = next(iter(env.site_packages.distributions(name="demo"))) + pathset = UninstallPathSet(dist, env.path) + pathset.add(str(stray)) + + # The sibling-path must be refused, not added for removal. + assert not any(str(stray) in p for p in pathset.paths) + assert pathset.refused == {_normalize_path(stray)} + + +def test_uninstall_distribution_collects_console_script(tmp_path: Path) -> None: + env = _make_env(tmp_path) + _install_fake_distribution(env, with_script=True) + scripts_dir = Path(env.paths["scripts"]) + demo_script = scripts_dir / "demo-cli" + assert demo_script.exists() + + pathset = uninstall_distribution(env, "demo") + assert pathset is not None + + assert any(p.startswith(_normalize_path(scripts_dir)) for p in pathset.paths) + + assert scripts_dir.exists() + assert not demo_script.exists() + + +@pytest.mark.skipif( + WINDOWS or platform.system() == "FreeBSD", + reason="chmod does not prevent deletion on Windows and FreeBSD", +) +def test_uninstall_distribution_removes_dist_info_last_and_is_rerunnable( + tmp_path: Path, +) -> None: + # If a file cannot be removed, the uninstall raises and the .dist-info + # directory (with its RECORD) is left intact so the user can re-trigger the + # uninstall. Make module.py unremovable by making its parent read-only. + env = _make_env(tmp_path) + dist_info, installed = _install_fake_distribution(env, with_script=False) + + purelib = Path(env.paths["purelib"]) + pkg_dir = purelib / "demo" + + # Drop write permission on the package dir so removing files inside fails. + pkg_dir.chmod(0o500) + try: + with pytest.raises(OSError): + uninstall_distribution(env, "demo") + finally: + pkg_dir.chmod(0o700) + + # The .dist-info directory (and RECORD) must survive so the uninstall can be + # triggered again. + assert dist_info.exists() + assert (dist_info / "RECORD").exists() + + # Re-running now succeeds and removes everything. + pathset = uninstall_distribution(env, "demo") + assert pathset is not None + for path in installed: + assert not path.exists(), f"{path} should have been removed" + assert not dist_info.exists() + + +def test_uninstall_distribution_ignores_already_missing_file(tmp_path: Path) -> None: + # A path listed in RECORD that is already gone from disk must not raise. + env = _make_env(tmp_path) + dist_info, installed = _install_fake_distribution(env, with_script=False) + + # Delete one recorded file before uninstalling. + (Path(env.paths["purelib"]) / "demo" / "module.py").unlink() + + pathset = uninstall_distribution(env, "demo") + assert pathset is not None + for path in installed: + assert not path.exists(), f"{path} should have been removed" + assert not dist_info.exists() + + +def test_refuses_paths_outside_env_prefix(tmp_path: Path) -> None: + env = _make_env(tmp_path) + _install_fake_distribution(env) + + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + outside_file = outside_dir / "stray.py" + outside_file.write_text("# outside\n", encoding="utf-8") + + dist = next(iter(env.site_packages.distributions(name="demo"))) + dist_parent = dist._path.parent # type: ignore[attr-defined] + pathset = UninstallPathSet(dist, env.path) + assert dist.files + for entry in dist.files: + pathset.add(os.path.join(dist_parent, entry)) + pathset.add(str(outside_file)) + + assert all("demo" in p for p in pathset.paths) + assert pathset.refused == {_normalize_path(outside_file)} + + pathset.remove() + assert outside_file.exists(), "files outside the env prefix must not be removed" + + +def test_uninstall_distribution_with_symlinked_directory(tmp_path: Path) -> None: + # A RECORD that lists a symlink to a directory must be unlinked (os.remove + # removes the link, not its target), and the package dir is then rmdir'd + # once empty. The link's target directory must be left untouched. + env = _make_env(tmp_path) + _, installed = _install_fake_distribution( + env, with_script=False, extra_symlinked_dirs=["demo/datalink"] + ) + + purelib = Path(env.paths["purelib"]) + link = purelib / "demo" / "datalink" + link_target = purelib / "demo-symlink-target" + assert link.is_symlink() + assert link_target.is_dir() + + pathset = uninstall_distribution(env, "demo") + assert pathset is not None + + assert not (purelib / "demo").exists() + assert not link.exists() + for path in installed: + assert not path.exists(), f"{path} should have been removed" + # The symlink is removed, but its target directory must be left untouched — + # the uninstaller removes links, not the things they point at. + assert link_target.is_dir() + + +def test_uninstall_distribution_prunes_empty_namespace_dirs(tmp_path: Path) -> None: + # A namespace-style layout whose only files live in a deep leaf (no + # __init__.py in the intermediate dirs). Removing the leaf files must prune + # the now-empty intermediate directories by climbing upward, while a sibling + # package sharing the namespace - and the site-packages root - survive. + env = _make_env(tmp_path) + purelib = Path(env.paths["purelib"]) + + # Sibling package under the same top-level namespace that is NOT uninstalled. + sibling = purelib / "ns" / "sibling" + sibling.mkdir(parents=True) + (sibling / "__init__.py").write_text("# sibling\n", encoding="utf-8") + + _, installed = _install_fake_distribution( + env, + with_script=False, + extra_files=[("ns/cloud/demo/leaf.py", "x = 1\n")], + ) + + pathset = uninstall_distribution(env, "demo") + assert pathset is not None + + for path in installed: + assert not path.exists(), f"{path} should have been removed" + # The emptied intermediate dirs are pruned ... + assert not (purelib / "ns" / "cloud").exists() + # ... but the namespace root survives because the sibling package remains, + # and site-packages itself is never removed. + assert sibling.exists() + assert (purelib / "ns").is_dir() + assert purelib.is_dir() + + +def test_prune_empty_dirs_tolerates_already_removed_dir(tmp_path: Path) -> None: + # Updates run in parallel, so two builtin uninstalls can prune a shared + # namespace directory concurrently. Pruning one that another uninstall has + # already removed must not raise - rmdir's OSError is swallowed and the + # climb simply stops. + env = _make_env(tmp_path) + _install_fake_distribution(env, with_script=False) + dist = next(iter(env.site_packages.distributions(name="demo"))) + + protected = {_normalize_path(p) for p in env.paths.values() if p} + protected.add(_normalize_path(env.path)) + pathset = UninstallPathSet(dist, env.path, protected_dirs=protected) + + purelib = Path(env.paths["purelib"]) + # A nested namespace dir that is never created on disk (as if a concurrent + # uninstall already removed it), still inside the env prefix. + gone = _normalize_path(purelib / "ns" / "cloud") + + pathset._prune_empty_dirs({gone}) # must not raise + + +def test_uninstall_distribution_keeps_directory_with_unrelated_file( + tmp_path: Path, +) -> None: + # A stray file that is not part of the distribution (not in RECORD) and + # shares the package directory must survive: rmdir refuses to remove the + # non-empty directory out from under it. + env = _make_env(tmp_path) + _, installed = _install_fake_distribution(env, with_script=False) + + purelib = Path(env.paths["purelib"]) + stray = purelib / "demo" / "stray.txt" + stray.write_text("not ours\n", encoding="utf-8") + + pathset = uninstall_distribution(env, "demo") + assert pathset is not None + + # Every recorded file (and the .dist-info directory) is gone ... + for path in installed: + assert not path.exists(), f"{path} should have been removed" + # ... but the package directory survives because of the unrelated file. + assert stray.exists() + assert (purelib / "demo").is_dir() + + +def test_uninstall_distribution_removes_pycache_bytecode(tmp_path: Path) -> None: + # Compiled bytecode in __pycache__ is discovered and removed along with the + # package even when it is not listed in RECORD (it is often compiled lazily + # on first import). + env = _make_env(tmp_path) + _, installed = _install_fake_distribution(env, with_script=False) + + purelib = Path(env.paths["purelib"]) + module_py = purelib / "demo" / "module.py" + pyc = Path(cache_from_source(str(module_py))) + pyc.parent.mkdir(parents=True, exist_ok=True) + pyc.write_bytes(b"\x00\x00\x00\x00") + + pathset = uninstall_distribution(env, "demo") + assert pathset is not None + + # The bytecode was discovered for removal (it is not listed in RECORD) ... + assert any(Path(p).name == pyc.name for p in pathset.paths) + # ... and is gone from disk, along with its __pycache__ directory. + assert not pyc.exists() + assert not pyc.parent.exists() + for path in installed: + assert not path.exists(), f"{path} should have been removed" + + +def test_uninstall_distribution_removes_bytecode_for_other_python_versions( + tmp_path: Path, +) -> None: + # The target environment may have been built with a different Python version + # than the interpreter running Poetry, so __pycache__ holds bytecode tagged + # for that version. All such cache files must be removed - not just the + # current interpreter's cache_from_source() tag - and none of them are + # listed in RECORD. + env = _make_env(tmp_path) + _, installed = _install_fake_distribution(env, with_script=False) + + purelib = Path(env.paths["purelib"]) + pycache = purelib / "demo" / "__pycache__" + pycache.mkdir() + # Foreign version tags (not the interpreter running the tests) plus an + # optimized variant and a cache file for a second source module. + foreign_pycs = [ + pycache / "module.cpython-38.pyc", + pycache / "module.cpython-313.pyc", + pycache / "module.cpython-313.opt-1.pyc", + pycache / "__init__.cpython-38.pyc", + ] + for pyc in foreign_pycs: + pyc.write_bytes(b"\x00\x00\x00\x00") + + # A bytecode file whose stem does not match any source module must be left + # alone - it does not belong to this distribution. + unrelated_pyc = pycache / "other.cpython-38.pyc" + unrelated_pyc.write_bytes(b"\x00\x00\x00\x00") + + pathset = uninstall_distribution(env, "demo") + assert pathset is not None + + discovered = {Path(p).name for p in pathset.paths} + for pyc in foreign_pycs: + assert pyc.name in discovered, f"{pyc.name} should have been discovered" + assert not pyc.exists() + assert unrelated_pyc.name not in discovered + assert unrelated_pyc.exists() + for path in installed: + assert not path.exists(), f"{path} should have been removed" + + +def test_uninstall_distribution_scans_each_pycache_once( + tmp_path: Path, mocker: MockerFixture +) -> None: + # add() runs for every .py file, but all .py files in one directory share a + # single __pycache__. The listing must be memoized so the directory is + # scanned once, not once per file. + env = _make_env(tmp_path) + extra_modules = [(f"demo/mod{i}.py", f"x = {i}\n") for i in range(5)] + _, installed = _install_fake_distribution( + env, with_script=False, extra_files=extra_modules + ) + + purelib = Path(env.paths["purelib"]) + pycache = purelib / "demo" / "__pycache__" + pycache.mkdir() + (pycache / "module.cpython-38.pyc").write_bytes(b"\x00\x00\x00\x00") + + listdir_spy = mocker.patch( + "poetry.installation.uninstaller.os.listdir", wraps=os.listdir + ) + + pathset = uninstall_distribution(env, "demo") + assert pathset is not None + + pycache_calls = [ + call + for call in listdir_spy.call_args_list + if str(call.args[0]).endswith("__pycache__") + ] + # demo/ holds 7 .py files (__init__, module, mod0..mod4) but only one + # __pycache__, which must be listed exactly once. + assert len(pycache_calls) == 1 + for path in installed: + assert not path.exists(), f"{path} should have been removed" + + +def test_uninstall_distribution_defers_directory_entry_in_record( + tmp_path: Path, +) -> None: + # If an entry listed in RECORD is a directory instead of a file + # (which should actually not happen) os.remove() must not raise + # out of the uninstall: the entry is deferred and removed + # with rmdir once the files inside it are gone. + env = _make_env(tmp_path) + _, installed = _install_fake_distribution(env, with_script=False) + + purelib = Path(env.paths["purelib"]) + empty_dir = purelib / "demo" / "emptysub" + empty_dir.mkdir() + + dist = next(iter(env.site_packages.distributions(name="demo"))) + dist_parent = dist._path.parent # type: ignore[attr-defined] + pathset = UninstallPathSet(dist, env.path) + assert dist.files + for record_path in dist.files: + pathset.add(os.path.join(dist_parent, record_path)) + # Inject the directory path as if RECORD had listed it. + pathset.add(str(empty_dir)) + assert _normalize_path(empty_dir) in pathset.paths + + pathset.remove() # must not raise + + assert not empty_dir.exists() + for path in installed: + assert not path.exists(), f"{path} should have been removed"