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