123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462 |
- """Utility functions to expand configuration directives or special values
- (such glob patterns).
- We can split the process of interpreting configuration files into 2 steps:
- 1. The parsing the file contents from strings to value objects
- that can be understand by Python (for example a string with a comma
- separated list of keywords into an actual Python list of strings).
- 2. The expansion (or post-processing) of these values according to the
- semantics ``setuptools`` assign to them (for example a configuration field
- with the ``file:`` directive should be expanded from a list of file paths to
- a single string with the contents of those files concatenated)
- This module focus on the second step, and therefore allow sharing the expansion
- functions among several configuration file formats.
- **PRIVATE MODULE**: API reserved for setuptools internal usage only.
- """
- import ast
- import importlib
- import io
- import os
- import pathlib
- import sys
- import warnings
- from glob import iglob
- from configparser import ConfigParser
- from importlib.machinery import ModuleSpec
- from itertools import chain
- from typing import (
- TYPE_CHECKING,
- Callable,
- Dict,
- Iterable,
- Iterator,
- List,
- Mapping,
- Optional,
- Tuple,
- TypeVar,
- Union,
- cast
- )
- from pathlib import Path
- from types import ModuleType
- from distutils.errors import DistutilsOptionError
- from .._path import same_path as _same_path
- if TYPE_CHECKING:
- from setuptools.dist import Distribution # noqa
- from setuptools.discovery import ConfigDiscovery # noqa
- from distutils.dist import DistributionMetadata # noqa
- chain_iter = chain.from_iterable
- _Path = Union[str, os.PathLike]
- _K = TypeVar("_K")
- _V = TypeVar("_V", covariant=True)
- class StaticModule:
- """Proxy to a module object that avoids executing arbitrary code."""
- def __init__(self, name: str, spec: ModuleSpec):
- module = ast.parse(pathlib.Path(spec.origin).read_bytes())
- vars(self).update(locals())
- del self.self
- def _find_assignments(self) -> Iterator[Tuple[ast.AST, ast.AST]]:
- for statement in self.module.body:
- if isinstance(statement, ast.Assign):
- yield from ((target, statement.value) for target in statement.targets)
- elif isinstance(statement, ast.AnnAssign) and statement.value:
- yield (statement.target, statement.value)
- def __getattr__(self, attr):
- """Attempt to load an attribute "statically", via :func:`ast.literal_eval`."""
- try:
- return next(
- ast.literal_eval(value)
- for target, value in self._find_assignments()
- if isinstance(target, ast.Name) and target.id == attr
- )
- except Exception as e:
- raise AttributeError(f"{self.name} has no attribute {attr}") from e
- def glob_relative(
- patterns: Iterable[str], root_dir: Optional[_Path] = None
- ) -> List[str]:
- """Expand the list of glob patterns, but preserving relative paths.
- :param list[str] patterns: List of glob patterns
- :param str root_dir: Path to which globs should be relative
- (current directory by default)
- :rtype: list
- """
- glob_characters = {'*', '?', '[', ']', '{', '}'}
- expanded_values = []
- root_dir = root_dir or os.getcwd()
- for value in patterns:
- # Has globby characters?
- if any(char in value for char in glob_characters):
- # then expand the glob pattern while keeping paths *relative*:
- glob_path = os.path.abspath(os.path.join(root_dir, value))
- expanded_values.extend(sorted(
- os.path.relpath(path, root_dir).replace(os.sep, "/")
- for path in iglob(glob_path, recursive=True)))
- else:
- # take the value as-is
- path = os.path.relpath(value, root_dir).replace(os.sep, "/")
- expanded_values.append(path)
- return expanded_values
- def read_files(filepaths: Union[str, bytes, Iterable[_Path]], root_dir=None) -> str:
- """Return the content of the files concatenated using ``\n`` as str
- This function is sandboxed and won't reach anything outside ``root_dir``
- (By default ``root_dir`` is the current directory).
- """
- from setuptools.extern.more_itertools import always_iterable
- root_dir = os.path.abspath(root_dir or os.getcwd())
- _filepaths = (os.path.join(root_dir, path) for path in always_iterable(filepaths))
- return '\n'.join(
- _read_file(path)
- for path in _filter_existing_files(_filepaths)
- if _assert_local(path, root_dir)
- )
- def _filter_existing_files(filepaths: Iterable[_Path]) -> Iterator[_Path]:
- for path in filepaths:
- if os.path.isfile(path):
- yield path
- else:
- warnings.warn(f"File {path!r} cannot be found")
- def _read_file(filepath: Union[bytes, _Path]) -> str:
- with io.open(filepath, encoding='utf-8') as f:
- return f.read()
- def _assert_local(filepath: _Path, root_dir: str):
- if Path(os.path.abspath(root_dir)) not in Path(os.path.abspath(filepath)).parents:
- msg = f"Cannot access {filepath!r} (or anything outside {root_dir!r})"
- raise DistutilsOptionError(msg)
- return True
- def read_attr(
- attr_desc: str,
- package_dir: Optional[Mapping[str, str]] = None,
- root_dir: Optional[_Path] = None
- ):
- """Reads the value of an attribute from a module.
- This function will try to read the attributed statically first
- (via :func:`ast.literal_eval`), and only evaluate the module if it fails.
- Examples:
- read_attr("package.attr")
- read_attr("package.module.attr")
- :param str attr_desc: Dot-separated string describing how to reach the
- attribute (see examples above)
- :param dict[str, str] package_dir: Mapping of package names to their
- location in disk (represented by paths relative to ``root_dir``).
- :param str root_dir: Path to directory containing all the packages in
- ``package_dir`` (current directory by default).
- :rtype: str
- """
- root_dir = root_dir or os.getcwd()
- attrs_path = attr_desc.strip().split('.')
- attr_name = attrs_path.pop()
- module_name = '.'.join(attrs_path)
- module_name = module_name or '__init__'
- _parent_path, path, module_name = _find_module(module_name, package_dir, root_dir)
- spec = _find_spec(module_name, path)
- try:
- return getattr(StaticModule(module_name, spec), attr_name)
- except Exception:
- # fallback to evaluate module
- module = _load_spec(spec, module_name)
- return getattr(module, attr_name)
- def _find_spec(module_name: str, module_path: Optional[_Path]) -> ModuleSpec:
- spec = importlib.util.spec_from_file_location(module_name, module_path)
- spec = spec or importlib.util.find_spec(module_name)
- if spec is None:
- raise ModuleNotFoundError(module_name)
- return spec
- def _load_spec(spec: ModuleSpec, module_name: str) -> ModuleType:
- name = getattr(spec, "__name__", module_name)
- if name in sys.modules:
- return sys.modules[name]
- module = importlib.util.module_from_spec(spec)
- sys.modules[name] = module # cache (it also ensures `==` works on loaded items)
- spec.loader.exec_module(module) # type: ignore
- return module
- def _find_module(
- module_name: str, package_dir: Optional[Mapping[str, str]], root_dir: _Path
- ) -> Tuple[_Path, Optional[str], str]:
- """Given a module (that could normally be imported by ``module_name``
- after the build is complete), find the path to the parent directory where
- it is contained and the canonical name that could be used to import it
- considering the ``package_dir`` in the build configuration and ``root_dir``
- """
- parent_path = root_dir
- module_parts = module_name.split('.')
- if package_dir:
- if module_parts[0] in package_dir:
- # A custom path was specified for the module we want to import
- custom_path = package_dir[module_parts[0]]
- parts = custom_path.rsplit('/', 1)
- if len(parts) > 1:
- parent_path = os.path.join(root_dir, parts[0])
- parent_module = parts[1]
- else:
- parent_module = custom_path
- module_name = ".".join([parent_module, *module_parts[1:]])
- elif '' in package_dir:
- # A custom parent directory was specified for all root modules
- parent_path = os.path.join(root_dir, package_dir[''])
- path_start = os.path.join(parent_path, *module_name.split("."))
- candidates = chain(
- (f"{path_start}.py", os.path.join(path_start, "__init__.py")),
- iglob(f"{path_start}.*")
- )
- module_path = next((x for x in candidates if os.path.isfile(x)), None)
- return parent_path, module_path, module_name
- def resolve_class(
- qualified_class_name: str,
- package_dir: Optional[Mapping[str, str]] = None,
- root_dir: Optional[_Path] = None
- ) -> Callable:
- """Given a qualified class name, return the associated class object"""
- root_dir = root_dir or os.getcwd()
- idx = qualified_class_name.rfind('.')
- class_name = qualified_class_name[idx + 1 :]
- pkg_name = qualified_class_name[:idx]
- _parent_path, path, module_name = _find_module(pkg_name, package_dir, root_dir)
- module = _load_spec(_find_spec(module_name, path), module_name)
- return getattr(module, class_name)
- def cmdclass(
- values: Dict[str, str],
- package_dir: Optional[Mapping[str, str]] = None,
- root_dir: Optional[_Path] = None
- ) -> Dict[str, Callable]:
- """Given a dictionary mapping command names to strings for qualified class
- names, apply :func:`resolve_class` to the dict values.
- """
- return {k: resolve_class(v, package_dir, root_dir) for k, v in values.items()}
- def find_packages(
- *,
- namespaces=True,
- fill_package_dir: Optional[Dict[str, str]] = None,
- root_dir: Optional[_Path] = None,
- **kwargs
- ) -> List[str]:
- """Works similarly to :func:`setuptools.find_packages`, but with all
- arguments given as keyword arguments. Moreover, ``where`` can be given
- as a list (the results will be simply concatenated).
- When the additional keyword argument ``namespaces`` is ``True``, it will
- behave like :func:`setuptools.find_namespace_packages`` (i.e. include
- implicit namespaces as per :pep:`420`).
- The ``where`` argument will be considered relative to ``root_dir`` (or the current
- working directory when ``root_dir`` is not given).
- If the ``fill_package_dir`` argument is passed, this function will consider it as a
- similar data structure to the ``package_dir`` configuration parameter add fill-in
- any missing package location.
- :rtype: list
- """
- from setuptools.discovery import construct_package_dir
- from setuptools.extern.more_itertools import unique_everseen, always_iterable
- if namespaces:
- from setuptools.discovery import PEP420PackageFinder as PackageFinder
- else:
- from setuptools.discovery import PackageFinder # type: ignore
- root_dir = root_dir or os.curdir
- where = kwargs.pop('where', ['.'])
- packages: List[str] = []
- fill_package_dir = {} if fill_package_dir is None else fill_package_dir
- search = list(unique_everseen(always_iterable(where)))
- if len(search) == 1 and all(not _same_path(search[0], x) for x in (".", root_dir)):
- fill_package_dir.setdefault("", search[0])
- for path in search:
- package_path = _nest_path(root_dir, path)
- pkgs = PackageFinder.find(package_path, **kwargs)
- packages.extend(pkgs)
- if pkgs and not (
- fill_package_dir.get("") == path
- or os.path.samefile(package_path, root_dir)
- ):
- fill_package_dir.update(construct_package_dir(pkgs, path))
- return packages
- def _nest_path(parent: _Path, path: _Path) -> str:
- path = parent if path in {".", ""} else os.path.join(parent, path)
- return os.path.normpath(path)
- def version(value: Union[Callable, Iterable[Union[str, int]], str]) -> str:
- """When getting the version directly from an attribute,
- it should be normalised to string.
- """
- if callable(value):
- value = value()
- value = cast(Iterable[Union[str, int]], value)
- if not isinstance(value, str):
- if hasattr(value, '__iter__'):
- value = '.'.join(map(str, value))
- else:
- value = '%s' % value
- return value
- def canonic_package_data(package_data: dict) -> dict:
- if "*" in package_data:
- package_data[""] = package_data.pop("*")
- return package_data
- def canonic_data_files(
- data_files: Union[list, dict], root_dir: Optional[_Path] = None
- ) -> List[Tuple[str, List[str]]]:
- """For compatibility with ``setup.py``, ``data_files`` should be a list
- of pairs instead of a dict.
- This function also expands glob patterns.
- """
- if isinstance(data_files, list):
- return data_files
- return [
- (dest, glob_relative(patterns, root_dir))
- for dest, patterns in data_files.items()
- ]
- def entry_points(text: str, text_source="entry-points") -> Dict[str, dict]:
- """Given the contents of entry-points file,
- process it into a 2-level dictionary (``dict[str, dict[str, str]]``).
- The first level keys are entry-point groups, the second level keys are
- entry-point names, and the second level values are references to objects
- (that correspond to the entry-point value).
- """
- parser = ConfigParser(default_section=None, delimiters=("=",)) # type: ignore
- parser.optionxform = str # case sensitive
- parser.read_string(text, text_source)
- groups = {k: dict(v.items()) for k, v in parser.items()}
- groups.pop(parser.default_section, None)
- return groups
- class EnsurePackagesDiscovered:
- """Some expand functions require all the packages to already be discovered before
- they run, e.g. :func:`read_attr`, :func:`resolve_class`, :func:`cmdclass`.
- Therefore in some cases we will need to run autodiscovery during the evaluation of
- the configuration. However, it is better to postpone calling package discovery as
- much as possible, because some parameters can influence it (e.g. ``package_dir``),
- and those might not have been processed yet.
- """
- def __init__(self, distribution: "Distribution"):
- self._dist = distribution
- self._called = False
- def __call__(self):
- """Trigger the automatic package discovery, if it is still necessary."""
- if not self._called:
- self._called = True
- self._dist.set_defaults(name=False) # Skip name, we can still be parsing
- def __enter__(self):
- return self
- def __exit__(self, _exc_type, _exc_value, _traceback):
- if self._called:
- self._dist.set_defaults.analyse_name() # Now we can set a default name
- def _get_package_dir(self) -> Mapping[str, str]:
- self()
- pkg_dir = self._dist.package_dir
- return {} if pkg_dir is None else pkg_dir
- @property
- def package_dir(self) -> Mapping[str, str]:
- """Proxy to ``package_dir`` that may trigger auto-discovery when used."""
- return LazyMappingProxy(self._get_package_dir)
- class LazyMappingProxy(Mapping[_K, _V]):
- """Mapping proxy that delays resolving the target object, until really needed.
- >>> def obtain_mapping():
- ... print("Running expensive function!")
- ... return {"key": "value", "other key": "other value"}
- >>> mapping = LazyMappingProxy(obtain_mapping)
- >>> mapping["key"]
- Running expensive function!
- 'value'
- >>> mapping["other key"]
- 'other value'
- """
- def __init__(self, obtain_mapping_value: Callable[[], Mapping[_K, _V]]):
- self._obtain = obtain_mapping_value
- self._value: Optional[Mapping[_K, _V]] = None
- def _target(self) -> Mapping[_K, _V]:
- if self._value is None:
- self._value = self._obtain()
- return self._value
- def __getitem__(self, key: _K) -> _V:
- return self._target()[key]
- def __len__(self) -> int:
- return len(self._target())
- def __iter__(self) -> Iterator[_K]:
- return iter(self._target())
|