jpayne@68: """Automatic discovery of Python modules and packages (for inclusion in the jpayne@68: distribution) and other config values. jpayne@68: jpayne@68: For the purposes of this module, the following nomenclature is used: jpayne@68: jpayne@68: - "src-layout": a directory representing a Python project that contains a "src" jpayne@68: folder. Everything under the "src" folder is meant to be included in the jpayne@68: distribution when packaging the project. Example:: jpayne@68: jpayne@68: . jpayne@68: ├── tox.ini jpayne@68: ├── pyproject.toml jpayne@68: └── src/ jpayne@68: └── mypkg/ jpayne@68: ├── __init__.py jpayne@68: ├── mymodule.py jpayne@68: └── my_data_file.txt jpayne@68: jpayne@68: - "flat-layout": a Python project that does not use "src-layout" but instead jpayne@68: have a directory under the project root for each package:: jpayne@68: jpayne@68: . jpayne@68: ├── tox.ini jpayne@68: ├── pyproject.toml jpayne@68: └── mypkg/ jpayne@68: ├── __init__.py jpayne@68: ├── mymodule.py jpayne@68: └── my_data_file.txt jpayne@68: jpayne@68: - "single-module": a project that contains a single Python script direct under jpayne@68: the project root (no directory used):: jpayne@68: jpayne@68: . jpayne@68: ├── tox.ini jpayne@68: ├── pyproject.toml jpayne@68: └── mymodule.py jpayne@68: jpayne@68: """ jpayne@68: jpayne@68: from __future__ import annotations jpayne@68: jpayne@68: import itertools jpayne@68: import os jpayne@68: from collections.abc import Iterator jpayne@68: from fnmatch import fnmatchcase jpayne@68: from glob import glob jpayne@68: from pathlib import Path jpayne@68: from typing import TYPE_CHECKING, Iterable, Mapping jpayne@68: jpayne@68: import _distutils_hack.override # noqa: F401 jpayne@68: jpayne@68: from ._path import StrPath jpayne@68: jpayne@68: from distutils import log jpayne@68: from distutils.util import convert_path jpayne@68: jpayne@68: if TYPE_CHECKING: jpayne@68: from setuptools import Distribution jpayne@68: jpayne@68: chain_iter = itertools.chain.from_iterable jpayne@68: jpayne@68: jpayne@68: def _valid_name(path: StrPath) -> bool: jpayne@68: # Ignore invalid names that cannot be imported directly jpayne@68: return os.path.basename(path).isidentifier() jpayne@68: jpayne@68: jpayne@68: class _Filter: jpayne@68: """ jpayne@68: Given a list of patterns, create a callable that will be true only if jpayne@68: the input matches at least one of the patterns. jpayne@68: """ jpayne@68: jpayne@68: def __init__(self, *patterns: str): jpayne@68: self._patterns = dict.fromkeys(patterns) jpayne@68: jpayne@68: def __call__(self, item: str) -> bool: jpayne@68: return any(fnmatchcase(item, pat) for pat in self._patterns) jpayne@68: jpayne@68: def __contains__(self, item: str) -> bool: jpayne@68: return item in self._patterns jpayne@68: jpayne@68: jpayne@68: class _Finder: jpayne@68: """Base class that exposes functionality for module/package finders""" jpayne@68: jpayne@68: ALWAYS_EXCLUDE: tuple[str, ...] = () jpayne@68: DEFAULT_EXCLUDE: tuple[str, ...] = () jpayne@68: jpayne@68: @classmethod jpayne@68: def find( jpayne@68: cls, jpayne@68: where: StrPath = '.', jpayne@68: exclude: Iterable[str] = (), jpayne@68: include: Iterable[str] = ('*',), jpayne@68: ) -> list[str]: jpayne@68: """Return a list of all Python items (packages or modules, depending on jpayne@68: the finder implementation) found within directory 'where'. jpayne@68: jpayne@68: 'where' is the root directory which will be searched. jpayne@68: It should be supplied as a "cross-platform" (i.e. URL-style) path; jpayne@68: it will be converted to the appropriate local path syntax. jpayne@68: jpayne@68: 'exclude' is a sequence of names to exclude; '*' can be used jpayne@68: as a wildcard in the names. jpayne@68: When finding packages, 'foo.*' will exclude all subpackages of 'foo' jpayne@68: (but not 'foo' itself). jpayne@68: jpayne@68: 'include' is a sequence of names to include. jpayne@68: If it's specified, only the named items will be included. jpayne@68: If it's not specified, all found items will be included. jpayne@68: 'include' can contain shell style wildcard patterns just like jpayne@68: 'exclude'. jpayne@68: """ jpayne@68: jpayne@68: exclude = exclude or cls.DEFAULT_EXCLUDE jpayne@68: return list( jpayne@68: cls._find_iter( jpayne@68: convert_path(str(where)), jpayne@68: _Filter(*cls.ALWAYS_EXCLUDE, *exclude), jpayne@68: _Filter(*include), jpayne@68: ) jpayne@68: ) jpayne@68: jpayne@68: @classmethod jpayne@68: def _find_iter( jpayne@68: cls, where: StrPath, exclude: _Filter, include: _Filter jpayne@68: ) -> Iterator[str]: jpayne@68: raise NotImplementedError jpayne@68: jpayne@68: jpayne@68: class PackageFinder(_Finder): jpayne@68: """ jpayne@68: Generate a list of all Python packages found within a directory jpayne@68: """ jpayne@68: jpayne@68: ALWAYS_EXCLUDE = ("ez_setup", "*__pycache__") jpayne@68: jpayne@68: @classmethod jpayne@68: def _find_iter( jpayne@68: cls, where: StrPath, exclude: _Filter, include: _Filter jpayne@68: ) -> Iterator[str]: jpayne@68: """ jpayne@68: All the packages found in 'where' that pass the 'include' filter, but jpayne@68: not the 'exclude' filter. jpayne@68: """ jpayne@68: for root, dirs, files in os.walk(str(where), followlinks=True): jpayne@68: # Copy dirs to iterate over it, then empty dirs. jpayne@68: all_dirs = dirs[:] jpayne@68: dirs[:] = [] jpayne@68: jpayne@68: for dir in all_dirs: jpayne@68: full_path = os.path.join(root, dir) jpayne@68: rel_path = os.path.relpath(full_path, where) jpayne@68: package = rel_path.replace(os.path.sep, '.') jpayne@68: jpayne@68: # Skip directory trees that are not valid packages jpayne@68: if '.' in dir or not cls._looks_like_package(full_path, package): jpayne@68: continue jpayne@68: jpayne@68: # Should this package be included? jpayne@68: if include(package) and not exclude(package): jpayne@68: yield package jpayne@68: jpayne@68: # Early pruning if there is nothing else to be scanned jpayne@68: if f"{package}*" in exclude or f"{package}.*" in exclude: jpayne@68: continue jpayne@68: jpayne@68: # Keep searching subdirectories, as there may be more packages jpayne@68: # down there, even if the parent was excluded. jpayne@68: dirs.append(dir) jpayne@68: jpayne@68: @staticmethod jpayne@68: def _looks_like_package(path: StrPath, _package_name: str) -> bool: jpayne@68: """Does a directory look like a package?""" jpayne@68: return os.path.isfile(os.path.join(path, '__init__.py')) jpayne@68: jpayne@68: jpayne@68: class PEP420PackageFinder(PackageFinder): jpayne@68: @staticmethod jpayne@68: def _looks_like_package(_path: StrPath, _package_name: str) -> bool: jpayne@68: return True jpayne@68: jpayne@68: jpayne@68: class ModuleFinder(_Finder): jpayne@68: """Find isolated Python modules. jpayne@68: This function will **not** recurse subdirectories. jpayne@68: """ jpayne@68: jpayne@68: @classmethod jpayne@68: def _find_iter( jpayne@68: cls, where: StrPath, exclude: _Filter, include: _Filter jpayne@68: ) -> Iterator[str]: jpayne@68: for file in glob(os.path.join(where, "*.py")): jpayne@68: module, _ext = os.path.splitext(os.path.basename(file)) jpayne@68: jpayne@68: if not cls._looks_like_module(module): jpayne@68: continue jpayne@68: jpayne@68: if include(module) and not exclude(module): jpayne@68: yield module jpayne@68: jpayne@68: _looks_like_module = staticmethod(_valid_name) jpayne@68: jpayne@68: jpayne@68: # We have to be extra careful in the case of flat layout to not include files jpayne@68: # and directories not meant for distribution (e.g. tool-related) jpayne@68: jpayne@68: jpayne@68: class FlatLayoutPackageFinder(PEP420PackageFinder): jpayne@68: _EXCLUDE = ( jpayne@68: "ci", jpayne@68: "bin", jpayne@68: "debian", jpayne@68: "doc", jpayne@68: "docs", jpayne@68: "documentation", jpayne@68: "manpages", jpayne@68: "news", jpayne@68: "newsfragments", jpayne@68: "changelog", jpayne@68: "test", jpayne@68: "tests", jpayne@68: "unit_test", jpayne@68: "unit_tests", jpayne@68: "example", jpayne@68: "examples", jpayne@68: "scripts", jpayne@68: "tools", jpayne@68: "util", jpayne@68: "utils", jpayne@68: "python", jpayne@68: "build", jpayne@68: "dist", jpayne@68: "venv", jpayne@68: "env", jpayne@68: "requirements", jpayne@68: # ---- Task runners / Build tools ---- jpayne@68: "tasks", # invoke jpayne@68: "fabfile", # fabric jpayne@68: "site_scons", # SCons jpayne@68: # ---- Other tools ---- jpayne@68: "benchmark", jpayne@68: "benchmarks", jpayne@68: "exercise", jpayne@68: "exercises", jpayne@68: "htmlcov", # Coverage.py jpayne@68: # ---- Hidden directories/Private packages ---- jpayne@68: "[._]*", jpayne@68: ) jpayne@68: jpayne@68: DEFAULT_EXCLUDE = tuple(chain_iter((p, f"{p}.*") for p in _EXCLUDE)) jpayne@68: """Reserved package names""" jpayne@68: jpayne@68: @staticmethod jpayne@68: def _looks_like_package(_path: StrPath, package_name: str) -> bool: jpayne@68: names = package_name.split('.') jpayne@68: # Consider PEP 561 jpayne@68: root_pkg_is_valid = names[0].isidentifier() or names[0].endswith("-stubs") jpayne@68: return root_pkg_is_valid and all(name.isidentifier() for name in names[1:]) jpayne@68: jpayne@68: jpayne@68: class FlatLayoutModuleFinder(ModuleFinder): jpayne@68: DEFAULT_EXCLUDE = ( jpayne@68: "setup", jpayne@68: "conftest", jpayne@68: "test", jpayne@68: "tests", jpayne@68: "example", jpayne@68: "examples", jpayne@68: "build", jpayne@68: # ---- Task runners ---- jpayne@68: "toxfile", jpayne@68: "noxfile", jpayne@68: "pavement", jpayne@68: "dodo", jpayne@68: "tasks", jpayne@68: "fabfile", jpayne@68: # ---- Other tools ---- jpayne@68: "[Ss][Cc]onstruct", # SCons jpayne@68: "conanfile", # Connan: C/C++ build tool jpayne@68: "manage", # Django jpayne@68: "benchmark", jpayne@68: "benchmarks", jpayne@68: "exercise", jpayne@68: "exercises", jpayne@68: # ---- Hidden files/Private modules ---- jpayne@68: "[._]*", jpayne@68: ) jpayne@68: """Reserved top-level module names""" jpayne@68: jpayne@68: jpayne@68: def _find_packages_within(root_pkg: str, pkg_dir: StrPath) -> list[str]: jpayne@68: nested = PEP420PackageFinder.find(pkg_dir) jpayne@68: return [root_pkg] + [".".join((root_pkg, n)) for n in nested] jpayne@68: jpayne@68: jpayne@68: class ConfigDiscovery: jpayne@68: """Fill-in metadata and options that can be automatically derived jpayne@68: (from other metadata/options, the file system or conventions) jpayne@68: """ jpayne@68: jpayne@68: def __init__(self, distribution: Distribution): jpayne@68: self.dist = distribution jpayne@68: self._called = False jpayne@68: self._disabled = False jpayne@68: self._skip_ext_modules = False jpayne@68: jpayne@68: def _disable(self): jpayne@68: """Internal API to disable automatic discovery""" jpayne@68: self._disabled = True jpayne@68: jpayne@68: def _ignore_ext_modules(self): jpayne@68: """Internal API to disregard ext_modules. jpayne@68: jpayne@68: Normally auto-discovery would not be triggered if ``ext_modules`` are set jpayne@68: (this is done for backward compatibility with existing packages relying on jpayne@68: ``setup.py`` or ``setup.cfg``). However, ``setuptools`` can call this function jpayne@68: to ignore given ``ext_modules`` and proceed with the auto-discovery if jpayne@68: ``packages`` and ``py_modules`` are not given (e.g. when using pyproject.toml jpayne@68: metadata). jpayne@68: """ jpayne@68: self._skip_ext_modules = True jpayne@68: jpayne@68: @property jpayne@68: def _root_dir(self) -> StrPath: jpayne@68: # The best is to wait until `src_root` is set in dist, before using _root_dir. jpayne@68: return self.dist.src_root or os.curdir jpayne@68: jpayne@68: @property jpayne@68: def _package_dir(self) -> dict[str, str]: jpayne@68: if self.dist.package_dir is None: jpayne@68: return {} jpayne@68: return self.dist.package_dir jpayne@68: jpayne@68: def __call__( jpayne@68: self, force: bool = False, name: bool = True, ignore_ext_modules: bool = False jpayne@68: ): jpayne@68: """Automatically discover missing configuration fields jpayne@68: and modifies the given ``distribution`` object in-place. jpayne@68: jpayne@68: Note that by default this will only have an effect the first time the jpayne@68: ``ConfigDiscovery`` object is called. jpayne@68: jpayne@68: To repeatedly invoke automatic discovery (e.g. when the project jpayne@68: directory changes), please use ``force=True`` (or create a new jpayne@68: ``ConfigDiscovery`` instance). jpayne@68: """ jpayne@68: if force is False and (self._called or self._disabled): jpayne@68: # Avoid overhead of multiple calls jpayne@68: return jpayne@68: jpayne@68: self._analyse_package_layout(ignore_ext_modules) jpayne@68: if name: jpayne@68: self.analyse_name() # depends on ``packages`` and ``py_modules`` jpayne@68: jpayne@68: self._called = True jpayne@68: jpayne@68: def _explicitly_specified(self, ignore_ext_modules: bool) -> bool: jpayne@68: """``True`` if the user has specified some form of package/module listing""" jpayne@68: ignore_ext_modules = ignore_ext_modules or self._skip_ext_modules jpayne@68: ext_modules = not (self.dist.ext_modules is None or ignore_ext_modules) jpayne@68: return ( jpayne@68: self.dist.packages is not None jpayne@68: or self.dist.py_modules is not None jpayne@68: or ext_modules jpayne@68: or hasattr(self.dist, "configuration") jpayne@68: and self.dist.configuration jpayne@68: # ^ Some projects use numpy.distutils.misc_util.Configuration jpayne@68: ) jpayne@68: jpayne@68: def _analyse_package_layout(self, ignore_ext_modules: bool) -> bool: jpayne@68: if self._explicitly_specified(ignore_ext_modules): jpayne@68: # For backward compatibility, just try to find modules/packages jpayne@68: # when nothing is given jpayne@68: return True jpayne@68: jpayne@68: log.debug( jpayne@68: "No `packages` or `py_modules` configuration, performing " jpayne@68: "automatic discovery." jpayne@68: ) jpayne@68: jpayne@68: return ( jpayne@68: self._analyse_explicit_layout() jpayne@68: or self._analyse_src_layout() jpayne@68: # flat-layout is the trickiest for discovery so it should be last jpayne@68: or self._analyse_flat_layout() jpayne@68: ) jpayne@68: jpayne@68: def _analyse_explicit_layout(self) -> bool: jpayne@68: """The user can explicitly give a package layout via ``package_dir``""" jpayne@68: package_dir = self._package_dir.copy() # don't modify directly jpayne@68: package_dir.pop("", None) # This falls under the "src-layout" umbrella jpayne@68: root_dir = self._root_dir jpayne@68: jpayne@68: if not package_dir: jpayne@68: return False jpayne@68: jpayne@68: log.debug(f"`explicit-layout` detected -- analysing {package_dir}") jpayne@68: pkgs = chain_iter( jpayne@68: _find_packages_within(pkg, os.path.join(root_dir, parent_dir)) jpayne@68: for pkg, parent_dir in package_dir.items() jpayne@68: ) jpayne@68: self.dist.packages = list(pkgs) jpayne@68: log.debug(f"discovered packages -- {self.dist.packages}") jpayne@68: return True jpayne@68: jpayne@68: def _analyse_src_layout(self) -> bool: jpayne@68: """Try to find all packages or modules under the ``src`` directory jpayne@68: (or anything pointed by ``package_dir[""]``). jpayne@68: jpayne@68: The "src-layout" is relatively safe for automatic discovery. jpayne@68: We assume that everything within is meant to be included in the jpayne@68: distribution. jpayne@68: jpayne@68: If ``package_dir[""]`` is not given, but the ``src`` directory exists, jpayne@68: this function will set ``package_dir[""] = "src"``. jpayne@68: """ jpayne@68: package_dir = self._package_dir jpayne@68: src_dir = os.path.join(self._root_dir, package_dir.get("", "src")) jpayne@68: if not os.path.isdir(src_dir): jpayne@68: return False jpayne@68: jpayne@68: log.debug(f"`src-layout` detected -- analysing {src_dir}") jpayne@68: package_dir.setdefault("", os.path.basename(src_dir)) jpayne@68: self.dist.package_dir = package_dir # persist eventual modifications jpayne@68: self.dist.packages = PEP420PackageFinder.find(src_dir) jpayne@68: self.dist.py_modules = ModuleFinder.find(src_dir) jpayne@68: log.debug(f"discovered packages -- {self.dist.packages}") jpayne@68: log.debug(f"discovered py_modules -- {self.dist.py_modules}") jpayne@68: return True jpayne@68: jpayne@68: def _analyse_flat_layout(self) -> bool: jpayne@68: """Try to find all packages and modules under the project root. jpayne@68: jpayne@68: Since the ``flat-layout`` is more dangerous in terms of accidentally including jpayne@68: extra files/directories, this function is more conservative and will raise an jpayne@68: error if multiple packages or modules are found. jpayne@68: jpayne@68: This assumes that multi-package dists are uncommon and refuse to support that jpayne@68: use case in order to be able to prevent unintended errors. jpayne@68: """ jpayne@68: log.debug(f"`flat-layout` detected -- analysing {self._root_dir}") jpayne@68: return self._analyse_flat_packages() or self._analyse_flat_modules() jpayne@68: jpayne@68: def _analyse_flat_packages(self) -> bool: jpayne@68: self.dist.packages = FlatLayoutPackageFinder.find(self._root_dir) jpayne@68: top_level = remove_nested_packages(remove_stubs(self.dist.packages)) jpayne@68: log.debug(f"discovered packages -- {self.dist.packages}") jpayne@68: self._ensure_no_accidental_inclusion(top_level, "packages") jpayne@68: return bool(top_level) jpayne@68: jpayne@68: def _analyse_flat_modules(self) -> bool: jpayne@68: self.dist.py_modules = FlatLayoutModuleFinder.find(self._root_dir) jpayne@68: log.debug(f"discovered py_modules -- {self.dist.py_modules}") jpayne@68: self._ensure_no_accidental_inclusion(self.dist.py_modules, "modules") jpayne@68: return bool(self.dist.py_modules) jpayne@68: jpayne@68: def _ensure_no_accidental_inclusion(self, detected: list[str], kind: str): jpayne@68: if len(detected) > 1: jpayne@68: from inspect import cleandoc jpayne@68: jpayne@68: from setuptools.errors import PackageDiscoveryError jpayne@68: jpayne@68: msg = f"""Multiple top-level {kind} discovered in a flat-layout: {detected}. jpayne@68: jpayne@68: To avoid accidental inclusion of unwanted files or directories, jpayne@68: setuptools will not proceed with this build. jpayne@68: jpayne@68: If you are trying to create a single distribution with multiple {kind} jpayne@68: on purpose, you should not rely on automatic discovery. jpayne@68: Instead, consider the following options: jpayne@68: jpayne@68: 1. set up custom discovery (`find` directive with `include` or `exclude`) jpayne@68: 2. use a `src-layout` jpayne@68: 3. explicitly set `py_modules` or `packages` with a list of names jpayne@68: jpayne@68: To find more information, look for "package discovery" on setuptools docs. jpayne@68: """ jpayne@68: raise PackageDiscoveryError(cleandoc(msg)) jpayne@68: jpayne@68: def analyse_name(self): jpayne@68: """The packages/modules are the essential contribution of the author. jpayne@68: Therefore the name of the distribution can be derived from them. jpayne@68: """ jpayne@68: if self.dist.metadata.name or self.dist.name: jpayne@68: # get_name() is not reliable (can return "UNKNOWN") jpayne@68: return jpayne@68: jpayne@68: log.debug("No `name` configuration, performing automatic discovery") jpayne@68: jpayne@68: name = ( jpayne@68: self._find_name_single_package_or_module() jpayne@68: or self._find_name_from_packages() jpayne@68: ) jpayne@68: if name: jpayne@68: self.dist.metadata.name = name jpayne@68: jpayne@68: def _find_name_single_package_or_module(self) -> str | None: jpayne@68: """Exactly one module or package""" jpayne@68: for field in ('packages', 'py_modules'): jpayne@68: items = getattr(self.dist, field, None) or [] jpayne@68: if items and len(items) == 1: jpayne@68: log.debug(f"Single module/package detected, name: {items[0]}") jpayne@68: return items[0] jpayne@68: jpayne@68: return None jpayne@68: jpayne@68: def _find_name_from_packages(self) -> str | None: jpayne@68: """Try to find the root package that is not a PEP 420 namespace""" jpayne@68: if not self.dist.packages: jpayne@68: return None jpayne@68: jpayne@68: packages = remove_stubs(sorted(self.dist.packages, key=len)) jpayne@68: package_dir = self.dist.package_dir or {} jpayne@68: jpayne@68: parent_pkg = find_parent_package(packages, package_dir, self._root_dir) jpayne@68: if parent_pkg: jpayne@68: log.debug(f"Common parent package detected, name: {parent_pkg}") jpayne@68: return parent_pkg jpayne@68: jpayne@68: log.warn("No parent package detected, impossible to derive `name`") jpayne@68: return None jpayne@68: jpayne@68: jpayne@68: def remove_nested_packages(packages: list[str]) -> list[str]: jpayne@68: """Remove nested packages from a list of packages. jpayne@68: jpayne@68: >>> remove_nested_packages(["a", "a.b1", "a.b2", "a.b1.c1"]) jpayne@68: ['a'] jpayne@68: >>> remove_nested_packages(["a", "b", "c.d", "c.d.e.f", "g.h", "a.a1"]) jpayne@68: ['a', 'b', 'c.d', 'g.h'] jpayne@68: """ jpayne@68: pkgs = sorted(packages, key=len) jpayne@68: top_level = pkgs[:] jpayne@68: size = len(pkgs) jpayne@68: for i, name in enumerate(reversed(pkgs)): jpayne@68: if any(name.startswith(f"{other}.") for other in top_level): jpayne@68: top_level.pop(size - i - 1) jpayne@68: jpayne@68: return top_level jpayne@68: jpayne@68: jpayne@68: def remove_stubs(packages: list[str]) -> list[str]: jpayne@68: """Remove type stubs (:pep:`561`) from a list of packages. jpayne@68: jpayne@68: >>> remove_stubs(["a", "a.b", "a-stubs", "a-stubs.b.c", "b", "c-stubs"]) jpayne@68: ['a', 'a.b', 'b'] jpayne@68: """ jpayne@68: return [pkg for pkg in packages if not pkg.split(".")[0].endswith("-stubs")] jpayne@68: jpayne@68: jpayne@68: def find_parent_package( jpayne@68: packages: list[str], package_dir: Mapping[str, str], root_dir: StrPath jpayne@68: ) -> str | None: jpayne@68: """Find the parent package that is not a namespace.""" jpayne@68: packages = sorted(packages, key=len) jpayne@68: common_ancestors = [] jpayne@68: for i, name in enumerate(packages): jpayne@68: if not all(n.startswith(f"{name}.") for n in packages[i + 1 :]): jpayne@68: # Since packages are sorted by length, this condition is able jpayne@68: # to find a list of all common ancestors. jpayne@68: # When there is divergence (e.g. multiple root packages) jpayne@68: # the list will be empty jpayne@68: break jpayne@68: common_ancestors.append(name) jpayne@68: jpayne@68: for name in common_ancestors: jpayne@68: pkg_path = find_package_path(name, package_dir, root_dir) jpayne@68: init = os.path.join(pkg_path, "__init__.py") jpayne@68: if os.path.isfile(init): jpayne@68: return name jpayne@68: jpayne@68: return None jpayne@68: jpayne@68: jpayne@68: def find_package_path( jpayne@68: name: str, package_dir: Mapping[str, str], root_dir: StrPath jpayne@68: ) -> str: jpayne@68: """Given a package name, return the path where it should be found on jpayne@68: disk, considering the ``package_dir`` option. jpayne@68: jpayne@68: >>> path = find_package_path("my.pkg", {"": "root/is/nested"}, ".") jpayne@68: >>> path.replace(os.sep, "/") jpayne@68: './root/is/nested/my/pkg' jpayne@68: jpayne@68: >>> path = find_package_path("my.pkg", {"my": "root/is/nested"}, ".") jpayne@68: >>> path.replace(os.sep, "/") jpayne@68: './root/is/nested/pkg' jpayne@68: jpayne@68: >>> path = find_package_path("my.pkg", {"my.pkg": "root/is/nested"}, ".") jpayne@68: >>> path.replace(os.sep, "/") jpayne@68: './root/is/nested' jpayne@68: jpayne@68: >>> path = find_package_path("other.pkg", {"my.pkg": "root/is/nested"}, ".") jpayne@68: >>> path.replace(os.sep, "/") jpayne@68: './other/pkg' jpayne@68: """ jpayne@68: parts = name.split(".") jpayne@68: for i in range(len(parts), 0, -1): jpayne@68: # Look backwards, the most specific package_dir first jpayne@68: partial_name = ".".join(parts[:i]) jpayne@68: if partial_name in package_dir: jpayne@68: parent = package_dir[partial_name] jpayne@68: return os.path.join(root_dir, parent, *parts[i:]) jpayne@68: jpayne@68: parent = package_dir.get("") or "" jpayne@68: return os.path.join(root_dir, *parent.split("/"), *parts) jpayne@68: jpayne@68: jpayne@68: def construct_package_dir(packages: list[str], package_path: StrPath) -> dict[str, str]: jpayne@68: parent_pkgs = remove_nested_packages(packages) jpayne@68: prefix = Path(package_path).parts jpayne@68: return {pkg: "/".join([*prefix, *pkg.split(".")]) for pkg in parent_pkgs}