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

199 statements  

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

1"""Pyproject writers for setuptools and poetry.""" 

2 

3import logging 

4from pathlib import Path 

5from typing import Any 

6 

7import tomlkit 

8import wrapt 

9from rich.pretty import pretty_repr 

10from tomlkit import load 

11from tomlkit.items import InlineTable 

12 

13from somesy.core.log import VERBOSE 

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

15from somesy.core.writer import IgnoreKey, ProjectMetadataWriter 

16 

17from .models import License, PoetryConfig, SetuptoolsConfig 

18 

19logger = logging.getLogger("somesy") 

20 

21 

22def license_expression(licenses) -> str: 

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

24 return " OR ".join( 

25 str(license) 

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

27 ) 

28 

29 

30class PyprojectCommon(ProjectMetadataWriter): 

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

32 

33 def __init__( 

34 self, 

35 path: Path, 

36 *, 

37 section: list[str], 

38 model_cls, 

39 direct_mappings=None, 

40 pass_validation: bool | None = False, 

41 ): 

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

43 

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

45 """ 

46 self._model_cls = model_cls 

47 self._section = section 

48 super().__init__( 

49 path, 

50 create_if_not_exists=False, 

51 direct_mappings=direct_mappings or {}, 

52 pass_validation=pass_validation, 

53 ) 

54 

55 @property 

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

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

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

59 

60 @property 

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

62 """Return the version of the project.""" 

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

64 

65 @version.setter 

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

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

68 if "version" in self._dynamic_fields: 

69 if version: 

70 logger.warning( 

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

72 ) 

73 return 

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

75 

76 @property 

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

78 """Return the description of the project.""" 

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

80 

81 @description.setter 

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

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

84 if "description" in self._dynamic_fields: 

85 if description: 

86 logger.warning( 

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

88 ) 

89 return 

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

91 

92 def _load(self) -> None: 

93 """Load pyproject.toml file.""" 

94 with open(self.path) as f: 

95 self._data = tomlkit.load(f) 

96 

97 def _validate(self) -> None: 

98 """Validate poetry config using pydantic class. 

99 

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

101 Pydantic class only used for validation. 

102 """ 

103 if self.pass_validation: 

104 return 

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

106 logger.debug( 

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

108 ) 

109 self._model_cls(**config) 

110 

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

112 """Save the pyproject file.""" 

113 path = path or self.path 

114 

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

116 tomlkit.dump(self._data, f) 

117 

118 def _get_property( 

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

120 ) -> Any: 

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

122 if isinstance(key, IgnoreKey): 

123 return None 

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

125 full_path = self._section + key_path 

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

127 

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

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

130 if isinstance(key, IgnoreKey): 

131 return 

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

133 

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

135 self._get_property(key_path, remove=True) 

136 return 

137 

138 # get the tomlkit object of the section 

139 dat = self._get_property([]) 

140 

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

142 curr = dat 

143 for path_key in key_path[:-1]: 

144 if path_key not in curr: 

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

146 curr = curr[path_key] 

147 

148 # Handle arrays with proper formatting 

149 if isinstance(value, list): 

150 array = tomlkit.array() 

151 array.extend(value) 

152 array.multiline(True) 

153 # Ensure whitespace after commas in inline tables 

154 for item in array: 

155 if isinstance(item, InlineTable): 

156 # Rebuild the inline table with desired formatting 

157 formatted_item = tomlkit.inline_table() 

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

159 formatted_item[k] = v 

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

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

162 curr[key_path[-1]] = array 

163 else: 

164 curr[key_path[-1]] = value 

165 

166 

167class Poetry(PyprojectCommon): 

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

169 

170 def __init__( 

171 self, 

172 path: Path, 

173 pass_validation: bool | None = False, 

174 version: int | None = 1, 

175 ): 

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

177 

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

179 """ 

180 self._poetry_version = version or 1 

181 v2_mappings = { 

182 "homepage": ["urls", "homepage"], 

183 "repository": ["urls", "repository"], 

184 "documentation": ["urls", "documentation"], 

185 "license": ["license", "text"], 

186 } 

187 if version == 1: 

188 super().__init__( 

189 path, 

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

191 model_cls=PoetryConfig, 

192 pass_validation=pass_validation, 

193 ) 

194 else: 

195 super().__init__( 

196 path, 

197 section=["project"], 

198 model_cls=PoetryConfig, 

199 pass_validation=pass_validation, 

200 direct_mappings=v2_mappings, 

201 ) 

202 

203 @staticmethod 

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

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

206 if poetry_version == 1: 

207 return person.to_name_email_string() 

208 else: 

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

210 if person.email: 

211 response["email"] = person.email 

212 return response 

213 

214 @staticmethod 

215 def _to_person( 

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

217 ) -> Person | Entity | None: 

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

219 if isinstance(person_obj, dict): 

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

221 if "email" in person_obj: 

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

223 person_obj = temp 

224 try: 

225 return Person.from_name_email_string(person_obj) 

226 except (ValueError, AttributeError): 

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

228 

229 try: 

230 return Entity.from_name_email_string(person_obj) 

231 except (ValueError, AttributeError): 

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

233 return None 

234 

235 @property 

236 def license(self) -> License | str | None: 

237 """Get license from pyproject.toml file.""" 

238 raw_license = self._get_property(["license"]) 

239 if self._poetry_version == 1: 

240 return raw_license 

241 if raw_license is None: 

242 return None 

243 if isinstance(raw_license, str): 

244 return raw_license 

245 return raw_license 

246 

247 @license.setter 

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

249 """Set license in pyproject.toml file.""" 

250 # if version is 1, set license as str 

251 if self._poetry_version == 1: 

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

253 else: 

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

255 

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

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

258 # Store original _from_person method 

259 original_from_person = self._from_person 

260 

261 # Override _from_person to include poetry_version 

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

263 person, poetry_version=self._poetry_version 

264 ) 

265 

266 # Call parent sync method 

267 super().sync(metadata) 

268 

269 # Restore original _from_person method 

270 self._from_person = original_from_person # type: ignore 

271 

272 if metadata.license: 

273 self.license = license_expression(metadata.license) 

274 

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

276 if self._poetry_version == 2: 

277 if ( 

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

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

280 ): 

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

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

283 ) 

284 # Move urls section to the end if it exists 

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

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

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

288 

289 

290class SetupTools(PyprojectCommon): 

291 """Setuptools config file handler parsed from setup.cfg.""" 

292 

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

294 """Setuptools config file handler parsed from pyproject.toml. 

295 

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

297 """ 

298 section = ["project"] 

299 mappings = { 

300 "homepage": ["urls", "homepage"], 

301 "repository": ["urls", "repository"], 

302 "documentation": ["urls", "documentation"], 

303 } 

304 super().__init__( 

305 path, 

306 section=section, 

307 direct_mappings=mappings, 

308 model_cls=SetuptoolsConfig, 

309 pass_validation=pass_validation, 

310 ) 

311 

312 @staticmethod 

313 def _from_person(person: Person | Entity): 

314 """Convert project metadata person object to setuptools dict for person format.""" 

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

316 if person.email: 

317 response["email"] = person.email 

318 return response 

319 

320 @staticmethod 

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

322 """Parse setuptools person string to a Person/Entity.""" 

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

324 # we only compare on full_name anyway. 

325 if isinstance(person_obj, dict): 

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

327 if "email" in person_obj: 

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

329 person_obj = temp 

330 

331 try: 

332 return Person.from_name_email_string(person_obj) 

333 except (ValueError, AttributeError): 

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

335 

336 try: 

337 return Entity.from_name_email_string(person_obj) 

338 except (ValueError, AttributeError): 

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

340 return None 

341 

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

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

344 super().sync(metadata) 

345 if metadata.license: 

346 self.license = license_expression(metadata.license) 

347 

348 

349# ---- 

350 

351 

352class Pyproject(wrapt.ObjectProxy): 

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

354 

355 __wrapped__: SetupTools | Poetry 

356 

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

358 """Pyproject wrapper class. Wraps either setuptools or poetry. 

359 

360 Args: 

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

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

363 

364 Raises: 

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

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

367 

368 """ 

369 data = None 

370 if not path.is_file(): 

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

372 

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

374 data = load(f) 

375 

376 # inspect file to pick suitable project metadata writer 

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

378 has_project = "project" in data 

379 

380 if is_poetry: 

381 if has_project: 

382 logger.log( 

383 VERBOSE, 

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

385 ) 

386 else: 

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

388 self.__wrapped__ = Poetry( 

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

390 ) 

391 elif has_project and not is_poetry: 

392 logger.log(VERBOSE, "Found setuptools-based metadata in pyproject.toml") 

393 self.__wrapped__ = SetupTools(path, pass_validation=pass_validation) 

394 else: 

395 msg = "The pyproject.toml file is ambiguous. For Poetry projects, ensure [tool.poetry] section exists. For setuptools, ensure [project] section exists without [tool.poetry]" 

396 raise ValueError(msg) 

397 

398 super().__init__(self.__wrapped__)