pyprojecttoml.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477
  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. fields = ("import-names", "import-namespaces")
  106. places = (project_table, project_table.get("dynamic", []))
  107. if any(field in place for field in fields for place in places):
  108. raise NotImplementedError(
  109. "Setuptools does not support `import-names` and `import-namespaces`"
  110. " in `pyproject.toml` yet. If your are interested in this feature, "
  111. " please consider submitting a contribution via pull requests."
  112. )
  113. with _ignore_errors(ignore_option_errors):
  114. # Don't complain about unrelated errors (e.g. tools not using the "tool" table)
  115. subset = {"project": project_table, "tool": {"setuptools": setuptools_table}}
  116. validate(subset, filepath)
  117. if expand:
  118. root_dir = os.path.dirname(filepath)
  119. return expand_configuration(asdict, root_dir, ignore_option_errors, dist)
  120. return asdict
  121. def expand_configuration(
  122. config: dict,
  123. root_dir: StrPath | None = None,
  124. ignore_option_errors: bool = False,
  125. dist: Distribution | None = None,
  126. ) -> dict:
  127. """Given a configuration with unresolved fields (e.g. dynamic, cmdclass, ...)
  128. find their final values.
  129. :param dict config: Dict containing the configuration for the distribution
  130. :param str root_dir: Top-level directory for the distribution/project
  131. (the same directory where ``pyproject.toml`` is place)
  132. :param bool ignore_option_errors: see :func:`read_configuration`
  133. :param Distribution|None: Distribution object to which the configuration refers.
  134. If not given a dummy object will be created and discarded after the
  135. configuration is read. Used in the case a dynamic configuration
  136. (e.g. ``attr`` or ``cmdclass``).
  137. :rtype: dict
  138. """
  139. return _ConfigExpander(config, root_dir, ignore_option_errors, dist).expand()
  140. class _ConfigExpander:
  141. def __init__(
  142. self,
  143. config: dict,
  144. root_dir: StrPath | None = None,
  145. ignore_option_errors: bool = False,
  146. dist: Distribution | None = None,
  147. ) -> None:
  148. self.config = config
  149. self.root_dir = root_dir or os.getcwd()
  150. self.project_cfg = config.get("project", {})
  151. self.dynamic = self.project_cfg.get("dynamic", [])
  152. self.setuptools_cfg = config.get("tool", {}).get("setuptools", {})
  153. self.dynamic_cfg = self.setuptools_cfg.get("dynamic", {})
  154. self.ignore_option_errors = ignore_option_errors
  155. self._dist = dist
  156. self._referenced_files = set[str]()
  157. def _ensure_dist(self) -> Distribution:
  158. from setuptools.dist import Distribution
  159. attrs = {"src_root": self.root_dir, "name": self.project_cfg.get("name", None)}
  160. return self._dist or Distribution(attrs)
  161. def _process_field(self, container: dict, field: str, fn: Callable):
  162. if field in container:
  163. with _ignore_errors(self.ignore_option_errors):
  164. container[field] = fn(container[field])
  165. def _canonic_package_data(self, field="package-data"):
  166. package_data = self.setuptools_cfg.get(field, {})
  167. return _expand.canonic_package_data(package_data)
  168. def expand(self):
  169. self._expand_packages()
  170. self._canonic_package_data()
  171. self._canonic_package_data("exclude-package-data")
  172. # A distribution object is required for discovering the correct package_dir
  173. dist = self._ensure_dist()
  174. ctx = _EnsurePackagesDiscovered(dist, self.project_cfg, self.setuptools_cfg)
  175. with ctx as ensure_discovered:
  176. package_dir = ensure_discovered.package_dir
  177. self._expand_data_files()
  178. self._expand_cmdclass(package_dir)
  179. self._expand_all_dynamic(dist, package_dir)
  180. dist._referenced_files.update(self._referenced_files)
  181. return self.config
  182. def _expand_packages(self):
  183. packages = self.setuptools_cfg.get("packages")
  184. if packages is None or isinstance(packages, (list, tuple)):
  185. return
  186. find = packages.get("find")
  187. if isinstance(find, dict):
  188. find["root_dir"] = self.root_dir
  189. find["fill_package_dir"] = self.setuptools_cfg.setdefault("package-dir", {})
  190. with _ignore_errors(self.ignore_option_errors):
  191. self.setuptools_cfg["packages"] = _expand.find_packages(**find)
  192. def _expand_data_files(self):
  193. data_files = partial(_expand.canonic_data_files, root_dir=self.root_dir)
  194. self._process_field(self.setuptools_cfg, "data-files", data_files)
  195. def _expand_cmdclass(self, package_dir: Mapping[str, str]):
  196. root_dir = self.root_dir
  197. cmdclass = partial(_expand.cmdclass, package_dir=package_dir, root_dir=root_dir)
  198. self._process_field(self.setuptools_cfg, "cmdclass", cmdclass)
  199. def _expand_all_dynamic(self, dist: Distribution, package_dir: Mapping[str, str]):
  200. special = ( # need special handling
  201. "version",
  202. "readme",
  203. "entry-points",
  204. "scripts",
  205. "gui-scripts",
  206. "classifiers",
  207. "dependencies",
  208. "optional-dependencies",
  209. )
  210. # `_obtain` functions are assumed to raise appropriate exceptions/warnings.
  211. obtained_dynamic = {
  212. field: self._obtain(dist, field, package_dir)
  213. for field in self.dynamic
  214. if field not in special
  215. }
  216. obtained_dynamic.update(
  217. self._obtain_entry_points(dist, package_dir) or {},
  218. version=self._obtain_version(dist, package_dir),
  219. readme=self._obtain_readme(dist),
  220. classifiers=self._obtain_classifiers(dist),
  221. dependencies=self._obtain_dependencies(dist),
  222. optional_dependencies=self._obtain_optional_dependencies(dist),
  223. )
  224. # `None` indicates there is nothing in `tool.setuptools.dynamic` but the value
  225. # might have already been set by setup.py/extensions, so avoid overwriting.
  226. updates = {k: v for k, v in obtained_dynamic.items() if v is not None}
  227. self.project_cfg.update(updates)
  228. def _ensure_previously_set(self, dist: Distribution, field: str):
  229. previous = _PREVIOUSLY_DEFINED[field](dist)
  230. if previous is None and not self.ignore_option_errors:
  231. msg = (
  232. f"No configuration found for dynamic {field!r}.\n"
  233. "Some dynamic fields need to be specified via `tool.setuptools.dynamic`"
  234. "\nothers must be specified via the equivalent attribute in `setup.py`."
  235. )
  236. raise InvalidConfigError(msg)
  237. def _expand_directive(
  238. self, specifier: str, directive, package_dir: Mapping[str, str]
  239. ):
  240. from more_itertools import always_iterable
  241. with _ignore_errors(self.ignore_option_errors):
  242. root_dir = self.root_dir
  243. if "file" in directive:
  244. self._referenced_files.update(always_iterable(directive["file"]))
  245. return _expand.read_files(directive["file"], root_dir)
  246. if "attr" in directive:
  247. return _expand.read_attr(directive["attr"], package_dir, root_dir)
  248. raise ValueError(f"invalid `{specifier}`: {directive!r}")
  249. return None
  250. def _obtain(self, dist: Distribution, field: str, package_dir: Mapping[str, str]):
  251. if field in self.dynamic_cfg:
  252. return self._expand_directive(
  253. f"tool.setuptools.dynamic.{field}",
  254. self.dynamic_cfg[field],
  255. package_dir,
  256. )
  257. self._ensure_previously_set(dist, field)
  258. return None
  259. def _obtain_version(self, dist: Distribution, package_dir: Mapping[str, str]):
  260. # Since plugins can set version, let's silently skip if it cannot be obtained
  261. if "version" in self.dynamic and "version" in self.dynamic_cfg:
  262. return _expand.version(
  263. # We already do an early check for the presence of "version"
  264. self._obtain(dist, "version", package_dir) # pyright: ignore[reportArgumentType]
  265. )
  266. return None
  267. def _obtain_readme(self, dist: Distribution) -> dict[str, str] | None:
  268. if "readme" not in self.dynamic:
  269. return None
  270. dynamic_cfg = self.dynamic_cfg
  271. if "readme" in dynamic_cfg:
  272. return {
  273. # We already do an early check for the presence of "readme"
  274. "text": self._obtain(dist, "readme", {}),
  275. "content-type": dynamic_cfg["readme"].get("content-type", "text/x-rst"),
  276. } # pyright: ignore[reportReturnType]
  277. self._ensure_previously_set(dist, "readme")
  278. return None
  279. def _obtain_entry_points(
  280. self, dist: Distribution, package_dir: Mapping[str, str]
  281. ) -> dict[str, dict[str, Any]] | None:
  282. fields = ("entry-points", "scripts", "gui-scripts")
  283. if not any(field in self.dynamic for field in fields):
  284. return None
  285. text = self._obtain(dist, "entry-points", package_dir)
  286. if text is None:
  287. return None
  288. groups = _expand.entry_points(text)
  289. # Any is str | dict[str, str], but causes variance issues
  290. expanded: dict[str, dict[str, Any]] = {"entry-points": groups}
  291. def _set_scripts(field: str, group: str):
  292. if group in groups:
  293. value = groups.pop(group)
  294. if field not in self.dynamic:
  295. raise InvalidConfigError(_MissingDynamic.details(field, value))
  296. expanded[field] = value
  297. _set_scripts("scripts", "console_scripts")
  298. _set_scripts("gui-scripts", "gui_scripts")
  299. return expanded
  300. def _obtain_classifiers(self, dist: Distribution):
  301. if "classifiers" in self.dynamic:
  302. value = self._obtain(dist, "classifiers", {})
  303. if value:
  304. return value.splitlines()
  305. return None
  306. def _obtain_dependencies(self, dist: Distribution):
  307. if "dependencies" in self.dynamic:
  308. value = self._obtain(dist, "dependencies", {})
  309. if value:
  310. return _parse_requirements_list(value)
  311. return None
  312. def _obtain_optional_dependencies(self, dist: Distribution):
  313. if "optional-dependencies" not in self.dynamic:
  314. return None
  315. if "optional-dependencies" in self.dynamic_cfg:
  316. optional_dependencies_map = self.dynamic_cfg["optional-dependencies"]
  317. assert isinstance(optional_dependencies_map, dict)
  318. return {
  319. group: _parse_requirements_list(
  320. self._expand_directive(
  321. f"tool.setuptools.dynamic.optional-dependencies.{group}",
  322. directive,
  323. {},
  324. )
  325. )
  326. for group, directive in optional_dependencies_map.items()
  327. }
  328. self._ensure_previously_set(dist, "optional-dependencies")
  329. return None
  330. def _parse_requirements_list(value):
  331. return [
  332. line
  333. for line in value.splitlines()
  334. if line.strip() and not line.strip().startswith("#")
  335. ]
  336. @contextmanager
  337. def _ignore_errors(ignore_option_errors: bool):
  338. if not ignore_option_errors:
  339. yield
  340. return
  341. try:
  342. yield
  343. except Exception as ex:
  344. _logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}")
  345. class _EnsurePackagesDiscovered(_expand.EnsurePackagesDiscovered):
  346. def __init__(
  347. self, distribution: Distribution, project_cfg: dict, setuptools_cfg: dict
  348. ) -> None:
  349. super().__init__(distribution)
  350. self._project_cfg = project_cfg
  351. self._setuptools_cfg = setuptools_cfg
  352. def __enter__(self) -> Self:
  353. """When entering the context, the values of ``packages``, ``py_modules`` and
  354. ``package_dir`` that are missing in ``dist`` are copied from ``setuptools_cfg``.
  355. """
  356. dist, cfg = self._dist, self._setuptools_cfg
  357. package_dir: dict[str, str] = cfg.setdefault("package-dir", {})
  358. package_dir.update(dist.package_dir or {})
  359. dist.package_dir = package_dir # needs to be the same object
  360. dist.set_defaults._ignore_ext_modules() # pyproject.toml-specific behaviour
  361. # Set `name`, `py_modules` and `packages` in dist to short-circuit
  362. # auto-discovery, but avoid overwriting empty lists purposefully set by users.
  363. if dist.metadata.name is None:
  364. dist.metadata.name = self._project_cfg.get("name")
  365. if dist.py_modules is None:
  366. dist.py_modules = cfg.get("py-modules")
  367. if dist.packages is None:
  368. dist.packages = cfg.get("packages")
  369. return super().__enter__()
  370. def __exit__(
  371. self,
  372. exc_type: type[BaseException] | None,
  373. exc_value: BaseException | None,
  374. traceback: TracebackType | None,
  375. ) -> None:
  376. """When exiting the context, if values of ``packages``, ``py_modules`` and
  377. ``package_dir`` are missing in ``setuptools_cfg``, copy from ``dist``.
  378. """
  379. # If anything was discovered set them back, so they count in the final config.
  380. self._setuptools_cfg.setdefault("packages", self._dist.packages)
  381. self._setuptools_cfg.setdefault("py-modules", self._dist.py_modules)
  382. return super().__exit__(exc_type, exc_value, traceback)
  383. class _ExperimentalConfiguration(SetuptoolsWarning):
  384. _SUMMARY = (
  385. "`{subject}` in `pyproject.toml` is still *experimental* "
  386. "and likely to change in future releases."
  387. )
  388. class _ToolsTypoInMetadata(SetuptoolsWarning):
  389. _SUMMARY = (
  390. "Ignoring [tools.setuptools] in pyproject.toml, did you mean [tool.setuptools]?"
  391. )