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

171 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2025-03-14 13:02 +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 with open(path, "w") as f: 

69 tomlkit.dump(self._data, f) 

70 

71 def _get_property( 

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

73 ) -> Optional[Any]: 

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

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

76 full_path = self._section + key_path 

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

78 

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

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

81 if isinstance(key, IgnoreKey): 

82 return 

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

84 

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

86 self._get_property(key_path, remove=True) 

87 return 

88 

89 # get the tomlkit object of the section 

90 dat = self._get_property([]) 

91 

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

93 curr = dat 

94 for key in key_path[:-1]: 

95 if key not in curr: 

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

97 curr = curr[key] 

98 

99 # Handle arrays with proper formatting 

100 if isinstance(value, list): 

101 array = tomlkit.array() 

102 array.extend(value) 

103 array.multiline(True) 

104 # Ensure whitespace after commas in inline tables 

105 for item in array: 

106 if isinstance(item, tomlkit.items.InlineTable): 

107 # Rebuild the inline table with desired formatting 

108 formatted_item = tomlkit.inline_table() 

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

110 formatted_item[k] = v 

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

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

113 curr[key_path[-1]] = array 

114 else: 

115 curr[key_path[-1]] = value 

116 

117 

118class Poetry(PyprojectCommon): 

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

120 

121 def __init__( 

122 self, 

123 path: Path, 

124 pass_validation: Optional[bool] = False, 

125 version: Optional[int] = 1, 

126 ): 

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

128 

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

130 """ 

131 self._poetry_version = version 

132 v2_mappings = { 

133 "homepage": ["urls", "homepage"], 

134 "repository": ["urls", "repository"], 

135 "documentation": ["urls", "documentation"], 

136 "license": ["license", "text"], 

137 } 

138 if version == 1: 

139 super().__init__( 

140 path, 

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

142 model_cls=PoetryConfig, 

143 pass_validation=pass_validation, 

144 ) 

145 else: 

146 super().__init__( 

147 path, 

148 section=["project"], 

149 model_cls=PoetryConfig, 

150 pass_validation=pass_validation, 

151 direct_mappings=v2_mappings, 

152 ) 

153 

154 @staticmethod 

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

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

157 if poetry_version == 1: 

158 return person.to_name_email_string() 

159 else: 

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

161 if person.email: 

162 response["email"] = person.email 

163 return response 

164 

165 @staticmethod 

166 def _to_person( 

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

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

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

170 if isinstance(person, dict): 

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

172 if "email" in person: 

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

174 person = temp 

175 try: 

176 return Person.from_name_email_string(person) 

177 except (ValueError, AttributeError): 

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

179 

180 try: 

181 return Entity.from_name_email_string(person) 

182 except (ValueError, AttributeError): 

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

184 return None 

185 

186 @property 

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

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

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

190 if self._poetry_version == 1: 

191 return raw_license 

192 if raw_license is None: 

193 return None 

194 if isinstance(raw_license, str): 

195 return License(text=raw_license) 

196 return raw_license 

197 

198 @license.setter 

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

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

201 # if version is 1, set license as str 

202 if self._poetry_version == 1: 

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

204 else: 

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

206 if isinstance(value, str): 

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

208 else: 

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

210 

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

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

213 # Store original _from_person method 

214 original_from_person = self._from_person 

215 

216 # Override _from_person to include poetry_version 

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

218 person, poetry_version=self._poetry_version 

219 ) 

220 

221 # Call parent sync method 

222 super().sync(metadata) 

223 

224 # Restore original _from_person method 

225 self._from_person = original_from_person # type: ignore 

226 

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

228 if self._poetry_version == 2: 

229 if "description" in self._data["project"]: 

230 if "\n" in self._data["project"]["description"]: 

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

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

233 ) 

234 # Move urls section to the end if it exists 

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

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

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

238 

239 

240class SetupTools(PyprojectCommon): 

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

242 

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

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

245 

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

247 """ 

248 section = ["project"] 

249 mappings = { 

250 "homepage": ["urls", "homepage"], 

251 "repository": ["urls", "repository"], 

252 "documentation": ["urls", "documentation"], 

253 "license": ["license", "text"], 

254 } 

255 super().__init__( 

256 path, 

257 section=section, 

258 direct_mappings=mappings, 

259 model_cls=SetuptoolsConfig, 

260 pass_validation=pass_validation, 

261 ) 

262 

263 @staticmethod 

264 def _from_person(person: Person): 

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

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

267 if person.email: 

268 response["email"] = person.email 

269 return response 

270 

271 @staticmethod 

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

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

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

275 # we only compare on full_name anyway. 

276 if isinstance(person, dict): 

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

278 if "email" in person: 

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

280 person = temp 

281 

282 try: 

283 return Person.from_name_email_string(person) 

284 except (ValueError, AttributeError): 

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

286 

287 try: 

288 return Entity.from_name_email_string(person) 

289 except (ValueError, AttributeError): 

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

291 return None 

292 

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

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

295 super().sync(metadata) 

296 

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

298 if ( 

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

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

301 ): 

302 # delete license file property 

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

304 

305 

306# ---- 

307 

308 

309class Pyproject(wrapt.ObjectProxy): 

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

311 

312 __wrapped__: Union[SetupTools, Poetry] 

313 

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

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

316 

317 Args: 

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

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

320 

321 Raises: 

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

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

324 

325 """ 

326 data = None 

327 if not path.is_file(): 

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

329 

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

331 data = load(f) 

332 

333 # inspect file to pick suitable project metadata writer 

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

335 has_project = "project" in data 

336 

337 if is_poetry: 

338 if has_project: 

339 logger.verbose( 

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

341 ) 

342 else: 

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

344 self.__wrapped__ = Poetry( 

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

346 ) 

347 elif has_project and not is_poetry: 

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

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

350 else: 

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

352 raise ValueError(msg) 

353 

354 super().__init__(self.__wrapped__)