Path: blob/main/test/lib/python3.9/site-packages/setuptools/discovery.py
4798 views
"""Automatic discovery of Python modules and packages (for inclusion in the1distribution) and other config values.23For the purposes of this module, the following nomenclature is used:45- "src-layout": a directory representing a Python project that contains a "src"6folder. Everything under the "src" folder is meant to be included in the7distribution when packaging the project. Example::89.10├── tox.ini11├── pyproject.toml12└── src/13└── mypkg/14├── __init__.py15├── mymodule.py16└── my_data_file.txt1718- "flat-layout": a Python project that does not use "src-layout" but instead19have a directory under the project root for each package::2021.22├── tox.ini23├── pyproject.toml24└── mypkg/25├── __init__.py26├── mymodule.py27└── my_data_file.txt2829- "single-module": a project that contains a single Python script direct under30the project root (no directory used)::3132.33├── tox.ini34├── pyproject.toml35└── mymodule.py3637"""3839import itertools40import os41from fnmatch import fnmatchcase42from glob import glob43from pathlib import Path44from typing import TYPE_CHECKING45from typing import Callable, Dict, Iterator, Iterable, List, Optional, Tuple, Union4647import _distutils_hack.override # noqa: F4014849from distutils import log50from distutils.util import convert_path5152_Path = Union[str, os.PathLike]53_Filter = Callable[[str], bool]54StrIter = Iterator[str]5556chain_iter = itertools.chain.from_iterable5758if TYPE_CHECKING:59from setuptools import Distribution # noqa606162def _valid_name(path: _Path) -> bool:63# Ignore invalid names that cannot be imported directly64return os.path.basename(path).isidentifier()656667class _Finder:68"""Base class that exposes functionality for module/package finders"""6970ALWAYS_EXCLUDE: Tuple[str, ...] = ()71DEFAULT_EXCLUDE: Tuple[str, ...] = ()7273@classmethod74def find(75cls,76where: _Path = '.',77exclude: Iterable[str] = (),78include: Iterable[str] = ('*',)79) -> List[str]:80"""Return a list of all Python items (packages or modules, depending on81the finder implementation) found within directory 'where'.8283'where' is the root directory which will be searched.84It should be supplied as a "cross-platform" (i.e. URL-style) path;85it will be converted to the appropriate local path syntax.8687'exclude' is a sequence of names to exclude; '*' can be used88as a wildcard in the names.89When finding packages, 'foo.*' will exclude all subpackages of 'foo'90(but not 'foo' itself).9192'include' is a sequence of names to include.93If it's specified, only the named items will be included.94If it's not specified, all found items will be included.95'include' can contain shell style wildcard patterns just like96'exclude'.97"""9899exclude = exclude or cls.DEFAULT_EXCLUDE100return list(101cls._find_iter(102convert_path(str(where)),103cls._build_filter(*cls.ALWAYS_EXCLUDE, *exclude),104cls._build_filter(*include),105)106)107108@classmethod109def _find_iter(cls, where: _Path, exclude: _Filter, include: _Filter) -> StrIter:110raise NotImplementedError111112@staticmethod113def _build_filter(*patterns: str) -> _Filter:114"""115Given a list of patterns, return a callable that will be true only if116the input matches at least one of the patterns.117"""118return lambda name: any(fnmatchcase(name, pat) for pat in patterns)119120121class PackageFinder(_Finder):122"""123Generate a list of all Python packages found within a directory124"""125126ALWAYS_EXCLUDE = ("ez_setup", "*__pycache__")127128@classmethod129def _find_iter(cls, where: _Path, exclude: _Filter, include: _Filter) -> StrIter:130"""131All the packages found in 'where' that pass the 'include' filter, but132not the 'exclude' filter.133"""134for root, dirs, files in os.walk(str(where), followlinks=True):135# Copy dirs to iterate over it, then empty dirs.136all_dirs = dirs[:]137dirs[:] = []138139for dir in all_dirs:140full_path = os.path.join(root, dir)141rel_path = os.path.relpath(full_path, where)142package = rel_path.replace(os.path.sep, '.')143144# Skip directory trees that are not valid packages145if '.' in dir or not cls._looks_like_package(full_path, package):146continue147148# Should this package be included?149if include(package) and not exclude(package):150yield package151152# Keep searching subdirectories, as there may be more packages153# down there, even if the parent was excluded.154dirs.append(dir)155156@staticmethod157def _looks_like_package(path: _Path, _package_name: str) -> bool:158"""Does a directory look like a package?"""159return os.path.isfile(os.path.join(path, '__init__.py'))160161162class PEP420PackageFinder(PackageFinder):163@staticmethod164def _looks_like_package(_path: _Path, _package_name: str) -> bool:165return True166167168class ModuleFinder(_Finder):169"""Find isolated Python modules.170This function will **not** recurse subdirectories.171"""172173@classmethod174def _find_iter(cls, where: _Path, exclude: _Filter, include: _Filter) -> StrIter:175for file in glob(os.path.join(where, "*.py")):176module, _ext = os.path.splitext(os.path.basename(file))177178if not cls._looks_like_module(module):179continue180181if include(module) and not exclude(module):182yield module183184_looks_like_module = staticmethod(_valid_name)185186187# We have to be extra careful in the case of flat layout to not include files188# and directories not meant for distribution (e.g. tool-related)189190191class FlatLayoutPackageFinder(PEP420PackageFinder):192_EXCLUDE = (193"ci",194"bin",195"doc",196"docs",197"documentation",198"manpages",199"news",200"changelog",201"test",202"tests",203"unit_test",204"unit_tests",205"example",206"examples",207"scripts",208"tools",209"util",210"utils",211"python",212"build",213"dist",214"venv",215"env",216"requirements",217# ---- Task runners / Build tools ----218"tasks", # invoke219"fabfile", # fabric220"site_scons", # SCons221# ---- Other tools ----222"benchmark",223"benchmarks",224"exercise",225"exercises",226# ---- Hidden directories/Private packages ----227"[._]*",228)229230DEFAULT_EXCLUDE = tuple(chain_iter((p, f"{p}.*") for p in _EXCLUDE))231"""Reserved package names"""232233@staticmethod234def _looks_like_package(_path: _Path, package_name: str) -> bool:235names = package_name.split('.')236# Consider PEP 561237root_pkg_is_valid = names[0].isidentifier() or names[0].endswith("-stubs")238return root_pkg_is_valid and all(name.isidentifier() for name in names[1:])239240241class FlatLayoutModuleFinder(ModuleFinder):242DEFAULT_EXCLUDE = (243"setup",244"conftest",245"test",246"tests",247"example",248"examples",249"build",250# ---- Task runners ----251"toxfile",252"noxfile",253"pavement",254"dodo",255"tasks",256"fabfile",257# ---- Other tools ----258"[Ss][Cc]onstruct", # SCons259"conanfile", # Connan: C/C++ build tool260"manage", # Django261"benchmark",262"benchmarks",263"exercise",264"exercises",265# ---- Hidden files/Private modules ----266"[._]*",267)268"""Reserved top-level module names"""269270271def _find_packages_within(root_pkg: str, pkg_dir: _Path) -> List[str]:272nested = PEP420PackageFinder.find(pkg_dir)273return [root_pkg] + [".".join((root_pkg, n)) for n in nested]274275276class ConfigDiscovery:277"""Fill-in metadata and options that can be automatically derived278(from other metadata/options, the file system or conventions)279"""280281def __init__(self, distribution: "Distribution"):282self.dist = distribution283self._called = False284self._disabled = False285self._skip_ext_modules = False286287def _disable(self):288"""Internal API to disable automatic discovery"""289self._disabled = True290291def _ignore_ext_modules(self):292"""Internal API to disregard ext_modules.293294Normally auto-discovery would not be triggered if ``ext_modules`` are set295(this is done for backward compatibility with existing packages relying on296``setup.py`` or ``setup.cfg``). However, ``setuptools`` can call this function297to ignore given ``ext_modules`` and proceed with the auto-discovery if298``packages`` and ``py_modules`` are not given (e.g. when using pyproject.toml299metadata).300"""301self._skip_ext_modules = True302303@property304def _root_dir(self) -> _Path:305# The best is to wait until `src_root` is set in dist, before using _root_dir.306return self.dist.src_root or os.curdir307308@property309def _package_dir(self) -> Dict[str, str]:310if self.dist.package_dir is None:311return {}312return self.dist.package_dir313314def __call__(self, force=False, name=True, ignore_ext_modules=False):315"""Automatically discover missing configuration fields316and modifies the given ``distribution`` object in-place.317318Note that by default this will only have an effect the first time the319``ConfigDiscovery`` object is called.320321To repeatedly invoke automatic discovery (e.g. when the project322directory changes), please use ``force=True`` (or create a new323``ConfigDiscovery`` instance).324"""325if force is False and (self._called or self._disabled):326# Avoid overhead of multiple calls327return328329self._analyse_package_layout(ignore_ext_modules)330if name:331self.analyse_name() # depends on ``packages`` and ``py_modules``332333self._called = True334335def _explicitly_specified(self, ignore_ext_modules: bool) -> bool:336"""``True`` if the user has specified some form of package/module listing"""337ignore_ext_modules = ignore_ext_modules or self._skip_ext_modules338ext_modules = not (self.dist.ext_modules is None or ignore_ext_modules)339return (340self.dist.packages is not None341or self.dist.py_modules is not None342or ext_modules343or hasattr(self.dist, "configuration") and self.dist.configuration344# ^ Some projects use numpy.distutils.misc_util.Configuration345)346347def _analyse_package_layout(self, ignore_ext_modules: bool) -> bool:348if self._explicitly_specified(ignore_ext_modules):349# For backward compatibility, just try to find modules/packages350# when nothing is given351return True352353log.debug(354"No `packages` or `py_modules` configuration, performing "355"automatic discovery."356)357358return (359self._analyse_explicit_layout()360or self._analyse_src_layout()361# flat-layout is the trickiest for discovery so it should be last362or self._analyse_flat_layout()363)364365def _analyse_explicit_layout(self) -> bool:366"""The user can explicitly give a package layout via ``package_dir``"""367package_dir = self._package_dir.copy() # don't modify directly368package_dir.pop("", None) # This falls under the "src-layout" umbrella369root_dir = self._root_dir370371if not package_dir:372return False373374log.debug(f"`explicit-layout` detected -- analysing {package_dir}")375pkgs = chain_iter(376_find_packages_within(pkg, os.path.join(root_dir, parent_dir))377for pkg, parent_dir in package_dir.items()378)379self.dist.packages = list(pkgs)380log.debug(f"discovered packages -- {self.dist.packages}")381return True382383def _analyse_src_layout(self) -> bool:384"""Try to find all packages or modules under the ``src`` directory385(or anything pointed by ``package_dir[""]``).386387The "src-layout" is relatively safe for automatic discovery.388We assume that everything within is meant to be included in the389distribution.390391If ``package_dir[""]`` is not given, but the ``src`` directory exists,392this function will set ``package_dir[""] = "src"``.393"""394package_dir = self._package_dir395src_dir = os.path.join(self._root_dir, package_dir.get("", "src"))396if not os.path.isdir(src_dir):397return False398399log.debug(f"`src-layout` detected -- analysing {src_dir}")400package_dir.setdefault("", os.path.basename(src_dir))401self.dist.package_dir = package_dir # persist eventual modifications402self.dist.packages = PEP420PackageFinder.find(src_dir)403self.dist.py_modules = ModuleFinder.find(src_dir)404log.debug(f"discovered packages -- {self.dist.packages}")405log.debug(f"discovered py_modules -- {self.dist.py_modules}")406return True407408def _analyse_flat_layout(self) -> bool:409"""Try to find all packages and modules under the project root.410411Since the ``flat-layout`` is more dangerous in terms of accidentally including412extra files/directories, this function is more conservative and will raise an413error if multiple packages or modules are found.414415This assumes that multi-package dists are uncommon and refuse to support that416use case in order to be able to prevent unintended errors.417"""418log.debug(f"`flat-layout` detected -- analysing {self._root_dir}")419return self._analyse_flat_packages() or self._analyse_flat_modules()420421def _analyse_flat_packages(self) -> bool:422self.dist.packages = FlatLayoutPackageFinder.find(self._root_dir)423top_level = remove_nested_packages(remove_stubs(self.dist.packages))424log.debug(f"discovered packages -- {self.dist.packages}")425self._ensure_no_accidental_inclusion(top_level, "packages")426return bool(top_level)427428def _analyse_flat_modules(self) -> bool:429self.dist.py_modules = FlatLayoutModuleFinder.find(self._root_dir)430log.debug(f"discovered py_modules -- {self.dist.py_modules}")431self._ensure_no_accidental_inclusion(self.dist.py_modules, "modules")432return bool(self.dist.py_modules)433434def _ensure_no_accidental_inclusion(self, detected: List[str], kind: str):435if len(detected) > 1:436from inspect import cleandoc437from setuptools.errors import PackageDiscoveryError438439msg = f"""Multiple top-level {kind} discovered in a flat-layout: {detected}.440441To avoid accidental inclusion of unwanted files or directories,442setuptools will not proceed with this build.443444If you are trying to create a single distribution with multiple {kind}445on purpose, you should not rely on automatic discovery.446Instead, consider the following options:4474481. set up custom discovery (`find` directive with `include` or `exclude`)4492. use a `src-layout`4503. explicitly set `py_modules` or `packages` with a list of names451452To find more information, look for "package discovery" on setuptools docs.453"""454raise PackageDiscoveryError(cleandoc(msg))455456def analyse_name(self):457"""The packages/modules are the essential contribution of the author.458Therefore the name of the distribution can be derived from them.459"""460if self.dist.metadata.name or self.dist.name:461# get_name() is not reliable (can return "UNKNOWN")462return None463464log.debug("No `name` configuration, performing automatic discovery")465466name = (467self._find_name_single_package_or_module()468or self._find_name_from_packages()469)470if name:471self.dist.metadata.name = name472self.dist.name = name473474def _find_name_single_package_or_module(self) -> Optional[str]:475"""Exactly one module or package"""476for field in ('packages', 'py_modules'):477items = getattr(self.dist, field, None) or []478if items and len(items) == 1:479log.debug(f"Single module/package detected, name: {items[0]}")480return items[0]481482return None483484def _find_name_from_packages(self) -> Optional[str]:485"""Try to find the root package that is not a PEP 420 namespace"""486if not self.dist.packages:487return None488489packages = remove_stubs(sorted(self.dist.packages, key=len))490package_dir = self.dist.package_dir or {}491492parent_pkg = find_parent_package(packages, package_dir, self._root_dir)493if parent_pkg:494log.debug(f"Common parent package detected, name: {parent_pkg}")495return parent_pkg496497log.warn("No parent package detected, impossible to derive `name`")498return None499500501def remove_nested_packages(packages: List[str]) -> List[str]:502"""Remove nested packages from a list of packages.503504>>> remove_nested_packages(["a", "a.b1", "a.b2", "a.b1.c1"])505['a']506>>> remove_nested_packages(["a", "b", "c.d", "c.d.e.f", "g.h", "a.a1"])507['a', 'b', 'c.d', 'g.h']508"""509pkgs = sorted(packages, key=len)510top_level = pkgs[:]511size = len(pkgs)512for i, name in enumerate(reversed(pkgs)):513if any(name.startswith(f"{other}.") for other in top_level):514top_level.pop(size - i - 1)515516return top_level517518519def remove_stubs(packages: List[str]) -> List[str]:520"""Remove type stubs (:pep:`561`) from a list of packages.521522>>> remove_stubs(["a", "a.b", "a-stubs", "a-stubs.b.c", "b", "c-stubs"])523['a', 'a.b', 'b']524"""525return [pkg for pkg in packages if not pkg.split(".")[0].endswith("-stubs")]526527528def find_parent_package(529packages: List[str], package_dir: Dict[str, str], root_dir: _Path530) -> Optional[str]:531"""Find the parent package that is not a namespace."""532packages = sorted(packages, key=len)533common_ancestors = []534for i, name in enumerate(packages):535if not all(n.startswith(f"{name}.") for n in packages[i+1:]):536# Since packages are sorted by length, this condition is able537# to find a list of all common ancestors.538# When there is divergence (e.g. multiple root packages)539# the list will be empty540break541common_ancestors.append(name)542543for name in common_ancestors:544pkg_path = find_package_path(name, package_dir, root_dir)545init = os.path.join(pkg_path, "__init__.py")546if os.path.isfile(init):547return name548549return None550551552def find_package_path(name: str, package_dir: Dict[str, str], root_dir: _Path) -> str:553"""Given a package name, return the path where it should be found on554disk, considering the ``package_dir`` option.555556>>> path = find_package_path("my.pkg", {"": "root/is/nested"}, ".")557>>> path.replace(os.sep, "/")558'./root/is/nested/my/pkg'559560>>> path = find_package_path("my.pkg", {"my": "root/is/nested"}, ".")561>>> path.replace(os.sep, "/")562'./root/is/nested/pkg'563564>>> path = find_package_path("my.pkg", {"my.pkg": "root/is/nested"}, ".")565>>> path.replace(os.sep, "/")566'./root/is/nested'567568>>> path = find_package_path("other.pkg", {"my.pkg": "root/is/nested"}, ".")569>>> path.replace(os.sep, "/")570'./other/pkg'571"""572parts = name.split(".")573for i in range(len(parts), 0, -1):574# Look backwards, the most specific package_dir first575partial_name = ".".join(parts[:i])576if partial_name in package_dir:577parent = package_dir[partial_name]578return os.path.join(root_dir, parent, *parts[i:])579580parent = package_dir.get("") or ""581return os.path.join(root_dir, *parent.split("/"), *parts)582583584def construct_package_dir(packages: List[str], package_path: _Path) -> Dict[str, str]:585parent_pkgs = remove_nested_packages(packages)586prefix = Path(package_path).parts587return {pkg: "/".join([*prefix, *pkg.split(".")]) for pkg in parent_pkgs}588589590