1 """Translation layer between pyproject config and setuptools distribution and
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.
8 **PRIVATE MODULE**: API reserved for setuptools internal usage only.
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,
21 from setuptools._deprecation_warning import SetuptoolsDeprecationWarning
24 from setuptools._importlib import metadata # noqa
25 from setuptools.dist import Distribution # noqa
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]
33 _logger = logging.getLogger(__name__)
36 def apply(dist: "Distribution", config: dict, filename: _Path) -> "Distribution":
37 """Apply configuration dict read with :func:`read_configuration`"""
40 return dist # short-circuit unrelated pyproject.toml file
42 root_dir = os.path.dirname(filename) or "."
44 _apply_project_table(dist, config, root_dir)
45 _apply_tool_table(dist, config, filename)
47 current_directory = os.getcwd()
50 dist._finalize_requires()
51 dist._finalize_license_files()
53 os.chdir(current_directory)
58 def _apply_project_table(dist: "Distribution", config: dict, root_dir: _Path):
59 project_table = config.get("project", {}).copy()
61 return # short-circuit
63 _handle_missing_dynamic(dist, project_table)
64 _unify_entry_points(project_table)
66 for field, value in project_table.items():
67 norm_key = json_compatible_key(field)
68 corresp = PYPROJECT_CORRESPONDENCE.get(norm_key, norm_key)
70 corresp(dist, value, root_dir)
72 _set_config(dist, corresp, value)
75 def _apply_tool_table(dist: "Distribution", config: dict, filename: _Path):
76 tool_table = config.get("tool", {}).get("setuptools", {})
78 return # short-circuit
80 for field, value in tool_table.items():
81 norm_key = json_compatible_key(field)
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)
88 norm_key = TOOL_TABLE_RENAMES.get(norm_key, norm_key)
89 _set_config(dist, norm_key, value)
91 _copy_command_options(config, dist, filename)
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):
102 msg = _WouldIgnoreField.message(field, value)
103 warnings.warn(msg, _WouldIgnoreField)
106 def json_compatible_key(key: str) -> str:
107 """As defined in :pep:`566#json-compatible-metadata`"""
108 return key.lower().replace("-", "_")
111 def _set_config(dist: "Distribution", field: str, value: Any):
112 setter = getattr(dist.metadata, f"set_{field}", None)
115 elif hasattr(dist.metadata, field) or field in SETUPTOOLS_PATCHES:
116 setattr(dist.metadata, field, value)
118 setattr(dist, field, value)
122 ".md": "text/markdown",
123 ".rst": "text/x-rst",
124 ".txt": "text/plain",
128 def _guess_content_type(file: str) -> Optional[str]:
129 _, ext = os.path.splitext(file.lower())
133 if ext in _CONTENT_TYPES:
134 return _CONTENT_TYPES[ext]
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}")
141 def _long_description(dist: "Distribution", val: _DictOrStr, root_dir: _Path):
142 from setuptools.config import expand
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)
149 file = val.get("file") or []
150 text = val.get("text") or expand.read_files(file, root_dir)
151 ctype = val["content-type"]
153 _set_config(dist, "long_description", text)
156 _set_config(dist, "long_description_content_type", ctype)
159 dist._referenced_files.add(cast(str, file))
162 def _license(dist: "Distribution", val: dict, root_dir: _Path):
163 from setuptools.config import expand
166 _set_config(dist, "license", expand.read_files([val["file"]], root_dir))
167 dist._referenced_files.add(val["file"])
169 _set_config(dist, "license", val["text"])
172 def _people(dist: "Distribution", val: List[dict], _root_dir: _Path, kind: str):
176 if "name" not in person:
177 email_field.append(person["email"])
178 elif "email" not in person:
179 field.append(person["name"])
181 addr = Address(display_name=person["name"], addr_spec=person["email"])
182 email_field.append(str(addr))
185 _set_config(dist, kind, ", ".join(field))
187 _set_config(dist, f"{kind}_email", ", ".join(email_field))
190 def _project_urls(dist: "Distribution", val: dict, _root_dir):
191 _set_config(dist, "project_urls", val)
194 def _python_requires(dist: "Distribution", val: dict, _root_dir):
195 from setuptools.extern.packaging.specifiers import SpecifierSet
197 _set_config(dist, "python_requires", SpecifierSet(val))
200 def _dependencies(dist: "Distribution", val: list, _root_dir):
201 if getattr(dist, "install_requires", []):
202 msg = "`install_requires` overwritten in `pyproject.toml` (dependencies)"
204 _set_config(dist, "install_requires", val)
207 def _optional_dependencies(dist: "Distribution", val: dict, _root_dir):
208 existing = getattr(dist, "extras_require", {})
209 _set_config(dist, "extras_require", {**existing, **val})
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)
222 project["entry-points"] = {
223 name: [f"{k} = {v}" for k, v in group.items()]
224 for name, group in entry_points.items()
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)
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)
242 # To avoid removing options that are specified dynamically we
244 _logger.warning(f"Command option {cmd}.{key} is not defined")
247 def _valid_command_options(cmdclass: Mapping = EMPTY) -> Dict[str, Set[str]]:
248 from .._importlib import metadata
249 from setuptools.dist import Distribution
251 valid_options = {"global": _normalise_cmd_options(Distribution.global_options)}
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
264 def _load_ep(ep: "metadata.EntryPoint") -> Optional[Tuple[str, Type]]:
265 # Ignore all the errors
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}")
274 def _normalise_cmd_option_key(name: str) -> str:
275 return json_compatible_key(name).strip("_=")
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}
282 def _attrgetter(attr):
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)
289 >>> _attrgetter("b.c")(obj)
291 >>> _attrgetter("d")(obj) is None
294 return partial(reduce, lambda acc, x: getattr(acc, x, None), attr.split("."))
297 def _some_attrgetter(*items):
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)
304 >>> _some_attrgetter("d", "e", "b.c", "a")(obj)
306 >>> _some_attrgetter("d", "e", "f")(obj) is None
310 values = (_attrgetter(i)(obj) for i in items)
311 return next((i for i in values if i is not None), None)
315 PYPROJECT_CORRESPONDENCE: Dict[str, _Correspondence] = {
316 "readme": _long_description,
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,
326 TOOL_TABLE_RENAMES = {"script_files": "scripts"}
327 TOOL_TABLE_DEPRECATIONS = {
328 "namespace_packages": "consider using implicit namespaces instead (PEP 420)."
331 SETUPTOOLS_PATCHES = {"long_description_content_type", "project_urls",
332 "provides_extras", "license_file", "license_files"}
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"),
352 class _WouldIgnoreField(UserWarning):
353 """Inform users that ``pyproject.toml`` would overwrite previous metadata."""
356 {field!r} defined outside of `pyproject.toml` would be ignored.
358 ##########################################################################
359 # configuration would be ignored/result in error due to `pyproject.toml` #
360 ##########################################################################
362 The following seems to be defined outside of `pyproject.toml`:
364 `{field} = {value!r}`
366 According to the spec (see the link below), however, setuptools CANNOT
367 consider this value unless {field!r} is listed as `dynamic`.
369 https://packaging.python.org/en/latest/specifications/declaring-project-metadata/
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.
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
382 def message(cls, field, value):
383 from inspect import cleandoc
384 return cleandoc(cls.MESSAGE.format(field=field, value=value))