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

1"""Citation File Format (CFF) parser and saver.""" 

2 

3import json 

4from copy import deepcopy 

5from io import StringIO 

6from pathlib import Path 

7 

8from cffconvert import Citation 

9from ruamel.yaml import YAML 

10from ruamel.yaml.scalarstring import LiteralScalarString 

11 

12from somesy.core.models import Entity, Person, ProjectMetadata 

13from somesy.core.writer import FieldKeyMapping, IgnoreKey, ProjectMetadataWriter 

14 

15 

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) 

22 

23 

24class CFF(ProjectMetadataWriter): 

25 """Citation File Format (CFF) parser and saver.""" 

26 

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. 

34 

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 

41 

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 ) 

56 

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) 

66 

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) 

71 

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 

85 

86 def save(self, path: Path | None = None) -> None: 

87 """Save the CFF object to a file.""" 

88 path = path or self.path 

89 

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"]) 

93 

94 self._yaml.dump(self._data, path) 

95 

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 ) 

101 

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 

114 

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 

133 

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) 

144 

145 # construct (partial) Person while preserving key order from YAML 

146 ret.set_key_order(list(person_obj.keys())) 

147 return ret