Coverage for src/somesy/cff/writer.py: 99%
71 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:13 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-11-03 13:13 +0000
1"""Citation File Format (CFF) parser and saver."""
3import json
4from copy import deepcopy
5from io import StringIO
6from pathlib import Path
8from cffconvert import Citation
9from ruamel.yaml import YAML
10from ruamel.yaml.scalarstring import LiteralScalarString
12from somesy.core.models import Entity, Person, ProjectMetadata
13from somesy.core.writer import FieldKeyMapping, IgnoreKey, ProjectMetadataWriter
16def _drop_rorid(data) -> None:
17 """Remove `rorid` from entities; earlier somesy versions wrote this non-CFF key."""
18 for key in ("authors", "contact"):
19 for entry in data.get(key) or []:
20 if isinstance(entry, dict) and "name" in entry:
21 entry.pop("rorid", None)
24class CFF(ProjectMetadataWriter):
25 """Citation File Format (CFF) parser and saver."""
27 def __init__(
28 self,
29 path: Path,
30 create_if_not_exists: bool = True,
31 pass_validation: bool = False,
32 ):
33 """Citation File Format (CFF) parser.
35 See [somesy.core.writer.ProjectMetadataWriter.__init__][].
36 """
37 self._yaml = YAML()
38 self._yaml.preserve_quotes = True
39 self._yaml.indent(mapping=2, sequence=4, offset=2)
40 self._yaml.width = 120
42 mappings: FieldKeyMapping = {
43 "name": ["title"],
44 "description": ["abstract"],
45 "homepage": ["url"],
46 "repository": ["repository-code"],
47 "documentation": IgnoreKey(),
48 "maintainers": ["contact"],
49 }
50 super().__init__(
51 path,
52 create_if_not_exists=create_if_not_exists,
53 direct_mappings=mappings,
54 pass_validation=pass_validation,
55 )
57 def _init_new_file(self):
58 """Initialize new CFF file."""
59 self._data = {
60 "cff-version": "1.2.0",
61 "message": "If you use this software, please cite it using these metadata.",
62 "type": "software",
63 }
64 with open(self.path, "w", encoding="utf-8") as f:
65 self._yaml.dump(self._data, f)
67 def _load(self):
68 """Load the CFF file."""
69 with open(self.path, encoding="utf-8") as f:
70 self._data = self._yaml.load(f)
72 def _validate(self) -> None:
73 """Validate the CFF file."""
74 if self.pass_validation:
75 return
76 try:
77 # validate the loaded data without a stray `rorid` (repaired on sync)
78 data = deepcopy(self._data)
79 _drop_rorid(data)
80 stream = StringIO()
81 self._yaml.dump(data, stream)
82 Citation(stream.getvalue(), src=self.path).validate()
83 except ValueError as e:
84 raise ValueError(f"CITATION.cff file is not valid!\n{e}") from e
86 def save(self, path: Path | None = None) -> None:
87 """Save the CFF object to a file."""
88 path = path or self.path
90 # if description have new line characters, it should be saved as multiline string
91 if "abstract" in self._data and "\n" in self._data["abstract"]:
92 self._data["abstract"] = LiteralScalarString(self._data["abstract"])
94 self._yaml.dump(self._data, path)
96 def _sync_authors(self, metadata: ProjectMetadata) -> None:
97 """Ensure that publication authors are added all into author list."""
98 self.authors = self._sync_person_list(
99 self.authors, metadata.publication_authors()
100 )
102 def sync(self, metadata: ProjectMetadata) -> None:
103 """Sync CFF metadata."""
104 _drop_rorid(self._data)
105 super().sync(metadata)
106 # CFF 1.2.0 allows a list of SPDX ids
107 if isinstance(metadata.license, list) and len(metadata.license) > 1:
108 self.license = [license.value for license in metadata.license]
109 # an abstract can be longer than a package summary, so prefer it here
110 if metadata.abstract:
111 self.description = metadata.abstract
112 if metadata.doi:
113 self._data["doi"] = metadata.doi
115 @staticmethod
116 def _from_person(person: Person | Entity):
117 """Convert project metadata person or entity object to cff dict for person format."""
118 json_str = person.model_dump_json(
119 exclude={
120 "contribution",
121 "contribution_types",
122 "contribution_begin",
123 "contribution_end",
124 "author",
125 "publication_author",
126 "maintainer",
127 },
128 by_alias=True, # e.g. family_names -> family-names, etc.
129 )
130 ret = json.loads(json_str)
131 ret.pop("rorid", None) # not a CFF field
132 return ret
134 @staticmethod
135 def _to_person(person_obj) -> Person | Entity:
136 """Parse CFF Person to a somesy Person or entity."""
137 # if the object has key name, it is an entity
138 if "name" in person_obj:
139 Entity._aliases()
140 ret = Entity.make_partial(person_obj)
141 else:
142 Person._aliases()
143 ret = Person.make_partial(person_obj)
145 # construct (partial) Person while preserving key order from YAML
146 ret.set_key_order(list(person_obj.keys()))
147 return ret