pyprojecttoml.py 17 KB

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