Skip to content

Commit 0df2ac7

Browse files
authored
Add support for workspace v2 endpoints (#702)
* Support v2 endpoints when creating workspace from connection string * Use arm and arg in workspace initialization when connection string is not used * Use azure core pipeline client instead of azure-mgmt dependencies * Mgmt client improvements including unit tests * Update endpoint uri that returns from ARG/ARM, use mock mgmt client only when not live * Add context manager protocol to Workspace * Update connection string regex with better location handling
1 parent 55ecf54 commit 0df2ac7

26 files changed

+1638
-234
lines changed

azure-quantum/README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,14 @@ To get started, visit the following Quickstart guides:
2929

3030
## General usage ##
3131

32-
To connect to your Azure Quantum Workspace, go to the [Azure Portal](https://portal.azure.com), navigate to your Workspace and copy-paste the resource ID and location into the code snippet below.
32+
To connect to your Azure Quantum Workspace, go to the [Azure Portal](https://portal.azure.com), navigate to your Workspace and copy-paste the resource ID into the code snippet below.
3333

3434
```python
3535
from azure.quantum import Workspace
3636

37-
# Enter your Workspace details (resource ID and location) below
37+
# Enter your Workspace resource ID below
3838
workspace = Workspace(
39-
resource_id="",
40-
location=""
39+
resource_id=""
4140
)
4241
```
4342

azure-quantum/azure/quantum/_constants.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ class ConnectionConstants:
5555
DATA_PLANE_CREDENTIAL_SCOPE = "https://quantum.microsoft.com/.default"
5656
ARM_CREDENTIAL_SCOPE = "https://management.azure.com/.default"
5757

58+
DEFAULT_ARG_API_VERSION = "2021-03-01"
59+
DEFAULT_WORKSPACE_API_VERSION = "2025-11-01-preview"
60+
5861
MSA_TENANT_ID = "9188040d-6c67-4c5b-b112-36a304b66dad"
5962

6063
AUTHORITY = AzureIdentityInternals.get_default_authority()
@@ -63,10 +66,14 @@ class ConnectionConstants:
6366
# pylint: disable=unnecessary-lambda-assignment
6467
GET_QUANTUM_PRODUCTION_ENDPOINT = \
6568
lambda location: f"https://{location}.quantum.azure.com/"
69+
GET_QUANTUM_PRODUCTION_ENDPOINT_v2 = \
70+
lambda location: f"https://{location}-v2.quantum.azure.com/"
6671
GET_QUANTUM_CANARY_ENDPOINT = \
6772
lambda location: f"https://{location or 'eastus2euap'}.quantum.azure.com/"
6873
GET_QUANTUM_DOGFOOD_ENDPOINT = \
6974
lambda location: f"https://{location}.quantum-test.azure.com/"
75+
GET_QUANTUM_DOGFOOD_ENDPOINT_v2 = \
76+
lambda location: f"https://{location}-v2.quantum-test.azure.com/"
7077

7178
ARM_PRODUCTION_ENDPOINT = "https://management.azure.com/"
7279
ARM_DOGFOOD_ENDPOINT = "https://api-dogfood.resources.windows-int.net/"
@@ -93,3 +100,65 @@ class ConnectionConstants:
93100
GUID_REGEX_PATTERN = (
94101
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
95102
)
103+
104+
VALID_WORKSPACE_NAME_PATTERN = r"^[a-zA-Z0-9]+(-*[a-zA-Z0-9])*$"
105+
106+
VALID_AZURE_REGIONS = {
107+
"australiacentral",
108+
"australiacentral2",
109+
"australiaeast",
110+
"australiasoutheast",
111+
"austriaeast",
112+
"belgiumcentral",
113+
"brazilsouth",
114+
"brazilsoutheast",
115+
"canadacentral",
116+
"canadaeast",
117+
"centralindia",
118+
"centralus",
119+
"centraluseuap",
120+
"chilecentral",
121+
"eastasia",
122+
"eastus",
123+
"eastus2",
124+
"eastus2euap",
125+
"francecentral",
126+
"francesouth",
127+
"germanynorth",
128+
"germanywestcentral",
129+
"indonesiacentral",
130+
"israelcentral",
131+
"italynorth",
132+
"japaneast",
133+
"japanwest",
134+
"koreacentral",
135+
"koreasouth",
136+
"malaysiawest",
137+
"mexicocentral",
138+
"newzealandnorth",
139+
"northcentralus",
140+
"northeurope",
141+
"norwayeast",
142+
"norwaywest",
143+
"polandcentral",
144+
"qatarcentral",
145+
"southafricanorth",
146+
"southafricawest",
147+
"southcentralus",
148+
"southindia",
149+
"southeastasia",
150+
"spaincentral",
151+
"swedencentral",
152+
"switzerlandnorth",
153+
"switzerlandwest",
154+
"uaecentral",
155+
"uaenorth",
156+
"uksouth",
157+
"ukwest",
158+
"westcentralus",
159+
"westeurope",
160+
"westindia",
161+
"westus",
162+
"westus2",
163+
"westus3",
164+
}
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
##
2+
# Copyright (c) Microsoft Corporation. All rights reserved.
3+
# Licensed under the MIT License.
4+
##
5+
"""
6+
Module providing the WorkspaceMgmtClient class for managing workspace operations.
7+
Created to do not add additional azure-mgmt-* dependencies that can conflict with existing ones.
8+
"""
9+
10+
import logging
11+
from http import HTTPStatus
12+
from typing import Any, Optional, cast
13+
from azure.core import PipelineClient
14+
from azure.core.credentials import TokenProvider
15+
from azure.core.pipeline import policies
16+
from azure.core.rest import HttpRequest
17+
from azure.core.exceptions import HttpResponseError
18+
from azure.quantum._workspace_connection_params import WorkspaceConnectionParams
19+
from azure.quantum._constants import ConnectionConstants
20+
from azure.quantum._client._configuration import VERSION
21+
22+
logger = logging.getLogger(__name__)
23+
24+
__all__ = ["WorkspaceMgmtClient"]
25+
26+
27+
class WorkspaceMgmtClient():
28+
"""
29+
Client for Azure Quantum Workspace related ARM/ARG operations.
30+
Uses PipelineClient under the hood which is standard for all Azure SDK clients,
31+
see https://learn.microsoft.com/en-us/azure/developer/python/sdk/fundamentals/http-pipeline-retries.
32+
33+
:param credential:
34+
The credential to use to connect to Azure services.
35+
36+
:param base_url:
37+
The base URL for the ARM endpoint.
38+
39+
:param user_agent:
40+
Add the specified value as a prefix to the HTTP User-Agent header.
41+
"""
42+
43+
# Constants
44+
DEFAULT_RETRY_TOTAL = 3
45+
CONTENT_TYPE_JSON = "application/json"
46+
CONNECT_DOC_LINK = "https://learn.microsoft.com/en-us/azure/quantum/how-to-connect-workspace"
47+
CONNECT_DOC_MESSAGE = f"To find details on how to connect to your workspace, please see {CONNECT_DOC_LINK}."
48+
49+
def __init__(self, credential: TokenProvider, base_url: str, user_agent: Optional[str] = None) -> None:
50+
"""
51+
Initialize the WorkspaceMgmtClient.
52+
53+
:param credential:
54+
The credential to use to connect to Azure services.
55+
56+
:param base_url:
57+
The base URL for the ARM endpoint.
58+
"""
59+
self._credential = credential
60+
self._base_url = base_url
61+
self._policies = [
62+
policies.RequestIdPolicy(),
63+
policies.HeadersPolicy({
64+
"Content-Type": self.CONTENT_TYPE_JSON,
65+
"Accept": self.CONTENT_TYPE_JSON,
66+
}),
67+
policies.UserAgentPolicy(user_agent=user_agent, sdk_moniker="quantum/{}".format(VERSION)),
68+
policies.RetryPolicy(retry_total=self.DEFAULT_RETRY_TOTAL),
69+
policies.BearerTokenCredentialPolicy(self._credential, ConnectionConstants.ARM_CREDENTIAL_SCOPE),
70+
]
71+
self._client: PipelineClient = PipelineClient(base_url=cast(str, base_url), policies=self._policies)
72+
73+
def close(self) -> None:
74+
self._client.close()
75+
76+
def __enter__(self) -> 'WorkspaceMgmtClient':
77+
self._client.__enter__()
78+
return self
79+
80+
def __exit__(self, *exc_details: Any) -> None:
81+
self._client.__exit__(*exc_details)
82+
83+
def load_workspace_from_arg(self, connection_params: WorkspaceConnectionParams) -> None:
84+
"""
85+
Queries Azure Resource Graph to find a workspace by name and optionally location, resource group, subscription.
86+
Provided workspace name, location, resource group, and subscription in connection params must be validated beforehand.
87+
88+
:param connection_params:
89+
The workspace connection parameters to use and update.
90+
"""
91+
if not connection_params.workspace_name:
92+
raise ValueError("Workspace name must be specified to try to load workspace details from ARG.")
93+
94+
query = f"""
95+
Resources
96+
| where type =~ 'microsoft.quantum/workspaces'
97+
| where name =~ '{connection_params.workspace_name}'
98+
"""
99+
100+
if connection_params.resource_group:
101+
query += f"\n | where resourceGroup =~ '{connection_params.resource_group}'"
102+
103+
if connection_params.location:
104+
query += f"\n | where location =~ '{connection_params.location}'"
105+
106+
query += """
107+
| extend endpointUri = tostring(properties.endpointUri)
108+
| project name, subscriptionId, resourceGroup, location, endpointUri
109+
"""
110+
111+
request_body = {
112+
"query": query
113+
}
114+
115+
if connection_params.subscription_id:
116+
request_body["subscriptions"] = [connection_params.subscription_id]
117+
118+
# Create request to Azure Resource Graph API
119+
request = HttpRequest(
120+
method="POST",
121+
url=self._client.format_url("/providers/Microsoft.ResourceGraph/resources"),
122+
params={"api-version": ConnectionConstants.DEFAULT_ARG_API_VERSION},
123+
json=request_body
124+
)
125+
126+
try:
127+
response = self._client.send_request(request)
128+
response.raise_for_status()
129+
result = response.json()
130+
except Exception as e:
131+
raise RuntimeError(
132+
f"Could not load workspace details from Azure Resource Graph: {str(e)}.\n{self.CONNECT_DOC_MESSAGE}"
133+
) from e
134+
135+
data = result.get('data', [])
136+
137+
if not data:
138+
raise ValueError(f"No matching workspace found with name '{connection_params.workspace_name}'. {self.CONNECT_DOC_MESSAGE}")
139+
140+
if len(data) > 1:
141+
raise ValueError(
142+
f"Multiple Azure Quantum workspaces found with name '{connection_params.workspace_name}'. "
143+
f"Please specify additional connection parameters. {self.CONNECT_DOC_MESSAGE}"
144+
)
145+
146+
workspace_data = data[0]
147+
148+
connection_params.subscription_id = workspace_data.get('subscriptionId')
149+
connection_params.resource_group = workspace_data.get('resourceGroup')
150+
connection_params.location = workspace_data.get('location')
151+
connection_params.quantum_endpoint = workspace_data.get('endpointUri')
152+
153+
logger.debug(
154+
"Found workspace '%s' in subscription '%s', resource group '%s', location '%s', endpoint '%s'",
155+
connection_params.workspace_name,
156+
connection_params.subscription_id,
157+
connection_params.resource_group,
158+
connection_params.location,
159+
connection_params.quantum_endpoint
160+
)
161+
162+
# If one of the required parameters is missing, probably workspace in failed provisioning state
163+
if not connection_params.is_complete():
164+
raise ValueError(
165+
f"Failed to retrieve complete workspace details for workspace '{connection_params.workspace_name}'. "
166+
"Please check that workspace is in valid state."
167+
)
168+
169+
def load_workspace_from_arm(self, connection_params: WorkspaceConnectionParams) -> None:
170+
"""
171+
Fetches the workspace resource from ARM and sets location and endpoint URI params.
172+
Provided workspace name, resource group, and subscription in connection params must be validated beforehand.
173+
174+
:param connection_params:
175+
The workspace connection parameters to use and update.
176+
"""
177+
if not all([connection_params.subscription_id, connection_params.resource_group, connection_params.workspace_name]):
178+
raise ValueError("Missing required connection parameters to load workspace details from ARM.")
179+
180+
api_version = connection_params.api_version or ConnectionConstants.DEFAULT_WORKSPACE_API_VERSION
181+
182+
url = (
183+
f"/subscriptions/{connection_params.subscription_id}"
184+
f"/resourceGroups/{connection_params.resource_group}"
185+
f"/providers/Microsoft.Quantum/workspaces/{connection_params.workspace_name}"
186+
)
187+
188+
request = HttpRequest(
189+
method="GET",
190+
url=self._client.format_url(url),
191+
params={"api-version": api_version},
192+
)
193+
194+
try:
195+
response = self._client.send_request(request)
196+
response.raise_for_status()
197+
workspace_data = response.json()
198+
except HttpResponseError as e:
199+
if e.status_code == HTTPStatus.NOT_FOUND:
200+
raise ValueError(
201+
f"Azure Quantum workspace '{connection_params.workspace_name}' "
202+
f"not found in resource group '{connection_params.resource_group}' "
203+
f"and subscription '{connection_params.subscription_id}'. "
204+
f"{self.CONNECT_DOC_MESSAGE}"
205+
) from e
206+
# Re-raise for other HTTP errors
207+
raise
208+
except Exception as e:
209+
raise RuntimeError(
210+
f"Could not load workspace details from ARM: {str(e)}.\n{self.CONNECT_DOC_MESSAGE}"
211+
) from e
212+
213+
# Extract and apply location
214+
location = workspace_data.get("location")
215+
if location:
216+
connection_params.location = location
217+
logger.debug(
218+
"Updated workspace location from ARM: %s",
219+
location
220+
)
221+
else:
222+
raise ValueError(
223+
f"Failed to retrieve location for workspace '{connection_params.workspace_name}'. "
224+
f"Please check that workspace is in valid state."
225+
)
226+
227+
# Extract and apply endpoint URI from properties
228+
properties = workspace_data.get("properties", {})
229+
endpoint_uri = properties.get("endpointUri")
230+
if endpoint_uri:
231+
connection_params.quantum_endpoint = endpoint_uri
232+
logger.debug(
233+
"Updated workspace endpoint from ARM: %s", connection_params.quantum_endpoint
234+
)
235+
else:
236+
raise ValueError(
237+
f"Failed to retrieve endpoint uri for workspace '{connection_params.workspace_name}'. "
238+
f"Please check that workspace is in valid state."
239+
)

0 commit comments

Comments
 (0)