Coverage for src/somesy/core/writer.py: 95%

258 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-11-03 13:31 +0000

1"""Project metadata writer base-class.""" 

2 

3import logging 

4from abc import ABC, abstractmethod 

5from collections.abc import Sequence 

6from pathlib import Path 

7from typing import Any 

8 

9import tomlkit 

10from tomlkit.items import AoT, Array, InlineTable 

11 

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

13 

14logger = logging.getLogger("somesy") 

15 

16 

17def toml_array(value: list) -> Array: 

18 """Build a multiline tomlkit array, with spaced inline tables.""" 

19 array = tomlkit.array() 

20 array.extend(value) 

21 array.multiline(True) 

22 # Ensure whitespace after commas in inline tables 

23 for item in array: 

24 if isinstance(item, InlineTable): 

25 # Rebuild the inline table with desired formatting 

26 formatted_item = tomlkit.inline_table() 

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

28 formatted_item[k] = v 

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

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

31 return array 

32 

33 

34def multiline_description(table: Any) -> None: 

35 """Store a description containing line breaks as a multiline TOML string.""" 

36 if "description" in table and "\n" in table["description"]: 

37 table["description"] = tomlkit.string(table["description"], multiline=True) 

38 

39 

40def format_toml_data(data: Any) -> None: 

41 """Prepare a tomlkit document for saving: multiline description, multiline arrays.""" 

42 multiline_description(data) 

43 

44 # Handle arrays with proper formatting (snapshot, since we assign while iterating) 

45 for key, value in list(data.items()): 

46 if isinstance(value, list) and not isinstance(value, AoT): 

47 data[key] = toml_array(value) 

48 else: 

49 data[key] = value 

50 

51 

52class IgnoreKey: 

53 """Special marker to be passed for dropping a key from serialization.""" 

54 

55 

56FieldKeyMapping = dict[str, str | list[str] | IgnoreKey] 

57"""Type to be used for the dict passed as `direct_mappings`.""" 

58 

59DictLike = Any 

60"""Dict-like that supports getitem, setitem, delitem, etc. 

61 

62NOTE: This should be probably turned into a proper protocol. 

63""" 

64 

65 

66def license_expression(licenses) -> str: 

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

68 return " OR ".join( 

69 str(license) 

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

71 ) 

72 

73 

74class ProjectMetadataWriter(ABC): 

75 """Base class for Project Metadata Output Wrapper. 

76 

77 All supported output formats are implemented as subclasses. 

78 """ 

79 

80 def __init__( 

81 self, 

82 path: Path, 

83 *, 

84 create_if_not_exists: bool | None = False, 

85 direct_mappings: FieldKeyMapping | None = None, 

86 merge: bool | None = False, 

87 pass_validation: bool | None = False, 

88 ) -> None: 

89 """Initialize the Project Metadata Output Wrapper. 

90 

91 Use the `direct_mappings` dict to define 

92 format-specific location for certain fields, 

93 if no additional processing is needed that 

94 requires a customized setter. 

95 

96 Args: 

97 path: Path to target output file. 

98 create_if_not_exists: Create an empty file if not exists. Defaults to False. 

99 direct_mappings: Dict with direct mappings of keys between somesy and target 

100 merge: Merge the output file with an existing file. Defaults to False. 

101 pass_validation: Pass validation for all output files. Defaults to False. 

102 

103 """ 

104 self._data: DictLike = {} 

105 self.path = path if isinstance(path, Path) else Path(path) 

106 self.create_if_not_exists = create_if_not_exists 

107 self.direct_mappings = direct_mappings or {} 

108 self.merge = merge 

109 self.pass_validation = pass_validation 

110 if self.path.is_file(): 

111 self._load() 

112 if not self.pass_validation: 

113 self._validate() 

114 else: 

115 if self.create_if_not_exists: 

116 self._init_new_file() 

117 self._load() 

118 else: 

119 raise FileNotFoundError(f"The file {self.path} does not exist.") 

120 

121 def _init_new_file(self) -> None: 

122 """Create an new suitable target file. 

123 

124 Override to initialize file with minimal contents, if needed. 

125 Make sure to set `self._data` to match the contents. 

126 """ 

127 self.path.touch() 

128 

129 @abstractmethod 

130 def _load(self): 

131 """Load the output file and validate it. 

132 

133 Implement this method so that it loads the file `self.path` 

134 into the `self._data` dict. 

135 

136 The file is guaranteed to exist. 

137 """ 

138 

139 @abstractmethod 

140 def _validate(self) -> None: 

141 """Validate the target file data. 

142 

143 Implement this method so that it checks 

144 the validity of the metadata (relevant to somesy) 

145 in that file and raises exceptions on failure. 

146 """ 

147 

148 @abstractmethod 

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

150 """Save the output file to the given path. 

151 

152 Implement this in a way that will carefully 

153 update the target file with new metadata 

154 without destroying its other contents or structure. 

155 """ 

156 

157 def _get_property( 

158 self, 

159 key: str | list[str] | IgnoreKey, 

160 *, 

161 remove: bool = False, 

162 ) -> Any: 

163 """Get a property from the data. 

164 

165 Override this to e.g. rewrite the retrieved key 

166 (e.g. if everything relevant is in some subobject). 

167 

168 Args: 

169 key: Name of the key or sequence of multiple keys to retrieve the value. 

170 remove: If True, will remove the retrieved value and clean up the dict. 

171 

172 """ 

173 if isinstance(key, IgnoreKey): 

174 return None 

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

176 

177 curr: Any = self._data 

178 seq = [curr] 

179 for k in key_path: 

180 curr = curr.get(k) 

181 seq.append(curr) 

182 if curr is None: 

183 return None 

184 

185 if remove: 

186 seq.pop() 

187 del seq[-1][key_path[-1]] # remove leaf value 

188 # clean up the tree 

189 for path_key, dct in reversed( 

190 list(zip(key_path[:-1], seq[:-1], strict=False)) 

191 ): 

192 if not dct.get(path_key): 

193 del dct[path_key] 

194 

195 return curr 

196 

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

198 """Set a property in the data. 

199 

200 Note if there are lists along the path, they are cleared out. 

201 

202 Override this to e.g. rewrite the retrieved key 

203 (e.g. if everything relevant is in some subobject). 

204 """ 

205 if isinstance(key, IgnoreKey): 

206 return 

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

208 

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

210 self._get_property(key_path, remove=True) 

211 return 

212 

213 # create path on the fly if needed 

214 curr = self._data 

215 for path_key in key_path[:-1]: 

216 if path_key not in curr: 

217 curr[path_key] = {} 

218 curr = curr[path_key] 

219 

220 curr[key_path[-1]] = value 

221 

222 # ---- 

223 # special handling for person metadata 

224 

225 def _merge_person_metadata( 

226 self, 

227 old: Sequence[Person | Entity], 

228 new: Sequence[Person | Entity], 

229 ) -> list[Person | Entity]: 

230 """Update metadata of a list of persons. 

231 

232 Will identify people based on orcid, email or full name. 

233 

234 If old list has same person listed multiple times, 

235 the resulting list will too (we cannot correctly merge for external formats.) 

236 """ 

237 new_people = [] # list for new people (e.g. added authors) 

238 # flag, meaning "person was not removed" 

239 still_exists = [False for i in range(len(old))] 

240 # copies of old person data, to be modified 

241 modified_people = [p.model_copy() for p in old] 

242 

243 # try to match new people to existing old ones 

244 # (inefficient, but author list are not that long usually) 

245 for person_meta in new: 

246 person_update = person_meta.model_dump() 

247 person_existed = False 

248 for i in range(len(modified_people)): 

249 person = modified_people[i] 

250 if not person.same_person(person_meta): 

251 continue 

252 

253 # not new person (-> will not append new record) 

254 person_existed = True 

255 # still exists (-> will not be removed from list) 

256 still_exists[i] = True 

257 

258 # if there were changes -> update person 

259 overlapping_fields = person.model_dump( 

260 include=set(person_update.keys()) 

261 ) 

262 if person_update != overlapping_fields: 

263 modified_people[i] = person.model_copy(update=person_update) 

264 

265 # show effective update in debug log 

266 old_fmt = self._from_person(person) 

267 new_fmt = self._from_person(modified_people[i]) 

268 if old_fmt != new_fmt: 

269 logger.debug(f"Updating person\n{old_fmt}\nto\n{new_fmt}") 

270 

271 if not person_existed: 

272 new_people.append(person_meta) 

273 

274 # show added and removed people in debug log 

275 removed_people = [old[i] for i in range(len(old)) if not still_exists[i]] 

276 for person in removed_people: 

277 logger.debug(f"Removing person\n{self._from_person(person)}") 

278 for person in new_people: 

279 logger.debug(f"Adding person\n{self._from_person(person)}") 

280 

281 # return updated list of (still existing) people, 

282 # and all new people coming after them. 

283 existing_modified = [ 

284 modified_people[i] for i in range(len(old)) if still_exists[i] 

285 ] 

286 return existing_modified + new_people 

287 

288 def _sync_person_list( 

289 self, old: list[Any], new: Sequence[Person | Entity] 

290 ) -> list[Any]: 

291 """Sync a list of persons with new metadata. 

292 

293 Args: 

294 old (List[Any]): list of persons in format-specific representation 

295 new (List[Person]): list of persons in somesy representation 

296 

297 Returns: 

298 List[Any]: updated list of persons in format-specific representation 

299 

300 """ 

301 old_people: list[Person | Entity] = self._parse_people(old) 

302 

303 # check if people are unique 

304 def filter_unique( 

305 people: Sequence[Person | Entity], 

306 ) -> list[Person | Entity]: 

307 """Filter out duplicate people from a list.""" 

308 if people is None or len(people) == 0: 

309 return [] 

310 

311 unique_people: list[Person | Entity] = [] 

312 # use same_person method to check if people are unique 

313 for person in people: 

314 if not any(person.same_person(p) for p in unique_people): 

315 unique_people.append(person) 

316 

317 return unique_people 

318 

319 old_people_unique = filter_unique(old_people) 

320 new_people_unique = filter_unique(new) 

321 

322 return self._merge_person_metadata(old_people_unique, new_people_unique) 

323 

324 def _sync_authors(self, metadata: ProjectMetadata) -> None: 

325 """Sync output file authors with authors from metadata. 

326 

327 This method is existing for the publication_author special case 

328 when synchronizing to CITATION.cff. 

329 """ 

330 if self.authors is None or len(self.authors) == 0: 

331 self.authors = metadata.authors() 

332 else: 

333 self.authors = self._sync_person_list(self.authors, metadata.authors()) 

334 

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

336 """Sync output file with other metadata files.""" 

337 if metadata.name is not None: 

338 self.name = metadata.name 

339 if metadata.description is not None: 

340 self.description = metadata.description 

341 

342 if metadata.version: 

343 self.version = metadata.version 

344 

345 if metadata.keywords: 

346 self.keywords = metadata.keywords 

347 

348 if metadata.authors(): 

349 self._sync_authors(metadata) 

350 self.maintainers = self._sync_person_list( 

351 self.maintainers, metadata.maintainers() 

352 ) 

353 

354 if licenses := metadata.license: 

355 self.license = license_expression(licenses) 

356 

357 # keep existing values in the target file if somesy has none 

358 if metadata.homepage: 

359 self.homepage = str(metadata.homepage) 

360 if metadata.repository: 

361 self.repository = str(metadata.repository) 

362 if metadata.documentation: 

363 self.documentation = str(metadata.documentation) 

364 

365 def harvest_metadata(self) -> dict[str, Any]: 

366 """Return metadata read from this endpoint in Somesy model terms.""" 

367 data: dict[str, Any] = {} 

368 for field in ( 

369 "name", 

370 "version", 

371 "description", 

372 "license", 

373 "homepage", 

374 "repository", 

375 "documentation", 

376 "keywords", 

377 ): 

378 try: 

379 value = getattr(self, field) 

380 except (KeyError, TypeError): 

381 continue 

382 if value not in (None, [], ""): 

383 data[field] = value 

384 

385 people: list[Person] = [] 

386 entities: list[Entity] = [] 

387 for role in ("authors", "maintainers", "contributors"): 

388 for person in self._parse_people(getattr(self, role) or []): 

389 updates = ( 

390 {"author": True, "publication_author": True} 

391 if role == "authors" 

392 else {} 

393 ) 

394 updates.update({"maintainer": True} if role == "maintainers" else {}) 

395 person = person.model_copy(update=updates) 

396 if isinstance(person, Entity): 

397 entities.append(person) 

398 else: 

399 people.append(person) 

400 

401 if people: 

402 data["people"] = people 

403 if entities: 

404 data["entities"] = entities 

405 return data 

406 

407 @staticmethod 

408 @abstractmethod 

409 def _from_person(person: Person | Entity) -> Any: 

410 """Convert a `Person` or `Entity` object into suitable target format.""" 

411 

412 @staticmethod 

413 @abstractmethod 

414 def _to_person(person_obj: Any) -> Person | Entity | None: 

415 """Convert an object representing a person into a `Person` or `Entity` object.""" 

416 

417 @classmethod 

418 def _parse_people(cls, people: list[Any] | None) -> list[Person | Entity]: 

419 """Return a list of Persons and Entities parsed from list of format-specific people representations.""" 

420 # remove None values 

421 return [ 

422 person 

423 for p in people or [] 

424 if p is not None 

425 if (person := cls._to_person(p)) is not None 

426 ] 

427 

428 # ---- 

429 # individual magic getters and setters 

430 

431 def _get_key(self, key: str) -> str | list[str] | IgnoreKey: 

432 """Get a key itself.""" 

433 return self.direct_mappings.get(key) or key 

434 

435 @property 

436 def name(self): 

437 """Return the name of the project.""" 

438 return self._get_property(self._get_key("name")) 

439 

440 @name.setter 

441 def name(self, name: str) -> None: 

442 """Set the name of the project.""" 

443 self._set_property(self._get_key("name"), name) 

444 

445 @property 

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

447 """Return the version of the project.""" 

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

449 

450 @version.setter 

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

452 """Set the version of the project.""" 

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

454 

455 @property 

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

457 """Return the description of the project.""" 

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

459 

460 @description.setter 

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

462 """Set the description of the project.""" 

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

464 

465 @property 

466 def authors(self): 

467 """Return the authors of the project.""" 

468 authors = self._get_property(self._get_key("authors")) 

469 if authors is None or len(authors) == 0: 

470 return [] 

471 

472 # only return authors that can be converted to Person 

473 authors_validated = [ 

474 author for author in authors if self._to_person(author) is not None 

475 ] 

476 return authors_validated 

477 

478 @authors.setter 

479 def authors(self, authors: list[Person | Entity]) -> None: 

480 """Set the authors of the project.""" 

481 authors = [self._from_person(c) for c in authors] 

482 self._set_property(self._get_key("authors"), authors) 

483 

484 @property 

485 def maintainers(self): 

486 """Return the maintainers of the project.""" 

487 maintainers = self._get_property(self._get_key("maintainers")) 

488 if maintainers is None: 

489 return [] 

490 

491 # only return maintainers that can be converted to Person 

492 maintainers_validated = [ 

493 maintainer 

494 for maintainer in maintainers 

495 if self._to_person(maintainer) is not None 

496 ] 

497 return maintainers_validated 

498 

499 @maintainers.setter 

500 def maintainers(self, maintainers: list[Person | Entity]) -> None: 

501 """Set the maintainers of the project.""" 

502 maintainers = [self._from_person(c) for c in maintainers] 

503 self._set_property(self._get_key("maintainers"), maintainers) 

504 

505 @property 

506 def contributors(self): 

507 """Return the contributors of the project.""" 

508 return self._get_property(self._get_key("contributors")) 

509 

510 @contributors.setter 

511 def contributors(self, contributors: list[Person | Entity]) -> None: 

512 """Set the contributors of the project.""" 

513 contributors = [self._from_person(c) for c in contributors] 

514 self._set_property(self._get_key("contributors"), contributors) 

515 

516 @property 

517 def keywords(self) -> list[str] | None: 

518 """Return the keywords of the project.""" 

519 return self._get_property(self._get_key("keywords")) 

520 

521 @keywords.setter 

522 def keywords(self, keywords: list[str]) -> None: 

523 """Set the keywords of the project.""" 

524 self._set_property(self._get_key("keywords"), keywords) 

525 

526 @property 

527 def license(self) -> Any: 

528 """Return the license of the project.""" 

529 return self._get_property(self._get_key("license")) 

530 

531 @license.setter 

532 def license(self, license: Any) -> None: 

533 """Set the license of the project.""" 

534 self._set_property(self._get_key("license"), license) 

535 

536 @property 

537 def homepage(self) -> str | None: 

538 """Return the homepage url of the project.""" 

539 return self._get_property(self._get_key("homepage")) 

540 

541 @homepage.setter 

542 def homepage(self, value: str | None) -> None: 

543 """Set the homepage url of the project.""" 

544 self._set_property(self._get_key("homepage"), value) 

545 

546 @property 

547 def repository(self) -> str | dict | None: 

548 """Return the repository url of the project.""" 

549 return self._get_property(self._get_key("repository")) 

550 

551 @repository.setter 

552 def repository(self, value: str | dict | None) -> None: 

553 """Set the repository url of the project.""" 

554 self._set_property(self._get_key("repository"), value) 

555 

556 @property 

557 def documentation(self) -> str | dict | None: 

558 """Return the documentation url of the project.""" 

559 return self._get_property(self._get_key("documentation")) 

560 

561 @documentation.setter 

562 def documentation(self, value: str | dict | None) -> None: 

563 """Set the documentation url of the project.""" 

564 self._set_property(self._get_key("documentation"), value)