Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
pyprojecttoml.py478 linesDownload Raw Back to config
1"""2Load setuptools configuration from ``pyproject.toml`` files.3 4**PRIVATE MODULE**: API reserved for setuptools internal usage only.5 6To read project metadata, consider using7``build.util.project_wheel_metadata`` (https://pypi.org/project/build/).8For simple scenarios, you can also try parsing the file directly9with the help of ``tomllib`` or ``tomli``.10"""11 12from __future__ import annotations13 14import logging15import os16from collections.abc import Mapping17from contextlib import contextmanager18from functools import partial19from types import TracebackType20from typing import TYPE_CHECKING, Any, Callable21 22from .._path import StrPath23from ..errors import FileError, InvalidConfigError24from ..warnings import SetuptoolsWarning25from . import expand as _expand26from ._apply_pyprojecttoml import _PREVIOUSLY_DEFINED, _MissingDynamic, apply as _apply27 28if TYPE_CHECKING:29    from typing_extensions import Self30 31    from setuptools.dist import Distribution32 33_logger = logging.getLogger(__name__)34 35 36def load_file(filepath: StrPath) -> dict:37    from ..compat.py310 import tomllib38 39    with open(filepath, "rb") as file:40        return tomllib.load(file)41 42 43def validate(config: dict, filepath: StrPath) -> bool:44    from . import _validate_pyproject as validator45 46    trove_classifier = validator.FORMAT_FUNCTIONS.get("trove-classifier")47    if hasattr(trove_classifier, "_disable_download"):48        # Improve reproducibility by default. See abravalheri/validate-pyproject#3149        trove_classifier._disable_download()  # type: ignore[union-attr]50 51    try:52        return validator.validate(config)53    except validator.ValidationError as ex:54        summary = f"configuration error: {ex.summary}"55        if ex.name.strip("`") != "project":56            # Probably it is just a field missing/misnamed, not worthy the verbosity...57            _logger.debug(summary)58            _logger.debug(ex.details)59 60        error = f"invalid pyproject.toml config: {ex.name}."61        raise ValueError(f"{error}\n{summary}") from None62 63 64def apply_configuration(65    dist: Distribution,66    filepath: StrPath,67    ignore_option_errors: bool = False,68) -> Distribution:69    """Apply the configuration from a ``pyproject.toml`` file into an existing70    distribution object.71    """72    config = read_configuration(filepath, True, ignore_option_errors, dist)73    return _apply(dist, config, filepath)74 75 76def read_configuration(77    filepath: StrPath,78    expand: bool = True,79    ignore_option_errors: bool = False,80    dist: Distribution | None = None,81) -> dict[str, Any]:82    """Read given configuration file and returns options from it as a dict.83 84    :param str|unicode filepath: Path to configuration file in the ``pyproject.toml``85        format.86 87    :param bool expand: Whether to expand directives and other computed values88        (i.e. post-process the given configuration)89 90    :param bool ignore_option_errors: Whether to silently ignore91        options, values of which could not be resolved (e.g. due to exceptions92        in directives such as file:, attr:, etc.).93        If False exceptions are propagated as expected.94 95    :param Distribution|None: Distribution object to which the configuration refers.96        If not given a dummy object will be created and discarded after the97        configuration is read. This is used for auto-discovery of packages and in the98        case a dynamic configuration (e.g. ``attr`` or ``cmdclass``) is expanded.99        When ``expand=False`` this object is simply ignored.100 101    :rtype: dict102    """103    filepath = os.path.abspath(filepath)104 105    if not os.path.isfile(filepath):106        raise FileError(f"Configuration file {filepath!r} does not exist.")107 108    asdict = load_file(filepath) or {}109    project_table = asdict.get("project", {})110    tool_table = asdict.get("tool", {})111    setuptools_table = tool_table.get("setuptools", {})112    if not asdict or not (project_table or setuptools_table):113        return {}  # User is not using pyproject to configure setuptools114 115    if "setuptools" in asdict.get("tools", {}):116        # let the user know they probably have a typo in their metadata117        _ToolsTypoInMetadata.emit()118 119    if "distutils" in tool_table:120        _ExperimentalConfiguration.emit(subject="[tool.distutils]")121 122    # There is an overall sense in the community that making include_package_data=True123    # the default would be an improvement.124    # `ini2toml` backfills include_package_data=False when nothing is explicitly given,125    # therefore setting a default here is backwards compatible.126    if dist and dist.include_package_data is not None:127        setuptools_table.setdefault("include-package-data", dist.include_package_data)128    else:129        setuptools_table.setdefault("include-package-data", True)130    # Persist changes:131    asdict["tool"] = tool_table132    tool_table["setuptools"] = setuptools_table133 134    if "ext-modules" in setuptools_table:135        _ExperimentalConfiguration.emit(subject="[tool.setuptools.ext-modules]")136 137    fields = ("import-names", "import-namespaces")138    places = (project_table, project_table.get("dynamic", []))139    if any(field in place for field in fields for place in places):140        raise NotImplementedError(141            "Setuptools does not support `import-names` and `import-namespaces`"142            " in `pyproject.toml` yet. If your are interested in this feature, "143            " please consider submitting a contribution via pull requests."144        )145 146    with _ignore_errors(ignore_option_errors):147        # Don't complain about unrelated errors (e.g. tools not using the "tool" table)148        subset = {"project": project_table, "tool": {"setuptools": setuptools_table}}149        validate(subset, filepath)150 151    if expand:152        root_dir = os.path.dirname(filepath)153        return expand_configuration(asdict, root_dir, ignore_option_errors, dist)154 155    return asdict156 157 158def expand_configuration(159    config: dict,160    root_dir: StrPath | None = None,161    ignore_option_errors: bool = False,162    dist: Distribution | None = None,163) -> dict:164    """Given a configuration with unresolved fields (e.g. dynamic, cmdclass, ...)165    find their final values.166 167    :param dict config: Dict containing the configuration for the distribution168    :param str root_dir: Top-level directory for the distribution/project169        (the same directory where ``pyproject.toml`` is place)170    :param bool ignore_option_errors: see :func:`read_configuration`171    :param Distribution|None: Distribution object to which the configuration refers.172        If not given a dummy object will be created and discarded after the173        configuration is read. Used in the case a dynamic configuration174        (e.g. ``attr`` or ``cmdclass``).175 176    :rtype: dict177    """178    return _ConfigExpander(config, root_dir, ignore_option_errors, dist).expand()179 180 181class _ConfigExpander:182    def __init__(183        self,184        config: dict,185        root_dir: StrPath | None = None,186        ignore_option_errors: bool = False,187        dist: Distribution | None = None,188    ) -> None:189        self.config = config190        self.root_dir = root_dir or os.getcwd()191        self.project_cfg = config.get("project", {})192        self.dynamic = self.project_cfg.get("dynamic", [])193        self.setuptools_cfg = config.get("tool", {}).get("setuptools", {})194        self.dynamic_cfg = self.setuptools_cfg.get("dynamic", {})195        self.ignore_option_errors = ignore_option_errors196        self._dist = dist197        self._referenced_files = set[str]()198 199    def _ensure_dist(self) -> Distribution:200        from setuptools.dist import Distribution201 202        attrs = {"src_root": self.root_dir, "name": self.project_cfg.get("name", None)}203        return self._dist or Distribution(attrs)204 205    def _process_field(self, container: dict, field: str, fn: Callable):206        if field in container:207            with _ignore_errors(self.ignore_option_errors):208                container[field] = fn(container[field])209 210    def _canonic_package_data(self, field="package-data"):211        package_data = self.setuptools_cfg.get(field, {})212        return _expand.canonic_package_data(package_data)213 214    def expand(self):215        self._expand_packages()216        self._canonic_package_data()217        self._canonic_package_data("exclude-package-data")218 219        # A distribution object is required for discovering the correct package_dir220        dist = self._ensure_dist()221        ctx = _EnsurePackagesDiscovered(dist, self.project_cfg, self.setuptools_cfg)222        with ctx as ensure_discovered:223            package_dir = ensure_discovered.package_dir224            self._expand_data_files()225            self._expand_cmdclass(package_dir)226            self._expand_all_dynamic(dist, package_dir)227 228        dist._referenced_files.update(self._referenced_files)229        return self.config230 231    def _expand_packages(self):232        packages = self.setuptools_cfg.get("packages")233        if packages is None or isinstance(packages, (list, tuple)):234            return235 236        find = packages.get("find")237        if isinstance(find, dict):238            find["root_dir"] = self.root_dir239            find["fill_package_dir"] = self.setuptools_cfg.setdefault("package-dir", {})240            with _ignore_errors(self.ignore_option_errors):241                self.setuptools_cfg["packages"] = _expand.find_packages(**find)242 243    def _expand_data_files(self):244        data_files = partial(_expand.canonic_data_files, root_dir=self.root_dir)245        self._process_field(self.setuptools_cfg, "data-files", data_files)246 247    def _expand_cmdclass(self, package_dir: Mapping[str, str]):248        root_dir = self.root_dir249        cmdclass = partial(_expand.cmdclass, package_dir=package_dir, root_dir=root_dir)250        self._process_field(self.setuptools_cfg, "cmdclass", cmdclass)251 252    def _expand_all_dynamic(self, dist: Distribution, package_dir: Mapping[str, str]):253        special = (  # need special handling254            "version",255            "readme",256            "entry-points",257            "scripts",258            "gui-scripts",259            "classifiers",260            "dependencies",261            "optional-dependencies",262        )263        # `_obtain` functions are assumed to raise appropriate exceptions/warnings.264        obtained_dynamic = {265            field: self._obtain(dist, field, package_dir)266            for field in self.dynamic267            if field not in special268        }269        obtained_dynamic.update(270            self._obtain_entry_points(dist, package_dir) or {},271            version=self._obtain_version(dist, package_dir),272            readme=self._obtain_readme(dist),273            classifiers=self._obtain_classifiers(dist),274            dependencies=self._obtain_dependencies(dist),275            optional_dependencies=self._obtain_optional_dependencies(dist),276        )277        # `None` indicates there is nothing in `tool.setuptools.dynamic` but the value278        # might have already been set by setup.py/extensions, so avoid overwriting.279        updates = {k: v for k, v in obtained_dynamic.items() if v is not None}280        self.project_cfg.update(updates)281 282    def _ensure_previously_set(self, dist: Distribution, field: str):283        previous = _PREVIOUSLY_DEFINED[field](dist)284        if previous is None and not self.ignore_option_errors:285            msg = (286                f"No configuration found for dynamic {field!r}.\n"287                "Some dynamic fields need to be specified via `tool.setuptools.dynamic`"288                "\nothers must be specified via the equivalent attribute in `setup.py`."289            )290            raise InvalidConfigError(msg)291 292    def _expand_directive(293        self, specifier: str, directive, package_dir: Mapping[str, str]294    ):295        from more_itertools import always_iterable296 297        with _ignore_errors(self.ignore_option_errors):298            root_dir = self.root_dir299            if "file" in directive:300                self._referenced_files.update(always_iterable(directive["file"]))301                return _expand.read_files(directive["file"], root_dir)302            if "attr" in directive:303                return _expand.read_attr(directive["attr"], package_dir, root_dir)304            raise ValueError(f"invalid `{specifier}`: {directive!r}")305        return None306 307    def _obtain(self, dist: Distribution, field: str, package_dir: Mapping[str, str]):308        if field in self.dynamic_cfg:309            return self._expand_directive(310                f"tool.setuptools.dynamic.{field}",311                self.dynamic_cfg[field],312                package_dir,313            )314        self._ensure_previously_set(dist, field)315        return None316 317    def _obtain_version(self, dist: Distribution, package_dir: Mapping[str, str]):318        # Since plugins can set version, let's silently skip if it cannot be obtained319        if "version" in self.dynamic and "version" in self.dynamic_cfg:320            return _expand.version(321                # We already do an early check for the presence of "version"322                self._obtain(dist, "version", package_dir)  # pyright: ignore[reportArgumentType]323            )324        return None325 326    def _obtain_readme(self, dist: Distribution) -> dict[str, str] | None:327        if "readme" not in self.dynamic:328            return None329 330        dynamic_cfg = self.dynamic_cfg331        if "readme" in dynamic_cfg:332            return {333                # We already do an early check for the presence of "readme"334                "text": self._obtain(dist, "readme", {}),335                "content-type": dynamic_cfg["readme"].get("content-type", "text/x-rst"),336            }  # pyright: ignore[reportReturnType]337 338        self._ensure_previously_set(dist, "readme")339        return None340 341    def _obtain_entry_points(342        self, dist: Distribution, package_dir: Mapping[str, str]343    ) -> dict[str, dict[str, Any]] | None:344        fields = ("entry-points", "scripts", "gui-scripts")345        if not any(field in self.dynamic for field in fields):346            return None347 348        text = self._obtain(dist, "entry-points", package_dir)349        if text is None:350            return None351 352        groups = _expand.entry_points(text)353        # Any is str | dict[str, str], but causes variance issues354        expanded: dict[str, dict[str, Any]] = {"entry-points": groups}355 356        def _set_scripts(field: str, group: str):357            if group in groups:358                value = groups.pop(group)359                if field not in self.dynamic:360                    raise InvalidConfigError(_MissingDynamic.details(field, value))361                expanded[field] = value362 363        _set_scripts("scripts", "console_scripts")364        _set_scripts("gui-scripts", "gui_scripts")365 366        return expanded367 368    def _obtain_classifiers(self, dist: Distribution):369        if "classifiers" in self.dynamic:370            value = self._obtain(dist, "classifiers", {})371            if value:372                return value.splitlines()373        return None374 375    def _obtain_dependencies(self, dist: Distribution):376        if "dependencies" in self.dynamic:377            value = self._obtain(dist, "dependencies", {})378            if value:379                return _parse_requirements_list(value)380        return None381 382    def _obtain_optional_dependencies(self, dist: Distribution):383        if "optional-dependencies" not in self.dynamic:384            return None385        if "optional-dependencies" in self.dynamic_cfg:386            optional_dependencies_map = self.dynamic_cfg["optional-dependencies"]387            assert isinstance(optional_dependencies_map, dict)388            return {389                group: _parse_requirements_list(390                    self._expand_directive(391                        f"tool.setuptools.dynamic.optional-dependencies.{group}",392                        directive,393                        {},394                    )395                )396                for group, directive in optional_dependencies_map.items()397            }398        self._ensure_previously_set(dist, "optional-dependencies")399        return None400 401 402def _parse_requirements_list(value):403    return [404        line405        for line in value.splitlines()406        if line.strip() and not line.strip().startswith("#")407    ]408 409 410@contextmanager411def _ignore_errors(ignore_option_errors: bool):412    if not ignore_option_errors:413        yield414        return415 416    try:417        yield418    except Exception as ex:419        _logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}")420 421 422class _EnsurePackagesDiscovered(_expand.EnsurePackagesDiscovered):423    def __init__(424        self, distribution: Distribution, project_cfg: dict, setuptools_cfg: dict425    ) -> None:426        super().__init__(distribution)427        self._project_cfg = project_cfg428        self._setuptools_cfg = setuptools_cfg429 430    def __enter__(self) -> Self:431        """When entering the context, the values of ``packages``, ``py_modules`` and432        ``package_dir`` that are missing in ``dist`` are copied from ``setuptools_cfg``.433        """434        dist, cfg = self._dist, self._setuptools_cfg435        package_dir: dict[str, str] = cfg.setdefault("package-dir", {})436        package_dir.update(dist.package_dir or {})437        dist.package_dir = package_dir  # needs to be the same object438 439        dist.set_defaults._ignore_ext_modules()  # pyproject.toml-specific behaviour440 441        # Set `name`, `py_modules` and `packages` in dist to short-circuit442        # auto-discovery, but avoid overwriting empty lists purposefully set by users.443        if dist.metadata.name is None:444            dist.metadata.name = self._project_cfg.get("name")445        if dist.py_modules is None:446            dist.py_modules = cfg.get("py-modules")447        if dist.packages is None:448            dist.packages = cfg.get("packages")449 450        return super().__enter__()451 452    def __exit__(453        self,454        exc_type: type[BaseException] | None,455        exc_value: BaseException | None,456        traceback: TracebackType | None,457    ) -> None:458        """When exiting the context, if values of ``packages``, ``py_modules`` and459        ``package_dir`` are missing in ``setuptools_cfg``, copy from ``dist``.460        """461        # If anything was discovered set them back, so they count in the final config.462        self._setuptools_cfg.setdefault("packages", self._dist.packages)463        self._setuptools_cfg.setdefault("py-modules", self._dist.py_modules)464        return super().__exit__(exc_type, exc_value, traceback)465 466 467class _ExperimentalConfiguration(SetuptoolsWarning):468    _SUMMARY = (469        "`{subject}` in `pyproject.toml` is still *experimental* "470        "and likely to change in future releases."471    )472 473 474class _ToolsTypoInMetadata(SetuptoolsWarning):475    _SUMMARY = (476        "Ignoring [tools.setuptools] in pyproject.toml, did you mean [tool.setuptools]?"477    )478 
codekingpro/portable-devtools · Team Ai