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

313 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-04 11:35 +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="Pass validation for all output files."), 

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 ) 

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

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

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

280 dct.update(self.model_dump()) 

281 somesy_input.config = SomesyConfig(**dct) 

282 return somesy_input 

283 

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

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

286 

287 Args: 

288 base_dir: The base directory to resolve paths against. 

289 

290 """ 

291 

292 def resolve_path( 

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

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

295 if paths is None: 

296 return None 

297 if isinstance(paths, list): 

298 return [base_dir / p for p in paths] 

299 return base_dir / paths 

300 

301 # Resolve all file paths 

302 resolved_input = resolve_path(self.input_file) 

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

304 for field in ( 

305 "pyproject_file", 

306 "package_json_file", 

307 "julia_file", 

308 "fortran_file", 

309 "pom_xml_file", 

310 "mkdocs_file", 

311 "rust_file", 

312 "cff_file", 

313 "codemeta_file", 

314 "packages", 

315 ): 

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

317 if resolved is not None: 

318 setattr(self, field, resolved) 

319 

320 

321# -------- 

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

323 

324 

325class ContributorBaseModel(SomesyBaseModel): 

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

327 

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

329 """ 

330 

331 email: Annotated[ 

332 str | None, 

333 Field( 

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

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

336 ), 

337 ] = None 

338 

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

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

341 None 

342 ) 

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

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

345 None 

346 ) 

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

348 post_code: Annotated[ 

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

350 ] = None 

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

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

353 

354 # ---- 

355 # somesy-specific extensions 

356 author: Annotated[ 

357 bool, 

358 Field( 

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

360 ), 

361 ] = False 

362 publication_author: Annotated[ 

363 bool | None, 

364 Field( 

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

366 ), 

367 ] = None 

368 maintainer: Annotated[ 

369 bool, 

370 Field( 

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

372 ), 

373 ] = False 

374 

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

376 contribution: Annotated[ 

377 str | None, 

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

379 ] = None 

380 contribution_types: Annotated[ 

381 list[ContributionTypeEnum] | None, 

382 Field( 

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

384 min_length=1, 

385 ), 

386 ] = None 

387 contribution_begin: Annotated[ 

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

389 ] = None 

390 contribution_end: Annotated[ 

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

392 ] = None 

393 

394 @model_validator(mode="before") 

395 @classmethod 

396 def author_implies_publication(cls, values): 

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

398 if values.get("author"): 

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

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

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

402 raise ValueError(msg) 

403 values["publication_author"] = True 

404 return values 

405 

406 # helper methods 

407 @property 

408 def full_name(self) -> str: 

409 """Return the name of the contributor.""" 

410 raise NotImplementedError 

411 

412 def to_name_email_string(self) -> str: 

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

414 if self.email: 

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

416 else: 

417 return self.full_name 

418 

419 @classmethod 

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

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

422 

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

424 """ 

425 raise NotImplementedError 

426 

427 

428class Entity(ContributorBaseModel): 

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

430 

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

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

433 """ 

434 

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

436 

437 date_end: Annotated[ 

438 date | None, 

439 Field( 

440 alias="date-end", 

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

442 ), 

443 ] = None 

444 date_start: Annotated[ 

445 date | None, 

446 Field( 

447 alias="date-start", 

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

449 ), 

450 ] = None 

451 location: Annotated[ 

452 str | None, 

453 Field( 

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

455 ), 

456 ] = None 

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

458 website: Annotated[ 

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

460 ] = None 

461 rorid: Annotated[ 

462 HttpUrlStr | None, 

463 Field( 

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

465 ), 

466 ] = None 

467 

468 # helper methods 

469 @property 

470 def full_name(self) -> str: 

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

472 return self.name 

473 

474 @classmethod 

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

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

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

478 if m is None: 

479 return Entity(name=person) 

480 

481 name, mail = ( 

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

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

484 ) 

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

486 

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

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

489 

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

491 """ 

492 if not isinstance(other, Entity): 

493 return False 

494 if ( 

495 self.rorid is not None 

496 and other.rorid is not None 

497 and self.rorid == other.rorid 

498 ): 

499 return True 

500 if ( 

501 self.website is not None 

502 and other.website is not None 

503 and self.website == other.website 

504 ): 

505 return True 

506 if ( 

507 self.email is not None 

508 and other.email is not None 

509 and self.email == other.email 

510 ): 

511 return True 

512 return self.name == other.name 

513 

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

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

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

517 # convert ret to dict 

518 ret = json.loads(ret) 

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

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

521 ret.pop("rorid") 

522 # convert ret back to json string 

523 return json.dumps(ret) 

524 

525 

526class Person(ContributorBaseModel): 

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

528 

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

530 """ 

531 

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

533 

534 orcid: Annotated[ 

535 HttpUrlStr | None, 

536 Field( 

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

538 ), 

539 ] = None 

540 family_names: Annotated[ 

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

542 ] 

543 given_names: Annotated[ 

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

545 ] 

546 name_particle: Annotated[ 

547 str | None, 

548 Field( 

549 alias="name-particle", 

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

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

552 examples=["von"], 

553 ), 

554 ] = None 

555 name_suffix: Annotated[ 

556 str | None, 

557 Field( 

558 alias="name-suffix", 

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

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

561 ), 

562 ] = None 

563 affiliation: Annotated[ 

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

565 ] = None 

566 

567 # helper methods 

568 

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

570 @classmethod 

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

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

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

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

575 if ( 

576 orcid is not None 

577 and isinstance(orcid, str) 

578 and re.match(orcid_regex, orcid) 

579 ): 

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

581 return orcid 

582 

583 @property 

584 def full_name(self) -> str: 

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

586 names = [] 

587 

588 if self.given_names: 

589 names.append(self.given_names) 

590 

591 if self.name_particle: 

592 names.append(self.name_particle) 

593 

594 if self.family_names: 

595 names.append(self.family_names) 

596 

597 if self.name_suffix: 

598 names.append(self.name_suffix) 

599 

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

601 

602 @classmethod 

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

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

605 

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

607 """ 

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

609 if m is None: 

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

611 return Person.model_validate( 

612 { 

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

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

615 } 

616 ) 

617 names, mail = ( 

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

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

620 ) 

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

622 # we only compare on full_name anyway. 

623 return Person.model_validate( 

624 { 

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

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

627 "email": mail, 

628 } 

629 ) 

630 

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

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

633 

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

635 """ 

636 if not isinstance(other, Person): 

637 return False 

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

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

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

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

642 

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

644 # sourcery skip: merge-nested-ifs 

645 if ( 

646 self.email is not None 

647 and other.email is not None 

648 and self.email == other.email 

649 ): 

650 # an email address belongs to exactly one person 

651 # => same email -> same person 

652 return True 

653 # otherwise, need to check name 

654 # (a person often has multiple email addresses) 

655 

656 # no orcids, no/distinct email address 

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

658 return self.full_name == other.full_name 

659 

660 

661class ProjectMetadata(SomesyBaseModel): 

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

663 

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

665 

666 @field_validator("people") 

667 @classmethod 

668 def ensure_distinct_people(cls, people): 

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

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

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

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

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

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

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

676 raise ValueError(msg) 

677 return people 

678 

679 @field_validator("entities") 

680 @classmethod 

681 def ensure_distinct_entities(cls, entities): 

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

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

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

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

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

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

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

689 raise ValueError(msg) 

690 return entities 

691 

692 @model_validator(mode="after") 

693 def at_least_one_author(self) -> ProjectMetadata: 

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

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

696 raise ValueError( 

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

698 ) 

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

700 e.author for e in self.entities 

701 ): 

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

703 return self 

704 

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

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

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

708 license: Annotated[ 

709 LicenseEnum | list[LicenseEnum], 

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

711 ] 

712 

713 @field_validator("license") 

714 @classmethod 

715 def validate_license_list(cls, license): 

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

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

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

719 return license 

720 

721 homepage: Annotated[ 

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

723 ] = None 

724 repository: Annotated[ 

725 HttpUrlStr | None, 

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

727 ] = None 

728 documentation: Annotated[ 

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

730 ] = None 

731 

732 keywords: Annotated[ 

733 list[str] | None, 

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

735 ] = None 

736 

737 people: list[Person] = Field( 

738 default_factory=list, 

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

740 ) 

741 

742 entities: list[Entity] = Field( 

743 default_factory=list, 

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

745 ) 

746 

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

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

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

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

751 return authors 

752 

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

754 """Return people marked as publication authors. 

755 

756 This always includes people marked as authors. 

757 """ 

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

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

760 p.publication_author for p in self.entities 

761 ): 

762 return [] 

763 publication_authors: list[Person | Entity] = [ 

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

765 ] 

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

767 return publication_authors 

768 

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

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

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

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

773 return maintainers 

774 

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

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

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

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

779 return contributors 

780 

781 

782class SomesyInput(SomesyBaseModel): 

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

784 

785 _origin: Path | None 

786 

787 project: Annotated[ 

788 ProjectMetadata, 

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

790 ] 

791 config: Annotated[ 

792 SomesyConfig, 

793 Field( 

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

795 default_factory=lambda: SomesyConfig(), 

796 ), 

797 ] 

798 

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

800 @model_validator(mode="after") 

801 def set_origin(self): 

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

803 if self.config and self.config.input_file: 

804 self._origin = self.config.input_file 

805 return self 

806 

807 def is_somesy_file(self) -> bool: 

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

809 

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

811 """ 

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

813 

814 @classmethod 

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

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

817 

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

819 """ 

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

821 

822 @classmethod 

823 def from_input_file(cls, path: Path) -> SomesyInput: 

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

825 content = get_input_content(path) 

826 ret = SomesyInput(**content) 

827 ret._origin = path 

828 return ret