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

194 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2025-03-10 14:57 +0000

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

2 

3import logging 

4from pathlib import Path 

5from typing import Any, Dict, List, Optional, Union 

6 

7import tomlkit 

8import wrapt 

9from rich.pretty import pretty_repr 

10from tomlkit import load 

11 

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

13from somesy.core.writer import IgnoreKey, ProjectMetadataWriter 

14 

15from .models import License, PoetryConfig, SetuptoolsConfig 

16 

17logger = logging.getLogger("somesy") 

18 

19 

20class PyprojectCommon(ProjectMetadataWriter): 

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

22 

23 def __init__( 

24 self, 

25 path: Path, 

26 *, 

27 section: List[str], 

28 model_cls, 

29 direct_mappings=None, 

30 pass_validation: Optional[bool] = False, 

31 ): 

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

33 

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

35 """ 

36 self._model_cls = model_cls 

37 self._section = section 

38 super().__init__( 

39 path, 

40 create_if_not_exists=False, 

41 direct_mappings=direct_mappings or {}, 

42 pass_validation=pass_validation, 

43 ) 

44 

45 def _load(self) -> None: 

46 """Load pyproject.toml file.""" 

47 with open(self.path) as f: 

48 self._data = tomlkit.load(f) 

49 

50 def _validate(self) -> None: 

51 """Validate poetry config using pydantic class. 

52 

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

54 Pydantic class only used for validation. 

55 """ 

56 if self.pass_validation: 

57 return 

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

59 logger.debug( 

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

61 ) 

62 self._model_cls(**config) 

63 

64 def save(self, path: Optional[Path] = None) -> None: 

65 """Save the pyproject file.""" 

66 path = path or self.path 

67 

68 if not path.is_file(): 

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

70 tomlkit.dump(self._data, f) 

71 return 

72 

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

74 # tomlkit formatting sometimes creates empty lines, dont change if context is not changed 

75 existing_data = f.read() 

76 

77 # remove empty lines 

78 existing_data = existing_data.replace("\n", "") 

79 

80 new_data = tomlkit.dumps(self._data) 

81 new_data = new_data.replace("\n", "") 

82 

83 if existing_data != new_data: 

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

85 tomlkit.dump(self._data, f) 

86 else: 

87 logger.debug("No changes to pyproject.toml file") 

88 

89 def _get_property( 

90 self, key: Union[str, List[str]], *, remove: bool = False, **kwargs 

91 ) -> Optional[Any]: 

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

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

94 full_path = self._section + key_path 

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

96 

97 def _set_property(self, key: Union[str, List[str], IgnoreKey], value: Any) -> None: 

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

99 if isinstance(key, IgnoreKey): 

100 return 

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

102 

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

104 self._get_property(key_path, remove=True) 

105 return 

106 

107 # get the tomlkit object of the section 

108 dat = self._get_property([]) 

109 

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

111 curr = dat 

112 for key in key_path[:-1]: 

113 if key not in curr: 

114 curr.add(key, tomlkit.table()) 

115 curr = curr[key] 

116 

117 # Handle arrays with proper formatting 

118 if isinstance(value, list): 

119 array = tomlkit.array() 

120 array.extend(value) 

121 array.multiline(True) 

122 curr[key_path[-1]] = array 

123 else: 

124 curr[key_path[-1]] = value 

125 

126 

127class Poetry(PyprojectCommon): 

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

129 

130 def __init__( 

131 self, 

132 path: Path, 

133 pass_validation: Optional[bool] = False, 

134 version: Optional[int] = 1, 

135 ): 

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

137 

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

139 """ 

140 self._poetry_version = version 

141 v2_mappings = { 

142 "homepage": ["urls", "homepage"], 

143 "repository": ["urls", "repository"], 

144 "documentation": ["urls", "documentation"], 

145 "license": ["license", "text"], 

146 } 

147 if version == 1: 

148 super().__init__( 

149 path, 

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

151 model_cls=PoetryConfig, 

152 pass_validation=pass_validation, 

153 ) 

154 else: 

155 super().__init__( 

156 path, 

157 section=["project"], 

158 model_cls=PoetryConfig, 

159 pass_validation=pass_validation, 

160 direct_mappings=v2_mappings, 

161 ) 

162 

163 @staticmethod 

164 def _from_person(person: Union[Person, Entity], poetry_version: int = 1): 

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

166 if poetry_version == 1: 

167 return person.to_name_email_string() 

168 else: 

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

170 if person.email: 

171 response["email"] = person.email 

172 return response 

173 

174 @staticmethod 

175 def _to_person( 

176 person: Union[str, Dict[str, str]], 

177 ) -> Optional[Union[Person, Entity]]: 

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

179 if isinstance(person, dict): 

180 temp = str(person["name"]) 

181 if "email" in person: 

182 temp = f"{temp} <{person['email']}>" 

183 person = temp 

184 try: 

185 return Person.from_name_email_string(person) 

186 except (ValueError, AttributeError): 

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

188 

189 try: 

190 return Entity.from_name_email_string(person) 

191 except (ValueError, AttributeError): 

192 logger.warning(f"Cannot convert {person} to Entity.") 

193 return None 

194 

195 @property 

196 def license(self) -> Optional[Union[License, str]]: 

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

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

199 if self._poetry_version == 1: 

200 return raw_license 

201 if raw_license is None: 

202 return None 

203 if isinstance(raw_license, str): 

204 return License(text=raw_license) 

205 return raw_license 

206 

207 @license.setter 

208 def license(self, value: Union[License, str]) -> None: 

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

210 # if version is 1, set license as str 

211 if self._poetry_version == 1: 

212 self._set_property(["license"], value) 

213 else: 

214 # if version is 2 and str, set as text 

215 if isinstance(value, str): 

216 self._set_property(["license"], {"text": value}) 

217 else: 

218 self._set_property(["license"], value) 

219 

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

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

222 # Store original _from_person method 

223 original_from_person = self._from_person 

224 

225 # Override _from_person to include poetry_version 

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

227 person, poetry_version=self._poetry_version 

228 ) 

229 

230 # Call parent sync method 

231 super().sync(metadata) 

232 

233 # Restore original _from_person method 

234 self._from_person = original_from_person # type: ignore 

235 

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

237 if self._poetry_version == 2: 

238 # if license field has text, or file, make it inline table of tomlkit 

239 if self._get_property(["license"]) is not None: 

240 license_value = self._get_property(["license"]) 

241 # Create and populate inline table 

242 inline_table = tomlkit.inline_table() 

243 if isinstance(license_value, str): 

244 inline_table["text"] = license_value 

245 elif isinstance(license_value, dict): 

246 if "text" in license_value: 

247 inline_table["text"] = license_value["text"] 

248 elif "file" in license_value: 

249 inline_table["file"] = license_value["file"] 

250 elif hasattr(license_value, "text"): 

251 inline_table["text"] = license_value.text 

252 elif hasattr(license_value, "file"): 

253 inline_table["file"] = license_value.file 

254 

255 # Create a new table with the same structure 

256 table = tomlkit.table() 

257 table.add("license", inline_table) 

258 if "license" in self._data["project"]: 

259 # Copy the whitespace/formatting from the existing table 

260 table.trivia.indent = self._data["project"]["license"].trivia.indent 

261 table.trivia.comment_ws = self._data["project"][ 

262 "license" 

263 ].trivia.comment_ws 

264 table.trivia.comment = self._data["project"][ 

265 "license" 

266 ].trivia.comment 

267 table.trivia.trail = self._data["project"]["license"].trivia.trail 

268 

269 self._data["project"]["license"] = table["license"] 

270 

271 # Move urls section to the end if it exists 

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

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

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

275 

276 

277class SetupTools(PyprojectCommon): 

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

279 

280 def __init__(self, path: Path, pass_validation: Optional[bool] = False): 

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

282 

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

284 """ 

285 section = ["project"] 

286 mappings = { 

287 "homepage": ["urls", "homepage"], 

288 "repository": ["urls", "repository"], 

289 "documentation": ["urls", "documentation"], 

290 "license": ["license", "text"], 

291 } 

292 super().__init__( 

293 path, 

294 section=section, 

295 direct_mappings=mappings, 

296 model_cls=SetuptoolsConfig, 

297 pass_validation=pass_validation, 

298 ) 

299 

300 @staticmethod 

301 def _from_person(person: Person): 

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

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

304 if person.email: 

305 response["email"] = person.email 

306 return response 

307 

308 @staticmethod 

309 def _to_person(person: Union[str, dict]) -> Optional[Union[Entity, Person]]: 

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

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

312 # we only compare on full_name anyway. 

313 if isinstance(person, dict): 

314 temp = str(person["name"]) 

315 if "email" in person: 

316 temp = f"{temp} <{person['email']}>" 

317 person = temp 

318 

319 try: 

320 return Person.from_name_email_string(person) 

321 except (ValueError, AttributeError): 

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

323 

324 try: 

325 return Entity.from_name_email_string(person) 

326 except (ValueError, AttributeError): 

327 logger.warning(f"Cannot convert {person} to Entity.") 

328 return None 

329 

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

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

332 super().sync(metadata) 

333 

334 # if license field has both text and file, remove file 

335 if ( 

336 self._get_property(["license", "file"]) is not None 

337 and self._get_property(["license", "text"]) is not None 

338 ): 

339 # delete license file property 

340 self._data["project"]["license"].pop("file") 

341 

342 

343# ---- 

344 

345 

346class Pyproject(wrapt.ObjectProxy): 

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

348 

349 __wrapped__: Union[SetupTools, Poetry] 

350 

351 def __init__(self, path: Path, pass_validation: Optional[bool] = False): 

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

353 

354 Args: 

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

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

357 

358 Raises: 

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

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

361 

362 """ 

363 data = None 

364 if not path.is_file(): 

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

366 

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

368 data = load(f) 

369 

370 # inspect file to pick suitable project metadata writer 

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

372 has_project = "project" in data 

373 

374 if is_poetry: 

375 if has_project: 

376 logger.verbose( 

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

378 ) 

379 else: 

380 logger.verbose("Found Poetry 1.x metadata in pyproject.toml") 

381 self.__wrapped__ = Poetry( 

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

383 ) 

384 elif has_project and not is_poetry: 

385 logger.verbose("Found setuptools-based metadata in pyproject.toml") 

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

387 else: 

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

389 raise ValueError(msg) 

390 

391 super().__init__(self.__wrapped__)