Coverage for src/somesy/pyproject/writer.py: 96%

202 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-11-03 13:13 +0000

1"""Pyproject writers for PEP 621 `[project]` metadata and Poetry.""" 

2 

3import logging 

4import re 

5from pathlib import Path 

6from typing import Any 

7 

8import tomlkit 

9import wrapt 

10from rich.pretty import pretty_repr 

11from tomlkit import load 

12from tomlkit.items import Table 

13 

14from somesy.core.log import VERBOSE 

15from somesy.core.models import Entity, Person, ProjectMetadata, parse_name_email 

16from somesy.core.writer import ( 

17 IgnoreKey, 

18 ProjectMetadataWriter, 

19 license_expression, 

20 multiline_description, 

21 toml_array, 

22) 

23 

24from .models import Pep621Config, PoetryConfig 

25 

26logger = logging.getLogger("somesy") 

27 

28SUMMARY_MAX_LENGTH = 512 

29"""Longest package summary (`description`) that PyPI accepts on upload.""" 

30 

31SUMMARY_LINE_BREAK = re.compile(r"[\r\n]") 

32"""Line breaks are not allowed in a package summary (core metadata `Summary`).""" 

33 

34 

35def normalize_url_key(name: str) -> str: 

36 """Return a `[project.urls]` key in a form that can be compared. 

37 

38 PEP 621 does not standardize these key names, so the same URL appears as 

39 "Bug Tracker", "bug-tracker" or "bugtracker" depending on the template the 

40 project started from. 

41 """ 

42 return re.sub(r"[\s_-]+", "", name).lower() 

43 

44 

45class PyprojectCommon(ProjectMetadataWriter): 

46 """Poetry config file handler parsed from pyproject.toml.""" 

47 

48 def __init__( 

49 self, 

50 path: Path, 

51 *, 

52 section: list[str], 

53 model_cls, 

54 direct_mappings=None, 

55 pass_validation: bool | None = False, 

56 ): 

57 """Poetry config file handler parsed from pyproject.toml. 

58 

59 See [somesy.core.writer.ProjectMetadataWriter.__init__][]. 

60 """ 

61 self._model_cls = model_cls 

62 self._section = section 

63 self._created_tables: list[Table] = [] 

64 super().__init__( 

65 path, 

66 create_if_not_exists=False, 

67 direct_mappings=direct_mappings or {}, 

68 pass_validation=pass_validation, 

69 ) 

70 self._adopt_url_key_spelling() 

71 

72 def _adopt_url_key_spelling(self) -> None: 

73 """Point the url mappings at the key spellings used in the file. 

74 

75 The common project templates capitalize the `[project.urls]` keys 

76 ("Homepage", "Bug Tracker"). Without this, somesy would neither read 

77 those entries nor update them, and would instead write a second, 

78 differently spelled entry next to them. A key somesy adds follows the 

79 capitalization of the keys already there, to keep the table uniform. 

80 """ 

81 keys = list(self._get_property(["urls"]) or {}) 

82 existing = {normalize_url_key(key): key for key in keys} 

83 capitalized = bool(keys) and all(key[:1].isupper() for key in keys) 

84 for field, key_path in self.direct_mappings.items(): 

85 if not isinstance(key_path, list) or key_path[:1] != ["urls"]: 

86 continue 

87 name = key_path[-1] 

88 spelling = existing.get(normalize_url_key(name)) 

89 if spelling is None and capitalized: 

90 spelling = name.capitalize() 

91 if spelling is not None: 

92 self.direct_mappings[field] = ["urls", spelling] 

93 

94 @property 

95 def _dynamic_fields(self) -> list[str]: 

96 """Return the list of fields marked as dynamic in pyproject.toml.""" 

97 return self._get_property(["dynamic"]) or [] 

98 

99 @property 

100 def version(self) -> str | None: 

101 """Return the version of the project.""" 

102 return self._get_property(self._get_key("version")) 

103 

104 @version.setter 

105 def version(self, version: str | None) -> None: 

106 """Set version, skipping if listed as dynamic.""" 

107 if "version" in self._dynamic_fields: 

108 if version: 

109 logger.warning( 

110 "Field 'version' is listed as dynamic — skipping sync from somesy." 

111 ) 

112 return 

113 self._set_property(self._get_key("version"), version) 

114 

115 @property 

116 def license(self) -> str | None: 

117 """Return the license of the project as an SPDX expression. 

118 

119 PEP 639 replaced the `license = { text = ... }` table with a plain 

120 expression, but files predating it are still valid and must be read. 

121 A `{ file = ... }` table names a license file instead of an 

122 identifier, so there is no expression to report. 

123 """ 

124 license = self._get_property(["license"]) 

125 if isinstance(license, dict): 

126 return license.get("text") 

127 return license 

128 

129 @license.setter 

130 def license(self, license: str | None) -> None: 

131 """Set the license of the project.""" 

132 self._set_property(["license"], license) 

133 

134 @property 

135 def description(self) -> str | None: 

136 """Return the description of the project.""" 

137 return self._get_property(self._get_key("description")) 

138 

139 @description.setter 

140 def description(self, description: str) -> None: 

141 """Set description, skipping if listed as dynamic.""" 

142 if "description" in self._dynamic_fields: 

143 if description: 

144 logger.warning( 

145 "Field 'description' is listed as dynamic — skipping sync from somesy." 

146 ) 

147 return 

148 if description and ( 

149 len(description) > SUMMARY_MAX_LENGTH 

150 or SUMMARY_LINE_BREAK.search(description) 

151 ): 

152 logger.warning( 

153 "Field 'description' becomes the package summary, which must be a " 

154 "single line of at most %d characters to be uploaded to PyPI. " 

155 "Put a longer text in the 'abstract' field instead.", 

156 SUMMARY_MAX_LENGTH, 

157 ) 

158 self._set_property(self._get_key("description"), description) 

159 

160 def _load(self) -> None: 

161 """Load pyproject.toml file.""" 

162 with open(self.path, encoding="utf-8") as f: 

163 self._data = tomlkit.load(f) 

164 

165 def _validate(self) -> None: 

166 """Validate poetry config using pydantic class. 

167 

168 In order to preserve toml comments and structure, tomlkit library is used. 

169 Pydantic class only used for validation. 

170 """ 

171 if self.pass_validation: 

172 return 

173 config = dict(self._get_property([])) 

174 logger.debug( 

175 f"Validating config using {self._model_cls.__name__}: {pretty_repr(config)}" 

176 ) 

177 self._model_cls(**config) 

178 

179 def save(self, path: Path | None = None) -> None: 

180 """Save the pyproject file.""" 

181 path = path or self.path 

182 

183 with open(path, "w", encoding="utf-8") as f: 

184 tomlkit.dump(self._data, f) 

185 

186 def _get_property( 

187 self, key: str | list[str] | IgnoreKey, *, remove: bool = False, **kwargs 

188 ) -> Any: 

189 """Get a property from the pyproject.toml file.""" 

190 if isinstance(key, IgnoreKey): 

191 return None 

192 key_path = [key] if isinstance(key, str) else key 

193 full_path = self._section + key_path 

194 return super()._get_property(full_path, remove=remove, **kwargs) 

195 

196 def _set_property(self, key: str | list[str] | IgnoreKey, value: Any) -> None: 

197 """Set a property in the pyproject.toml file.""" 

198 if isinstance(key, IgnoreKey): 

199 return 

200 key_path = [key] if isinstance(key, str) else key 

201 

202 if not value: # remove value and clean up the sub-dict 

203 self._get_property(key_path, remove=True) 

204 return 

205 

206 # get the tomlkit object of the section 

207 dat = self._get_property([]) 

208 

209 # dig down, create missing nested objects on the fly 

210 curr = dat 

211 for path_key in key_path[:-1]: 

212 if path_key not in curr: 

213 table = tomlkit.table() 

214 curr.add(path_key, table) 

215 self._created_tables.append(table) 

216 curr = curr[path_key] 

217 

218 # Handle arrays with proper formatting 

219 if isinstance(value, list): 

220 array = toml_array(value) 

221 curr[key_path[-1]] = array 

222 else: 

223 curr[key_path[-1]] = value 

224 

225 if any(curr is table for table in self._created_tables): 

226 for field in curr: 

227 curr.item(field).trivia.trail = "\n" 

228 curr.item(list(curr)[-1]).trivia.trail = "\n\n" 

229 

230 

231class Poetry(PyprojectCommon): 

232 """Poetry config file handler parsed from pyproject.toml.""" 

233 

234 def __init__( 

235 self, 

236 path: Path, 

237 pass_validation: bool | None = False, 

238 version: int | None = 1, 

239 ): 

240 """Poetry config file handler parsed from pyproject.toml. 

241 

242 See [somesy.core.writer.ProjectMetadataWriter.__init__][]. 

243 """ 

244 version = version or 1 

245 self._poetry_version = version 

246 v2_mappings = { 

247 "homepage": ["urls", "homepage"], 

248 "repository": ["urls", "repository"], 

249 "documentation": ["urls", "documentation"], 

250 } 

251 if version == 1: 

252 super().__init__( 

253 path, 

254 section=["tool", "poetry"], 

255 model_cls=PoetryConfig, 

256 pass_validation=pass_validation, 

257 ) 

258 else: 

259 super().__init__( 

260 path, 

261 section=["project"], 

262 model_cls=PoetryConfig, 

263 pass_validation=pass_validation, 

264 direct_mappings=v2_mappings, 

265 ) 

266 

267 @staticmethod 

268 def _from_person(person: Person | Entity, poetry_version: int = 1): 

269 """Convert project metadata person object to poetry string for person format "full name <email>.""" 

270 if poetry_version == 1: 

271 return person.to_name_email_string() 

272 else: 

273 return _pep621_person(person) 

274 

275 @staticmethod 

276 def _to_person( 

277 person_obj: str | dict[str, str], 

278 ) -> Person | Entity | None: 

279 """Convert from free string to person or entity object.""" 

280 if isinstance(person_obj, dict): 

281 temp = str(person_obj["name"]) 

282 if "email" in person_obj: 

283 temp = f"{temp} <{person_obj['email']}>" 

284 person_obj = temp 

285 return parse_name_email(person_obj) 

286 

287 def sync(self, metadata: ProjectMetadata) -> None: 

288 """Sync metadata with pyproject.toml file.""" 

289 # Store original _from_person method 

290 original_from_person = self._from_person 

291 

292 # Override _from_person to include poetry_version 

293 self._from_person = lambda person: original_from_person( # type: ignore 

294 person, poetry_version=self._poetry_version 

295 ) 

296 

297 try: 

298 # Call parent sync method 

299 super().sync(metadata) 

300 finally: 

301 # Restore original _from_person method 

302 self._from_person = original_from_person # type: ignore 

303 

304 if metadata.license: 

305 self.license = license_expression(metadata.license) 

306 

307 # For Poetry v2, convert authors and maintainers from array of tables to inline tables 

308 if self._poetry_version == 2: 

309 multiline_description(self._data["project"]) 

310 # Move urls section to the end if it exists 

311 if "urls" in self._data["project"]: 

312 urls = self._data["project"].pop("urls") 

313 self._data["project"]["urls"] = urls 

314 

315 

316class Pep621(PyprojectCommon): 

317 """Handler for PEP 621 `[project]` metadata in pyproject.toml. 

318 

319 This covers every backend that stores its metadata in the standard 

320 `[project]` table, i.e. uv, hatchling, flit, PDM, setuptools and 

321 Poetry 2.x. Only Poetry 1.x needs its own handler, see [somesy.pyproject.writer.Poetry][]. 

322 """ 

323 

324 def __init__(self, path: Path, pass_validation: bool | None = False): 

325 """PEP 621 `[project]` config file handler parsed from pyproject.toml. 

326 

327 See [somesy.core.writer.ProjectMetadataWriter.__init__][]. 

328 """ 

329 section = ["project"] 

330 mappings = { 

331 "homepage": ["urls", "homepage"], 

332 "repository": ["urls", "repository"], 

333 "documentation": ["urls", "documentation"], 

334 } 

335 super().__init__( 

336 path, 

337 section=section, 

338 direct_mappings=mappings, 

339 model_cls=Pep621Config, 

340 pass_validation=pass_validation, 

341 ) 

342 

343 @staticmethod 

344 def _from_person(person: Person | Entity): 

345 """Convert project metadata person object to a PEP 621 person table.""" 

346 return _pep621_person(person) 

347 

348 @staticmethod 

349 def _to_person(person_obj: str | dict) -> Entity | Person | None: 

350 """Parse a PEP 621 person entry to a Person/Entity.""" 

351 # NOTE: for our purposes, does not matter what are given or family names, 

352 # we only compare on full_name anyway. 

353 if isinstance(person_obj, dict): 

354 temp = str(person_obj["name"]) 

355 if "email" in person_obj: 

356 temp = f"{temp} <{person_obj['email']}>" 

357 person_obj = temp 

358 

359 return parse_name_email(person_obj) 

360 

361 def sync(self, metadata: ProjectMetadata) -> None: 

362 """Sync metadata with pyproject.toml file and fix license field.""" 

363 super().sync(metadata) 

364 if metadata.license: 

365 self.license = license_expression(metadata.license) 

366 

367 

368def _pep621_person(person: Person | Entity) -> dict[str, str]: 

369 """Convert a person/entity to a PEP 621 person table.""" 

370 response = {"name": person.full_name} 

371 if person.email: 

372 response["email"] = person.email 

373 return response 

374 

375 

376def _builds_with_poetry(data: Any) -> bool: 

377 """Return whether the project declares a Poetry build backend. 

378 

379 Only used to tell a Poetry 2.x project apart from a project of another 

380 backend that kept a `[tool.poetry]` section for its dependencies, for 

381 example while migrating away from Poetry. Without a build backend we 

382 cannot tell, and assume Poetry as before. 

383 """ 

384 backend = data.get("build-system", {}).get("build-backend") 

385 return not backend or "poetry" in str(backend) 

386 

387 

388# ---- 

389 

390 

391class Pyproject(wrapt.ObjectProxy): 

392 """Class for syncing pyproject file with other metadata files.""" 

393 

394 __wrapped__: Pep621 | Poetry 

395 

396 def __init__(self, path: Path, pass_validation: bool | None = False): 

397 """Pyproject wrapper class. Wraps either PEP 621 `[project]` or Poetry metadata. 

398 

399 The handler is picked based on the metadata tables present in the file, 

400 not on the configured build backend. 

401 

402 Args: 

403 path (Path): Path to pyproject.toml file. 

404 pass_validation (bool, optional): Whether to pass validation. Defaults to False. 

405 

406 Raises: 

407 FileNotFoundError: Raised when pyproject.toml file is not found. 

408 ValueError: Neither project nor tool.poetry object is found in pyproject.toml file. 

409 

410 """ 

411 data = None 

412 if not path.is_file(): 

413 raise FileNotFoundError(f"pyproject file {path} not found") 

414 

415 with open(path, "r", encoding="utf-8") as f: 

416 data = load(f) 

417 

418 # inspect file to pick suitable project metadata writer 

419 is_poetry = "tool" in data and "poetry" in data["tool"] 

420 has_project = "project" in data 

421 

422 if is_poetry and has_project and not _builds_with_poetry(data): 

423 # another backend builds the project, so it reads the metadata from 

424 # [project] and what remains in [tool.poetry] is only configuration 

425 logger.log( 

426 VERBOSE, 

427 "Ignoring the tool.poetry section, the project is not built with Poetry", 

428 ) 

429 is_poetry = False 

430 

431 if is_poetry: 

432 if has_project: 

433 logger.log( 

434 VERBOSE, 

435 "Found Poetry 2.x metadata with project section in pyproject.toml", 

436 ) 

437 else: 

438 logger.log(VERBOSE, "Found Poetry 1.x metadata in pyproject.toml") 

439 self.__wrapped__ = Poetry( 

440 path, pass_validation=pass_validation, version=2 if has_project else 1 

441 ) 

442 elif has_project and not is_poetry: 

443 # brackets are escaped, the log handler renders rich markup 

444 logger.log(VERBOSE, "Found PEP 621 \\[project] metadata in pyproject.toml") 

445 self.__wrapped__ = Pep621(path, pass_validation=pass_validation) 

446 else: 

447 msg = ( 

448 "The pyproject.toml file is ambiguous. Ensure it has either a PEP 621 " 

449 "[project] section (uv, hatchling, flit, PDM, setuptools, Poetry 2.x) " 

450 "or a [tool.poetry] section (Poetry 1.x)." 

451 ) 

452 raise ValueError(msg) 

453 

454 super().__init__(self.__wrapped__)