Coverage for src/somesy/core/models.py: 95%
352 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:31 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:31 +0000
1"""Core models for the somesy package."""
3from __future__ import annotations
5import functools
6import json
7import logging
8import re
9from datetime import date
10from pathlib import Path
11from typing import Annotated, Any
13from pydantic import (
14 BaseModel,
15 Field,
16 PrivateAttr,
17 field_validator,
18 model_validator,
19)
20from rich.pretty import pretty_repr
22from .core import get_input_content
23from .log import SomesyLogLevel
24from .types import ContributionTypeEnum, Country, HttpUrlStr, LicenseEnum
26logger = logging.getLogger("somesy")
28# --------
29# Somesy configuration model
32class SomesyBaseModel(BaseModel):
33 """Customized pydantic BaseModel for somesy.
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).
39 It can be set on an instance using the set_key_order method,
40 and is preserved by `copy()`.
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 """
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 }
54 # ----
55 # Key order magic
57 _key_order: list[str] = PrivateAttr([])
58 """List of field names (NOT aliases!) in the order they should be written in."""
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()}
66 @classmethod
67 def make_partial(cls, dct):
68 """Construct unvalidated partial model from dict.
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()})
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]
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
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
94 def _reorder_dict(self, dct):
95 """Return dict with patched key order (according to `self._key_order`).
97 Keys in `dct` not listed in `self._key_order` come after all others.
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}
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)
112 dct = super().model_dump(*args, **kwargs, by_alias=False)
113 ret = self._reorder_dict(dct)
115 if by_alias:
116 ret = {type(self).model_fields[k].alias or k: v for k, v in ret.items()}
117 return ret
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)
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)
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)
133_SOMESY_TARGETS = [
134 "cff",
135 "pyproject",
136 "package_json",
137 "codemeta",
138 "julia",
139 "fortran",
140 "pom_xml",
141 "mkdocs",
142 "rust",
143]
146class SomesyConfig(SomesyBaseModel):
147 """Pydantic model for somesy tool configuration.
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 """
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)
161 return values
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
177 input_file: Annotated[
178 Path | None, Field(description="Project metadata input file path.")
179 ] = Path("somesy.toml")
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")
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")
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")
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")
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")
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")
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")
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 )
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
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
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
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 )
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
274 _cli_overrides: dict[str, Any] = PrivateAttr(default_factory=dict)
275 """Options explicitly set on this config (CLI), set on the combined config."""
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
292 def resolve_paths(self, base_dir: Path) -> None:
293 """Resolve all paths in the config relative to the given base directory.
295 Args:
296 base_dir: The base directory to resolve paths against.
298 """
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
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)
329# --------
330# Project metadata model (modified from CITATION.cff)
333class ContributorBaseModel(SomesyBaseModel):
334 """Base model for Person and Entity models.
336 This schema is based on CITATION.cff 1.2, modified and extended for the needs of somesy.
337 """
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
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
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
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
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
414 # helper methods
415 @property
416 def full_name(self) -> str:
417 """Return the name of the contributor."""
418 raise NotImplementedError
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
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>`.
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
437class Entity(ContributorBaseModel):
438 """Metadata about an entity in the context of a software project ownership.
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 """
444 # NOTE: we rely on the defined aliases for direct CITATION.cff interoperability.
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
477 # helper methods
478 @property
479 def full_name(self) -> str:
480 """Use same property as Person for code integration."""
481 return self.name
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)
490 name, mail = (
491 m.group(1).strip(),
492 m.group(2).strip(),
493 )
494 return Entity(name=name, email=mail)
496 def same_person(self, other: Person | Entity) -> bool:
497 """Return whether two Entity metadata records are about the same real person.
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
528 def model_dump_json(self, *args, **kwargs):
529 """Patched json method (to preserve custom key order).
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)
544class Person(ContributorBaseModel):
545 """Metadata about a person in the context of a software project.
547 This schema is based on CITATION.cff 1.2, modified and extended for the needs of somesy.
548 """
550 # NOTE: we rely on the defined aliases for direct CITATION.cff interoperability.
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
585 # helper methods
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
601 @property
602 def full_name(self) -> str:
603 """Return the full name of the person."""
604 names = []
606 if self.given_names:
607 names.append(self.given_names)
609 if self.name_particle:
610 names.append(self.name_particle)
612 if self.family_names:
613 names.append(self.family_names)
615 if self.name_suffix:
616 names.append(self.name_suffix)
618 return " ".join(names) if names else ""
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>`.
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 )
650 def same_person(self, other) -> bool:
651 """Return whether two Person metadata records are about the same real person.
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)
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)
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
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.")
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
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
706class ProjectMetadata(SomesyBaseModel):
707 """Pydantic model for Project Metadata Input."""
709 model_config = {"extra": "ignore"}
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")
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")
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
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 ]
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.")
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
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
784 keywords: Annotated[
785 list[str] | None,
786 Field(min_length=1, description="Keywords that describe the project."),
787 ] = None
789 people: list[Person] = Field(
790 default_factory=list,
791 description="Project authors, maintainers and contributors.",
792 )
794 entities: list[Entity] = Field(
795 default_factory=list,
796 description="Project authors, maintainers and contributors as entities (organizations).",
797 )
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
805 def publication_authors(self) -> list[Person | Entity]:
806 """Return people marked as publication authors.
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
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
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
834class PartialProjectMetadata(ProjectMetadata):
835 """Validated project metadata that may omit normally required values."""
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]
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
847class SomesyInput(SomesyBaseModel):
848 """The complete somesy input file (`somesy.toml`) or section (`pyproject.toml`)."""
850 _origin: Path | None = PrivateAttr(default=None)
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 ]
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
876 def is_somesy_file(self) -> bool:
877 """Return whether this somesy input is from a somesy config file.
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("."))
883 @classmethod
884 def is_somesy_file_path(cls, path: Path) -> bool:
885 """Return whether the path looks like a somesy config file.
887 That means, returns False if it is e.g. pyproject.toml or package.json.
888 """
889 return str(path).endswith("somesy.toml")
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