pyprojecttoml.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. """Load setuptools configuration from ``pyproject.toml`` files"""
  2. import logging
  3. import os
  4. import warnings
  5. from contextlib import contextmanager
  6. from functools import partial
  7. from typing import TYPE_CHECKING, Callable, Dict, Optional, Mapping, Union
  8. from setuptools.errors import FileError, OptionError
  9. from . import expand as _expand
  10. from ._apply_pyprojecttoml import apply as _apply
  11. from ._apply_pyprojecttoml import _PREVIOUSLY_DEFINED, _WouldIgnoreField
  12. if TYPE_CHECKING:
  13. from setuptools.dist import Distribution # noqa
  14. _Path = Union[str, os.PathLike]
  15. _logger = logging.getLogger(__name__)
  16. def load_file(filepath: _Path) -> dict:
  17. from setuptools.extern import tomli # type: ignore
  18. with open(filepath, "rb") as file:
  19. return tomli.load(file)
  20. def validate(config: dict, filepath: _Path) -> bool:
  21. from . import _validate_pyproject as validator
  22. trove_classifier = validator.FORMAT_FUNCTIONS.get("trove-classifier")
  23. if hasattr(trove_classifier, "_disable_download"):
  24. # Improve reproducibility by default. See issue 31 for validate-pyproject.
  25. trove_classifier._disable_download() # type: ignore
  26. try:
  27. return validator.validate(config)
  28. except validator.ValidationError as ex:
  29. _logger.error(f"configuration error: {ex.summary}") # type: ignore
  30. _logger.debug(ex.details) # type: ignore
  31. error = ValueError(f"invalid pyproject.toml config: {ex.name}") # type: ignore
  32. raise error from None
  33. def apply_configuration(
  34. dist: "Distribution",
  35. filepath: _Path,
  36. ignore_option_errors=False,
  37. ) -> "Distribution":
  38. """Apply the configuration from a ``pyproject.toml`` file into an existing
  39. distribution object.
  40. """
  41. config = read_configuration(filepath, True, ignore_option_errors, dist)
  42. return _apply(dist, config, filepath)
  43. def read_configuration(
  44. filepath: _Path,
  45. expand=True,
  46. ignore_option_errors=False,
  47. dist: Optional["Distribution"] = None,
  48. ):
  49. """Read given configuration file and returns options from it as a dict.
  50. :param str|unicode filepath: Path to configuration file in the ``pyproject.toml``
  51. format.
  52. :param bool expand: Whether to expand directives and other computed values
  53. (i.e. post-process the given configuration)
  54. :param bool ignore_option_errors: Whether to silently ignore
  55. options, values of which could not be resolved (e.g. due to exceptions
  56. in directives such as file:, attr:, etc.).
  57. If False exceptions are propagated as expected.
  58. :param Distribution|None: Distribution object to which the configuration refers.
  59. If not given a dummy object will be created and discarded after the
  60. configuration is read. This is used for auto-discovery of packages in the case
  61. a dynamic configuration (e.g. ``attr`` or ``cmdclass``) is expanded.
  62. When ``expand=False`` this object is simply ignored.
  63. :rtype: dict
  64. """
  65. filepath = os.path.abspath(filepath)
  66. if not os.path.isfile(filepath):
  67. raise FileError(f"Configuration file {filepath!r} does not exist.")
  68. asdict = load_file(filepath) or {}
  69. project_table = asdict.get("project", {})
  70. tool_table = asdict.get("tool", {})
  71. setuptools_table = tool_table.get("setuptools", {})
  72. if not asdict or not (project_table or setuptools_table):
  73. return {} # User is not using pyproject to configure setuptools
  74. # TODO: Remove the following once the feature stabilizes:
  75. msg = (
  76. "Support for project metadata in `pyproject.toml` is still experimental "
  77. "and may be removed (or change) in future releases."
  78. )
  79. warnings.warn(msg, _ExperimentalProjectMetadata)
  80. # There is an overall sense in the community that making include_package_data=True
  81. # the default would be an improvement.
  82. # `ini2toml` backfills include_package_data=False when nothing is explicitly given,
  83. # therefore setting a default here is backwards compatible.
  84. orig_setuptools_table = setuptools_table.copy()
  85. if dist and getattr(dist, "include_package_data") is not None:
  86. setuptools_table.setdefault("include-package-data", dist.include_package_data)
  87. else:
  88. setuptools_table.setdefault("include-package-data", True)
  89. # Persist changes:
  90. asdict["tool"] = tool_table
  91. tool_table["setuptools"] = setuptools_table
  92. try:
  93. # Don't complain about unrelated errors (e.g. tools not using the "tool" table)
  94. subset = {"project": project_table, "tool": {"setuptools": setuptools_table}}
  95. validate(subset, filepath)
  96. except Exception as ex:
  97. # TODO: Remove the following once the feature stabilizes:
  98. if _skip_bad_config(project_table, orig_setuptools_table, dist):
  99. return {}
  100. # TODO: After the previous statement is removed the try/except can be replaced
  101. # by the _ignore_errors context manager.
  102. if ignore_option_errors:
  103. _logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}")
  104. else:
  105. raise # re-raise exception
  106. if expand:
  107. root_dir = os.path.dirname(filepath)
  108. return expand_configuration(asdict, root_dir, ignore_option_errors, dist)
  109. return asdict
  110. def _skip_bad_config(
  111. project_cfg: dict, setuptools_cfg: dict, dist: Optional["Distribution"]
  112. ) -> bool:
  113. """Be temporarily forgiving with invalid ``pyproject.toml``"""
  114. # See pypa/setuptools#3199 and pypa/cibuildwheel#1064
  115. if dist is None or (
  116. dist.metadata.name is None
  117. and dist.metadata.version is None
  118. and dist.install_requires is None
  119. ):
  120. # It seems that the build is not getting any configuration from other places
  121. return False
  122. if setuptools_cfg:
  123. # If `[tool.setuptools]` is set, then `pyproject.toml` config is intentional
  124. return False
  125. given_config = set(project_cfg.keys())
  126. popular_subset = {"name", "version", "python_requires", "requires-python"}
  127. if given_config <= popular_subset:
  128. # It seems that the docs in cibuildtool has been inadvertently encouraging users
  129. # to create `pyproject.toml` files that are not compliant with the standards.
  130. # Let's be forgiving for the time being.
  131. warnings.warn(_InvalidFile.message(), _InvalidFile, stacklevel=2)
  132. return True
  133. return False
  134. def expand_configuration(
  135. config: dict,
  136. root_dir: Optional[_Path] = None,
  137. ignore_option_errors: bool = False,
  138. dist: Optional["Distribution"] = None,
  139. ) -> dict:
  140. """Given a configuration with unresolved fields (e.g. dynamic, cmdclass, ...)
  141. find their final values.
  142. :param dict config: Dict containing the configuration for the distribution
  143. :param str root_dir: Top-level directory for the distribution/project
  144. (the same directory where ``pyproject.toml`` is place)
  145. :param bool ignore_option_errors: see :func:`read_configuration`
  146. :param Distribution|None: Distribution object to which the configuration refers.
  147. If not given a dummy object will be created and discarded after the
  148. configuration is read. Used in the case a dynamic configuration
  149. (e.g. ``attr`` or ``cmdclass``).
  150. :rtype: dict
  151. """
  152. return _ConfigExpander(config, root_dir, ignore_option_errors, dist).expand()
  153. class _ConfigExpander:
  154. def __init__(
  155. self,
  156. config: dict,
  157. root_dir: Optional[_Path] = None,
  158. ignore_option_errors: bool = False,
  159. dist: Optional["Distribution"] = None,
  160. ):
  161. self.config = config
  162. self.root_dir = root_dir or os.getcwd()
  163. self.project_cfg = config.get("project", {})
  164. self.dynamic = self.project_cfg.get("dynamic", [])
  165. self.setuptools_cfg = config.get("tool", {}).get("setuptools", {})
  166. self.dynamic_cfg = self.setuptools_cfg.get("dynamic", {})
  167. self.ignore_option_errors = ignore_option_errors
  168. self._dist = dist
  169. def _ensure_dist(self) -> "Distribution":
  170. from setuptools.dist import Distribution
  171. attrs = {"src_root": self.root_dir, "name": self.project_cfg.get("name", None)}
  172. return self._dist or Distribution(attrs)
  173. def _process_field(self, container: dict, field: str, fn: Callable):
  174. if field in container:
  175. with _ignore_errors(self.ignore_option_errors):
  176. container[field] = fn(container[field])
  177. def _canonic_package_data(self, field="package-data"):
  178. package_data = self.setuptools_cfg.get(field, {})
  179. return _expand.canonic_package_data(package_data)
  180. def expand(self):
  181. self._expand_packages()
  182. self._canonic_package_data()
  183. self._canonic_package_data("exclude-package-data")
  184. # A distribution object is required for discovering the correct package_dir
  185. dist = self._ensure_dist()
  186. with _EnsurePackagesDiscovered(dist, self.setuptools_cfg) as ensure_discovered:
  187. package_dir = ensure_discovered.package_dir
  188. self._expand_data_files()
  189. self._expand_cmdclass(package_dir)
  190. self._expand_all_dynamic(dist, package_dir)
  191. return self.config
  192. def _expand_packages(self):
  193. packages = self.setuptools_cfg.get("packages")
  194. if packages is None or isinstance(packages, (list, tuple)):
  195. return
  196. find = packages.get("find")
  197. if isinstance(find, dict):
  198. find["root_dir"] = self.root_dir
  199. find["fill_package_dir"] = self.setuptools_cfg.setdefault("package-dir", {})
  200. with _ignore_errors(self.ignore_option_errors):
  201. self.setuptools_cfg["packages"] = _expand.find_packages(**find)
  202. def _expand_data_files(self):
  203. data_files = partial(_expand.canonic_data_files, root_dir=self.root_dir)
  204. self._process_field(self.setuptools_cfg, "data-files", data_files)
  205. def _expand_cmdclass(self, package_dir: Mapping[str, str]):
  206. root_dir = self.root_dir
  207. cmdclass = partial(_expand.cmdclass, package_dir=package_dir, root_dir=root_dir)
  208. self._process_field(self.setuptools_cfg, "cmdclass", cmdclass)
  209. def _expand_all_dynamic(self, dist: "Distribution", package_dir: Mapping[str, str]):
  210. special = ( # need special handling
  211. "version",
  212. "readme",
  213. "entry-points",
  214. "scripts",
  215. "gui-scripts",
  216. "classifiers",
  217. )
  218. # `_obtain` functions are assumed to raise appropriate exceptions/warnings.
  219. obtained_dynamic = {
  220. field: self._obtain(dist, field, package_dir)
  221. for field in self.dynamic
  222. if field not in special
  223. }
  224. obtained_dynamic.update(
  225. self._obtain_entry_points(dist, package_dir) or {},
  226. version=self._obtain_version(dist, package_dir),
  227. readme=self._obtain_readme(dist),
  228. classifiers=self._obtain_classifiers(dist),
  229. )
  230. # `None` indicates there is nothing in `tool.setuptools.dynamic` but the value
  231. # might have already been set by setup.py/extensions, so avoid overwriting.
  232. updates = {k: v for k, v in obtained_dynamic.items() if v is not None}
  233. self.project_cfg.update(updates)
  234. def _ensure_previously_set(self, dist: "Distribution", field: str):
  235. previous = _PREVIOUSLY_DEFINED[field](dist)
  236. if previous is None and not self.ignore_option_errors:
  237. msg = (
  238. f"No configuration found for dynamic {field!r}.\n"
  239. "Some dynamic fields need to be specified via `tool.setuptools.dynamic`"
  240. "\nothers must be specified via the equivalent attribute in `setup.py`."
  241. )
  242. raise OptionError(msg)
  243. def _obtain(self, dist: "Distribution", field: str, package_dir: Mapping[str, str]):
  244. if field in self.dynamic_cfg:
  245. directive = self.dynamic_cfg[field]
  246. with _ignore_errors(self.ignore_option_errors):
  247. root_dir = self.root_dir
  248. if "file" in directive:
  249. return _expand.read_files(directive["file"], root_dir)
  250. if "attr" in directive:
  251. return _expand.read_attr(directive["attr"], package_dir, root_dir)
  252. msg = f"invalid `tool.setuptools.dynamic.{field}`: {directive!r}"
  253. raise ValueError(msg)
  254. return None
  255. self._ensure_previously_set(dist, field)
  256. return None
  257. def _obtain_version(self, dist: "Distribution", package_dir: Mapping[str, str]):
  258. # Since plugins can set version, let's silently skip if it cannot be obtained
  259. if "version" in self.dynamic and "version" in self.dynamic_cfg:
  260. return _expand.version(self._obtain(dist, "version", package_dir))
  261. return None
  262. def _obtain_readme(self, dist: "Distribution") -> Optional[Dict[str, str]]:
  263. if "readme" not in self.dynamic:
  264. return None
  265. dynamic_cfg = self.dynamic_cfg
  266. if "readme" in dynamic_cfg:
  267. return {
  268. "text": self._obtain(dist, "readme", {}),
  269. "content-type": dynamic_cfg["readme"].get("content-type", "text/x-rst"),
  270. }
  271. self._ensure_previously_set(dist, "readme")
  272. return None
  273. def _obtain_entry_points(
  274. self, dist: "Distribution", package_dir: Mapping[str, str]
  275. ) -> Optional[Dict[str, dict]]:
  276. fields = ("entry-points", "scripts", "gui-scripts")
  277. if not any(field in self.dynamic for field in fields):
  278. return None
  279. text = self._obtain(dist, "entry-points", package_dir)
  280. if text is None:
  281. return None
  282. groups = _expand.entry_points(text)
  283. expanded = {"entry-points": groups}
  284. def _set_scripts(field: str, group: str):
  285. if group in groups:
  286. value = groups.pop(group)
  287. if field not in self.dynamic:
  288. msg = _WouldIgnoreField.message(field, value)
  289. warnings.warn(msg, _WouldIgnoreField)
  290. # TODO: Don't set field when support for pyproject.toml stabilizes
  291. # instead raise an error as specified in PEP 621
  292. expanded[field] = value
  293. _set_scripts("scripts", "console_scripts")
  294. _set_scripts("gui-scripts", "gui_scripts")
  295. return expanded
  296. def _obtain_classifiers(self, dist: "Distribution"):
  297. if "classifiers" in self.dynamic:
  298. value = self._obtain(dist, "classifiers", {})
  299. if value:
  300. return value.splitlines()
  301. return None
  302. @contextmanager
  303. def _ignore_errors(ignore_option_errors: bool):
  304. if not ignore_option_errors:
  305. yield
  306. return
  307. try:
  308. yield
  309. except Exception as ex:
  310. _logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}")
  311. class _EnsurePackagesDiscovered(_expand.EnsurePackagesDiscovered):
  312. def __init__(self, distribution: "Distribution", setuptools_cfg: dict):
  313. super().__init__(distribution)
  314. self._setuptools_cfg = setuptools_cfg
  315. def __enter__(self):
  316. """When entering the context, the values of ``packages``, ``py_modules`` and
  317. ``package_dir`` that are missing in ``dist`` are copied from ``setuptools_cfg``.
  318. """
  319. dist, cfg = self._dist, self._setuptools_cfg
  320. package_dir: Dict[str, str] = cfg.setdefault("package-dir", {})
  321. package_dir.update(dist.package_dir or {})
  322. dist.package_dir = package_dir # needs to be the same object
  323. dist.set_defaults._ignore_ext_modules() # pyproject.toml-specific behaviour
  324. # Set `py_modules` and `packages` in dist to short-circuit auto-discovery,
  325. # but avoid overwriting empty lists purposefully set by users.
  326. if dist.py_modules is None:
  327. dist.py_modules = cfg.get("py-modules")
  328. if dist.packages is None:
  329. dist.packages = cfg.get("packages")
  330. return super().__enter__()
  331. def __exit__(self, exc_type, exc_value, traceback):
  332. """When exiting the context, if values of ``packages``, ``py_modules`` and
  333. ``package_dir`` are missing in ``setuptools_cfg``, copy from ``dist``.
  334. """
  335. # If anything was discovered set them back, so they count in the final config.
  336. self._setuptools_cfg.setdefault("packages", self._dist.packages)
  337. self._setuptools_cfg.setdefault("py-modules", self._dist.py_modules)
  338. return super().__exit__(exc_type, exc_value, traceback)
  339. class _ExperimentalProjectMetadata(UserWarning):
  340. """Explicitly inform users that `pyproject.toml` configuration is experimental"""
  341. class _InvalidFile(UserWarning):
  342. """Inform users that the given `pyproject.toml` is experimental:
  343. !!\n\n
  344. ############################
  345. # Invalid `pyproject.toml` #
  346. ############################
  347. Any configurations in `pyproject.toml` will be ignored.
  348. Please note that future releases of setuptools will halt the build process
  349. if an invalid file is given.
  350. To prevent setuptools from considering `pyproject.toml` please
  351. DO NOT include the `[project]` or `[tool.setuptools]` tables in your file.
  352. \n\n!!
  353. """
  354. @classmethod
  355. def message(cls):
  356. from inspect import cleandoc
  357. msg = "\n".join(cls.__doc__.splitlines()[1:])
  358. return cleandoc(msg)