Skip to content

Commit c9d980f

Browse files
authored
Refactor/unify/extract shutil.rmtree callbacks (and avoid repetition) (#4682)
2 parents e14cfec + db2b206 commit c9d980f

File tree

8 files changed

+90
-104
lines changed

8 files changed

+90
-104
lines changed

setuptools/_shutil.py

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""Convenience layer on top of stdlib's shutil and os"""
2+
3+
import os
4+
import stat
5+
from typing import Callable, TypeVar
6+
7+
from .compat import py311
8+
9+
from distutils import log
10+
11+
try:
12+
from os import chmod # pyright: ignore[reportAssignmentType]
13+
# Losing type-safety w/ pyright, but that's ok
14+
except ImportError: # pragma: no cover
15+
# Jython compatibility
16+
def chmod(*args: object, **kwargs: object) -> None: # type: ignore[misc] # Mypy reuses the imported definition anyway
17+
pass
18+
19+
20+
_T = TypeVar("_T")
21+
22+
23+
def attempt_chmod_verbose(path, mode):
24+
log.debug("changing mode of %s to %o", path, mode)
25+
try:
26+
chmod(path, mode)
27+
except OSError as e: # pragma: no cover
28+
log.debug("chmod failed: %s", e)
29+
30+
31+
# Must match shutil._OnExcCallback
32+
def _auto_chmod(
33+
func: Callable[..., _T], arg: str, exc: BaseException
34+
) -> _T: # pragma: no cover
35+
"""shutils onexc callback to automatically call chmod for certain functions."""
36+
# Only retry for scenarios known to have an issue
37+
if func in [os.unlink, os.remove] and os.name == 'nt':
38+
attempt_chmod_verbose(arg, stat.S_IWRITE)
39+
return func(arg)
40+
raise exc
41+
42+
43+
def rmtree(path, ignore_errors=False, onexc=_auto_chmod):
44+
"""
45+
Similar to ``shutil.rmtree`` but automatically executes ``chmod``
46+
for well know Windows failure scenarios.
47+
"""
48+
return py311.shutil_rmtree(path, ignore_errors, onexc)
49+
50+
51+
def rmdir(path, **opts):
52+
if os.path.isdir(path):
53+
rmtree(path, **opts)

setuptools/command/bdist_wheel.py

+5-28
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import os
1010
import re
1111
import shutil
12-
import stat
1312
import struct
1413
import sys
1514
import sysconfig
@@ -18,23 +17,19 @@
1817
from email.generator import BytesGenerator, Generator
1918
from email.policy import EmailPolicy
2019
from glob import iglob
21-
from shutil import rmtree
22-
from typing import TYPE_CHECKING, Callable, Literal, cast
20+
from typing import Literal, cast
2321
from zipfile import ZIP_DEFLATED, ZIP_STORED
2422

2523
from packaging import tags, version as _packaging_version
2624
from wheel.metadata import pkginfo_to_metadata
2725
from wheel.wheelfile import WheelFile
2826

29-
from .. import Command, __version__
27+
from .. import Command, __version__, _shutil
3028
from ..warnings import SetuptoolsDeprecationWarning
3129
from .egg_info import egg_info as egg_info_cls
3230

3331
from distutils import log
3432

35-
if TYPE_CHECKING:
36-
from _typeshed import ExcInfo
37-
3833

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

150145

151-
def remove_readonly(
152-
func: Callable[..., object],
153-
path: str,
154-
excinfo: ExcInfo,
155-
) -> None:
156-
remove_readonly_exc(func, path, excinfo[1])
157-
158-
159-
def remove_readonly_exc(
160-
func: Callable[..., object], path: str, exc: BaseException
161-
) -> None:
162-
os.chmod(path, stat.S_IWRITE)
163-
func(path)
164-
165-
166146
class bdist_wheel(Command):
167147
description = "create a wheel distribution"
168148

@@ -458,7 +438,7 @@ def run(self):
458438
shutil.copytree(self.dist_info_dir, distinfo_dir)
459439
# Egg info is still generated, so remove it now to avoid it getting
460440
# copied into the wheel.
461-
shutil.rmtree(self.egginfo_dir)
441+
_shutil.rmtree(self.egginfo_dir)
462442
else:
463443
# Convert the generated egg-info into dist-info.
464444
self.egg2dist(self.egginfo_dir, distinfo_dir)
@@ -483,10 +463,7 @@ def run(self):
483463
if not self.keep_temp:
484464
log.info(f"removing {self.bdist_dir}")
485465
if not self.dry_run:
486-
if sys.version_info < (3, 12):
487-
rmtree(self.bdist_dir, onerror=remove_readonly)
488-
else:
489-
rmtree(self.bdist_dir, onexc=remove_readonly_exc)
466+
_shutil.rmtree(self.bdist_dir)
490467

491468
def write_wheelfile(
492469
self, wheelfile_base: str, generator: str = f"setuptools ({__version__})"
@@ -570,7 +547,7 @@ def egg2dist(self, egginfo_path: str, distinfo_path: str) -> None:
570547
def adios(p: str) -> None:
571548
"""Appropriately delete directory, file or link."""
572549
if os.path.exists(p) and not os.path.islink(p) and os.path.isdir(p):
573-
shutil.rmtree(p)
550+
_shutil.rmtree(p)
574551
elif os.path.exists(p):
575552
os.unlink(p)
576553

setuptools/command/dist_info.py

+1-5
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from typing import cast
1111

1212
from .. import _normalization
13+
from .._shutil import rmdir as _rm
1314
from .egg_info import egg_info as egg_info_cls
1415

1516
from distutils import log
@@ -100,8 +101,3 @@ def run(self) -> None:
100101
# TODO: if bdist_wheel if merged into setuptools, just add "keep_egg_info" there
101102
with self._maybe_bkp_dir(egg_info_dir, self.keep_egg_info):
102103
bdist_wheel.egg2dist(egg_info_dir, self.dist_info_dir)
103-
104-
105-
def _rm(dir_name, **opts):
106-
if os.path.isdir(dir_name):
107-
shutil.rmtree(dir_name, **opts)

setuptools/command/easy_install.py

+3-36
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from collections.abc import Iterable
3535
from glob import glob
3636
from sysconfig import get_path
37-
from typing import TYPE_CHECKING, Callable, NoReturn, TypedDict, TypeVar
37+
from typing import TYPE_CHECKING, NoReturn, TypedDict
3838

3939
from jaraco.text import yield_lines
4040

@@ -63,7 +63,8 @@
6363
from setuptools.wheel import Wheel
6464

6565
from .._path import ensure_directory
66-
from ..compat import py39, py311, py312
66+
from .._shutil import attempt_chmod_verbose as chmod, rmtree as _rmtree
67+
from ..compat import py39, py312
6768

6869
from distutils import dir_util, log
6970
from distutils.command import install
@@ -89,8 +90,6 @@
8990
'get_exe_prefixes',
9091
]
9192

92-
_T = TypeVar("_T")
93-
9493

9594
def is_64bit():
9695
return struct.calcsize("P") == 8
@@ -1789,16 +1788,6 @@ def _first_line_re():
17891788
return re.compile(first_line_re.pattern.decode())
17901789

17911790

1792-
# Must match shutil._OnExcCallback
1793-
def auto_chmod(func: Callable[..., _T], arg: str, exc: BaseException) -> _T:
1794-
"""shutils onexc callback to automatically call chmod for certain functions."""
1795-
# Only retry for scenarios known to have an issue
1796-
if func in [os.unlink, os.remove] and os.name == 'nt':
1797-
chmod(arg, stat.S_IWRITE)
1798-
return func(arg)
1799-
raise exc
1800-
1801-
18021791
def update_dist_caches(dist_path, fix_zipimporter_caches):
18031792
"""
18041793
Fix any globally cached `dist_path` related data
@@ -2021,24 +2010,6 @@ def is_python_script(script_text, filename):
20212010
return False # Not any Python I can recognize
20222011

20232012

2024-
try:
2025-
from os import (
2026-
chmod as _chmod, # pyright: ignore[reportAssignmentType] # Losing type-safety w/ pyright, but that's ok
2027-
)
2028-
except ImportError:
2029-
# Jython compatibility
2030-
def _chmod(*args: object, **kwargs: object) -> None: # type: ignore[misc] # Mypy reuses the imported definition anyway
2031-
pass
2032-
2033-
2034-
def chmod(path, mode):
2035-
log.debug("changing mode of %s to %o", path, mode)
2036-
try:
2037-
_chmod(path, mode)
2038-
except OSError as e:
2039-
log.debug("chmod failed: %s", e)
2040-
2041-
20422013
class _SplitArgs(TypedDict, total=False):
20432014
comments: bool
20442015
posix: bool
@@ -2350,10 +2321,6 @@ def load_launcher_manifest(name):
23502321
return manifest.decode('utf-8') % vars()
23512322

23522323

2353-
def _rmtree(path, ignore_errors: bool = False, onexc=auto_chmod):
2354-
return py311.shutil_rmtree(path, ignore_errors, onexc)
2355-
2356-
23572324
def current_umask():
23582325
tmp = os.umask(0o022)
23592326
os.umask(tmp)

setuptools/command/editable_wheel.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from types import TracebackType
2828
from typing import TYPE_CHECKING, Protocol, TypeVar, cast
2929

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

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

setuptools/command/rotate.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
from __future__ import annotations
22

33
import os
4-
import shutil
54
from typing import ClassVar
65

7-
from setuptools import Command
6+
from .. import Command, _shutil
87

98
from distutils import log
109
from distutils.errors import DistutilsOptionError
@@ -61,6 +60,6 @@ def run(self) -> None:
6160
log.info("Deleting %s", f)
6261
if not self.dry_run:
6362
if os.path.isdir(f):
64-
shutil.rmtree(f)
63+
_shutil.rmtree(f)
6564
else:
6665
os.unlink(f)

setuptools/tests/test_bdist_wheel.py

+1-30
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,14 @@
1111
import sysconfig
1212
from contextlib import suppress
1313
from inspect import cleandoc
14-
from unittest.mock import Mock
1514
from zipfile import ZipFile
1615

1716
import jaraco.path
1817
import pytest
1918
from packaging import tags
2019

2120
import setuptools
22-
from setuptools.command.bdist_wheel import (
23-
bdist_wheel,
24-
get_abi_tag,
25-
remove_readonly,
26-
remove_readonly_exc,
27-
)
21+
from setuptools.command.bdist_wheel import bdist_wheel, get_abi_tag
2822
from setuptools.dist import Distribution
2923
from setuptools.warnings import SetuptoolsDeprecationWarning
3024

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

512506

513-
def test_rmtree_readonly(monkeypatch, tmp_path):
514-
"""Verify onerr works as expected"""
515-
516-
bdist_dir = tmp_path / "with_readonly"
517-
bdist_dir.mkdir()
518-
some_file = bdist_dir.joinpath("file.txt")
519-
some_file.touch()
520-
some_file.chmod(stat.S_IREAD)
521-
522-
expected_count = 1 if sys.platform.startswith("win") else 0
523-
524-
if sys.version_info < (3, 12):
525-
count_remove_readonly = Mock(side_effect=remove_readonly)
526-
shutil.rmtree(bdist_dir, onerror=count_remove_readonly)
527-
assert count_remove_readonly.call_count == expected_count
528-
else:
529-
count_remove_readonly_exc = Mock(side_effect=remove_readonly_exc)
530-
shutil.rmtree(bdist_dir, onexc=count_remove_readonly_exc)
531-
assert count_remove_readonly_exc.call_count == expected_count
532-
533-
assert not bdist_dir.is_dir()
534-
535-
536507
def test_data_dir_with_tag_build(monkeypatch, tmp_path):
537508
"""
538509
Setuptools allow authors to set PEP 440's local version segments
+23
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import stat
2+
import sys
3+
from unittest.mock import Mock
4+
5+
from setuptools import _shutil
6+
7+
8+
def test_rmtree_readonly(monkeypatch, tmp_path):
9+
"""Verify onerr works as expected"""
10+
11+
tmp_dir = tmp_path / "with_readonly"
12+
tmp_dir.mkdir()
13+
some_file = tmp_dir.joinpath("file.txt")
14+
some_file.touch()
15+
some_file.chmod(stat.S_IREAD)
16+
17+
expected_count = 1 if sys.platform.startswith("win") else 0
18+
chmod_fn = Mock(wraps=_shutil.attempt_chmod_verbose)
19+
monkeypatch.setattr(_shutil, "attempt_chmod_verbose", chmod_fn)
20+
21+
_shutil.rmtree(tmp_dir)
22+
assert chmod_fn.call_count == expected_count
23+
assert not tmp_dir.is_dir()

0 commit comments

Comments
 (0)