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

131 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2025-03-10 14:56 +0000

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

2 

3import json 

4import logging 

5from collections import OrderedDict 

6from pathlib import Path 

7from typing import Any, List, Optional, Union 

8 

9from somesy.codemeta.utils import validate_codemeta 

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

11from somesy.core.writer import FieldKeyMapping, ProjectMetadataWriter 

12 

13logger = logging.getLogger("somesy") 

14 

15 

16class CodeMeta(ProjectMetadataWriter): 

17 """Codemeta.json parser and saver.""" 

18 

19 def __init__( 

20 self, 

21 path: Path, 

22 merge: Optional[bool] = False, 

23 pass_validation: Optional[bool] = False, 

24 ): 

25 """Codemeta.json parser. 

26 

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

28 """ 

29 self.merge = merge 

30 self._default_context = [ 

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

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

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

34 "https://schema.org", 

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

36 ] 

37 mappings: FieldKeyMapping = { 

38 "repository": ["codeRepository"], 

39 "homepage": ["softwareHelp"], 

40 "documentation": ["buildInstructions"], 

41 "keywords": ["keywords"], 

42 "authors": ["author"], 

43 "maintainers": ["maintainer"], 

44 "contributors": ["contributor"], 

45 } 

46 # delete the file if it exists 

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

48 logger.verbose("Deleting existing codemeta.json file.") 

49 path.unlink() 

50 super().__init__( 

51 path, 

52 create_if_not_exists=True, 

53 direct_mappings=mappings, 

54 pass_validation=pass_validation, 

55 ) 

56 

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

58 if self.merge: 

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

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

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

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

63 for item in self._default_context: 

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

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

66 

67 # add (or overwrite) the type 

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

69 

70 # overwrite authors, maintainers, contributors 

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

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

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

74 

75 @property 

76 def authors(self): 

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

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

79 

80 @authors.setter 

81 def authors(self, authors: List[Union[Person, Entity]]) -> None: 

82 """Set the authors of the project.""" 

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

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

85 

86 @property 

87 def maintainers(self): 

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

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

90 

91 @maintainers.setter 

92 def maintainers(self, maintainers: List[Union[Person, Entity]]) -> None: 

93 """Set the maintainers of the project.""" 

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

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

96 

97 @property 

98 def contributors(self): 

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

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

101 

102 @contributors.setter 

103 def contributors(self, contributors: List[Union[Person, Entity]]) -> None: 

104 """Set the contributors of the project.""" 

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

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

107 

108 def _load(self) -> None: 

109 """Load codemeta.json file.""" 

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

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

112 

113 def _validate(self) -> None: 

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

115 if self.pass_validation: 

116 return 

117 invalid_fields = validate_codemeta(self._data) 

118 if invalid_fields and self.merge: 

119 raise ValueError( 

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

121 ) 

122 

123 def _init_new_file(self) -> None: 

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

125 data = { 

126 "@context": [ 

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

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

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

130 "https://schema.org", 

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

132 ], 

133 "@type": "SoftwareSourceCode", 

134 "author": [], 

135 } 

136 # dump to file 

137 with self.path.open("w+") as f: 

138 json.dump(data, f) 

139 

140 def save(self, path: Optional[Path] = None) -> None: 

141 """Save the codemeta.json file.""" 

142 path = path or self.path 

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

144 

145 # copy the _data 

146 data = self._data.copy() 

147 

148 # set license 

149 if "license" in data: 

150 data["license"] = (f"https://spdx.org/licenses/{data['license']}",) 

151 

152 # if softwareHelp is set, set url to softwareHelp 

153 if "softwareHelp" in data: 

154 data["url"] = data["softwareHelp"] 

155 

156 with path.open("w") as f: 

157 # codemeta.json indentation is 2 spaces 

158 json.dump(data, f) 

159 

160 @staticmethod 

161 def _from_person(person: Union[Person, Entity]) -> dict: 

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

163 if isinstance(person, Person): 

164 person_dict = { 

165 "@type": "Person", 

166 } 

167 if person.given_names: 

168 person_dict["givenName"] = person.given_names 

169 if person.family_names: 

170 person_dict["familyName"] = person.family_names 

171 if person.email: 

172 person_dict["email"] = person.email 

173 if person.orcid: 

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

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

176 if person.address: 

177 person_dict["address"] = person.address 

178 if person.affiliation: 

179 person_dict["affiliation"] = person.affiliation 

180 return person_dict 

181 else: 

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

183 if person.address: 

184 entity_dict["address"] = person.address 

185 if person.email: 

186 entity_dict["email"] = person.email 

187 if person.date_start: 

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

189 if person.date_end: 

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

191 if person.website: 

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

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

194 if person.rorid: 

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

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

197 return entity_dict 

198 

199 @staticmethod 

200 def _to_person(person) -> Union[Person, Entity]: 

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

202 if "name" in person: 

203 entity_obj = {"name": person["name"]} 

204 return Entity(**entity_obj) 

205 else: 

206 person_obj = {} 

207 if "givenName" in person: 

208 person_obj["given_names"] = person["givenName"].strip() 

209 if "familyName" in person: 

210 person_obj["family_names"] = person["familyName"].strip() 

211 if "email" in person: 

212 person_obj["email"] = person["email"].strip() 

213 if "@id" in person: 

214 person_obj["orcid"] = person["@id"].strip() 

215 if "address" in person: 

216 person_obj["address"] = person["address"].strip() 

217 

218 return Person(**person_obj) 

219 

220 def _sync_person_list( 

221 self, old: List[Any], new: List[Union[Person, Entity]] 

222 ) -> List[Any]: 

223 """Override the _sync_person_list function from ProjectMetadataWriter. 

224 

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

226 

227 Args: 

228 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. 

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

230 

231 Returns: 

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

233 

234 """ 

235 return new 

236 

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

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

239 

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

241 """ 

242 super().sync(metadata) 

243 self.contributors = metadata.contributors() 

244 

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

246 for item in self._default_context: 

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

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