richqueue 1.11.2__tar.gz → 1.12.1__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.
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: richqueue
3
- Version: 1.11.2
3
+ Version: 1.12.1
4
4
  Summary: RichQueue
5
5
  Project-URL: Homepage, https://github.com/mwinokan/RichQueue/issues
6
6
  Project-URL: Bug Tracker, https://github.com/mwinokan/RichQueue/issues
@@ -10,8 +10,6 @@ Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Operating System :: OS Independent
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Requires-Python: >=3.10
13
- Requires-Dist: ipython
14
- Requires-Dist: mpytools
15
13
  Requires-Dist: pandas
16
14
  Requires-Dist: rich
17
15
  Requires-Dist: typer
@@ -3,14 +3,13 @@ requires = ["hatchling"]
3
3
  build-backend = "hatchling.build"
4
4
  [project]
5
5
  name = "richqueue"
6
- version = "1.11.2"
6
+ version = "1.12.1"
7
7
  authors = [
8
8
  { name = "Max Winokan", email = "max@winokan.com" },
9
9
  ]
10
10
  description = "RichQueue"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
13
- requires = []
14
13
  classifiers = [
15
14
  "Programming Language :: Python :: 3",
16
15
  "License :: OSI Approved :: MIT License",
@@ -20,8 +19,6 @@ dependencies = [
20
19
  "typer",
21
20
  "rich",
22
21
  "pandas",
23
- "mpytools",
24
- "IPython",
25
22
  ]
26
23
  [project.urls]
27
24
  "Homepage" = "https://github.com/mwinokan/RichQueue/issues"
@@ -14,8 +14,6 @@ from .slurm import (
14
14
  NotOnClusterError,
15
15
  )
16
16
 
17
- import mrich
18
-
19
17
  # set up singletons
20
18
  app = Typer()
21
19
 
@@ -3,6 +3,8 @@ from rich.panel import Panel
3
3
  from rich.text import Text
4
4
  import subprocess
5
5
  import json
6
+ import os
7
+ import re
6
8
  from .console import console
7
9
  from pathlib import Path
8
10
  import datetime
@@ -13,6 +15,9 @@ from .tools import human_timedelta
13
15
 
14
16
  METADATA = {}
15
17
 
18
+ SLURM_VERSION = None
19
+ CLUSTER_NAME = None
20
+
16
21
  PANEL_PADDING = 4
17
22
 
18
23
  ### CONSTRUCT LAYOUT
@@ -162,6 +167,9 @@ def get_squeue(
162
167
  user: str | None = None, job: int | None = None, **kwargs
163
168
  ) -> "pandas.DataFrame":
164
169
 
170
+ if not has_json():
171
+ return get_squeue_text(user=user, job=job, **kwargs)
172
+
165
173
  if job:
166
174
  assert isinstance(job, int)
167
175
  command = f"squeue --job={job} --json"
@@ -246,6 +254,11 @@ def get_sacct(
246
254
 
247
255
  hist = hist or 4
248
256
 
257
+ if not has_json():
258
+ return get_sacct_text(
259
+ user=user, hist=hist, job=job, hist_unit=hist_unit, **kwargs
260
+ )
261
+
249
262
  if job:
250
263
  command = f"sacct --job={job} --json"
251
264
  elif user:
@@ -313,6 +326,9 @@ def get_sacct(
313
326
 
314
327
  def get_sinfo(**kwargs) -> "pandas.DataFrame":
315
328
 
329
+ if not has_json():
330
+ return get_sinfo_text(**kwargs)
331
+
316
332
  command = "sinfo -N --json"
317
333
 
318
334
  try:
@@ -393,6 +409,310 @@ def combined_df(**kwargs) -> "DataFrame":
393
409
  return df
394
410
 
395
411
 
412
+ ### SLURM < 21.08 (NO --json)
413
+
414
+
415
+ def get_slurm_version() -> tuple:
416
+ """(major, minor) from e.g. 'slurm 20.11.9', empty if unknown"""
417
+
418
+ global SLURM_VERSION
419
+
420
+ if SLURM_VERSION is None:
421
+ try:
422
+ output = subprocess.run(
423
+ ["squeue", "--version"], capture_output=True, text=True
424
+ ).stdout
425
+ except OSError:
426
+ output = ""
427
+ # e.g. 'slurm-wlm 20.11.4' from Debian packages
428
+ match = re.search(r"(\d+)\.(\d+)", output)
429
+ SLURM_VERSION = (int(match[1]), int(match[2])) if match else ()
430
+
431
+ return SLURM_VERSION
432
+
433
+
434
+ def has_json() -> bool:
435
+ """squeue/sacct/sinfo --json were added in Slurm 21.08"""
436
+ version = get_slurm_version()
437
+ return not version or version >= (21, 8)
438
+
439
+
440
+ def get_cluster_name() -> str:
441
+ """Text output has no metadata, so ask scontrol once"""
442
+
443
+ global CLUSTER_NAME
444
+
445
+ if CLUSTER_NAME is None:
446
+ output = subprocess.run(
447
+ ["scontrol", "show", "config"], capture_output=True, text=True
448
+ ).stdout
449
+ match = re.search(r"^ClusterName\s*=\s*(\S+)", output, re.MULTILINE)
450
+ CLUSTER_NAME = match[1] if match else "unknown HPC"
451
+
452
+ return CLUSTER_NAME
453
+
454
+
455
+ def run_text(command: list[str], columns: list[str]) -> "pandas.DataFrame":
456
+ """Run a Slurm command that prints TEXT_SEP after every field"""
457
+
458
+ # user's SQUEUE_*, SACCT_* and SINFO_* variables can filter or duplicate rows
459
+ env = {
460
+ k: v
461
+ for k, v in os.environ.items()
462
+ if not k.startswith(("SQUEUE_", "SACCT_", "SINFO_"))
463
+ }
464
+ env["SLURM_TIME_FORMAT"] = "standard"
465
+
466
+ process = subprocess.run(command, capture_output=True, env=env)
467
+
468
+ if process.returncode:
469
+ stderr = process.stderr.decode(errors="replace").strip()
470
+ raise NotOnClusterError(f"{command[0]} failed: {stderr}")
471
+
472
+ # split on TEXT_SEP + newline in case a field contains a newline
473
+ records = process.stdout.decode(errors="replace").split(TEXT_SEP + "\n")[:-1]
474
+ rows = [record.split(TEXT_SEP) for record in records]
475
+ return DataFrame(rows, columns=columns)
476
+
477
+
478
+ def get_squeue_text(
479
+ user: str | None = None, job: int | None = None, **kwargs
480
+ ) -> "pandas.DataFrame":
481
+
482
+ fields = ",".join(f"{field}:0{TEXT_SEP}" for field in TEXT_FIELDS["squeue"])
483
+ command = ["squeue", "--noheader", "--noconvert", "--array-unique", "-O", fields]
484
+
485
+ if job:
486
+ command.append(f"--jobs={job}")
487
+ elif user:
488
+ command += ["-u", user]
489
+
490
+ columns = list(TEXT_FIELDS["squeue"].values())
491
+
492
+ try:
493
+ df = run_text(command, columns)
494
+ except NotOnClusterError as e:
495
+ if job and "Invalid job id specified" in str(e):
496
+ return get_sacct(user=user, job=job, **kwargs)
497
+ raise
498
+
499
+ # recently finished jobs are known to slurmctld but hidden by default
500
+ if job and not len(df):
501
+ df = run_text(command + ["--states=all"], columns)
502
+
503
+ if job and not len(df):
504
+ return get_sacct(user=user, job=job, **kwargs)
505
+
506
+ global METADATA
507
+
508
+ METADATA = {"cluster_name": get_cluster_name()}
509
+
510
+ if not len(df):
511
+ return DataFrame(columns=COLUMNS["squeue"])
512
+
513
+ for key in ["job_id", "node_count", "cpus", "tasks", "restart_cnt"]:
514
+ df[key] = df[key].astype(int)
515
+
516
+ for key in ["name", "qos", "command", "dependency"]:
517
+ df[key] = df[key].replace("(null)", "")
518
+
519
+ df["cpus_per_task"] = df["cpus_per_task"].map(
520
+ lambda x: None if x == "N/A" else int(x)
521
+ )
522
+ df["threads_per_core"] = df["threads_per_core"].map(
523
+ lambda x: None if x == "*" else int(x)
524
+ )
525
+ df[["memory_per_cpu", "memory_per_node"]] = df.apply(
526
+ split_text_memory, axis=1, result_type="expand"
527
+ )
528
+
529
+ df["start_time"] = df["start_time"].map(parse_text_time)
530
+ df["submit_time"] = df["submit_time"].map(parse_text_time)
531
+ df["time_limit"] = df["time_limit"].map(parse_text_time_limit)
532
+
533
+ df["requeue"] = df["requeue"] == "1"
534
+ df["exclusive"] = df["exclusive"].map(lambda x: EXCLUSIVE.get(x, ""))
535
+
536
+ df["standard_output"] = df.apply(
537
+ lambda x: text_log_path(x, x["standard_output"]), axis=1
538
+ )
539
+ df["standard_error"] = df.apply(
540
+ lambda x: text_log_path(x, x["standard_error"]), axis=1
541
+ )
542
+
543
+ return df[COLUMNS["squeue"]]
544
+
545
+
546
+ def get_sacct_text(
547
+ user: str | None = None,
548
+ hist: int | None = 4,
549
+ job: int | None = None,
550
+ hist_unit: str = "weeks",
551
+ **kwargs,
552
+ ) -> "pandas.DataFrame":
553
+
554
+ fields = ",".join(TEXT_FIELDS["sacct"])
555
+ command = [
556
+ "sacct",
557
+ "-X",
558
+ "-p",
559
+ "-n",
560
+ f"--delimiter={TEXT_SEP}",
561
+ f"--format={fields}",
562
+ ]
563
+
564
+ if job:
565
+ command.append(f"--jobs={job}")
566
+ else:
567
+ command.append(f"--starttime=now-{hist}{hist_unit}")
568
+ if user:
569
+ command += ["-u", user]
570
+
571
+ df = run_text(command, list(TEXT_FIELDS["sacct"].values()))
572
+
573
+ global METADATA
574
+
575
+ METADATA = {
576
+ "cluster_name": get_cluster_name(),
577
+ "hist": hist,
578
+ "hist_unit": hist_unit,
579
+ }
580
+
581
+ df["job_id"] = df["job_id"].astype(int)
582
+
583
+ # e.g. "CANCELLED by 1234"
584
+ df["job_state"] = df["job_state"].str.split(" ").str[0]
585
+
586
+ for key in ["submit_time", "start_time", "end_time"]:
587
+ df[key] = df[key].map(parse_text_time)
588
+
589
+ df = df[df["job_state"] != "RUNNING"]
590
+ df = df[df["job_state"] != "PENDING"]
591
+
592
+ return df
593
+
594
+
595
+ def get_sinfo_text(**kwargs) -> "pandas.DataFrame":
596
+
597
+ fields = ",".join(f"{field}:0{TEXT_SEP}" for field in TEXT_FIELDS["sinfo"])
598
+ command = ["sinfo", "-N", "--noheader", "-O", fields]
599
+
600
+ df = run_text(command, list(TEXT_FIELDS["sinfo"].values()))
601
+
602
+ global METADATA
603
+
604
+ METADATA = {"cluster_name": get_cluster_name()}
605
+
606
+ # e.g. "idle~", "down*"
607
+ df["node_state"] = df["node_state"].str.rstrip("*~#%$@+").str.upper()
608
+
609
+ # allocated/idle/other/total
610
+ cpus = df["cpus"].str.split("/", expand=True).astype(int)
611
+ df["cpus_allocated"] = cpus[0]
612
+ df["cpus_idle"] = cpus[1]
613
+ df["cpus_max"] = cpus[3]
614
+ df["cpu_string"] = df.apply(lambda x: f"{x.cpus_idle}/{x.cpus_max}", axis=1)
615
+
616
+ df["memory_max"] = df["memory_max"].astype(int)
617
+ df["memory_free"] = df["memory_free"].replace("N/A", "0").astype(int)
618
+ df["memory_allocated"] = df["memory_allocated"].astype(int)
619
+
620
+ df["features"] = df["features"].replace("(null)", "")
621
+
622
+ # sinfo has no per-node reservation field
623
+ df["reservation"] = ""
624
+
625
+ return df.drop(columns=["cpus"])
626
+
627
+
628
+ def parse_text_time(value: str) -> datetime.datetime:
629
+ """Unset times print as N/A or Unknown, the JSON path gives them as 0"""
630
+ try:
631
+ return datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S")
632
+ except ValueError:
633
+ return datetime.datetime.fromtimestamp(0)
634
+
635
+
636
+ def parse_text_time_limit(value: str) -> str:
637
+ """[days-]hours:minutes:seconds or minutes:seconds, or UNLIMITED etc."""
638
+
639
+ if ":" not in value:
640
+ return ""
641
+
642
+ days, _, clock = value.rpartition("-")
643
+
644
+ seconds = 0
645
+ for part in clock.split(":"):
646
+ seconds = 60 * seconds + int(part)
647
+
648
+ return human_timedelta(datetime.timedelta(days=int(days or 0), seconds=seconds))
649
+
650
+
651
+ def mb_from_text(value: str) -> float:
652
+ """e.g. '2500M', '1.50G' or '0'"""
653
+ match = re.fullmatch(r"([\d.]+)([KMGTP]?)", value)
654
+ return float(match[1]) * MB_PER_UNIT.get(match[2], 1)
655
+
656
+
657
+ def split_text_memory(row) -> tuple:
658
+ """squeue doesn't say if min memory is per CPU or per node, so compare
659
+ with the job's total memory in its TRES string"""
660
+
661
+ memory = mb_from_text(row["min_memory"])
662
+ total = re.search(r"mem=([\d.]+[KMGTP]?)", row["tres"])
663
+
664
+ if total:
665
+ total = mb_from_text(total[1])
666
+ per_cpu = abs(total - memory * row["cpus"])
667
+ per_node = abs(total - memory * row["node_count"])
668
+ if per_cpu < per_node:
669
+ return memory, None
670
+
671
+ return None, memory
672
+
673
+
674
+ def text_log_path(row, path: str) -> str:
675
+ """Expand squeue's raw stdout/stderr path like Slurm's slurm_get_job_stdout"""
676
+
677
+ cwd = row["current_working_directory"]
678
+ task = row["array_task_id"]
679
+
680
+ # printed by squeue when unset
681
+ default = f"{cwd}/slurm-{row['job_id']}.out"
682
+
683
+ if path == "N/A" or (path == default and row["batch_flag"] == "0"):
684
+ return ""
685
+
686
+ if path == default:
687
+ if task.isdigit():
688
+ return f"{cwd}/slurm-{row['array_job_id']}_{task}.out"
689
+ return default
690
+
691
+ values = {
692
+ "A": row["array_job_id"],
693
+ "a": task if task.isdigit() else NO_VAL,
694
+ "j": row["job_id"],
695
+ "u": row["user_name"],
696
+ "x": row["name"],
697
+ }
698
+
699
+ # e.g. %j, %4a or %% as in Slurm 23.11's _fname_format
700
+ def substitute(match):
701
+ if match[0] == "%%":
702
+ return "%"
703
+ value = str(values[match[2]])
704
+ if match[2] in "Aaj":
705
+ value = value.zfill(min(int(match[1] or 0), 10))
706
+ return value
707
+
708
+ path = re.sub(r"%%|%(\d*)([Aajux])", substitute, path)
709
+
710
+ if path.startswith("/"):
711
+ return path
712
+
713
+ return f"{cwd}/{path}"
714
+
715
+
396
716
  ### ADD COLUMNS
397
717
 
398
718
 
@@ -512,5 +832,72 @@ COLUMNS = {
512
832
  ],
513
833
  }
514
834
 
835
+ # ASCII unit separator, won't appear in names or paths
836
+ TEXT_SEP = "\x1f"
837
+
838
+ NO_VAL = 4294967294
839
+
840
+ # Slurm field: column, for text output
841
+ TEXT_FIELDS = {
842
+ "squeue": {
843
+ "jobid": "job_id",
844
+ "arrayjobid": "array_job_id",
845
+ "arraytaskid": "array_task_id",
846
+ "batchflag": "batch_flag",
847
+ "name": "name",
848
+ "state": "job_state",
849
+ "username": "user_name",
850
+ "groupname": "group_name",
851
+ "partition": "partition",
852
+ "qos": "qos",
853
+ "nodelist": "nodes",
854
+ "numnodes": "node_count",
855
+ "numcpus": "cpus",
856
+ "numtasks": "tasks",
857
+ "cpus-per-task": "cpus_per_task",
858
+ "threads": "threads_per_core",
859
+ "minmemory": "min_memory",
860
+ "tres-alloc": "tres",
861
+ "starttime": "start_time",
862
+ "submittime": "submit_time",
863
+ "timelimit": "time_limit",
864
+ "command": "command",
865
+ "workdir": "current_working_directory",
866
+ "stdout": "standard_output",
867
+ "stderr": "standard_error",
868
+ "dependency": "dependency",
869
+ "requeue": "requeue",
870
+ "restartcnt": "restart_cnt",
871
+ "oversubscribe": "exclusive",
872
+ "derivedec": "derived_exit_code",
873
+ },
874
+ "sacct": {
875
+ "JobIDRaw": "job_id",
876
+ "State": "job_state",
877
+ "JobName": "name",
878
+ "NodeList": "nodes",
879
+ "Partition": "partition",
880
+ "User": "user_name",
881
+ "Submit": "submit_time",
882
+ "Start": "start_time",
883
+ "End": "end_time",
884
+ },
885
+ "sinfo": {
886
+ "NodeList": "node_name",
887
+ "PartitionName": "partition",
888
+ "StateLong": "node_state",
889
+ "CPUsState": "cpus",
890
+ "Memory": "memory_max",
891
+ "FreeMem": "memory_free",
892
+ "AllocMem": "memory_allocated",
893
+ "Features": "features",
894
+ },
895
+ }
896
+
897
+ # squeue oversubscribe: JSON exclusive
898
+ EXCLUSIVE = {"NO": "true", "YES": "false", "USER": "user", "MCS": "mcs"}
899
+
900
+ MB_PER_UNIT = {"K": 1 / 1024, "M": 1, "G": 1024, "T": 1024**2, "P": 1024**3}
901
+
515
902
 
516
903
  class NotOnClusterError(Exception): ...
File without changes
File without changes
File without changes
File without changes
File without changes