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

352 statements  

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

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

2 

3from __future__ import annotations 

4 

5import functools 

6import json 

7import logging 

8import re 

9from datetime import date 

10from pathlib import Path 

11from typing import Annotated, Any 

12 

13from pydantic import ( 

14 BaseModel, 

15 Field, 

16 PrivateAttr, 

17 field_validator, 

18 model_validator, 

19) 

20from rich.pretty import pretty_repr 

21 

22from .core import get_input_content 

23from .log import SomesyLogLevel 

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

25 

26logger = logging.getLogger("somesy") 

27 

28# -------- 

29# Somesy configuration model 

30 

31 

32class SomesyBaseModel(BaseModel): 

33 """Customized pydantic BaseModel for somesy. 

34 

35 Apart from some general tweaks for better defaults, 

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

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

38 

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

40 and is preserved by `copy()`. 

41 

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

43 custom order will not work correctly across nesting layers. 

44 """ 

45 

46 model_config = { 

47 "extra": "forbid", 

48 "validate_assignment": True, 

49 "populate_by_name": True, 

50 "str_strip_whitespace": True, 

51 "str_min_length": 1, 

52 } 

53 

54 # ---- 

55 # Key order magic 

56 

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

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

59 

60 @classmethod 

61 @functools.lru_cache # compute once per class 

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

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

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

65 

66 @classmethod 

67 def make_partial(cls, dct): 

68 """Construct unvalidated partial model from dict. 

69 

70 Handles aliases correctly, unlike `construct`. 

71 """ 

72 un_alias = cls._aliases() 

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

74 

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

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

77 un_alias = self._aliases() 

78 # make sure we use the _actual_ field names 

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

80 

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

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

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

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

85 return ret 

86 

87 @staticmethod 

88 def _patch_kwargs_defaults(kwargs): 

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

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

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

92 kwargs[key] = True 

93 

94 def _reorder_dict(self, dct): 

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

96 

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

98 

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

100 """ 

101 key_order = self._key_order or [] 

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

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

104 key_order += [k for k in dct if k not in existing] 

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

106 

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

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

109 self._patch_kwargs_defaults(kwargs) 

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

111 

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

113 ret = self._reorder_dict(dct) 

114 

115 if by_alias: 

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

117 return ret 

118 

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

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

121 self._patch_kwargs_defaults(kwargs) 

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

123 

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

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

126 ret = self._reorder_dict(dct) 

127 

128 if by_alias: 

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

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

131 

132 

133_SOMESY_TARGETS = [ 

134 "cff", 

135 "pyproject", 

136 "package_json", 

137 "codemeta", 

138 "julia", 

139 "fortran", 

140 "pom_xml", 

141 "mkdocs", 

142 "rust", 

143] 

144 

145 

146class SomesyConfig(SomesyBaseModel): 

147 """Pydantic model for somesy tool configuration. 

148 

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

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

151 """ 

152 

153 @model_validator(mode="before") 

154 @classmethod 

155 def at_least_one_target(cls, values): 

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

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

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

159 raise ValueError(msg) 

160 

161 return values 

162 

163 # cli flags 

164 show_info: Annotated[ 

165 bool, 

166 Field( 

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

168 ), 

169 ] = False 

170 verbose: Annotated[ 

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

172 ] = False 

173 debug: Annotated[ 

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

175 ] = False 

176 

177 input_file: Annotated[ 

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

179 ] = Path("somesy.toml") 

180 

181 no_sync_pyproject: Annotated[ 

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

183 ] = False 

184 pyproject_file: Annotated[ 

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

186 ] = Path("pyproject.toml") 

187 

188 no_sync_package_json: Annotated[ 

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

190 ] = False 

191 package_json_file: Annotated[ 

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

193 ] = Path("package.json") 

194 

195 no_sync_julia: Annotated[ 

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

197 ] = False 

198 julia_file: Annotated[ 

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

200 ] = Path("Project.toml") 

201 

202 no_sync_fortran: Annotated[ 

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

204 ] = False 

205 fortran_file: Annotated[ 

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

207 ] = Path("fpm.toml") 

208 

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

210 False 

211 ) 

212 pom_xml_file: Annotated[ 

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

214 ] = Path("pom.xml") 

215 

216 no_sync_mkdocs: Annotated[ 

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

218 ] = False 

219 mkdocs_file: Annotated[ 

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

221 ] = Path("mkdocs.yml") 

222 

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

224 False 

225 ) 

226 rust_file: Annotated[ 

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

228 ] = Path("Cargo.toml") 

229 

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

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

232 "CITATION.cff" 

233 ) 

234 

235 no_sync_codemeta: Annotated[ 

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

237 ] = False 

238 codemeta_file: Annotated[ 

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

240 ] = Path("codemeta.json") 

241 merge_codemeta: Annotated[ 

242 bool, 

243 Field( 

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

245 ), 

246 ] = False 

247 

248 # property to pass validation for all inputs/outputs 

249 pass_validation: Annotated[ 

250 bool | None, 

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

252 ] = False 

253 

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

255 packages: Annotated[ 

256 Path | list[Path] | None, 

257 Field( 

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

259 ), 

260 ] = None 

261 

262 def log_level(self) -> SomesyLogLevel: 

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

264 return SomesyLogLevel.from_flags( 

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

266 ) 

267 

268 def update_log_level(self, log_level: SomesyLogLevel): 

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

270 self.show_info = log_level == SomesyLogLevel.INFO 

271 self.verbose = log_level == SomesyLogLevel.VERBOSE 

272 self.debug = log_level == SomesyLogLevel.DEBUG 

273 

274 _cli_overrides: dict[str, Any] = PrivateAttr(default_factory=dict) 

275 """Options explicitly set on this config (CLI), set on the combined config.""" 

276 

277 def get_input(self) -> SomesyInput: 

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

279 # get metadata+config from specified input file 

280 somesy_input = SomesyInput.from_input_file( 

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

282 allow_incomplete=bool(self.pass_validation), 

283 ) 

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

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

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

287 dct.update(self.model_dump()) 

288 somesy_input.config = SomesyConfig(**dct) 

289 somesy_input.config._cli_overrides = self.model_dump() 

290 return somesy_input 

291 

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

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

294 

295 Args: 

296 base_dir: The base directory to resolve paths against. 

297 

298 """ 

299 

300 def resolve_path( 

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

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

303 if paths is None: 

304 return None 

305 if isinstance(paths, list): 

306 return [base_dir / p for p in paths] 

307 return base_dir / paths 

308 

309 # Resolve all file paths 

310 resolved_input = resolve_path(self.input_file) 

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

312 for field in ( 

313 "pyproject_file", 

314 "package_json_file", 

315 "julia_file", 

316 "fortran_file", 

317 "pom_xml_file", 

318 "mkdocs_file", 

319 "rust_file", 

320 "cff_file", 

321 "codemeta_file", 

322 "packages", 

323 ): 

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

325 if resolved is not None: 

326 setattr(self, field, resolved) 

327 

328 

329# -------- 

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

331 

332 

333class ContributorBaseModel(SomesyBaseModel): 

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

335 

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

337 """ 

338 

339 email: Annotated[ 

340 str | None, 

341 Field( 

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

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

344 ), 

345 ] = None 

346 

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

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

349 None 

350 ) 

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

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

353 None 

354 ) 

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

356 post_code: Annotated[ 

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

358 ] = None 

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

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

361 

362 # ---- 

363 # somesy-specific extensions 

364 author: Annotated[ 

365 bool, 

366 Field( 

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

368 ), 

369 ] = False 

370 publication_author: Annotated[ 

371 bool | None, 

372 Field( 

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

374 ), 

375 ] = None 

376 maintainer: Annotated[ 

377 bool, 

378 Field( 

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

380 ), 

381 ] = False 

382 

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

384 contribution: Annotated[ 

385 str | None, 

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

387 ] = None 

388 contribution_types: Annotated[ 

389 list[ContributionTypeEnum] | None, 

390 Field( 

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

392 min_length=1, 

393 ), 

394 ] = None 

395 contribution_begin: Annotated[ 

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

397 ] = None 

398 contribution_end: Annotated[ 

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

400 ] = None 

401 

402 @model_validator(mode="before") 

403 @classmethod 

404 def author_implies_publication(cls, values): 

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

406 if values.get("author"): 

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

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

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

410 raise ValueError(msg) 

411 values["publication_author"] = True 

412 return values 

413 

414 # helper methods 

415 @property 

416 def full_name(self) -> str: 

417 """Return the name of the contributor.""" 

418 raise NotImplementedError 

419 

420 def to_name_email_string(self) -> str: 

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

422 if self.email: 

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

424 else: 

425 return self.full_name 

426 

427 @classmethod 

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

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

430 

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

432 A single-word name is not a valid person name and raises a `ValidationError`. 

433 """ 

434 raise NotImplementedError 

435 

436 

437class Entity(ContributorBaseModel): 

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

439 

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

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

442 """ 

443 

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

445 

446 date_end: Annotated[ 

447 date | None, 

448 Field( 

449 alias="date-end", 

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

451 ), 

452 ] = None 

453 date_start: Annotated[ 

454 date | None, 

455 Field( 

456 alias="date-start", 

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

458 ), 

459 ] = None 

460 location: Annotated[ 

461 str | None, 

462 Field( 

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

464 ), 

465 ] = None 

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

467 website: Annotated[ 

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

469 ] = None 

470 rorid: Annotated[ 

471 HttpUrlStr | None, 

472 Field( 

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

474 ), 

475 ] = None 

476 

477 # helper methods 

478 @property 

479 def full_name(self) -> str: 

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

481 return self.name 

482 

483 @classmethod 

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

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

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

487 if m is None: 

488 return Entity(name=person) 

489 

490 name, mail = ( 

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

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

493 ) 

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

495 

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

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

498 

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

500 """ 

501 if not isinstance(other, Entity): 

502 return False 

503 if ( 

504 self.rorid is not None 

505 and other.rorid is not None 

506 and self.rorid == other.rorid 

507 ): 

508 return True 

509 if self.rorid is not None and other.rorid is not None: 

510 # distinct identifiers: different organizations, even with equal names 

511 return False 

512 if ( 

513 self.website is not None 

514 and other.website is not None 

515 and self.website == other.website 

516 ): 

517 return True 

518 if self.website is not None and other.website is not None: 

519 return False 

520 if ( 

521 self.email is not None 

522 and other.email is not None 

523 and self.email == other.email 

524 ): 

525 return True 

526 return self.name == other.name 

527 

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

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

530 

531 If no website is set, the rorid is used as website and removed. 

532 If a website is set, the rorid is kept alongside it. 

533 """ 

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

535 # convert ret to dict 

536 ret = json.loads(ret) 

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

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

539 ret.pop("rorid", None) 

540 # convert ret back to json string 

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

542 

543 

544class Person(ContributorBaseModel): 

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

546 

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

548 """ 

549 

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

551 

552 orcid: Annotated[ 

553 HttpUrlStr | None, 

554 Field( 

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

556 ), 

557 ] = None 

558 family_names: Annotated[ 

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

560 ] 

561 given_names: Annotated[ 

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

563 ] 

564 name_particle: Annotated[ 

565 str | None, 

566 Field( 

567 alias="name-particle", 

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

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

570 examples=["von"], 

571 ), 

572 ] = None 

573 name_suffix: Annotated[ 

574 str | None, 

575 Field( 

576 alias="name-suffix", 

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

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

579 ), 

580 ] = None 

581 affiliation: Annotated[ 

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

583 ] = None 

584 

585 # helper methods 

586 

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

588 @classmethod 

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

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

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

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

593 if ( 

594 orcid is not None 

595 and isinstance(orcid, str) 

596 and re.match(orcid_regex, orcid) 

597 ): 

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

599 return orcid 

600 

601 @property 

602 def full_name(self) -> str: 

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

604 names = [] 

605 

606 if self.given_names: 

607 names.append(self.given_names) 

608 

609 if self.name_particle: 

610 names.append(self.name_particle) 

611 

612 if self.family_names: 

613 names.append(self.family_names) 

614 

615 if self.name_suffix: 

616 names.append(self.name_suffix) 

617 

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

619 

620 @classmethod 

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

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

623 

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

625 A single-word name is not a valid person name and raises a `ValidationError`. 

626 """ 

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

628 if m is None: 

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

630 return Person.model_validate( 

631 { 

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

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

634 } 

635 ) 

636 names, mail = ( 

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

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

639 ) 

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

641 # we only compare on full_name anyway. 

642 return Person.model_validate( 

643 { 

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

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

646 "email": mail, 

647 } 

648 ) 

649 

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

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

652 

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

654 """ 

655 if not isinstance(other, Person): 

656 return False 

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

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

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

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

661 

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

663 # sourcery skip: merge-nested-ifs 

664 if ( 

665 self.email is not None 

666 and other.email is not None 

667 and self.email == other.email 

668 ): 

669 # an email address belongs to exactly one person 

670 # => same email -> same person 

671 return True 

672 # otherwise, need to check name 

673 # (a person often has multiple email addresses) 

674 

675 # no orcids, no/distinct email address 

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

677 return self.full_name == other.full_name 

678 

679 

680def parse_name_email(person_obj: str) -> Person | Entity | None: 

681 """Parse a "Name <email>" string to a Person, falling back to an Entity.""" 

682 try: 

683 return Person.from_name_email_string(person_obj) 

684 except (ValueError, AttributeError): 

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

686 

687 try: 

688 return Entity.from_name_email_string(person_obj) 

689 except (ValueError, AttributeError): 

690 logger.warning(f"Cannot convert {person_obj} to Entity.") 

691 return None 

692 

693 

694def _ensure_distinct(items: list, kind: str) -> list: 

695 """Raise a ValueError if the same person/entity is listed twice.""" 

696 for i in range(len(items)): 

697 for j in range(i + 1, len(items)): 

698 if items[i].same_person(items[j]): 

699 a = pretty_repr(json.loads(items[i].model_dump_json())) 

700 b = pretty_repr(json.loads(items[j].model_dump_json())) 

701 msg = f"Same {kind} is listed twice:\n{a}\n{b}" 

702 raise ValueError(msg) 

703 return items 

704 

705 

706class ProjectMetadata(SomesyBaseModel): 

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

708 

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

710 

711 @field_validator("people") 

712 @classmethod 

713 def ensure_distinct_people(cls, people): 

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

715 return _ensure_distinct(people, "person") 

716 

717 @field_validator("entities") 

718 @classmethod 

719 def ensure_distinct_entities(cls, entities): 

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

721 return _ensure_distinct(entities, "entity") 

722 

723 @model_validator(mode="after") 

724 def at_least_one_author(self) -> ProjectMetadata: 

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

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

727 raise ValueError( 

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

729 ) 

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

731 e.author for e in self.entities 

732 ): 

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

734 return self 

735 

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

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

738 abstract: Annotated[ 

739 str | None, 

740 Field( 

741 description="Project abstract for CITATION.cff (defaults to description)." 

742 ), 

743 ] = None 

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

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

746 license: Annotated[ 

747 LicenseEnum | list[LicenseEnum], 

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

749 ] 

750 

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

752 @classmethod 

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

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

755 if doi is None: 

756 return None 

757 if isinstance(doi, str): 

758 doi = re.sub( 

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

760 ) 

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

762 return doi 

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

764 

765 @field_validator("license") 

766 @classmethod 

767 def validate_license_list(cls, license): 

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

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

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

771 return license 

772 

773 homepage: Annotated[ 

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

775 ] = None 

776 repository: Annotated[ 

777 HttpUrlStr | None, 

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

779 ] = None 

780 documentation: Annotated[ 

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

782 ] = None 

783 

784 keywords: Annotated[ 

785 list[str] | None, 

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

787 ] = None 

788 

789 people: list[Person] = Field( 

790 default_factory=list, 

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

792 ) 

793 

794 entities: list[Entity] = Field( 

795 default_factory=list, 

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

797 ) 

798 

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

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

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

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

803 return authors 

804 

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

806 """Return people marked as publication authors. 

807 

808 This always includes people marked as authors. 

809 """ 

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

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

812 p.publication_author for p in self.entities 

813 ): 

814 return [] 

815 publication_authors: list[Person | Entity] = [ 

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

817 ] 

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

819 return publication_authors 

820 

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

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

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

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

825 return maintainers 

826 

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

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

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

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

831 return contributors 

832 

833 

834class PartialProjectMetadata(ProjectMetadata): 

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

836 

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

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

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

840 

841 @model_validator(mode="after") 

842 def at_least_one_author(self) -> PartialProjectMetadata: 

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

844 return self 

845 

846 

847class SomesyInput(SomesyBaseModel): 

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

849 

850 _origin: Path | None = PrivateAttr(default=None) 

851 

852 project: Annotated[ 

853 ProjectMetadata | PartialProjectMetadata, 

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

855 ] 

856 config: Annotated[ 

857 SomesyConfig, 

858 Field( 

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

860 default_factory=lambda: SomesyConfig(), 

861 ), 

862 ] 

863 

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

865 @model_validator(mode="after") 

866 def set_origin(self): 

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

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

869 self.config.pass_validation 

870 ): 

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

872 if self.config and self.config.input_file: 

873 self._origin = self.config.input_file 

874 return self 

875 

876 def is_somesy_file(self) -> bool: 

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

878 

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

880 """ 

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

882 

883 @classmethod 

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

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

886 

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

888 """ 

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

890 

891 @classmethod 

892 def from_input_file( 

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

894 ) -> SomesyInput: 

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

896 content = get_input_content(path) 

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

898 if allow_incomplete: 

899 config.pass_validation = True 

900 project_model = ( 

901 PartialProjectMetadata if config.pass_validation else ProjectMetadata 

902 ) 

903 ret = cls.model_validate( 

904 { 

905 **content, 

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

907 "config": config, 

908 } 

909 ) 

910 ret._origin = path 

911 return ret