Skip to content

Commit decf35d

Browse files
authored
Merge branch 'main' into feat/slurm_signals
2 parents 792bc00 + 8489b10 commit decf35d

4 files changed

Lines changed: 118 additions & 52 deletions

File tree

snakemake_executor_plugin_slurm/__init__.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1320,7 +1320,7 @@ async def check_active_jobs(
13201320
# slurmdbd/accounting hiccups where job states are briefly incomplete.
13211321
for i in range(status_attempts):
13221322
async with self.status_rate_limiter:
1323-
(status_of_jobs, sacct_query_duration) = await query_job_status(
1323+
status_of_jobs, sacct_query_duration = await query_job_status(
13241324
status_command, self.logger
13251325
)
13261326
if status_of_jobs is None and sacct_query_duration is None:
@@ -1616,8 +1616,10 @@ def get_account_arg(self, job: JobExecutorInterface):
16161616
if job.resources.get("slurm_account"):
16171617
# split the account upon ',' and whitespace, to allow
16181618
# multiple accounts being given
1619+
# Note: YAML parsing may convert numeric strings (e.g. "123456") to int.
1620+
# Ensure we always work with strings for re.split and shlex.quote.
16191621
accounts = [
1620-
a for a in re.split(r"[,\s]+", job.resources.slurm_account) if a
1622+
a for a in re.split(r"[,\s]+", str(job.resources.slurm_account)) if a
16211623
]
16221624
for account in accounts:
16231625
# here, we check whether the given or guessed account is valid

snakemake_executor_plugin_slurm/utils.py

Lines changed: 25 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -474,59 +474,49 @@ def set_gres_string(job: JobExecutorInterface) -> str:
474474
Function to set the gres string for the SLURM job
475475
based on the resources requested in the job.
476476
"""
477-
# generic resources (GRES) arguments can be of type
477+
# generic resources (GRES) arguments can be of type "string",
478478
# "string:int" or "string:string:int" with optional postfix 'T' or 'G' or 'M'
479-
gres_re = re.compile(r"^[a-zA-Z0-9_]+(:[a-zA-Z0-9_\.]+)?:\d+[TGM]?$")
479+
gres_re = re.compile(r"^[a-zA-Z0-9_]+(:[a-zA-Z0-9_\.]+)?(:\d+[TGM]?)?$")
480480

481481
# gpu model arguments can be of type "string"
482482
# The model string may contain a dot for variants, see
483483
# https://github.com/snakemake/snakemake-executor-plugin-slurm/issues/387
484484
gpu_model_re = re.compile(r"^[a-zA-Z0-9_\.]+$")
485+
485486
# any arguments should not start and end with ticks or
486487
# quotation marks:
487488
string_check = re.compile(r"^[^'\"].*[^'\"]$")
489+
488490
# The Snakemake resources can be only be of type "int",
489491
# hence no further regex is needed.
490492

491-
gpu_string = None
492-
if job.resources.get("gpu"):
493-
gpu_string = str(job.resources.get("gpu"))
494-
495-
gpu_model = None
496-
if job.resources.get("gpu_model"):
497-
gpu_model = job.resources.get("gpu_model")
498-
499-
# ensure that gres is not set, if gpu and gpu_model are set
500-
if job.resources.get("gres") and gpu_string:
501-
raise WorkflowError(
502-
"GRES and GPU are set. Please only set one of them.", rule=job.rule
503-
)
504-
elif not job.resources.get("gres") and not gpu_model and not gpu_string:
505-
return ""
506-
493+
# GRES
494+
gres = ""
507495
if job.resources.get("gres"):
508496
# Validate GRES format (e.g., "gpu:1", "gpu:tesla:2")
509-
gres = job.resources.gres
510-
if not gres_re.match(gres):
511-
if not string_check.match(gres):
497+
if not gres_re.match(job.resources.gres):
498+
if not string_check.match(job.resources.gres):
512499
raise WorkflowError(
513500
"GRES format should not be a nested string (start "
514501
"and end with ticks or quotation marks). "
515-
"Expected format: "
502+
"Expected format: '<name>', "
516503
"'<name>:<number>' or '<name>:<type>:<number>' with an optional "
517504
"'T' 'M' or 'G' postfix "
518-
"(e.g., 'gpu:1' or 'gpu:tesla:2')"
505+
"(e.g., 'gpu', 'gpu:2' or 'gpu:tesla:1')"
519506
)
520507
else:
521508
raise WorkflowError(
522-
f"Invalid GRES format: {gres}. Expected format: "
523-
"'<name>:<number>' or '<name>:<type>:<number>' with an optional "
524-
"'T' 'M' or 'G' postfix "
525-
"(e.g., 'gpu:1' or 'gpu:tesla:2') "
509+
f"Invalid GRES format: {job.resources.gres}. Expected format: "
510+
"'<name>', '<name>:<number>' or '<name>:<type>:<number>' "
511+
"with an optional 'T' 'M' or 'G' postfix "
512+
"(e.g., 'gpu', 'gpu:1' or 'gpu:tesla:2') "
526513
)
527-
return f" --gres={job.resources.gres}"
514+
gres += f" --gres={job.resources.gres}"
528515

529-
if gpu_model and gpu_string:
516+
# GPU
517+
gpu_string = job.resources.get("gpu")
518+
gpu_model = job.resources.get("gpu_model")
519+
if gpu_model:
530520
# validate GPU model format
531521
if not gpu_model_re.match(gpu_model):
532522
if not string_check.match(gpu_model):
@@ -540,10 +530,12 @@ def set_gres_string(job: JobExecutorInterface) -> str:
540530
f"Invalid GPU model format: {gpu_model}."
541531
" Expected format: '<name>' (e.g., 'tesla')"
542532
)
543-
return f" --gpus={gpu_model}:{gpu_string}"
544-
elif gpu_model and not gpu_string:
545-
raise WorkflowError("GPU model is set, but no GPU number is given")
533+
# Default GPU to 1
534+
gpu_string = job.resources.get("gpu", "1")
535+
gres += f" --gpus={gpu_model}:{gpu_string}"
546536
elif gpu_string:
547537
# we assume here, that the validator ensures that the 'gpu_string'
548538
# is an integer
549-
return f" --gpus={gpu_string}"
539+
gres += f" --gpus={gpu_string}"
540+
541+
return gres

tests/test_account.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Unit tests for get_account_arg() method."""
2+
3+
from unittest.mock import MagicMock, patch
4+
5+
from snakemake_executor_plugin_slurm import Executor
6+
7+
8+
class _Resources(dict):
9+
"""Dict-like resources with attribute access for known keys only."""
10+
11+
def __getattr__(self, name):
12+
try:
13+
return self[name]
14+
except KeyError as e:
15+
raise AttributeError(name) from e
16+
17+
18+
def _make_mock_job(
19+
rule_name="myrule", name=None, wildcards=None, jobid=1, is_group=False, **resources
20+
):
21+
"""Return a minimal mock job compatible with get_account_arg."""
22+
mock_resources = _Resources(resources)
23+
24+
mock_rule = MagicMock()
25+
mock_rule.name = rule_name
26+
27+
job = MagicMock()
28+
job.resources = mock_resources
29+
job.rule = mock_rule
30+
job.name = name if name is not None else rule_name
31+
job.wildcards = wildcards if wildcards is not None else {}
32+
job.is_group.return_value = is_group
33+
job.threads = resources.get("threads", 1)
34+
job.jobid = jobid
35+
return job
36+
37+
38+
class TestGetAccountArg:
39+
"""Tests for get_account_arg() method handling string and integer accounts."""
40+
41+
def _make_executor_stub(self):
42+
"""Return a minimal Executor stub."""
43+
executor = Executor.__new__(Executor)
44+
executor.logger = MagicMock()
45+
executor._fallback_account_arg = None
46+
return executor
47+
48+
# Patches prevent actual SLURM command execution (sacct/sacctmgr).
49+
# - get_account() would call 'sacct' to guess account from recent jobs.
50+
# - test_account() would call 'sacctmgr' or 'sshare' to validate account.
51+
# Mocks allow testing without a live SLURM cluster.
52+
@patch("snakemake_executor_plugin_slurm.get_account")
53+
@patch("snakemake_executor_plugin_slurm.test_account")
54+
def test_string_account(self, mock_test_account, mock_get_account, tmp_path):
55+
"""String account values work correctly."""
56+
executor = self._make_executor_stub()
57+
job = _make_mock_job(slurm_account="123456789")
58+
result = next(executor.get_account_arg(job))
59+
assert result == " -A 123456789"
60+
mock_test_account.assert_called_with("123456789", executor.logger)
61+
62+
@patch("snakemake_executor_plugin_slurm.get_account")
63+
@patch("snakemake_executor_plugin_slurm.test_account")
64+
def test_integer_account(self, mock_test_account, mock_get_account, tmp_path):
65+
"""Integer account values (from YAML parsing) work correctly."""
66+
executor = self._make_executor_stub()
67+
job = _make_mock_job(slurm_account=123456789)
68+
result = next(executor.get_account_arg(job))
69+
assert result == " -A 123456789"
70+
mock_test_account.assert_called_with("123456789", executor.logger)
71+
72+
@patch("snakemake_executor_plugin_slurm.get_account")
73+
@patch("snakemake_executor_plugin_slurm.test_account")
74+
def test_multiple_accounts_string(
75+
self, mock_test_account, mock_get_account, tmp_path
76+
):
77+
"""Multiple string accounts work correctly."""
78+
executor = self._make_executor_stub()
79+
job = _make_mock_job(slurm_account="acc1,acc2 acc3")
80+
results = list(executor.get_account_arg(job))
81+
assert results == [" -A acc1", " -A acc2", " -A acc3"]

tests/tests.py

Lines changed: 8 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ def test_valid_gres_with_model(self, mock_job):
327327

328328
assert set_gres_string(job) == " --gres=gpu:tesla:2"
329329

330-
def test_invalid_gres_format(self, mock_job):
330+
def test_valid_gres_format_gpu(self, mock_job):
331331
"""Test with invalid GRES format."""
332332
job = mock_job(gres="gpu")
333333

@@ -338,12 +338,12 @@ def test_invalid_gres_format(self, mock_job):
338338
process_mock.communicate.return_value = ("123", "")
339339
process_mock.returncode = 0
340340
mock_popen.return_value = process_mock
341-
with pytest.raises(WorkflowError, match="Invalid GRES format"):
342-
set_gres_string(job)
343341

344-
def test_invalid_gres_format_missing_count(self, mock_job):
342+
assert set_gres_string(job) == " --gres=gpu"
343+
344+
def test_valid_gres_format_gpu_model(self, mock_job):
345345
"""Test with invalid GRES format (missing count)."""
346-
job = mock_job(gres="gpu:tesla:")
346+
job = mock_job(gres="gpu:tesla")
347347

348348
# Patch subprocess.Popen to capture the sbatch command
349349
with patch("subprocess.Popen") as mock_popen:
@@ -353,8 +353,7 @@ def test_invalid_gres_format_missing_count(self, mock_job):
353353
process_mock.returncode = 0
354354
mock_popen.return_value = process_mock
355355

356-
with pytest.raises(WorkflowError, match="Invalid GRES format"):
357-
set_gres_string(job)
356+
assert set_gres_string(job) == " --gres=gpu:tesla"
358357

359358
def test_valid_gpu_number(self, mock_job):
360359
"""Test with valid GPU number."""
@@ -424,11 +423,7 @@ def test_gpu_model_without_gpu(self, mock_job):
424423
process_mock.returncode = 0
425424
mock_popen.return_value = process_mock
426425

427-
# test whether the resource setting raises the correct error
428-
with pytest.raises(
429-
WorkflowError, match="GPU model is set, but no GPU number is given"
430-
):
431-
set_gres_string(job)
426+
assert set_gres_string(job) == " --gpus=tesla:1"
432427

433428
def test_tmpspace_gres_10G(self, mock_job):
434429
"""Test with valid GRES format (simple)."""
@@ -456,11 +451,7 @@ def test_both_gres_and_gpu_set(self, mock_job):
456451
process_mock.returncode = 0
457452
mock_popen.return_value = process_mock
458453

459-
# Ensure the error is raised when both GRES and GPU are set
460-
with pytest.raises(
461-
WorkflowError, match="GRES and GPU are set. Please only set one"
462-
):
463-
set_gres_string(job)
454+
assert set_gres_string(job) == " --gres=gpu:1 --gpus=2"
464455

465456
def test_nested_string_raise(self, mock_job):
466457
"""Test error case when gres is a nested string."""

0 commit comments

Comments
 (0)