From 23cf9bd03dc2e20b66825766d12ebd09d241fb55 Mon Sep 17 00:00:00 2001 From: David Pape Date: Tue, 7 Oct 2025 11:20:48 +0200 Subject: [PATCH 1/8] Create curate plugin base class, accept plugin --- hermes.toml | 3 ++ pyproject.toml | 3 ++ src/hermes/commands/curate/accept.py | 23 ++++++++++++ src/hermes/commands/curate/base.py | 53 ++++++++++++++++++++-------- 4 files changed, 68 insertions(+), 14 deletions(-) create mode 100644 src/hermes/commands/curate/accept.py diff --git a/hermes.toml b/hermes.toml index ed60a0fd..fd6e4ac6 100644 --- a/hermes.toml +++ b/hermes.toml @@ -8,6 +8,9 @@ sources = [ "cff", "toml", "file_exists" ] # ordered priority (first one is most [harvest.file_exists.search_patterns] community = ["contributing.md", "governance.md"] +[curate] +method = "accept" + [deposit] target = "invenio_rdm" diff --git a/pyproject.toml b/pyproject.toml index 3daab351..2457db8a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,9 @@ cff = "hermes.commands.harvest.cff:CffHarvestPlugin" codemeta = "hermes.commands.harvest.codemeta:CodeMetaHarvestPlugin" file_exists = "hermes.commands.harvest.file_exists:FileExistsHarvestPlugin" +[project.entry-points."hermes.curate"] +accept = "hermes.commands.curate.accept:AcceptCuratePlugin" + [project.entry-points."hermes.deposit"] file = "hermes.commands.deposit.file:FileDepositPlugin" invenio = "hermes.commands.deposit.invenio:InvenioDepositPlugin" diff --git a/src/hermes/commands/curate/accept.py b/src/hermes/commands/curate/accept.py new file mode 100644 index 00000000..0a369cf3 --- /dev/null +++ b/src/hermes/commands/curate/accept.py @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: 2022 German Aerospace Center (DLR), 2025 Helmholtz-Zentrum Dresden-Rossendorf (HZDR) +# +# SPDX-License-Identifier: Apache-2.0 + +# SPDX-FileContributor: Michael Meinel +# SPDX-FileContributor: David Pape + +import os +import shutil + +from hermes.commands.curate.base import BaseCuratePlugin +from hermes.model.context import CodeMetaContext + + +class AcceptCuratePlugin(BaseCuratePlugin): + def __call__(self, command): + ctx = CodeMetaContext() + process_output = ctx.hermes_dir / "process" / (ctx.hermes_name + ".json") + + os.makedirs(ctx.hermes_dir / "curate", exist_ok=True) + shutil.copy( + process_output, ctx.hermes_dir / "curate" / (ctx.hermes_name + ".json") + ) diff --git a/src/hermes/commands/curate/base.py b/src/hermes/commands/curate/base.py index 4c990bc7..d73a8ee8 100644 --- a/src/hermes/commands/curate/base.py +++ b/src/hermes/commands/curate/base.py @@ -1,28 +1,39 @@ -# SPDX-FileCopyrightText: 2022 German Aerospace Center (DLR) +# SPDX-FileCopyrightText: 2022 German Aerospace Center (DLR), 2025 Helmholtz-Zentrum Dresden-Rossendorf (HZDR) # # SPDX-License-Identifier: Apache-2.0 # SPDX-FileContributor: Michael Meinel +# SPDX-FileContributor: David Pape import argparse -import os -import shutil +import json import sys from pydantic import BaseModel -from hermes.commands.base import HermesCommand +from hermes.commands.base import HermesCommand, HermesPlugin from hermes.model.context import CodeMetaContext +from hermes.model.errors import HermesValidationError +from hermes.model.path import ContextPath class _CurateSettings(BaseModel): - """Generic deposition settings.""" + """Generic curation settings.""" - pass + method: str = "accept" + + +class BaseCuratePlugin(HermesPlugin): + def __init__(self, command, ctx): + self.command = command + self.ctx = ctx + + def __call__(self, command: HermesCommand) -> None: + pass class HermesCurateCommand(HermesCommand): - """ Curate the unified metadata before deposition. """ + """Curate the unified metadata before deposition.""" command_name = "curate" settings_class = _CurateSettings @@ -31,17 +42,31 @@ def init_command_parser(self, command_parser: argparse.ArgumentParser) -> None: pass def __call__(self, args: argparse.Namespace) -> None: - - self.log.info("# Metadata curation") + self.args = args + plugin_name = self.settings.method ctx = CodeMetaContext() - process_output = ctx.hermes_dir / 'process' / (ctx.hermes_name + ".json") - - if not process_output.is_file(): + process_output = ctx.get_cache("process", ctx.hermes_name) + if not process_output.exists(): self.log.error( "No processed metadata found. Please run `hermes process` before curation." ) sys.exit(1) - os.makedirs(ctx.hermes_dir / 'curate', exist_ok=True) - shutil.copy(process_output, ctx.hermes_dir / 'curate' / (ctx.hermes_name + '.json')) + curate_path = ContextPath("curate") + with open(process_output) as process_output_fh: + ctx.update(curate_path, json.load(process_output_fh)) + + try: + plugin_func = self.plugins[plugin_name](self, ctx) + + except KeyError as e: + self.log.error("Plugin '%s' not found.", plugin_name) + self.errors.append(e) + + try: + plugin_func(self) + + except HermesValidationError as e: + self.log.error("Error while executing %s: %s", plugin_name, e) + self.errors.append(e) From b648240032f760fa0012f4f5d2687230a57da575 Mon Sep 17 00:00:00 2001 From: David Pape Date: Tue, 7 Oct 2025 11:52:36 +0200 Subject: [PATCH 2/8] Introduce sub-steps --- src/hermes/commands/curate/accept.py | 14 +++++++++++++- src/hermes/commands/curate/base.py | 25 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/hermes/commands/curate/accept.py b/src/hermes/commands/curate/accept.py index 0a369cf3..30e9a681 100644 --- a/src/hermes/commands/curate/accept.py +++ b/src/hermes/commands/curate/accept.py @@ -13,7 +13,19 @@ class AcceptCuratePlugin(BaseCuratePlugin): - def __call__(self, command): + """Accept plugin for the curation step. + + This plugin creates a positive curation result, i.e. it accepts the produced + metadata as correct and lets the execution continue without human intervention. It + also copies the metadata produced in the process step to the "curate" directory. + """ + + def get_decision(self): + """Simulate positive curation result.""" + return True + + def process_decision_positive(self): + """In case of positive curation result, copy files to next step.""" ctx = CodeMetaContext() process_output = ctx.hermes_dir / "process" / (ctx.hermes_name + ".json") diff --git a/src/hermes/commands/curate/base.py b/src/hermes/commands/curate/base.py index d73a8ee8..17485102 100644 --- a/src/hermes/commands/curate/base.py +++ b/src/hermes/commands/curate/base.py @@ -20,6 +20,7 @@ class _CurateSettings(BaseModel): """Generic curation settings.""" + #: Parameter by which the plugin is selected. By default, the accept plugin is used. method: str = "accept" @@ -29,8 +30,32 @@ def __init__(self, command, ctx): self.ctx = ctx def __call__(self, command: HermesCommand) -> None: + self.prepare() + self.validate() + self.create_report() + if self.get_decision(): + self.process_decision_positive() + else: + self.process_decision_negative() + + def prepare(self): pass + def validate(self): + pass + + def create_report(self): + pass + + def get_decision(self) -> bool: + return False + + def process_decision_positive(self): + pass + + def process_decision_negative(self): + raise RuntimeError("Curation declined further processing") + class HermesCurateCommand(HermesCommand): """Curate the unified metadata before deposition.""" From c859c2fed9e5f0481baa4416e3bdb5994688ab25 Mon Sep 17 00:00:00 2001 From: David Pape Date: Tue, 7 Oct 2025 14:32:40 +0200 Subject: [PATCH 3/8] Add docstrings to the base class. --- src/hermes/commands/curate/base.py | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/hermes/commands/curate/base.py b/src/hermes/commands/curate/base.py index 17485102..e46c522b 100644 --- a/src/hermes/commands/curate/base.py +++ b/src/hermes/commands/curate/base.py @@ -25,11 +25,23 @@ class _CurateSettings(BaseModel): class BaseCuratePlugin(HermesPlugin): + """Base class for curation plugins. + + Objects of this class are callables. + """ + def __init__(self, command, ctx): self.command = command self.ctx = ctx def __call__(self, command: HermesCommand) -> None: + """Entry point of the callable. + + This method runs the main logic of the plugin. It calls the other methods of the + object in the correct order. Depending on the result of ``get_decision`` the + corresponding ``process_decision_*()`` method is called, based on the curation + decision. + """ self.prepare() self.validate() self.create_report() @@ -39,21 +51,51 @@ def __call__(self, command: HermesCommand) -> None: self.process_decision_negative() def prepare(self): + """Prepare the plugin. + + This method may be used to perform preparatory tasks such as configuration + checks, token permission checks, loading of resources, etc. + """ pass def validate(self): + """Validate the metadata. + + This method performs the validation of the metadata from the data model. + """ pass def create_report(self): + """Create a curation report. + + This method is responsible for creating any number of reports about the curation + process. These reports may be machine-readable, human-readable, or both. + """ pass def get_decision(self) -> bool: + """Return the publication decision made through the curation process. + + If publication is allowed, this method must return ``True``. By default, + ``False`` is returned. + """ return False def process_decision_positive(self): + """Process a positive curation decision. + + This method is called if a positive publication decision was made in the + curation process. + """ pass def process_decision_negative(self): + """Process a negative curation decision. + + This method is called if a negative publication decision was made in the + curation process. By default, a ``RuntimeError`` is raised, halting the + execution. + """ raise RuntimeError("Curation declined further processing") From ff8ea6e9aef57f1fc4f0db0350f6ba7081e369ff Mon Sep 17 00:00:00 2001 From: David Pape Date: Wed, 8 Oct 2025 09:23:19 +0200 Subject: [PATCH 4/8] Use class member context in accept plugin --- src/hermes/commands/curate/accept.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/hermes/commands/curate/accept.py b/src/hermes/commands/curate/accept.py index 30e9a681..2cbe2ed0 100644 --- a/src/hermes/commands/curate/accept.py +++ b/src/hermes/commands/curate/accept.py @@ -9,7 +9,6 @@ import shutil from hermes.commands.curate.base import BaseCuratePlugin -from hermes.model.context import CodeMetaContext class AcceptCuratePlugin(BaseCuratePlugin): @@ -26,10 +25,12 @@ def get_decision(self): def process_decision_positive(self): """In case of positive curation result, copy files to next step.""" - ctx = CodeMetaContext() - process_output = ctx.hermes_dir / "process" / (ctx.hermes_name + ".json") + process_output = ( + self.ctx.hermes_dir / "process" / (self.ctx.hermes_name + ".json") + ) - os.makedirs(ctx.hermes_dir / "curate", exist_ok=True) + os.makedirs(self.ctx.hermes_dir / "curate", exist_ok=True) shutil.copy( - process_output, ctx.hermes_dir / "curate" / (ctx.hermes_name + ".json") + process_output, + self.ctx.hermes_dir / "curate" / (self.ctx.hermes_name + ".json"), ) From df0ea0f361a828f95be4d3bddb67d17106bc52b9 Mon Sep 17 00:00:00 2001 From: David Pape Date: Fri, 10 Oct 2025 12:24:07 +0200 Subject: [PATCH 5/8] =?UTF-8?q?Update=20docstring:=20{unified=E2=86=92proc?= =?UTF-8?q?essed}=20metadata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Stephan Druskat --- src/hermes/commands/curate/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hermes/commands/curate/base.py b/src/hermes/commands/curate/base.py index e46c522b..0d549cc4 100644 --- a/src/hermes/commands/curate/base.py +++ b/src/hermes/commands/curate/base.py @@ -100,7 +100,7 @@ def process_decision_negative(self): class HermesCurateCommand(HermesCommand): - """Curate the unified metadata before deposition.""" + """Curate the processed metadata before deposition.""" command_name = "curate" settings_class = _CurateSettings From 6649bc68020fda53107ae4c9da44f26f7eee3a47 Mon Sep 17 00:00:00 2001 From: David Pape Date: Fri, 23 Jan 2026 13:29:17 +0100 Subject: [PATCH 6/8] Rename `get_decision` --> `is_publication_approved` --- src/hermes/commands/curate/accept.py | 2 +- src/hermes/commands/curate/base.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/hermes/commands/curate/accept.py b/src/hermes/commands/curate/accept.py index 2cbe2ed0..4b05b857 100644 --- a/src/hermes/commands/curate/accept.py +++ b/src/hermes/commands/curate/accept.py @@ -19,7 +19,7 @@ class AcceptCuratePlugin(BaseCuratePlugin): also copies the metadata produced in the process step to the "curate" directory. """ - def get_decision(self): + def is_publication_approved(self): """Simulate positive curation result.""" return True diff --git a/src/hermes/commands/curate/base.py b/src/hermes/commands/curate/base.py index 0d549cc4..00535253 100644 --- a/src/hermes/commands/curate/base.py +++ b/src/hermes/commands/curate/base.py @@ -38,14 +38,14 @@ def __call__(self, command: HermesCommand) -> None: """Entry point of the callable. This method runs the main logic of the plugin. It calls the other methods of the - object in the correct order. Depending on the result of ``get_decision`` the - corresponding ``process_decision_*()`` method is called, based on the curation - decision. + object in the correct order. Depending on the result of + ``is_publication_approved`` the corresponding ``process_decision_*()`` method is + called, based on the curation decision. """ self.prepare() self.validate() self.create_report() - if self.get_decision(): + if self.is_publication_approved(): self.process_decision_positive() else: self.process_decision_negative() @@ -73,7 +73,7 @@ def create_report(self): """ pass - def get_decision(self) -> bool: + def is_publication_approved(self) -> bool: """Return the publication decision made through the curation process. If publication is allowed, this method must return ``True``. By default, From 223cb2ce69e9110c89fb73c49bd909d8c9fd9454 Mon Sep 17 00:00:00 2001 From: David Pape Date: Fri, 23 Jan 2026 13:39:34 +0100 Subject: [PATCH 7/8] Move mention of "callables" into base class --- src/hermes/commands/base.py | 5 ++++- src/hermes/commands/curate/base.py | 5 +---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/hermes/commands/base.py b/src/hermes/commands/base.py index 3ae9030b..f04399dd 100644 --- a/src/hermes/commands/base.py +++ b/src/hermes/commands/base.py @@ -162,7 +162,10 @@ def __call__(self, args: argparse.Namespace): class HermesPlugin(abc.ABC): - """Base class for all HERMES plugins.""" + """Base class for all HERMES plugins. + + Objects of this class are callables. + """ settings_class: Optional[Type] = None diff --git a/src/hermes/commands/curate/base.py b/src/hermes/commands/curate/base.py index 00535253..a347fede 100644 --- a/src/hermes/commands/curate/base.py +++ b/src/hermes/commands/curate/base.py @@ -25,10 +25,7 @@ class _CurateSettings(BaseModel): class BaseCuratePlugin(HermesPlugin): - """Base class for curation plugins. - - Objects of this class are callables. - """ + """Base class for curation plugins.""" def __init__(self, command, ctx): self.command = command From d162a61094329deac964c377d9d2475e452bc908 Mon Sep 17 00:00:00 2001 From: David Pape Date: Fri, 23 Jan 2026 13:55:02 +0100 Subject: [PATCH 8/8] Make `is_publication_approved` an abstract method --- src/hermes/commands/curate/base.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/hermes/commands/curate/base.py b/src/hermes/commands/curate/base.py index a347fede..742d5122 100644 --- a/src/hermes/commands/curate/base.py +++ b/src/hermes/commands/curate/base.py @@ -5,6 +5,7 @@ # SPDX-FileContributor: Michael Meinel # SPDX-FileContributor: David Pape +from abc import abstractmethod import argparse import json import sys @@ -70,13 +71,14 @@ def create_report(self): """ pass + @abstractmethod def is_publication_approved(self) -> bool: """Return the publication decision made through the curation process. - If publication is allowed, this method must return ``True``. By default, - ``False`` is returned. + If publication is allowed, this method must return ``True``, otherwise + ``False``. """ - return False + pass def process_decision_positive(self): """Process a positive curation decision.