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
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-04 11:35 +0000
1"""codemeta.json creation module."""
3import json
4import logging
5from collections import OrderedDict
6from collections.abc import Sequence
7from pathlib import Path
8from typing import Any
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
15logger = logging.getLogger("somesy")
18class CodeMeta(ProjectMetadataWriter):
19 """Codemeta.json parser and saver."""
21 def __init__(
22 self,
23 path: Path,
24 merge: bool | None = False,
25 pass_validation: bool | None = False,
26 ):
27 """Codemeta.json parser.
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 )
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)
69 # add (or overwrite) the type
70 self._data["@type"] = "SoftwareSourceCode"
72 # overwrite authors, maintainers, contributors
73 self._data["author"] = []
74 self._data["maintainer"] = []
75 self._data["contributor"] = []
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 []
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)
88 @property
89 def maintainers(self):
90 """Return the maintainers of the codemeta.json file."""
91 return self._get_property(self._get_key("maintainers"))
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)
99 @property
100 def contributors(self):
101 """Return the contributors of the codemeta.json file."""
102 return self._get_property(self._get_key("contributors"))
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)
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)
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 )
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)
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}")
147 # copy the _data
148 data = self._data.copy()
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 ]
158 # if softwareHelp is set, set url to softwareHelp
159 if "softwareHelp" in data:
160 data["url"] = data["softwareHelp"]
162 with path.open("w", newline="\n") as f:
163 # codemeta.json indentation is 2 spaces
164 json.dump(data, f)
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
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()
224 return Person(**person_data)
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.
231 This method wont care about existing persons in codemeta.json file.
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
237 Returns:
238 List[Any]: list of new persons to add to codemeta.json file
240 """
241 return list(new)
243 def sync(self, metadata: ProjectMetadata) -> None:
244 """Sync codemeta.json with project metadata.
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()
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)