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

337 statements  

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

1"""Core models for the somesy package.""" 

2 

3from __future__ import annotations 

4 

5import functools 

6import json 

7import re 

8from datetime import date 

9from pathlib import Path 

10from typing import Annotated, Any 

11 

12from pydantic import ( 

13 BaseModel, 

14 Field, 

15 PrivateAttr, 

16 field_validator, 

17 model_validator, 

18) 

19from rich.pretty import pretty_repr 

20 

21from .core import get_input_content 

22from .log import SomesyLogLevel 

23from .types import ContributionTypeEnum, Country, HttpUrlStr, LicenseEnum 

24 

25# -------- 

26# Somesy configuration model 

27 

28 

29class SomesyBaseModel(BaseModel): 

30 """Customized pydantic BaseModel for somesy. 

31 

32 Apart from some general tweaks for better defaults, 

33 adds a private `_key_order` field, which is used to track the 

34 preferred order for serialization (usually coming from some existing input). 

35 

36 It can be set on an instance using the set_key_order method, 

37 and is preserved by `copy()`. 

38 

39 NOTE: The custom order is intended for leaf models (no further nested models), 

40 custom order will not work correctly across nesting layers. 

41 """ 

42 

43 model_config = { 

44 "extra": "forbid", 

45 "validate_assignment": True, 

46 "populate_by_name": True, 

47 "str_strip_whitespace": True, 

48 "str_min_length": 1, 

49 } 

50 

51 # ---- 

52 # Key order magic 

53 

54 _key_order: list[str] = PrivateAttr([]) 

55 """List of field names (NOT aliases!) in the order they should be written in.""" 

56 

57 @classmethod 

58 @functools.lru_cache # compute once per class 

59 def _aliases(cls) -> dict[str, str]: 

60 """Map back from alias field names to internal field names.""" 

61 return {v.alias or k: k for k, v in cls.model_fields.items()} 

62 

63 @classmethod 

64 def make_partial(cls, dct): 

65 """Construct unvalidated partial model from dict. 

66 

67 Handles aliases correctly, unlike `construct`. 

68 """ 

69 un_alias = cls._aliases() 

70 return cls.model_construct(**{un_alias.get(k) or k: v for k, v in dct.items()}) 

71 

72 def set_key_order(self, keys: list[str]): 

73 """Setter for custom key order used in serialization.""" 

74 un_alias = self._aliases() 

75 # make sure we use the _actual_ field names 

76 self._key_order = [un_alias.get(k) or k for k in keys] 

77 

78 def model_copy(self, *args, **kwargs): 

79 """Patched copy method (to preserve custom key order).""" 

80 ret = super().model_copy(*args, **kwargs) 

81 ret.set_key_order(list(self._key_order)) 

82 return ret 

83 

84 @staticmethod 

85 def _patch_kwargs_defaults(kwargs): 

86 """Set some default arguments if they are not set by kwargs.""" 

87 for key in ["exclude_defaults", "exclude_none"]: 

88 if kwargs.get(key, None) is None: 

89 kwargs[key] = True 

90 

91 def _reorder_dict(self, dct): 

92 """Return dict with patched key order (according to `self._key_order`). 

93 

94 Keys in `dct` not listed in `self._key_order` come after all others. 

95 

96 Used to patch up `model_dump()` and `model_dump_json()`. 

97 """ 

98 key_order = self._key_order or [] 

99 existing = set(key_order).intersection(set(dct.keys())) 

100 key_order = [k for k in key_order if k in existing] 

101 key_order += list(set(dct.keys()) - set(key_order)) 

102 return {k: dct[k] for k in key_order} 

103 

104 def model_dump(self, *args, **kwargs): 

105 """Patched dict method (to preserve custom key order).""" 

106 self._patch_kwargs_defaults(kwargs) 

107 by_alias = kwargs.pop("by_alias", False) 

108 

109 dct = super().model_dump(*args, **kwargs, by_alias=False) 

110 ret = self._reorder_dict(dct) 

111 

112 if by_alias: 

113 ret = {self.model_fields[k].alias or k: v for k, v in ret.items()} 

114 return ret 

115 

116 def model_dump_json(self, *args, **kwargs): 

117 """Patched json method (to preserve custom key order).""" 

118 self._patch_kwargs_defaults(kwargs) 

119 by_alias = kwargs.pop("by_alias", False) 

120 

121 # loop back json through dict to apply custom key order 

122 dct = json.loads(super().model_dump_json(*args, **kwargs, by_alias=False)) 

123 ret = self._reorder_dict(dct) 

124 

125 if by_alias: 

126 ret = {self.model_fields[k].alias or k: v for k, v in ret.items()} 

127 return json.dumps(ret, ensure_ascii=False) 

128 

129 

130_SOMESY_TARGETS = [ 

131 "cff", 

132 "pyproject", 

133 "package_json", 

134 "codemeta", 

135 "julia", 

136 "fortran", 

137 "pom_xml", 

138 "mkdocs", 

139 "rust", 

140] 

141 

142 

143class SomesyConfig(SomesyBaseModel): 

144 """Pydantic model for somesy tool configuration. 

145 

146 Note that all fields match CLI options, and CLI options will override the 

147 values declared in a somesy input file (such as `somesy.toml`). 

148 """ 

149 

150 @model_validator(mode="before") 

151 @classmethod 

152 def at_least_one_target(cls, values): 

153 """Check that at least one output file is enabled.""" 

154 if all(values.get(f"no_sync_{x}") for x in _SOMESY_TARGETS): 

155 msg = "No sync target enabled, nothing to do. Probably this is a mistake?" 

156 raise ValueError(msg) 

157 

158 return values 

159 

160 # cli flags 

161 show_info: Annotated[ 

162 bool, 

163 Field( 

164 description="Show basic information messages on run (-v flag).", 

165 ), 

166 ] = False 

167 verbose: Annotated[ 

168 bool, Field(description="Show verbose messages on run (-vv flag).") 

169 ] = False 

170 debug: Annotated[ 

171 bool, Field(description="Show debug messages on run (-vvv flag).") 

172 ] = False 

173 

174 input_file: Annotated[ 

175 Path | None, Field(description="Project metadata input file path.") 

176 ] = Path("somesy.toml") 

177 

178 no_sync_pyproject: Annotated[ 

179 bool, Field(description="Do not sync with pyproject.toml.") 

180 ] = False 

181 pyproject_file: Annotated[ 

182 Path | list[Path], Field(description="pyproject.toml file path.") 

183 ] = Path("pyproject.toml") 

184 

185 no_sync_package_json: Annotated[ 

186 bool, Field(description="Do not sync with package.json.") 

187 ] = False 

188 package_json_file: Annotated[ 

189 Path | list[Path], Field(description="package.json file path.") 

190 ] = Path("package.json") 

191 

192 no_sync_julia: Annotated[ 

193 bool, Field(description="Do not sync with Project.toml.") 

194 ] = False 

195 julia_file: Annotated[ 

196 Path | list[Path], Field(description="Project.toml file path.") 

197 ] = Path("Project.toml") 

198 

199 no_sync_fortran: Annotated[ 

200 bool, Field(description="Do not sync with fpm.toml.") 

201 ] = False 

202 fortran_file: Annotated[ 

203 Path | list[Path], Field(description="fpm.toml file path.") 

204 ] = Path("fpm.toml") 

205 

206 no_sync_pom_xml: Annotated[bool, Field(description="Do not sync with pom.xml.")] = ( 

207 False 

208 ) 

209 pom_xml_file: Annotated[ 

210 Path | list[Path], Field(description="pom.xml file path.") 

211 ] = Path("pom.xml") 

212 

213 no_sync_mkdocs: Annotated[ 

214 bool, Field(description="Do not sync with mkdocs.yml.") 

215 ] = False 

216 mkdocs_file: Annotated[ 

217 Path | list[Path], Field(description="mkdocs.yml file path.") 

218 ] = Path("mkdocs.yml") 

219 

220 no_sync_rust: Annotated[bool, Field(description="Do not sync with Cargo.toml.")] = ( 

221 False 

222 ) 

223 rust_file: Annotated[ 

224 Path | list[Path], Field(description="Cargo.toml file path.") 

225 ] = Path("Cargo.toml") 

226 

227 no_sync_cff: Annotated[bool, Field(description="Do not sync with CFF.")] = False 

228 cff_file: Annotated[Path | list[Path], Field(description="CFF file path.")] = Path( 

229 "CITATION.cff" 

230 ) 

231 

232 no_sync_codemeta: Annotated[ 

233 bool, Field(description="Do not sync with codemeta.json.") 

234 ] = False 

235 codemeta_file: Annotated[ 

236 Path | list[Path], Field(description="codemeta.json file path.") 

237 ] = Path("codemeta.json") 

238 merge_codemeta: Annotated[ 

239 bool, 

240 Field( 

241 description="Merge codemeta.json with with an existing codemeta.json file." 

242 ), 

243 ] = False 

244 

245 # property to pass validation for all inputs/outputs 

246 pass_validation: Annotated[ 

247 bool | None, 

248 Field(description="Allow incomplete input and pass output validation."), 

249 ] = False 

250 

251 # packages (sub-folders) for monorepos with their own somesy config 

252 packages: Annotated[ 

253 Path | list[Path] | None, 

254 Field( 

255 description="Packages (sub-folders) for monorepos with their own somesy config." 

256 ), 

257 ] = None 

258 

259 def log_level(self) -> SomesyLogLevel: 

260 """Return log level derived from this configuration.""" 

261 return SomesyLogLevel.from_flags( 

262 info=self.show_info, verbose=self.verbose, debug=self.debug 

263 ) 

264 

265 def update_log_level(self, log_level: SomesyLogLevel): 

266 """Update config flags according to passed log level.""" 

267 self.show_info = log_level == SomesyLogLevel.INFO 

268 self.verbose = log_level == SomesyLogLevel.VERBOSE 

269 self.debug = log_level == SomesyLogLevel.DEBUG 

270 

271 def get_input(self) -> SomesyInput: 

272 """Based on the somesy config, load the complete somesy input.""" 

273 # get metadata+config from specified input file 

274 somesy_input = SomesyInput.from_input_file( 

275 self.input_file or Path("somesy.toml"), 

276 allow_incomplete=bool(self.pass_validation), 

277 ) 

278 # update input with merged config settings (cli overrides config file) 

279 dct: dict[str, Any] = {} 

280 dct.update(somesy_input.config or {}) 

281 dct.update(self.model_dump()) 

282 somesy_input.config = SomesyConfig(**dct) 

283 return somesy_input 

284 

285 def resolve_paths(self, base_dir: Path) -> None: 

286 """Resolve all paths in the config relative to the given base directory. 

287 

288 Args: 

289 base_dir: The base directory to resolve paths against. 

290 

291 """ 

292 

293 def resolve_path( 

294 paths: Path | list[Path] | None, 

295 ) -> Path | list[Path] | None: 

296 if paths is None: 

297 return None 

298 if isinstance(paths, list): 

299 return [base_dir / p for p in paths] 

300 return base_dir / paths 

301 

302 # Resolve all file paths 

303 resolved_input = resolve_path(self.input_file) 

304 self.input_file = resolved_input if isinstance(resolved_input, Path) else None 

305 for field in ( 

306 "pyproject_file", 

307 "package_json_file", 

308 "julia_file", 

309 "fortran_file", 

310 "pom_xml_file", 

311 "mkdocs_file", 

312 "rust_file", 

313 "cff_file", 

314 "codemeta_file", 

315 "packages", 

316 ): 

317 resolved = resolve_path(getattr(self, field)) 

318 if resolved is not None: 

319 setattr(self, field, resolved) 

320 

321 

322# -------- 

323# Project metadata model (modified from CITATION.cff) 

324 

325 

326class ContributorBaseModel(SomesyBaseModel): 

327 """Base model for Person and Entity models. 

328 

329 This schema is based on CITATION.cff 1.2, modified and extended for the needs of somesy. 

330 """ 

331 

332 email: Annotated[ 

333 str | None, 

334 Field( 

335 pattern=r"^[\S]+@[\S]+\.[\S]{2,}$", 

336 description="The person's email address.", 

337 ), 

338 ] = None 

339 

340 alias: Annotated[str | None, Field(description="The contributor's alias.")] = None 

341 address: Annotated[str | None, Field(description="The contributor's address.")] = ( 

342 None 

343 ) 

344 city: Annotated[str | None, Field(description="The entity's city.")] = None 

345 country: Annotated[Country | None, Field(description="The entity's country.")] = ( 

346 None 

347 ) 

348 fax: Annotated[str | None, Field(description="The person's fax number.")] = None 

349 post_code: Annotated[ 

350 str | None, Field(alias="post-code", description="The entity's post-code.") 

351 ] = None 

352 region: Annotated[str | None, Field(description="The entity's region.")] = None 

353 tel: Annotated[str | None, Field(description="The entity's phone number.")] = None 

354 

355 # ---- 

356 # somesy-specific extensions 

357 author: Annotated[ 

358 bool, 

359 Field( 

360 description="Indicates whether the entity is an author of the project (i.e. significant contributor)." 

361 ), 

362 ] = False 

363 publication_author: Annotated[ 

364 bool | None, 

365 Field( 

366 description="Indicates whether the entity is to be listed as an author in academic citations." 

367 ), 

368 ] = None 

369 maintainer: Annotated[ 

370 bool, 

371 Field( 

372 description="Indicates whether the entity is a maintainer of the project (i.e. for contact)." 

373 ), 

374 ] = False 

375 

376 # NOTE: CFF 1.3 (once done) might provide ways for refined contributor description. That should be implemented here. 

377 contribution: Annotated[ 

378 str | None, 

379 Field(description="Summary of how the entity contributed to the project."), 

380 ] = None 

381 contribution_types: Annotated[ 

382 list[ContributionTypeEnum] | None, 

383 Field( 

384 description="Relevant types of contributions (see https://allcontributors.org/docs/de/emoji-key).", 

385 min_length=1, 

386 ), 

387 ] = None 

388 contribution_begin: Annotated[ 

389 date | None, Field(description="Beginning date of the contribution.") 

390 ] = None 

391 contribution_end: Annotated[ 

392 date | None, Field(description="Ending date of the contribution.") 

393 ] = None 

394 

395 @model_validator(mode="before") 

396 @classmethod 

397 def author_implies_publication(cls, values): 

398 """Ensure consistency of author and publication_author.""" 

399 if values.get("author"): 

400 # NOTE: explicitly check for False (different case from None = missing!) 

401 if values.get("publication_author") is False: 

402 msg = "Combining author=true and publication_author=false is invalid!" 

403 raise ValueError(msg) 

404 values["publication_author"] = True 

405 return values 

406 

407 # helper methods 

408 @property 

409 def full_name(self) -> str: 

410 """Return the name of the contributor.""" 

411 raise NotImplementedError 

412 

413 def to_name_email_string(self) -> str: 

414 """Convert project metadata person object to poetry string for person format `full name <x@y.z>`.""" 

415 if self.email: 

416 return f"{self.full_name} <{self.email}>" 

417 else: 

418 return self.full_name 

419 

420 @classmethod 

421 def from_name_email_string(cls, person: str) -> ContributorBaseModel: 

422 """Return the type of class based on an name/e-mail string like `full name <x@y.z>`. 

423 

424 If the name is `A B C`, then `A B` will be the given names and `C` will be the family name. 

425 """ 

426 raise NotImplementedError 

427 

428 

429class Entity(ContributorBaseModel): 

430 """Metadata about an entity in the context of a software project ownership. 

431 

432 An entity, i.e., an institution, team, research group, company, conference, etc., as opposed to a single natural person. 

433 This schema is based on CITATION.cff 1.2, modified and extended for the needs of somesy. 

434 """ 

435 

436 # NOTE: we rely on the defined aliases for direct CITATION.cff interoperability. 

437 

438 date_end: Annotated[ 

439 date | None, 

440 Field( 

441 alias="date-end", 

442 description="The entity's ending date, e.g., when the entity is a conference.", 

443 ), 

444 ] = None 

445 date_start: Annotated[ 

446 date | None, 

447 Field( 

448 alias="date-start", 

449 description="The entity's starting date, e.g., when the entity is a conference.", 

450 ), 

451 ] = None 

452 location: Annotated[ 

453 str | None, 

454 Field( 

455 description="The entity's location, e.g., when the entity is a conference." 

456 ), 

457 ] = None 

458 name: Annotated[str, Field(description="The entity's name.")] 

459 website: Annotated[ 

460 HttpUrlStr | None, Field(description="The entity's website.") 

461 ] = None 

462 rorid: Annotated[ 

463 HttpUrlStr | None, 

464 Field( 

465 description="The entity's ROR ID url **(not required, but highly suggested)**." 

466 ), 

467 ] = None 

468 

469 # helper methods 

470 @property 

471 def full_name(self) -> str: 

472 """Use same property as Person for code integration.""" 

473 return self.name 

474 

475 @classmethod 

476 def from_name_email_string(cls, person: str) -> Entity: 

477 """Return an `Entity` based on an name/e-mail string like `name <x@y.z>`.""" 

478 m = re.match(r"\s*([^<]+)<([^>]+)>", person) 

479 if m is None: 

480 return Entity(name=person) 

481 

482 name, mail = ( 

483 m.group(1).strip(), 

484 m.group(2).strip(), 

485 ) 

486 return Entity(name=name, email=mail) 

487 

488 def same_person(self, other: Person | Entity) -> bool: 

489 """Return whether two Entity metadata records are about the same real person. 

490 

491 Uses heuristic match based on email and name (whichever are provided). 

492 """ 

493 if not isinstance(other, Entity): 

494 return False 

495 if ( 

496 self.rorid is not None 

497 and other.rorid is not None 

498 and self.rorid == other.rorid 

499 ): 

500 return True 

501 if ( 

502 self.website is not None 

503 and other.website is not None 

504 and self.website == other.website 

505 ): 

506 return True 

507 if ( 

508 self.email is not None 

509 and other.email is not None 

510 and self.email == other.email 

511 ): 

512 return True 

513 return self.name == other.name 

514 

515 def model_dump_json(self, *args, **kwargs): 

516 """Patched json method (to preserve custom key order), remove rorid and set it as website if it is not None.""" 

517 ret = super().model_dump_json(*args, **kwargs) 

518 # convert ret to dict 

519 ret = json.loads(ret) 

520 if self.rorid is not None and "website" not in ret: 

521 ret["website"] = str(self.rorid) 

522 ret.pop("rorid") 

523 # convert ret back to json string 

524 return json.dumps(ret) 

525 

526 

527class Person(ContributorBaseModel): 

528 """Metadata about a person in the context of a software project. 

529 

530 This schema is based on CITATION.cff 1.2, modified and extended for the needs of somesy. 

531 """ 

532 

533 # NOTE: we rely on the defined aliases for direct CITATION.cff interoperability. 

534 

535 orcid: Annotated[ 

536 HttpUrlStr | None, 

537 Field( 

538 description="The person's ORCID url **(not required, but highly suggested)**." 

539 ), 

540 ] = None 

541 family_names: Annotated[ 

542 str, Field(alias="family-names", description="The person's family names.") 

543 ] 

544 given_names: Annotated[ 

545 str, Field(alias="given-names", description="The person's given names.") 

546 ] 

547 name_particle: Annotated[ 

548 str | None, 

549 Field( 

550 alias="name-particle", 

551 description="The person's name particle, e.g., a nobiliary particle or a preposition meaning 'of' or 'from'" 

552 " (for example 'von' in 'Alexander von Humboldt').", 

553 examples=["von"], 

554 ), 

555 ] = None 

556 name_suffix: Annotated[ 

557 str | None, 

558 Field( 

559 alias="name-suffix", 

560 description="The person's name-suffix, e.g. 'Jr.' for Sammy Davis Jr. or 'III' for Frank Edwin Wright III.", 

561 examples=["Jr.", "III"], 

562 ), 

563 ] = None 

564 affiliation: Annotated[ 

565 str | None, Field(description="The person's affiliation.") 

566 ] = None 

567 

568 # helper methods 

569 

570 @field_validator("orcid", mode="before") 

571 @classmethod 

572 def orcid_from_string(cls, orcid: Any) -> Any: 

573 """Convert orcid id string to HttpUrlStr.""" 

574 # orcid regex without https://orcid.org/ prefix 

575 orcid_regex = r"^[0-9]{4}-[0-9]{4}-[0-9]{4}-[0-9]{3}[0-9X]$" 

576 if ( 

577 orcid is not None 

578 and isinstance(orcid, str) 

579 and re.match(orcid_regex, orcid) 

580 ): 

581 return f"https://orcid.org/{orcid}" 

582 return orcid 

583 

584 @property 

585 def full_name(self) -> str: 

586 """Return the full name of the person.""" 

587 names = [] 

588 

589 if self.given_names: 

590 names.append(self.given_names) 

591 

592 if self.name_particle: 

593 names.append(self.name_particle) 

594 

595 if self.family_names: 

596 names.append(self.family_names) 

597 

598 if self.name_suffix: 

599 names.append(self.name_suffix) 

600 

601 return " ".join(names) if names else "" 

602 

603 @classmethod 

604 def from_name_email_string(cls, person: str) -> Person: 

605 """Return a `Person` based on an name/e-mail string like `full name <x@y.z>`. 

606 

607 If the name is `A B C`, then `A B` will be the given names and `C` will be the family name. 

608 """ 

609 m = re.match(r"\s*([^<]+)<([^>]+)>", person) 

610 if m is None: 

611 names = [s.strip() for s in person.split()] 

612 return Person.model_validate( 

613 { 

614 "given-names": " ".join(names[:-1]), 

615 "family-names": names[-1], 

616 } 

617 ) 

618 names, mail = ( 

619 [s.strip() for s in m.group(1).split()], 

620 m.group(2).strip(), 

621 ) 

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

623 # we only compare on full_name anyway. 

624 return Person.model_validate( 

625 { 

626 "given-names": " ".join(names[:-1]), 

627 "family-names": names[-1], 

628 "email": mail, 

629 } 

630 ) 

631 

632 def same_person(self, other) -> bool: 

633 """Return whether two Person metadata records are about the same real person. 

634 

635 Uses heuristic match based on orcid, email and name (whichever are provided). 

636 """ 

637 if not isinstance(other, Person): 

638 return False 

639 if self.orcid is not None and other.orcid is not None: 

640 # having orcids is the best case, a real identifier 

641 # NOTE: converting to str from pydantic-internal Url object for == ! 

642 return str(self.orcid) == str(other.orcid) 

643 

644 # otherwise, try to match according to mail/name 

645 # sourcery skip: merge-nested-ifs 

646 if ( 

647 self.email is not None 

648 and other.email is not None 

649 and self.email == other.email 

650 ): 

651 # an email address belongs to exactly one person 

652 # => same email -> same person 

653 return True 

654 # otherwise, need to check name 

655 # (a person often has multiple email addresses) 

656 

657 # no orcids, no/distinct email address 

658 # -> decide based on full_name (which is always present) 

659 return self.full_name == other.full_name 

660 

661 

662class ProjectMetadata(SomesyBaseModel): 

663 """Pydantic model for Project Metadata Input.""" 

664 

665 model_config = {"extra": "ignore"} 

666 

667 @field_validator("people") 

668 @classmethod 

669 def ensure_distinct_people(cls, people): 

670 """Make sure that no person is listed twice in the same list.""" 

671 for i in range(len(people)): 

672 for j in range(i + 1, len(people)): 

673 if people[i].same_person(people[j]): 

674 p1 = pretty_repr(json.loads(people[i].model_dump_json())) 

675 p2 = pretty_repr(json.loads(people[j].model_dump_json())) 

676 msg = f"Same person is listed twice:\n{p1}\n{p2}" 

677 raise ValueError(msg) 

678 return people 

679 

680 @field_validator("entities") 

681 @classmethod 

682 def ensure_distinct_entities(cls, entities): 

683 """Make sure that no entity is listed twice in the same list.""" 

684 for i in range(len(entities)): 

685 for j in range(i + 1, len(entities)): 

686 if entities[i].same_person(entities[j]): 

687 e1 = pretty_repr(json.loads(entities[i].model_dump_json())) 

688 e2 = pretty_repr(json.loads(entities[j].model_dump_json())) 

689 msg = f"Same entity is listed twice:\n{e1}\n{e2}" 

690 raise ValueError(msg) 

691 return entities 

692 

693 @model_validator(mode="after") 

694 def at_least_one_author(self) -> ProjectMetadata: 

695 """Make sure there is at least one author.""" 

696 if not self.people and not self.entities: 

697 raise ValueError( 

698 "There have to be at least a person or an organization in the input" 

699 ) 

700 if not any(p.author for p in self.people) and not any( 

701 e.author for e in self.entities 

702 ): 

703 raise ValueError("At least one person must be an author of this project.") 

704 return self 

705 

706 name: Annotated[str, Field(description="Project name.")] 

707 description: Annotated[str, Field(description="Project description.")] 

708 version: Annotated[str | None, Field(description="Project version.")] = None 

709 doi: Annotated[str | None, Field(description="Project DOI.")] = None 

710 license: Annotated[ 

711 LicenseEnum | list[LicenseEnum], 

712 Field(description="SPDX License string(s)."), 

713 ] 

714 

715 @field_validator("doi", mode="before") 

716 @classmethod 

717 def normalize_doi(cls, doi: Any) -> str | None: 

718 """Normalize DOI URLs to the DOI value accepted by CITATION.cff.""" 

719 if doi is None: 

720 return None 

721 if isinstance(doi, str): 

722 doi = re.sub( 

723 r"^https?://(?:dx\.)?doi\.org/", "", doi.strip(), flags=re.IGNORECASE 

724 ) 

725 if re.fullmatch(r"10\.\d{4,9}/[-._;()/:A-Z0-9]+", doi, flags=re.IGNORECASE): 

726 return doi 

727 raise ValueError("DOI must be a DOI or a doi.org URL.") 

728 

729 @field_validator("license") 

730 @classmethod 

731 def validate_license_list(cls, license): 

732 """Require at least one license when licenses are provided as a list.""" 

733 if isinstance(license, list) and not license: 

734 raise ValueError("At least one license must be provided.") 

735 return license 

736 

737 homepage: Annotated[ 

738 HttpUrlStr | None, Field(description="URL of the project homepage.") 

739 ] = None 

740 repository: Annotated[ 

741 HttpUrlStr | None, 

742 Field(description="URL of the project source code repository."), 

743 ] = None 

744 documentation: Annotated[ 

745 HttpUrlStr | None, Field(description="URL of the project documentation.") 

746 ] = None 

747 

748 keywords: Annotated[ 

749 list[str] | None, 

750 Field(min_length=1, description="Keywords that describe the project."), 

751 ] = None 

752 

753 people: list[Person] = Field( 

754 default_factory=list, 

755 description="Project authors, maintainers and contributors.", 

756 ) 

757 

758 entities: list[Entity] = Field( 

759 default_factory=list, 

760 description="Project authors, maintainers and contributors as entities (organizations).", 

761 ) 

762 

763 def authors(self) -> list[Person | Entity]: 

764 """Return people and entities explicitly marked as authors.""" 

765 authors: list[Person | Entity] = [p for p in self.people if p.author] 

766 authors.extend([e for e in self.entities if e.author]) 

767 return authors 

768 

769 def publication_authors(self) -> list[Person | Entity]: 

770 """Return people marked as publication authors. 

771 

772 This always includes people marked as authors. 

773 """ 

774 # return an empty list if no publication authors are specified 

775 if not any(p.publication_author for p in self.people) and not any( 

776 p.publication_author for p in self.entities 

777 ): 

778 return [] 

779 publication_authors: list[Person | Entity] = [ 

780 p for p in self.people if p.publication_author 

781 ] 

782 publication_authors.extend([e for e in self.entities if e.publication_author]) 

783 return publication_authors 

784 

785 def maintainers(self) -> list[Person | Entity]: 

786 """Return people and entities marked as maintainers.""" 

787 maintainers: list[Person | Entity] = [p for p in self.people if p.maintainer] 

788 maintainers.extend([e for e in self.entities if e.maintainer]) 

789 return maintainers 

790 

791 def contributors(self) -> list[Person | Entity]: 

792 """Return only people and entities not marked as authors.""" 

793 contributors: list[Person | Entity] = [p for p in self.people if not p.author] 

794 contributors.extend([e for e in self.entities if not e.author]) 

795 return contributors 

796 

797 

798class PartialProjectMetadata(ProjectMetadata): 

799 """Validated project metadata that may omit normally required values.""" 

800 

801 name: str | None = None # type: ignore[assignment] 

802 description: str | None = None # type: ignore[assignment] 

803 license: LicenseEnum | list[LicenseEnum] | None = None # type: ignore[assignment] 

804 

805 @model_validator(mode="after") 

806 def at_least_one_author(self) -> PartialProjectMetadata: 

807 """Allow an author to be omitted while retaining all field validation.""" 

808 return self 

809 

810 

811class SomesyInput(SomesyBaseModel): 

812 """The complete somesy input file (`somesy.toml`) or section (`pyproject.toml`).""" 

813 

814 _origin: Path | None 

815 

816 project: Annotated[ 

817 ProjectMetadata | PartialProjectMetadata, 

818 Field(description="Project metadata to be used and synchronized."), 

819 ] 

820 config: Annotated[ 

821 SomesyConfig, 

822 Field( 

823 description="somesy tool configuration (matches CLI flags).", 

824 default_factory=lambda: SomesyConfig(), 

825 ), 

826 ] 

827 

828 # if config.input_file is set, use it as origin 

829 @model_validator(mode="after") 

830 def set_origin(self): 

831 """Set the origin of the input file.""" 

832 if isinstance(self.project, PartialProjectMetadata) and not bool( 

833 self.config.pass_validation 

834 ): 

835 ProjectMetadata.model_validate(self.project.model_dump()) 

836 if self.config and self.config.input_file: 

837 self._origin = self.config.input_file 

838 return self 

839 

840 def is_somesy_file(self) -> bool: 

841 """Return whether this somesy input is from a somesy config file. 

842 

843 That means, returns False if it is from pyproject.toml or package.json. 

844 """ 

845 return self.is_somesy_file_path(self._origin or Path(".")) 

846 

847 @classmethod 

848 def is_somesy_file_path(cls, path: Path) -> bool: 

849 """Return whether the path looks like a somesy config file. 

850 

851 That means, returns False if it is e.g. pyproject.toml or package.json. 

852 """ 

853 return str(path).endswith("somesy.toml") 

854 

855 @classmethod 

856 def from_input_file( 

857 cls, path: Path, *, allow_incomplete: bool = False 

858 ) -> SomesyInput: 

859 """Load somesy input from given file.""" 

860 content = get_input_content(path) 

861 config = SomesyConfig(**content.get("config", {})) 

862 if allow_incomplete: 

863 config.pass_validation = True 

864 project_model = ( 

865 PartialProjectMetadata if config.pass_validation else ProjectMetadata 

866 ) 

867 ret = cls.model_validate( 

868 { 

869 **content, 

870 "project": project_model(**content["project"]), 

871 "config": config, 

872 } 

873 ) 

874 ret._origin = path 

875 return ret