slurmate 0.1.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.
- slurmate/__init__.py +3 -0
- slurmate/builder.py +219 -0
- slurmate/main.py +512 -0
- slurmate/py.typed +0 -0
- slurmate/system_utils.py +599 -0
- slurmate/theme.py +155 -0
- slurmate/tui.py +1125 -0
- slurmate-0.1.0.dist-info/METADATA +246 -0
- slurmate-0.1.0.dist-info/RECORD +13 -0
- slurmate-0.1.0.dist-info/WHEEL +5 -0
- slurmate-0.1.0.dist-info/entry_points.txt +2 -0
- slurmate-0.1.0.dist-info/licenses/LICENSE +21 -0
- slurmate-0.1.0.dist-info/top_level.txt +1 -0
slurmate/__init__.py
ADDED
slurmate/builder.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from .system_utils import _parse_slurm_time_to_minutes
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def build_from_answers(answers: dict[str, Any], partial: bool = False) -> str:
|
|
10
|
+
"""Build an sbatch script from an answers dict.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
answers: Collected wizard/CLI answers.
|
|
14
|
+
partial: When True, only emit directives for keys the user has actually
|
|
15
|
+
provided (used by the live preview, so unentered fields don't show
|
|
16
|
+
up as placeholder lines). When False, defaults fill in a complete,
|
|
17
|
+
submittable script.
|
|
18
|
+
"""
|
|
19
|
+
output_dir = answers.get("output_dir")
|
|
20
|
+
output_file = answers.get("output_file")
|
|
21
|
+
job_name = answers.get("job_name", "")
|
|
22
|
+
prefix = job_name if job_name else "slurm"
|
|
23
|
+
|
|
24
|
+
def _in_dir(name: str) -> str:
|
|
25
|
+
# Place a bare filename inside output_dir; leave explicit paths alone.
|
|
26
|
+
if output_dir and not os.path.isabs(name) and not os.path.dirname(name):
|
|
27
|
+
return f"{output_dir.strip().rstrip('/')}/{name}"
|
|
28
|
+
return name
|
|
29
|
+
|
|
30
|
+
output_path: str | None
|
|
31
|
+
error_path: str | None
|
|
32
|
+
if output_file:
|
|
33
|
+
of = output_file.strip()
|
|
34
|
+
output_path = _in_dir(of)
|
|
35
|
+
if of.endswith(".out"):
|
|
36
|
+
error_path = output_path[:-4] + ".err"
|
|
37
|
+
else:
|
|
38
|
+
error_path = output_path + ".err"
|
|
39
|
+
elif output_dir:
|
|
40
|
+
out_dir = output_dir.strip().rstrip("/")
|
|
41
|
+
output_path = f"{out_dir}/{prefix}-%j.out"
|
|
42
|
+
error_path = f"{out_dir}/{prefix}-%j.err"
|
|
43
|
+
else:
|
|
44
|
+
output_path = None
|
|
45
|
+
error_path = None
|
|
46
|
+
|
|
47
|
+
def opt(key: str, default: Any) -> Any:
|
|
48
|
+
# In partial mode, leave a value unset (None) until the user supplies it.
|
|
49
|
+
if partial and key not in answers:
|
|
50
|
+
return None
|
|
51
|
+
return answers.get(key, default)
|
|
52
|
+
|
|
53
|
+
return build_sbatch_script(
|
|
54
|
+
job_name=job_name,
|
|
55
|
+
partition=answers.get("partition", ""),
|
|
56
|
+
account=answers.get("account"),
|
|
57
|
+
qos=answers.get("qos"),
|
|
58
|
+
cpus=opt("cpus", 1),
|
|
59
|
+
memory=opt("memory", "16G"),
|
|
60
|
+
time_limit=opt("time_limit", "02:00:00"),
|
|
61
|
+
nodes=opt("nodes", 1),
|
|
62
|
+
ntasks_per_node=answers.get("ntasks_per_node"),
|
|
63
|
+
gpus=answers.get("gpus", 0) or 0,
|
|
64
|
+
gpu_type=answers.get("gpu_type"),
|
|
65
|
+
array_spec=answers.get("array_spec"),
|
|
66
|
+
output_path=output_path,
|
|
67
|
+
error_path=error_path,
|
|
68
|
+
modules=answers.get("modules"),
|
|
69
|
+
custom_sbatch=answers.get("custom_sbatch"),
|
|
70
|
+
env_name=answers.get("env_name"),
|
|
71
|
+
env_type=answers.get("env_type"),
|
|
72
|
+
gpu_format=answers.get("gpu_format"),
|
|
73
|
+
command=answers.get("command", ""),
|
|
74
|
+
partial=partial,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_sbatch_script(
|
|
79
|
+
job_name: str,
|
|
80
|
+
partition: str,
|
|
81
|
+
cpus: int | None,
|
|
82
|
+
memory: str | None,
|
|
83
|
+
time_limit: str | None,
|
|
84
|
+
nodes: int | None = 1,
|
|
85
|
+
ntasks_per_node: int | None = None,
|
|
86
|
+
gpus: int = 0,
|
|
87
|
+
gpu_type: str | None = None,
|
|
88
|
+
account: str | None = None,
|
|
89
|
+
qos: str | None = None,
|
|
90
|
+
array_spec: str | None = None,
|
|
91
|
+
output_path: str | None = None,
|
|
92
|
+
error_path: str | None = None,
|
|
93
|
+
modules: list[str] | None = None,
|
|
94
|
+
custom_sbatch: list[str] | None = None,
|
|
95
|
+
env_name: str | None = None,
|
|
96
|
+
env_type: str | None = None,
|
|
97
|
+
gpu_format: str | None = None,
|
|
98
|
+
command: str = "",
|
|
99
|
+
partial: bool = False,
|
|
100
|
+
) -> str:
|
|
101
|
+
lines = ["#!/bin/bash", ""]
|
|
102
|
+
|
|
103
|
+
# One contiguous #SBATCH block, emitted in the same order the wizard asks
|
|
104
|
+
# the questions, so the live preview grows top-to-bottom without reshuffling.
|
|
105
|
+
if job_name or not partial:
|
|
106
|
+
lines.append(f"#SBATCH --job-name={job_name}")
|
|
107
|
+
if partition or not partial:
|
|
108
|
+
lines.append(f"#SBATCH --partition={partition}")
|
|
109
|
+
if account:
|
|
110
|
+
lines.append(f"#SBATCH --account={account}")
|
|
111
|
+
if qos and qos != "Default (none)":
|
|
112
|
+
lines.append(f"#SBATCH --qos={qos}")
|
|
113
|
+
if cpus is not None:
|
|
114
|
+
lines.append(f"#SBATCH --cpus-per-task={cpus}")
|
|
115
|
+
if memory:
|
|
116
|
+
lines.append(f"#SBATCH --mem={memory}")
|
|
117
|
+
if time_limit:
|
|
118
|
+
lines.append(f"#SBATCH --time={time_limit}")
|
|
119
|
+
if nodes is not None:
|
|
120
|
+
lines.append(f"#SBATCH --nodes={nodes}")
|
|
121
|
+
if ntasks_per_node is not None:
|
|
122
|
+
lines.append(f"#SBATCH --ntasks-per-node={ntasks_per_node}")
|
|
123
|
+
elif nodes is not None and nodes > 1:
|
|
124
|
+
lines.append("#SBATCH --ntasks-per-node=1")
|
|
125
|
+
|
|
126
|
+
if gpus > 0:
|
|
127
|
+
gpu_fmt = gpu_format or os.environ.get("SLURMATE_GPU_FORMAT", "gres_type").lower()
|
|
128
|
+
if gpu_fmt == "gres_type" and gpu_type:
|
|
129
|
+
lines.append(f"#SBATCH --gres=gpu:{gpu_type}:{gpus}")
|
|
130
|
+
elif gpu_fmt == "gpus":
|
|
131
|
+
if gpu_type:
|
|
132
|
+
lines.append(f"#SBATCH --gpus={gpu_type}:{gpus}")
|
|
133
|
+
else:
|
|
134
|
+
lines.append(f"#SBATCH --gpus={gpus}")
|
|
135
|
+
else: # "constraint"
|
|
136
|
+
lines.append(f"#SBATCH --gres=gpu:{gpus}")
|
|
137
|
+
if gpu_type:
|
|
138
|
+
lines.append(f"#SBATCH --constraint={gpu_type}")
|
|
139
|
+
|
|
140
|
+
if array_spec:
|
|
141
|
+
lines.append(f"#SBATCH --array={array_spec}")
|
|
142
|
+
|
|
143
|
+
# Output/error are auto-derived. In a partial preview, only show them once
|
|
144
|
+
# the user has actually configured an output dir/file (output_path is set).
|
|
145
|
+
if not partial or output_path:
|
|
146
|
+
prefix = job_name if job_name else "slurm"
|
|
147
|
+
out = output_path or f"{prefix}-%j.out"
|
|
148
|
+
err = error_path or f"{prefix}-%j.err"
|
|
149
|
+
lines.append(f"#SBATCH --output={out}")
|
|
150
|
+
lines.append(f"#SBATCH --error={err}")
|
|
151
|
+
|
|
152
|
+
if custom_sbatch:
|
|
153
|
+
for flag in custom_sbatch:
|
|
154
|
+
if gpus > 0:
|
|
155
|
+
parts = flag.strip().split('=', 1)
|
|
156
|
+
flag_name = parts[0].strip()
|
|
157
|
+
flag_val = parts[1].strip() if len(parts) > 1 else ""
|
|
158
|
+
if flag_name == "--gres" and flag_val.startswith("gpu"):
|
|
159
|
+
continue
|
|
160
|
+
if flag_name == "--gpus":
|
|
161
|
+
continue
|
|
162
|
+
if flag_name == "--constraint" and gpu_type and flag_val == gpu_type:
|
|
163
|
+
continue
|
|
164
|
+
lines.append(f"#SBATCH {flag}")
|
|
165
|
+
|
|
166
|
+
if modules:
|
|
167
|
+
lines.append("")
|
|
168
|
+
for mod in modules:
|
|
169
|
+
lines.append(f"module load {mod}")
|
|
170
|
+
|
|
171
|
+
if env_name:
|
|
172
|
+
strategy = (env_type or "conda").lower()
|
|
173
|
+
if strategy == "conda":
|
|
174
|
+
lines.append("")
|
|
175
|
+
lines.append("source $(conda info --base)/etc/profile.d/conda.sh")
|
|
176
|
+
lines.append(f"conda activate {env_name}")
|
|
177
|
+
elif strategy == "mamba":
|
|
178
|
+
lines.append("")
|
|
179
|
+
lines.append("source $(conda info --base)/etc/profile.d/conda.sh")
|
|
180
|
+
lines.append(f"mamba activate {env_name}")
|
|
181
|
+
elif strategy in ("virtualenv (venv)", "venv"):
|
|
182
|
+
lines.append("")
|
|
183
|
+
lines.append(f"source {env_name}/bin/activate")
|
|
184
|
+
|
|
185
|
+
if command:
|
|
186
|
+
lines.append("")
|
|
187
|
+
lines.append(command.rstrip())
|
|
188
|
+
|
|
189
|
+
if partial:
|
|
190
|
+
while len(lines) > 2 and lines[-1] == "":
|
|
191
|
+
lines.pop()
|
|
192
|
+
else:
|
|
193
|
+
lines.append("")
|
|
194
|
+
return "\n".join(lines)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def estimate_su(cpus: int, time_limit: str, nodes: int = 1) -> str:
|
|
198
|
+
"""Estimate Service Units (SU) cost for a job.
|
|
199
|
+
|
|
200
|
+
Service Units are typically core-hours (CPUs * hours * nodes).
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
cpus: Number of CPU cores per task.
|
|
204
|
+
time_limit: Time limit string in Slurm format (e.g. "hh:mm:ss" or "d-hh:mm:ss").
|
|
205
|
+
nodes: Number of nodes requested.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Formatted string representation of estimated SUs.
|
|
209
|
+
"""
|
|
210
|
+
minutes = _parse_slurm_time_to_minutes(time_limit) if time_limit else 120.0
|
|
211
|
+
if minutes <= 0:
|
|
212
|
+
minutes = 120.0
|
|
213
|
+
hours = minutes / 60.0
|
|
214
|
+
su = cpus * hours * nodes
|
|
215
|
+
if su < 1:
|
|
216
|
+
return f"{su:.2f}"
|
|
217
|
+
if su < 100:
|
|
218
|
+
return f"{su:.1f}"
|
|
219
|
+
return f"{su:,.0f}"
|