From b879578c52c567a036bd2d9b2256a7377af9ea98 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Tue, 11 Aug 2026 14:31:48 -0700 Subject: [PATCH 1/3] Add `az quantum job events` command to list job lifecycle events --- src/quantum/HISTORY.rst | 4 + src/quantum/azext_quantum/_help.py | 10 +++ src/quantum/azext_quantum/commands.py | 10 +++ src/quantum/azext_quantum/operations/job.py | 35 ++++++++ .../tests/latest/test_quantum_jobs.py | 86 ++++++++++++++++++- src/quantum/setup.py | 2 +- 6 files changed, 145 insertions(+), 2 deletions(-) diff --git a/src/quantum/HISTORY.rst b/src/quantum/HISTORY.rst index f5e90b25774..44a094fbd21 100644 --- a/src/quantum/HISTORY.rst +++ b/src/quantum/HISTORY.rst @@ -3,6 +3,10 @@ Release History =============== +1.0.0b21 ++++++++++++++++ +* Added the ``az quantum job events`` command to list the lifecycle events for a job. + 1.0.0b20 +++++++++++++++ * Added the ``az quantum workspace user create`` and ``az quantum workspace user delete`` commands to manage user access to an Azure Quantum workspace. diff --git a/src/quantum/azext_quantum/_help.py b/src/quantum/azext_quantum/_help.py index c32a0a8ae7c..0673d503392 100644 --- a/src/quantum/azext_quantum/_help.py +++ b/src/quantum/azext_quantum/_help.py @@ -114,6 +114,16 @@ -j yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy --query status """ +helps['quantum job events'] = """ + type: command + short-summary: Get the lifecycle events for a job. + examples: + - name: List the lifecycle events for an Azure Quantum job. + text: |- + az quantum job events -g MyResourceGroup -w MyWorkspace \\ + -j yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy +""" + helps['quantum job submit'] = """ type: command short-summary: Submit a program or circuit to run on Azure Quantum. diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index c95c447df7a..a52e2316d3f 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -56,6 +56,15 @@ def transform_jobs(results): return [transform_job(job) for job in results] +def transform_events(events): + return [OrderedDict([ + ('Event', event['event']), + ('Timestamp', event['timestamp']), + ('Status', event['status']), + ('Message', event['message']) + ]) for event in events] + + def transform_offerings(offerings): def one(offering): return OrderedDict([ @@ -149,6 +158,7 @@ def load_command_table(self, _): with self.command_group('quantum job', job_ops) as j: j.command('list', 'list', validator=validate_workspace_info, table_transformer=transform_jobs) j.show_command('show', 'job_show', validator=validate_workspace_info, table_transformer=transform_job) + j.command('events', 'list_events', validator=validate_workspace_info, table_transformer=transform_events) j.command('submit', 'submit', validator=validate_workspace_and_target_info, table_transformer=transform_job) j.command('wait', 'wait', validator=validate_workspace_info, table_transformer=transform_job) j.command('output', 'output', validator=validate_workspace_info, table_transformer=transform_output) diff --git a/src/quantum/azext_quantum/operations/job.py b/src/quantum/azext_quantum/operations/job.py index 69232510c63..cadf91e81a1 100644 --- a/src/quantum/azext_quantum/operations/job.py +++ b/src/quantum/azext_quantum/operations/job.py @@ -150,6 +150,41 @@ def get(cmd, job_id, resource_group_name=None, workspace_name=None): return client.get(job_id) +def list_events(cmd, job_id, resource_group_name=None, workspace_name=None): + """ + Get the lifecycle events for a job. + """ + info = WorkspaceInfo(cmd, resource_group_name, workspace_name) + client = cf_jobs(cmd.cli_ctx, info.subscription, info.resource_group, info.name, info.endpoint) + job = client.get(info.subscription, info.resource_group, info.name, job_id) + return _get_job_events(job) + + +def _get_job_events(job): + events = [] + + def add_event(name, timestamp, status=None, message=None): + if timestamp is not None: + events.append({ + "event": name, + "timestamp": timestamp, + "status": status, + "message": message + }) + + add_event("Created", job.creation_time) + add_event("Executing", job.begin_execution_time) + + status = getattr(job.status, "value", job.status) + message = job.error_data.message if job.error_data is not None else None + if job.cancellation_time is not None: + add_event("Cancelled", job.cancellation_time, status, message) + else: + add_event("Finished", job.end_execution_time, status, message) + + return events + + def _has_completed(job): return job.status in ("Succeeded", "Failed", "Cancelled", "Completed") diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_jobs.py b/src/quantum/azext_quantum/tests/latest/test_quantum_jobs.py index cc77be6b36e..9a849404676 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_jobs.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_jobs.py @@ -9,6 +9,9 @@ import random import time import unittest +from datetime import datetime, timezone +from types import SimpleNamespace +from unittest.mock import Mock, patch from urllib.parse import urlparse, parse_qs from azure.cli.testsdk.scenario_tests import AllowLargeResponse, live_only @@ -18,12 +21,14 @@ from .utils import get_test_resource_group, get_test_workspace, get_test_workspace_location, issue_cmd_with_param_missing, get_test_workspace_storage, get_test_workspace_random_name from ...commands import transform_output from ...operations.job import ( + _get_job_events, _validate_max_poll_wait_secs, _convert_numeric_params, _construct_filter_query, _construct_orderby_expression, ERROR_MSG_INVALID_ORDER_ARGUMENT, - ERROR_MSG_MISSING_ORDERBY_ARGUMENT) + ERROR_MSG_MISSING_ORDERBY_ARGUMENT, + list_events) TEST_DIR = os.path.abspath(os.path.join(os.path.abspath(__file__), '..')) @@ -47,6 +52,7 @@ def test_jobs(self): def test_job_errors(self): issue_cmd_with_param_missing(self, "az quantum job cancel", "az quantum job cancel -g MyResourceGroup -w MyWorkspace -j yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy\nCancel an Azure Quantum job by id.") issue_cmd_with_param_missing(self, "az quantum job delete", "az quantum job delete -g MyResourceGroup -w MyWorkspace -j yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy\nDelete an Azure Quantum job by id.") + issue_cmd_with_param_missing(self, "az quantum job events", "az quantum job events -g MyResourceGroup -w MyWorkspace -j yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy\nGet the lifecycle events for a job.") issue_cmd_with_param_missing(self, "az quantum job output", "az quantum job output -g MyResourceGroup -w MyWorkspace -j yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy -o table\nPrint the results of a successful Azure Quantum job.") issue_cmd_with_param_missing(self, "az quantum job show", "az quantum job show -g MyResourceGroup -w MyWorkspace -j yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy --query status\nGet the status of an Azure Quantum job.") issue_cmd_with_param_missing(self, "az quantum job wait", "az quantum job wait -g MyResourceGroup -w MyWorkspace -j yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy --max-poll-wait-secs 60 -o table\nWait for completion of a job, check at 60 second intervals.") @@ -142,6 +148,84 @@ def test_transform_output(self): self.assertEqual(table['Job ID'], notFound) self.assertEqual(table['Submission Time'], notFound) + def test_get_job_events(self): + creation_time = datetime(2026, 8, 7, 10, 0, tzinfo=timezone.utc) + begin_execution_time = datetime(2026, 8, 7, 10, 1, tzinfo=timezone.utc) + end_execution_time = datetime(2026, 8, 7, 10, 2, tzinfo=timezone.utc) + job = SimpleNamespace( + creation_time=creation_time, + begin_execution_time=begin_execution_time, + end_execution_time=end_execution_time, + cancellation_time=None, + status=SimpleNamespace(value="Failed"), + error_data=SimpleNamespace(message="Provider execution failed")) + + assert _get_job_events(job) == [ + {"event": "Created", "timestamp": creation_time, "status": None, "message": None}, + {"event": "Executing", "timestamp": begin_execution_time, "status": None, "message": None}, + {"event": "Finished", "timestamp": end_execution_time, "status": "Failed", "message": "Provider execution failed"} + ] + + def test_get_job_events_omits_missing_timestamps(self): + creation_time = datetime(2026, 8, 7, 10, 0, tzinfo=timezone.utc) + job = SimpleNamespace( + creation_time=creation_time, + begin_execution_time=None, + end_execution_time=None, + cancellation_time=None, + status="Queued", + error_data=None) + + assert _get_job_events(job) == [ + {"event": "Created", "timestamp": creation_time, "status": None, "message": None} + ] + + def test_get_job_events_uses_cancellation_time(self): + creation_time = datetime(2026, 8, 7, 10, 0, tzinfo=timezone.utc) + cancellation_time = datetime(2026, 8, 7, 10, 1, tzinfo=timezone.utc) + job = SimpleNamespace( + creation_time=creation_time, + begin_execution_time=None, + end_execution_time=None, + cancellation_time=cancellation_time, + status="Cancelled", + error_data=None) + + assert _get_job_events(job) == [ + {"event": "Created", "timestamp": creation_time, "status": None, "message": None}, + {"event": "Cancelled", "timestamp": cancellation_time, "status": "Cancelled", "message": None} + ] + + @patch("azext_quantum.operations.job.cf_jobs") + @patch("azext_quantum.operations.job.WorkspaceInfo") + def test_list_events_gets_job_with_workspace_path(self, workspace_info, client_factory): + info = SimpleNamespace( + subscription="subscription-id", + resource_group="resource-group", + name="workspace", + endpoint="https://example.quantum.azure.com") + workspace_info.return_value = info + job = SimpleNamespace( + creation_time=datetime(2026, 8, 7, 10, 0, tzinfo=timezone.utc), + begin_execution_time=None, + end_execution_time=None, + cancellation_time=None, + status="Queued", + error_data=None) + client = Mock() + client.get.return_value = job + client_factory.return_value = client + cmd = SimpleNamespace(cli_ctx=object()) + + events = list_events(cmd, "job-id", "resource-group", "workspace") + + workspace_info.assert_called_once_with(cmd, "resource-group", "workspace") + client_factory.assert_called_once_with( + cmd.cli_ctx, info.subscription, info.resource_group, info.name, info.endpoint) + client.get.assert_called_once_with( + info.subscription, info.resource_group, info.name, "job-id") + assert events[0]["event"] == "Created" + def test_validate_max_poll_wait_secs(self): wait_secs = _validate_max_poll_wait_secs(1) self.assertEqual(type(wait_secs), float) diff --git a/src/quantum/setup.py b/src/quantum/setup.py index 22a1659670f..ea438d3bff6 100644 --- a/src/quantum/setup.py +++ b/src/quantum/setup.py @@ -17,7 +17,7 @@ # This version should match the latest entry in HISTORY.rst # Also, when updating this, please review the version used by the extension to # submit requests, which can be found at './azext_quantum/__init__.py' -VERSION = '1.0.0b20' +VERSION = '1.0.0b21' # The full list of classifiers is available at # https://pypi.python.org/pypi?%3Aaction=list_classifiers From d505c8c9653efe3f64e4b4d77f072521d6ea1d37 Mon Sep 17 00:00:00 2001 From: Konstantin Date: Tue, 11 Aug 2026 15:04:49 -0700 Subject: [PATCH 2/3] Enhance job event logging by adding elapsed time and error code to events --- src/quantum/azext_quantum/commands.py | 2 + src/quantum/azext_quantum/operations/job.py | 37 ++++++++++++++----- .../tests/latest/test_quantum_jobs.py | 22 +++++++---- 3 files changed, 43 insertions(+), 18 deletions(-) diff --git a/src/quantum/azext_quantum/commands.py b/src/quantum/azext_quantum/commands.py index a52e2316d3f..e1d98180153 100644 --- a/src/quantum/azext_quantum/commands.py +++ b/src/quantum/azext_quantum/commands.py @@ -60,7 +60,9 @@ def transform_events(events): return [OrderedDict([ ('Event', event['event']), ('Timestamp', event['timestamp']), + ('Elapsed', event['elapsed']), ('Status', event['status']), + ('Code', event['code']), ('Message', event['message']) ]) for event in events] diff --git a/src/quantum/azext_quantum/operations/job.py b/src/quantum/azext_quantum/operations/job.py index cadf91e81a1..12f1f6bedbd 100644 --- a/src/quantum/azext_quantum/operations/job.py +++ b/src/quantum/azext_quantum/operations/job.py @@ -163,28 +163,45 @@ def list_events(cmd, job_id, resource_group_name=None, workspace_name=None): def _get_job_events(job): events = [] - def add_event(name, timestamp, status=None, message=None): - if timestamp is not None: - events.append({ - "event": name, - "timestamp": timestamp, - "status": status, - "message": message - }) + def add_event(name, timestamp, status=None, code=None, message=None): + if timestamp is None: + return + elapsed = _format_duration(timestamp - events[-1]["timestamp"]) if events else None + events.append({ + "event": name, + "timestamp": timestamp, + "elapsed": elapsed, + "status": status, + "code": code, + "message": message + }) add_event("Created", job.creation_time) add_event("Executing", job.begin_execution_time) status = getattr(job.status, "value", job.status) + code = job.error_data.code if job.error_data is not None else None message = job.error_data.message if job.error_data is not None else None if job.cancellation_time is not None: - add_event("Cancelled", job.cancellation_time, status, message) + add_event("Cancelled", job.cancellation_time, status, code, message) else: - add_event("Finished", job.end_execution_time, status, message) + add_event("Finished", job.end_execution_time, status, code, message) return events +def _format_duration(delta): + # Elapsed time since the previous lifecycle event (queue time, then run time). + total_seconds = delta.total_seconds() + if total_seconds < 60: + return f"{total_seconds:.3f}s" + minutes, seconds = divmod(int(round(total_seconds)), 60) + hours, minutes = divmod(minutes, 60) + if hours: + return f"{hours}h {minutes}m {seconds}s" + return f"{minutes}m {seconds}s" + + def _has_completed(job): return job.status in ("Succeeded", "Failed", "Cancelled", "Completed") diff --git a/src/quantum/azext_quantum/tests/latest/test_quantum_jobs.py b/src/quantum/azext_quantum/tests/latest/test_quantum_jobs.py index 9a849404676..fad3a09b39a 100644 --- a/src/quantum/azext_quantum/tests/latest/test_quantum_jobs.py +++ b/src/quantum/azext_quantum/tests/latest/test_quantum_jobs.py @@ -9,7 +9,7 @@ import random import time import unittest -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import Mock, patch from urllib.parse import urlparse, parse_qs @@ -22,6 +22,7 @@ from ...commands import transform_output from ...operations.job import ( _get_job_events, + _format_duration, _validate_max_poll_wait_secs, _convert_numeric_params, _construct_filter_query, @@ -158,12 +159,12 @@ def test_get_job_events(self): end_execution_time=end_execution_time, cancellation_time=None, status=SimpleNamespace(value="Failed"), - error_data=SimpleNamespace(message="Provider execution failed")) + error_data=SimpleNamespace(code="ProviderExecutionFailed", message="Provider execution failed")) assert _get_job_events(job) == [ - {"event": "Created", "timestamp": creation_time, "status": None, "message": None}, - {"event": "Executing", "timestamp": begin_execution_time, "status": None, "message": None}, - {"event": "Finished", "timestamp": end_execution_time, "status": "Failed", "message": "Provider execution failed"} + {"event": "Created", "timestamp": creation_time, "elapsed": None, "status": None, "code": None, "message": None}, + {"event": "Executing", "timestamp": begin_execution_time, "elapsed": "1m 0s", "status": None, "code": None, "message": None}, + {"event": "Finished", "timestamp": end_execution_time, "elapsed": "1m 0s", "status": "Failed", "code": "ProviderExecutionFailed", "message": "Provider execution failed"} ] def test_get_job_events_omits_missing_timestamps(self): @@ -177,7 +178,7 @@ def test_get_job_events_omits_missing_timestamps(self): error_data=None) assert _get_job_events(job) == [ - {"event": "Created", "timestamp": creation_time, "status": None, "message": None} + {"event": "Created", "timestamp": creation_time, "elapsed": None, "status": None, "code": None, "message": None} ] def test_get_job_events_uses_cancellation_time(self): @@ -192,10 +193,15 @@ def test_get_job_events_uses_cancellation_time(self): error_data=None) assert _get_job_events(job) == [ - {"event": "Created", "timestamp": creation_time, "status": None, "message": None}, - {"event": "Cancelled", "timestamp": cancellation_time, "status": "Cancelled", "message": None} + {"event": "Created", "timestamp": creation_time, "elapsed": None, "status": None, "code": None, "message": None}, + {"event": "Cancelled", "timestamp": cancellation_time, "elapsed": "1m 0s", "status": "Cancelled", "code": None, "message": None} ] + def test_format_duration(self): + assert _format_duration(timedelta(seconds=5.5)) == "5.500s" + assert _format_duration(timedelta(seconds=90)) == "1m 30s" + assert _format_duration(timedelta(hours=1, minutes=2, seconds=3)) == "1h 2m 3s" + @patch("azext_quantum.operations.job.cf_jobs") @patch("azext_quantum.operations.job.WorkspaceInfo") def test_list_events_gets_job_with_workspace_path(self, workspace_info, client_factory): From 12f283b189deb369cc17864dad1a541caaaa426f Mon Sep 17 00:00:00 2001 From: Konstantin Date: Tue, 11 Aug 2026 15:35:29 -0700 Subject: [PATCH 3/3] Fix job retrieval by updating client.get parameters to include subscription and resource group --- src/quantum/azext_quantum/operations/job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/quantum/azext_quantum/operations/job.py b/src/quantum/azext_quantum/operations/job.py index 12f1f6bedbd..e6f5be3690c 100644 --- a/src/quantum/azext_quantum/operations/job.py +++ b/src/quantum/azext_quantum/operations/job.py @@ -147,7 +147,7 @@ def get(cmd, job_id, resource_group_name=None, workspace_name=None): """ info = WorkspaceInfo(cmd, resource_group_name, workspace_name) client = cf_jobs(cmd.cli_ctx, info.subscription, info.resource_group, info.name, info.endpoint) - return client.get(job_id) + return client.get(info.subscription, info.resource_group, info.name, job_id) def list_events(cmd, job_id, resource_group_name=None, workspace_name=None):