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
« prev ^ index » next coverage.py v7.6.0, created at 2025-03-10 14:56 +0000
1"""codemeta.json creation module."""
3import json
4import logging
5from collections import OrderedDict
6from pathlib import Path
7from typing import Any, List, Optional, Union
9from somesy.codemeta.utils import validate_codemeta
10from somesy.core.models import Entity, Person, ProjectMetadata
11from somesy.core.writer import FieldKeyMapping, ProjectMetadataWriter
13logger = logging.getLogger("somesy")
16class CodeMeta(ProjectMetadataWriter):
17 """Codemeta.json parser and saver."""
19 def __init__(
20 self,
21 path: Path,
22 merge: Optional[bool] = False,
23 pass_validation: Optional[bool] = False,
24 ):
25 """Codemeta.json parser.
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 )
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)
67 # add (or overwrite) the type
68 self._data["@type"] = "SoftwareSourceCode"
70 # overwrite authors, maintainers, contributors
71 self._data["author"] = []
72 self._data["maintainer"] = []
73 self._data["contributor"] = []
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 []
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)
86 @property
87 def maintainers(self):
88 """Return the maintainers of the codemeta.json file."""
89 return self._get_property(self._get_key("maintainers"))
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)
97 @property
98 def contributors(self):
99 """Return the contributors of the codemeta.json file."""
100 return self._get_property(self._get_key("contributors"))
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)
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)
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 )
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)
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}")
145 # copy the _data
146 data = self._data.copy()
148 # set license
149 if "license" in data:
150 data["license"] = (f"https://spdx.org/licenses/{data['license']}",)
152 # if softwareHelp is set, set url to softwareHelp
153 if "softwareHelp" in data:
154 data["url"] = data["softwareHelp"]
156 with path.open("w") as f:
157 # codemeta.json indentation is 2 spaces
158 json.dump(data, f)
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
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()
218 return Person(**person_obj)
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.
225 This method wont care about existing persons in codemeta.json file.
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
231 Returns:
232 List[Any]: list of new persons to add to codemeta.json file
234 """
235 return new
237 def sync(self, metadata: ProjectMetadata) -> None:
238 """Sync codemeta.json with project metadata.
240 Use existing sync function from ProjectMetadataWriter but update repository and contributors.
241 """
242 super().sync(metadata)
243 self.contributors = metadata.contributors()
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)