Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor/unify/extract shutil.rmtree callbacks (and avoid repetition) #4682

Merged
merged 7 commits into from
Nov 11, 2024
53 changes: 53 additions & 0 deletions setuptools/_shutil.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Convenience layer on top of stdlib's shutil and os"""

import os
import stat
from typing import Callable, TypeVar

from .compat import py311

from distutils import log

try:
from os import chmod # pyright: ignore[reportAssignmentType]
# Losing type-safety w/ pyright, but that's ok
except ImportError: # pragma: no cover
# Jython compatibility
def chmod(*args: object, **kwargs: object) -> None: # type: ignore[misc] # Mypy reuses the imported definition anyway
pass


_T = TypeVar("_T")


def attempt_chmod_verbose(path, mode):
log.debug("changing mode of %s to %o", path, mode)
try:
chmod(path, mode)
except OSError as e: # pragma: no cover
log.debug("chmod failed: %s", e)


# Must match shutil._OnExcCallback
def _auto_chmod(
func: Callable[..., _T], arg: str, exc: BaseException
) -> _T: # pragma: no cover
"""shutils onexc callback to automatically call chmod for certain functions."""
# Only retry for scenarios known to have an issue
if func in [os.unlink, os.remove] and os.name == 'nt':
attempt_chmod_verbose(arg, stat.S_IWRITE)
return func(arg)
raise exc


def rmtree(path, ignore_errors=False, onexc=_auto_chmod):
"""
Similar to ``shutil.rmtree`` but automatically executes ``chmod``
for well know Windows failure scenarios.
abravalheri marked this conversation as resolved.
Show resolved Hide resolved
"""
return py311.shutil_rmtree(path, ignore_errors, onexc)


def rmdir(path, **opts):
if os.path.isdir(path):
rmtree(path, **opts)
33 changes: 5 additions & 28 deletions setuptools/command/bdist_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
import os
import re
import shutil
import stat
import struct
import sys
import sysconfig
Expand All @@ -18,23 +17,19 @@
from email.generator import BytesGenerator, Generator
from email.policy import EmailPolicy
from glob import iglob
from shutil import rmtree
from typing import TYPE_CHECKING, Callable, Literal, cast
from typing import Literal, cast
from zipfile import ZIP_DEFLATED, ZIP_STORED

from packaging import tags, version as _packaging_version
from wheel.metadata import pkginfo_to_metadata
from wheel.wheelfile import WheelFile

from .. import Command, __version__
from .. import Command, __version__, _shutil
from ..warnings import SetuptoolsDeprecationWarning
from .egg_info import egg_info as egg_info_cls

from distutils import log

if TYPE_CHECKING:
from _typeshed import ExcInfo


def safe_name(name: str) -> str:
"""Convert an arbitrary string to a standard distribution name
Expand Down Expand Up @@ -148,21 +143,6 @@ def safer_version(version: str) -> str:
return safe_version(version).replace("-", "_")


def remove_readonly(
func: Callable[..., object],
path: str,
excinfo: ExcInfo,
) -> None:
remove_readonly_exc(func, path, excinfo[1])


def remove_readonly_exc(
func: Callable[..., object], path: str, exc: BaseException
) -> None:
os.chmod(path, stat.S_IWRITE)
func(path)


class bdist_wheel(Command):
description = "create a wheel distribution"

Expand Down Expand Up @@ -458,7 +438,7 @@ def run(self):
shutil.copytree(self.dist_info_dir, distinfo_dir)
# Egg info is still generated, so remove it now to avoid it getting
# copied into the wheel.
shutil.rmtree(self.egginfo_dir)
_shutil.rmtree(self.egginfo_dir)
else:
# Convert the generated egg-info into dist-info.
self.egg2dist(self.egginfo_dir, distinfo_dir)
Expand All @@ -483,10 +463,7 @@ def run(self):
if not self.keep_temp:
log.info(f"removing {self.bdist_dir}")
if not self.dry_run:
if sys.version_info < (3, 12):
rmtree(self.bdist_dir, onerror=remove_readonly)
else:
rmtree(self.bdist_dir, onexc=remove_readonly_exc)
_shutil.rmtree(self.bdist_dir)

def write_wheelfile(
self, wheelfile_base: str, generator: str = f"setuptools ({__version__})"
Expand Down Expand Up @@ -570,7 +547,7 @@ def egg2dist(self, egginfo_path: str, distinfo_path: str) -> None:
def adios(p: str) -> None:
"""Appropriately delete directory, file or link."""
if os.path.exists(p) and not os.path.islink(p) and os.path.isdir(p):
shutil.rmtree(p)
_shutil.rmtree(p)
elif os.path.exists(p):
os.unlink(p)

Expand Down
6 changes: 1 addition & 5 deletions setuptools/command/dist_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from typing import cast

from .. import _normalization
from .._shutil import rmdir as _rm
from .egg_info import egg_info as egg_info_cls

from distutils import log
Expand Down Expand Up @@ -100,8 +101,3 @@ def run(self) -> None:
# TODO: if bdist_wheel if merged into setuptools, just add "keep_egg_info" there
with self._maybe_bkp_dir(egg_info_dir, self.keep_egg_info):
bdist_wheel.egg2dist(egg_info_dir, self.dist_info_dir)


def _rm(dir_name, **opts):
if os.path.isdir(dir_name):
shutil.rmtree(dir_name, **opts)
39 changes: 3 additions & 36 deletions setuptools/command/easy_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from collections.abc import Iterable
from glob import glob
from sysconfig import get_path
from typing import TYPE_CHECKING, Callable, NoReturn, TypedDict, TypeVar
from typing import TYPE_CHECKING, NoReturn, TypedDict

from jaraco.text import yield_lines

Expand Down Expand Up @@ -63,7 +63,8 @@
from setuptools.wheel import Wheel

from .._path import ensure_directory
from ..compat import py39, py311, py312
from .._shutil import attempt_chmod_verbose as chmod, rmtree as _rmtree
from ..compat import py39, py312

from distutils import dir_util, log
from distutils.command import install
Expand All @@ -89,8 +90,6 @@
'get_exe_prefixes',
]

_T = TypeVar("_T")


def is_64bit():
return struct.calcsize("P") == 8
Expand Down Expand Up @@ -1789,16 +1788,6 @@ def _first_line_re():
return re.compile(first_line_re.pattern.decode())


# Must match shutil._OnExcCallback
def auto_chmod(func: Callable[..., _T], arg: str, exc: BaseException) -> _T:
"""shutils onexc callback to automatically call chmod for certain functions."""
# Only retry for scenarios known to have an issue
if func in [os.unlink, os.remove] and os.name == 'nt':
chmod(arg, stat.S_IWRITE)
return func(arg)
raise exc


def update_dist_caches(dist_path, fix_zipimporter_caches):
"""
Fix any globally cached `dist_path` related data
Expand Down Expand Up @@ -2021,24 +2010,6 @@ def is_python_script(script_text, filename):
return False # Not any Python I can recognize


try:
from os import (
chmod as _chmod, # pyright: ignore[reportAssignmentType] # Losing type-safety w/ pyright, but that's ok
)
except ImportError:
# Jython compatibility
def _chmod(*args: object, **kwargs: object) -> None: # type: ignore[misc] # Mypy reuses the imported definition anyway
pass


def chmod(path, mode):
log.debug("changing mode of %s to %o", path, mode)
try:
_chmod(path, mode)
except OSError as e:
log.debug("chmod failed: %s", e)


class _SplitArgs(TypedDict, total=False):
comments: bool
posix: bool
Expand Down Expand Up @@ -2350,10 +2321,6 @@ def load_launcher_manifest(name):
return manifest.decode('utf-8') % vars()


def _rmtree(path, ignore_errors: bool = False, onexc=auto_chmod):
return py311.shutil_rmtree(path, ignore_errors, onexc)


def current_umask():
tmp = os.umask(0o022)
os.umask(tmp)
Expand Down
4 changes: 2 additions & 2 deletions setuptools/command/editable_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
from types import TracebackType
from typing import TYPE_CHECKING, Protocol, TypeVar, cast

from .. import Command, _normalization, _path, errors, namespaces
from .. import Command, _normalization, _path, _shutil, errors, namespaces
from .._path import StrPath
from ..compat import py312
from ..discovery import find_package_path
Expand Down Expand Up @@ -773,7 +773,7 @@ def _is_nested(pkg: str, pkg_path: str, parent: str, parent_path: str) -> bool:

def _empty_dir(dir_: _P) -> _P:
"""Create a directory ensured to be empty. Existing files may be removed."""
shutil.rmtree(dir_, ignore_errors=True)
_shutil.rmtree(dir_, ignore_errors=True)
os.makedirs(dir_)
return dir_

Expand Down
5 changes: 2 additions & 3 deletions setuptools/command/rotate.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
from __future__ import annotations

import os
import shutil
from typing import ClassVar

from setuptools import Command
from .. import Command, _shutil

from distutils import log
from distutils.errors import DistutilsOptionError
Expand Down Expand Up @@ -61,6 +60,6 @@ def run(self) -> None:
log.info("Deleting %s", f)
if not self.dry_run:
if os.path.isdir(f):
shutil.rmtree(f)
_shutil.rmtree(f)
else:
os.unlink(f)
31 changes: 1 addition & 30 deletions setuptools/tests/test_bdist_wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,14 @@
import sysconfig
from contextlib import suppress
from inspect import cleandoc
from unittest.mock import Mock
from zipfile import ZipFile

import jaraco.path
import pytest
from packaging import tags

import setuptools
from setuptools.command.bdist_wheel import (
bdist_wheel,
get_abi_tag,
remove_readonly,
remove_readonly_exc,
)
from setuptools.command.bdist_wheel import bdist_wheel, get_abi_tag
from setuptools.dist import Distribution
from setuptools.warnings import SetuptoolsDeprecationWarning

Expand Down Expand Up @@ -510,29 +504,6 @@ def test_platform_with_space(dummy_dist, monkeypatch):
bdist_wheel_cmd(plat_name="isilon onefs").run()


def test_rmtree_readonly(monkeypatch, tmp_path):
"""Verify onerr works as expected"""

bdist_dir = tmp_path / "with_readonly"
bdist_dir.mkdir()
some_file = bdist_dir.joinpath("file.txt")
some_file.touch()
some_file.chmod(stat.S_IREAD)

expected_count = 1 if sys.platform.startswith("win") else 0

if sys.version_info < (3, 12):
count_remove_readonly = Mock(side_effect=remove_readonly)
shutil.rmtree(bdist_dir, onerror=count_remove_readonly)
assert count_remove_readonly.call_count == expected_count
else:
count_remove_readonly_exc = Mock(side_effect=remove_readonly_exc)
shutil.rmtree(bdist_dir, onexc=count_remove_readonly_exc)
assert count_remove_readonly_exc.call_count == expected_count

assert not bdist_dir.is_dir()


def test_data_dir_with_tag_build(monkeypatch, tmp_path):
"""
Setuptools allow authors to set PEP 440's local version segments
Expand Down
23 changes: 23 additions & 0 deletions setuptools/tests/test_shutil_wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import stat
import sys
from unittest.mock import Mock

from setuptools import _shutil


def test_rmtree_readonly(monkeypatch, tmp_path):
"""Verify onerr works as expected"""

tmp_dir = tmp_path / "with_readonly"
tmp_dir.mkdir()
some_file = tmp_dir.joinpath("file.txt")
some_file.touch()
some_file.chmod(stat.S_IREAD)

expected_count = 1 if sys.platform.startswith("win") else 0
abravalheri marked this conversation as resolved.
Show resolved Hide resolved
chmod_fn = Mock(wraps=_shutil.attempt_chmod_verbose)
monkeypatch.setattr(_shutil, "attempt_chmod_verbose", chmod_fn)

_shutil.rmtree(tmp_dir)
assert chmod_fn.call_count == expected_count
assert not tmp_dir.is_dir()
Loading