import itertools
import os
from fnmatch import fnmatchcase
from glob import glob
from pathlib import Path
from typing import (
TYPE_CHECKING,
Callable,
Dict,
Iterable,
Iterator,
List,
Mapping,
Optional,
Tuple,
Union
)
import _distutils_hack.override
from distutils import log
from distutils.util import convert_path
_Path = Union[str, os.PathLike]
_Filter = Callable[[str], bool]
StrIter = Iterator[str]
chain_iter = itertools.chain.from_iterable
if TYPE_CHECKING:
from setuptools import Distribution
def _valid_name(path: _Path) -> bool:
return os.path.basename(path).isidentifier()
class _Finder:
ALWAYS_EXCLUDE: Tuple[str, ...] = ()
DEFAULT_EXCLUDE: Tuple[str, ...] = ()
@classmethod
def find(
cls,
where: _Path = '.',
exclude: Iterable[str] = (),
include: Iterable[str] = ('*',)
) -> List[str]:
exclude = exclude or cls.DEFAULT_EXCLUDE
return list(
cls._find_iter(
convert_path(str(where)),
cls._build_filter(*cls.ALWAYS_EXCLUDE, *exclude),
cls._build_filter(*include),
)
)
@classmethod
def _find_iter(cls, where: _Path, exclude: _Filter, include: _Filter) -> StrIter:
raise NotImplementedError
@staticmethod
def _build_filter(*patterns: str) -> _Filter:
return lambda name: any(fnmatchcase(name, pat) for pat in patterns)
class PackageFinder(_Finder):
ALWAYS_EXCLUDE = ("ez_setup", "*__pycache__")
@classmethod
def _find_iter(cls, where: _Path, exclude: _Filter, include: _Filter) -> StrIter:
for root, dirs, files in os.walk(str(where), followlinks=True):
all_dirs = dirs[:]
dirs[:] = []
for dir in all_dirs:
full_path = os.path.join(root, dir)
rel_path = os.path.relpath(full_path, where)
package = rel_path.replace(os.path.sep, '.')
if '.' in dir or not cls._looks_like_package(full_path, package):
continue
if include(package) and not exclude(package):
yield package
dirs.append(dir)
@staticmethod
def _looks_like_package(path: _Path, _package_name: str) -> bool:
return os.path.isfile(os.path.join(path, '__init__.py'))
class PEP420PackageFinder(PackageFinder):
@staticmethod
def _looks_like_package(_path: _Path, _package_name: str) -> bool:
return True
class ModuleFinder(_Finder):
@classmethod
def _find_iter(cls, where: _Path, exclude: _Filter, include: _Filter) -> StrIter:
for file in glob(os.path.join(where, "*.py")):
module, _ext = os.path.splitext(os.path.basename(file))
if not cls._looks_like_module(module):
continue
if include(module) and not exclude(module):
yield module
_looks_like_module = staticmethod(_valid_name)
class FlatLayoutPackageFinder(PEP420PackageFinder):
_EXCLUDE = (
"ci",
"bin",
"doc",
"docs",
"documentation",
"manpages",
"news",
"changelog",
"test",
"tests",
"unit_test",
"unit_tests",
"example",
"examples",
"scripts",
"tools",
"util",
"utils",
"python",
"build",
"dist",
"venv",
"env",
"requirements",
"tasks", "fabfile", "site_scons", "benchmark",
"benchmarks",
"exercise",
"exercises",
"[._]*",
)
DEFAULT_EXCLUDE = tuple(chain_iter((p, f"{p}.*") for p in _EXCLUDE))
@staticmethod
def _looks_like_package(_path: _Path, package_name: str) -> bool:
names = package_name.split('.')
root_pkg_is_valid = names[0].isidentifier() or names[0].endswith("-stubs")
return root_pkg_is_valid and all(name.isidentifier() for name in names[1:])
class FlatLayoutModuleFinder(ModuleFinder):
DEFAULT_EXCLUDE = (
"setup",
"conftest",
"test",
"tests",
"example",
"examples",
"build",
"toxfile",
"noxfile",
"pavement",
"dodo",
"tasks",
"fabfile",
"[Ss][Cc]onstruct", "conanfile", "manage", "benchmark",
"benchmarks",
"exercise",
"exercises",
"[._]*",
)
def _find_packages_within(root_pkg: str, pkg_dir: _Path) -> List[str]:
nested = PEP420PackageFinder.find(pkg_dir)
return [root_pkg] + [".".join((root_pkg, n)) for n in nested]
class ConfigDiscovery:
def __init__(self, distribution: "Distribution"):
self.dist = distribution
self._called = False
self._disabled = False
self._skip_ext_modules = False
def _disable(self):
self._disabled = True
def _ignore_ext_modules(self):
self._skip_ext_modules = True
@property
def _root_dir(self) -> _Path:
return self.dist.src_root or os.curdir
@property
def _package_dir(self) -> Dict[str, str]:
if self.dist.package_dir is None:
return {}
return self.dist.package_dir
def __call__(self, force=False, name=True, ignore_ext_modules=False):
if force is False and (self._called or self._disabled):
return
self._analyse_package_layout(ignore_ext_modules)
if name:
self.analyse_name()
self._called = True
def _explicitly_specified(self, ignore_ext_modules: bool) -> bool:
ignore_ext_modules = ignore_ext_modules or self._skip_ext_modules
ext_modules = not (self.dist.ext_modules is None or ignore_ext_modules)
return (
self.dist.packages is not None
or self.dist.py_modules is not None
or ext_modules
or hasattr(self.dist, "configuration") and self.dist.configuration
)
def _analyse_package_layout(self, ignore_ext_modules: bool) -> bool:
if self._explicitly_specified(ignore_ext_modules):
return True
log.debug(
"No `packages` or `py_modules` configuration, performing "
"automatic discovery."
)
return (
self._analyse_explicit_layout()
or self._analyse_src_layout()
or self._analyse_flat_layout()
)
def _analyse_explicit_layout(self) -> bool:
package_dir = self._package_dir.copy() package_dir.pop("", None) root_dir = self._root_dir
if not package_dir:
return False
log.debug(f"`explicit-layout` detected -- analysing {package_dir}")
pkgs = chain_iter(
_find_packages_within(pkg, os.path.join(root_dir, parent_dir))
for pkg, parent_dir in package_dir.items()
)
self.dist.packages = list(pkgs)
log.debug(f"discovered packages -- {self.dist.packages}")
return True
def _analyse_src_layout(self) -> bool:
package_dir = self._package_dir
src_dir = os.path.join(self._root_dir, package_dir.get("", "src"))
if not os.path.isdir(src_dir):
return False
log.debug(f"`src-layout` detected -- analysing {src_dir}")
package_dir.setdefault("", os.path.basename(src_dir))
self.dist.package_dir = package_dir self.dist.packages = PEP420PackageFinder.find(src_dir)
self.dist.py_modules = ModuleFinder.find(src_dir)
log.debug(f"discovered packages -- {self.dist.packages}")
log.debug(f"discovered py_modules -- {self.dist.py_modules}")
return True
def _analyse_flat_layout(self) -> bool:
log.debug(f"`flat-layout` detected -- analysing {self._root_dir}")
return self._analyse_flat_packages() or self._analyse_flat_modules()
def _analyse_flat_packages(self) -> bool:
self.dist.packages = FlatLayoutPackageFinder.find(self._root_dir)
top_level = remove_nested_packages(remove_stubs(self.dist.packages))
log.debug(f"discovered packages -- {self.dist.packages}")
self._ensure_no_accidental_inclusion(top_level, "packages")
return bool(top_level)
def _analyse_flat_modules(self) -> bool:
self.dist.py_modules = FlatLayoutModuleFinder.find(self._root_dir)
log.debug(f"discovered py_modules -- {self.dist.py_modules}")
self._ensure_no_accidental_inclusion(self.dist.py_modules, "modules")
return bool(self.dist.py_modules)
def _ensure_no_accidental_inclusion(self, detected: List[str], kind: str):
if len(detected) > 1:
from inspect import cleandoc
from setuptools.errors import PackageDiscoveryError
msg = f"""Multiple top-level {kind} discovered in a flat-layout: {detected}.
To avoid accidental inclusion of unwanted files or directories,
setuptools will not proceed with this build.
If you are trying to create a single distribution with multiple {kind}
on purpose, you should not rely on automatic discovery.
Instead, consider the following options:
1. set up custom discovery (`find` directive with `include` or `exclude`)
2. use a `src-layout`
3. explicitly set `py_modules` or `packages` with a list of names
To find more information, look for "package discovery" on setuptools docs.
"""
raise PackageDiscoveryError(cleandoc(msg))
def analyse_name(self):
if self.dist.metadata.name or self.dist.name:
return None
log.debug("No `name` configuration, performing automatic discovery")
name = (
self._find_name_single_package_or_module()
or self._find_name_from_packages()
)
if name:
self.dist.metadata.name = name
def _find_name_single_package_or_module(self) -> Optional[str]:
for field in ('packages', 'py_modules'):
items = getattr(self.dist, field, None) or []
if items and len(items) == 1:
log.debug(f"Single module/package detected, name: {items[0]}")
return items[0]
return None
def _find_name_from_packages(self) -> Optional[str]:
if not self.dist.packages:
return None
packages = remove_stubs(sorted(self.dist.packages, key=len))
package_dir = self.dist.package_dir or {}
parent_pkg = find_parent_package(packages, package_dir, self._root_dir)
if parent_pkg:
log.debug(f"Common parent package detected, name: {parent_pkg}")
return parent_pkg
log.warn("No parent package detected, impossible to derive `name`")
return None
def remove_nested_packages(packages: List[str]) -> List[str]:
pkgs = sorted(packages, key=len)
top_level = pkgs[:]
size = len(pkgs)
for i, name in enumerate(reversed(pkgs)):
if any(name.startswith(f"{other}.") for other in top_level):
top_level.pop(size - i - 1)
return top_level
def remove_stubs(packages: List[str]) -> List[str]:
return [pkg for pkg in packages if not pkg.split(".")[0].endswith("-stubs")]
def find_parent_package(
packages: List[str], package_dir: Mapping[str, str], root_dir: _Path
) -> Optional[str]:
packages = sorted(packages, key=len)
common_ancestors = []
for i, name in enumerate(packages):
if not all(n.startswith(f"{name}.") for n in packages[i+1:]):
break
common_ancestors.append(name)
for name in common_ancestors:
pkg_path = find_package_path(name, package_dir, root_dir)
init = os.path.join(pkg_path, "__init__.py")
if os.path.isfile(init):
return name
return None
def find_package_path(
name: str, package_dir: Mapping[str, str], root_dir: _Path
) -> str:
parts = name.split(".")
for i in range(len(parts), 0, -1):
partial_name = ".".join(parts[:i])
if partial_name in package_dir:
parent = package_dir[partial_name]
return os.path.join(root_dir, parent, *parts[i:])
parent = package_dir.get("") or ""
return os.path.join(root_dir, *parent.split("/"), *parts)
def construct_package_dir(packages: List[str], package_path: _Path) -> Dict[str, str]:
parent_pkgs = remove_nested_packages(packages)
prefix = Path(package_path).parts
return {pkg: "/".join([*prefix, *pkg.split(".")]) for pkg in parent_pkgs}