Path: blob/main/test/lib/python3.9/site-packages/setuptools/config/expand.py
4799 views
"""Utility functions to expand configuration directives or special values1(such glob patterns).23We can split the process of interpreting configuration files into 2 steps:451. The parsing the file contents from strings to value objects6that can be understand by Python (for example a string with a comma7separated list of keywords into an actual Python list of strings).892. The expansion (or post-processing) of these values according to the10semantics ``setuptools`` assign to them (for example a configuration field11with the ``file:`` directive should be expanded from a list of file paths to12a single string with the contents of those files concatenated)1314This module focus on the second step, and therefore allow sharing the expansion15functions among several configuration file formats.16"""17import ast18import importlib19import io20import os21import sys22import warnings23from glob import iglob24from configparser import ConfigParser25from importlib.machinery import ModuleSpec26from itertools import chain27from typing import (28TYPE_CHECKING,29Callable,30Dict,31Iterable,32Iterator,33List,34Mapping,35Optional,36Tuple,37TypeVar,38Union,39cast40)41from types import ModuleType4243from distutils.errors import DistutilsOptionError4445if TYPE_CHECKING:46from setuptools.dist import Distribution # noqa47from setuptools.discovery import ConfigDiscovery # noqa48from distutils.dist import DistributionMetadata # noqa4950chain_iter = chain.from_iterable51_Path = Union[str, os.PathLike]52_K = TypeVar("_K")53_V = TypeVar("_V", covariant=True)545556class StaticModule:57"""Proxy to a module object that avoids executing arbitrary code."""5859def __init__(self, name: str, spec: ModuleSpec):60with open(spec.origin) as strm: # type: ignore61src = strm.read()62module = ast.parse(src)63vars(self).update(locals())64del self.self6566def __getattr__(self, attr):67"""Attempt to load an attribute "statically", via :func:`ast.literal_eval`."""68try:69assignment_expressions = (70statement71for statement in self.module.body72if isinstance(statement, ast.Assign)73)74expressions_with_target = (75(statement, target)76for statement in assignment_expressions77for target in statement.targets78)79matching_values = (80statement.value81for statement, target in expressions_with_target82if isinstance(target, ast.Name) and target.id == attr83)84return next(ast.literal_eval(value) for value in matching_values)85except Exception as e:86raise AttributeError(f"{self.name} has no attribute {attr}") from e878889def glob_relative(90patterns: Iterable[str], root_dir: Optional[_Path] = None91) -> List[str]:92"""Expand the list of glob patterns, but preserving relative paths.9394:param list[str] patterns: List of glob patterns95:param str root_dir: Path to which globs should be relative96(current directory by default)97:rtype: list98"""99glob_characters = {'*', '?', '[', ']', '{', '}'}100expanded_values = []101root_dir = root_dir or os.getcwd()102for value in patterns:103104# Has globby characters?105if any(char in value for char in glob_characters):106# then expand the glob pattern while keeping paths *relative*:107glob_path = os.path.abspath(os.path.join(root_dir, value))108expanded_values.extend(sorted(109os.path.relpath(path, root_dir).replace(os.sep, "/")110for path in iglob(glob_path, recursive=True)))111112else:113# take the value as-is114path = os.path.relpath(value, root_dir).replace(os.sep, "/")115expanded_values.append(path)116117return expanded_values118119120def read_files(filepaths: Union[str, bytes, Iterable[_Path]], root_dir=None) -> str:121"""Return the content of the files concatenated using ``\n`` as str122123This function is sandboxed and won't reach anything outside ``root_dir``124125(By default ``root_dir`` is the current directory).126"""127from setuptools.extern.more_itertools import always_iterable128129root_dir = os.path.abspath(root_dir or os.getcwd())130_filepaths = (os.path.join(root_dir, path) for path in always_iterable(filepaths))131return '\n'.join(132_read_file(path)133for path in _filter_existing_files(_filepaths)134if _assert_local(path, root_dir)135)136137138def _filter_existing_files(filepaths: Iterable[_Path]) -> Iterator[_Path]:139for path in filepaths:140if os.path.isfile(path):141yield path142else:143warnings.warn(f"File {path!r} cannot be found")144145146def _read_file(filepath: Union[bytes, _Path]) -> str:147with io.open(filepath, encoding='utf-8') as f:148return f.read()149150151def _assert_local(filepath: _Path, root_dir: str):152if not os.path.abspath(filepath).startswith(root_dir):153msg = f"Cannot access {filepath!r} (or anything outside {root_dir!r})"154raise DistutilsOptionError(msg)155156return True157158159def read_attr(160attr_desc: str,161package_dir: Optional[Mapping[str, str]] = None,162root_dir: Optional[_Path] = None163):164"""Reads the value of an attribute from a module.165166This function will try to read the attributed statically first167(via :func:`ast.literal_eval`), and only evaluate the module if it fails.168169Examples:170read_attr("package.attr")171read_attr("package.module.attr")172173:param str attr_desc: Dot-separated string describing how to reach the174attribute (see examples above)175:param dict[str, str] package_dir: Mapping of package names to their176location in disk (represented by paths relative to ``root_dir``).177:param str root_dir: Path to directory containing all the packages in178``package_dir`` (current directory by default).179:rtype: str180"""181root_dir = root_dir or os.getcwd()182attrs_path = attr_desc.strip().split('.')183attr_name = attrs_path.pop()184module_name = '.'.join(attrs_path)185module_name = module_name or '__init__'186_parent_path, path, module_name = _find_module(module_name, package_dir, root_dir)187spec = _find_spec(module_name, path)188189try:190return getattr(StaticModule(module_name, spec), attr_name)191except Exception:192# fallback to evaluate module193module = _load_spec(spec, module_name)194return getattr(module, attr_name)195196197def _find_spec(module_name: str, module_path: Optional[_Path]) -> ModuleSpec:198spec = importlib.util.spec_from_file_location(module_name, module_path)199spec = spec or importlib.util.find_spec(module_name)200201if spec is None:202raise ModuleNotFoundError(module_name)203204return spec205206207def _load_spec(spec: ModuleSpec, module_name: str) -> ModuleType:208name = getattr(spec, "__name__", module_name)209if name in sys.modules:210return sys.modules[name]211module = importlib.util.module_from_spec(spec)212sys.modules[name] = module # cache (it also ensures `==` works on loaded items)213spec.loader.exec_module(module) # type: ignore214return module215216217def _find_module(218module_name: str, package_dir: Optional[Mapping[str, str]], root_dir: _Path219) -> Tuple[_Path, Optional[str], str]:220"""Given a module (that could normally be imported by ``module_name``221after the build is complete), find the path to the parent directory where222it is contained and the canonical name that could be used to import it223considering the ``package_dir`` in the build configuration and ``root_dir``224"""225parent_path = root_dir226module_parts = module_name.split('.')227if package_dir:228if module_parts[0] in package_dir:229# A custom path was specified for the module we want to import230custom_path = package_dir[module_parts[0]]231parts = custom_path.rsplit('/', 1)232if len(parts) > 1:233parent_path = os.path.join(root_dir, parts[0])234parent_module = parts[1]235else:236parent_module = custom_path237module_name = ".".join([parent_module, *module_parts[1:]])238elif '' in package_dir:239# A custom parent directory was specified for all root modules240parent_path = os.path.join(root_dir, package_dir[''])241242path_start = os.path.join(parent_path, *module_name.split("."))243candidates = chain(244(f"{path_start}.py", os.path.join(path_start, "__init__.py")),245iglob(f"{path_start}.*")246)247module_path = next((x for x in candidates if os.path.isfile(x)), None)248return parent_path, module_path, module_name249250251def resolve_class(252qualified_class_name: str,253package_dir: Optional[Mapping[str, str]] = None,254root_dir: Optional[_Path] = None255) -> Callable:256"""Given a qualified class name, return the associated class object"""257root_dir = root_dir or os.getcwd()258idx = qualified_class_name.rfind('.')259class_name = qualified_class_name[idx + 1 :]260pkg_name = qualified_class_name[:idx]261262_parent_path, path, module_name = _find_module(pkg_name, package_dir, root_dir)263module = _load_spec(_find_spec(module_name, path), module_name)264return getattr(module, class_name)265266267def cmdclass(268values: Dict[str, str],269package_dir: Optional[Mapping[str, str]] = None,270root_dir: Optional[_Path] = None271) -> Dict[str, Callable]:272"""Given a dictionary mapping command names to strings for qualified class273names, apply :func:`resolve_class` to the dict values.274"""275return {k: resolve_class(v, package_dir, root_dir) for k, v in values.items()}276277278def find_packages(279*,280namespaces=True,281fill_package_dir: Optional[Dict[str, str]] = None,282root_dir: Optional[_Path] = None,283**kwargs284) -> List[str]:285"""Works similarly to :func:`setuptools.find_packages`, but with all286arguments given as keyword arguments. Moreover, ``where`` can be given287as a list (the results will be simply concatenated).288289When the additional keyword argument ``namespaces`` is ``True``, it will290behave like :func:`setuptools.find_namespace_packages`` (i.e. include291implicit namespaces as per :pep:`420`).292293The ``where`` argument will be considered relative to ``root_dir`` (or the current294working directory when ``root_dir`` is not given).295296If the ``fill_package_dir`` argument is passed, this function will consider it as a297similar data structure to the ``package_dir`` configuration parameter add fill-in298any missing package location.299300:rtype: list301"""302from setuptools.discovery import construct_package_dir303from setuptools.extern.more_itertools import unique_everseen, always_iterable304305if namespaces:306from setuptools.discovery import PEP420PackageFinder as PackageFinder307else:308from setuptools.discovery import PackageFinder # type: ignore309310root_dir = root_dir or os.curdir311where = kwargs.pop('where', ['.'])312packages: List[str] = []313fill_package_dir = {} if fill_package_dir is None else fill_package_dir314search = list(unique_everseen(always_iterable(where)))315316if len(search) == 1 and all(not _same_path(search[0], x) for x in (".", root_dir)):317fill_package_dir.setdefault("", search[0])318319for path in search:320package_path = _nest_path(root_dir, path)321pkgs = PackageFinder.find(package_path, **kwargs)322packages.extend(pkgs)323if pkgs and not (324fill_package_dir.get("") == path325or os.path.samefile(package_path, root_dir)326):327fill_package_dir.update(construct_package_dir(pkgs, path))328329return packages330331332def _same_path(p1: _Path, p2: _Path) -> bool:333"""Differs from os.path.samefile because it does not require paths to exist.334Purely string based (no comparison between i-nodes).335>>> _same_path("a/b", "./a/b")336True337>>> _same_path("a/b", "a/./b")338True339>>> _same_path("a/b", "././a/b")340True341>>> _same_path("a/b", "./a/b/c/..")342True343>>> _same_path("a/b", "../a/b/c")344False345>>> _same_path("a", "a/b")346False347"""348return os.path.normpath(p1) == os.path.normpath(p2)349350351def _nest_path(parent: _Path, path: _Path) -> str:352path = parent if path in {".", ""} else os.path.join(parent, path)353return os.path.normpath(path)354355356def version(value: Union[Callable, Iterable[Union[str, int]], str]) -> str:357"""When getting the version directly from an attribute,358it should be normalised to string.359"""360if callable(value):361value = value()362363value = cast(Iterable[Union[str, int]], value)364365if not isinstance(value, str):366if hasattr(value, '__iter__'):367value = '.'.join(map(str, value))368else:369value = '%s' % value370371return value372373374def canonic_package_data(package_data: dict) -> dict:375if "*" in package_data:376package_data[""] = package_data.pop("*")377return package_data378379380def canonic_data_files(381data_files: Union[list, dict], root_dir: Optional[_Path] = None382) -> List[Tuple[str, List[str]]]:383"""For compatibility with ``setup.py``, ``data_files`` should be a list384of pairs instead of a dict.385386This function also expands glob patterns.387"""388if isinstance(data_files, list):389return data_files390391return [392(dest, glob_relative(patterns, root_dir))393for dest, patterns in data_files.items()394]395396397def entry_points(text: str, text_source="entry-points") -> Dict[str, dict]:398"""Given the contents of entry-points file,399process it into a 2-level dictionary (``dict[str, dict[str, str]]``).400The first level keys are entry-point groups, the second level keys are401entry-point names, and the second level values are references to objects402(that correspond to the entry-point value).403"""404parser = ConfigParser(default_section=None, delimiters=("=",)) # type: ignore405parser.optionxform = str # case sensitive406parser.read_string(text, text_source)407groups = {k: dict(v.items()) for k, v in parser.items()}408groups.pop(parser.default_section, None)409return groups410411412class EnsurePackagesDiscovered:413"""Some expand functions require all the packages to already be discovered before414they run, e.g. :func:`read_attr`, :func:`resolve_class`, :func:`cmdclass`.415416Therefore in some cases we will need to run autodiscovery during the evaluation of417the configuration. However, it is better to postpone calling package discovery as418much as possible, because some parameters can influence it (e.g. ``package_dir``),419and those might not have been processed yet.420"""421422def __init__(self, distribution: "Distribution"):423self._dist = distribution424self._called = False425426def __call__(self):427"""Trigger the automatic package discovery, if it is still necessary."""428if not self._called:429self._called = True430self._dist.set_defaults(name=False) # Skip name, we can still be parsing431432def __enter__(self):433return self434435def __exit__(self, _exc_type, _exc_value, _traceback):436if self._called:437self._dist.set_defaults.analyse_name() # Now we can set a default name438439def _get_package_dir(self) -> Mapping[str, str]:440self()441pkg_dir = self._dist.package_dir442return {} if pkg_dir is None else pkg_dir443444@property445def package_dir(self) -> Mapping[str, str]:446"""Proxy to ``package_dir`` that may trigger auto-discovery when used."""447return LazyMappingProxy(self._get_package_dir)448449450class LazyMappingProxy(Mapping[_K, _V]):451"""Mapping proxy that delays resolving the target object, until really needed.452453>>> def obtain_mapping():454... print("Running expensive function!")455... return {"key": "value", "other key": "other value"}456>>> mapping = LazyMappingProxy(obtain_mapping)457>>> mapping["key"]458Running expensive function!459'value'460>>> mapping["other key"]461'other value'462"""463464def __init__(self, obtain_mapping_value: Callable[[], Mapping[_K, _V]]):465self._obtain = obtain_mapping_value466self._value: Optional[Mapping[_K, _V]] = None467468def _target(self) -> Mapping[_K, _V]:469if self._value is None:470self._value = self._obtain()471return self._value472473def __getitem__(self, key: _K) -> _V:474return self._target()[key]475476def __len__(self) -> int:477return len(self._target())478479def __iter__(self) -> Iterator[_K]:480return iter(self._target())481482483