Path: blob/main/test/lib/python3.9/site-packages/setuptools/config/_apply_pyprojecttoml.py
4799 views
"""Translation layer between pyproject config and setuptools distribution and1metadata objects.23The distribution and metadata objects are modeled after (an old version of)4core metadata, therefore configs in the format specified for ``pyproject.toml``5need to be processed before being applied.6"""7import logging8import os9import warnings10from collections.abc import Mapping11from email.headerregistry import Address12from functools import partial, reduce13from itertools import chain14from types import MappingProxyType15from typing import (TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Tuple,16Type, Union)1718from setuptools._deprecation_warning import SetuptoolsDeprecationWarning1920if TYPE_CHECKING:21from setuptools._importlib import metadata # noqa22from setuptools.dist import Distribution # noqa2324EMPTY: Mapping = MappingProxyType({}) # Immutable dict-like25_Path = Union[os.PathLike, str]26_DictOrStr = Union[dict, str]27_CorrespFn = Callable[["Distribution", Any, _Path], None]28_Correspondence = Union[str, _CorrespFn]2930_logger = logging.getLogger(__name__)313233def apply(dist: "Distribution", config: dict, filename: _Path) -> "Distribution":34"""Apply configuration dict read with :func:`read_configuration`"""3536if not config:37return dist # short-circuit unrelated pyproject.toml file3839root_dir = os.path.dirname(filename) or "."4041_apply_project_table(dist, config, root_dir)42_apply_tool_table(dist, config, filename)4344current_directory = os.getcwd()45os.chdir(root_dir)46try:47dist._finalize_requires()48dist._finalize_license_files()49finally:50os.chdir(current_directory)5152return dist535455def _apply_project_table(dist: "Distribution", config: dict, root_dir: _Path):56project_table = config.get("project", {}).copy()57if not project_table:58return # short-circuit5960_handle_missing_dynamic(dist, project_table)61_unify_entry_points(project_table)6263for field, value in project_table.items():64norm_key = json_compatible_key(field)65corresp = PYPROJECT_CORRESPONDENCE.get(norm_key, norm_key)66if callable(corresp):67corresp(dist, value, root_dir)68else:69_set_config(dist, corresp, value)707172def _apply_tool_table(dist: "Distribution", config: dict, filename: _Path):73tool_table = config.get("tool", {}).get("setuptools", {})74if not tool_table:75return # short-circuit7677for field, value in tool_table.items():78norm_key = json_compatible_key(field)7980if norm_key in TOOL_TABLE_DEPRECATIONS:81suggestion = TOOL_TABLE_DEPRECATIONS[norm_key]82msg = f"The parameter `{norm_key}` is deprecated, {suggestion}"83warnings.warn(msg, SetuptoolsDeprecationWarning)8485norm_key = TOOL_TABLE_RENAMES.get(norm_key, norm_key)86_set_config(dist, norm_key, value)8788_copy_command_options(config, dist, filename)899091def _handle_missing_dynamic(dist: "Distribution", project_table: dict):92"""Be temporarily forgiving with ``dynamic`` fields not listed in ``dynamic``"""93# TODO: Set fields back to `None` once the feature stabilizes94dynamic = set(project_table.get("dynamic", []))95for field, getter in _PREVIOUSLY_DEFINED.items():96if not (field in project_table or field in dynamic):97value = getter(dist)98if value:99msg = _WouldIgnoreField.message(field, value)100warnings.warn(msg, _WouldIgnoreField)101102103def json_compatible_key(key: str) -> str:104"""As defined in :pep:`566#json-compatible-metadata`"""105return key.lower().replace("-", "_")106107108def _set_config(dist: "Distribution", field: str, value: Any):109setter = getattr(dist.metadata, f"set_{field}", None)110if setter:111setter(value)112elif hasattr(dist.metadata, field) or field in SETUPTOOLS_PATCHES:113setattr(dist.metadata, field, value)114else:115setattr(dist, field, value)116117118_CONTENT_TYPES = {119".md": "text/markdown",120".rst": "text/x-rst",121".txt": "text/plain",122}123124125def _guess_content_type(file: str) -> Optional[str]:126_, ext = os.path.splitext(file.lower())127if not ext:128return None129130if ext in _CONTENT_TYPES:131return _CONTENT_TYPES[ext]132133valid = ", ".join(f"{k} ({v})" for k, v in _CONTENT_TYPES.items())134msg = f"only the following file extensions are recognized: {valid}."135raise ValueError(f"Undefined content type for {file}, {msg}")136137138def _long_description(dist: "Distribution", val: _DictOrStr, root_dir: _Path):139from setuptools.config import expand140141if isinstance(val, str):142text = expand.read_files(val, root_dir)143ctype = _guess_content_type(val)144else:145text = val.get("text") or expand.read_files(val.get("file", []), root_dir)146ctype = val["content-type"]147148_set_config(dist, "long_description", text)149if ctype:150_set_config(dist, "long_description_content_type", ctype)151152153def _license(dist: "Distribution", val: dict, root_dir: _Path):154from setuptools.config import expand155156if "file" in val:157_set_config(dist, "license", expand.read_files([val["file"]], root_dir))158else:159_set_config(dist, "license", val["text"])160161162def _people(dist: "Distribution", val: List[dict], _root_dir: _Path, kind: str):163field = []164email_field = []165for person in val:166if "name" not in person:167email_field.append(person["email"])168elif "email" not in person:169field.append(person["name"])170else:171addr = Address(display_name=person["name"], addr_spec=person["email"])172email_field.append(str(addr))173174if field:175_set_config(dist, kind, ", ".join(field))176if email_field:177_set_config(dist, f"{kind}_email", ", ".join(email_field))178179180def _project_urls(dist: "Distribution", val: dict, _root_dir):181_set_config(dist, "project_urls", val)182183184def _python_requires(dist: "Distribution", val: dict, _root_dir):185from setuptools.extern.packaging.specifiers import SpecifierSet186187_set_config(dist, "python_requires", SpecifierSet(val))188189190def _dependencies(dist: "Distribution", val: list, _root_dir):191if getattr(dist, "install_requires", []):192msg = "`install_requires` overwritten in `pyproject.toml` (dependencies)"193warnings.warn(msg)194_set_config(dist, "install_requires", val)195196197def _optional_dependencies(dist: "Distribution", val: dict, _root_dir):198existing = getattr(dist, "extras_require", {})199_set_config(dist, "extras_require", {**existing, **val})200201202def _unify_entry_points(project_table: dict):203project = project_table204entry_points = project.pop("entry-points", project.pop("entry_points", {}))205renaming = {"scripts": "console_scripts", "gui_scripts": "gui_scripts"}206for key, value in list(project.items()): # eager to allow modifications207norm_key = json_compatible_key(key)208if norm_key in renaming and value:209entry_points[renaming[norm_key]] = project.pop(key)210211if entry_points:212project["entry-points"] = {213name: [f"{k} = {v}" for k, v in group.items()]214for name, group in entry_points.items()215}216217218def _copy_command_options(pyproject: dict, dist: "Distribution", filename: _Path):219tool_table = pyproject.get("tool", {})220cmdclass = tool_table.get("setuptools", {}).get("cmdclass", {})221valid_options = _valid_command_options(cmdclass)222223cmd_opts = dist.command_options224for cmd, config in pyproject.get("tool", {}).get("distutils", {}).items():225cmd = json_compatible_key(cmd)226valid = valid_options.get(cmd, set())227cmd_opts.setdefault(cmd, {})228for key, value in config.items():229key = json_compatible_key(key)230cmd_opts[cmd][key] = (str(filename), value)231if key not in valid:232# To avoid removing options that are specified dynamically we233# just log a warn...234_logger.warning(f"Command option {cmd}.{key} is not defined")235236237def _valid_command_options(cmdclass: Mapping = EMPTY) -> Dict[str, Set[str]]:238from .._importlib import metadata239from setuptools.dist import Distribution240241valid_options = {"global": _normalise_cmd_options(Distribution.global_options)}242243unloaded_entry_points = metadata.entry_points(group='distutils.commands')244loaded_entry_points = (_load_ep(ep) for ep in unloaded_entry_points)245entry_points = (ep for ep in loaded_entry_points if ep)246for cmd, cmd_class in chain(entry_points, cmdclass.items()):247opts = valid_options.get(cmd, set())248opts = opts | _normalise_cmd_options(getattr(cmd_class, "user_options", []))249valid_options[cmd] = opts250251return valid_options252253254def _load_ep(ep: "metadata.EntryPoint") -> Optional[Tuple[str, Type]]:255# Ignore all the errors256try:257return (ep.name, ep.load())258except Exception as ex:259msg = f"{ex.__class__.__name__} while trying to load entry-point {ep.name}"260_logger.warning(f"{msg}: {ex}")261return None262263264def _normalise_cmd_option_key(name: str) -> str:265return json_compatible_key(name).strip("_=")266267268def _normalise_cmd_options(desc: List[Tuple[str, Optional[str], str]]) -> Set[str]:269return {_normalise_cmd_option_key(fancy_option[0]) for fancy_option in desc}270271272def _attrgetter(attr):273"""274Similar to ``operator.attrgetter`` but returns None if ``attr`` is not found275>>> from types import SimpleNamespace276>>> obj = SimpleNamespace(a=42, b=SimpleNamespace(c=13))277>>> _attrgetter("a")(obj)27842279>>> _attrgetter("b.c")(obj)28013281>>> _attrgetter("d")(obj) is None282True283"""284return partial(reduce, lambda acc, x: getattr(acc, x, None), attr.split("."))285286287def _some_attrgetter(*items):288"""289Return the first "truth-y" attribute or None290>>> from types import SimpleNamespace291>>> obj = SimpleNamespace(a=42, b=SimpleNamespace(c=13))292>>> _some_attrgetter("d", "a", "b.c")(obj)29342294>>> _some_attrgetter("d", "e", "b.c", "a")(obj)29513296>>> _some_attrgetter("d", "e", "f")(obj) is None297True298"""299def _acessor(obj):300values = (_attrgetter(i)(obj) for i in items)301return next((i for i in values if i is not None), None)302return _acessor303304305PYPROJECT_CORRESPONDENCE: Dict[str, _Correspondence] = {306"readme": _long_description,307"license": _license,308"authors": partial(_people, kind="author"),309"maintainers": partial(_people, kind="maintainer"),310"urls": _project_urls,311"dependencies": _dependencies,312"optional_dependencies": _optional_dependencies,313"requires_python": _python_requires,314}315316TOOL_TABLE_RENAMES = {"script_files": "scripts"}317TOOL_TABLE_DEPRECATIONS = {318"namespace_packages": "consider using implicit namespaces instead (PEP 420)."319}320321SETUPTOOLS_PATCHES = {"long_description_content_type", "project_urls",322"provides_extras", "license_file", "license_files"}323324_PREVIOUSLY_DEFINED = {325"name": _attrgetter("metadata.name"),326"version": _attrgetter("metadata.version"),327"description": _attrgetter("metadata.description"),328"readme": _attrgetter("metadata.long_description"),329"requires-python": _some_attrgetter("python_requires", "metadata.python_requires"),330"license": _attrgetter("metadata.license"),331"authors": _some_attrgetter("metadata.author", "metadata.author_email"),332"maintainers": _some_attrgetter("metadata.maintainer", "metadata.maintainer_email"),333"keywords": _attrgetter("metadata.keywords"),334"classifiers": _attrgetter("metadata.classifiers"),335"urls": _attrgetter("metadata.project_urls"),336"entry-points": _attrgetter("entry_points"),337"dependencies": _some_attrgetter("_orig_install_requires", "install_requires"),338"optional-dependencies": _some_attrgetter("_orig_extras_require", "extras_require"),339}340341342class _WouldIgnoreField(UserWarning):343"""Inform users that ``pyproject.toml`` would overwrite previous metadata."""344345MESSAGE = """\346{field!r} defined outside of `pyproject.toml` would be ignored.347!!\n\n348##########################################################################349# configuration would be ignored/result in error due to `pyproject.toml` #350##########################################################################351352The following seems to be defined outside of `pyproject.toml`:353354`{field} = {value!r}`355356According to the spec (see the link bellow), however, setuptools CANNOT357consider this value unless {field!r} is listed as `dynamic`.358359https://packaging.python.org/en/latest/specifications/declaring-project-metadata/360361For the time being, `setuptools` will still consider the given value (as a362**transitional** measure), but please note that future releases of setuptools will363follow strictly the standard.364365To prevent this warning, you can list {field!r} under `dynamic` or alternatively366remove the `[project]` table from your file and rely entirely on other means of367configuration.368\n\n!!369"""370371@classmethod372def message(cls, field, value):373from inspect import cleandoc374return cleandoc(cls.MESSAGE.format(field=field, value=value))375376377