Skip to content

Commit e55637f

Browse files
chore: addressed comments
1 parent 8142e95 commit e55637f

File tree

4 files changed

+139
-88
lines changed

4 files changed

+139
-88
lines changed

src/hyperpod_cli/commands/job.py

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -199,27 +199,9 @@ def list_jobs(
199199
try:
200200
logger.debug("Listing training jobs")
201201
result = list_training_job_service.list_training_jobs(
202-
namespace, all_namespaces, selector
202+
namespace, all_namespaces, selector, output
203203
)
204-
result_dict = json.loads(result)
205-
206-
jobs = []
207-
if "jobs" in result_dict and isinstance(result_dict["jobs"], list):
208-
jobs = [job.values() for job in result_dict["jobs"]]
209-
210-
headers = [
211-
"Name",
212-
"Namespace",
213-
"CreationTime",
214-
"State"
215-
]
216-
217-
if output == OutputFormat.TABLE.value:
218-
click.echo(tabulate(jobs, headers=headers, tablefmt="presto"))
219-
elif output == OutputFormat.JSON.value:
220-
json_list = [dict(zip(headers, value)) for value in jobs]
221-
json_jobs = {"jobs": json_list}
222-
click.echo(json.dumps(json_jobs, indent=4))
204+
click.echo(result)
223205
except Exception as e:
224206
sys.exit(f"Unexpected error happens when trying to list training job : {e}")
225207

src/hyperpod_cli/service/list_training_jobs.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
1111
# ANY KIND, either express or implied. See the License for the specific
1212
# language governing permissions and limitations under the License.
13+
import copy
1314
from typing import List, Optional
1415

1516
import json
@@ -23,9 +24,10 @@
2324
V1ResourceAttributes
2425
)
2526

26-
from hyperpod_cli.constants.command_constants import KUEUE_WORKLOAD_PRIORITY_CLASS_LABEL_KEY
27+
from hyperpod_cli.constants.command_constants import KUEUE_WORKLOAD_PRIORITY_CLASS_LABEL_KEY, OutputFormat
2728
from hyperpod_cli.constants.pytorch_constants import PYTORCH_CUSTOM_OBJECT_GROUP, PYTORCH_CUSTOM_OBJECT_PLURAL
2829
from hyperpod_cli.service.discover_namespaces import DiscoverNamespaces
30+
from tabulate import tabulate
2931

3032

3133
class ListTrainingJobs:
@@ -37,14 +39,14 @@ def list_training_jobs(
3739
namespace: Optional[str],
3840
all_namespaces: Optional[bool],
3941
selector: Optional[str],
42+
output: Optional[str],
4043
) -> str:
4144
"""
4245
List training job provided by the user in the specified namespace.
4346
If namespace is not provided job are listed the default namespace in user context
4447
If all_namespace is true we will list training job from all namespaces that user has access
4548
Selector when specified will filter list of job addisional based on labels filter provided
4649
"""
47-
4850
k8s_client = KubernetesClient()
4951

5052
jobs: List = []
@@ -80,10 +82,12 @@ def list_training_jobs(
8082
except ApiException as e:
8183
raise RuntimeError(f"Unexpected API error: {e.reason} ({e.status})")
8284

83-
return self._generate_list_training_job_output(jobs)
85+
return self._generate_list_training_job_output(jobs, output)
8486

85-
def _generate_list_training_job_output(self, jobs: List):
87+
def _generate_list_training_job_output(self, jobs: List, output: Optional[str]):
8688
output_jobs = {"jobs": []}
89+
priority_header_required = False
90+
8791
for job in jobs:
8892
if job.get("metadata"):
8993
name = job.get("metadata").get("name")
@@ -104,10 +108,34 @@ def _generate_list_training_job_output(self, jobs: List):
104108

105109
if priority is not None:
106110
job_summary["priority"] = priority
111+
priority_header_required = True
107112

108113
output_jobs["jobs"].append(job_summary)
109114

110-
return json.dumps(output_jobs, indent=1, sort_keys=False)
115+
if output == OutputFormat.TABLE.value:
116+
return self._generate_table(output_jobs, priority_header_required)
117+
return json.dumps(output_jobs, indent=4, sort_keys=False)
118+
119+
def _generate_table(self, output_jobs, priority_header_required):
120+
headers = [
121+
"Name",
122+
"Namespace",
123+
"CreationTime",
124+
"State"
125+
]
126+
127+
if priority_header_required:
128+
headers.append("Priority")
129+
130+
jobs = []
131+
if "jobs" in output_jobs and isinstance(output_jobs["jobs"], list):
132+
for job in output_jobs["jobs"]:
133+
job_values = list(job.values())
134+
if priority_header_required and len(job_values) == 4:
135+
job_values.append("NA")
136+
jobs.append(job_values)
137+
138+
return tabulate(jobs, headers=headers, tablefmt="presto")
111139

112140
def _get_job_status(self, status: List) -> Optional[str]:
113141
current_status = None

test/unit_tests/service/test_list_training_jobs_service.py

Lines changed: 99 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
)
2424

2525
from kubernetes.client.rest import ApiException
26+
from tabulate import tabulate
2627

2728
SAMPLE_OUTPUT = {
2829
"items": [
@@ -152,7 +153,7 @@ def test_list_training_jobs_with_namespace(
152153
mock_kubernetes_client.return_value = self.mock_k8s_client
153154
self.mock_k8s_client.list_training_jobs.return_value = SAMPLE_OUTPUT
154155
result = self.mock_list_training_jobs.list_training_jobs(
155-
"namespace", None, None
156+
"namespace", None, None, None
156157
)
157158
self.assertIn("test-name", result)
158159
self.assertIn("test-name1", result)
@@ -166,7 +167,7 @@ def test_list_training_jobs_without_namespace(
166167
mock_kubernetes_client.return_value = self.mock_k8s_client
167168
self.mock_k8s_client.get_current_context_namespace.return_value = "namespace"
168169
self.mock_k8s_client.list_training_jobs.return_value = SAMPLE_OUTPUT
169-
result = self.mock_list_training_jobs.list_training_jobs(None, None, None)
170+
result = self.mock_list_training_jobs.list_training_jobs(None, None, None, None)
170171
self.assertIn("test-name", result)
171172
self.assertIn("test-name1", result)
172173
self.assertIn("Running", result)
@@ -182,7 +183,7 @@ def test_list_training_jobs_with_namespace_auto_discover(
182183
mock_discover_accessible_namespace.return_value = "discovered-namespace"
183184
self.mock_k8s_client.get_current_context_namespace.return_value = None
184185
self.mock_k8s_client.list_training_jobs.return_value = SAMPLE_OUTPUT
185-
result = self.mock_list_training_jobs.list_training_jobs(None, None, None)
186+
result = self.mock_list_training_jobs.list_training_jobs(None, None, None, None)
186187
mock_discover_accessible_namespace.assert_called_once_with(
187188
V1ResourceAttributes(
188189
verb="list",
@@ -209,7 +210,7 @@ def test_list_training_jobs_without_namespace_no_jobs(
209210
mock_kubernetes_client.return_value = self.mock_k8s_client
210211
self.mock_k8s_client.get_current_context_namespace.return_value = "namespace"
211212
self.mock_k8s_client.list_training_jobs.return_value = {"items": []}
212-
result = self.mock_list_training_jobs.list_training_jobs(None, None, None)
213+
result = self.mock_list_training_jobs.list_training_jobs(None, None, None, None)
213214
self.assertNotIn("test-name", result)
214215
self.assertNotIn("test-name1", result)
215216

@@ -221,7 +222,7 @@ def test_list_training_jobs_all_namespace(
221222
mock_kubernetes_client.return_value = self.mock_k8s_client
222223
self.mock_k8s_client.list_namespaces.return_value = ["namespace"]
223224
self.mock_k8s_client.list_training_jobs.return_value = SAMPLE_OUTPUT
224-
result = self.mock_list_training_jobs.list_training_jobs(None, True, None)
225+
result = self.mock_list_training_jobs.list_training_jobs(None, True, None, None)
225226
self.assertIn("test-name", result)
226227
self.assertIn("test-name1", result)
227228

@@ -236,7 +237,7 @@ def test_list_training_jobs_all_namespace_api_exception(
236237
status="Failed", reason="unexpected"
237238
)
238239
with self.assertRaises(RuntimeError):
239-
self.mock_list_training_jobs.list_training_jobs(None, True, None)
240+
self.mock_list_training_jobs.list_training_jobs(None, True, None, None)
240241

241242
@mock.patch("hyperpod_cli.clients.kubernetes_client.KubernetesClient.__new__")
242243
def test_list_training_jobs_all_namespace_no_jobs(
@@ -246,7 +247,7 @@ def test_list_training_jobs_all_namespace_no_jobs(
246247
mock_kubernetes_client.return_value = self.mock_k8s_client
247248
self.mock_k8s_client.list_namespaces.return_value = ["namespace"]
248249
self.mock_k8s_client.list_training_jobs.return_value = {"items": []}
249-
result = self.mock_list_training_jobs.list_training_jobs(None, True, None)
250+
result = self.mock_list_training_jobs.list_training_jobs(None, True, None, None)
250251
self.assertNotIn("test-name", result)
251252
self.assertNotIn("test-name1", result)
252253

@@ -258,7 +259,7 @@ def test_list_training_jobs_all_namespace_missing_metadata(
258259
mock_kubernetes_client.return_value = self.mock_k8s_client
259260
self.mock_k8s_client.list_namespaces.return_value = ["namespace"]
260261
self.mock_k8s_client.list_training_jobs.return_value = INVALID_OUTPUT
261-
result = self.mock_list_training_jobs.list_training_jobs(None, True, None)
262+
result = self.mock_list_training_jobs.list_training_jobs(None, True, None, None)
262263
self.assertNotIn("name", result)
263264

264265
@mock.patch("hyperpod_cli.clients.kubernetes_client.KubernetesClient.__new__")
@@ -269,5 +270,94 @@ def test_list_training_jobs_all_namespace_missing_status(
269270
mock_kubernetes_client.return_value = self.mock_k8s_client
270271
self.mock_k8s_client.list_namespaces.return_value = ["namespace"]
271272
self.mock_k8s_client.list_training_jobs.return_value = OUTPUT_WITHOUT_STATUS
272-
result = self.mock_list_training_jobs.list_training_jobs(None, True, None)
273+
result = self.mock_list_training_jobs.list_training_jobs(None, True, None, None)
273274
self.assertNotIn("State: null", result)
275+
276+
def test_generate_table_with_no_priority_header_and_values(self):
277+
list_training_jobs = ListTrainingJobs()
278+
output_jobs = {
279+
"jobs": [
280+
{
281+
"Name": "job1",
282+
"Namespace": "namespace1",
283+
"CreationTime": "2023-01-01T00:00:00Z",
284+
"State": "Running"
285+
}
286+
]
287+
}
288+
priority_header_required = False
289+
290+
result = list_training_jobs._generate_table(output_jobs, priority_header_required)
291+
292+
expected_headers = ["Name", "Namespace", "CreationTime", "State"]
293+
expected_jobs = [["job1", "namespace1", "2023-01-01T00:00:00Z", "Running"]]
294+
expected_result = tabulate(expected_jobs, headers=expected_headers, tablefmt="presto")
295+
296+
assert result == expected_result
297+
298+
def test_generate_table_with_priority_header_and_priority_values(self):
299+
list_training_jobs = ListTrainingJobs()
300+
output_jobs = {
301+
"jobs": [
302+
{
303+
"Name": "job1",
304+
"Namespace": "namespace1",
305+
"CreationTime": "2023-01-01T00:00:00Z",
306+
"State": "Running",
307+
"priority": "high"
308+
},
309+
{
310+
"Name": "job2",
311+
"Namespace": "namespace2",
312+
"CreationTime": "2023-01-02T00:00:00Z",
313+
"State": "Completed",
314+
"priority": "low"
315+
}
316+
]
317+
}
318+
priority_header_required = True
319+
320+
result = list_training_jobs._generate_table(output_jobs, priority_header_required)
321+
322+
expected_headers = ["Name", "Namespace", "CreationTime", "State", "Priority"]
323+
expected_jobs = [
324+
["job1", "namespace1", "2023-01-01T00:00:00Z", "Running", "high"],
325+
["job2", "namespace2", "2023-01-02T00:00:00Z", "Completed", "low"]
326+
]
327+
expected_result = tabulate(expected_jobs, headers=expected_headers, tablefmt="presto")
328+
329+
assert result == expected_result
330+
331+
def test_generate_table_with_priority_header_but_no_priority_value(self):
332+
list_training_jobs = ListTrainingJobs()
333+
output_jobs = {
334+
"jobs": [
335+
{
336+
"Name": "job1",
337+
"Namespace": "namespace1",
338+
"CreationTime": "2023-01-01T00:00:00Z",
339+
"State": "Running"
340+
}
341+
]
342+
}
343+
priority_header_required = True
344+
345+
result = list_training_jobs._generate_table(output_jobs, priority_header_required)
346+
347+
expected_headers = ["Name", "Namespace", "CreationTime", "State", "Priority"]
348+
expected_jobs = [["job1", "namespace1", "2023-01-01T00:00:00Z", "Running", "NA"]]
349+
expected_result = tabulate(expected_jobs, headers=expected_headers, tablefmt="presto")
350+
351+
assert result == expected_result
352+
353+
def test_generate_table_empty_jobs(self):
354+
list_training_jobs = ListTrainingJobs()
355+
output_jobs = {"jobs": []}
356+
priority_header_required = False
357+
358+
result = list_training_jobs._generate_table(output_jobs, priority_header_required)
359+
360+
expected_headers = ["Name", "Namespace", "CreationTime", "State"]
361+
expected_result = tabulate([], headers=expected_headers, tablefmt="presto")
362+
363+
assert result == expected_result

test/unit_tests/test_job.py

Lines changed: 5 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF
1111
# ANY KIND, either express or implied. See the License for the specific
1212
# language governing permissions and limitations under the License.
13-
import json
1413
import unittest
1514
import click
1615
import subprocess
@@ -160,7 +159,7 @@ def test_list_job_happy_case(
160159
mock_list_training_job_service: mock.Mock,
161160
):
162161
mock_list_training_job_service.return_value = self.mock_list_jobs
163-
mock_list_training_job_service_and_list_jobs.return_value = json.dumps({"jobs": []})
162+
mock_list_training_job_service_and_list_jobs.return_value = {"jobs": []}
164163
result = self.runner.invoke(list_jobs)
165164
self.assertEqual(result.exit_code, 0)
166165
self.assertIn("jobs", result.output)
@@ -177,60 +176,12 @@ def test_list_job_happy_case_debug_mode(
177176
mock_list_training_job_service: mock.Mock,
178177
):
179178
mock_list_training_job_service.return_value = self.mock_list_jobs
180-
mock_list_training_job_service_and_list_jobs.return_value = json.dumps({"jobs": []})
179+
mock_list_training_job_service_and_list_jobs.return_value = {"jobs": []}
181180
result = self.runner.invoke(list_jobs, ["--debug"])
182181
self.assertEqual(result.exit_code, 0)
183182
self.assertIn("jobs", result.output)
184183
mock_debug.assert_called()
185184

186-
@mock.patch("hyperpod_cli.service.list_training_jobs.ListTrainingJobs")
187-
@mock.patch(
188-
"hyperpod_cli.service.list_training_jobs.ListTrainingJobs.list_training_jobs"
189-
)
190-
@mock.patch("logging.Logger.debug")
191-
def test_list_job_happy_case_default_output_mode_json(
192-
self,
193-
mock_debug,
194-
mock_list_training_job_service_and_list_jobs: mock.Mock,
195-
mock_list_training_job_service: mock.Mock,
196-
):
197-
mock_list_training_job_service.return_value = self.mock_list_jobs
198-
mock_list_training_job_service_and_list_jobs.return_value = json.dumps({"jobs": [{
199-
"Name": "test-job-name",
200-
"Namespace": "test_namespace",
201-
"CreationTime": "2025-01-01T01:01:01Z",
202-
"State": "Succeeded"
203-
}]})
204-
result = self.runner.invoke(list_jobs, ["--output", "json"])
205-
self.assertEqual(result.exit_code, 0)
206-
expected_output = '{\n "jobs": [\n {\n "Name": "test-job-name",\n "Namespace": "test_namespace",\n "CreationTime": "2025-01-01T01:01:01Z",\n "State": "Succeeded"\n }\n ]\n}\n'
207-
self.assertEqual(expected_output, result.output)
208-
mock_debug.assert_called()
209-
210-
@mock.patch("hyperpod_cli.service.list_training_jobs.ListTrainingJobs")
211-
@mock.patch(
212-
"hyperpod_cli.service.list_training_jobs.ListTrainingJobs.list_training_jobs"
213-
)
214-
@mock.patch("logging.Logger.debug")
215-
def test_list_job_happy_case_default_output_mode_table(
216-
self,
217-
mock_debug,
218-
mock_list_training_job_service_and_list_jobs: mock.Mock,
219-
mock_list_training_job_service: mock.Mock,
220-
):
221-
mock_list_training_job_service.return_value = self.mock_list_jobs
222-
mock_list_training_job_service_and_list_jobs.return_value = json.dumps({"jobs": [{
223-
"Name": "test-job-name",
224-
"Namespace": "test_namespace",
225-
"CreationTime": "2025-01-01T01:01:01Z",
226-
"State": "Succeeded"
227-
}]})
228-
result = self.runner.invoke(list_jobs, ["--output", "table"])
229-
self.assertEqual(result.exit_code, 0)
230-
expected_output = ' Name | Namespace | CreationTime | State\n---------------+----------------+----------------------+-----------\n test-job-name | test_namespace | 2025-01-01T01:01:01Z | Succeeded\n'
231-
self.assertEqual(expected_output, result.output)
232-
mock_debug.assert_called()
233-
234185
@mock.patch("hyperpod_cli.service.list_training_jobs.ListTrainingJobs")
235186
@mock.patch(
236187
"hyperpod_cli.service.list_training_jobs.ListTrainingJobs.list_training_jobs"
@@ -241,7 +192,7 @@ def test_list_job_happy_case_with_namespace(
241192
mock_list_training_job_service: mock.Mock,
242193
):
243194
mock_list_training_job_service.return_value = self.mock_list_jobs
244-
mock_list_training_job_service_and_list_jobs.return_value = json.dumps({"jobs": []})
195+
mock_list_training_job_service_and_list_jobs.return_value = {"jobs": []}
245196
result = self.runner.invoke(list_jobs, ["--namespace", "kubeflow"])
246197
self.assertEqual(result.exit_code, 0)
247198
self.assertIn("jobs", result.output)
@@ -256,7 +207,7 @@ def test_list_job_happy_case_with_all_namespace(
256207
mock_list_training_job_service: mock.Mock,
257208
):
258209
mock_list_training_job_service.return_value = self.mock_list_jobs
259-
mock_list_training_job_service_and_list_jobs.return_value = json.dumps({'jobs': []})
210+
mock_list_training_job_service_and_list_jobs.return_value = {"jobs": []}
260211
result = self.runner.invoke(list_jobs, ["-A"])
261212
self.assertEqual(result.exit_code, 0)
262213
self.assertIn("jobs", result.output)
@@ -271,7 +222,7 @@ def test_list_job_happy_case_with_all_namespace_and_selector(
271222
mock_list_training_job_service: mock.Mock,
272223
):
273224
mock_list_training_job_service.return_value = self.mock_list_jobs
274-
mock_list_training_job_service_and_list_jobs.return_value = json.dumps({"jobs": []})
225+
mock_list_training_job_service_and_list_jobs.return_value = {"jobs": []}
275226
result = self.runner.invoke(list_jobs, ["-A", "-l", "test=test"])
276227
self.assertEqual(result.exit_code, 0)
277228
self.assertIn("jobs", result.output)

0 commit comments

Comments
 (0)