1"""Translation layer between pyproject config and setuptools distribution and 2metadata 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""" 8import logging 9import os 10from collections.abc import Mapping 11from email.headerregistry import Address 12from functools import partial 13from itertools import chain 14from types import MappingProxyType 15from typing import (TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Tuple, 16 Type, Union) 17 18if TYPE_CHECKING: 19 from setuptools._importlib import metadata # noqa 20 from setuptools.dist import Distribution # noqa 21 22EMPTY: Mapping = MappingProxyType({}) # Immutable dict-like 23_Path = Union[os.PathLike, str] 24_DictOrStr = Union[dict, str] 25_CorrespFn = Callable[["Distribution", Any, _Path], None] 26_Correspondence = Union[str, _CorrespFn] 27 28_logger = logging.getLogger(__name__) 29 30 31def apply(dist: "Distribution", config: dict, filename: _Path) -> "Distribution": 32 """Apply configuration dict read with :func:`read_configuration`""" 33 34 root_dir = os.path.dirname(filename) or "." 35 tool_table = config.get("tool", {}).get("setuptools", {}) 36 project_table = config.get("project", {}).copy() 37 _unify_entry_points(project_table) 38 for field, value in project_table.items(): 39 norm_key = json_compatible_key(field) 40 corresp = PYPROJECT_CORRESPONDENCE.get(norm_key, norm_key) 41 if callable(corresp): 42 corresp(dist, value, root_dir) 43 else: 44 _set_config(dist, corresp, value) 45 46 for field, value in tool_table.items(): 47 norm_key = json_compatible_key(field) 48 norm_key = TOOL_TABLE_RENAMES.get(norm_key, norm_key) 49 _set_config(dist, norm_key, value) 50 51 _copy_command_options(config, dist, filename) 52 53 current_directory = os.getcwd() 54 os.chdir(root_dir) 55 try: 56 dist._finalize_requires() 57 dist._finalize_license_files() 58 finally: 59 os.chdir(current_directory) 60 61 return dist 62 63 64def json_compatible_key(key: str) -> str: 65 """As defined in :pep:`566#json-compatible-metadata`""" 66 return key.lower().replace("-", "_") 67 68 69def _set_config(dist: "Distribution", field: str, value: Any): 70 setter = getattr(dist.metadata, f"set_{field}", None) 71 if setter: 72 setter(value) 73 elif hasattr(dist.metadata, field) or field in SETUPTOOLS_PATCHES: 74 setattr(dist.metadata, field, value) 75 else: 76 setattr(dist, field, value) 77 78 79_CONTENT_TYPES = { 80 ".md": "text/markdown", 81 ".rst": "text/x-rst", 82 ".txt": "text/plain", 83} 84 85 86def _guess_content_type(file: str) -> Optional[str]: 87 _, ext = os.path.splitext(file.lower()) 88 if not ext: 89 return None 90 91 if ext in _CONTENT_TYPES: 92 return _CONTENT_TYPES[ext] 93 94 valid = ", ".join(f"{k} ({v})" for k, v in _CONTENT_TYPES.items()) 95 msg = f"only the following file extensions are recognized: {valid}." 96 raise ValueError(f"Undefined content type for {file}, {msg}") 97 98 99def _long_description(dist: "Distribution", val: _DictOrStr, root_dir: _Path): 100 from setuptools.config import expand 101 102 if isinstance(val, str): 103 text = expand.read_files(val, root_dir) 104 ctype = _guess_content_type(val) 105 else: 106 text = val.get("text") or expand.read_files(val.get("file", []), root_dir) 107 ctype = val["content-type"] 108 109 _set_config(dist, "long_description", text) 110 if ctype: 111 _set_config(dist, "long_description_content_type", ctype) 112 113 114def _license(dist: "Distribution", val: dict, root_dir: _Path): 115 from setuptools.config import expand 116 117 if "file" in val: 118 _set_config(dist, "license", expand.read_files([val["file"]], root_dir)) 119 else: 120 _set_config(dist, "license", val["text"]) 121 122 123def _people(dist: "Distribution", val: List[dict], _root_dir: _Path, kind: str): 124 field = [] 125 email_field = [] 126 for person in val: 127 if "name" not in person: 128 email_field.append(person["email"]) 129 elif "email" not in person: 130 field.append(person["name"]) 131 else: 132 addr = Address(display_name=person["name"], addr_spec=person["email"]) 133 email_field.append(str(addr)) 134 135 if field: 136 _set_config(dist, kind, ", ".join(field)) 137 if email_field: 138 _set_config(dist, f"{kind}_email", ", ".join(email_field)) 139 140 141def _project_urls(dist: "Distribution", val: dict, _root_dir): 142 special = {"downloadurl": "download_url", "homepage": "url"} 143 for key, url in val.items(): 144 norm_key = json_compatible_key(key).replace("_", "") 145 _set_config(dist, special.get(norm_key, key), url) 146 # If `homepage` is missing, distutils will warn the following message: 147 # "warning: check: missing required meta-data: url" 148 # In the context of PEP 621, users might ask themselves: "which url?". 149 # Let's add a warning before distutils check to help users understand the problem: 150 if not dist.metadata.url: 151 msg = ( 152 "Missing `Homepage` url.\nIt is advisable to link some kind of reference " 153 "for your project (e.g. source code or documentation).\n" 154 ) 155 _logger.warning(msg) 156 _set_config(dist, "project_urls", val.copy()) 157 158 159def _python_requires(dist: "Distribution", val: dict, _root_dir): 160 from setuptools.extern.packaging.specifiers import SpecifierSet 161 162 _set_config(dist, "python_requires", SpecifierSet(val)) 163 164 165def _unify_entry_points(project_table: dict): 166 project = project_table 167 entry_points = project.pop("entry-points", project.pop("entry_points", {})) 168 renaming = {"scripts": "console_scripts", "gui_scripts": "gui_scripts"} 169 for key, value in list(project.items()): # eager to allow modifications 170 norm_key = json_compatible_key(key) 171 if norm_key in renaming and value: 172 entry_points[renaming[norm_key]] = project.pop(key) 173 174 if entry_points: 175 project["entry-points"] = { 176 name: [f"{k} = {v}" for k, v in group.items()] 177 for name, group in entry_points.items() 178 } 179 180 181def _copy_command_options(pyproject: dict, dist: "Distribution", filename: _Path): 182 tool_table = pyproject.get("tool", {}) 183 cmdclass = tool_table.get("setuptools", {}).get("cmdclass", {}) 184 valid_options = _valid_command_options(cmdclass) 185 186 cmd_opts = dist.command_options 187 for cmd, config in pyproject.get("tool", {}).get("distutils", {}).items(): 188 cmd = json_compatible_key(cmd) 189 valid = valid_options.get(cmd, set()) 190 cmd_opts.setdefault(cmd, {}) 191 for key, value in config.items(): 192 key = json_compatible_key(key) 193 cmd_opts[cmd][key] = (str(filename), value) 194 if key not in valid: 195 # To avoid removing options that are specified dynamically we 196 # just log a warn... 197 _logger.warning(f"Command option {cmd}.{key} is not defined") 198 199 200def _valid_command_options(cmdclass: Mapping = EMPTY) -> Dict[str, Set[str]]: 201 from .._importlib import metadata 202 from setuptools.dist import Distribution 203 204 valid_options = {"global": _normalise_cmd_options(Distribution.global_options)} 205 206 unloaded_entry_points = metadata.entry_points(group='distutils.commands') 207 loaded_entry_points = (_load_ep(ep) for ep in unloaded_entry_points) 208 entry_points = (ep for ep in loaded_entry_points if ep) 209 for cmd, cmd_class in chain(entry_points, cmdclass.items()): 210 opts = valid_options.get(cmd, set()) 211 opts = opts | _normalise_cmd_options(getattr(cmd_class, "user_options", [])) 212 valid_options[cmd] = opts 213 214 return valid_options 215 216 217def _load_ep(ep: "metadata.EntryPoint") -> Optional[Tuple[str, Type]]: 218 # Ignore all the errors 219 try: 220 return (ep.name, ep.load()) 221 except Exception as ex: 222 msg = f"{ex.__class__.__name__} while trying to load entry-point {ep.name}" 223 _logger.warning(f"{msg}: {ex}") 224 return None 225 226 227def _normalise_cmd_option_key(name: str) -> str: 228 return json_compatible_key(name).strip("_=") 229 230 231def _normalise_cmd_options(desc: List[Tuple[str, Optional[str], str]]) -> Set[str]: 232 return {_normalise_cmd_option_key(fancy_option[0]) for fancy_option in desc} 233 234 235PYPROJECT_CORRESPONDENCE: Dict[str, _Correspondence] = { 236 "readme": _long_description, 237 "license": _license, 238 "authors": partial(_people, kind="author"), 239 "maintainers": partial(_people, kind="maintainer"), 240 "urls": _project_urls, 241 "dependencies": "install_requires", 242 "optional_dependencies": "extras_require", 243 "requires_python": _python_requires, 244} 245 246TOOL_TABLE_RENAMES = {"script_files": "scripts"} 247 248SETUPTOOLS_PATCHES = {"long_description_content_type", "project_urls", 249 "provides_extras", "license_file", "license_files"} 250