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

230 statements  

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

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

10 

11logger = logging.getLogger("somesy") 

12 

13 

14class IgnoreKey: 

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

16 

17 

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

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

20 

21DictLike = Any 

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

23 

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

25""" 

26 

27 

28class ProjectMetadataWriter(ABC): 

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

30 

31 All supported output formats are implemented as subclasses. 

32 """ 

33 

34 def __init__( 

35 self, 

36 path: Path, 

37 *, 

38 create_if_not_exists: bool | None = False, 

39 direct_mappings: FieldKeyMapping | None = None, 

40 merge: bool | None = False, 

41 pass_validation: bool | None = False, 

42 ) -> None: 

43 """Initialize the Project Metadata Output Wrapper. 

44 

45 Use the `direct_mappings` dict to define 

46 format-specific location for certain fields, 

47 if no additional processing is needed that 

48 requires a customized setter. 

49 

50 Args: 

51 path: Path to target output file. 

52 create_if_not_exists: Create an empty CFF file if not exists. Defaults to True. 

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

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

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

56 

57 """ 

58 self._data: DictLike = {} 

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

60 self.create_if_not_exists = create_if_not_exists 

61 self.direct_mappings = direct_mappings or {} 

62 self.merge = merge 

63 self.pass_validation = pass_validation 

64 if self.path.is_file(): 

65 self._load() 

66 if not self.pass_validation: 

67 self._validate() 

68 else: 

69 if self.create_if_not_exists: 

70 self._init_new_file() 

71 self._load() 

72 else: 

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

74 

75 def _init_new_file(self) -> None: 

76 """Create an new suitable target file. 

77 

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

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

80 """ 

81 self.path.touch() 

82 

83 @abstractmethod 

84 def _load(self): 

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

86 

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

88 into the `self._data` dict. 

89 

90 The file is guaranteed to exist. 

91 """ 

92 

93 @abstractmethod 

94 def _validate(self) -> None: 

95 """Validate the target file data. 

96 

97 Implement this method so that it checks 

98 the validity of the metadata (relevant to somesy) 

99 in that file and raises exceptions on failure. 

100 """ 

101 

102 @abstractmethod 

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

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

105 

106 Implement this in a way that will carefully 

107 update the target file with new metadata 

108 without destroying its other contents or structure. 

109 """ 

110 

111 def _get_property( 

112 self, 

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

114 *, 

115 only_first: bool = False, 

116 remove: bool = False, 

117 ) -> Any: 

118 """Get a property from the data. 

119 

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

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

122 

123 Args: 

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

125 only_first: If True, returns only first entry if the value is a list. 

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

127 

128 """ 

129 if isinstance(key, IgnoreKey): 

130 return None 

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

132 

133 curr: Any = self._data 

134 seq = [curr] 

135 for k in key_path: 

136 curr = curr.get(k) 

137 curr = curr[0] if isinstance(curr, list) and only_first else curr 

138 seq.append(curr) 

139 if curr is None: 

140 return None 

141 

142 if remove: 

143 seq.pop() 

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

145 # clean up the tree 

146 for path_key, dct in reversed( 

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

148 ): 

149 if not dct.get(path_key): 

150 del dct[path_key] 

151 

152 if isinstance(curr, list) and only_first: 

153 return curr[0] 

154 return curr 

155 

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

157 """Set a property in the data. 

158 

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

160 

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

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

163 """ 

164 if isinstance(key, IgnoreKey): 

165 return 

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

167 

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

169 self._get_property(key_path, remove=True) 

170 return 

171 

172 # create path on the fly if needed 

173 curr = self._data 

174 for path_key in key_path[:-1]: 

175 if path_key not in curr: 

176 curr[path_key] = {} 

177 curr = curr[path_key] 

178 

179 curr[key_path[-1]] = value 

180 

181 # ---- 

182 # special handling for person metadata 

183 

184 def _merge_person_metadata( 

185 self, 

186 old: Sequence[Person | Entity], 

187 new: Sequence[Person | Entity], 

188 ) -> list[Person | Entity]: 

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

190 

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

192 

193 If old list has same person listed multiple times, 

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

195 """ 

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

197 # flag, meaning "person was not removed" 

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

199 # copies of old person data, to be modified 

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

201 

202 # try to match new people to existing old ones 

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

204 for person_meta in new: 

205 person_update = person_meta.model_dump() 

206 person_existed = False 

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

208 person = modified_people[i] 

209 if not person.same_person(person_meta): 

210 continue 

211 

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

213 person_existed = True 

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

215 still_exists[i] = True 

216 

217 # if there were changes -> update person 

218 overlapping_fields = person.model_dump( 

219 include=set(person_update.keys()) 

220 ) 

221 if person_update != overlapping_fields: 

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

223 

224 # show effective update in debug log 

225 old_fmt = self._from_person(person) 

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

227 if old_fmt != new_fmt: 

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

229 

230 if not person_existed: 

231 new_people.append(person_meta) 

232 

233 # show added and removed people in debug log 

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

235 for person in removed_people: 

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

237 for person in new_people: 

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

239 

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

241 # and all new people coming after them. 

242 existing_modified = [ 

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

244 ] 

245 return existing_modified + new_people 

246 

247 def _sync_person_list( 

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

249 ) -> list[Any]: 

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

251 

252 Args: 

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

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

255 

256 Returns: 

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

258 

259 """ 

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

261 

262 # check if people are unique 

263 def filter_unique( 

264 people: Sequence[Person | Entity], 

265 ) -> list[Person | Entity]: 

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

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

268 return [] 

269 

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

271 # use same_person method to check if people are unique 

272 for person in people: 

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

274 unique_people.append(person) 

275 

276 return unique_people 

277 

278 old_people_unique = filter_unique(old_people) 

279 new_people_unique = filter_unique(new) 

280 

281 return self._merge_person_metadata(old_people_unique, new_people_unique) 

282 

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

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

285 

286 This method is existing for the publication_author special case 

287 when synchronizing to CITATION.cff. 

288 """ 

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

290 self.authors = metadata.authors() 

291 else: 

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

293 

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

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

296 self.name = metadata.name 

297 self.description = metadata.description 

298 

299 if metadata.version: 

300 self.version = metadata.version 

301 

302 if metadata.keywords: 

303 self.keywords = metadata.keywords 

304 

305 self._sync_authors(metadata) 

306 self.maintainers = self._sync_person_list( 

307 self.maintainers, metadata.maintainers() 

308 ) 

309 

310 licenses = metadata.license 

311 self.license = ( 

312 licenses[0].value if isinstance(licenses, list) else licenses.value 

313 ) 

314 

315 self.homepage = str(metadata.homepage) if metadata.homepage else None 

316 self.repository = str(metadata.repository) if metadata.repository else None 

317 self.documentation = ( 

318 str(metadata.documentation) if metadata.documentation else None 

319 ) 

320 

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

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

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

324 for field in ( 

325 "name", 

326 "version", 

327 "description", 

328 "license", 

329 "homepage", 

330 "repository", 

331 "documentation", 

332 "keywords", 

333 ): 

334 try: 

335 value = getattr(self, field) 

336 except (KeyError, TypeError): 

337 continue 

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

339 data[field] = value 

340 

341 people: list[Person] = [] 

342 entities: list[Entity] = [] 

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

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

345 updates = {"author": True} if role == "authors" else {} 

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

347 person = person.model_copy(update=updates) 

348 if isinstance(person, Entity): 

349 entities.append(person) 

350 else: 

351 people.append(person) 

352 

353 if people: 

354 data["people"] = people 

355 if entities: 

356 data["entities"] = entities 

357 return data 

358 

359 @staticmethod 

360 @abstractmethod 

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

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

363 

364 @staticmethod 

365 @abstractmethod 

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

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

368 

369 @classmethod 

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

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

372 # remove None values 

373 return [ 

374 person 

375 for p in people or [] 

376 if p is not None 

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

378 ] 

379 

380 # ---- 

381 # individual magic getters and setters 

382 

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

384 """Get a key itself.""" 

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

386 

387 @property 

388 def name(self): 

389 """Return the name of the project.""" 

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

391 

392 @name.setter 

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

394 """Set the name of the project.""" 

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

396 

397 @property 

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

399 """Return the version of the project.""" 

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

401 

402 @version.setter 

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

404 """Set the version of the project.""" 

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

406 

407 @property 

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

409 """Return the description of the project.""" 

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

411 

412 @description.setter 

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

414 """Set the description of the project.""" 

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

416 

417 @property 

418 def authors(self): 

419 """Return the authors of the project.""" 

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

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

422 return [] 

423 

424 # only return authors that can be converted to Person 

425 authors_validated = [ 

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

427 ] 

428 return authors_validated 

429 

430 @authors.setter 

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

432 """Set the authors of the project.""" 

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

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

435 

436 @property 

437 def maintainers(self): 

438 """Return the maintainers of the project.""" 

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

440 if maintainers is None: 

441 return [] 

442 

443 # only return maintainers that can be converted to Person 

444 maintainers_validated = [ 

445 maintainer 

446 for maintainer in maintainers 

447 if self._to_person(maintainer) is not None 

448 ] 

449 return maintainers_validated 

450 

451 @maintainers.setter 

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

453 """Set the maintainers of the project.""" 

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

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

456 

457 @property 

458 def contributors(self): 

459 """Return the contributors of the project.""" 

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

461 

462 @contributors.setter 

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

464 """Set the contributors of the project.""" 

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

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

467 

468 @property 

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

470 """Return the keywords of the project.""" 

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

472 

473 @keywords.setter 

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

475 """Set the keywords of the project.""" 

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

477 

478 @property 

479 def license(self) -> Any: 

480 """Return the license of the project.""" 

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

482 

483 @license.setter 

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

485 """Set the license of the project.""" 

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

487 

488 @property 

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

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

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

492 

493 @homepage.setter 

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

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

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

497 

498 @property 

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

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

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

502 

503 @repository.setter 

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

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

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

507 

508 @property 

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

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

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

512 

513 @documentation.setter 

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

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

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