• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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