hermes-plugin-rsd 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hermes_plugin_rsd/__init__.py +4 -0
- hermes_plugin_rsd/deposit.py +516 -0
- hermes_plugin_rsd/harvest.py +69 -0
- hermes_plugin_rsd/models/__init__.py +4 -0
- hermes_plugin_rsd/models/contributor.py +81 -0
- hermes_plugin_rsd/models/keyword.py +20 -0
- hermes_plugin_rsd/models/keyword_for_software.py +29 -0
- hermes_plugin_rsd/models/license_for_software.py +31 -0
- hermes_plugin_rsd/models/organisation.py +45 -0
- hermes_plugin_rsd/models/repository_url.py +26 -0
- hermes_plugin_rsd/models/rsd_base_model.py +19 -0
- hermes_plugin_rsd/models/rsd_software_entry.py +88 -0
- hermes_plugin_rsd/models/software.py +45 -0
- hermes_plugin_rsd/models/software_for_organisation.py +27 -0
- hermes_plugin_rsd/models/swhid_for_software.py +21 -0
- hermes_plugin_rsd/settings.py +15 -0
- hermes_plugin_rsd/utils/__init__.py +4 -0
- hermes_plugin_rsd/utils/misc.py +23 -0
- hermes_plugin_rsd/utils/ror.py +84 -0
- hermes_plugin_rsd/utils/rsd_api_handler.py +383 -0
- hermes_plugin_rsd/utils/rsd_client.py +239 -0
- hermes_plugin_rsd-0.1.0.dist-info/METADATA +139 -0
- hermes_plugin_rsd-0.1.0.dist-info/RECORD +29 -0
- hermes_plugin_rsd-0.1.0.dist-info/WHEEL +4 -0
- hermes_plugin_rsd-0.1.0.dist-info/entry_points.txt +6 -0
- hermes_plugin_rsd-0.1.0.dist-info/licenses/LICENSES/Apache-2.0.txt +73 -0
- hermes_plugin_rsd-0.1.0.dist-info/licenses/LICENSES/CC-BY-4.0.txt +156 -0
- hermes_plugin_rsd-0.1.0.dist-info/licenses/LICENSES/CC0-1.0.txt +121 -0
- hermes_plugin_rsd-0.1.0.dist-info/licenses/LICENSES/EUPL-1.2.txt +190 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2025 - 2026 GFZ Helmholtz Centre for Geosciences
|
|
2
|
+
# SPDX-FileContributor: Christian Meeßen (GFZ) <christian.meessen@gfz.de>
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: EUPL-1.2
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from hermes.commands.deposit.base import (
|
|
11
|
+
BaseDepositPlugin,
|
|
12
|
+
CodeMetaContext,
|
|
13
|
+
HermesDepositCommand,
|
|
14
|
+
)
|
|
15
|
+
from pydantic_core import ValidationError
|
|
16
|
+
|
|
17
|
+
from hermes_plugin_rsd.models.contributor import Contributor
|
|
18
|
+
from hermes_plugin_rsd.models.keyword import Keyword
|
|
19
|
+
from hermes_plugin_rsd.models.keyword_for_software import KeywordForSoftware
|
|
20
|
+
from hermes_plugin_rsd.models.license_for_software import LicenseForSoftware
|
|
21
|
+
from hermes_plugin_rsd.models.organisation import Organisation
|
|
22
|
+
from hermes_plugin_rsd.models.repository_url import RepositoryUrl
|
|
23
|
+
from hermes_plugin_rsd.models.rsd_software_entry import RsdSoftwareEntry
|
|
24
|
+
from hermes_plugin_rsd.models.software import Software
|
|
25
|
+
from hermes_plugin_rsd.models.software_for_organisation import SoftwareForOrganisation
|
|
26
|
+
from hermes_plugin_rsd.settings import RsdPluginSettings
|
|
27
|
+
from hermes_plugin_rsd.utils.misc import get_new_objects
|
|
28
|
+
from hermes_plugin_rsd.utils.ror import RorResolver, create_organisation_from_ror_entry
|
|
29
|
+
from hermes_plugin_rsd.utils.rsd_api_handler import RsdApiHandler
|
|
30
|
+
from hermes_plugin_rsd.utils.rsd_client import RsdClient
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger("hermes.deposit.rsd")
|
|
33
|
+
|
|
34
|
+
SOFTWARE_SHORT_STATEMENT_MAX_LENGTH = 300
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def spdx_identifier_from_url(url: str) -> str | None:
|
|
38
|
+
prefix = "https://spdx.org/licenses/"
|
|
39
|
+
if url.startswith(prefix):
|
|
40
|
+
return url.removeprefix(prefix)
|
|
41
|
+
return url
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def truncate_short_statement(
|
|
45
|
+
sentence: str, max_len: int = SOFTWARE_SHORT_STATEMENT_MAX_LENGTH
|
|
46
|
+
):
|
|
47
|
+
suffix = " …"
|
|
48
|
+
if len(sentence) <= max_len:
|
|
49
|
+
return sentence
|
|
50
|
+
if " " in sentence[max_len - 2 : max_len - 1]:
|
|
51
|
+
return sentence[: max_len - 1] + suffix
|
|
52
|
+
words = sentence.split(" ")
|
|
53
|
+
for n in range(len(words), 0, -1):
|
|
54
|
+
short_statement = " ".join(words[:n]).strip(":,") + suffix
|
|
55
|
+
if len(short_statement) <= max_len:
|
|
56
|
+
return short_statement
|
|
57
|
+
raise RuntimeError("Could not shorten sentence: ", sentence)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def split_name(name: str) -> list[str]:
|
|
61
|
+
if ", " in name:
|
|
62
|
+
family_name, given_name = name.split(", ")
|
|
63
|
+
elif " " in name:
|
|
64
|
+
given_name, family_name = name.split(" ")
|
|
65
|
+
else:
|
|
66
|
+
log.error(
|
|
67
|
+
"Cannot split name '%s'. Please provide a mapping in hermes.toml.", name
|
|
68
|
+
)
|
|
69
|
+
raise ValueError("Cannot split name '%s'", name)
|
|
70
|
+
return [given_name, family_name]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RsdDepositPlugin(BaseDepositPlugin):
|
|
74
|
+
"""RSD Deposit Plugin"""
|
|
75
|
+
|
|
76
|
+
keywords: list[Keyword] = []
|
|
77
|
+
organisations: list[Organisation] = []
|
|
78
|
+
settings_class = RsdPluginSettings
|
|
79
|
+
settings: RsdPluginSettings = None
|
|
80
|
+
software_entry: RsdSoftwareEntry = None
|
|
81
|
+
rsd_client = None
|
|
82
|
+
rsd_api_handler = None
|
|
83
|
+
ror_resolver = None
|
|
84
|
+
|
|
85
|
+
def __init__(self, command: HermesDepositCommand, ctx: CodeMetaContext) -> None:
|
|
86
|
+
super().__init__(command, ctx)
|
|
87
|
+
self.settings = self.command.settings.rsd
|
|
88
|
+
api_token = os.environ.get("RSD_API_TOKEN", "")
|
|
89
|
+
|
|
90
|
+
if api_token == "":
|
|
91
|
+
raise ValueError("Environment variable RSD_API_TOKEN is undefined.")
|
|
92
|
+
|
|
93
|
+
self.rsd_api_handler = RsdApiHandler(
|
|
94
|
+
RsdClient(api_token=api_token, rsd_url=self.settings.rsd_url)
|
|
95
|
+
)
|
|
96
|
+
self.ror_resolver = RorResolver()
|
|
97
|
+
|
|
98
|
+
def __call__(self, command: HermesDepositCommand):
|
|
99
|
+
"""__call__
|
|
100
|
+
Overwrite base class __call__ as it does not fit to this plugin.
|
|
101
|
+
|
|
102
|
+
Minimum required data for the first-time usage:
|
|
103
|
+
|
|
104
|
+
- rsd url
|
|
105
|
+
- rsd token
|
|
106
|
+
- desired software slug
|
|
107
|
+
|
|
108
|
+
Worklfow:
|
|
109
|
+
validate metadata
|
|
110
|
+
if is initial version
|
|
111
|
+
- check availability of desired software name and slug
|
|
112
|
+
- set publish-method to POST
|
|
113
|
+
if not:
|
|
114
|
+
- set publish-method to PUT
|
|
115
|
+
map metadata
|
|
116
|
+
publish data
|
|
117
|
+
- if initial version: warning that software must be set to publicly visible in
|
|
118
|
+
RSD manually
|
|
119
|
+
"""
|
|
120
|
+
self.prepare()
|
|
121
|
+
|
|
122
|
+
if self.is_initial_publication():
|
|
123
|
+
self.create_initial_version()
|
|
124
|
+
|
|
125
|
+
self.map_metadata()
|
|
126
|
+
self.publish()
|
|
127
|
+
|
|
128
|
+
def prepare(self) -> None:
|
|
129
|
+
self.validate()
|
|
130
|
+
self.software_entry = self.rsd_api_handler.get_representation_from_rsd(
|
|
131
|
+
self.settings.software_slug
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def validate(self) -> None:
|
|
135
|
+
self.validate_slug()
|
|
136
|
+
self.validate_url()
|
|
137
|
+
self.validate_repository_type()
|
|
138
|
+
|
|
139
|
+
def validate_slug(self) -> None:
|
|
140
|
+
# Definition see
|
|
141
|
+
# https://github.com/research-software-directory/RSD-as-a-service/blob/main/database/004-create-software-table.sql
|
|
142
|
+
|
|
143
|
+
if len(self.settings.software_slug) > 250:
|
|
144
|
+
raise ValueError("software_slug is longer than 250 characters.")
|
|
145
|
+
pattern = r"^[a-z0-9]+(-[a-z0-9]+)*$"
|
|
146
|
+
if not re.match(pattern, self.settings.software_slug):
|
|
147
|
+
raise ValueError("Invalid software_slug.")
|
|
148
|
+
|
|
149
|
+
def validate_url(self) -> None:
|
|
150
|
+
if self.settings.rsd_url is None:
|
|
151
|
+
raise ValueError("The parameter rsd_url must be provided.")
|
|
152
|
+
if not self.settings.rsd_url.startswith(("http://", "https://")):
|
|
153
|
+
raise ValueError("URL must start with 'http://' or 'https://'")
|
|
154
|
+
self.settings.rsd_url = self.settings.rsd_url.removesuffix("/")
|
|
155
|
+
|
|
156
|
+
def validate_repository_type(self) -> None:
|
|
157
|
+
repository_type = self.ctx["codemeta"].get("@type", None)
|
|
158
|
+
if repository_type != "SoftwareSourceCode":
|
|
159
|
+
log.error(
|
|
160
|
+
"Repository must be source code to be published, but is type %s",
|
|
161
|
+
repository_type,
|
|
162
|
+
)
|
|
163
|
+
raise ValidationError("Invalid @type. Must be SoftwareSourceCode.")
|
|
164
|
+
|
|
165
|
+
def is_initial_publication(self) -> bool:
|
|
166
|
+
if self.software_entry.software.id is None:
|
|
167
|
+
log.info("Software entry does not exist yet.")
|
|
168
|
+
return True
|
|
169
|
+
log.info(
|
|
170
|
+
"Software entry with id %s already exists.",
|
|
171
|
+
self.software_entry.software.id,
|
|
172
|
+
)
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
def create_initial_version(self) -> None:
|
|
176
|
+
log.info("Creating initial representation in the RSD.")
|
|
177
|
+
self.software_entry.software = Software(
|
|
178
|
+
brand_name="Initial version", slug=self.settings.software_slug
|
|
179
|
+
)
|
|
180
|
+
self.software_entry.software.id = (
|
|
181
|
+
self.rsd_api_handler.create_or_update_software(self.software_entry.software)
|
|
182
|
+
)
|
|
183
|
+
log.info("Software has id %s", self.software_entry.software.id)
|
|
184
|
+
|
|
185
|
+
def map_metadata(self) -> None:
|
|
186
|
+
"""
|
|
187
|
+
Create a new RsdSoftwareEntry object that holds the metadata collected by
|
|
188
|
+
HERMES. This object is later merged with the existing or new RsdSoftwareEntry.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
"""
|
|
192
|
+
Process codemeta context
|
|
193
|
+
- [ ] author, cff
|
|
194
|
+
- [x] contributors, git
|
|
195
|
+
- [-] date published, cff
|
|
196
|
+
- [x] description, cff (cff prop: abstract) => short_statement
|
|
197
|
+
- [x] identifier, cff (doi, identifiers)
|
|
198
|
+
- [x] keywords, cff
|
|
199
|
+
- [x] license (as url referencing to spdx.org), cff
|
|
200
|
+
- [x] name, cff (cff prop: title)
|
|
201
|
+
- [x] type, cff (Dataset, SoftwareSourceCode)
|
|
202
|
+
- [x] urls, cff
|
|
203
|
+
- [-] version, cff
|
|
204
|
+
"""
|
|
205
|
+
codemeta = self.ctx["codemeta"]
|
|
206
|
+
|
|
207
|
+
self.map_software_entry(codemeta)
|
|
208
|
+
self.map_contributors(codemeta)
|
|
209
|
+
self.map_repository_url(codemeta)
|
|
210
|
+
self.map_license(codemeta)
|
|
211
|
+
self.map_keywords(codemeta)
|
|
212
|
+
self.infer_organisations()
|
|
213
|
+
|
|
214
|
+
def infer_organisations(self):
|
|
215
|
+
"""Infers ROR organisations based on email addresses of contributors.
|
|
216
|
+
|
|
217
|
+
If an organisation is found it will be added to self.organisations
|
|
218
|
+
"""
|
|
219
|
+
for contributor in self.software_entry.contributors:
|
|
220
|
+
if not contributor.email_address:
|
|
221
|
+
continue
|
|
222
|
+
domain = contributor.email_address.split("@")[1]
|
|
223
|
+
ror_organisation = self.ror_resolver.search_organisation_by_domain(domain)
|
|
224
|
+
if ror_organisation:
|
|
225
|
+
# Check if already exists in RSD
|
|
226
|
+
organisation = self.rsd_api_handler.get_organisation(
|
|
227
|
+
ror_id=ror_organisation["id"]
|
|
228
|
+
)
|
|
229
|
+
if organisation is None:
|
|
230
|
+
organisation = create_organisation_from_ror_entry(ror_organisation)
|
|
231
|
+
if organisation not in self.organisations:
|
|
232
|
+
# TODO: check if this should be moved to self.software_entry
|
|
233
|
+
self.organisations.append(organisation)
|
|
234
|
+
else:
|
|
235
|
+
log.info(
|
|
236
|
+
"Could not find an associated organisation for %s",
|
|
237
|
+
contributor.email_address,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
for organisation in self.organisations:
|
|
241
|
+
if organisation not in self.software_entry.software_for_organisation:
|
|
242
|
+
self.software_entry.software_for_organisation.append(
|
|
243
|
+
SoftwareForOrganisation(
|
|
244
|
+
organisation=organisation,
|
|
245
|
+
software=self.software_entry.software.id,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def map_keywords(self, codemeta):
|
|
250
|
+
if self.software_entry.software.id is None:
|
|
251
|
+
raise ValueError("Software ID must be defined to map keywords.")
|
|
252
|
+
keywords = codemeta.get("keywords", [])
|
|
253
|
+
for keyword in keywords:
|
|
254
|
+
# Check if keyword already in software entry
|
|
255
|
+
if keyword in self.software_entry.keywords:
|
|
256
|
+
continue
|
|
257
|
+
# Check if keyword already exists in the RSD
|
|
258
|
+
# If yes, use keyword ID
|
|
259
|
+
keyword_reference = self.rsd_api_handler.get_keyword(keyword)
|
|
260
|
+
if keyword_reference:
|
|
261
|
+
# Keyword exists, reference it
|
|
262
|
+
new_keyword_for_software = KeywordForSoftware(
|
|
263
|
+
software=self.software_entry.software.id,
|
|
264
|
+
keyword=keyword_reference,
|
|
265
|
+
)
|
|
266
|
+
if new_keyword_for_software not in self.software_entry.keywords:
|
|
267
|
+
self.software_entry.keywords.append(new_keyword_for_software)
|
|
268
|
+
else:
|
|
269
|
+
# Keyword does not exist yet, we need to create it
|
|
270
|
+
# this also means it does not exist in the entry yet
|
|
271
|
+
new_keyword = Keyword(value=keyword, id=None)
|
|
272
|
+
self.keywords.append(new_keyword)
|
|
273
|
+
self.software_entry.keywords.append(
|
|
274
|
+
KeywordForSoftware(
|
|
275
|
+
software=self.software_entry.software.id,
|
|
276
|
+
keyword=new_keyword,
|
|
277
|
+
)
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def map_license(self, codemeta):
|
|
281
|
+
licences = codemeta.get("license", None)
|
|
282
|
+
spdx_prefix = "https://spdx.org/licenses/"
|
|
283
|
+
if type(licences) is str:
|
|
284
|
+
licences = [licences]
|
|
285
|
+
for licence_data in licences:
|
|
286
|
+
open_source = False
|
|
287
|
+
licence = licence_data
|
|
288
|
+
if licence.startswith(spdx_prefix):
|
|
289
|
+
open_source = True
|
|
290
|
+
licence = licence_data.removeprefix(spdx_prefix)
|
|
291
|
+
new_licence = LicenseForSoftware(
|
|
292
|
+
license=licence,
|
|
293
|
+
open_source=open_source,
|
|
294
|
+
software=self.software_entry.software.id,
|
|
295
|
+
)
|
|
296
|
+
if new_licence not in self.software_entry.license:
|
|
297
|
+
self.software_entry.license.append(new_licence)
|
|
298
|
+
|
|
299
|
+
def map_repository_url(self, codemeta):
|
|
300
|
+
code_repository = codemeta.get("codeRepository", None)
|
|
301
|
+
if not code_repository:
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
code_platform = self.rsd_api_handler.get_code_platform(code_repository)
|
|
305
|
+
log.debug("Identified code platform: %s", code_platform)
|
|
306
|
+
|
|
307
|
+
if not self.software_entry.repository_url:
|
|
308
|
+
self.software_entry.repository_url = RepositoryUrl(
|
|
309
|
+
software=self.software_entry.software.id,
|
|
310
|
+
url=code_repository,
|
|
311
|
+
code_platform=code_platform,
|
|
312
|
+
)
|
|
313
|
+
else:
|
|
314
|
+
self.software_entry.repository_url.url = code_repository
|
|
315
|
+
self.software_entry.repository_url.code_platform = code_platform
|
|
316
|
+
|
|
317
|
+
def map_contributors(self, codemeta):
|
|
318
|
+
# Map Contributors
|
|
319
|
+
contributors = codemeta.get("contributor", [])
|
|
320
|
+
|
|
321
|
+
# Process Contributors
|
|
322
|
+
# https://schema.org/contributor
|
|
323
|
+
# Expected types: People, Organizations
|
|
324
|
+
if len(contributors) > 0:
|
|
325
|
+
for contributor in contributors:
|
|
326
|
+
contributor_type = contributor.get("@type", None)
|
|
327
|
+
if contributor_type == "Person":
|
|
328
|
+
self.map_person_metadata(contributor)
|
|
329
|
+
elif contributor_type == "Organization":
|
|
330
|
+
# TODO: Process organisation
|
|
331
|
+
pass
|
|
332
|
+
else:
|
|
333
|
+
log.error("Invalid contributor type: %s", contributor_type)
|
|
334
|
+
raise TypeError(f"Invalid contributor type: {contributor_type}")
|
|
335
|
+
|
|
336
|
+
def map_software_entry(self, codemeta):
|
|
337
|
+
"""Map codemeta data to Software
|
|
338
|
+
|
|
339
|
+
Metadata in the ex
|
|
340
|
+
|
|
341
|
+
- [x] brand_name
|
|
342
|
+
- [x] concept_doi
|
|
343
|
+
- [x] description
|
|
344
|
+
- [x] short_statement
|
|
345
|
+
- [x] get_started_url
|
|
346
|
+
"""
|
|
347
|
+
description = codemeta.get("description", None)
|
|
348
|
+
software = self.software_entry.software
|
|
349
|
+
|
|
350
|
+
if description:
|
|
351
|
+
if len(description) > SOFTWARE_SHORT_STATEMENT_MAX_LENGTH:
|
|
352
|
+
log.warning(
|
|
353
|
+
"Description length is > %s characters. Shortening description to "
|
|
354
|
+
"fit into short description.",
|
|
355
|
+
SOFTWARE_SHORT_STATEMENT_MAX_LENGTH,
|
|
356
|
+
)
|
|
357
|
+
software.short_statement = truncate_short_statement(description)
|
|
358
|
+
software.description = description
|
|
359
|
+
|
|
360
|
+
title = codemeta.get("name", None)
|
|
361
|
+
if title:
|
|
362
|
+
software.brand_name = title
|
|
363
|
+
else:
|
|
364
|
+
log.error("A name is required to create an RSD entry.")
|
|
365
|
+
raise ValidationError("Software title undefined.")
|
|
366
|
+
|
|
367
|
+
software.get_started_url = codemeta.get("url", None)
|
|
368
|
+
|
|
369
|
+
identifier = codemeta.get("identifier", None)
|
|
370
|
+
if identifier:
|
|
371
|
+
if identifier.startswith("https://doi.org/"):
|
|
372
|
+
log.warning(
|
|
373
|
+
"Found an identifier in codemeta context. "
|
|
374
|
+
"Assuming this is the concept DOI of the work."
|
|
375
|
+
)
|
|
376
|
+
identifier = identifier.removeprefix("https://doi.org/")
|
|
377
|
+
if re.search(r"^10.\d{4,}/\S+", identifier):
|
|
378
|
+
log.debug("%s seems to be a DOI.", identifier)
|
|
379
|
+
software.concept_doi = identifier
|
|
380
|
+
elif identifier is not None:
|
|
381
|
+
log.warning(
|
|
382
|
+
"Identifier %s is not a DOI and is unsupported.", identifier
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def map_person_metadata(self, person: dict) -> None:
|
|
386
|
+
"""
|
|
387
|
+
Maps person metadata and creates contributors for each person.
|
|
388
|
+
|
|
389
|
+
The `hermes.harvest.git` plugin does not return family and given name, only the
|
|
390
|
+
complete author name. We use the following strategy for splitting:
|
|
391
|
+
|
|
392
|
+
- One space inside "name" => split into `[givenName] [familyName]`
|
|
393
|
+
- ", " inside "name" => split into `[familyName], [givenName]`
|
|
394
|
+
- else: require manual changes
|
|
395
|
+
|
|
396
|
+
Attribute mapping
|
|
397
|
+
-----------------
|
|
398
|
+
|
|
399
|
+
| RSD Attribute | CodeMeta |
|
|
400
|
+
|-----------------|------------|
|
|
401
|
+
| family_names | familyName |
|
|
402
|
+
| given_names | givenName |
|
|
403
|
+
|
|
404
|
+
Parameters
|
|
405
|
+
----------
|
|
406
|
+
person
|
|
407
|
+
Dictionary containing person metadata
|
|
408
|
+
|
|
409
|
+
Merge strategy
|
|
410
|
+
--------------
|
|
411
|
+
Adds a new contributor to the list if:
|
|
412
|
+
|
|
413
|
+
- no contributor with the same `given_names` and `family_names` exists
|
|
414
|
+
- or if no contributor with the same ORCiD exists
|
|
415
|
+
|
|
416
|
+
This means changes to other attributes are not regarded, especially changes in
|
|
417
|
+
|
|
418
|
+
- affiliation
|
|
419
|
+
- email address
|
|
420
|
+
- contact person
|
|
421
|
+
- role
|
|
422
|
+
|
|
423
|
+
Raises
|
|
424
|
+
------
|
|
425
|
+
RuntimeError
|
|
426
|
+
If name of person cannot be detected.
|
|
427
|
+
ValidationError
|
|
428
|
+
If person attributes are missing or invalid.
|
|
429
|
+
"""
|
|
430
|
+
family_name = person.get("familyName")
|
|
431
|
+
given_name = person.get("givenName")
|
|
432
|
+
if family_name is None or given_name is None:
|
|
433
|
+
log.info("familyName or givenName undefined for person: %s", person)
|
|
434
|
+
name = person.get("name")
|
|
435
|
+
if name is None:
|
|
436
|
+
log.error("Unable to detect name for person: %s", person)
|
|
437
|
+
raise RuntimeError("Unable to detect name for person.")
|
|
438
|
+
if name == "GitHub":
|
|
439
|
+
log.info(
|
|
440
|
+
"hermes.harvest.git detected contributions by GitHub. "
|
|
441
|
+
"Ignoring as these will not be listed."
|
|
442
|
+
)
|
|
443
|
+
return
|
|
444
|
+
given_name, family_name = split_name(person["name"])
|
|
445
|
+
|
|
446
|
+
try:
|
|
447
|
+
log.info(
|
|
448
|
+
"Generating contributor for givenName: %s and familyName: %s.",
|
|
449
|
+
given_name,
|
|
450
|
+
family_name,
|
|
451
|
+
)
|
|
452
|
+
contributor = Contributor(
|
|
453
|
+
family_names=family_name,
|
|
454
|
+
given_names=given_name,
|
|
455
|
+
software=self.software_entry.software.id,
|
|
456
|
+
)
|
|
457
|
+
contributor.email_address = person.get("email")
|
|
458
|
+
if contributor not in self.software_entry.contributors:
|
|
459
|
+
self.software_entry.contributors.append(contributor)
|
|
460
|
+
else:
|
|
461
|
+
log.info(
|
|
462
|
+
"Contributor %s already exists in the RSD entry, not updating.",
|
|
463
|
+
contributor,
|
|
464
|
+
)
|
|
465
|
+
except ValidationError as e:
|
|
466
|
+
log.error(
|
|
467
|
+
"%d validation error(s) while processing Contributor %s:",
|
|
468
|
+
len(e.errors()),
|
|
469
|
+
person,
|
|
470
|
+
)
|
|
471
|
+
for error in e.errors():
|
|
472
|
+
log.error(
|
|
473
|
+
"Required attribute is missing or invalid: %s", error["loc"][0]
|
|
474
|
+
)
|
|
475
|
+
raise e
|
|
476
|
+
|
|
477
|
+
def publish(self):
|
|
478
|
+
# TODO: do not stop execution when adding relations to software. collect the
|
|
479
|
+
# errors and generate a meaningful error message at the end of the publish
|
|
480
|
+
# process
|
|
481
|
+
# At this point the software representation definitely exists in the RSD
|
|
482
|
+
self.rsd_api_handler.create_or_update_software(self.software_entry.software)
|
|
483
|
+
|
|
484
|
+
if self.software_entry.repository_url is not None:
|
|
485
|
+
self.rsd_api_handler.create_or_update_repository_url(
|
|
486
|
+
self.software_entry.repository_url
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
new_licenses = get_new_objects(self.software_entry.license)
|
|
490
|
+
for licence in new_licenses:
|
|
491
|
+
self.rsd_api_handler.create_license_for_software(licence)
|
|
492
|
+
|
|
493
|
+
new_keywords = get_new_objects(self.keywords)
|
|
494
|
+
for keyword in new_keywords:
|
|
495
|
+
self.rsd_api_handler.create_keyword(keyword)
|
|
496
|
+
|
|
497
|
+
new_keywords_for_software = get_new_objects(self.software_entry.keywords)
|
|
498
|
+
for keyword_for_software in new_keywords_for_software:
|
|
499
|
+
self.rsd_api_handler.create_keyword_for_software(keyword_for_software)
|
|
500
|
+
|
|
501
|
+
new_contributors = get_new_objects(self.software_entry.contributors)
|
|
502
|
+
for contributor in new_contributors:
|
|
503
|
+
self.rsd_api_handler.create_contributor(contributor)
|
|
504
|
+
|
|
505
|
+
new_sw_for_org = get_new_objects(self.software_entry.software_for_organisation)
|
|
506
|
+
for software_for_organisation in new_sw_for_org:
|
|
507
|
+
self.rsd_api_handler.create_software_for_organisation(
|
|
508
|
+
software_for_organisation
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
log.info(
|
|
512
|
+
"\n*************************************\n"
|
|
513
|
+
"Software entry successfully created. Please review the software entry "
|
|
514
|
+
"and publish it so that it will be discoverable.\n"
|
|
515
|
+
"*************************************\n"
|
|
516
|
+
)
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# SPDX-FileContributor: Christian Meeßen (GFZ) <christian.meessen@gfz.de>
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 GFZ Helmholtz Centre for Geosciences
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: EUPL-1.2
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
from hermes.commands import HermesHarvestCommand
|
|
10
|
+
from hermes.commands.harvest.base import HermesHarvestPlugin
|
|
11
|
+
from hermes.model.errors import HermesValidationError
|
|
12
|
+
|
|
13
|
+
from hermes_plugin_rsd.settings import RsdPluginSettings
|
|
14
|
+
from hermes_plugin_rsd.utils.rsd_api_handler import RsdApiHandler
|
|
15
|
+
from hermes_plugin_rsd.utils.rsd_client import RsdClient
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("cli.harvest.rsd")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RsdHarvestPlugin(HermesHarvestPlugin):
|
|
21
|
+
settings_class = RsdPluginSettings
|
|
22
|
+
command: HermesHarvestCommand
|
|
23
|
+
rsd_api_handler: RsdApiHandler
|
|
24
|
+
settings = None
|
|
25
|
+
|
|
26
|
+
def __call__(self, command: HermesHarvestCommand):
|
|
27
|
+
self.command = command
|
|
28
|
+
self.settings = self.command.settings.rsd
|
|
29
|
+
api_token = os.environ.get("RSD_API_TOKEN", "")
|
|
30
|
+
|
|
31
|
+
if api_token == "":
|
|
32
|
+
log.warning(
|
|
33
|
+
"RSD_API_TOKEN is undefined, unable to access unpublished software "
|
|
34
|
+
"entries."
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
self.rsd_api_handler = RsdApiHandler(
|
|
38
|
+
RsdClient(api_token=api_token, rsd_url=self.settings.rsd_url)
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
"""
|
|
42
|
+
Harvests relevant metadata from an RSD entry.
|
|
43
|
+
|
|
44
|
+
Latest supported and tested RSD version: v5.4.4
|
|
45
|
+
|
|
46
|
+
The following endpoints are fetched:
|
|
47
|
+
|
|
48
|
+
- [x] software
|
|
49
|
+
- [x] repository_url
|
|
50
|
+
- [ ] category_for_software
|
|
51
|
+
- [x] keyword_for_software
|
|
52
|
+
- [x] license_for_software
|
|
53
|
+
- [ ] mention_for_software
|
|
54
|
+
- [ ] reference_paper_for_software
|
|
55
|
+
- [ ] software_for_software
|
|
56
|
+
- [x] swhid_for_software
|
|
57
|
+
|
|
58
|
+
The output will be digested by HermesHarvestContext.update_from().
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
slug = self.settings.software_slug
|
|
62
|
+
rsd_entry = self.rsd_api_handler.get_representation_from_rsd(slug)
|
|
63
|
+
if not rsd_entry.exists():
|
|
64
|
+
raise HermesValidationError(
|
|
65
|
+
"Software entry does not exist in", self.settings.rsd_url
|
|
66
|
+
)
|
|
67
|
+
harvested_data = rsd_entry.to_codemeta()
|
|
68
|
+
tags = {"rsd_url": self.settings.rsd_url + "/software/" + slug}
|
|
69
|
+
return harvested_data, tags
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# SPDX-FileContributor: Christian Meeßen (GFZ) <christian.meessen@gfz.de>
|
|
2
|
+
# SPDX-FileCopyrightText: 2025, 2026 GFZ Helmholtz Centre for Geosciences
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: EUPL-1.2
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
from pydantic import PlainSerializer, ValidationError, field_validator
|
|
11
|
+
|
|
12
|
+
from hermes_plugin_rsd.models.rsd_base_model import RsdBaseModel
|
|
13
|
+
from hermes_plugin_rsd.utils.misc import serialise_uuid
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Contributor(RsdBaseModel):
|
|
17
|
+
# See
|
|
18
|
+
# https://github.com/research-software-directory/RSD-as-a-service/blob/main/database/005-create-relations-for-software.sql
|
|
19
|
+
# Required attributes
|
|
20
|
+
family_names: str
|
|
21
|
+
given_names: str
|
|
22
|
+
software: Annotated[uuid.UUID, PlainSerializer(serialise_uuid)]
|
|
23
|
+
# Optional
|
|
24
|
+
affiliation: str | None = None
|
|
25
|
+
email_address: str | None = None
|
|
26
|
+
is_contact_person: bool = False
|
|
27
|
+
orcid: str | None = None
|
|
28
|
+
role: str | None = None
|
|
29
|
+
|
|
30
|
+
def __str__(self):
|
|
31
|
+
return f"{self.given_names} {self.family_names}"
|
|
32
|
+
|
|
33
|
+
def __eq__(self, other):
|
|
34
|
+
if isinstance(other, Contributor):
|
|
35
|
+
if self.orcid and other.orcid:
|
|
36
|
+
return self.orcid == other.orcid
|
|
37
|
+
if (
|
|
38
|
+
self.family_names
|
|
39
|
+
and self.given_names
|
|
40
|
+
and other.family_names
|
|
41
|
+
and other.given_names
|
|
42
|
+
):
|
|
43
|
+
return (
|
|
44
|
+
self.family_names == other.family_names
|
|
45
|
+
and self.given_names == other.given_names
|
|
46
|
+
)
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
def __hash__(self):
|
|
50
|
+
return hash((self.family_names, self.given_names, self.orcid))
|
|
51
|
+
|
|
52
|
+
@field_validator("email_address")
|
|
53
|
+
@classmethod
|
|
54
|
+
def validate_email(cls, value: str | None) -> str | None:
|
|
55
|
+
if value is None:
|
|
56
|
+
return None
|
|
57
|
+
if not re.match(r"[^@]+@[^@]+\.[^@]+", value):
|
|
58
|
+
raise ValidationError("Invalid e-mail address: %s", value)
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
@field_validator("orcid")
|
|
62
|
+
@classmethod
|
|
63
|
+
def validate_orcid(cls, value: str | None) -> str | None:
|
|
64
|
+
if value is None:
|
|
65
|
+
return None
|
|
66
|
+
if not re.match(r"^\d{4}-\d{4}-\d{4}-\d{3}[0-9X]{1}$", value):
|
|
67
|
+
raise ValidationError("Invalid ORCID: %s", value)
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
def to_codemeta(self):
|
|
71
|
+
person = {
|
|
72
|
+
"@type": "Person",
|
|
73
|
+
"name": self.given_names + " " + self.family_names,
|
|
74
|
+
}
|
|
75
|
+
if self.affiliation:
|
|
76
|
+
person["affiliation"] = {"@type": "Organization", "name": self.affiliation}
|
|
77
|
+
if self.email_address:
|
|
78
|
+
person["email"] = self.email_address
|
|
79
|
+
if self.orcid:
|
|
80
|
+
person["identifier"] = self.orcid
|
|
81
|
+
return person
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# SPDX-FileContributor: Christian Meeßen (GFZ) <christian.meessen@gfz.de>
|
|
2
|
+
# SPDX-FileCopyrightText: 2026 GFZ Helmholtz Centre for Geosciences
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: EUPL-1.2
|
|
5
|
+
|
|
6
|
+
import uuid
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
from pydantic import PlainSerializer
|
|
10
|
+
|
|
11
|
+
from hermes_plugin_rsd.models.rsd_base_model import RsdBaseModel
|
|
12
|
+
from hermes_plugin_rsd.utils.misc import serialise_uuid
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Keyword(RsdBaseModel):
|
|
16
|
+
id: Annotated[uuid.UUID | None, PlainSerializer(serialise_uuid)] = None
|
|
17
|
+
value: str
|
|
18
|
+
|
|
19
|
+
def __str__(self):
|
|
20
|
+
return self.value
|