Conversation
package_data files that also live inside an implicit namespace subpackage (e.g. a `.py` file in `pkg/scripts/` where `pkg/scripts` has no `__init__.py`) get discovered twice while building: once as a "module" by build_module() (which copies with preserve_mode=False), and once as "data" by build_package_data() (which copies with preserve_mode=True to restore the correct mode). Because build_module()'s copy already sets the destination mtime to match the source (preserve_times=True), the second, mode-restoring copy performed by build_package_data() can be skipped entirely by copy_file()'s mtime-based "already up-to-date" check -- leaving the file with the wrong (non-executable) mode. Fix build_package_data() to explicitly sync the mode bits from the source file after calling copy_file(), regardless of whether the "up-to-date" skip kicked in. Fixes pypa#5296
|
Tick the box to add this pull request to the merge queue (same as
|
CAOShurong
left a comment
There was a problem hiding this comment.
Verified this fix end-to-end on the current head (3b1b43b) — it exactly restores the v83 behavior, including an edge case the diff ordering silently decides.
Method: WSL/ext4; setuptools 83.0.0 and 84.0.0 wheels unpacked from PyPI and isolated via PYTHONPATH; the reporter's pyproject/package-data reproducer; the PR's own setuptools/command/build_py.py swapped in as-is; wheel entry modes read via zipfile.ZipInfo.external_attr >> 16.
Results (wheel entry mode for mypkg/scripts/some_script.py):
| build | source mode 0o755 | source mode 0o555 |
|---|---|---|
| 83.0.0 | 0o755 | 0o755 |
| 84.0.0 | 0o644 (bug) | 0o644 (bug) |
| this PR head | 0o755 | 0o755 |
The 555 case is where the ordering matters: os.chmod(target, S_IMODE(src)) placed before make_writable(target) reproduces v83 exactly (v83 = preserve-mode copy + make_writable adding u+w). Placing it after make_writable instead yields 0o555 for read-only sources, which v83 never produced (see my note on #5299).
I also confirmed the root-cause mechanism by class-level tracing of copy_file on the 84 build path: the package-data .py is first copied by build_module with preserve_mode=False (0o755 -> staged 0o644), and the mode-restoring build_package_data copy is then skipped by the mtime up-to-date check (copied=False), so the explicit chmod in this PR is the right minimal fix at the right place.
|
@Mergifyio queue |
☑️ Command disallowed due to command restrictions in the Mergify configuration.Details
|
Summary
Fixes #5296.
setuptools84.0.0 no longer preserves the executable bit (or othermode bits) on
package_datafiles that also happen to be discoveredas Python modules -- specifically,
.pyfiles that live inside animplicit namespace subpackage (a subdirectory with no
__init__.py).Root cause
Such a file gets copied into
build/libtwice while runningbuild_py:build_module()discovers it as a "module" (via PEP 420 implicitnamespace package auto-discovery) and copies it with
preserve_mode=False.build_package_data()also matches it via the declaredpackage_dataglob, and copies it again -- this time withpreserve_mode=True(the default), which is supposed to restorethe correct mode.
Because the first copy already sets the destination's mtime to match
the source (
preserve_times=True), the second copy'supdate=True"already up-to-date" check (
copy_file()comparingnewer(src, dst))can see identical mtimes and skip the copy entirely -- silently
skipping the mode-restoring chmod along with it.
This mtime-equality edge case became reachable after
_modified.py'snewer()-adjacent copy_file logic started comparing full-precisionst_mtimefloats (previously it effectively worked withlower-precision/truncated timestamps that made an exact match much
less likely), which is part of what changed between 83.0.0 and
84.0.0 via the distutils sync in #5292.
I verified this empirically by instrumenting
copy_file()in both83.0.0 and 84.0.0 with debug prints: in 83.0.0 the second copy's
newer(src, dst)check evaluatesTrue(so it copies and fixes themode); in 84.0.0 it evaluates
False(so it's skipped, leaving thefile non-executable).
Fix
In
build_py.build_package_data(), explicitly sync the destination'smode bits from the source file after the
copy_file()call,regardless of whether the "up-to-date" skip kicked in. This matches
the existing pattern right below it (
make_writable(target), whichalready runs unconditionally after
copy_file()for the same reason).Testing
Added a regression test,
test_executable_data_in_implicit_namespace_subpackage, modeled onthe existing
test_executable_datatest but using a package_datafile inside an implicit namespace subpackage to reproduce the
double-copy scenario. Confirmed it fails with
AssertionError: Script is not executablewithout the fix, andpasses with it. Full
setuptools/tests/test_build_py.pysuite(18 tests) passes.
ruff check/ruff format --checkclean.Also added a news fragment (
newsfragments/5296.bugfix.rst) per theTowncrier convention.