Skip to content

Commit 876be56

Browse files
authored
Merge pull request #853 from nipype/lmod-environment
Lmod-environment
2 parents d7b5dc7 + e3781fe commit 876be56

File tree

10 files changed

+372
-20
lines changed

10 files changed

+372
-20
lines changed

.github/workflows/ci-cd.yml

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,50 @@ jobs:
143143
fail_ci_if_error: true
144144
token: ${{ secrets.CODECOV_TOKEN }}
145145

146+
test-lmod:
147+
runs-on: ubuntu-22.04
148+
strategy:
149+
matrix:
150+
python-version: ['3.11', '3.12', '3.13']
151+
dependencies: [latest]
152+
fail-fast: False
153+
154+
env:
155+
DEPENDS: ${{ matrix.dependencies }}
156+
157+
steps:
158+
- name: Install Lmod
159+
run: sudo apt-get install -y lmod
160+
- name: Set env
161+
run: |
162+
echo "RELEASE_VERSION=v3.7.1" >> $GITHUB_ENV
163+
echo "NO_ET=TRUE" >> $GITHUB_ENV
164+
- uses: actions/checkout@v5
165+
with:
166+
fetch-depth: 0
167+
- name: Install the latest version of uv
168+
uses: astral-sh/setup-uv@v7
169+
with:
170+
python-version: ${{ matrix.python-version }}
171+
- name: Install tox
172+
run: |
173+
uv tool install tox --with=tox-uv --with=tox-gh-actions
174+
- name: Show tox config
175+
run: tox c
176+
- name: Run tox
177+
# Run test files with singularity tests; re-add the overridable "-n auto"
178+
run: |
179+
source /etc/profile.d/lmod.sh
180+
export
181+
tox -v --exit-and-dump-after 1200 -- -n auto \
182+
pydra/environments/tests/test_lmod.py
183+
- name: Upload coverage to Codecov
184+
uses: codecov/codecov-action@v5
185+
with:
186+
fail_ci_if_error: true
187+
token: ${{ secrets.CODECOV_TOKEN }}
188+
189+
146190
test-slurm:
147191
strategy:
148192
matrix:
@@ -334,7 +378,7 @@ jobs:
334378
path: docs/build/html
335379

336380
deploy:
337-
needs: [build, build-docs, test, test-singularity, test-slurm]
381+
needs: [build, build-docs, test, test-singularity, test-slurm, test-lmod]
338382
runs-on: ubuntu-latest
339383
if: github.event_name == 'release'
340384
permissions:

docs/source/explanation/environments.rst

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,18 @@ construction, and allows tasks to be run in environments that are isolated from
77
host system, and that have specific software dependencies.
88

99
The environment a task runs within is specified by the ``environment`` argument passed
10-
to the execution call (e.g. ``my_task(worker="cf", environment="docker")``) or in the
10+
to the execution call (e.g. ``my_task(worker="cf", environment=Docker("brainlife/fsl")``) or in the
1111
``workflow.add()`` call in workflow constructors.
1212

1313
Specifying at execution
1414
-----------------------
1515

1616
The environment for a task can be specified at execution time by passing the ``environment`` argument to the task call.
1717
This can be an instance of `pydra.environments.native.Environment` (for the host system),
18-
`pydra.environments.docker.Environment` (for Docker containers), or
19-
`pydra.environments.singularity.Environment` (for Singularity containers), or a custom environment.
18+
`pydra.environments.docker.Environment` (for Docker containers),
19+
`pydra.environments.singularity.Environment` (for Singularity containers),
20+
`pydra.environments.lmod.Lmod` (for lmod environment modules, e.g. ``module load fsl/6.0.7``)
21+
or a your own custom environment.
2022

2123
Example:
2224

docs/source/reference/api.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,21 @@ Engine classes
3232
:members:
3333
:undoc-members:
3434
:show-inheritance:
35+
36+
37+
Environments
38+
------------
39+
40+
.. automodule:: pydra.environments.docker.Docker
41+
:members:
42+
:show-inheritance:
43+
44+
45+
.. automodule:: pydra.environments.singularity.Singularity
46+
:members:
47+
:show-inheritance:
48+
49+
50+
.. automodule:: pydra.environments.lmod.Lmod
51+
:members:
52+
:show-inheritance:

docs/source/tutorial/2-advanced-execution.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@
312312
"## Environments and hooks\n",
313313
"\n",
314314
"For shell tasks, it is possible to specify that the command runs within a specific\n",
315-
"software environment, such as those provided by software containers (e.g. Docker or Singularity/Apptainer).\n",
315+
"software environment, such as those provided by software containers (e.g. Docker, Singularity/Apptainer or Lmod).\n",
316316
"This is down by providing the environment to the submitter/execution call,"
317317
]
318318
},

pydra/engine/tests/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# Tasks for testing
2+
import os
23
import time
34
import sys
45
import shutil
@@ -25,6 +26,9 @@
2526
need_singularity = pytest.mark.skipif(
2627
shutil.which("singularity") is None, reason="no singularity available"
2728
)
29+
need_lmod = pytest.mark.skipif(
30+
"MODULEPATH" not in os.environ, reason="modules not available"
31+
)
2832
no_win = pytest.mark.skipif(
2933
sys.platform.startswith("win"),
3034
reason="docker command not adjusted for windows docker",

pydra/environments/base.py

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,11 @@ def map_path(fileset: os.PathLike | FileSet) -> Path:
200200
return bindings, values
201201

202202

203-
def execute(cmd, strip=False):
203+
def execute(
204+
cmd: ty.Sequence[str],
205+
strip: bool = False,
206+
**kwargs: ty.Any,
207+
) -> tuple[int, str, str]:
204208
"""
205209
Run the event loop with coroutine.
206210
@@ -213,26 +217,23 @@ def execute(cmd, strip=False):
213217
cmd : :obj:`list` or :obj:`tuple`
214218
The command line to be executed.
215219
strip : :obj:`bool`
216-
TODO
220+
Whether to strip the output strings. Default is ``False``.
221+
kwargs : keyword arguments
222+
Additional keyword arguments passed to the subprocess call.
217223
218224
"""
219-
rc, stdout, stderr = read_and_display(*cmd, strip=strip)
220-
"""
221-
loop = get_open_loop()
222-
if loop.is_running():
223-
rc, stdout, stderr = read_and_display(*cmd, strip=strip)
224-
else:
225-
rc, stdout, stderr = loop.run_until_complete(
226-
read_and_display_async(*cmd, strip=strip)
227-
)
228-
"""
225+
rc, stdout, stderr = read_and_display(*cmd, strip=strip, **kwargs)
229226
return rc, stdout, stderr
230227

231228

232-
def read_and_display(*cmd, strip=False, hide_display=False):
229+
def read_and_display(
230+
*cmd: str, strip: bool = False, **kwargs: ty.Any
231+
) -> tuple[int, str, str]:
233232
"""Capture a process' standard output."""
234233
try:
235-
process = sp.run(cmd, stdout=sp.PIPE, stderr=sp.PIPE)
234+
process: sp.CompletedProcess = sp.run(
235+
cmd, stdout=sp.PIPE, stderr=sp.PIPE, **kwargs
236+
)
236237
except Exception:
237238
# TODO editing some tracing?
238239
raise

pydra/environments/lmod.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import os
2+
import typing as ty
3+
import logging
4+
from pathlib import Path
5+
import re
6+
import subprocess as sp
7+
8+
import attrs
9+
from pydra.compose import shell
10+
from pydra.environments import base
11+
from pydra.utils.general import ensure_list
12+
13+
14+
logger = logging.getLogger("pydra")
15+
16+
if ty.TYPE_CHECKING:
17+
from pydra.engine.job import Job
18+
19+
20+
@attrs.define
21+
class Lmod(base.Environment):
22+
"""Lmod environment."""
23+
24+
modules: list[str] = attrs.field(converter=ensure_list)
25+
26+
@modules.validator
27+
def _validate_modules(self, _, value: ty.Any) -> None:
28+
if not value:
29+
raise ValueError("At least one module must be specified")
30+
if not all(isinstance(v, str) for v in value):
31+
raise ValueError("All module names must be strings")
32+
33+
def execute(self, job: "Job[shell.Task]") -> dict[str, int | str]:
34+
env_src = self.run_lmod_cmd("python", "load", *self.modules)
35+
env = {}
36+
for key, value in re.findall(
37+
r"""os\.environ\[['"](.*?)['"]\]\s*=\s*['"](.*?)['"]""", env_src
38+
):
39+
env[key] = value
40+
cmd_args = job.task._command_args(values=job.inputs)
41+
values = base.execute(cmd_args, env=env)
42+
return_code, stdout, stderr = values
43+
if return_code:
44+
msg = f"Error running '{job.name}' job with {cmd_args}:"
45+
if stderr:
46+
msg += "\n\nstderr:\n" + stderr
47+
if stdout:
48+
msg += "\n\nstdout:\n" + stdout
49+
raise RuntimeError(msg)
50+
return {"return_code": return_code, "stdout": stdout, "stderr": stderr}
51+
52+
@classmethod
53+
def modules_are_installed(cls) -> bool:
54+
return "MODULESHOME" in os.environ
55+
56+
@classmethod
57+
def run_lmod_cmd(cls, *args: str) -> str:
58+
if not cls.modules_are_installed():
59+
raise RuntimeError(
60+
"Could not find Lmod installation, please ensure it is installed and MODULESHOME is set"
61+
)
62+
lmod_exec = Path(os.environ["MODULESHOME"]) / "libexec" / "lmod"
63+
64+
try:
65+
output_bytes, error_bytes = sp.Popen(
66+
[str(lmod_exec)] + list(args),
67+
stdout=sp.PIPE,
68+
stderr=sp.PIPE,
69+
).communicate()
70+
except (sp.CalledProcessError, OSError) as e:
71+
raise RuntimeError(f"Error running 'lmod': {e}")
72+
73+
output = output_bytes.decode("utf-8")
74+
error = error_bytes.decode("utf-8")
75+
76+
if output == "_mlstatus = False\n":
77+
raise RuntimeError(f"Error running module cmd '{' '.join(args)}':\n{error}")
78+
79+
return output
80+
81+
82+
# Alias so it can be referred to as lmod.Environment
83+
Environment = Lmod

0 commit comments

Comments
 (0)