您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符
 
 
 
 

469 行
18 KiB

  1. """
  2. Load setuptools configuration from ``pyproject.toml`` files.
  3. **PRIVATE MODULE**: API reserved for setuptools internal usage only.
  4. To read project metadata, consider using
  5. ``build.util.project_wheel_metadata`` (https://pypi.org/project/build/).
  6. For simple scenarios, you can also try parsing the file directly
  7. with the help of ``tomllib`` or ``tomli``.
  8. """
  9. from __future__ import annotations
  10. import logging
  11. import os
  12. from collections.abc import Mapping
  13. from contextlib import contextmanager
  14. from functools import partial
  15. from types import TracebackType
  16. from typing import TYPE_CHECKING, Any, Callable
  17. from .._path import StrPath
  18. from ..errors import FileError, InvalidConfigError
  19. from ..warnings import SetuptoolsWarning
  20. from . import expand as _expand
  21. from ._apply_pyprojecttoml import _PREVIOUSLY_DEFINED, _MissingDynamic, apply as _apply
  22. if TYPE_CHECKING:
  23. from typing_extensions import Self
  24. from setuptools.dist import Distribution
  25. _logger = logging.getLogger(__name__)
  26. def load_file(filepath: StrPath) -> dict:
  27. from ..compat.py310 import tomllib
  28. with open(filepath, "rb") as file:
  29. return tomllib.load(file)
  30. def validate(config: dict, filepath: StrPath) -> bool:
  31. from . import _validate_pyproject as validator
  32. trove_classifier = validator.FORMAT_FUNCTIONS.get("trove-classifier")
  33. if hasattr(trove_classifier, "_disable_download"):
  34. # Improve reproducibility by default. See abravalheri/validate-pyproject#31
  35. trove_classifier._disable_download() # type: ignore[union-attr]
  36. try:
  37. return validator.validate(config)
  38. except validator.ValidationError as ex:
  39. summary = f"configuration error: {ex.summary}"
  40. if ex.name.strip("`") != "project":
  41. # Probably it is just a field missing/misnamed, not worthy the verbosity...
  42. _logger.debug(summary)
  43. _logger.debug(ex.details)
  44. error = f"invalid pyproject.toml config: {ex.name}."
  45. raise ValueError(f"{error}\n{summary}") from None
  46. def apply_configuration(
  47. dist: Distribution,
  48. filepath: StrPath,
  49. ignore_option_errors: bool = False,
  50. ) -> Distribution:
  51. """Apply the configuration from a ``pyproject.toml`` file into an existing
  52. distribution object.
  53. """
  54. config = read_configuration(filepath, True, ignore_option_errors, dist)
  55. return _apply(dist, config, filepath)
  56. def read_configuration(
  57. filepath: StrPath,
  58. expand: bool = True,
  59. ignore_option_errors: bool = False,
  60. dist: Distribution | None = None,
  61. ) -> dict[str, Any]:
  62. """Read given configuration file and returns options from it as a dict.
  63. :param str|unicode filepath: Path to configuration file in the ``pyproject.toml``
  64. format.
  65. :param bool expand: Whether to expand directives and other computed values
  66. (i.e. post-process the given configuration)
  67. :param bool ignore_option_errors: Whether to silently ignore
  68. options, values of which could not be resolved (e.g. due to exceptions
  69. in directives such as file:, attr:, etc.).
  70. If False exceptions are propagated as expected.
  71. :param Distribution|None: Distribution object to which the configuration refers.
  72. If not given a dummy object will be created and discarded after the
  73. configuration is read. This is used for auto-discovery of packages and in the
  74. case a dynamic configuration (e.g. ``attr`` or ``cmdclass``) is expanded.
  75. When ``expand=False`` this object is simply ignored.
  76. :rtype: dict
  77. """
  78. filepath = os.path.abspath(filepath)
  79. if not os.path.isfile(filepath):
  80. raise FileError(f"Configuration file {filepath!r} does not exist.")
  81. asdict = load_file(filepath) or {}
  82. project_table = asdict.get("project", {})
  83. tool_table = asdict.get("tool", {})
  84. setuptools_table = tool_table.get("setuptools", {})
  85. if not asdict or not (project_table or setuptools_table):
  86. return {} # User is not using pyproject to configure setuptools
  87. if "setuptools" in asdict.get("tools", {}):
  88. # let the user know they probably have a typo in their metadata
  89. _ToolsTypoInMetadata.emit()
  90. if "distutils" in tool_table:
  91. _ExperimentalConfiguration.emit(subject="[tool.distutils]")
  92. # There is an overall sense in the community that making include_package_data=True
  93. # the default would be an improvement.
  94. # `ini2toml` backfills include_package_data=False when nothing is explicitly given,
  95. # therefore setting a default here is backwards compatible.
  96. if dist and dist.include_package_data is not None:
  97. setuptools_table.setdefault("include-package-data", dist.include_package_data)
  98. else:
  99. setuptools_table.setdefault("include-package-data", True)
  100. # Persist changes:
  101. asdict["tool"] = tool_table
  102. tool_table["setuptools"] = setuptools_table
  103. if "ext-modules" in setuptools_table:
  104. _ExperimentalConfiguration.emit(subject="[tool.setuptools.ext-modules]")
  105. with _ignore_errors(ignore_option_errors):
  106. # Don't complain about unrelated errors (e.g. tools not using the "tool" table)
  107. subset = {"project": project_table, "tool": {"setuptools": setuptools_table}}
  108. validate(subset, filepath)
  109. if expand:
  110. root_dir = os.path.dirname(filepath)
  111. return expand_configuration(asdict, root_dir, ignore_option_errors, dist)
  112. return asdict
  113. def expand_configuration(
  114. config: dict,
  115. root_dir: StrPath | None = None,
  116. ignore_option_errors: bool = False,
  117. dist: Distribution | None = None,
  118. ) -> dict:
  119. """Given a configuration with unresolved fields (e.g. dynamic, cmdclass, ...)
  120. find their final values.
  121. :param dict config: Dict containing the configuration for the distribution
  122. :param str root_dir: Top-level directory for the distribution/project
  123. (the same directory where ``pyproject.toml`` is place)
  124. :param bool ignore_option_errors: see :func:`read_configuration`
  125. :param Distribution|None: Distribution object to which the configuration refers.
  126. If not given a dummy object will be created and discarded after the
  127. configuration is read. Used in the case a dynamic configuration
  128. (e.g. ``attr`` or ``cmdclass``).
  129. :rtype: dict
  130. """
  131. return _ConfigExpander(config, root_dir, ignore_option_errors, dist).expand()
  132. class _ConfigExpander:
  133. def __init__(
  134. self,
  135. config: dict,
  136. root_dir: StrPath | None = None,
  137. ignore_option_errors: bool = False,
  138. dist: Distribution | None = None,
  139. ) -> None:
  140. self.config = config
  141. self.root_dir = root_dir or os.getcwd()
  142. self.project_cfg = config.get("project", {})
  143. self.dynamic = self.project_cfg.get("dynamic", [])
  144. self.setuptools_cfg = config.get("tool", {}).get("setuptools", {})
  145. self.dynamic_cfg = self.setuptools_cfg.get("dynamic", {})
  146. self.ignore_option_errors = ignore_option_errors
  147. self._dist = dist
  148. self._referenced_files = set[str]()
  149. def _ensure_dist(self) -> Distribution:
  150. from setuptools.dist import Distribution
  151. attrs = {"src_root": self.root_dir, "name": self.project_cfg.get("name", None)}
  152. return self._dist or Distribution(attrs)
  153. def _process_field(self, container: dict, field: str, fn: Callable):
  154. if field in container:
  155. with _ignore_errors(self.ignore_option_errors):
  156. container[field] = fn(container[field])
  157. def _canonic_package_data(self, field="package-data"):
  158. package_data = self.setuptools_cfg.get(field, {})
  159. return _expand.canonic_package_data(package_data)
  160. def expand(self):
  161. self._expand_packages()
  162. self._canonic_package_data()
  163. self._canonic_package_data("exclude-package-data")
  164. # A distribution object is required for discovering the correct package_dir
  165. dist = self._ensure_dist()
  166. ctx = _EnsurePackagesDiscovered(dist, self.project_cfg, self.setuptools_cfg)
  167. with ctx as ensure_discovered:
  168. package_dir = ensure_discovered.package_dir
  169. self._expand_data_files()
  170. self._expand_cmdclass(package_dir)
  171. self._expand_all_dynamic(dist, package_dir)
  172. dist._referenced_files.update(self._referenced_files)
  173. return self.config
  174. def _expand_packages(self):
  175. packages = self.setuptools_cfg.get("packages")
  176. if packages is None or isinstance(packages, (list, tuple)):
  177. return
  178. find = packages.get("find")
  179. if isinstance(find, dict):
  180. find["root_dir"] = self.root_dir
  181. find["fill_package_dir"] = self.setuptools_cfg.setdefault("package-dir", {})
  182. with _ignore_errors(self.ignore_option_errors):
  183. self.setuptools_cfg["packages"] = _expand.find_packages(**find)
  184. def _expand_data_files(self):
  185. data_files = partial(_expand.canonic_data_files, root_dir=self.root_dir)
  186. self._process_field(self.setuptools_cfg, "data-files", data_files)
  187. def _expand_cmdclass(self, package_dir: Mapping[str, str]):
  188. root_dir = self.root_dir
  189. cmdclass = partial(_expand.cmdclass, package_dir=package_dir, root_dir=root_dir)
  190. self._process_field(self.setuptools_cfg, "cmdclass", cmdclass)
  191. def _expand_all_dynamic(self, dist: Distribution, package_dir: Mapping[str, str]):
  192. special = ( # need special handling
  193. "version",
  194. "readme",
  195. "entry-points",
  196. "scripts",
  197. "gui-scripts",
  198. "classifiers",
  199. "dependencies",
  200. "optional-dependencies",
  201. )
  202. # `_obtain` functions are assumed to raise appropriate exceptions/warnings.
  203. obtained_dynamic = {
  204. field: self._obtain(dist, field, package_dir)
  205. for field in self.dynamic
  206. if field not in special
  207. }
  208. obtained_dynamic.update(
  209. self._obtain_entry_points(dist, package_dir) or {},
  210. version=self._obtain_version(dist, package_dir),
  211. readme=self._obtain_readme(dist),
  212. classifiers=self._obtain_classifiers(dist),
  213. dependencies=self._obtain_dependencies(dist),
  214. optional_dependencies=self._obtain_optional_dependencies(dist),
  215. )
  216. # `None` indicates there is nothing in `tool.setuptools.dynamic` but the value
  217. # might have already been set by setup.py/extensions, so avoid overwriting.
  218. updates = {k: v for k, v in obtained_dynamic.items() if v is not None}
  219. self.project_cfg.update(updates)
  220. def _ensure_previously_set(self, dist: Distribution, field: str):
  221. previous = _PREVIOUSLY_DEFINED[field](dist)
  222. if previous is None and not self.ignore_option_errors:
  223. msg = (
  224. f"No configuration found for dynamic {field!r}.\n"
  225. "Some dynamic fields need to be specified via `tool.setuptools.dynamic`"
  226. "\nothers must be specified via the equivalent attribute in `setup.py`."
  227. )
  228. raise InvalidConfigError(msg)
  229. def _expand_directive(
  230. self, specifier: str, directive, package_dir: Mapping[str, str]
  231. ):
  232. from more_itertools import always_iterable
  233. with _ignore_errors(self.ignore_option_errors):
  234. root_dir = self.root_dir
  235. if "file" in directive:
  236. self._referenced_files.update(always_iterable(directive["file"]))
  237. return _expand.read_files(directive["file"], root_dir)
  238. if "attr" in directive:
  239. return _expand.read_attr(directive["attr"], package_dir, root_dir)
  240. raise ValueError(f"invalid `{specifier}`: {directive!r}")
  241. return None
  242. def _obtain(self, dist: Distribution, field: str, package_dir: Mapping[str, str]):
  243. if field in self.dynamic_cfg:
  244. return self._expand_directive(
  245. f"tool.setuptools.dynamic.{field}",
  246. self.dynamic_cfg[field],
  247. package_dir,
  248. )
  249. self._ensure_previously_set(dist, field)
  250. return None
  251. def _obtain_version(self, dist: Distribution, package_dir: Mapping[str, str]):
  252. # Since plugins can set version, let's silently skip if it cannot be obtained
  253. if "version" in self.dynamic and "version" in self.dynamic_cfg:
  254. return _expand.version(
  255. # We already do an early check for the presence of "version"
  256. self._obtain(dist, "version", package_dir) # pyright: ignore[reportArgumentType]
  257. )
  258. return None
  259. def _obtain_readme(self, dist: Distribution) -> dict[str, str] | None:
  260. if "readme" not in self.dynamic:
  261. return None
  262. dynamic_cfg = self.dynamic_cfg
  263. if "readme" in dynamic_cfg:
  264. return {
  265. # We already do an early check for the presence of "readme"
  266. "text": self._obtain(dist, "readme", {}),
  267. "content-type": dynamic_cfg["readme"].get("content-type", "text/x-rst"),
  268. } # pyright: ignore[reportReturnType]
  269. self._ensure_previously_set(dist, "readme")
  270. return None
  271. def _obtain_entry_points(
  272. self, dist: Distribution, package_dir: Mapping[str, str]
  273. ) -> dict[str, dict[str, Any]] | None:
  274. fields = ("entry-points", "scripts", "gui-scripts")
  275. if not any(field in self.dynamic for field in fields):
  276. return None
  277. text = self._obtain(dist, "entry-points", package_dir)
  278. if text is None:
  279. return None
  280. groups = _expand.entry_points(text)
  281. # Any is str | dict[str, str], but causes variance issues
  282. expanded: dict[str, dict[str, Any]] = {"entry-points": groups}
  283. def _set_scripts(field: str, group: str):
  284. if group in groups:
  285. value = groups.pop(group)
  286. if field not in self.dynamic:
  287. raise InvalidConfigError(_MissingDynamic.details(field, value))
  288. expanded[field] = value
  289. _set_scripts("scripts", "console_scripts")
  290. _set_scripts("gui-scripts", "gui_scripts")
  291. return expanded
  292. def _obtain_classifiers(self, dist: Distribution):
  293. if "classifiers" in self.dynamic:
  294. value = self._obtain(dist, "classifiers", {})
  295. if value:
  296. return value.splitlines()
  297. return None
  298. def _obtain_dependencies(self, dist: Distribution):
  299. if "dependencies" in self.dynamic:
  300. value = self._obtain(dist, "dependencies", {})
  301. if value:
  302. return _parse_requirements_list(value)
  303. return None
  304. def _obtain_optional_dependencies(self, dist: Distribution):
  305. if "optional-dependencies" not in self.dynamic:
  306. return None
  307. if "optional-dependencies" in self.dynamic_cfg:
  308. optional_dependencies_map = self.dynamic_cfg["optional-dependencies"]
  309. assert isinstance(optional_dependencies_map, dict)
  310. return {
  311. group: _parse_requirements_list(
  312. self._expand_directive(
  313. f"tool.setuptools.dynamic.optional-dependencies.{group}",
  314. directive,
  315. {},
  316. )
  317. )
  318. for group, directive in optional_dependencies_map.items()
  319. }
  320. self._ensure_previously_set(dist, "optional-dependencies")
  321. return None
  322. def _parse_requirements_list(value):
  323. return [
  324. line
  325. for line in value.splitlines()
  326. if line.strip() and not line.strip().startswith("#")
  327. ]
  328. @contextmanager
  329. def _ignore_errors(ignore_option_errors: bool):
  330. if not ignore_option_errors:
  331. yield
  332. return
  333. try:
  334. yield
  335. except Exception as ex:
  336. _logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}")
  337. class _EnsurePackagesDiscovered(_expand.EnsurePackagesDiscovered):
  338. def __init__(
  339. self, distribution: Distribution, project_cfg: dict, setuptools_cfg: dict
  340. ) -> None:
  341. super().__init__(distribution)
  342. self._project_cfg = project_cfg
  343. self._setuptools_cfg = setuptools_cfg
  344. def __enter__(self) -> Self:
  345. """When entering the context, the values of ``packages``, ``py_modules`` and
  346. ``package_dir`` that are missing in ``dist`` are copied from ``setuptools_cfg``.
  347. """
  348. dist, cfg = self._dist, self._setuptools_cfg
  349. package_dir: dict[str, str] = cfg.setdefault("package-dir", {})
  350. package_dir.update(dist.package_dir or {})
  351. dist.package_dir = package_dir # needs to be the same object
  352. dist.set_defaults._ignore_ext_modules() # pyproject.toml-specific behaviour
  353. # Set `name`, `py_modules` and `packages` in dist to short-circuit
  354. # auto-discovery, but avoid overwriting empty lists purposefully set by users.
  355. if dist.metadata.name is None:
  356. dist.metadata.name = self._project_cfg.get("name")
  357. if dist.py_modules is None:
  358. dist.py_modules = cfg.get("py-modules")
  359. if dist.packages is None:
  360. dist.packages = cfg.get("packages")
  361. return super().__enter__()
  362. def __exit__(
  363. self,
  364. exc_type: type[BaseException] | None,
  365. exc_value: BaseException | None,
  366. traceback: TracebackType | None,
  367. ) -> None:
  368. """When exiting the context, if values of ``packages``, ``py_modules`` and
  369. ``package_dir`` are missing in ``setuptools_cfg``, copy from ``dist``.
  370. """
  371. # If anything was discovered set them back, so they count in the final config.
  372. self._setuptools_cfg.setdefault("packages", self._dist.packages)
  373. self._setuptools_cfg.setdefault("py-modules", self._dist.py_modules)
  374. return super().__exit__(exc_type, exc_value, traceback)
  375. class _ExperimentalConfiguration(SetuptoolsWarning):
  376. _SUMMARY = (
  377. "`{subject}` in `pyproject.toml` is still *experimental* "
  378. "and likely to change in future releases."
  379. )
  380. class _ToolsTypoInMetadata(SetuptoolsWarning):
  381. _SUMMARY = (
  382. "Ignoring [tools.setuptools] in pyproject.toml, did you mean [tool.setuptools]?"
  383. )