Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/quantum/HISTORY.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions src/quantum/azext_quantum/_help.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions src/quantum/azext_quantum/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,17 @@ def transform_jobs(results):
return [transform_job(job) for job in results]


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]


def transform_offerings(offerings):
def one(offering):
return OrderedDict([
Expand Down Expand Up @@ -149,6 +160,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)
Expand Down
54 changes: 53 additions & 1 deletion src/quantum/azext_quantum/operations/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,59 @@ 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):
"""
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, 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, code, message)
else:
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):
Expand Down
92 changes: 91 additions & 1 deletion src/quantum/azext_quantum/tests/latest/test_quantum_jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
import random
import time
import unittest
from datetime import datetime, timedelta, 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
Expand All @@ -18,12 +21,15 @@
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,
_format_duration,
_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__), '..'))

Expand All @@ -47,6 +53,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.")
Expand Down Expand Up @@ -142,6 +149,89 @@ 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(code="ProviderExecutionFailed", message="Provider execution failed"))

assert _get_job_events(job) == [
{"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):
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, "elapsed": None, "status": None, "code": 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, "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):
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)
Expand Down
2 changes: 1 addition & 1 deletion src/quantum/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading