richqueue 0.5__tar.gz → 0.7__tar.gz

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.
richqueue-0.7/PKG-INFO ADDED
@@ -0,0 +1,57 @@
1
+ Metadata-Version: 2.3
2
+ Name: richqueue
3
+ Version: 0.7
4
+ Summary: RichQueue
5
+ Project-URL: Homepage, https://github.com/mwinokan/RichQueue/issues
6
+ Project-URL: Bug Tracker, https://github.com/mwinokan/RichQueue/issues
7
+ Author-email: Max Winokan <max@winokan.com>
8
+ License-File: LICENSE
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: pandas
14
+ Requires-Dist: rich
15
+ Requires-Dist: typer
16
+ Description-Content-Type: text/markdown
17
+
18
+ # RichQueue
19
+
20
+ > 💰 RichQueue: A colourful and pythonic SLURM queue viewer
21
+
22
+ Installation from a modern python environment (>3.10) should be as simple as:
23
+
24
+ ```shell
25
+
26
+ pip install --upgrade richqueue
27
+ ```
28
+
29
+ To see your live-updating SLURM queue:
30
+
31
+ ```shell
32
+ rq
33
+ ```
34
+
35
+ ![queue_example](https://github.com/user-attachments/assets/d99ca5e9-7675-4853-ab28-2d7b4da855f2)
36
+
37
+ ## Other options
38
+
39
+ To see more detail:
40
+
41
+ ```rq --long```
42
+
43
+ To see someone else's queue:
44
+
45
+ ```rq --user USER```
46
+
47
+ To see the last `X` weeks history:
48
+
49
+ ```rq --hist X```
50
+
51
+ To list available nodes on the cluster:
52
+
53
+ ```rq --idle```
54
+
55
+ To show a static view:
56
+
57
+ ```rq --no-loop```
@@ -0,0 +1,40 @@
1
+ # RichQueue
2
+
3
+ > 💰 RichQueue: A colourful and pythonic SLURM queue viewer
4
+
5
+ Installation from a modern python environment (>3.10) should be as simple as:
6
+
7
+ ```shell
8
+
9
+ pip install --upgrade richqueue
10
+ ```
11
+
12
+ To see your live-updating SLURM queue:
13
+
14
+ ```shell
15
+ rq
16
+ ```
17
+
18
+ ![queue_example](https://github.com/user-attachments/assets/d99ca5e9-7675-4853-ab28-2d7b4da855f2)
19
+
20
+ ## Other options
21
+
22
+ To see more detail:
23
+
24
+ ```rq --long```
25
+
26
+ To see someone else's queue:
27
+
28
+ ```rq --user USER```
29
+
30
+ To see the last `X` weeks history:
31
+
32
+ ```rq --hist X```
33
+
34
+ To list available nodes on the cluster:
35
+
36
+ ```rq --idle```
37
+
38
+ To show a static view:
39
+
40
+ ```rq --no-loop```
@@ -3,7 +3,7 @@ requires = ["hatchling"]
3
3
  build-backend = "hatchling.build"
4
4
  [project]
5
5
  name = "richqueue"
6
- version = "0.5"
6
+ version = "0.7"
7
7
  authors = [
8
8
  { name = "Max Winokan", email = "max@winokan.com" },
9
9
  ]
@@ -0,0 +1,3 @@
1
+ from rich.console import Console
2
+
3
+ console = Console()
@@ -0,0 +1,29 @@
1
+ from rich.layout import Layout
2
+ from rich.panel import Panel
3
+ from .slurm import PANEL_PADDING
4
+ from rich.text import Text
5
+
6
+
7
+ def dual_layout(function, **kwargs):
8
+
9
+ layout = Layout()
10
+
11
+ upper, lower = function(**kwargs)
12
+
13
+ upper = Layout(renderable=upper, name="upper")
14
+ lower = Layout(renderable=lower, name="lower")
15
+
16
+ upper.size = upper.renderable.renderable.row_count + PANEL_PADDING
17
+ lower.size = lower.renderable.renderable.row_count + PANEL_PADDING
18
+
19
+ layout.split_column(
20
+ upper,
21
+ lower,
22
+ )
23
+
24
+ return layout
25
+
26
+
27
+ def simple_layout():
28
+
29
+ raise NotImplementedError
@@ -0,0 +1,90 @@
1
+ from typer import Typer
2
+ from .layout import dual_layout
3
+ from rich.live import Live
4
+ import time
5
+ from .console import console
6
+ from .tools import curry
7
+ from .slurm import get_layout_pair, get_node_layout, get_hist_layout
8
+
9
+ # set up singletons
10
+ app = Typer()
11
+
12
+ # main CLI command
13
+
14
+
15
+ @app.command()
16
+ def show(
17
+ user: None | str = None,
18
+ long: bool = False,
19
+ idle: bool = False,
20
+ loop: bool = True,
21
+ hist: int | None = None,
22
+ hist_unit: str = "weeks",
23
+ # screen: bool = True,
24
+ # disappear: bool = True,
25
+ ):
26
+
27
+ screen = True
28
+ disappear = not screen
29
+
30
+ kwargs = {
31
+ "user": user,
32
+ "long": long,
33
+ "idle": idle,
34
+ "loop": loop,
35
+ "hist": hist,
36
+ "hist_unit": hist_unit,
37
+ "screen": screen,
38
+ "disappear": disappear,
39
+ }
40
+
41
+ # console.print(kwargs)
42
+
43
+ match (bool(idle), bool(hist)):
44
+ case (True, False):
45
+ loop = False
46
+ layout_func = get_node_layout
47
+ case (False, True):
48
+ loop = False
49
+ layout_func = get_hist_layout
50
+ case (False, False):
51
+ layout_func = curry(dual_layout, get_layout_pair)
52
+ case _:
53
+ raise Exception("Unsupported CLI options")
54
+
55
+ # live updating layout
56
+ if loop:
57
+
58
+ layout = layout_func(**kwargs)
59
+
60
+ with Live(
61
+ layout,
62
+ refresh_per_second=4,
63
+ screen=screen,
64
+ transient=disappear,
65
+ vertical_overflow="visible",
66
+ ) as live:
67
+
68
+ try:
69
+ while True:
70
+ layout = layout_func(**kwargs)
71
+ live.update(layout)
72
+ time.sleep(1)
73
+ except KeyboardInterrupt:
74
+ live.stop()
75
+
76
+ # static layout
77
+ else:
78
+
79
+ layout = layout_func(**kwargs)
80
+ console.print(layout)
81
+
82
+
83
+ # start Typer app
84
+ def main():
85
+ app()
86
+
87
+
88
+ # start Typer app
89
+ if __name__ == "__main__":
90
+ app()
@@ -0,0 +1,418 @@
1
+ from pandas import concat, DataFrame, isnull
2
+ from rich.panel import Panel
3
+ from rich.text import Text
4
+ import subprocess
5
+ import json
6
+ from .console import console
7
+ from pathlib import Path
8
+ import datetime
9
+ from .table import running_job_table, history_job_table, node_table
10
+ from .tools import human_timedelta
11
+
12
+ # from numpy import isnat
13
+
14
+ METADATA = {}
15
+
16
+ PANEL_PADDING = 4
17
+
18
+ ### CONSTRUCT LAYOUT
19
+
20
+
21
+ def get_layout_pair(user: str | None, **kwargs):
22
+
23
+ if user == "all":
24
+ user = None
25
+ elif user is None:
26
+ x = subprocess.Popen(["whoami"], shell=True, stdout=subprocess.PIPE)
27
+ output = x.communicate()
28
+ user = output[0].strip().decode("utf-8")
29
+
30
+ df = combined_df(user=user, **kwargs)
31
+
32
+ n_rows = len(df)
33
+ n_running = len(df[df["job_state"] == "RUNNING"])
34
+ n_pending = len(df[df["job_state"] == "PENDING"])
35
+
36
+ hide_pending = False
37
+
38
+ running_df = df[df["job_state"].isin(["RUNNING", "PENDING"])]
39
+ history_df = df[~df["job_state"].isin(["RUNNING", "PENDING"])]
40
+
41
+ console_height = console.size.height
42
+ max_rows = console_height - 2 * PANEL_PADDING
43
+
44
+ if n_rows > max_rows:
45
+
46
+ # hide history?
47
+ if n_running + n_pending + PANEL_PADDING + 1 < console_height:
48
+ history_limit = 0
49
+ running_limit = None
50
+
51
+ # hide pending?
52
+ elif n_rows - n_pending < max_rows:
53
+ # running_df = running_df[running_df["job_state"]=="RUNNING"]
54
+ running_limit = None
55
+ history_limit = None
56
+ hide_pending = n_pending
57
+
58
+ # fallback clip
59
+ else:
60
+ running_limit = 5
61
+ history_limit = 5
62
+
63
+ else:
64
+ running_limit = None
65
+ history_limit = None
66
+
67
+ running = Panel(
68
+ running_job_table(
69
+ running_df,
70
+ limit=running_limit,
71
+ hide_pending=hide_pending,
72
+ user=user,
73
+ **kwargs,
74
+ ),
75
+ expand=False,
76
+ )
77
+
78
+ if history_limit == 0:
79
+ history = Text("history hidden, resize window or use --hist")
80
+ else:
81
+ history = Panel(
82
+ history_job_table(history_df, limit=history_limit, user=user, **kwargs),
83
+ expand=False,
84
+ )
85
+
86
+ return running, history
87
+
88
+
89
+ def get_hist_layout(user: str | None, **kwargs):
90
+
91
+ if user == "all":
92
+ user = None
93
+ elif user is None:
94
+ x = subprocess.Popen(["whoami"], shell=True, stdout=subprocess.PIPE)
95
+ output = x.communicate()
96
+ user = output[0].strip().decode("utf-8")
97
+
98
+ df = combined_df(user=user, **kwargs)
99
+
100
+ history_df = df[~df["job_state"].isin(["RUNNING", "PENDING"])]
101
+
102
+ history = Panel(
103
+ history_job_table(history_df, limit=None, user=user, **kwargs),
104
+ expand=False,
105
+ )
106
+
107
+ return history
108
+
109
+
110
+ def get_node_layout(idle: bool = True, **kwargs):
111
+
112
+ df = get_sinfo(**kwargs)
113
+
114
+ if idle:
115
+ df = df[df["node_state"].isin(["IDLE", "MIXED"])]
116
+
117
+ table = node_table(df, **kwargs)
118
+
119
+ return Panel(table, expand=False)
120
+
121
+
122
+ ### GET QUEUE DFs
123
+
124
+
125
+ def get_squeue(user: str | None = None, **kwargs) -> "pandas.DataFrame":
126
+
127
+ if user:
128
+ command = f"squeue -u {user} --json"
129
+ else:
130
+ command = f"squeue --json"
131
+
132
+ try:
133
+ process = subprocess.Popen(
134
+ [command], shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
135
+ )
136
+ output = process.communicate()
137
+ payload = json.loads(output[0])
138
+ except json.JSONDecodeError:
139
+ # console.print('[orange1 bold]Warning: using example data')
140
+ payload = json.load(
141
+ open(
142
+ Path(__file__).parent.parent / "example_data" / "squeue_long.json", "rt"
143
+ )
144
+ )
145
+
146
+ global METADATA
147
+
148
+ METADATA = {
149
+ "cluster_name": payload["meta"]["slurm"]["cluster"],
150
+ "user": payload["meta"]["client"]["user"],
151
+ "group": payload["meta"]["client"]["group"],
152
+ }
153
+
154
+ # parse payload
155
+ df = DataFrame(payload["jobs"])
156
+
157
+ # filter columns
158
+ columns = COLUMNS["squeue"]
159
+
160
+ try:
161
+ df = df[columns]
162
+ except KeyError:
163
+ for key in columns:
164
+ if key not in df.columns:
165
+ raise KeyError(key)
166
+
167
+ extract_inner(df, "cpus", "number")
168
+ extract_inner(df, "node_count", "number")
169
+ extract_inner(df, "cpus_per_task", "number")
170
+ extract_inner(df, "threads_per_core", "number")
171
+
172
+ extract_time(df, "start_time")
173
+ extract_time(df, "submit_time")
174
+ extract_time(df, "time_limit")
175
+
176
+ extract_list(df, "job_state")
177
+
178
+ return df
179
+
180
+
181
+ def get_sacct(
182
+ user: str | None = None, hist: int | None = 4, hist_unit: str = "weeks", **kwargs
183
+ ) -> "pandas.DataFrame":
184
+
185
+ hist = hist or 4
186
+
187
+ if user:
188
+ command = f"sacct -u {user} --json -S now-{hist}{hist_unit}"
189
+ else:
190
+ command = f"sacct --json -S now-{hist}{hist_unit}"
191
+
192
+ try:
193
+ process = subprocess.Popen(
194
+ [command], shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
195
+ )
196
+ output = process.communicate()
197
+ payload = json.loads(output[0])
198
+ except json.JSONDecodeError:
199
+ # console.print('[orange1 bold]Warning: using example data')
200
+ payload = json.load(
201
+ open(Path(__file__).parent.parent / "example_data" / "sacct.json", "rt")
202
+ )
203
+
204
+ global METADATA
205
+
206
+ METADATA = {
207
+ "cluster_name": payload["meta"]["slurm"]["cluster"],
208
+ "user": payload["meta"]["client"]["user"],
209
+ "group": payload["meta"]["client"]["group"],
210
+ "hist": hist,
211
+ "hist_unit": hist_unit,
212
+ }
213
+
214
+ # parse payload
215
+ df = DataFrame(payload["jobs"])
216
+
217
+ # filter columns
218
+ columns = COLUMNS["sacct"]
219
+
220
+ try:
221
+ df = df[columns]
222
+ except KeyError:
223
+ for key in columns:
224
+ if key not in df.columns:
225
+ raise KeyError(key)
226
+
227
+ df = df.rename(columns={"user": "user_name", "state": "job_state"})
228
+
229
+ extract_inner(df, "job_state", "current")
230
+
231
+ extract_sacct_times(df)
232
+
233
+ extract_list(df, "job_state")
234
+
235
+ df = df[df["job_state"] != "RUNNING"]
236
+ df = df[df["job_state"] != "PENDING"]
237
+
238
+ return df
239
+
240
+
241
+ def get_sinfo(**kwargs) -> "pandas.DataFrame":
242
+
243
+ command = "sinfo -N --json"
244
+
245
+ try:
246
+ process = subprocess.Popen(
247
+ [command], shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
248
+ )
249
+ output = process.communicate()
250
+ payload = json.loads(output[0])
251
+ except json.JSONDecodeError:
252
+ payload = json.load(
253
+ open(Path(__file__).parent.parent / "example_data" / "sinfo_N.json", "rt")
254
+ )
255
+
256
+ global METADATA
257
+
258
+ METADATA = {
259
+ "cluster_name": payload["meta"]["slurm"]["cluster"],
260
+ "user": payload["meta"]["client"]["user"],
261
+ "group": payload["meta"]["client"]["group"],
262
+ }
263
+
264
+ df = DataFrame(payload["sinfo"])
265
+
266
+ df.drop(
267
+ columns=[
268
+ "port",
269
+ "weight",
270
+ "disk",
271
+ "sockets",
272
+ "threads",
273
+ "cluster",
274
+ "comment",
275
+ "extra",
276
+ "gres",
277
+ "reason",
278
+ "cores",
279
+ ],
280
+ inplace=True,
281
+ )
282
+
283
+ df["node"] = df.apply(lambda x: x["node"]["state"][0], axis=1)
284
+ df["nodes"] = df.apply(lambda x: x["nodes"]["nodes"][0], axis=1)
285
+ df["cpus_max"] = df.apply(lambda x: x["cpus"]["maximum"], axis=1)
286
+ df["cpus_idle"] = df.apply(lambda x: x["cpus"]["idle"], axis=1)
287
+ df["cpus_allocated"] = df.apply(lambda x: x["cpus"]["allocated"], axis=1)
288
+ df["cpu_string"] = df.apply(lambda x: f"{x.cpus_idle}/{x.cpus_max}", axis=1)
289
+ df["memory_max"] = df.apply(lambda x: x["memory"]["maximum"], axis=1)
290
+ df["memory_free"] = df.apply(
291
+ lambda x: x["memory"]["free"]["maximum"]["number"], axis=1
292
+ )
293
+ df["memory_allocated"] = df.apply(lambda x: x["memory"]["allocated"], axis=1)
294
+ df["features"] = df.apply(lambda x: x["features"]["total"], axis=1)
295
+ df["partition"] = df.apply(lambda x: x["partition"]["name"], axis=1)
296
+
297
+ df.drop(columns=["cpus", "memory"], inplace=True)
298
+
299
+ df.rename(columns={"node": "node_state", "nodes": "node_name"}, inplace=True)
300
+
301
+ return df
302
+
303
+
304
+ def combined_df(**kwargs) -> "DataFrame":
305
+ """Get combined DataFrame of SLURM job information"""
306
+ df1 = get_squeue(**kwargs)
307
+ df2 = get_sacct(**kwargs)
308
+ df = concat([df1, df2], ignore_index=True)
309
+ df["run_time"] = add_run_time(df)
310
+ df = df.sort_values(by="submit_time", ascending=True)
311
+ return df
312
+
313
+
314
+ ### ADD COLUMNS
315
+
316
+
317
+ def add_run_time(df):
318
+
319
+ def inner(row):
320
+ # print(f'{row.end_time=} {type(row.end_time)=}')
321
+
322
+ if row.job_state == "PENDING":
323
+ return ""
324
+
325
+ if isnull(row.end_time):
326
+ return human_timedelta(datetime.datetime.now() - row.start_time)
327
+ else:
328
+ return human_timedelta(row.end_time - row.start_time)
329
+
330
+ return df.apply(inner, axis=1)
331
+
332
+
333
+ ### EXTRACTORS
334
+
335
+
336
+ def extract_inner(df, key, inner):
337
+
338
+ def _inner(x):
339
+ d = x[key]
340
+ if "set" in d:
341
+ if d["set"]:
342
+ return d[inner]
343
+ else:
344
+ return None
345
+ else:
346
+ return d[inner]
347
+
348
+ df[key] = df.apply(_inner, axis=1)
349
+
350
+
351
+ def extract_time(df, key):
352
+ df[key] = df.apply(
353
+ lambda x: datetime.datetime.fromtimestamp(x[key]["number"]), axis=1
354
+ )
355
+
356
+
357
+ def extract_sacct_times(df):
358
+ df["start_time"] = df.apply(
359
+ lambda x: datetime.datetime.fromtimestamp(x["time"]["start"]), axis=1
360
+ )
361
+ df["end_time"] = df.apply(
362
+ lambda x: datetime.datetime.fromtimestamp(x["time"]["end"]), axis=1
363
+ )
364
+ df["submit_time"] = df.apply(
365
+ lambda x: datetime.datetime.fromtimestamp(x["time"]["submission"]), axis=1
366
+ )
367
+
368
+
369
+ def extract_list(df, key):
370
+ def inner(x):
371
+ if len(x[key]) == 1:
372
+ return x[key][0]
373
+ else:
374
+ return x[key]
375
+
376
+ df[key] = df.apply(inner, axis=1)
377
+
378
+
379
+ COLUMNS = {
380
+ "sacct": [
381
+ "job_id",
382
+ "state",
383
+ "name",
384
+ "nodes",
385
+ "partition",
386
+ "user",
387
+ "time",
388
+ ],
389
+ "squeue": [
390
+ "command",
391
+ "cpus_per_task",
392
+ "dependency",
393
+ "derived_exit_code",
394
+ "group_name",
395
+ "job_id",
396
+ "job_state",
397
+ "name",
398
+ "nodes",
399
+ "node_count",
400
+ "cpus",
401
+ "tasks",
402
+ "partition",
403
+ "memory_per_cpu",
404
+ "memory_per_node",
405
+ "qos",
406
+ "restart_cnt",
407
+ "requeue",
408
+ "exclusive",
409
+ "start_time",
410
+ "standard_error",
411
+ "standard_output",
412
+ "submit_time",
413
+ "time_limit",
414
+ "threads_per_core",
415
+ "user_name",
416
+ "current_working_directory",
417
+ ],
418
+ }