Skip to content

Commit 8f13be7

Browse files
authored
Merge pull request #309 from softwarepub/feature/init-command
Feature/init command
2 parents a67e2f9 + 36622cc commit 8f13be7

File tree

11 files changed

+462
-226
lines changed

11 files changed

+462
-226
lines changed

docs/source/tutorials/writing-a-plugin-for-hermes.md

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
<!--
2-
SPDX-FileCopyrightText: 2024 German Aerospace Center (DLR)
2+
SPDX-FileCopyrightText: 2025 German Aerospace Center (DLR), Forschungszentrum Jülich GmbH
33
44
SPDX-License-Identifier: CC-BY-SA-4.0
55
-->
66

77
<!--
88
SPDX-FileContributor: Michael Meinel
99
SPDX-FileContributor: Sophie Kernchen
10+
SPDX-FileContributor: Nitai Heeb
11+
SPDX-FileContributor: Oliver Bertuch
1012
-->
1113

1214
# Write a plugin for HERMES
@@ -26,7 +28,7 @@ If you never used HERMES before, you might want to check the tutorial: [Automate
2628

2729
HERMES uses a plugin architecture. Therefore, users are invited to contribute own features.
2830
The structure for every plugin follows the same schema.
29-
There is a top-level base class for every plugin. In this `HermesPlugin` class there is one abstract method `__ call __` which needs to be overwritten.
31+
There is a top-level base class for every plugin. In this `HermesPlugin` class there is one abstract method `__call__` which needs to be overwritten.
3032
Furthermore, the `HermesCommand` class provides all needs for writing a plugin used in a HERMES command.
3133
So the `HermesPlugin`s call method gets an instance of the `HermesCommand` that triggered this plugin to run.
3234
In our case this will be the `HermesHarvestCommand` which calls all harvest plugins.
@@ -38,6 +40,7 @@ To overwrite a parameter from command line, use the `-O` command line option fol
3840
E.g., you can set your authentication token for InvenioRDM by adding the following options to your call to `hermes deposit`:
3941
```shell
4042
hermes deposit -O invenio_rdm.auth_token YourSecretAuthToken
43+
```
4144

4245
## Set Up Plugin
4346
To write a new plugin, it is important to follow the given structure.
@@ -64,15 +67,21 @@ class GitHarvestPlugin(HermesHarvestPlugin):
6467
return {}, {}
6568
```
6669

67-
The code uses the `HermesHarvestPlugin` as base class and pydantics Basemodel for the settings. In the `GitHarvestSettings` you
68-
can see that an additional parameter is defined. The Parameter `from_branch` is specific for this plugin and can be accessed inside the plugin using `self.settings.harvest.git.git_branch` as long as our plugin will be named git.
70+
The code uses the `HermesHarvestPlugin` as base class and pydantic's base model for the settings.
71+
In the `GitHarvestSettings` you can see that an additional parameter is defined.
72+
The Parameter `from_branch` is specific for this plugin and can be accessed inside the plugin using `self.settings.harvest.git.from_branch` as long as our plugin will be named `git`.
6973
In the `hermes.toml` this would be achieved by [harvest.{plugin_name}].
70-
The `GitHarvestSettings` are associated with the `GitHarvestPlugin`. In the plugin you need to overwrite the `__ call __` method.
71-
For now a simple Hello World will do. The method returns two dictionaries. These will contain the harvested data in CodeMeta (JSON-LD) and additional information, e.g., to provide provenance information.
74+
The `GitHarvestSettings` are associated with the `GitHarvestPlugin`.
75+
In the plugin you need to overwrite the `__call__` method.
76+
For now a simple "Hello World" will do. The method returns two dictionaries.
77+
These will contain the harvested data in CodeMeta (JSON-LD) and additional information, e.g., to provide provenance information.
7278
That is the basic structure for the plugins source code.
7379

74-
To integrate this code, you have to register it as a plugin in the `pyproject.toml`. To learn more about the `pyproject.toml` check https://python-poetry.org/docs/pyproject/ or refer to [PEP621](https://peps.python.org/pep-0621/).
75-
We will just look at the important places for this plugin. There are two ways to integrate this plugin. First we will show how to use the plugin environment as the running base with HERMES as a dependency.
80+
To integrate this code, you have to register it as a plugin in the `pyproject.toml`.
81+
To learn more about the `pyproject.toml` check https://python-poetry.org/docs/pyproject/ or refer to [PEP621](https://peps.python.org/pep-0621/).
82+
We will just look at the important places for this plugin.
83+
There are two ways to integrate this plugin.
84+
First we will show how to use the plugin environment as the running base with HERMES as a dependency.
7685
Then we say how to integrate this plugin in HERMES itself.
7786

7887
### Include HERMES as Dependency
@@ -124,10 +133,10 @@ Note that this differs with the accessibility and your wishes, check [Explicit P
124133

125134
The second thing to adapt is to declare the access point for the plugin.
126135
You can do that with `git = "hermes_plugin_git.harvest:GitHarvestPlugin"`.
127-
This expression makes the GitHarvestPlugin from the hermes_plugin_git package, a hermes.harvest plugin named git.
136+
This expression makes the `GitHarvestPlugin` from the `hermes_plugin_git` package, a `hermes.harvest` plugin named `git`.
128137
So you need to configure this line with your plugin properties.
129138

130-
Now you just need to add the plugin to the hermes.toml and reinstall the adapted poetry package.
139+
Now you just need to add the plugin to the `hermes.toml` and reinstall the adapted poetry package.
131140

132141
### Configure hermes.toml
133142
To use the plugin, you have to activate it in the `hermes.toml`.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
# SPDX-FileCopyrightText: 2024 Forschungszentrum Jülich
1+
# SPDX-FileCopyrightText: 2024 Forschungszentrum Jülich GmbH
22
# SPDX-License-Identifier: Apache-2.0

src/hermes/commands/init/base.py

Lines changed: 108 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,30 @@
1-
# SPDX-FileCopyrightText: 2024 Forschungszentrum Jülich
1+
# SPDX-FileCopyrightText: 2024 Forschungszentrum Jülich GmbH
22
# SPDX-License-Identifier: Apache-2.0
33
# SPDX-FileContributor: Nitai Heeb
44

55
import argparse
6+
import logging
67
import os
8+
import re
79
import subprocess
810
import sys
9-
import requests
10-
import toml
11-
import re
11+
from dataclasses import dataclass
1212
from enum import Enum, auto
13-
from urllib.parse import urlparse, urljoin
13+
from importlib import metadata
1414
from pathlib import Path
15+
from urllib.parse import urljoin, urlparse
16+
17+
import requests
18+
import toml
1519
from pydantic import BaseModel
16-
from dataclasses import dataclass
17-
from hermes.commands.base import HermesCommand
18-
import hermes.commands.init.connect_github as connect_github
19-
import hermes.commands.init.connect_gitlab as connect_gitlab
20-
import hermes.commands.init.connect_zenodo as connect_zenodo
21-
import hermes.commands.init.slim_click as sc
20+
from requests import HTTPError
2221

22+
import hermes.commands.init.util.slim_click as sc
23+
from hermes.commands.base import HermesCommand, HermesPlugin
24+
from hermes.commands.init.util import (connect_github, connect_gitlab,
25+
connect_zenodo)
2326

24-
TUTORIAL_URL = "https://docs.software-metadata.pub/en/latest/tutorials/automated-publication-with-ci.html"
27+
TUTORIAL_URL = "https://hermes.software-metadata.pub/en/latest/tutorials/automated-publication-with-ci.html"
2528

2629

2730
class GitHoster(Enum):
@@ -52,7 +55,7 @@ class DepositPlatform(Enum):
5255
class HermesInitFolderInfo:
5356
def __init__(self):
5457
self.absolute_path: str = ""
55-
self.has_git: bool = False
58+
self.has_git_folder: bool = False
5659
self.git_remote_url: str = ""
5760
self.git_base_url: str = ""
5861
self.used_git_hoster: GitHoster = GitHoster.Empty
@@ -75,31 +78,50 @@ def is_git_installed():
7578

7679

7780
def scout_current_folder() -> HermesInitFolderInfo:
81+
"""
82+
This method looks at the current directory and collects all init relevant data.
83+
84+
@return: HermesInitFolderInfo object containing the gathered knowledge
85+
"""
7886
info = HermesInitFolderInfo()
7987
current_dir = os.getcwd()
8088
info.current_dir = current_dir
8189
info.absolute_path = str(current_dir)
82-
info.has_git = os.path.isdir(os.path.join(current_dir, ".git"))
83-
if info.has_git:
90+
91+
info.has_git_folder = os.path.isdir(os.path.join(current_dir, ".git"))
92+
# git-enabled project
93+
if info.has_git_folder:
94+
# Get remote name for next command
95+
# TODO: missing case of multiple configured remotes (should we make the user choose?)
8496
git_remote = str(subprocess.run(['git', 'remote'], capture_output=True, text=True).stdout).strip()
8597
sc.debug_info(f"git remote = {git_remote}")
86-
# Get remote url
98+
99+
# Get remote url via Git CLI and convert it to an HTTP link in case it's an SSH remote.
100+
# TODO: unchecked usage of empty remote name
87101
info.git_remote_url = convert_remote_url(
88102
str(subprocess.run(['git', 'remote', 'get-url', git_remote], capture_output=True, text=True).stdout)
89103
)
104+
# TODO: missing an "else" part!
105+
# TODO: can't these three pieces be stitched together to only execute when there is a remote?
106+
# TODO: is the url parsing necessary of it's hosted on github?
107+
if info.git_remote_url:
108+
parsed_url = urlparse(info.git_remote_url)
109+
info.git_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}/"
110+
sc.debug_info(f"git base url = {info.git_base_url}")
111+
if "github.com" in info.git_remote_url:
112+
info.used_git_hoster = GitHoster.GitHub
113+
elif connect_gitlab.is_url_gitlab(info.git_base_url):
114+
info.used_git_hoster = GitHoster.GitLab
115+
116+
# Extract current branch name information by parsing Git output
117+
# TODO: no exception or handling in case branch is empty (e.g. detached HEAD)
118+
# TODO: why not use git rev-parse --abbrev-ref HEAD ?
90119
branch_info = str(subprocess.run(['git', 'branch'], capture_output=True, text=True).stdout)
91120
for line in branch_info.splitlines():
92121
if line.startswith("*"):
93122
info.current_branch = line.split()[1].strip()
94123
break
95-
if info.git_remote_url:
96-
parsed_url = urlparse(info.git_remote_url)
97-
info.git_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}/"
98-
sc.debug_info(f"git base url = {info.git_base_url}")
99-
if "github.com" in info.git_remote_url:
100-
info.used_git_hoster = GitHoster.GitHub
101-
elif connect_gitlab.is_url_gitlab(info.git_base_url):
102-
info.used_git_hoster = GitHoster.GitLab
124+
103125
info.has_hermes_toml = os.path.isfile(os.path.join(current_dir, "hermes.toml"))
104126
info.has_gitignore = os.path.isfile(os.path.join(current_dir, ".gitignore"))
105127
info.has_citation_cff = os.path.isfile(os.path.join(current_dir, "CITATION.cff"))
@@ -110,15 +132,19 @@ def scout_current_folder() -> HermesInitFolderInfo:
110132
if os.path.isdir(os.path.join(current_dir, f))
111133
and not f.startswith(".")
112134
]
135+
113136
return info
114137

115138

116139
def download_file_from_url(url, filepath, append: bool = False):
117-
with requests.get(url, stream=True) as r:
118-
r.raise_for_status()
119-
with open(filepath, 'ab' if append else 'wb') as f:
120-
for chunk in r.iter_content(chunk_size=8192):
121-
f.write(chunk)
140+
try:
141+
with requests.get(url, stream=True) as r:
142+
r.raise_for_status()
143+
with open(filepath, 'ab' if append else 'wb') as f:
144+
for chunk in r.iter_content(chunk_size=8192):
145+
f.write(chunk)
146+
except HTTPError:
147+
sc.echo(f"No file found at {url}.", formatting=sc.Formats.FAIL)
122148

123149

124150
def string_in_file(file_path, search_string: str) -> bool:
@@ -139,6 +165,28 @@ def convert_remote_url(url: str) -> str:
139165
return url
140166

141167

168+
def get_builtin_plugins(plugin_commands: list[str]) -> dict[str: HermesPlugin]:
169+
plugins = {}
170+
for plugin_command_name in plugin_commands:
171+
entry_point_group = f"hermes.{plugin_command_name}"
172+
group_plugins = {
173+
entry_point.name: entry_point.load()
174+
for entry_point in metadata.entry_points(group=entry_point_group)
175+
}
176+
plugins.update(group_plugins)
177+
return plugins
178+
179+
180+
def get_handler_by_name(name: str) -> logging.Handler:
181+
"""Own implementation of logging.getHandlerByName so that we don't require Python 3.12"""
182+
for logger_name in logging.root.manager.loggerDict:
183+
logger = logging.getLogger(logger_name)
184+
for handler in logger.handlers:
185+
if handler.get_name() == name:
186+
return handler
187+
return None
188+
189+
142190
class _HermesInitSettings(BaseModel):
143191
"""Configuration of the ``init`` command."""
144192
pass
@@ -166,6 +214,8 @@ def __init__(self, parser: argparse.ArgumentParser):
166214
"deposit_extra_files": "",
167215
"push_branch": "main"
168216
}
217+
self.plugin_relevant_commands = ["harvest", "deposit"]
218+
self.builtin_plugins: dict[str: HermesPlugin] = get_builtin_plugins(self.plugin_relevant_commands)
169219

170220
def init_command_parser(self, command_parser: argparse.ArgumentParser) -> None:
171221
command_parser.add_argument('--template-branch', nargs=1, default="",
@@ -175,11 +225,24 @@ def load_settings(self, args: argparse.Namespace):
175225
pass
176226

177227
def refresh_folder_info(self):
178-
sc.echo("Scanning folder...", debug=True)
228+
sc.debug_info("Scanning folder...")
179229
self.folder_info = scout_current_folder()
180-
sc.echo("Scan complete.", debug=True)
230+
sc.debug_info("Scan complete.")
231+
232+
def setup_file_logging(self):
233+
# Silence old StreamHandler
234+
handler = get_handler_by_name("terminal")
235+
if handler:
236+
handler.setLevel(logging.CRITICAL)
237+
# Set file logger level
238+
self.log.setLevel(level=logging.INFO)
239+
# Connect logger with sc
240+
sc.default_file_logger = self.log
181241

182242
def __call__(self, args: argparse.Namespace) -> None:
243+
# Setup logging
244+
self.setup_file_logging()
245+
183246
# Save command parameter (template branch)
184247
if hasattr(args, "template_branch"):
185248
if args.template_branch != "":
@@ -230,7 +293,7 @@ def test_initialization(self):
230293
self.refresh_folder_info()
231294

232295
# Abort if there is no git
233-
if not self.folder_info.has_git:
296+
if not self.folder_info.has_git_folder:
234297
sc.echo("The current directory has no `.git` subdirectory. "
235298
"Please execute `hermes init` in the root directory of your git project.",
236299
formatting=sc.Formats.WARNING)
@@ -317,6 +380,7 @@ def update_gitignore(self):
317380
with open(".gitignore", "a") as file:
318381
file.write("# Ignoring all HERMES cache files\n")
319382
file.write(".hermes/\n")
383+
file.write("hermes.log\n")
320384
sc.echo("Added `.hermes/` to the `.gitignore` file.", formatting=sc.Formats.OKGREEN)
321385

322386
def get_template_url(self, filename: str) -> str:
@@ -384,12 +448,6 @@ def get_zenodo_token(self):
384448
# Deactivated Zenodo OAuth as long as the refresh token bug is not fixed.
385449
if self.setup_method == "a":
386450
sc.echo("Doing OAuth with Zenodo is currently not available.")
387-
# self.tokens[self.deposit_platform] = "REFRESH_TOKEN:" + connect_zenodo.get_refresh_token()
388-
# if self.tokens[self.deposit_platform]:
389-
# sc.echo("OAuth at Zenodo was successful.")
390-
# sc.echo(self.tokens[self.deposit_platform], debug=True)
391-
# else:
392-
# sc.echo("Something went wrong while doing OAuth. You'll have to do it manually instead.")
393451
if self.setup_method == "m" or self.tokens[self.deposit_platform] == '':
394452
zenodo_token_url = urljoin(DepositPlatformUrls[self.deposit_platform],
395453
"account/settings/applications/tokens/new/")
@@ -400,7 +458,18 @@ def get_zenodo_token(self):
400458
if self.setup_method == "m":
401459
sc.press_enter_to_continue()
402460
else:
403-
self.tokens[self.deposit_platform] = sc.answer("Then enter the token here: ")
461+
while True:
462+
self.tokens[self.deposit_platform] = sc.answer("Enter the token here: ")
463+
valid = connect_zenodo.test_if_token_is_valid(self.tokens[self.deposit_platform])
464+
if valid:
465+
sc.echo(f"The token was validated by {connect_zenodo.name}.",
466+
formatting=sc.Formats.OKGREEN)
467+
break
468+
else:
469+
sc.echo(f"The token could not be validated by {connect_zenodo.name}. "
470+
"Make sure to enter the complete token.\n"
471+
"(If this error persists, you should try switching to the manual setup mode.)",
472+
formatting=sc.Formats.WARNING)
404473

405474
def configure_git_project(self):
406475
"""Adding the token to the git secrets & changing action workflow settings"""

0 commit comments

Comments
 (0)