snakemake-executor-plugin-slurm 1.7.0__py3-none-any.whl → 1.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of snakemake-executor-plugin-slurm might be problematic. Click here for more details.

@@ -18,6 +18,7 @@ import uuid
18
18
 
19
19
  from snakemake_interface_executor_plugins.executors.base import SubmittedJobInfo
20
20
  from snakemake_interface_executor_plugins.executors.remote import RemoteExecutor
21
+
21
22
  from snakemake_interface_executor_plugins.settings import (
22
23
  ExecutorSettingsBase,
23
24
  CommonSettings,
@@ -34,6 +35,7 @@ from .utils import (
34
35
  )
35
36
  from .efficiency_report import create_efficiency_report
36
37
  from .submit_string import get_submit_command
38
+ from .validation import validate_slurm_extra
37
39
 
38
40
 
39
41
  @dataclass
@@ -307,7 +309,7 @@ class Executor(RemoteExecutor):
307
309
  "run_uuid": self.run_uuid,
308
310
  "slurm_logfile": slurm_logfile,
309
311
  "comment_str": comment_str,
310
- "account": self.get_account_arg(job),
312
+ "account": next(self.get_account_arg(job)),
311
313
  "partition": self.get_partition_arg(job),
312
314
  "workdir": self.workflow.workdir_init,
313
315
  }
@@ -339,17 +341,6 @@ class Executor(RemoteExecutor):
339
341
  "- submitting without. This might or might not work on your cluster."
340
342
  )
341
343
 
342
- # MPI job
343
- if job.resources.get("mpi", False):
344
- if not job.resources.get("tasks_per_node") and not job.resources.get(
345
- "nodes"
346
- ):
347
- self.logger.warning(
348
- "MPI job detected, but no 'tasks_per_node' or 'nodes' "
349
- "specified. Assuming 'tasks_per_node=1'."
350
- "Probably not what you want."
351
- )
352
-
353
344
  exec_job = self.format_job_exec(job)
354
345
 
355
346
  # and finally the job to execute with all the snakemake parameters
@@ -668,10 +659,22 @@ We leave it to SLURM to resume your job(s)"""
668
659
  else raises an error - implicetly.
669
660
  """
670
661
  if job.resources.get("slurm_account"):
671
- # here, we check whether the given or guessed account is valid
672
- # if not, a WorkflowError is raised
673
- self.test_account(job.resources.slurm_account)
674
- return f" -A '{job.resources.slurm_account}'"
662
+ # split the account upon ',' and whitespace, to allow
663
+ # multiple accounts being given
664
+ accounts = [
665
+ a for a in re.split(r"[,\s]+", job.resources.slurm_account) if a
666
+ ]
667
+ for account in accounts:
668
+ # here, we check whether the given or guessed account is valid
669
+ # if not, a WorkflowError is raised
670
+ self.test_account(account)
671
+ # sbatch only allows one account per submission
672
+ # yield one after the other, if multiple were given
673
+ # we have to quote the account, because it might
674
+ # contain build-in shell commands - see issue #354
675
+ for account in accounts:
676
+ self.test_account(account)
677
+ yield f" -A {shlex.quote(account)}"
675
678
  else:
676
679
  if self._fallback_account_arg is None:
677
680
  self.logger.warning("No SLURM account given, trying to guess.")
@@ -679,7 +682,7 @@ We leave it to SLURM to resume your job(s)"""
679
682
  if account:
680
683
  self.logger.warning(f"Guessed SLURM account: {account}")
681
684
  self.test_account(f"{account}")
682
- self._fallback_account_arg = f" -A {account}"
685
+ self._fallback_account_arg = f" -A {shlex.quote(account)}"
683
686
  else:
684
687
  self.logger.warning(
685
688
  "Unable to guess SLURM account. Trying to proceed without."
@@ -687,7 +690,7 @@ We leave it to SLURM to resume your job(s)"""
687
690
  self._fallback_account_arg = (
688
691
  "" # no account specific args for sbatch
689
692
  )
690
- return self._fallback_account_arg
693
+ yield self._fallback_account_arg
691
694
 
692
695
  def get_partition_arg(self, job: JobExecutorInterface):
693
696
  """
@@ -702,7 +705,9 @@ We leave it to SLURM to resume your job(s)"""
702
705
  self._fallback_partition = self.get_default_partition(job)
703
706
  partition = self._fallback_partition
704
707
  if partition:
705
- return f" -p {partition}"
708
+ # we have to quote the partition, because it might
709
+ # contain build-in shell commands
710
+ return f" -p {shlex.quote(partition)}"
706
711
  else:
707
712
  return ""
708
713
 
@@ -730,32 +735,35 @@ We leave it to SLURM to resume your job(s)"""
730
735
  """
731
736
  tests whether the given account is registered, raises an error, if not
732
737
  """
733
- cmd = "sshare -U --format Account%256 --noheader"
738
+ # first we need to test with sacctmgr because sshare might not
739
+ # work in a multicluster environment
740
+ cmd = f'sacctmgr -n -s list user "{os.environ["USER"]}" format=account%256'
741
+ sacctmgr_report = sshare_report = ""
734
742
  try:
735
743
  accounts = subprocess.check_output(
736
744
  cmd, shell=True, text=True, stderr=subprocess.PIPE
737
745
  )
738
746
  except subprocess.CalledProcessError as e:
739
- sshare_report = (
747
+ sacctmgr_report = (
740
748
  "Unable to test the validity of the given or guessed"
741
- f" SLURM account '{account}' with sshare: {e.stderr}."
749
+ f" SLURM account '{account}' with sacctmgr: {e.stderr}."
742
750
  )
743
751
  accounts = ""
744
752
 
745
753
  if not accounts.strip():
746
- cmd = f'sacctmgr -n -s list user "{os.environ["USER"]}" format=account%256'
754
+ cmd = "sshare -U --format Account%256 --noheader"
747
755
  try:
748
756
  accounts = subprocess.check_output(
749
757
  cmd, shell=True, text=True, stderr=subprocess.PIPE
750
758
  )
751
759
  except subprocess.CalledProcessError as e:
752
- sacctmgr_report = (
760
+ sshare_report = (
753
761
  "Unable to test the validity of the given or guessed "
754
- f"SLURM account '{account}' with sacctmgr: {e.stderr}."
762
+ f"SLURM account '{account}' with sshare: {e.stderr}."
755
763
  )
756
764
  raise WorkflowError(
757
- f"The 'sshare' reported: '{sshare_report}' "
758
- f"and likewise 'sacctmgr' reported: '{sacctmgr_report}'."
765
+ f"The 'sacctmgr' reported: '{sacctmgr_report}' "
766
+ f"and likewise 'sshare' reported: '{sshare_report}'."
759
767
  )
760
768
 
761
769
  # The set() has been introduced during review to eliminate
@@ -764,7 +772,7 @@ We leave it to SLURM to resume your job(s)"""
764
772
 
765
773
  if not accounts:
766
774
  self.logger.warning(
767
- f"Both 'sshare' and 'sacctmgr' returned empty results for account "
775
+ f"Both 'sacctmgr' and 'sshare' returned empty results for account "
768
776
  f"'{account}'. Proceeding without account validation."
769
777
  )
770
778
  return ""
@@ -804,13 +812,5 @@ We leave it to SLURM to resume your job(s)"""
804
812
  return ""
805
813
 
806
814
  def check_slurm_extra(self, job):
807
- jobname = re.compile(r"--job-name[=?|\s+]|-J\s?")
808
- if re.search(jobname, job.resources.slurm_extra):
809
- raise WorkflowError(
810
- "The --job-name option is not allowed in the 'slurm_extra' parameter. "
811
- "The job name is set by snakemake and must not be overwritten. "
812
- "It is internally used to check the stati of the all submitted jobs "
813
- "by this workflow."
814
- "Please consult the documentation if you are unsure how to "
815
- "query the status of your jobs."
816
- )
815
+ """Validate that slurm_extra doesn't contain executor-managed options."""
816
+ validate_slurm_extra(job)
@@ -3,24 +3,67 @@ import pandas as pd
3
3
  from pathlib import Path
4
4
  import subprocess
5
5
  import shlex
6
-
7
- import os # only temporarily needed for printf debugging
6
+ from datetime import datetime
8
7
  import numpy as np
8
+ import os
9
9
 
10
10
 
11
11
  def time_to_seconds(time_str):
12
- """Convert SLURM time format to seconds."""
13
- if pd.isna(time_str) or time_str.strip() == "":
12
+ """
13
+ Convert SLURM sacct time format to seconds.
14
+
15
+ Handles sacct output formats:
16
+ - Elapsed: [D-]HH:MM:SS or [DD-]HH:MM:SS (no fractional seconds)
17
+ - TotalCPU: [D-][HH:]MM:SS or [DD-][HH:]MM:SS (with fractional seconds)
18
+
19
+ Examples:
20
+ - "1-12:30:45" -> 131445 seconds (1 day + 12h 30m 45s)
21
+ - "23:59:59" -> 86399 seconds
22
+ - "45:30" -> 2730 seconds (45 minutes 30 seconds)
23
+ - "30.5" -> 30.5 seconds (fractional seconds for TotalCPU)
24
+ """
25
+ if (
26
+ pd.isna(time_str)
27
+ or str(time_str).strip() == ""
28
+ or str(time_str).strip() == "invalid"
29
+ ):
14
30
  return 0
15
- parts = time_str.split(":")
16
-
17
- if len(parts) == 3: # H:M:S
18
- return int(parts[0]) * 3600 + int(parts[1]) * 60 + float(parts[2])
19
- elif len(parts) == 2: # M:S
20
- return int(parts[0]) * 60 + float(parts[1])
21
- elif len(parts) == 1: # S
22
- return float(parts[0])
23
- return 0
31
+
32
+ time_str = str(time_str).strip()
33
+
34
+ # Try different SLURM time formats with datetime parsing
35
+ time_formats = [
36
+ "%d-%H:%M:%S.%f", # D-HH:MM:SS.ffffff (with fractional seconds)
37
+ "%d-%H:%M:%S", # D-HH:MM:SS
38
+ "%d-%M:%S", # D-MM:SS
39
+ "%d-%M:%S.%f", # D-MM:SS.ffffff (with fractional seconds)
40
+ "%H:%M:%S.%f", # HH:MM:SS.ffffff (with fractional seconds)
41
+ "%H:%M:%S", # HH:MM:SS
42
+ "%M:%S.%f", # MM:SS.ffffff (with fractional seconds)
43
+ "%M:%S", # MM:SS
44
+ "%S.%f", # SS.ffffff (with fractional seconds)
45
+ "%S", # SS
46
+ ]
47
+
48
+ for fmt in time_formats:
49
+ try:
50
+ time_obj = datetime.strptime(time_str, fmt)
51
+
52
+ total_seconds = (
53
+ time_obj.hour * 3600
54
+ + time_obj.minute * 60
55
+ + time_obj.second
56
+ + time_obj.microsecond / 1000000
57
+ )
58
+
59
+ # Add days if present (datetime treats day 1 as the first day)
60
+ if fmt.startswith("%d-"):
61
+ total_seconds += time_obj.day * 86400
62
+
63
+ return total_seconds
64
+ except ValueError:
65
+ continue
66
+ return 0 # If all parsing attempts fail, return 0
24
67
 
25
68
 
26
69
  def parse_maxrss(maxrss):
@@ -1,5 +1,19 @@
1
+ from snakemake_interface_common.exceptions import WorkflowError
1
2
  from snakemake_executor_plugin_slurm_jobstep import get_cpu_setting
2
3
  from types import SimpleNamespace
4
+ import shlex
5
+
6
+
7
+ def safe_quote(value):
8
+ """
9
+ Safely quote a parameter value using shlex.quote.
10
+ Handles None values and converts to string if needed.
11
+ Returns empty quotes for empty strings.
12
+ """
13
+ str_value = str(value)
14
+ if str_value == "":
15
+ return "''"
16
+ return shlex.quote(str_value)
3
17
 
4
18
 
5
19
  def get_submit_command(job, params):
@@ -10,37 +24,41 @@ def get_submit_command(job, params):
10
24
  params = SimpleNamespace(**params)
11
25
 
12
26
  call = (
13
- f"sbatch "
14
- f"--parsable "
15
- f"--job-name {params.run_uuid} "
16
- f'--output "{params.slurm_logfile}" '
17
- f"--export=ALL "
18
- f'--comment "{params.comment_str}"'
27
+ "sbatch "
28
+ "--parsable "
29
+ f"--job-name {safe_quote(params.run_uuid)} "
30
+ f"--output {safe_quote(params.slurm_logfile)} "
31
+ "--export=ALL "
32
+ f"--comment {safe_quote(params.comment_str)}"
19
33
  )
20
34
 
21
35
  # No accout or partition checking is required, here.
22
36
  # Checking is done in the submit function.
23
37
 
24
38
  # here, only the string is used, as it already contains
25
- # '-A {account_name}'
39
+ # "-A '{account_name}'"
26
40
  call += f" {params.account}"
27
41
  # here, only the string is used, as it already contains
28
- # '- p {partition_name}'
42
+ # "- p '{partition_name}'"
29
43
  call += f" {params.partition}"
30
44
 
31
45
  if job.resources.get("clusters"):
32
- call += f" --clusters {job.resources.clusters}"
46
+ call += f" --clusters {safe_quote(job.resources.clusters)}"
33
47
 
34
48
  if job.resources.get("runtime"):
35
- call += f" -t {job.resources.runtime}"
49
+ call += f" -t {safe_quote(job.resources.runtime)}"
36
50
 
37
- if job.resources.get("constraint") or isinstance(
38
- job.resources.get("constraint"), str
39
- ):
40
- call += f" -C '{job.resources.get('constraint')}'"
51
+ # Both, constraint and qos are optional.
52
+ # If not set, they will not be added to the sbatch call.
53
+ # If explicitly set to an empty string,
54
+ # `--constraint ''` or `--qos ''` will be added.
55
+ constraint = job.resources.get("constraint")
56
+ if constraint is not None:
57
+ call += f" -C {safe_quote(constraint)}"
41
58
 
42
- if job.resources.get("qos") or isinstance(job.resources.get("qos"), str):
43
- call += f" --qos='{job.resources.qos}'"
59
+ qos = job.resources.get("qos")
60
+ if qos is not None:
61
+ call += f" --qos={safe_quote(qos)}"
44
62
 
45
63
  if job.resources.get("mem_mb_per_cpu"):
46
64
  call += f" --mem-per-cpu {job.resources.mem_mb_per_cpu}"
@@ -66,7 +84,36 @@ def get_submit_command(job, params):
66
84
  else:
67
85
  # fixes #40 - set ntasks regardless of mpi, because
68
86
  # SLURM v22.05 will require it for all jobs
69
- call += f" --ntasks={job.resources.get('tasks') or 1}"
87
+ # if the job is a MPI job, ntasks will be set later
88
+ if not job.resources.get("mpi", False):
89
+ call += f" --ntasks={job.resources.get('tasks') or 1}"
90
+
91
+ # if the job is an MPI job, we need to have some task setting:
92
+ if job.resources.get("mpi", False):
93
+ if not job.resources.get("tasks_per_node") and not job.resources.get("tasks"):
94
+ raise WorkflowError(
95
+ "For MPI jobs, please specify either "
96
+ "'tasks_per_node' or 'tasks' (at least one is required)."
97
+ )
98
+ # raise an error if both task settings are used
99
+ if job.resources.get("tasks_per_node") and job.resources.get("tasks"):
100
+ raise WorkflowError(
101
+ "For MPI jobs, please specify either 'tasks_per_node' or 'tasks', "
102
+ "but not both."
103
+ )
104
+ if job.resources.get("tasks_per_node"):
105
+ if job.resources.get("tasks_per_node") <= 1:
106
+ raise WorkflowError(
107
+ "For MPI jobs, 'tasks_per_node' must be greater than 1."
108
+ )
109
+ call += f" --ntasks-per-node={job.resources.tasks_per_node}"
110
+ elif job.resources.get("tasks"):
111
+ if job.resources.get("tasks") == 1:
112
+ raise WorkflowError("For MPI jobs, 'tasks' must be greater than 1.")
113
+ call += f" --ntasks={job.resources.tasks}"
114
+ # nodes CAN be set independently of tasks or tasks_per_node
115
+ # this is at a user's discretion. The nodes flag might already
116
+ # be set above, if the user specified it.
70
117
 
71
118
  # we need to set cpus-per-task OR cpus-per-gpu, the function
72
119
  # will return a string with the corresponding value
@@ -77,6 +124,7 @@ def get_submit_command(job, params):
77
124
  # ensure that workdir is set correctly
78
125
  # use short argument as this is the same in all slurm versions
79
126
  # (see https://github.com/snakemake/snakemake/issues/2014)
80
- call += f" -D '{params.workdir}'"
127
+ if params.workdir:
128
+ call += f" -D {safe_quote(params.workdir)}"
81
129
 
82
130
  return call
@@ -0,0 +1,75 @@
1
+ """
2
+ SLURM parameter validation functions for the Snakemake executor plugin.
3
+ """
4
+
5
+ import re
6
+ from snakemake_interface_common.exceptions import WorkflowError
7
+
8
+
9
+ def get_forbidden_slurm_options():
10
+ """
11
+ Return a dictionary of forbidden SLURM options that the executor manages.
12
+
13
+ Returns:
14
+ dict: Mapping of regex patterns to human-readable option names
15
+ """
16
+ return {
17
+ # Job identification and output
18
+ r"--job-name[=\s]|-J\s?": "job name",
19
+ r"--output[=\s]|-o\s": "output file",
20
+ r"--error[=\s]|-e\s": "error file",
21
+ r"--parsable": "parsable output",
22
+ r"--export[=\s]": "environment export",
23
+ r"--comment[=\s]": "job comment",
24
+ r"--workdir[=\s]|-D\s": "working directory",
25
+ # Account and partition
26
+ r"--account[=\s]|-A\s": "account",
27
+ r"--partition[=\s]|-p\s": "partition",
28
+ # Memory options
29
+ r"--mem[=\s]": "memory",
30
+ r"--mem-per-cpu[=\s]": "memory per CPU",
31
+ # CPU and task options
32
+ r"--ntasks[=\s]|-n\s": "number of tasks",
33
+ r"--ntasks-per-gpu[=\s]": "tasks per GPU",
34
+ r"--cpus-per-task[=\s]|-c\s": "CPUs per task",
35
+ r"--cpus-per-gpu[=\s]": "CPUs per GPU",
36
+ # Time and resource constraints
37
+ r"--time[=\s]|-t\s": "runtime/time limit",
38
+ r"--constraint[=\s]|-C\s": "node constraints",
39
+ r"--qos[=\s]": "quality of service",
40
+ r"--nodes[=\s]|-N\s": "number of nodes",
41
+ r"--clusters[=\s]": "cluster specification",
42
+ # GPU options
43
+ r"--gres[=\s]": "generic resources (GRES)",
44
+ r"--gpus[=\s]": "GPU allocation",
45
+ }
46
+
47
+
48
+ def validate_slurm_extra(job):
49
+ """
50
+ Validate that slurm_extra doesn't contain executor-managed options.
51
+
52
+ Args:
53
+ job: Snakemake job object with resources attribute
54
+
55
+ Raises:
56
+ WorkflowError: If forbidden SLURM options are found in slurm_extra
57
+ """
58
+ # skip testing if no slurm_extra is set
59
+ slurm_extra = getattr(job.resources, "slurm_extra", None)
60
+ if not slurm_extra:
61
+ return
62
+
63
+ forbidden_options = get_forbidden_slurm_options()
64
+
65
+ for pattern, option_name in forbidden_options.items():
66
+ if re.search(pattern, slurm_extra):
67
+ raise WorkflowError(
68
+ f"The --{option_name.replace(' ', '-')} option is not "
69
+ f"allowed in the 'slurm_extra' parameter. "
70
+ f"The {option_name} is set by the snakemake executor plugin "
71
+ f"and must not be overwritten. "
72
+ f"Please use the appropriate snakemake resource "
73
+ f"specification instead. "
74
+ f"Consult the documentation for proper resource configuration."
75
+ )
@@ -1,8 +1,9 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: snakemake-executor-plugin-slurm
3
- Version: 1.7.0
3
+ Version: 1.9.0
4
4
  Summary: A Snakemake executor plugin for submitting jobs to a SLURM cluster.
5
5
  License: MIT
6
+ License-File: LICENSE
6
7
  Keywords: snakemake,plugin,executor,cluster,slurm
7
8
  Author: Christian Meesters
8
9
  Author-email: meesters@uni-mainz.de
@@ -12,6 +13,7 @@ Classifier: Programming Language :: Python :: 3
12
13
  Classifier: Programming Language :: Python :: 3.11
13
14
  Classifier: Programming Language :: Python :: 3.12
14
15
  Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
15
17
  Requires-Dist: numpy (>=1.26.4,<3)
16
18
  Requires-Dist: pandas (>=2.2.3,<3.0.0)
17
19
  Requires-Dist: snakemake-executor-plugin-slurm-jobstep (>=0.3.0,<0.4.0)
@@ -0,0 +1,9 @@
1
+ snakemake_executor_plugin_slurm/__init__.py,sha256=YUFJMWKlrbId4VgRreaea3B0JXJ_xTB1zNBJgAZ59Vw,33953
2
+ snakemake_executor_plugin_slurm/efficiency_report.py,sha256=3F6AqkKOZBn9MxI7T27u3FF3-OAxY_ee_toHXnTGirs,8812
3
+ snakemake_executor_plugin_slurm/submit_string.py,sha256=HL2QyT7-_HRu4yg_7tJSs1VtxGebNODOV8OQd2hYirg,5021
4
+ snakemake_executor_plugin_slurm/utils.py,sha256=7XVXtzu7bg_89wWZisW-Zk7TNQyEgK4v_y4Y3F9uOwc,4491
5
+ snakemake_executor_plugin_slurm/validation.py,sha256=hyWQWDjqHDLhO7caksYILTKjA2Ak91CMJeFwEcltYFo,2702
6
+ snakemake_executor_plugin_slurm-1.9.0.dist-info/METADATA,sha256=UxDm36Px5LDOM8PtDP3LMv8mHyWUzuuNqVD2CW1CA_Y,1507
7
+ snakemake_executor_plugin_slurm-1.9.0.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
8
+ snakemake_executor_plugin_slurm-1.9.0.dist-info/licenses/LICENSE,sha256=YVc4xTLWMqGfFL36120k7rzXtsT6e4RkJsh68VVn12s,1076
9
+ snakemake_executor_plugin_slurm-1.9.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.1.3
2
+ Generator: poetry-core 2.2.1
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,8 +0,0 @@
1
- snakemake_executor_plugin_slurm/__init__.py,sha256=BOuvCB954jBaxqoh4ezFBYLStptsrESbPduDXMT0K9Q,33916
2
- snakemake_executor_plugin_slurm/efficiency_report.py,sha256=crPfJDK4NojfRbu_wEw3ZmC3suMRABr5r-1rO5q3WEo,7429
3
- snakemake_executor_plugin_slurm/submit_string.py,sha256=Cn9qopyQwBqs1MvZFxSyRR_7mZzCVj8_vO_JNzbiqew,2896
4
- snakemake_executor_plugin_slurm/utils.py,sha256=7XVXtzu7bg_89wWZisW-Zk7TNQyEgK4v_y4Y3F9uOwc,4491
5
- snakemake_executor_plugin_slurm-1.7.0.dist-info/LICENSE,sha256=YVc4xTLWMqGfFL36120k7rzXtsT6e4RkJsh68VVn12s,1076
6
- snakemake_executor_plugin_slurm-1.7.0.dist-info/METADATA,sha256=XiUbU8KRQKmnK55EVkfrEKRYny3I4IF2WpbrfP43Acc,1434
7
- snakemake_executor_plugin_slurm-1.7.0.dist-info/WHEEL,sha256=b4K_helf-jlQoXBBETfwnf4B04YC67LOev0jo4fX5m8,88
8
- snakemake_executor_plugin_slurm-1.7.0.dist-info/RECORD,,