From 4c437b89560336c55f18d5ef9df4b2062108b2e4 Mon Sep 17 00:00:00 2001 From: Giovanni Pizzi Date: Tue, 4 Nov 2025 09:40:03 +0100 Subject: [PATCH 1/3] Adding support for AiiDA monitors Now launch_shell_job allows to optionally set monitors which are simply passed to the underlying builder. Also, I am slighly adapting tests to ensure they can also run on a default MacOS machine (where default paths for common bash commands are a bit different, as well as the behaviour of some commands, such as the available flags of the `date` command...) --- src/aiida_shell/launch.py | 9 +++++++++ src/aiida_shell/parsers/shell.py | 2 +- tests/calculations/test_shell.py | 6 +++++- tests/calculations/test_shell/test_filename_stdin.txt | 2 +- tests/conftest.py | 5 ++++- tests/test_launch.py | 7 +++++-- 6 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/aiida_shell/launch.py b/src/aiida_shell/launch.py index 8b53865..15e8d47 100644 --- a/src/aiida_shell/launch.py +++ b/src/aiida_shell/launch.py @@ -32,6 +32,7 @@ def launch_shell_job( # noqa: PLR0913 metadata: dict[str, t.Any] | None = None, submit: bool = False, resolve_command: bool = True, + monitors: dict[str, Data] | None = None, ) -> tuple[dict[str, Data], ProcessNode]: """Launch a :class:`aiida_shell.ShellJob` job for the given command. @@ -52,6 +53,8 @@ def launch_shell_job( # noqa: PLR0913 :param resolve_command: Whether to resolve the command to the absolute path of the executable. If set to ``True``, the ``which`` command is executed on the target computer to attempt and determine the absolute path. Otherwise, the command is set as the ``filepath_executable`` attribute of the created ``AbstractCode`` instance. + :param monitors: Optional dictionary of ``Data`` nodes to be used as monitors for the job (see AiiDA + documentation on how to define monitors). :raises TypeError: If the value specified for ``metadata.options.computer`` is not a ``Computer``. :raises ValueError: If ``resolve_command=True`` and the absolute path of the command on the computer could not be determined. @@ -69,6 +72,7 @@ def launch_shell_job( # noqa: PLR0913 parser=parser, metadata=metadata, resolve_command=resolve_command, + monitors=monitors, ) if submit: @@ -91,6 +95,7 @@ def prepare_shell_job_inputs( # noqa: PLR0913 parser: ParserFunctionType | str | None = None, metadata: dict[str, t.Any] | None = None, resolve_command: bool = True, + monitors: dict[str, Data] | None = None, ) -> dict[str, t.Any]: """Prepare inputs for the ShellJob based on the provided parameters. @@ -110,6 +115,8 @@ def prepare_shell_job_inputs( # noqa: PLR0913 :param resolve_command: Whether to resolve the command to the absolute path of the executable. If set to ``True``, the ``which`` command is executed on the target computer to attempt and determine the absolute path. Otherwise, the command is set as the ``filepath_executable`` attribute of the created ``AbstractCode`` instance. + :param monitors: Optional dictionary of ``Data`` nodes to be used as monitors for the job (see AiiDA + documentation on how to define monitors). :raises TypeError: If the value specified for ``metadata.options.computer`` is not a ``Computer``. :raises ValueError: If ``resolve_command=True`` and the absolute path of the command on the computer could not be determined. @@ -148,6 +155,8 @@ def prepare_shell_job_inputs( # noqa: PLR0913 'parser': parser, 'metadata': metadata or {}, } + if monitors: + inputs['monitors'] = monitors return inputs diff --git a/src/aiida_shell/parsers/shell.py b/src/aiida_shell/parsers/shell.py index 4b701e7..fef695e 100644 --- a/src/aiida_shell/parsers/shell.py +++ b/src/aiida_shell/parsers/shell.py @@ -84,7 +84,7 @@ def parse_default_outputs(self, dirpath: pathlib.Path) -> ExitCode: except FileNotFoundError: stderr = '' else: - stderr = node_stderr.get_content() # type: ignore[assignment] + stderr = node_stderr.get_content(mode='r') self.out(ShellJob.FILENAME_STDERR, node_stderr) filename_stdout = self.node.get_option('output_filename') or ShellJob.FILENAME_STDOUT diff --git a/tests/calculations/test_shell.py b/tests/calculations/test_shell.py index c1b6531..96318c5 100644 --- a/tests/calculations/test_shell.py +++ b/tests/calculations/test_shell.py @@ -286,7 +286,11 @@ def test_output_filename(generate_calc_job, generate_code, file_regression): def test_filename_stdin(generate_calc_job, generate_code, file_regression): """Test the ``metadata.options.filename_stdin`` input.""" inputs = { - 'code': generate_code('cat'), + # even if 'cat' would be more natural, we use `diff` to avoid issues + # in this specific test, because the exact path of the `cat` binary + # may differ across systems (Linux vs. MacOS), while `diff` seems + # to be (by default) more consistent (in /usr/bin). + 'code': generate_code('diff'), 'arguments': List(['{filename}']), 'nodes': {'filename': SinglefileData.from_string('content')}, 'metadata': {'options': {'filename_stdin': 'filename'}}, diff --git a/tests/calculations/test_shell/test_filename_stdin.txt b/tests/calculations/test_shell/test_filename_stdin.txt index 6fdccec..18521f0 100644 --- a/tests/calculations/test_shell/test_filename_stdin.txt +++ b/tests/calculations/test_shell/test_filename_stdin.txt @@ -3,6 +3,6 @@ exec > _scheduler-stdout.txt exec 2> _scheduler-stderr.txt -'/usr/bin/cat' < 'filename' > 'stdout' 2> 'stderr' +'/usr/bin/diff' < 'filename' > 'stdout' 2> 'stderr' echo $? > status diff --git a/tests/conftest.py b/tests/conftest.py index 46a8ac0..5dd2df4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ import collections import pathlib +import shutil import tempfile import typing as t import uuid @@ -170,8 +171,10 @@ def factory(label='localhost', hostname='localhost', scheduler_type='core.direct @pytest.fixture def generate_code(generate_computer): """Return a :class:`aiida_shell.data.code.ShellCode` instance, either already existing or created.""" + default_command = shutil.which('true') # /bin/true on Linux, /usr/bin/true on macOS + assert default_command is not None, 'The `true` command must be available on the system for the tests to run.' - def factory(command='/bin/true', computer_label='localhost', label=None, entry_point_name='core.shell'): + def factory(command=default_command, computer_label='localhost', label=None, entry_point_name='core.shell'): """Return a :class:`aiida_shell.data.code.ShellCode` instance, either already existing or created.""" label = label or str(uuid.uuid4()) computer = generate_computer(computer_label) diff --git a/tests/test_launch.py b/tests/test_launch.py index dfbe236..974aae7 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -10,6 +10,9 @@ from aiida_shell.calculations.shell import ShellJob from aiida_shell.launch import launch_shell_job, prepare_computer +DATE_COMMAND = shutil.which('date') +assert DATE_COMMAND is not None, 'The `date` command must be available in order to run the tests.' + class ShellWorkChain(WorkChain): """Implementation of :class:`aiida.engine.processes.workchains.workchain.WorkChain` that submits a ``ShellJob``.""" @@ -86,7 +89,7 @@ def test_arguments(): shellfunction runs just before midnight and the comparison ``datetime`` call runs in the next day causing the test to fail, but that seems extremely unlikely. """ - arguments = ['--iso-8601'] + arguments = ['-I'] # equivalent to --iso-8601, but supported also on MacOS results, node = launch_shell_job('date', arguments=arguments) assert node.is_finished_ok @@ -273,7 +276,7 @@ def job_function(): @pytest.mark.parametrize( 'resolve_command, executable', ( - (True, '/usr/bin/date'), + (True, DATE_COMMAND), (False, 'date'), ), ) From af83dcc065c1cf7d4d67c7b0d09e517a07899d81 Mon Sep 17 00:00:00 2001 From: Julian Geiger Date: Tue, 6 Jan 2026 18:11:07 +0100 Subject: [PATCH 2/3] Apply suggestion from @GeigerJ2 --- src/aiida_shell/launch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aiida_shell/launch.py b/src/aiida_shell/launch.py index 15e8d47..459a4df 100644 --- a/src/aiida_shell/launch.py +++ b/src/aiida_shell/launch.py @@ -32,7 +32,7 @@ def launch_shell_job( # noqa: PLR0913 metadata: dict[str, t.Any] | None = None, submit: bool = False, resolve_command: bool = True, - monitors: dict[str, Data] | None = None, + monitors: dict[str, Dict] | None = None, ) -> tuple[dict[str, Data], ProcessNode]: """Launch a :class:`aiida_shell.ShellJob` job for the given command. From 954a65d28cc506487e4a35ef59bf8e0fffcab5b9 Mon Sep 17 00:00:00 2001 From: Julian Geiger Date: Wed, 7 Jan 2026 11:00:22 +0100 Subject: [PATCH 3/3] update type annotation and add monitor pass-through test --- src/aiida_shell/launch.py | 8 ++++---- tests/test_launch.py | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/aiida_shell/launch.py b/src/aiida_shell/launch.py index 459a4df..8ff4f2d 100644 --- a/src/aiida_shell/launch.py +++ b/src/aiida_shell/launch.py @@ -11,7 +11,7 @@ from aiida.common import exceptions, lang from aiida.common.warnings import AiidaDeprecationWarning from aiida.engine import Process, WorkChain, launch -from aiida.orm import AbstractCode, Computer, Data, ProcessNode, SinglefileData, load_code, load_computer +from aiida.orm import AbstractCode, Computer, Data, Dict, ProcessNode, SinglefileData, load_code, load_computer from aiida_shell import ShellCode, ShellJob @@ -53,7 +53,7 @@ def launch_shell_job( # noqa: PLR0913 :param resolve_command: Whether to resolve the command to the absolute path of the executable. If set to ``True``, the ``which`` command is executed on the target computer to attempt and determine the absolute path. Otherwise, the command is set as the ``filepath_executable`` attribute of the created ``AbstractCode`` instance. - :param monitors: Optional dictionary of ``Data`` nodes to be used as monitors for the job (see AiiDA + :param monitors: Optional dictionary of ``Dict`` nodes to be used as monitors for the job (see AiiDA documentation on how to define monitors). :raises TypeError: If the value specified for ``metadata.options.computer`` is not a ``Computer``. :raises ValueError: If ``resolve_command=True`` and the absolute path of the command on the computer could not be @@ -95,7 +95,7 @@ def prepare_shell_job_inputs( # noqa: PLR0913 parser: ParserFunctionType | str | None = None, metadata: dict[str, t.Any] | None = None, resolve_command: bool = True, - monitors: dict[str, Data] | None = None, + monitors: dict[str, Dict] | None = None, ) -> dict[str, t.Any]: """Prepare inputs for the ShellJob based on the provided parameters. @@ -115,7 +115,7 @@ def prepare_shell_job_inputs( # noqa: PLR0913 :param resolve_command: Whether to resolve the command to the absolute path of the executable. If set to ``True``, the ``which`` command is executed on the target computer to attempt and determine the absolute path. Otherwise, the command is set as the ``filepath_executable`` attribute of the created ``AbstractCode`` instance. - :param monitors: Optional dictionary of ``Data`` nodes to be used as monitors for the job (see AiiDA + :param monitors: Optional dictionary of ``Dict`` nodes to be used as monitors for the job (see AiiDA documentation on how to define monitors). :raises TypeError: If the value specified for ``metadata.options.computer`` is not a ``Computer``. :raises ValueError: If ``resolve_command=True`` and the absolute path of the command on the computer could not be diff --git a/tests/test_launch.py b/tests/test_launch.py index 974aae7..bad05ed 100644 --- a/tests/test_launch.py +++ b/tests/test_launch.py @@ -382,3 +382,25 @@ def test_metadata_computer(generate_computer): _, node = launch_shell_job('date', metadata={'computer': computer}) assert node.is_finished_ok assert node.inputs.code.computer.uuid == computer.uuid + + +def test_monitors(): + """Test the ``monitors`` input.""" + from aiida.orm import Dict + + # Create simple monitor configurations + dict_one = {'entry_point': 'core.always_kill', 'minimum_poll_interval': 60} + dict_two = {'entry_point': 'core.always_kill', 'minimum_poll_interval': 120} + monitors = { + 'monitor_one': Dict(dict_one), + 'monitor_two': Dict(dict_two), + } + + _, node = launch_shell_job('date', monitors=monitors) + assert node.is_finished_ok + assert 'monitors' in node.inputs + assert 'monitor_one' in node.inputs.monitors + assert 'monitor_two' in node.inputs.monitors + + assert node.inputs.monitors.monitor_one.get_dict() == dict_one + assert node.inputs.monitors.monitor_two.get_dict() == dict_two