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

216 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-18 08:48 +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 

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 super().__init__( 

60 path, 

61 create_if_not_exists=False, 

62 direct_mappings=direct_mappings or {}, 

63 pass_validation=pass_validation, 

64 ) 

65 self._adopt_url_key_spelling() 

66 

67 def _adopt_url_key_spelling(self) -> None: 

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

69 

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

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

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

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

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

75 """ 

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

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

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

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

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

81 continue 

82 name = key_path[-1] 

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

84 if spelling is None and capitalized: 

85 spelling = name.capitalize() 

86 if spelling is not None: 

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

88 

89 @property 

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

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

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

93 

94 @property 

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

96 """Return the version of the project.""" 

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

98 

99 @version.setter 

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

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

102 if "version" in self._dynamic_fields: 

103 if version: 

104 logger.warning( 

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

106 ) 

107 return 

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

109 

110 @property 

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

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

113 

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

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

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

117 identifier, so there is no expression to report. 

118 """ 

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

120 if isinstance(license, dict): 

121 return license.get("text") 

122 return license 

123 

124 @license.setter 

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

126 """Set the license of the project.""" 

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

128 

129 @property 

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

131 """Return the description of the project.""" 

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

133 

134 @description.setter 

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

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

137 if "description" in self._dynamic_fields: 

138 if description: 

139 logger.warning( 

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

141 ) 

142 return 

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

144 

145 def _load(self) -> None: 

146 """Load pyproject.toml file.""" 

147 with open(self.path) as f: 

148 self._data = tomlkit.load(f) 

149 

150 def _validate(self) -> None: 

151 """Validate poetry config using pydantic class. 

152 

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

154 Pydantic class only used for validation. 

155 """ 

156 if self.pass_validation: 

157 return 

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

159 logger.debug( 

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

161 ) 

162 self._model_cls(**config) 

163 

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

165 """Save the pyproject file.""" 

166 path = path or self.path 

167 

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

169 tomlkit.dump(self._data, f) 

170 

171 def _get_property( 

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

173 ) -> Any: 

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

175 if isinstance(key, IgnoreKey): 

176 return None 

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

178 full_path = self._section + key_path 

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

180 

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

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

183 if isinstance(key, IgnoreKey): 

184 return 

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

186 

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

188 self._get_property(key_path, remove=True) 

189 return 

190 

191 # get the tomlkit object of the section 

192 dat = self._get_property([]) 

193 

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

195 curr = dat 

196 for path_key in key_path[:-1]: 

197 if path_key not in curr: 

198 curr.add(path_key, tomlkit.table()) 

199 curr = curr[path_key] 

200 

201 # Handle arrays with proper formatting 

202 if isinstance(value, list): 

203 array = tomlkit.array() 

204 array.extend(value) 

205 array.multiline(True) 

206 # Ensure whitespace after commas in inline tables 

207 for item in array: 

208 if isinstance(item, InlineTable): 

209 # Rebuild the inline table with desired formatting 

210 formatted_item = tomlkit.inline_table() 

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

212 formatted_item[k] = v 

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

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

215 curr[key_path[-1]] = array 

216 else: 

217 curr[key_path[-1]] = value 

218 

219 

220class Poetry(PyprojectCommon): 

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

222 

223 def __init__( 

224 self, 

225 path: Path, 

226 pass_validation: bool | None = False, 

227 version: int | None = 1, 

228 ): 

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

230 

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

232 """ 

233 self._poetry_version = version or 1 

234 v2_mappings = { 

235 "homepage": ["urls", "homepage"], 

236 "repository": ["urls", "repository"], 

237 "documentation": ["urls", "documentation"], 

238 } 

239 if version == 1: 

240 super().__init__( 

241 path, 

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

243 model_cls=PoetryConfig, 

244 pass_validation=pass_validation, 

245 ) 

246 else: 

247 super().__init__( 

248 path, 

249 section=["project"], 

250 model_cls=PoetryConfig, 

251 pass_validation=pass_validation, 

252 direct_mappings=v2_mappings, 

253 ) 

254 

255 @staticmethod 

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

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

258 if poetry_version == 1: 

259 return person.to_name_email_string() 

260 else: 

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

262 if person.email: 

263 response["email"] = person.email 

264 return response 

265 

266 @staticmethod 

267 def _to_person( 

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

269 ) -> Person | Entity | None: 

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

271 if isinstance(person_obj, dict): 

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

273 if "email" in person_obj: 

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

275 person_obj = temp 

276 try: 

277 return Person.from_name_email_string(person_obj) 

278 except (ValueError, AttributeError): 

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

280 

281 try: 

282 return Entity.from_name_email_string(person_obj) 

283 except (ValueError, AttributeError): 

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

285 return None 

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 # Call parent sync method 

298 super().sync(metadata) 

299 

300 # Restore original _from_person method 

301 self._from_person = original_from_person # type: ignore 

302 

303 if metadata.license: 

304 self.license = license_expression(metadata.license) 

305 

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

307 if self._poetry_version == 2: 

308 if ( 

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

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

311 ): 

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

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

314 ) 

315 # Move urls section to the end if it exists 

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

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

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

319 

320 

321class Pep621(PyprojectCommon): 

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

323 

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

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

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

327 """ 

328 

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

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

331 

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

333 """ 

334 section = ["project"] 

335 mappings = { 

336 "homepage": ["urls", "homepage"], 

337 "repository": ["urls", "repository"], 

338 "documentation": ["urls", "documentation"], 

339 } 

340 super().__init__( 

341 path, 

342 section=section, 

343 direct_mappings=mappings, 

344 model_cls=Pep621Config, 

345 pass_validation=pass_validation, 

346 ) 

347 

348 @staticmethod 

349 def _from_person(person: Person | Entity): 

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

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

352 if person.email: 

353 response["email"] = person.email 

354 return response 

355 

356 @staticmethod 

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

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

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

360 # we only compare on full_name anyway. 

361 if isinstance(person_obj, dict): 

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

363 if "email" in person_obj: 

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

365 person_obj = temp 

366 

367 try: 

368 return Person.from_name_email_string(person_obj) 

369 except (ValueError, AttributeError): 

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

371 

372 try: 

373 return Entity.from_name_email_string(person_obj) 

374 except (ValueError, AttributeError): 

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

376 return None 

377 

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

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

380 super().sync(metadata) 

381 if metadata.license: 

382 self.license = license_expression(metadata.license) 

383 

384 

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

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

387 

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

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

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

391 cannot tell, and assume Poetry as before. 

392 """ 

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

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

395 

396 

397# ---- 

398 

399 

400class Pyproject(wrapt.ObjectProxy): 

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

402 

403 __wrapped__: Pep621 | Poetry 

404 

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

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

407 

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

409 not on the configured build backend. 

410 

411 Args: 

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

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

414 

415 Raises: 

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

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

418 

419 """ 

420 data = None 

421 if not path.is_file(): 

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

423 

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

425 data = load(f) 

426 

427 # inspect file to pick suitable project metadata writer 

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

429 has_project = "project" in data 

430 

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

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

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

434 logger.log( 

435 VERBOSE, 

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

437 ) 

438 is_poetry = False 

439 

440 if is_poetry: 

441 if has_project: 

442 logger.log( 

443 VERBOSE, 

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

445 ) 

446 else: 

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

448 self.__wrapped__ = Poetry( 

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

450 ) 

451 elif has_project and not is_poetry: 

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

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

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

455 else: 

456 msg = ( 

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

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

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

460 ) 

461 raise ValueError(msg) 

462 

463 super().__init__(self.__wrapped__)