Skip to content

Fix executable bit lost on package_data files that are also modules (#5296) - #5302

Open
agu2347 wants to merge 1 commit into
pypa:mainfrom
agu2347:fix-package-data-executable-bit-namespace-subpackage
Open

agu2347 wants to merge 1 commit into
pypa:mainfrom
agu2347:fix-package-data-executable-bit-namespace-subpackage

Conversation

@agu2347

@agu2347 agu2347 commented Aug 15, 2026

Copy link
Copy Markdown

Summary

Fixes #5296.

setuptools 84.0.0 no longer preserves the executable bit (or other
mode bits) on package_data files that also happen to be discovered
as Python modules -- specifically, .py files that live inside an
implicit namespace subpackage (a subdirectory with no __init__.py).

Root cause

Such a file gets copied into build/lib twice while running
build_py:

  1. build_module() discovers it as a "module" (via PEP 420 implicit
    namespace package auto-discovery) and copies it with
    preserve_mode=False.
  2. build_package_data() also matches it via the declared
    package_data glob, and copies it again -- this time with
    preserve_mode=True (the default), which is supposed to restore
    the correct mode.

Because the first copy already sets the destination's mtime to match
the source (preserve_times=True), the second copy's update=True
"already up-to-date" check (copy_file() comparing newer(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's
newer()-adjacent copy_file logic started comparing full-precision
st_mtime floats (previously it effectively worked with
lower-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 both
83.0.0 and 84.0.0 with debug prints: in 83.0.0 the second copy's
newer(src, dst) check evaluates True (so it copies and fixes the
mode); in 84.0.0 it evaluates False (so it's skipped, leaving the
file non-executable).

Fix

In build_py.build_package_data(), explicitly sync the destination's
mode 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), which
already runs unconditionally after copy_file() for the same reason).

Testing

Added a regression test,
test_executable_data_in_implicit_namespace_subpackage, modeled on
the existing test_executable_data test but using a package_data
file inside an implicit namespace subpackage to reproduce the
double-copy scenario. Confirmed it fails with
AssertionError: Script is not executable without the fix, and
passes with it. Full setuptools/tests/test_build_py.py suite
(18 tests) passes. ruff check / ruff format --check clean.

Also added a news fragment (newsfragments/5296.bugfix.rst) per the
Towncrier convention.

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
@mergify

mergify Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue).

  • Queue this pull request

@CAOShurong CAOShurong left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@agu2347

agu2347 commented Sep 3, 2026

Copy link
Copy Markdown
Author

@Mergifyio queue

@mergify

mergify Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

queue

☑️ Command disallowed due to command restrictions in the Mergify configuration.

Details
  • sender-permission >= write

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] setuptools 84.0.0 no longer preserves executable bit on  package_data  files in wheels

2 participants