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.
@@ -0,0 +1,4 @@
1
+ # SPDX-FileContributor: Christian Meeßen (GFZ) <christian.meessen@gfz.de>
2
+ # SPDX-FileCopyrightText: 2025 GFZ Helmholtz Centre for Geosciences
3
+ #
4
+ # SPDX-License-Identifier: EUPL-1.2
@@ -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,4 @@
1
+ # SPDX-FileContributor: Christian Meeßen (GFZ) <christian.meessen@gfz.de>
2
+ # SPDX-FileCopyrightText: 2025 GFZ Helmholtz Centre for Geosciences
3
+ #
4
+ # SPDX-License-Identifier: EUPL-1.2
@@ -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