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

Avoid adding an empty/incomplete .egg-info to sys.path while traversing importlib.metadata.entry_points #4670

Closed
wants to merge 7 commits into from
49 changes: 49 additions & 0 deletions setuptools/_shutil.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""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):
return py311.shutil_rmtree(path, ignore_errors, onexc)


def rmdir(path, **opts):
if os.path.isdir(path):
rmtree(path, **opts)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This module was extracted from easy_install for re-use.

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):
# 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, TypeVar
from typing import TYPE_CHECKING

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 @@ -1788,16 +1787,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 @@ -2020,24 +2009,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 CommandSpec(list):
"""
A command spec for a #! header, specified as a list of arguments akin to
Expand Down Expand Up @@ -2340,10 +2311,6 @@ def load_launcher_manifest(name):
return manifest.decode('utf-8') % vars()


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


def current_umask():
tmp = os.umask(0o022)
os.umask(tmp)
Expand Down
52 changes: 35 additions & 17 deletions setuptools/command/egg_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@
import functools
import os
import re
import shutil
import sys
import time
from tempfile import TemporaryDirectory

import packaging
import packaging.requirements
Expand All @@ -20,7 +22,7 @@
from setuptools.command.setopt import edit_config
from setuptools.glob import glob

from .. import _entry_points, _normalization
from .. import _entry_points, _normalization, _shutil
from .._importlib import metadata
from ..warnings import SetuptoolsDeprecationWarning
from . import _requirestxt
Expand Down Expand Up @@ -293,20 +295,35 @@ def delete_file(self, filename):
os.unlink(filename)

def run(self):
self.mkpath(self.egg_info)
try:
os.utime(self.egg_info, None)
except OSError as e:
msg = f"Cannot update time stamp of directory '{self.egg_info}'"
raise distutils.errors.DistutilsFileError(msg) from e
for ep in metadata.entry_points(group='egg_info.writers'):
writer = ep.load()
writer(self, ep.name, os.path.join(self.egg_info, ep.name))

# Get rid of native_libs.txt if it was put there by older bdist_egg
nl = os.path.join(self.egg_info, "native_libs.txt")
if os.path.exists(nl):
self.delete_file(nl)
# Avoid adding an empty .egg-info to sys.path while using importlib.metadata
# See pypa/pyproject-hooks#206
self.mkpath(self.egg_base) # avoid file system errors with tmpdir / os.replace
with TemporaryDirectory(prefix=".tmp-", dir=self.egg_base) as tmp:
staging = os.path.join(tmp, "egg_info")
if os.path.isdir(self.egg_info):
shutil.copytree(self.egg_info, staging) # account pre-existing files
else:
os.mkdir(staging)

for ep in metadata.entry_points(group='egg_info.writers'):
writer = ep.load()
writer(self, ep.name, os.path.join(staging, ep.name))

# Get rid of native_libs.txt if it was put there by older bdist_egg
nl = os.path.join(staging, "native_libs.txt")
if os.path.exists(nl): # pragma: no cover
self.delete_file(nl)

# Remove old directory and create the new one
try:
# Unfortunately os.replace does not work for existing destination dirs,
# so we cannot have a single atomic operation
_shutil.rmdir(self.egg_info)
os.replace(staging, self.egg_info)
log.info(f"renaming {staging!r} to {self.egg_info!r}")
except OSError as e:
msg = f"Cannot create directory '{self.egg_info}' ({e})"
raise distutils.errors.DistutilsFileError(msg) from e
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jaraco in this rewrite the main idea is to use a temporary directory to avoid adding an empty .egg-info to the path right before iterating over importlib_metadata.entry_points.

May be a bit convoluted, but this is what I could come up to make the process a bit more atomic.

This is related to the discussion in pypa/pyproject-hooks#206.


self.find_sources()

Expand Down Expand Up @@ -654,13 +671,14 @@ def write_pkg_info(cmd, basename, filename):
try:
# write unescaped data to PKG-INFO, so older pkg_resources
# can still parse it
metadata.write_pkg_info(cmd.egg_info)
parent_dir = os.path.dirname(filename)
metadata.write_pkg_info(parent_dir)
finally:
metadata.name, metadata.version = oldname, oldver

safe = getattr(cmd.distribution, 'zip_safe', None)

bdist_egg.write_safety_flag(cmd.egg_info, safe)
bdist_egg.write_safety_flag(parent_dir, safe)


def warn_depends_obsolete(cmd, basename, filename):
Expand Down
4 changes: 2 additions & 2 deletions setuptools/dist.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ def warn_dash_deprecation(self, opt, section):
versions. Please use the underscore name {underscore_opt!r} instead.
""",
see_docs="userguide/declarative_config.html",
due_date=(2024, 9, 26),
due_date=(2025, 3, 3),
Copy link
Contributor Author

@abravalheri abravalheri Oct 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

# Warning initially introduced in 3 Mar 2021
)
return underscore_opt
Expand All @@ -529,7 +529,7 @@ def make_option_lowercase(self, opt, section):
future versions. Please use lowercase {lowercase_opt!r} instead.
""",
see_docs="userguide/declarative_config.html",
due_date=(2024, 9, 26),
due_date=(2025, 3, 3),
Copy link
Contributor Author

@abravalheri abravalheri Oct 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be discussed in a separated issue/PR. For now just postponing to get the CI error out of the way.

#4672

# Warning initially introduced in 6 Mar 2021
)
return lowercase_opt
Expand Down
14 changes: 6 additions & 8 deletions setuptools/tests/test_egg_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,19 +165,17 @@ def test_expected_files_produced(self, tmpdir_cwd, env):
]
assert sorted(actual) == expected

def test_handling_utime_error(self, tmpdir_cwd, env):
dist = Distribution()
def test_handling_file_system_error(self, tmpdir_cwd, env):
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repurposing test, since the implementation no longer uses utime.

dist = Distribution({"script_name": "hello.py"})
ei = egg_info(dist)
utime_patch = mock.patch('os.utime', side_effect=OSError("TEST"))
mkpath_patch = mock.patch(
'setuptools.command.egg_info.egg_info.mkpath', return_val=None
)
rm_patch = mock.patch('setuptools._shutil.rmdir', side_effect=OSError("TEST"))

with utime_patch, mkpath_patch:
with rm_patch:
import distutils.errors

msg = r"Cannot update time stamp of directory 'None'"
msg = r"Cannot create directory .*egg-info"
with pytest.raises(distutils.errors.DistutilsFileError, match=msg):
ei.ensure_finalized()
ei.run()

def test_license_is_a_string(self, tmpdir_cwd, env):
Expand Down
Loading