Coverage for src/somesy/codemeta/writer.py: 66%

137 statements  

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

1"""codemeta.json creation module.""" 

2 

3import json 

4import logging 

5from collections import OrderedDict 

6from collections.abc import Sequence 

7from pathlib import Path 

8from typing import Any 

9 

10from somesy.codemeta.utils import validate_codemeta 

11from somesy.core.log import VERBOSE 

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

13from somesy.core.writer import FieldKeyMapping, ProjectMetadataWriter 

14 

15logger = logging.getLogger("somesy") 

16 

17 

18class CodeMeta(ProjectMetadataWriter): 

19 """Codemeta.json parser and saver.""" 

20 

21 def __init__( 

22 self, 

23 path: Path, 

24 merge: bool | None = False, 

25 pass_validation: bool | None = False, 

26 ): 

27 """Codemeta.json parser. 

28 

29 See [somesy.core.writer.ProjectMetadataWriter.__init__][]. 

30 """ 

31 self.merge = merge 

32 self._default_context = [ 

33 "https://doi.org/10.5063/schema/codemeta-2.0", 

34 "https://w3id.org/software-iodata", 

35 "https://raw.githubusercontent.com/jantman/repostatus.org/master/badges/latest/ontology.jsonld", 

36 "https://schema.org", 

37 "https://w3id.org/software-types", 

38 ] 

39 mappings: FieldKeyMapping = { 

40 "repository": ["codeRepository"], 

41 "homepage": ["softwareHelp"], 

42 "documentation": ["buildInstructions"], 

43 "keywords": ["keywords"], 

44 "authors": ["author"], 

45 "maintainers": ["maintainer"], 

46 "contributors": ["contributor"], 

47 } 

48 # delete the file if it exists 

49 if path.is_file() and not self.merge: 

50 logger.log(VERBOSE, "Deleting existing codemeta.json file.") 

51 path.unlink() 

52 super().__init__( 

53 path, 

54 create_if_not_exists=True, 

55 direct_mappings=mappings, 

56 pass_validation=pass_validation, 

57 ) 

58 

59 # if merge is True, add necessary keys to the codemeta.json file 

60 if self.merge: 

61 # check if the context exists but is not a list 

62 if isinstance(self._data["@context"], str): 

63 self._data["@context"] = [self._data["@context"]] 

64 # finally add each item in the context to the codemeta.json file if it does not exist in the list 

65 for item in self._default_context: 

66 if item not in self._data["@context"]: 

67 self._data["@context"].append(item) 

68 

69 # add (or overwrite) the type 

70 self._data["@type"] = "SoftwareSourceCode" 

71 

72 # overwrite authors, maintainers, contributors 

73 self._data["author"] = [] 

74 self._data["maintainer"] = [] 

75 self._data["contributor"] = [] 

76 

77 @property 

78 def authors(self): 

79 """Return the only author of the codemeta.json file as list.""" 

80 return self._get_property(self._get_key("publication_authors")) or [] 

81 

82 @authors.setter 

83 def authors(self, authors: list[Person | Entity]) -> None: 

84 """Set the authors of the project.""" 

85 authors_dict = [self._from_person(a) for a in authors] 

86 self._set_property(self._get_key("authors"), authors_dict) 

87 

88 @property 

89 def maintainers(self): 

90 """Return the maintainers of the codemeta.json file.""" 

91 return self._get_property(self._get_key("maintainers")) 

92 

93 @maintainers.setter 

94 def maintainers(self, maintainers: list[Person | Entity]) -> None: 

95 """Set the maintainers of the project.""" 

96 maintainers_dict = [self._from_person(m) for m in maintainers] 

97 self._set_property(self._get_key("maintainers"), maintainers_dict) 

98 

99 @property 

100 def contributors(self): 

101 """Return the contributors of the codemeta.json file.""" 

102 return self._get_property(self._get_key("contributors")) 

103 

104 @contributors.setter 

105 def contributors(self, contributors: list[Person | Entity]) -> None: 

106 """Set the contributors of the project.""" 

107 contributors_dict = [self._from_person(c) for c in contributors] 

108 self._set_property(self._get_key("contributors"), contributors_dict) 

109 

110 def _load(self) -> None: 

111 """Load codemeta.json file.""" 

112 with self.path.open() as f: 

113 self._data = json.load(f, object_pairs_hook=OrderedDict) 

114 

115 def _validate(self) -> None: 

116 """Validate codemeta.json content using pydantic class.""" 

117 if self.pass_validation: 

118 return 

119 invalid_fields = validate_codemeta(self._data) 

120 if invalid_fields and self.merge: 

121 raise ValueError( 

122 f"Invalid fields in codemeta.json: {invalid_fields}. Cannot merge with invalid fields." 

123 ) 

124 

125 def _init_new_file(self) -> None: 

126 """Create a new codemeta.json file with bare minimum generic data.""" 

127 data = { 

128 "@context": [ 

129 "https://doi.org/10.5063/schema/codemeta-2.0", 

130 "https://w3id.org/software-iodata", 

131 "https://raw.githubusercontent.com/jantman/repostatus.org/master/badges/latest/ontology.jsonld", 

132 "https://schema.org", 

133 "https://w3id.org/software-types", 

134 ], 

135 "@type": "SoftwareSourceCode", 

136 "author": [], 

137 } 

138 # dump to file 

139 with self.path.open("w+", newline="\n") as f: 

140 json.dump(data, f) 

141 

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

143 """Save the codemeta.json file.""" 

144 path = path or self.path 

145 logger.debug(f"Saving codemeta.json to {path}") 

146 

147 # copy the _data 

148 data = self._data.copy() 

149 

150 # set license 

151 if "license" in data: 

152 licenses = data["license"] 

153 licenses = licenses if isinstance(licenses, list) else [licenses] 

154 data["license"] = [ 

155 f"https://spdx.org/licenses/{license}" for license in licenses 

156 ] 

157 

158 # if softwareHelp is set, set url to softwareHelp 

159 if "softwareHelp" in data: 

160 data["url"] = data["softwareHelp"] 

161 

162 with path.open("w", newline="\n") as f: 

163 # codemeta.json indentation is 2 spaces 

164 json.dump(data, f) 

165 

166 @staticmethod 

167 def _from_person(person: Person | Entity) -> dict: 

168 """Convert project metadata person object to codemeta.json dict for person format.""" 

169 if isinstance(person, Person): 

170 person_dict = { 

171 "@type": "Person", 

172 } 

173 if person.given_names: 

174 person_dict["givenName"] = person.given_names 

175 if person.family_names: 

176 person_dict["familyName"] = person.family_names 

177 if person.email: 

178 person_dict["email"] = person.email 

179 if person.orcid: 

180 person_dict["@id"] = str(person.orcid) 

181 person_dict["identifier"] = str(person.orcid) 

182 if person.address: 

183 person_dict["address"] = person.address 

184 if person.affiliation: 

185 person_dict["affiliation"] = person.affiliation 

186 return person_dict 

187 else: 

188 entity_dict = {"@type": "Organization", "name": person.name} 

189 if person.address: 

190 entity_dict["address"] = person.address 

191 if person.email: 

192 entity_dict["email"] = person.email 

193 if person.date_start: 

194 entity_dict["startDate"] = person.date_start.isoformat() 

195 if person.date_end: 

196 entity_dict["endDate"] = person.date_end.isoformat() 

197 if person.website: 

198 entity_dict["@id"] = str(person.website) 

199 entity_dict["identifier"] = str(person.website) 

200 if person.rorid: 

201 entity_dict["@id"] = str(person.rorid) 

202 entity_dict["identifier"] = str(person.rorid) 

203 return entity_dict 

204 

205 @staticmethod 

206 def _to_person(person_obj) -> Person | Entity: 

207 """Convert codemeta.json dict or str for person/entity format to project metadata person object.""" 

208 if "name" in person_obj: 

209 entity_obj = {"name": person_obj["name"]} 

210 return Entity(**entity_obj) 

211 else: 

212 person_data = {} 

213 if "givenName" in person_obj: 

214 person_data["given_names"] = person_obj["givenName"].strip() 

215 if "familyName" in person_obj: 

216 person_data["family_names"] = person_obj["familyName"].strip() 

217 if "email" in person_obj: 

218 person_data["email"] = person_obj["email"].strip() 

219 if "@id" in person_obj: 

220 person_data["orcid"] = person_obj["@id"].strip() 

221 if "address" in person_obj: 

222 person_data["address"] = person_obj["address"].strip() 

223 

224 return Person(**person_data) 

225 

226 def _sync_person_list( 

227 self, old: list[Any], new: Sequence[Person | Entity] 

228 ) -> list[Any]: 

229 """Override the _sync_person_list function from ProjectMetadataWriter. 

230 

231 This method wont care about existing persons in codemeta.json file. 

232 

233 Args: 

234 old (List[Any]): existing persons in codemeta.json file, in this case ignored in the output. However, it is necessary to make the function compatible with the parent class. 

235 new (List[Person]): new persons to add to codemeta.json file 

236 

237 Returns: 

238 List[Any]: list of new persons to add to codemeta.json file 

239 

240 """ 

241 return list(new) 

242 

243 def sync(self, metadata: ProjectMetadata) -> None: 

244 """Sync codemeta.json with project metadata. 

245 

246 Use existing sync function from ProjectMetadataWriter but update repository and contributors. 

247 """ 

248 super().sync(metadata) 

249 licenses = metadata.license 

250 self.license = ( 

251 [license.value for license in licenses] 

252 if isinstance(licenses, list) 

253 else licenses.value 

254 ) 

255 self.contributors = metadata.contributors() 

256 

257 # add the default context items if they are not already in the codemeta.json file 

258 for item in self._default_context: 

259 if item not in self._data["@context"]: 

260 self._data["@context"].append(item)