c805e63940269dbbcc5dc293736b9cd6d12d0d2d
[SubU] /
1 """Translation layer between pyproject config and setuptools distribution and
2 metadata objects.
3
4 The distribution and metadata objects are modeled after (an old version of)
5 core metadata, therefore configs in the format specified for ``pyproject.toml``
6 need to be processed before being applied.
7
8 **PRIVATE MODULE**: API reserved for setuptools internal usage only.
9 """
10 import logging
11 import os
12 import warnings
13 from collections.abc import Mapping
14 from email.headerregistry import Address
15 from functools import partial, reduce
16 from itertools import chain
17 from types import MappingProxyType
18 from typing import (TYPE_CHECKING, Any, Callable, Dict, List, Optional, Set, Tuple,
19                     Type, Union, cast)
20
21 from setuptools._deprecation_warning import SetuptoolsDeprecationWarning
22
23 if TYPE_CHECKING:
24     from setuptools._importlib import metadata  # noqa
25     from setuptools.dist import Distribution  # noqa
26
27 EMPTY: Mapping = MappingProxyType({})  # Immutable dict-like
28 _Path = Union[os.PathLike, str]
29 _DictOrStr = Union[dict, str]
30 _CorrespFn = Callable[["Distribution", Any, _Path], None]
31 _Correspondence = Union[str, _CorrespFn]
32
33 _logger = logging.getLogger(__name__)
34
35
36 def apply(dist: "Distribution", config: dict, filename: _Path) -> "Distribution":
37     """Apply configuration dict read with :func:`read_configuration`"""
38
39     if not config:
40         return dist  # short-circuit unrelated pyproject.toml file
41
42     root_dir = os.path.dirname(filename) or "."
43
44     _apply_project_table(dist, config, root_dir)
45     _apply_tool_table(dist, config, filename)
46
47     current_directory = os.getcwd()
48     os.chdir(root_dir)
49     try:
50         dist._finalize_requires()
51         dist._finalize_license_files()
52     finally:
53         os.chdir(current_directory)
54
55     return dist
56
57
58 def _apply_project_table(dist: "Distribution", config: dict, root_dir: _Path):
59     project_table = config.get("project", {}).copy()
60     if not project_table:
61         return  # short-circuit
62
63     _handle_missing_dynamic(dist, project_table)
64     _unify_entry_points(project_table)
65
66     for field, value in project_table.items():
67         norm_key = json_compatible_key(field)
68         corresp = PYPROJECT_CORRESPONDENCE.get(norm_key, norm_key)
69         if callable(corresp):
70             corresp(dist, value, root_dir)
71         else:
72             _set_config(dist, corresp, value)
73
74
75 def _apply_tool_table(dist: "Distribution", config: dict, filename: _Path):
76     tool_table = config.get("tool", {}).get("setuptools", {})
77     if not tool_table:
78         return  # short-circuit
79
80     for field, value in tool_table.items():
81         norm_key = json_compatible_key(field)
82
83         if norm_key in TOOL_TABLE_DEPRECATIONS:
84             suggestion = TOOL_TABLE_DEPRECATIONS[norm_key]
85             msg = f"The parameter `{norm_key}` is deprecated, {suggestion}"
86             warnings.warn(msg, SetuptoolsDeprecationWarning)
87
88         norm_key = TOOL_TABLE_RENAMES.get(norm_key, norm_key)
89         _set_config(dist, norm_key, value)
90
91     _copy_command_options(config, dist, filename)
92
93
94 def _handle_missing_dynamic(dist: "Distribution", project_table: dict):
95     """Be temporarily forgiving with ``dynamic`` fields not listed in ``dynamic``"""
96     # TODO: Set fields back to `None` once the feature stabilizes
97     dynamic = set(project_table.get("dynamic", []))
98     for field, getter in _PREVIOUSLY_DEFINED.items():
99         if not (field in project_table or field in dynamic):
100             value = getter(dist)
101             if value:
102                 msg = _WouldIgnoreField.message(field, value)
103                 warnings.warn(msg, _WouldIgnoreField)
104
105
106 def json_compatible_key(key: str) -> str:
107     """As defined in :pep:`566#json-compatible-metadata`"""
108     return key.lower().replace("-", "_")
109
110
111 def _set_config(dist: "Distribution", field: str, value: Any):
112     setter = getattr(dist.metadata, f"set_{field}", None)
113     if setter:
114         setter(value)
115     elif hasattr(dist.metadata, field) or field in SETUPTOOLS_PATCHES:
116         setattr(dist.metadata, field, value)
117     else:
118         setattr(dist, field, value)
119
120
121 _CONTENT_TYPES = {
122     ".md": "text/markdown",
123     ".rst": "text/x-rst",
124     ".txt": "text/plain",
125 }
126
127
128 def _guess_content_type(file: str) -> Optional[str]:
129     _, ext = os.path.splitext(file.lower())
130     if not ext:
131         return None
132
133     if ext in _CONTENT_TYPES:
134         return _CONTENT_TYPES[ext]
135
136     valid = ", ".join(f"{k} ({v})" for k, v in _CONTENT_TYPES.items())
137     msg = f"only the following file extensions are recognized: {valid}."
138     raise ValueError(f"Undefined content type for {file}, {msg}")
139
140
141 def _long_description(dist: "Distribution", val: _DictOrStr, root_dir: _Path):
142     from setuptools.config import expand
143
144     if isinstance(val, str):
145         file: Union[str, list] = val
146         text = expand.read_files(file, root_dir)
147         ctype = _guess_content_type(val)
148     else:
149         file = val.get("file") or []
150         text = val.get("text") or expand.read_files(file, root_dir)
151         ctype = val["content-type"]
152
153     _set_config(dist, "long_description", text)
154
155     if ctype:
156         _set_config(dist, "long_description_content_type", ctype)
157
158     if file:
159         dist._referenced_files.add(cast(str, file))
160
161
162 def _license(dist: "Distribution", val: dict, root_dir: _Path):
163     from setuptools.config import expand
164
165     if "file" in val:
166         _set_config(dist, "license", expand.read_files([val["file"]], root_dir))
167         dist._referenced_files.add(val["file"])
168     else:
169         _set_config(dist, "license", val["text"])
170
171
172 def _people(dist: "Distribution", val: List[dict], _root_dir: _Path, kind: str):
173     field = []
174     email_field = []
175     for person in val:
176         if "name" not in person:
177             email_field.append(person["email"])
178         elif "email" not in person:
179             field.append(person["name"])
180         else:
181             addr = Address(display_name=person["name"], addr_spec=person["email"])
182             email_field.append(str(addr))
183
184     if field:
185         _set_config(dist, kind, ", ".join(field))
186     if email_field:
187         _set_config(dist, f"{kind}_email", ", ".join(email_field))
188
189
190 def _project_urls(dist: "Distribution", val: dict, _root_dir):
191     _set_config(dist, "project_urls", val)
192
193
194 def _python_requires(dist: "Distribution", val: dict, _root_dir):
195     from setuptools.extern.packaging.specifiers import SpecifierSet
196
197     _set_config(dist, "python_requires", SpecifierSet(val))
198
199
200 def _dependencies(dist: "Distribution", val: list, _root_dir):
201     if getattr(dist, "install_requires", []):
202         msg = "`install_requires` overwritten in `pyproject.toml` (dependencies)"
203         warnings.warn(msg)
204     _set_config(dist, "install_requires", val)
205
206
207 def _optional_dependencies(dist: "Distribution", val: dict, _root_dir):
208     existing = getattr(dist, "extras_require", {})
209     _set_config(dist, "extras_require", {**existing, **val})
210
211
212 def _unify_entry_points(project_table: dict):
213     project = project_table
214     entry_points = project.pop("entry-points", project.pop("entry_points", {}))
215     renaming = {"scripts": "console_scripts", "gui_scripts": "gui_scripts"}
216     for key, value in list(project.items()):  # eager to allow modifications
217         norm_key = json_compatible_key(key)
218         if norm_key in renaming and value:
219             entry_points[renaming[norm_key]] = project.pop(key)
220
221     if entry_points:
222         project["entry-points"] = {
223             name: [f"{k} = {v}" for k, v in group.items()]
224             for name, group in entry_points.items()
225         }
226
227
228 def _copy_command_options(pyproject: dict, dist: "Distribution", filename: _Path):
229     tool_table = pyproject.get("tool", {})
230     cmdclass = tool_table.get("setuptools", {}).get("cmdclass", {})
231     valid_options = _valid_command_options(cmdclass)
232
233     cmd_opts = dist.command_options
234     for cmd, config in pyproject.get("tool", {}).get("distutils", {}).items():
235         cmd = json_compatible_key(cmd)
236         valid = valid_options.get(cmd, set())
237         cmd_opts.setdefault(cmd, {})
238         for key, value in config.items():
239             key = json_compatible_key(key)
240             cmd_opts[cmd][key] = (str(filename), value)
241             if key not in valid:
242                 # To avoid removing options that are specified dynamically we
243                 # just log a warn...
244                 _logger.warning(f"Command option {cmd}.{key} is not defined")
245
246
247 def _valid_command_options(cmdclass: Mapping = EMPTY) -> Dict[str, Set[str]]:
248     from .._importlib import metadata
249     from setuptools.dist import Distribution
250
251     valid_options = {"global": _normalise_cmd_options(Distribution.global_options)}
252
253     unloaded_entry_points = metadata.entry_points(group='distutils.commands')
254     loaded_entry_points = (_load_ep(ep) for ep in unloaded_entry_points)
255     entry_points = (ep for ep in loaded_entry_points if ep)
256     for cmd, cmd_class in chain(entry_points, cmdclass.items()):
257         opts = valid_options.get(cmd, set())
258         opts = opts | _normalise_cmd_options(getattr(cmd_class, "user_options", []))
259         valid_options[cmd] = opts
260
261     return valid_options
262
263
264 def _load_ep(ep: "metadata.EntryPoint") -> Optional[Tuple[str, Type]]:
265     # Ignore all the errors
266     try:
267         return (ep.name, ep.load())
268     except Exception as ex:
269         msg = f"{ex.__class__.__name__} while trying to load entry-point {ep.name}"
270         _logger.warning(f"{msg}: {ex}")
271         return None
272
273
274 def _normalise_cmd_option_key(name: str) -> str:
275     return json_compatible_key(name).strip("_=")
276
277
278 def _normalise_cmd_options(desc: List[Tuple[str, Optional[str], str]]) -> Set[str]:
279     return {_normalise_cmd_option_key(fancy_option[0]) for fancy_option in desc}
280
281
282 def _attrgetter(attr):
283     """
284     Similar to ``operator.attrgetter`` but returns None if ``attr`` is not found
285     >>> from types import SimpleNamespace
286     >>> obj = SimpleNamespace(a=42, b=SimpleNamespace(c=13))
287     >>> _attrgetter("a")(obj)
288     42
289     >>> _attrgetter("b.c")(obj)
290     13
291     >>> _attrgetter("d")(obj) is None
292     True
293     """
294     return partial(reduce, lambda acc, x: getattr(acc, x, None), attr.split("."))
295
296
297 def _some_attrgetter(*items):
298     """
299     Return the first "truth-y" attribute or None
300     >>> from types import SimpleNamespace
301     >>> obj = SimpleNamespace(a=42, b=SimpleNamespace(c=13))
302     >>> _some_attrgetter("d", "a", "b.c")(obj)
303     42
304     >>> _some_attrgetter("d", "e", "b.c", "a")(obj)
305     13
306     >>> _some_attrgetter("d", "e", "f")(obj) is None
307     True
308     """
309     def _acessor(obj):
310         values = (_attrgetter(i)(obj) for i in items)
311         return next((i for i in values if i is not None), None)
312     return _acessor
313
314
315 PYPROJECT_CORRESPONDENCE: Dict[str, _Correspondence] = {
316     "readme": _long_description,
317     "license": _license,
318     "authors": partial(_people, kind="author"),
319     "maintainers": partial(_people, kind="maintainer"),
320     "urls": _project_urls,
321     "dependencies": _dependencies,
322     "optional_dependencies": _optional_dependencies,
323     "requires_python": _python_requires,
324 }
325
326 TOOL_TABLE_RENAMES = {"script_files": "scripts"}
327 TOOL_TABLE_DEPRECATIONS = {
328     "namespace_packages": "consider using implicit namespaces instead (PEP 420)."
329 }
330
331 SETUPTOOLS_PATCHES = {"long_description_content_type", "project_urls",
332                       "provides_extras", "license_file", "license_files"}
333
334 _PREVIOUSLY_DEFINED = {
335     "name": _attrgetter("metadata.name"),
336     "version": _attrgetter("metadata.version"),
337     "description": _attrgetter("metadata.description"),
338     "readme": _attrgetter("metadata.long_description"),
339     "requires-python": _some_attrgetter("python_requires", "metadata.python_requires"),
340     "license": _attrgetter("metadata.license"),
341     "authors": _some_attrgetter("metadata.author", "metadata.author_email"),
342     "maintainers": _some_attrgetter("metadata.maintainer", "metadata.maintainer_email"),
343     "keywords": _attrgetter("metadata.keywords"),
344     "classifiers": _attrgetter("metadata.classifiers"),
345     "urls": _attrgetter("metadata.project_urls"),
346     "entry-points": _attrgetter("entry_points"),
347     "dependencies": _some_attrgetter("_orig_install_requires", "install_requires"),
348     "optional-dependencies": _some_attrgetter("_orig_extras_require", "extras_require"),
349 }
350
351
352 class _WouldIgnoreField(UserWarning):
353     """Inform users that ``pyproject.toml`` would overwrite previous metadata."""
354
355     MESSAGE = """\
356     {field!r} defined outside of `pyproject.toml` would be ignored.
357     !!\n\n
358     ##########################################################################
359     # configuration would be ignored/result in error due to `pyproject.toml` #
360     ##########################################################################
361
362     The following seems to be defined outside of `pyproject.toml`:
363
364     `{field} = {value!r}`
365
366     According to the spec (see the link below), however, setuptools CANNOT
367     consider this value unless {field!r} is listed as `dynamic`.
368
369     https://packaging.python.org/en/latest/specifications/declaring-project-metadata/
370
371     For the time being, `setuptools` will still consider the given value (as a
372     **transitional** measure), but please note that future releases of setuptools will
373     follow strictly the standard.
374
375     To prevent this warning, you can list {field!r} under `dynamic` or alternatively
376     remove the `[project]` table from your file and rely entirely on other means of
377     configuration.
378     \n\n!!
379     """
380
381     @classmethod
382     def message(cls, field, value):
383         from inspect import cleandoc
384         return cleandoc(cls.MESSAGE.format(field=field, value=value))