Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
_apply_pyprojecttoml.py535 linesDownload Raw Back to config
1"""Translation layer between pyproject config and setuptools distribution and2metadata objects.3 4The distribution and metadata objects are modeled after (an old version of)5core metadata, therefore configs in the format specified for ``pyproject.toml``6need to be processed before being applied.7 8**PRIVATE MODULE**: API reserved for setuptools internal usage only.9"""10 11from __future__ import annotations12 13import logging14import os15from collections.abc import Mapping16from email.headerregistry import Address17from functools import partial, reduce18from inspect import cleandoc19from itertools import chain20from types import MappingProxyType21from typing import TYPE_CHECKING, Any, Callable, TypeVar, Union22 23from .. import _static24from .._path import StrPath25from ..errors import InvalidConfigError, RemovedConfigError26from ..extension import Extension27from ..warnings import SetuptoolsDeprecationWarning, SetuptoolsWarning28 29if TYPE_CHECKING:30    from typing_extensions import TypeAlias31 32    from setuptools._importlib import metadata33    from setuptools.dist import Distribution34 35    from distutils.dist import _OptionsList  # Comes from typeshed36 37 38EMPTY: Mapping = MappingProxyType({})  # Immutable dict-like39_ProjectReadmeValue: TypeAlias = Union[str, dict[str, str]]40_Correspondence: TypeAlias = Callable[["Distribution", Any, Union[StrPath, None]], None]41_T = TypeVar("_T")42 43_logger = logging.getLogger(__name__)44 45 46def apply(dist: Distribution, config: dict, filename: StrPath) -> Distribution:47    """Apply configuration dict read with :func:`read_configuration`"""48 49    if not config:50        return dist  # short-circuit unrelated pyproject.toml file51 52    root_dir = os.path.dirname(filename) or "."53 54    _apply_project_table(dist, config, root_dir)55    _apply_tool_table(dist, config, filename)56 57    current_directory = os.getcwd()58    os.chdir(root_dir)59    try:60        dist._finalize_requires()61        dist._finalize_license_expression()62        dist._finalize_license_files()63    finally:64        os.chdir(current_directory)65 66    return dist67 68 69def _apply_project_table(dist: Distribution, config: dict, root_dir: StrPath):70    orig_config = config.get("project", {})71    if not orig_config:72        return  # short-circuit73 74    project_table = {k: _static.attempt_conversion(v) for k, v in orig_config.items()}75    _handle_missing_dynamic(dist, project_table)76    _unify_entry_points(project_table)77 78    for field, value in project_table.items():79        norm_key = json_compatible_key(field)80        corresp = PYPROJECT_CORRESPONDENCE.get(norm_key, norm_key)81        if callable(corresp):82            corresp(dist, value, root_dir)83        else:84            _set_config(dist, corresp, value)85 86 87def _apply_tool_table(dist: Distribution, config: dict, filename: StrPath):88    tool_table = config.get("tool", {}).get("setuptools", {})89    if not tool_table:90        return  # short-circuit91 92    if "license-files" in tool_table:93        if "license-files" in config.get("project", {}):94            # https://github.com/pypa/setuptools/pull/4837#discussion_r200498334995            raise InvalidConfigError(96                "'project.license-files' is defined already. "97                "Remove 'tool.setuptools.license-files'."98            )99 100        pypa_guides = "guides/writing-pyproject-toml/#license-files"101        SetuptoolsDeprecationWarning.emit(102            "'tool.setuptools.license-files' is deprecated in favor of "103            "'project.license-files' (available on setuptools>=77.0.0).",104            see_url=f"https://packaging.python.org/en/latest/{pypa_guides}",105            due_date=(2027, 2, 18),  # Warning introduced on 2025-02-18106        )107 108    for field, value in tool_table.items():109        norm_key = json_compatible_key(field)110 111        if norm_key in TOOL_TABLE_REMOVALS:112            suggestion = cleandoc(TOOL_TABLE_REMOVALS[norm_key])113            msg = f"""114            The parameter `tool.setuptools.{field}` was long deprecated115            and has been removed from `pyproject.toml`.116            """117            raise RemovedConfigError("\n".join([cleandoc(msg), suggestion]))118 119        norm_key = TOOL_TABLE_RENAMES.get(norm_key, norm_key)120        corresp = TOOL_TABLE_CORRESPONDENCE.get(norm_key, norm_key)121        if callable(corresp):122            corresp(dist, value)123        else:124            _set_config(dist, corresp, value)125 126    _copy_command_options(config, dist, filename)127 128 129def _handle_missing_dynamic(dist: Distribution, project_table: dict):130    """Be temporarily forgiving with ``dynamic`` fields not listed in ``dynamic``"""131    dynamic = set(project_table.get("dynamic", []))132    for field, getter in _PREVIOUSLY_DEFINED.items():133        if not (field in project_table or field in dynamic):134            value = getter(dist)135            if value:136                _MissingDynamic.emit(field=field, value=value)137                project_table[field] = _RESET_PREVIOUSLY_DEFINED.get(field)138 139 140def json_compatible_key(key: str) -> str:141    """As defined in :pep:`566#json-compatible-metadata`"""142    return key.lower().replace("-", "_")143 144 145def _set_config(dist: Distribution, field: str, value: Any):146    val = _PREPROCESS.get(field, _noop)(dist, value)147    setter = getattr(dist.metadata, f"set_{field}", None)148    if setter:149        setter(val)150    elif hasattr(dist.metadata, field) or field in SETUPTOOLS_PATCHES:151        setattr(dist.metadata, field, val)152    else:153        setattr(dist, field, val)154 155 156_CONTENT_TYPES = {157    ".md": "text/markdown",158    ".rst": "text/x-rst",159    ".txt": "text/plain",160}161 162 163def _guess_content_type(file: str) -> str | None:164    _, ext = os.path.splitext(file.lower())165    if not ext:166        return None167 168    if ext in _CONTENT_TYPES:169        return _static.Str(_CONTENT_TYPES[ext])170 171    valid = ", ".join(f"{k} ({v})" for k, v in _CONTENT_TYPES.items())172    msg = f"only the following file extensions are recognized: {valid}."173    raise ValueError(f"Undefined content type for {file}, {msg}")174 175 176def _long_description(177    dist: Distribution, val: _ProjectReadmeValue, root_dir: StrPath | None178):179    from setuptools.config import expand180 181    file: str | tuple[()]182    if isinstance(val, str):183        file = val184        text = expand.read_files(file, root_dir)185        ctype = _guess_content_type(file)186    else:187        file = val.get("file") or ()188        text = val.get("text") or expand.read_files(file, root_dir)189        ctype = val["content-type"]190 191    # XXX: Is it completely safe to assume static?192    _set_config(dist, "long_description", _static.Str(text))193 194    if ctype:195        _set_config(dist, "long_description_content_type", _static.Str(ctype))196 197    if file:198        dist._referenced_files.add(file)199 200 201def _license(dist: Distribution, val: str | dict, root_dir: StrPath | None):202    from setuptools.config import expand203 204    if isinstance(val, str):205        if getattr(dist.metadata, "license", None):206            SetuptoolsWarning.emit("`license` overwritten by `pyproject.toml`")207            dist.metadata.license = None208        _set_config(dist, "license_expression", _static.Str(val))209    else:210        pypa_guides = "guides/writing-pyproject-toml/#license"211        SetuptoolsDeprecationWarning.emit(212            "`project.license` as a TOML table is deprecated",213            "Please use a simple string containing a SPDX expression for "214            "`project.license`. You can also use `project.license-files`. "215            "(Both options available on setuptools>=77.0.0).",216            see_url=f"https://packaging.python.org/en/latest/{pypa_guides}",217            due_date=(2027, 2, 18),  # Introduced on 2025-02-18218        )219        if "file" in val:220            # XXX: Is it completely safe to assume static?221            value = expand.read_files([val["file"]], root_dir)222            _set_config(dist, "license", _static.Str(value))223            dist._referenced_files.add(val["file"])224        else:225            _set_config(dist, "license", _static.Str(val["text"]))226 227 228def _people(dist: Distribution, val: list[dict], _root_dir: StrPath | None, kind: str):229    field = []230    email_field = []231    for person in val:232        if "name" not in person:233            email_field.append(person["email"])234        elif "email" not in person:235            field.append(person["name"])236        else:237            addr = Address(display_name=person["name"], addr_spec=person["email"])238            email_field.append(str(addr))239 240    if field:241        _set_config(dist, kind, _static.Str(", ".join(field)))242    if email_field:243        _set_config(dist, f"{kind}_email", _static.Str(", ".join(email_field)))244 245 246def _project_urls(dist: Distribution, val: dict, _root_dir: StrPath | None):247    _set_config(dist, "project_urls", val)248 249 250def _python_requires(dist: Distribution, val: str, _root_dir: StrPath | None):251    _set_config(dist, "python_requires", _static.SpecifierSet(val))252 253 254def _dependencies(dist: Distribution, val: list, _root_dir: StrPath | None):255    if getattr(dist, "install_requires", []):256        msg = "`install_requires` overwritten in `pyproject.toml` (dependencies)"257        SetuptoolsWarning.emit(msg)258    dist.install_requires = val259 260 261def _optional_dependencies(dist: Distribution, val: dict, _root_dir: StrPath | None):262    if getattr(dist, "extras_require", None):263        msg = "`extras_require` overwritten in `pyproject.toml` (optional-dependencies)"264        SetuptoolsWarning.emit(msg)265    dist.extras_require = val266 267 268def _ext_modules(dist: Distribution, val: list[dict]) -> list[Extension]:269    existing = dist.ext_modules or []270    args = ({k.replace("-", "_"): v for k, v in x.items()} for x in val)271    new = (Extension(**_adjust_ext_attrs(kw)) for kw in args)272    return [*existing, *new]273 274 275def _adjust_ext_attrs(attrs: dict) -> dict:276    # https://github.com/pypa/setuptools/issues/4810277    # In TOML there is no differentiation between tuples and lists,278    # and distutils requires tuples...279    attrs["define_macros"] = list(map(tuple, attrs.get("define_macros") or []))280    return attrs281 282 283def _noop(_dist: Distribution, val: _T) -> _T:284    return val285 286 287def _identity(val: _T) -> _T:288    return val289 290 291def _unify_entry_points(project_table: dict):292    project = project_table293    given = project.pop("entry-points", project.pop("entry_points", {}))294    entry_points = dict(given)  # Avoid problems with static295    renaming = {"scripts": "console_scripts", "gui_scripts": "gui_scripts"}296    for key, value in list(project.items()):  # eager to allow modifications297        norm_key = json_compatible_key(key)298        if norm_key in renaming:299            # Don't skip even if value is empty (reason: reset missing `dynamic`)300            entry_points[renaming[norm_key]] = project.pop(key)301 302    if entry_points:303        project["entry-points"] = {304            name: [f"{k} = {v}" for k, v in group.items()]305            for name, group in entry_points.items()306            if group  # now we can skip empty groups307        }308        # Sometimes this will set `project["entry-points"] = {}`, and that is309        # intentional (for resetting configurations that are missing `dynamic`).310 311 312def _copy_command_options(pyproject: dict, dist: Distribution, filename: StrPath):313    tool_table = pyproject.get("tool", {})314    cmdclass = tool_table.get("setuptools", {}).get("cmdclass", {})315    valid_options = _valid_command_options(cmdclass)316 317    cmd_opts = dist.command_options318    for cmd, config in pyproject.get("tool", {}).get("distutils", {}).items():319        cmd = json_compatible_key(cmd)320        valid = valid_options.get(cmd, set())321        cmd_opts.setdefault(cmd, {})322        for key, value in config.items():323            key = json_compatible_key(key)324            cmd_opts[cmd][key] = (str(filename), value)325            if key not in valid:326                # To avoid removing options that are specified dynamically we327                # just log a warn...328                _logger.warning(f"Command option {cmd}.{key} is not defined")329 330 331def _valid_command_options(cmdclass: Mapping = EMPTY) -> dict[str, set[str]]:332    from setuptools.dist import Distribution333 334    from .._importlib import metadata335 336    valid_options = {"global": _normalise_cmd_options(Distribution.global_options)}337 338    unloaded_entry_points = metadata.entry_points(group='distutils.commands')339    loaded_entry_points = (_load_ep(ep) for ep in unloaded_entry_points)340    entry_points = (ep for ep in loaded_entry_points if ep)341    for cmd, cmd_class in chain(entry_points, cmdclass.items()):342        opts = valid_options.get(cmd, set())343        opts = opts | _normalise_cmd_options(getattr(cmd_class, "user_options", []))344        valid_options[cmd] = opts345 346    return valid_options347 348 349def _load_ep(ep: metadata.EntryPoint) -> tuple[str, type] | None:350    if ep.value.startswith("wheel.bdist_wheel"):351        # Ignore deprecated entrypoint from wheel and avoid warning pypa/wheel#631352        # TODO: remove check when `bdist_wheel` has been fully removed from pypa/wheel353        return None354 355    # Ignore all the errors356    try:357        return (ep.name, ep.load())358    except Exception as ex:359        msg = f"{ex.__class__.__name__} while trying to load entry-point {ep.name}"360        _logger.warning(f"{msg}: {ex}")361        return None362 363 364def _normalise_cmd_option_key(name: str) -> str:365    return json_compatible_key(name).strip("_=")366 367 368def _normalise_cmd_options(desc: _OptionsList) -> set[str]:369    return {_normalise_cmd_option_key(fancy_option[0]) for fancy_option in desc}370 371 372def _get_previous_entrypoints(dist: Distribution) -> dict[str, list]:373    ignore = ("console_scripts", "gui_scripts")374    value = getattr(dist, "entry_points", None) or {}375    return {k: v for k, v in value.items() if k not in ignore}376 377 378def _get_previous_scripts(dist: Distribution) -> list | None:379    value = getattr(dist, "entry_points", None) or {}380    return value.get("console_scripts")381 382 383def _get_previous_gui_scripts(dist: Distribution) -> list | None:384    value = getattr(dist, "entry_points", None) or {}385    return value.get("gui_scripts")386 387 388def _set_static_list_metadata(attr: str, dist: Distribution, val: list) -> None:389    """Apply distutils metadata validation but preserve "static" behaviour"""390    meta = dist.metadata391    setter, getter = getattr(meta, f"set_{attr}"), getattr(meta, f"get_{attr}")392    setter(val)393    setattr(meta, attr, _static.List(getter()))394 395 396def _attrgetter(attr):397    """398    Similar to ``operator.attrgetter`` but returns None if ``attr`` is not found399    >>> from types import SimpleNamespace400    >>> obj = SimpleNamespace(a=42, b=SimpleNamespace(c=13))401    >>> _attrgetter("a")(obj)402    42403    >>> _attrgetter("b.c")(obj)404    13405    >>> _attrgetter("d")(obj) is None406    True407    """408    return partial(reduce, lambda acc, x: getattr(acc, x, None), attr.split("."))409 410 411def _some_attrgetter(*items):412    """413    Return the first "truth-y" attribute or None414    >>> from types import SimpleNamespace415    >>> obj = SimpleNamespace(a=42, b=SimpleNamespace(c=13))416    >>> _some_attrgetter("d", "a", "b.c")(obj)417    42418    >>> _some_attrgetter("d", "e", "b.c", "a")(obj)419    13420    >>> _some_attrgetter("d", "e", "f")(obj) is None421    True422    """423 424    def _acessor(obj):425        values = (_attrgetter(i)(obj) for i in items)426        return next((i for i in values if i is not None), None)427 428    return _acessor429 430 431PYPROJECT_CORRESPONDENCE: dict[str, _Correspondence] = {432    "readme": _long_description,433    "license": _license,434    "authors": partial(_people, kind="author"),435    "maintainers": partial(_people, kind="maintainer"),436    "urls": _project_urls,437    "dependencies": _dependencies,438    "optional_dependencies": _optional_dependencies,439    "requires_python": _python_requires,440}441 442TOOL_TABLE_RENAMES = {"script_files": "scripts"}443TOOL_TABLE_REMOVALS = {444    "namespace_packages": """445        Please migrate to implicit native namespaces instead.446        See https://packaging.python.org/en/latest/guides/packaging-namespace-packages/.447        """,448}449TOOL_TABLE_CORRESPONDENCE = {450    # Fields with corresponding core metadata need to be marked as static:451    "obsoletes": partial(_set_static_list_metadata, "obsoletes"),452    "provides": partial(_set_static_list_metadata, "provides"),453    "platforms": partial(_set_static_list_metadata, "platforms"),454}455 456SETUPTOOLS_PATCHES = {457    "long_description_content_type",458    "project_urls",459    "provides_extras",460    "license_file",461    "license_files",462    "license_expression",463}464 465_PREPROCESS = {466    "ext_modules": _ext_modules,467}468 469_PREVIOUSLY_DEFINED = {470    "name": _attrgetter("metadata.name"),471    "version": _attrgetter("metadata.version"),472    "description": _attrgetter("metadata.description"),473    "readme": _attrgetter("metadata.long_description"),474    "requires-python": _some_attrgetter("python_requires", "metadata.python_requires"),475    "license": _some_attrgetter("metadata.license_expression", "metadata.license"),476    # XXX: `license-file` is currently not considered in the context of `dynamic`.477    #      See TestPresetField.test_license_files_exempt_from_dynamic478    "authors": _some_attrgetter("metadata.author", "metadata.author_email"),479    "maintainers": _some_attrgetter("metadata.maintainer", "metadata.maintainer_email"),480    "keywords": _attrgetter("metadata.keywords"),481    "classifiers": _attrgetter("metadata.classifiers"),482    "urls": _attrgetter("metadata.project_urls"),483    "entry-points": _get_previous_entrypoints,484    "scripts": _get_previous_scripts,485    "gui-scripts": _get_previous_gui_scripts,486    "dependencies": _attrgetter("install_requires"),487    "optional-dependencies": _attrgetter("extras_require"),488}489 490 491_RESET_PREVIOUSLY_DEFINED: dict = {492    # Fix improper setting: given in `setup.py`, but not listed in `dynamic`493    # Use "immutable" data structures to avoid in-place modification.494    # dict: pyproject name => value to which reset495    "license": "",496    # XXX: `license-file` is currently not considered in the context of `dynamic`.497    #      See TestPresetField.test_license_files_exempt_from_dynamic498    "authors": _static.EMPTY_LIST,499    "maintainers": _static.EMPTY_LIST,500    "keywords": _static.EMPTY_LIST,501    "classifiers": _static.EMPTY_LIST,502    "urls": _static.EMPTY_DICT,503    "entry-points": _static.EMPTY_DICT,504    "scripts": _static.EMPTY_DICT,505    "gui-scripts": _static.EMPTY_DICT,506    "dependencies": _static.EMPTY_LIST,507    "optional-dependencies": _static.EMPTY_DICT,508}509 510 511class _MissingDynamic(SetuptoolsWarning):512    _SUMMARY = "`{field}` defined outside of `pyproject.toml` is ignored."513 514    _DETAILS = """515    The following seems to be defined outside of `pyproject.toml`:516 517    `{field} = {value!r}`518 519    According to the spec (see the link below), however, setuptools CANNOT520    consider this value unless `{field}` is listed as `dynamic`.521 522    https://packaging.python.org/en/latest/specifications/pyproject-toml/#declaring-project-metadata-the-project-table523 524    To prevent this problem, you can list `{field}` under `dynamic` or alternatively525    remove the `[project]` table from your file and rely entirely on other means of526    configuration.527    """528    # TODO: Consider removing this check in the future?529    #       There is a trade-off here between improving "debug-ability" and the cost530    #       of running/testing/maintaining these unnecessary checks...531 532    @classmethod533    def details(cls, field: str, value: Any) -> str:534        return cls._DETAILS.format(field=field, value=value)535 
codekingpro/portable-devtools · Team Ai