Skip to content

Commit 6f386a2

Browse files
committed
make helpers more general for updating bd
1 parent 9d93398 commit 6f386a2

8 files changed

Lines changed: 65 additions & 273 deletions

File tree

.github/workflows/precommit.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
- uses: actions/checkout@v5
1212
- uses: actions/setup-python@v6
1313
with:
14-
python-version: "3.10"
14+
python-version: "3.11"
1515
- name: Set up uv
1616
uses: astral-sh/setup-uv@v7
1717
- name: Install dependencies

batchtools/batchtools.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ def build_parser(self) -> None:
3232
"--verbose",
3333
"-v",
3434
action="count",
35+
default=0,
3536
help="Increase verbosity of output",
3637
)
3738

batchtools/bd.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,13 @@ def run(args: argparse.Namespace):
7070
if name not in found:
7171
print(f"{name} is not a GPU job and cannot be deleted.")
7272
continue
73-
oc_delete(name)
73+
oc_delete("job", name)
7474
else:
7575
# case where user does not provide jobs to delete, delete all
7676
print("No job names provided -> deleting all GPU workloads:\n")
7777
for job in gpu_jobs:
7878
name = job.model.metadata.name
79-
oc_delete(name)
79+
oc_delete("job", name)
8080

8181
except oc.OpenShiftPythonException as e:
8282
sys.exit(f"Error occurred while deleting jobs: {e}")

batchtools/bps.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,16 @@
1111

1212

1313
class ListPodsCommandArgs(argparse.Namespace):
14-
verbose: int = 0
14+
verbose: int | None = 0
1515
node_names: list[str] = []
1616

1717

1818
class ListPodsCommand(Command):
1919
"""
20-
List active GPU pods per node. By default prints only BUSY nodes.
21-
With -v/--verbose, prints FREE for nodes seen with Running pods but 0 GPUs.
20+
batchtools [-v] bps [-h] [node-name [node-name ...]]
21+
22+
List active GPU pods per node. By default prints only BUSY nodes. So, it all nodes are FREE, it will return empty nodes/
23+
With -v/--verbose, prints FREE for nodes that have Running pods but 0 GPUs.
2224
"""
2325

2426
name: str = "bps"
@@ -35,43 +37,45 @@ def build_parser(cls, subparsers: SubParserFactory):
3537
@override
3638
def run(args: argparse.Namespace):
3739
args = cast(ListPodsCommandArgs, args)
40+
v = args.verbose or 0 # treat None as 0
41+
3842
try:
3943
with oc.timeout(120):
4044
all_pods = oc.selector("pods", all_namespaces=True).objects()
4145

4246
if args.node_names:
43-
# get individual nodes without repeats
47+
# filter to Running pods on requested nodes
4448
node_set = set(args.node_names)
45-
# Filter to Running pods on requested nodes
4649
pods_for_nodes = [
4750
p
4851
for p in all_pods
4952
if getattr(p.model.status, "phase", None) == "Running"
5053
and (getattr(p.model.spec, "nodeName", None) or "") in node_set
5154
]
55+
5256
# Group by node
53-
pods_by_node = defaultdict(list)
57+
pods_by_node: dict[str, list] = defaultdict(list)
5458
for p in pods_for_nodes:
5559
n = getattr(p.model.spec, "nodeName", None) or ""
5660
pods_by_node[n].append(p)
5761

62+
# Emit per requested node (only those requested)
5863
for node in node_set:
59-
lines = summarize_gpu_pods(
60-
pods_by_node.get(node, []), args.verbose > 0
61-
)
62-
if not lines and args.verbose:
64+
lines = summarize_gpu_pods(pods_by_node.get(node, []), v > 0)
65+
if not lines and v > 0:
6366
print(f"{node}: FREE")
6467
else:
6568
for ln in lines:
6669
print(ln)
70+
6771
else:
68-
# One global summary over all Running pods
72+
# Global summary over all Running pods
6973
running = [
7074
p
7175
for p in all_pods
7276
if getattr(p.model.status, "phase", None) == "Running"
7377
]
74-
for ln in summarize_gpu_pods(running, args.verbose > 0):
78+
for ln in summarize_gpu_pods(running, v > 0):
7579
print(ln)
7680

7781
except oc.OpenShiftPythonException as e:
@@ -105,7 +109,7 @@ def summarize_gpu_pods(pods, verbose: bool) -> list[str]:
105109
except Exception:
106110
continue
107111

108-
lines = []
112+
lines: list[str] = []
109113
nodes = sorted(seen_nodes or totals.keys())
110114
for node in nodes:
111115
total = totals.get(node, 0)

batchtools/br.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ def run(args: argparse.Namespace):
225225

226226
if args.job_delete and args.wait:
227227
print(f"RUNDIR: jobs/{job_name}")
228-
oc_delete(job_name)
228+
oc_delete("job", job_name)
229229
else:
230230
print(
231231
f"User specified not to wait, or not to delete, so {job_name} must be deleted by user."
@@ -268,7 +268,7 @@ def log_job_output(job_name: str, *, wait: bool, timeout: int | None) -> None:
268268
if timeout and (time.monotonic() - start) > timeout:
269269
print(f"Timeout waiting for pod {pod_name} to complete")
270270
print(f"Deleting pod {pod_name}")
271-
oc_delete(job_name)
271+
oc_delete("job", job_name)
272272
return
273273

274274
# sleep to avoid hammering the server

batchtools/helpers.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,9 @@ def pretty_print(pod: oc.APIObject) -> str:
2121
return formatted_logs
2222

2323

24-
def oc_delete(job_name: str) -> None:
24+
def oc_delete(obj_type: str, obj_name: str) -> None:
2525
try:
26-
print(f"Deleting {job_name}")
27-
oc.invoke("delete", ["job", job_name])
26+
print(f"Deleting {obj_type}/{obj_name}")
27+
oc.selector(f"{obj_type}/{obj_name}").delete()
2828
except oc.OpenShiftPythonException as e:
29-
print(f"Error occurred while deleting job: {e}")
29+
print(f"Error occurred while deleting {obj_type}/{obj_name}: {e}")

tests/test_bd.py

Lines changed: 36 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
import pytest
33
from unittest import mock
44
from contextlib import contextmanager
5-
65
import argparse
76

87
from batchtools.bd import DeleteJobsCommand
@@ -12,84 +11,72 @@
1211
@pytest.fixture
1312
def jobs():
1413
return [
15-
DictToObject(
16-
{
17-
"model": {"metadata": {"name": "job-job-1"}},
18-
}
19-
),
20-
DictToObject(
21-
{
22-
"model": {"metadata": {"name": "job-job-2"}},
23-
}
24-
),
14+
DictToObject({"model": {"metadata": {"name": "job-job-1"}}}),
15+
DictToObject({"model": {"metadata": {"name": "job-job-2"}}}),
2516
]
2617

2718

2819
@pytest.fixture
2920
def ignored_jobs():
30-
return [
31-
DictToObject(
32-
{
33-
"model": {"metadata": {"name": "ignored-1"}},
34-
}
35-
),
36-
]
21+
return [DictToObject({"model": {"metadata": {"name": "ignored-1"}}})]
3722

3823

3924
@pytest.fixture
4025
def failed_jobs():
41-
return [
42-
DictToObject(
43-
{
44-
"model": {"metadata": {"name": "job-job-1"}},
45-
}
46-
),
47-
]
26+
return [DictToObject({"model": {"metadata": {"name": "job-job-1"}}})]
4827

4928

5029
@contextmanager
51-
def patch_jobs_selector(jobs: list[DictToObject]):
30+
def patch_jobs_selector(job_list: list[DictToObject]):
31+
"""
32+
Patches openshift_client.selector for BOTH:
33+
- the list call: selector("job", labels=...).objects() -> job_list
34+
- the delete calls: selector("job/<name>").delete()
35+
"""
5236
with mock.patch("openshift_client.selector") as mock_selector:
53-
mock_result = mock.Mock(name="result")
54-
mock_result.objects.return_value = jobs
55-
mock_selector.return_value = mock_result
37+
result = mock.Mock(name="selector_result")
38+
result.objects.return_value = job_list
39+
mock_selector.return_value = result
5640
yield mock_selector
5741

5842

5943
def test_no_jobs(args: argparse.Namespace, capsys):
6044
with patch_jobs_selector([]):
6145
DeleteJobsCommand.run(args)
62-
captured = capsys.readouterr()
63-
assert "No jobs found" in captured.out
46+
out = capsys.readouterr().out
47+
assert "No jobs found" in out
6448

6549

6650
def test_no_gpu_jobs(args: argparse.Namespace, ignored_jobs, capsys):
6751
with patch_jobs_selector(ignored_jobs):
6852
DeleteJobsCommand.run(args)
69-
captured = capsys.readouterr()
70-
assert "No GPU workloads to delete" in captured.out
53+
out = capsys.readouterr().out
54+
assert "No GPU workloads to delete" in out
7155

7256

73-
def test_delete_jobs(args: argparse.Namespace, jobs, capsys):
57+
def test_delete_obj(args: argparse.Namespace, jobs, capsys):
7458
args.job_names = []
75-
with (
76-
patch_jobs_selector(jobs),
77-
mock.patch("openshift_client.invoke") as mock_invoke,
78-
):
59+
with patch_jobs_selector(jobs) as mock_selector:
7960
DeleteJobsCommand.run(args)
80-
captured = capsys.readouterr()
81-
for job, ca in zip(jobs, mock_invoke.call_args_list):
82-
assert f"Deleting {job.model.metadata.name}" in captured.out
83-
assert ca.args == ("delete", ["job", job.model.metadata.name])
61+
out = capsys.readouterr().out
62+
63+
for obj in jobs:
64+
name = obj.model.metadata.name
65+
assert f"Deleting job/{name}" in out
66+
67+
called_with = [c.args[0] for c in mock_selector.call_args_list if c.args]
68+
for obj in jobs:
69+
assert f"job/{obj.model.metadata.name}" in called_with
8470

8571

8672
def test_delete_jobs_fail(args: argparse.Namespace, failed_jobs, capsys):
8773
args.job_names = []
88-
with (
89-
patch_jobs_selector(failed_jobs),
90-
mock.patch("openshift_client.invoke") as mock_invoke,
91-
):
92-
mock_invoke.side_effect = OpenShiftPythonException("test exception")
74+
with patch_jobs_selector(failed_jobs) as mock_selector:
75+
mock_selector.return_value.delete.side_effect = OpenShiftPythonException(
76+
"test exception"
77+
)
78+
9379
DeleteJobsCommand.run(args)
94-
captured = capsys.readouterr()
95-
assert "Error occurred while deleting job: test exception" in captured.out
80+
out = capsys.readouterr().out
81+
82+
assert "Error occurred while deleting job/job-job-1: test exception" in out

0 commit comments

Comments
 (0)