Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions newsfragments/3260.bugfix.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fixed ``packages.find.exclude`` so excluded nested Python subpackages are no
longer copied into wheels through ``include_package_data=True``.
119 changes: 104 additions & 15 deletions setuptools/command/build_py.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import os
import stat
import textwrap
from collections.abc import Iterable, Iterator
from collections.abc import Iterable, Iterator, Mapping
from functools import partial
from glob import glob
from pathlib import Path
Expand All @@ -15,6 +15,7 @@
from more_itertools import unique_everseen

from .._path import StrPath, StrPathT
from ..compat.py310 import tomllib
from ..dist import Distribution
from ..warnings import SetuptoolsDeprecationWarning

Expand Down Expand Up @@ -196,24 +197,19 @@ def analyze_manifest(self) -> None:
egg_info_dir = ei_cmd.egg_info
files = ei_cmd.filelist.files

check = _IncludePackageDataAbuse()
excluded = _find_packages_exclude_patterns(self.distribution)
check = _IncludePackageDataAbuse(excluded)
for path in self._filter_build_files(files, egg_info_dir):
d, f = os.path.split(assert_relative(path))
prev = None
oldf = f
while d and d != prev and d not in src_dirs:
prev = d
d, df = os.path.split(d)
f = os.path.join(df, f)
if d in src_dirs:
if f == oldf:
if dunder_path := _find_package_data_owner(path, src_dirs, excluded):
package, f, direct_path = dunder_path
if direct_path:
if check.is_module(f):
continue # it's a module, not data
else:
importable = check.importable_subpackage(src_dirs[d], f)
importable = check.importable_subpackage(package, f)
if importable:
check.warn(importable)
self.manifest_files.setdefault(src_dirs[d], []).append(path)
self.manifest_files.setdefault(package, []).append(path)

def _filter_build_files(
self, files: Iterable[str], egg_info: StrPath
Expand Down Expand Up @@ -338,6 +334,93 @@ def assert_relative(path):
raise DistutilsSetupError(msg)


def _contains_python_sources(directory: str) -> bool:
return any(
all(
part.isidentifier()
for part in candidate.relative_to(directory).with_suffix("").parts
)
for candidate in Path(directory).glob("**/*.py")
)


def _find_packages_exclude_patterns(dist: Distribution) -> tuple[str, ...]:
setup_cfg = dist.command_options.get("options.packages.find", {})
if exclude := setup_cfg.get("exclude"):
_origin, patterns = exclude
return _split_find_excludes(patterns)

pyproject = Path("pyproject.toml")
if not pyproject.exists():
return ()

with pyproject.open("rb") as file:
data = tomllib.load(file)

setuptools_cfg = data.get("tool", {}).get("setuptools", {})
packages = setuptools_cfg.get("packages", {})
if not isinstance(packages, Mapping):
return ()

find = packages.get("find", {})
if not isinstance(find, Mapping):
return ()

patterns = find.get("exclude", [])
if isinstance(patterns, str):
return (patterns,)
if isinstance(patterns, Iterable):
return tuple(patterns)
return ()


def _split_find_excludes(patterns: str) -> tuple[str, ...]:
return tuple(filter(None, (line.strip() for line in patterns.splitlines())))


def _is_explicitly_excluded(
parent: str, filename: str, excluded: tuple[str, ...]
) -> bool:
if not excluded:
return False

package = Path(filename).parent
parts = list(itertools.takewhile(str.isidentifier, package.parts))
if not parts:
return False

candidates = (".".join([parent, *parts[: i + 1]]) for i in range(len(parts)))
return any(
fnmatch.fnmatchcase(candidate, pattern)
for candidate in candidates
for pattern in excluded
)


def _find_package_data_owner(
path: str, src_dirs: dict[str, str], excluded: tuple[str, ...]
) -> tuple[str, str, bool] | None:
directory, filename = os.path.split(assert_relative(path))
previous = None
original = filename

while directory and directory != previous and directory not in src_dirs:
if _contains_python_sources(directory):
return None
previous = directory
directory, parent = os.path.split(directory)
filename = os.path.join(parent, filename)

if directory not in src_dirs:
return None

package = src_dirs[directory]
if _is_explicitly_excluded(package, filename, excluded):
return None

return package, filename, filename == original


class _IncludePackageDataAbuse:
"""Inform users that package or module is included as 'data file'"""

Expand Down Expand Up @@ -384,8 +467,9 @@ class _Warning(SetuptoolsDeprecationWarning):
# _DUE_DATE: still not defined as this is particularly controversial.
# Warning initially introduced in May 2022. See issue #3340 for discussion.

def __init__(self) -> None:
def __init__(self, excluded: tuple[str, ...] = ()) -> None:
self._already_warned = set[str]()
self._excluded = excluded

def is_module(self, file):
return file.endswith(".py") and file[: -len(".py")].isidentifier()
Expand All @@ -394,7 +478,12 @@ def importable_subpackage(self, parent, file):
pkg = Path(file).parent
parts = list(itertools.takewhile(str.isidentifier, pkg.parts))
if parts:
return ".".join([parent, *parts])
importable = ".".join([parent, *parts])
if any(
fnmatch.fnmatchcase(importable, pattern) for pattern in self._excluded
):
return None
return importable
return None

def warn(self, importable):
Expand Down
Loading
Loading