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

223 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-25 10:55 +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 InlineTable, Table 

13 

14from somesy.core.log import VERBOSE 

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

16from somesy.core.writer import IgnoreKey, ProjectMetadataWriter 

17 

18from .models import Pep621Config, PoetryConfig 

19 

20logger = logging.getLogger("somesy") 

21 

22 

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

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

25 

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

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

28 project started from. 

29 """ 

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

31 

32 

33def license_expression(licenses) -> str: 

34 """Convert one or more license identifiers to an SPDX expression.""" 

35 return " OR ".join( 

36 str(license) 

37 for license in (licenses if isinstance(licenses, list) else [licenses]) 

38 ) 

39 

40 

41class PyprojectCommon(ProjectMetadataWriter): 

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

43 

44 def __init__( 

45 self, 

46 path: Path, 

47 *, 

48 section: list[str], 

49 model_cls, 

50 direct_mappings=None, 

51 pass_validation: bool | None = False, 

52 ): 

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

54 

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

56 """ 

57 self._model_cls = model_cls 

58 self._section = section 

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

60 super().__init__( 

61 path, 

62 create_if_not_exists=False, 

63 direct_mappings=direct_mappings or {}, 

64 pass_validation=pass_validation, 

65 ) 

66 self._adopt_url_key_spelling() 

67 

68 def _adopt_url_key_spelling(self) -> None: 

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

70 

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

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

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

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

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

76 """ 

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

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

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

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

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

82 continue 

83 name = key_path[-1] 

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

85 if spelling is None and capitalized: 

86 spelling = name.capitalize() 

87 if spelling is not None: 

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

89 

90 @property 

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

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

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

94 

95 @property 

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

97 """Return the version of the project.""" 

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

99 

100 @version.setter 

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

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

103 if "version" in self._dynamic_fields: 

104 if version: 

105 logger.warning( 

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

107 ) 

108 return 

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

110 

111 @property 

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

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

114 

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

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

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

118 identifier, so there is no expression to report. 

119 """ 

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

121 if isinstance(license, dict): 

122 return license.get("text") 

123 return license 

124 

125 @license.setter 

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

127 """Set the license of the project.""" 

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

129 

130 @property 

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

132 """Return the description of the project.""" 

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

134 

135 @description.setter 

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

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

138 if "description" in self._dynamic_fields: 

139 if description: 

140 logger.warning( 

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

142 ) 

143 return 

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

145 

146 def _load(self) -> None: 

147 """Load pyproject.toml file.""" 

148 with open(self.path) as f: 

149 self._data = tomlkit.load(f) 

150 

151 def _validate(self) -> None: 

152 """Validate poetry config using pydantic class. 

153 

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

155 Pydantic class only used for validation. 

156 """ 

157 if self.pass_validation: 

158 return 

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

160 logger.debug( 

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

162 ) 

163 self._model_cls(**config) 

164 

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

166 """Save the pyproject file.""" 

167 path = path or self.path 

168 

169 with open(path, "w") as f: 

170 tomlkit.dump(self._data, f) 

171 

172 def _get_property( 

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

174 ) -> Any: 

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

176 if isinstance(key, IgnoreKey): 

177 return None 

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

179 full_path = self._section + key_path 

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

181 

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

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

184 if isinstance(key, IgnoreKey): 

185 return 

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

187 

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

189 self._get_property(key_path, remove=True) 

190 return 

191 

192 # get the tomlkit object of the section 

193 dat = self._get_property([]) 

194 

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

196 curr = dat 

197 for path_key in key_path[:-1]: 

198 if path_key not in curr: 

199 table = tomlkit.table() 

200 curr.add(path_key, table) 

201 self._created_tables.append(table) 

202 curr = curr[path_key] 

203 

204 # Handle arrays with proper formatting 

205 if isinstance(value, list): 

206 array = tomlkit.array() 

207 array.extend(value) 

208 array.multiline(True) 

209 # Ensure whitespace after commas in inline tables 

210 for item in array: 

211 if isinstance(item, InlineTable): 

212 # Rebuild the inline table with desired formatting 

213 formatted_item = tomlkit.inline_table() 

214 for k, v in item.value.items(): 

215 formatted_item[k] = v 

216 formatted_item.trivia.trail = " " # Add space after each comma 

217 array[array.index(item)] = formatted_item 

218 curr[key_path[-1]] = array 

219 else: 

220 curr[key_path[-1]] = value 

221 

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

223 for field in curr: 

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

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

226 

227 

228class Poetry(PyprojectCommon): 

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

230 

231 def __init__( 

232 self, 

233 path: Path, 

234 pass_validation: bool | None = False, 

235 version: int | None = 1, 

236 ): 

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

238 

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

240 """ 

241 self._poetry_version = version or 1 

242 v2_mappings = { 

243 "homepage": ["urls", "homepage"], 

244 "repository": ["urls", "repository"], 

245 "documentation": ["urls", "documentation"], 

246 } 

247 if version == 1: 

248 super().__init__( 

249 path, 

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

251 model_cls=PoetryConfig, 

252 pass_validation=pass_validation, 

253 ) 

254 else: 

255 super().__init__( 

256 path, 

257 section=["project"], 

258 model_cls=PoetryConfig, 

259 pass_validation=pass_validation, 

260 direct_mappings=v2_mappings, 

261 ) 

262 

263 @staticmethod 

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

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

266 if poetry_version == 1: 

267 return person.to_name_email_string() 

268 else: 

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

270 if person.email: 

271 response["email"] = person.email 

272 return response 

273 

274 @staticmethod 

275 def _to_person( 

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

277 ) -> Person | Entity | None: 

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

279 if isinstance(person_obj, dict): 

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

281 if "email" in person_obj: 

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

283 person_obj = temp 

284 try: 

285 return Person.from_name_email_string(person_obj) 

286 except (ValueError, AttributeError): 

287 logger.info(f"Cannot convert {person_obj} to Person object, trying Entity.") 

288 

289 try: 

290 return Entity.from_name_email_string(person_obj) 

291 except (ValueError, AttributeError): 

292 logger.warning(f"Cannot convert {person_obj} to Entity.") 

293 return None 

294 

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

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

297 # Store original _from_person method 

298 original_from_person = self._from_person 

299 

300 # Override _from_person to include poetry_version 

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

302 person, poetry_version=self._poetry_version 

303 ) 

304 

305 # Call parent sync method 

306 super().sync(metadata) 

307 

308 # Restore original _from_person method 

309 self._from_person = original_from_person # type: ignore 

310 

311 if metadata.license: 

312 self.license = license_expression(metadata.license) 

313 

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

315 if self._poetry_version == 2: 

316 if ( 

317 "description" in self._data["project"] 

318 and "\n" in self._data["project"]["description"] 

319 ): 

320 self._data["project"]["description"] = tomlkit.string( 

321 self._data["project"]["description"], multiline=True 

322 ) 

323 # Move urls section to the end if it exists 

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

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

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

327 

328 

329class Pep621(PyprojectCommon): 

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

331 

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

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

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

335 """ 

336 

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

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

339 

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

341 """ 

342 section = ["project"] 

343 mappings = { 

344 "homepage": ["urls", "homepage"], 

345 "repository": ["urls", "repository"], 

346 "documentation": ["urls", "documentation"], 

347 } 

348 super().__init__( 

349 path, 

350 section=section, 

351 direct_mappings=mappings, 

352 model_cls=Pep621Config, 

353 pass_validation=pass_validation, 

354 ) 

355 

356 @staticmethod 

357 def _from_person(person: Person | Entity): 

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

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

360 if person.email: 

361 response["email"] = person.email 

362 return response 

363 

364 @staticmethod 

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

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

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

368 # we only compare on full_name anyway. 

369 if isinstance(person_obj, dict): 

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

371 if "email" in person_obj: 

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

373 person_obj = temp 

374 

375 try: 

376 return Person.from_name_email_string(person_obj) 

377 except (ValueError, AttributeError): 

378 logger.info(f"Cannot convert {person_obj} to Person object, trying Entity.") 

379 

380 try: 

381 return Entity.from_name_email_string(person_obj) 

382 except (ValueError, AttributeError): 

383 logger.warning(f"Cannot convert {person_obj} to Entity.") 

384 return None 

385 

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

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

388 super().sync(metadata) 

389 if metadata.license: 

390 self.license = license_expression(metadata.license) 

391 

392 

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

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

395 

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

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

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

399 cannot tell, and assume Poetry as before. 

400 """ 

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

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

403 

404 

405# ---- 

406 

407 

408class Pyproject(wrapt.ObjectProxy): 

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

410 

411 __wrapped__: Pep621 | Poetry 

412 

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

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

415 

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

417 not on the configured build backend. 

418 

419 Args: 

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

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

422 

423 Raises: 

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

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

426 

427 """ 

428 data = None 

429 if not path.is_file(): 

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

431 

432 with open(path, "r") as f: 

433 data = load(f) 

434 

435 # inspect file to pick suitable project metadata writer 

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

437 has_project = "project" in data 

438 

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

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

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

442 logger.log( 

443 VERBOSE, 

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

445 ) 

446 is_poetry = False 

447 

448 if is_poetry: 

449 if has_project: 

450 logger.log( 

451 VERBOSE, 

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

453 ) 

454 else: 

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

456 self.__wrapped__ = Poetry( 

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

458 ) 

459 elif has_project and not is_poetry: 

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

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

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

463 else: 

464 msg = ( 

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

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

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

468 ) 

469 raise ValueError(msg) 

470 

471 super().__init__(self.__wrapped__)