Skip to content

Commit b80168e

Browse files
committed
merged with develop
2 parents ff70fd3 + d44b095 commit b80168e

5 files changed

Lines changed: 252 additions & 41 deletions

File tree

docs/adr/0013-plugin-init.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<!--
2+
SPDX-FileCopyrightText: 2022 German Aerospace Center (DLR), Forschungszentrum Jülich, Helmholtz-Zentrum Dresden-Rossendorf
3+
4+
SPDX-License-Identifier: CC-BY-SA-4.0
5+
-->
6+
# Installing plugins from the marketplace using hermes init
7+
8+
* Status: accepted
9+
* Deciders: nheeb
10+
* Date: 2025-03-04
11+
12+
## Context and Problem Statement
13+
14+
For a smooth user experience common plugins (those which are on the marketplace) should be installable via `hermes init`. That however means we need to decide on a way for the plugins to ask question during that same init process.
15+
16+
## Considered Options
17+
18+
* **Full install**: Plugins are installed locally during `hermes init`, can hook into the init process, and ask questions themselves to adjust the `hermes.toml` or similar.
19+
* **No install**: Plugins are only added to the CI pipeline as `pip install ...` lines. Additional questions for the init process could be added in the marketplace input mask along with a property name under which the answer is stored in the `hermes.toml`.
20+
* **Partial install**: Plugins are only added to the CI pipeline as `pip install ...` lines. Additional questions for the init process are handled in a "detached" script that is downloaded, executed locally, and then deleted again (this script may only have dependencies of Hermes).
21+
22+
## Decision Outcome
23+
24+
Chosen option: "**Partial install**", because it is a good trade-off between giving plugins more controll over their init process and not being too invasive with local installs.
25+
26+
## Pros and Cons of the Options
27+
28+
### Full install
29+
- (+) Plugins have a lot of control over how they design their own init process.
30+
- (+) The plugin can be used locally on the device directly if the user intends to do so.
31+
- (-) The plugin might be installed unnecessarily, as locally only the init part may be executed.
32+
- (-) The plugin may introduce many dependencies that the user doesn’t necessarily need.
33+
- (-) There could be environment complications (e.g., if Hermes was installed with `pipx`).
34+
35+
### No install
36+
- (+) Nothing is installed locally via `hermes init`.
37+
- (+) Overall, slightly less effort for the init command and plugin developers.
38+
- (-) The marketplace would need input masks for an arbitrary number of such questions.
39+
- (-) Plugins have little control over how they design their own init process.
40+
- (-) Plugins intended for local use must be installed manually.
41+
42+
### Partial install
43+
- (+) Nothing is installed locally via `hermes init`.
44+
- (+) Plugins have relatively high control over how they design their own init process.
45+
- (-) Plugins intended for local use must be installed manually.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
[tool.poetry]
1010
# Reference at https://python-poetry.org/docs/pyproject/
1111
name = "hermes"
12-
version = "0.9.0"
12+
version = "0.10.0.dev0"
1313
description = "Workflow to publish research software with rich metadata"
1414
homepage = "https://software-metadata.pub"
1515
license = "Apache-2.0"

src/hermes/commands/marketplace.py

Lines changed: 99 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
# SPDX-FileContributor: Stephan Druskat
55

66
"""Basic CLI to list plugins from the Hermes marketplace."""
7+
8+
from functools import cache
79
from html.parser import HTMLParser
810
from typing import List, Optional
911

@@ -12,7 +14,7 @@
1214
from pydantic.alias_generators import to_camel
1315

1416
from hermes.commands.init.util import slim_click
15-
from hermes.utils import hermes_doi, hermes_user_agent
17+
from hermes.utils import hermes_doi, hermes_concept_doi, hermes_user_agent
1618

1719
MARKETPLACE_URL = "https://hermes.software-metadata.pub/marketplace"
1820

@@ -64,7 +66,14 @@ class SchemaOrgSoftwareApplication(SchemaOrgModel):
6466
keywords: List["str"] = None
6567

6668

67-
schema_org_hermes = SchemaOrgSoftwareApplication(id_=hermes_doi, name="hermes")
69+
schema_org_hermes = SchemaOrgSoftwareApplication(
70+
id_=(
71+
hermes_doi
72+
if hermes_doi.startswith("https://doi.org/")
73+
else f"https://doi.org/{hermes_doi}"
74+
),
75+
name="hermes",
76+
)
6877

6978

7079
class PluginMarketPlaceParser(HTMLParser):
@@ -88,6 +97,49 @@ def handle_data(self, data):
8897
self.plugins.append(plugin)
8998

9099

100+
@cache
101+
def _doi_is_version_of_concept_doi(doi: str, concept_doi: str) -> bool:
102+
"""Check whether ``doi`` is a version of ``concept_doi``.
103+
104+
The check is performed by requesting ``doi`` from the DataCite API and checking
105+
whether its related identifier of type ``IsVersionOf`` points to ``concept_doi``.
106+
This is the case if ``conecpt_doi`` is the concept DOI of ``doi``.
107+
"""
108+
109+
doi = doi.removeprefix("https://doi.org/")
110+
concept_doi = concept_doi.removeprefix("https://doi.org/")
111+
112+
response = requests.get(
113+
f"https://api.datacite.org/dois/{doi}",
114+
headers={"User-Agent": hermes_user_agent},
115+
)
116+
response.raise_for_status()
117+
118+
for identifier in response.json()["data"]["attributes"]["relatedIdentifiers"]:
119+
if (
120+
identifier["relationType"] == "IsVersionOf"
121+
and identifier["relatedIdentifier"] == concept_doi
122+
):
123+
return True
124+
125+
return False
126+
127+
128+
def _is_hermes_reference(reference: Optional[SchemaOrgModel]):
129+
"""Figure out whether ``reference`` refers to HERMES."""
130+
if reference is None:
131+
return False
132+
133+
if reference.id_ in [
134+
schema_org_hermes.id_,
135+
hermes_concept_doi,
136+
f"https://doi.org/{hermes_concept_doi}",
137+
]:
138+
return True
139+
140+
return _doi_is_version_of_concept_doi(reference.id_, hermes_concept_doi)
141+
142+
91143
def _sort_plugins_by_step(plugins: list[SchemaOrgSoftwareApplication]) -> dict[str, list[SchemaOrgSoftwareApplication]]:
92144
sorted_plugins = {k: [] for k in ["harvest", "process", "curate", "deposit", "postprocess"]}
93145
for p in plugins:
@@ -101,44 +153,6 @@ def _plugin_loc(_plugin: SchemaOrgSoftwareApplication) -> str:
101153
return "builtin" if _plugin.is_part_of == schema_org_hermes else (_plugin.url or "")
102154

103155

104-
class PluginInfo:
105-
"""
106-
This class contains all the information about a plugin which are needed for the init-Command.
107-
"""
108-
def __init__(self):
109-
self.name: str = ""
110-
self.location: str = ""
111-
self.step: str = ""
112-
self.builtin: bool = True
113-
self.install_url: str = ""
114-
self.abstract: str = ""
115-
116-
def __str__(self):
117-
step_text = f"[{self.step}]"
118-
return f"{step_text} {slim_click.Formats.BOLD.wrap_around(self.name)} ({self.location})"
119-
120-
def get_pip_install_command(self) -> str:
121-
"""
122-
Returns the pip install command which can be used to install the plugin.
123-
Tries to extract the project name from the install_url (PyPI-URL) if possible.
124-
Otherwise, it tries to use the location (Git-Project-URL) for the pip install command.
125-
"""
126-
if self.install_url and self.install_url.startswith("https://pypi.org/project/"):
127-
project_name = self.install_url.rstrip("/").removeprefix("https://pypi.org/project/")
128-
return f"pip install {project_name}"
129-
if self.location and self.location.startswith(("https://", "git@", "ssh://")):
130-
git_url = self.location.rstrip("/")
131-
return f"pip install git+{git_url}"
132-
return ""
133-
134-
def is_valid(self) -> bool:
135-
"""
136-
Returns True if the plugin can be installed. Maybe we'll check the actual repository here later
137-
to make sure that other things are valid too.
138-
"""
139-
return self.get_pip_install_command() != ""
140-
141-
142156
def get_plugin_infos() -> list[PluginInfo]:
143157
response = requests.get(MARKETPLACE_URL, headers={"User-Agent": hermes_user_agent})
144158
response.raise_for_status()
@@ -172,6 +186,13 @@ def main():
172186
MARKETPLACE_URL + "."
173187
)
174188

189+
def _plugin_loc(_plugin: SchemaOrgSoftwareApplication) -> str:
190+
return (
191+
"builtin"
192+
if _is_hermes_reference(_plugin.is_part_of)
193+
else (_plugin.url or "")
194+
)
195+
175196
if parser.plugins:
176197
print()
177198
max_name_len = max(map(lambda plugin: len(plugin.name), parser.plugins))
@@ -189,3 +210,41 @@ def main():
189210

190211
if __name__ == "__main__":
191212
main()
213+
214+
215+
class PluginInfo:
216+
"""
217+
This class contains all the information about a plugin which are needed for the init-Command.
218+
"""
219+
def __init__(self):
220+
self.name: str = ""
221+
self.location: str = ""
222+
self.step: str = ""
223+
self.builtin: bool = True
224+
self.install_url: str = ""
225+
self.abstract: str = ""
226+
227+
def __str__(self):
228+
step_text = f"[{self.step}]"
229+
return f"{step_text} {slim_click.Formats.BOLD.wrap_around(self.name)} ({self.location})"
230+
231+
def get_pip_install_command(self) -> str:
232+
"""
233+
Returns the pip install command which can be used to install the plugin.
234+
Tries to extract the project name from the install_url (PyPI-URL) if possible.
235+
Otherwise, it tries to use the location (Git-Project-URL) for the pip install command.
236+
"""
237+
if self.install_url and self.install_url.startswith("https://pypi.org/project/"):
238+
project_name = self.install_url.rstrip("/").removeprefix("https://pypi.org/project/")
239+
return f"pip install {project_name}"
240+
if self.location and self.location.startswith(("https://", "git@", "ssh://")):
241+
git_url = self.location.rstrip("/")
242+
return f"pip install git+{git_url}"
243+
return ""
244+
245+
def is_valid(self) -> bool:
246+
"""
247+
Returns True if the plugin can be installed. Maybe we'll check the actual repository here later
248+
to make sure that other things are valid too.
249+
"""
250+
return self.get_pip_install_command() != ""

src/hermes/utils.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,7 @@
1616
# TODO: Fetch this from somewhere
1717
hermes_doi = "10.5281/zenodo.13311079" # hermes v0.8.1
1818

19+
hermes_concept_doi = "10.5281/zenodo.13221383"
20+
"""Fix "concept" DOI that always refers to the newest version."""
21+
1922
hermes_user_agent = f"{hermes_name}/{hermes_version} ({hermes_homepage})"
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# SPDX-FileCopyrightText: 2025 Helmholtz-Zentrum Dresden-Rossendorf
2+
# SPDX-License-Identifier: Apache-2.0
3+
# SPDX-FileContributor: David Pape
4+
5+
import requests_mock
6+
7+
from hermes.commands.marketplace import (
8+
schema_org_hermes,
9+
SchemaOrgModel,
10+
_is_hermes_reference,
11+
)
12+
13+
14+
def test_schema_org_hermes_doi_is_absolute():
15+
"""The HERMES DOI rendered to the plugins list must always be a full URL."""
16+
assert isinstance(schema_org_hermes, SchemaOrgModel)
17+
assert schema_org_hermes.id_ is not None
18+
assert schema_org_hermes.id_.startswith("https://doi.org/")
19+
20+
21+
def test_concept_doi_is_hermes_reference():
22+
"""The HERMES concept DOI is a reference to HERMES.
23+
24+
We must be able to figure this out without asking DataCite.
25+
"""
26+
with requests_mock.Mocker() as m:
27+
assert _is_hermes_reference(
28+
SchemaOrgModel(
29+
type_="SoftwareSourceCode",
30+
id_="https://doi.org/10.5281/zenodo.13221383",
31+
)
32+
)
33+
# DataCite API was not called
34+
assert m.call_count == 0
35+
36+
37+
def test_is_hermes_reference_if_datacite_api_returns_concept_doi_as_rel_id():
38+
with requests_mock.Mocker() as m:
39+
m.get(
40+
"https://api.datacite.org/dois/10.9999/fake.1000",
41+
text="""
42+
{
43+
"data": {
44+
"id": "10.9999/fake.1000",
45+
"type": "dois",
46+
"attributes": {
47+
"doi": "10.9999/fake.1000",
48+
"prefix": "10.9999",
49+
"suffix": "fake.1000",
50+
"relatedIdentifiers": [
51+
{
52+
"relationType": "IsVersionOf",
53+
"relatedIdentifier": "10.5281/zenodo.13221383",
54+
"relatedIdentifierType": "DOI"
55+
}
56+
]
57+
}
58+
}
59+
}
60+
""".strip(),
61+
)
62+
# 10.5281/zenodo.13221383 retured from DataCite is HERMES concept DOI
63+
assert _is_hermes_reference(
64+
SchemaOrgModel(
65+
type_="SoftwareSourceCode", id_="https://doi.org/10.9999/fake.1000"
66+
)
67+
)
68+
# DataCite API was called once
69+
assert m.call_count == 1
70+
71+
72+
def test_not_is_hermes_reference_if_datacite_api_returns_wrong_rel_id():
73+
with requests_mock.Mocker() as m:
74+
m.get(
75+
"https://api.datacite.org/dois/10.9999/fake.2000",
76+
text="""
77+
{
78+
"data": {
79+
"id": "10.9999/fake.2000",
80+
"type": "dois",
81+
"attributes": {
82+
"doi": "10.9999/fake.2000",
83+
"prefix": "10.9999",
84+
"suffix": "fake.2000",
85+
"relatedIdentifiers": [
86+
{
87+
"relationType": "IsVersionOf",
88+
"relatedIdentifier": "10.9999/fake.1999",
89+
"relatedIdentifierType": "DOI"
90+
}
91+
]
92+
}
93+
}
94+
}
95+
""".strip(),
96+
)
97+
# 10.9999/fake.1999 returned from DataCite is not HERMES concept DOI
98+
assert not _is_hermes_reference(
99+
SchemaOrgModel(
100+
type_="SoftwareSourceCode", id_="https://doi.org/10.9999/fake.2000"
101+
)
102+
)
103+
# DataCite API was called once
104+
assert m.call_count == 1

0 commit comments

Comments
 (0)