Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.10/site-packages/setuptools/config/pyprojecttoml.py: 1%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

236 statements  

1""" 

2Load setuptools configuration from ``pyproject.toml`` files. 

3 

4**PRIVATE MODULE**: API reserved for setuptools internal usage only. 

5 

6To read project metadata, consider using 

7``build.util.project_wheel_metadata`` (https://pypi.org/project/build/). 

8For simple scenarios, you can also try parsing the file directly 

9with the help of ``tomllib`` or ``tomli``. 

10""" 

11 

12from __future__ import annotations 

13 

14import logging 

15import os 

16from collections.abc import Mapping 

17from contextlib import contextmanager 

18from functools import partial 

19from types import TracebackType 

20from typing import TYPE_CHECKING, Any, Callable 

21 

22from .._path import StrPath 

23from ..errors import FileError, InvalidConfigError 

24from ..warnings import SetuptoolsWarning 

25from . import expand as _expand 

26from ._apply_pyprojecttoml import _PREVIOUSLY_DEFINED, _MissingDynamic, apply as _apply 

27 

28if TYPE_CHECKING: 

29 from typing_extensions import Self 

30 

31 from setuptools.dist import Distribution 

32 

33_logger = logging.getLogger(__name__) 

34 

35 

36def load_file(filepath: StrPath) -> dict: 

37 from ..compat.py310 import tomllib 

38 

39 with open(filepath, "rb") as file: 

40 return tomllib.load(file) 

41 

42 

43def validate(config: dict, filepath: StrPath) -> bool: 

44 from . import _validate_pyproject as validator 

45 

46 trove_classifier = validator.FORMAT_FUNCTIONS.get("trove-classifier") 

47 if hasattr(trove_classifier, "_disable_download"): 

48 # Improve reproducibility by default. See abravalheri/validate-pyproject#31 

49 trove_classifier._disable_download() # type: ignore[union-attr] 

50 

51 try: 

52 return validator.validate(config) 

53 except validator.ValidationError as ex: 

54 summary = f"configuration error: {ex.summary}" 

55 if ex.name.strip("`") != "project": 

56 # Probably it is just a field missing/misnamed, not worthy the verbosity... 

57 _logger.debug(summary) 

58 _logger.debug(ex.details) 

59 

60 error = f"invalid pyproject.toml config: {ex.name}." 

61 raise ValueError(f"{error}\n{summary}") from None 

62 

63 

64def apply_configuration( 

65 dist: Distribution, 

66 filepath: StrPath, 

67 ignore_option_errors: bool = False, 

68) -> Distribution: 

69 """Apply the configuration from a ``pyproject.toml`` file into an existing 

70 distribution object. 

71 """ 

72 config = read_configuration(filepath, True, ignore_option_errors, dist) 

73 return _apply(dist, config, filepath) 

74 

75 

76def read_configuration( 

77 filepath: StrPath, 

78 expand: bool = True, 

79 ignore_option_errors: bool = False, 

80 dist: Distribution | None = None, 

81) -> dict[str, Any]: 

82 """Read given configuration file and returns options from it as a dict. 

83 

84 :param str|unicode filepath: Path to configuration file in the ``pyproject.toml`` 

85 format. 

86 

87 :param bool expand: Whether to expand directives and other computed values 

88 (i.e. post-process the given configuration) 

89 

90 :param bool ignore_option_errors: Whether to silently ignore 

91 options, values of which could not be resolved (e.g. due to exceptions 

92 in directives such as file:, attr:, etc.). 

93 If False exceptions are propagated as expected. 

94 

95 :param Distribution|None: Distribution object to which the configuration refers. 

96 If not given a dummy object will be created and discarded after the 

97 configuration is read. This is used for auto-discovery of packages and in the 

98 case a dynamic configuration (e.g. ``attr`` or ``cmdclass``) is expanded. 

99 When ``expand=False`` this object is simply ignored. 

100 

101 :rtype: dict 

102 """ 

103 filepath = os.path.abspath(filepath) 

104 

105 if not os.path.isfile(filepath): 

106 raise FileError(f"Configuration file {filepath!r} does not exist.") 

107 

108 asdict = load_file(filepath) or {} 

109 project_table = asdict.get("project", {}) 

110 tool_table = asdict.get("tool", {}) 

111 setuptools_table = tool_table.get("setuptools", {}) 

112 if not asdict or not (project_table or setuptools_table): 

113 return {} # User is not using pyproject to configure setuptools 

114 

115 if "setuptools" in asdict.get("tools", {}): 

116 # let the user know they probably have a typo in their metadata 

117 _ToolsTypoInMetadata.emit() 

118 

119 if "distutils" in tool_table: 

120 _ExperimentalConfiguration.emit(subject="[tool.distutils]") 

121 

122 # There is an overall sense in the community that making include_package_data=True 

123 # the default would be an improvement. 

124 # `ini2toml` backfills include_package_data=False when nothing is explicitly given, 

125 # therefore setting a default here is backwards compatible. 

126 if dist and dist.include_package_data is not None: 

127 setuptools_table.setdefault("include-package-data", dist.include_package_data) 

128 else: 

129 setuptools_table.setdefault("include-package-data", True) 

130 # Persist changes: 

131 asdict["tool"] = tool_table 

132 tool_table["setuptools"] = setuptools_table 

133 

134 if "ext-modules" in setuptools_table: 

135 _ExperimentalConfiguration.emit(subject="[tool.setuptools.ext-modules]") 

136 

137 with _ignore_errors(ignore_option_errors): 

138 # Don't complain about unrelated errors (e.g. tools not using the "tool" table) 

139 subset = {"project": project_table, "tool": {"setuptools": setuptools_table}} 

140 validate(subset, filepath) 

141 

142 if expand: 

143 root_dir = os.path.dirname(filepath) 

144 return expand_configuration(asdict, root_dir, ignore_option_errors, dist) 

145 

146 return asdict 

147 

148 

149def expand_configuration( 

150 config: dict, 

151 root_dir: StrPath | None = None, 

152 ignore_option_errors: bool = False, 

153 dist: Distribution | None = None, 

154) -> dict: 

155 """Given a configuration with unresolved fields (e.g. dynamic, cmdclass, ...) 

156 find their final values. 

157 

158 :param dict config: Dict containing the configuration for the distribution 

159 :param str root_dir: Top-level directory for the distribution/project 

160 (the same directory where ``pyproject.toml`` is place) 

161 :param bool ignore_option_errors: see :func:`read_configuration` 

162 :param Distribution|None: Distribution object to which the configuration refers. 

163 If not given a dummy object will be created and discarded after the 

164 configuration is read. Used in the case a dynamic configuration 

165 (e.g. ``attr`` or ``cmdclass``). 

166 

167 :rtype: dict 

168 """ 

169 return _ConfigExpander(config, root_dir, ignore_option_errors, dist).expand() 

170 

171 

172class _ConfigExpander: 

173 def __init__( 

174 self, 

175 config: dict, 

176 root_dir: StrPath | None = None, 

177 ignore_option_errors: bool = False, 

178 dist: Distribution | None = None, 

179 ) -> None: 

180 self.config = config 

181 self.root_dir = root_dir or os.getcwd() 

182 self.project_cfg = config.get("project", {}) 

183 self.dynamic = self.project_cfg.get("dynamic", []) 

184 self.setuptools_cfg = config.get("tool", {}).get("setuptools", {}) 

185 self.dynamic_cfg = self.setuptools_cfg.get("dynamic", {}) 

186 self.ignore_option_errors = ignore_option_errors 

187 self._dist = dist 

188 self._referenced_files = set[str]() 

189 

190 def _ensure_dist(self) -> Distribution: 

191 from setuptools.dist import Distribution 

192 

193 attrs = {"src_root": self.root_dir, "name": self.project_cfg.get("name", None)} 

194 return self._dist or Distribution(attrs) 

195 

196 def _process_field(self, container: dict, field: str, fn: Callable): 

197 if field in container: 

198 with _ignore_errors(self.ignore_option_errors): 

199 container[field] = fn(container[field]) 

200 

201 def _canonic_package_data(self, field="package-data"): 

202 package_data = self.setuptools_cfg.get(field, {}) 

203 return _expand.canonic_package_data(package_data) 

204 

205 def expand(self): 

206 self._expand_packages() 

207 self._canonic_package_data() 

208 self._canonic_package_data("exclude-package-data") 

209 

210 # A distribution object is required for discovering the correct package_dir 

211 dist = self._ensure_dist() 

212 ctx = _EnsurePackagesDiscovered(dist, self.project_cfg, self.setuptools_cfg) 

213 with ctx as ensure_discovered: 

214 package_dir = ensure_discovered.package_dir 

215 self._expand_data_files() 

216 self._expand_cmdclass(package_dir) 

217 self._expand_all_dynamic(dist, package_dir) 

218 

219 dist._referenced_files.update(self._referenced_files) 

220 return self.config 

221 

222 def _expand_packages(self): 

223 packages = self.setuptools_cfg.get("packages") 

224 if packages is None or isinstance(packages, (list, tuple)): 

225 return 

226 

227 find = packages.get("find") 

228 if isinstance(find, dict): 

229 find["root_dir"] = self.root_dir 

230 find["fill_package_dir"] = self.setuptools_cfg.setdefault("package-dir", {}) 

231 with _ignore_errors(self.ignore_option_errors): 

232 self.setuptools_cfg["packages"] = _expand.find_packages(**find) 

233 

234 def _expand_data_files(self): 

235 data_files = partial(_expand.canonic_data_files, root_dir=self.root_dir) 

236 self._process_field(self.setuptools_cfg, "data-files", data_files) 

237 

238 def _expand_cmdclass(self, package_dir: Mapping[str, str]): 

239 root_dir = self.root_dir 

240 cmdclass = partial(_expand.cmdclass, package_dir=package_dir, root_dir=root_dir) 

241 self._process_field(self.setuptools_cfg, "cmdclass", cmdclass) 

242 

243 def _expand_all_dynamic(self, dist: Distribution, package_dir: Mapping[str, str]): 

244 special = ( # need special handling 

245 "version", 

246 "readme", 

247 "entry-points", 

248 "scripts", 

249 "gui-scripts", 

250 "classifiers", 

251 "dependencies", 

252 "optional-dependencies", 

253 ) 

254 # `_obtain` functions are assumed to raise appropriate exceptions/warnings. 

255 obtained_dynamic = { 

256 field: self._obtain(dist, field, package_dir) 

257 for field in self.dynamic 

258 if field not in special 

259 } 

260 obtained_dynamic.update( 

261 self._obtain_entry_points(dist, package_dir) or {}, 

262 version=self._obtain_version(dist, package_dir), 

263 readme=self._obtain_readme(dist), 

264 classifiers=self._obtain_classifiers(dist), 

265 dependencies=self._obtain_dependencies(dist), 

266 optional_dependencies=self._obtain_optional_dependencies(dist), 

267 ) 

268 # `None` indicates there is nothing in `tool.setuptools.dynamic` but the value 

269 # might have already been set by setup.py/extensions, so avoid overwriting. 

270 updates = {k: v for k, v in obtained_dynamic.items() if v is not None} 

271 self.project_cfg.update(updates) 

272 

273 def _ensure_previously_set(self, dist: Distribution, field: str): 

274 previous = _PREVIOUSLY_DEFINED[field](dist) 

275 if previous is None and not self.ignore_option_errors: 

276 msg = ( 

277 f"No configuration found for dynamic {field!r}.\n" 

278 "Some dynamic fields need to be specified via `tool.setuptools.dynamic`" 

279 "\nothers must be specified via the equivalent attribute in `setup.py`." 

280 ) 

281 raise InvalidConfigError(msg) 

282 

283 def _expand_directive( 

284 self, specifier: str, directive, package_dir: Mapping[str, str] 

285 ): 

286 from more_itertools import always_iterable 

287 

288 with _ignore_errors(self.ignore_option_errors): 

289 root_dir = self.root_dir 

290 if "file" in directive: 

291 self._referenced_files.update(always_iterable(directive["file"])) 

292 return _expand.read_files(directive["file"], root_dir) 

293 if "attr" in directive: 

294 return _expand.read_attr(directive["attr"], package_dir, root_dir) 

295 raise ValueError(f"invalid `{specifier}`: {directive!r}") 

296 return None 

297 

298 def _obtain(self, dist: Distribution, field: str, package_dir: Mapping[str, str]): 

299 if field in self.dynamic_cfg: 

300 return self._expand_directive( 

301 f"tool.setuptools.dynamic.{field}", 

302 self.dynamic_cfg[field], 

303 package_dir, 

304 ) 

305 self._ensure_previously_set(dist, field) 

306 return None 

307 

308 def _obtain_version(self, dist: Distribution, package_dir: Mapping[str, str]): 

309 # Since plugins can set version, let's silently skip if it cannot be obtained 

310 if "version" in self.dynamic and "version" in self.dynamic_cfg: 

311 return _expand.version( 

312 # We already do an early check for the presence of "version" 

313 self._obtain(dist, "version", package_dir) # pyright: ignore[reportArgumentType] 

314 ) 

315 return None 

316 

317 def _obtain_readme(self, dist: Distribution) -> dict[str, str] | None: 

318 if "readme" not in self.dynamic: 

319 return None 

320 

321 dynamic_cfg = self.dynamic_cfg 

322 if "readme" in dynamic_cfg: 

323 return { 

324 # We already do an early check for the presence of "readme" 

325 "text": self._obtain(dist, "readme", {}), 

326 "content-type": dynamic_cfg["readme"].get("content-type", "text/x-rst"), 

327 } # pyright: ignore[reportReturnType] 

328 

329 self._ensure_previously_set(dist, "readme") 

330 return None 

331 

332 def _obtain_entry_points( 

333 self, dist: Distribution, package_dir: Mapping[str, str] 

334 ) -> dict[str, dict[str, Any]] | None: 

335 fields = ("entry-points", "scripts", "gui-scripts") 

336 if not any(field in self.dynamic for field in fields): 

337 return None 

338 

339 text = self._obtain(dist, "entry-points", package_dir) 

340 if text is None: 

341 return None 

342 

343 groups = _expand.entry_points(text) 

344 # Any is str | dict[str, str], but causes variance issues 

345 expanded: dict[str, dict[str, Any]] = {"entry-points": groups} 

346 

347 def _set_scripts(field: str, group: str): 

348 if group in groups: 

349 value = groups.pop(group) 

350 if field not in self.dynamic: 

351 raise InvalidConfigError(_MissingDynamic.details(field, value)) 

352 expanded[field] = value 

353 

354 _set_scripts("scripts", "console_scripts") 

355 _set_scripts("gui-scripts", "gui_scripts") 

356 

357 return expanded 

358 

359 def _obtain_classifiers(self, dist: Distribution): 

360 if "classifiers" in self.dynamic: 

361 value = self._obtain(dist, "classifiers", {}) 

362 if value: 

363 return value.splitlines() 

364 return None 

365 

366 def _obtain_dependencies(self, dist: Distribution): 

367 if "dependencies" in self.dynamic: 

368 value = self._obtain(dist, "dependencies", {}) 

369 if value: 

370 return _parse_requirements_list(value) 

371 return None 

372 

373 def _obtain_optional_dependencies(self, dist: Distribution): 

374 if "optional-dependencies" not in self.dynamic: 

375 return None 

376 if "optional-dependencies" in self.dynamic_cfg: 

377 optional_dependencies_map = self.dynamic_cfg["optional-dependencies"] 

378 assert isinstance(optional_dependencies_map, dict) 

379 return { 

380 group: _parse_requirements_list( 

381 self._expand_directive( 

382 f"tool.setuptools.dynamic.optional-dependencies.{group}", 

383 directive, 

384 {}, 

385 ) 

386 ) 

387 for group, directive in optional_dependencies_map.items() 

388 } 

389 self._ensure_previously_set(dist, "optional-dependencies") 

390 return None 

391 

392 

393def _parse_requirements_list(value): 

394 return [ 

395 line 

396 for line in value.splitlines() 

397 if line.strip() and not line.strip().startswith("#") 

398 ] 

399 

400 

401@contextmanager 

402def _ignore_errors(ignore_option_errors: bool): 

403 if not ignore_option_errors: 

404 yield 

405 return 

406 

407 try: 

408 yield 

409 except Exception as ex: 

410 _logger.debug(f"ignored error: {ex.__class__.__name__} - {ex}") 

411 

412 

413class _EnsurePackagesDiscovered(_expand.EnsurePackagesDiscovered): 

414 def __init__( 

415 self, distribution: Distribution, project_cfg: dict, setuptools_cfg: dict 

416 ) -> None: 

417 super().__init__(distribution) 

418 self._project_cfg = project_cfg 

419 self._setuptools_cfg = setuptools_cfg 

420 

421 def __enter__(self) -> Self: 

422 """When entering the context, the values of ``packages``, ``py_modules`` and 

423 ``package_dir`` that are missing in ``dist`` are copied from ``setuptools_cfg``. 

424 """ 

425 dist, cfg = self._dist, self._setuptools_cfg 

426 package_dir: dict[str, str] = cfg.setdefault("package-dir", {}) 

427 package_dir.update(dist.package_dir or {}) 

428 dist.package_dir = package_dir # needs to be the same object 

429 

430 dist.set_defaults._ignore_ext_modules() # pyproject.toml-specific behaviour 

431 

432 # Set `name`, `py_modules` and `packages` in dist to short-circuit 

433 # auto-discovery, but avoid overwriting empty lists purposefully set by users. 

434 if dist.metadata.name is None: 

435 dist.metadata.name = self._project_cfg.get("name") 

436 if dist.py_modules is None: 

437 dist.py_modules = cfg.get("py-modules") 

438 if dist.packages is None: 

439 dist.packages = cfg.get("packages") 

440 

441 return super().__enter__() 

442 

443 def __exit__( 

444 self, 

445 exc_type: type[BaseException] | None, 

446 exc_value: BaseException | None, 

447 traceback: TracebackType | None, 

448 ) -> None: 

449 """When exiting the context, if values of ``packages``, ``py_modules`` and 

450 ``package_dir`` are missing in ``setuptools_cfg``, copy from ``dist``. 

451 """ 

452 # If anything was discovered set them back, so they count in the final config. 

453 self._setuptools_cfg.setdefault("packages", self._dist.packages) 

454 self._setuptools_cfg.setdefault("py-modules", self._dist.py_modules) 

455 return super().__exit__(exc_type, exc_value, traceback) 

456 

457 

458class _ExperimentalConfiguration(SetuptoolsWarning): 

459 _SUMMARY = ( 

460 "`{subject}` in `pyproject.toml` is still *experimental* " 

461 "and likely to change in future releases." 

462 ) 

463 

464 

465class _ToolsTypoInMetadata(SetuptoolsWarning): 

466 _SUMMARY = ( 

467 "Ignoring [tools.setuptools] in pyproject.toml, did you mean [tool.setuptools]?" 

468 )