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.
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
31 from ..warnings
import SetuptoolsWarning
, SetuptoolsDeprecationWarning
34 from setuptools
._importlib
import metadata
# noqa
35 from setuptools
.dist
import Distribution
# noqa
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
]
43 _logger
= logging
.getLogger(__name__
)
46 def apply(dist
: "Distribution", config
: dict, filename
: _Path
) -> "Distribution":
47 """Apply configuration dict read with :func:`read_configuration`"""
50 return dist
# short-circuit unrelated pyproject.toml file
52 root_dir
= os
.path
.dirname(filename
) or "."
54 _apply_project_table(dist
, config
, root_dir
)
55 _apply_tool_table(dist
, config
, filename
)
57 current_directory
= os
.getcwd()
60 dist
._finalize
_requires
()
61 dist
._finalize
_license
_files
()
63 os
.chdir(current_directory
)
68 def _apply_project_table(dist
: "Distribution", config
: dict, root_dir
: _Path
):
69 project_table
= config
.get("project", {}).copy()
71 return # short-circuit
73 _handle_missing_dynamic(dist
, project_table
)
74 _unify_entry_points(project_table
)
76 for field
, value
in project_table
.items():
77 norm_key
= json_compatible_key(field
)
78 corresp
= PYPROJECT_CORRESPONDENCE
.get(norm_key
, norm_key
)
80 corresp(dist
, value
, root_dir
)
82 _set_config(dist
, corresp
, value
)
85 def _apply_tool_table(dist
: "Distribution", config
: dict, filename
: _Path
):
86 tool_table
= config
.get("tool", {}).get("setuptools", {}
)
88 return # short-circuit
90 for field
, value
in tool_table
.items():
91 norm_key
= json_compatible_key(field
)
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
100 norm_key
= TOOL_TABLE_RENAMES
.get(norm_key
, norm_key
)
101 _set_config(dist
, norm_key
, value
)
103 _copy_command_options(config
, dist
, filename
)
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
):
114 _WouldIgnoreField
.emit(field
=field
, value
=value
)
117 def json_compatible_key(key
: str) -> str:
118 """As defined in :pep:`566#json-compatible-metadata`"""
119 return key
.lower().replace("-", "_")
122 def _set_config(dist
: "Distribution", field
: str, value
: Any
):
123 setter
= getattr(dist
.metadata
, f
"set_{field}", None)
126 elif hasattr(dist
.metadata
, field
) or field
in SETUPTOOLS_PATCHES
:
127 setattr(dist
.metadata
, field
, value
)
129 setattr(dist
, field
, value
)
133 ".md": "text/markdown",
134 ".rst": "text/x-rst",
135 ".txt": "text/plain",
139 def _guess_content_type(file: str) -> Optional
[str]:
140 _
, ext
= os
.path
.splitext(file.lower())
144 if ext
in _CONTENT_TYPES
:
145 return _CONTENT_TYPES
[ext
]
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}")
152 def _long_description(dist
: "Distribution", val
: _DictOrStr
, root_dir
: _Path
):
153 from setuptools
.config
import expand
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
)
160 file = val
.get("file") or []
161 text
= val
.get("text") or expand
.read_files(file, root_dir
)
162 ctype
= val
["content-type"]
164 _set_config(dist
, "long_description", text
)
167 _set_config(dist
, "long_description_content_type", ctype
)
170 dist
._referenced
_files
.add(cast(str, file))
173 def _license(dist
: "Distribution", val
: dict, root_dir
: _Path
):
174 from setuptools
.config
import expand
177 _set_config(dist
, "license", expand
.read_files([val
["file"]], root_dir
))
178 dist
._referenced
_files
.add(val
["file"])
180 _set_config(dist
, "license", val
["text"])
183 def _people(dist
: "Distribution", val
: List
[dict], _root_dir
: _Path
, kind
: str):
187 if "name" not in person
:
188 email_field
.append(person
["email"])
189 elif "email" not in person
:
190 field
.append(person
["name"])
192 addr
= Address(display_name
=person
["name"], addr_spec
=person
["email"])
193 email_field
.append(str(addr
))
196 _set_config(dist
, kind
, ", ".join(field
))
198 _set_config(dist
, f
"{kind}_email", ", ".join(email_field
))
201 def _project_urls(dist
: "Distribution", val
: dict, _root_dir
):
202 _set_config(dist
, "project_urls", val
)
205 def _python_requires(dist
: "Distribution", val
: dict, _root_dir
):
206 from setuptools
.extern
.packaging
.specifiers
import SpecifierSet
208 _set_config(dist
, "python_requires", SpecifierSet(val
))
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
)
218 def _optional_dependencies(dist
: "Distribution", val
: dict, _root_dir
):
219 existing
= getattr(dist
, "extras_require", {})
220 _set_config(dist
, "extras_require", {**existing, **val}
)
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
)
233 project
["entry-points"] = {
234 name
: [f
"{k} = {v}" for k
, v
in group
.items()]
235 for name
, group
in entry_points
.items()
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
)
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
)
253 # To avoid removing options that are specified dynamically we
255 _logger
.warning(f
"Command option {cmd}.{key} is not defined")
258 def _valid_command_options(cmdclass
: Mapping
= EMPTY
) -> Dict
[str, Set
[str]]:
259 from .._importlib
import metadata
260 from setuptools
.dist
import Distribution
262 valid_options
= {"global": _normalise_cmd_options(Distribution.global_options)}
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
275 def _load_ep(ep
: "metadata.EntryPoint") -> Optional
[Tuple
[str, Type
]]:
276 # Ignore all the errors
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}")
285 def _normalise_cmd_option_key(name
: str) -> str:
286 return json_compatible_key(name
).strip("_=")
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}
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}
299 def _get_previous_scripts(dist
: "Distribution") -> Optional
[list]:
300 value
= getattr(dist
, "entry_points", None) or {}
301 return value
.get("console_scripts")
304 def _get_previous_gui_scripts(dist
: "Distribution") -> Optional
[list]:
305 value
= getattr(dist
, "entry_points", None) or {}
306 return value
.get("gui_scripts")
309 def _attrgetter(attr
):
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)
316 >>> _attrgetter("b.c")(obj)
318 >>> _attrgetter("d")(obj) is None
321 return partial(reduce, lambda acc
, x
: getattr(acc
, x
, None), attr
.split("."))
324 def _some_attrgetter(*items
):
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)
331 >>> _some_attrgetter("d", "e", "b.c", "a")(obj)
333 >>> _some_attrgetter("d", "e", "f")(obj) is None
338 values
= (_attrgetter(i
)(obj
) for i
in items
)
339 return next((i
for i
in values
if i
is not None), None)
344 PYPROJECT_CORRESPONDENCE
: Dict
[str, _Correspondence
] = {
345 "readme": _long_description
,
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
,
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
363 SETUPTOOLS_PATCHES
= {
364 "long_description_content_type",
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"),
391 class _WouldIgnoreField(SetuptoolsDeprecationWarning
):
392 _SUMMARY
= "`{field}` defined outside of `pyproject.toml` would be ignored."
395 ##########################################################################
396 # configuration would be ignored/result in error due to `pyproject.toml` #
397 ##########################################################################
399 The following seems to be defined outside of `pyproject.toml`:
401 `{field} = {value!r}`
403 According to the spec (see the link below), however, setuptools CANNOT
404 consider this value unless `{field}` is listed as `dynamic`.
406 https://packaging.python.org/en/latest/specifications/declaring-project-metadata/
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.
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
416 _DUE_DATE
= (2023, 10, 30) # Initially introduced in 27 May 2022