diff --git a/azure-quantum/azure/quantum/_constants.py b/azure-quantum/azure/quantum/_constants.py index 79e9cd23b..49254afac 100644 --- a/azure-quantum/azure/quantum/_constants.py +++ b/azure-quantum/azure/quantum/_constants.py @@ -22,6 +22,7 @@ class EnvironmentVariables: AZURE_CLIENT_SECRET = SdkEnvironmentVariables.AZURE_CLIENT_SECRET AZURE_TENANT_ID = SdkEnvironmentVariables.AZURE_TENANT_ID QUANTUM_TOKEN_FILE = "AZURE_QUANTUM_TOKEN_FILE" + CONNECTION_STRING = "AZURE_QUANTUM_CONNECTION_STRING" ALL = [ USER_AGENT_APPID, QUANTUM_LOCATION, @@ -36,6 +37,7 @@ class EnvironmentVariables: AZURE_CLIENT_SECRET, AZURE_TENANT_ID, QUANTUM_TOKEN_FILE, + CONNECTION_STRING, ] @@ -73,6 +75,17 @@ class ConnectionConstants: f"Workspaces/{workspace_name}" ) + VALID_CONNECTION_STRING = ( + lambda subscription_id, resource_group, workspace_name, api_key, quantum_endpoint: + f"SubscriptionId={subscription_id};" + + f"ResourceGroupName={resource_group};" + + f"WorkspaceName={workspace_name};" + + f"ApiKey={api_key};" + + f"QuantumEndpoint={quantum_endpoint};" + ) + + QUANTUM_API_KEY_HEADER = "x-ms-quantum-api-key" + GUID_REGEX_PATTERN = ( r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" ) diff --git a/azure-quantum/azure/quantum/_workspace_connection_params.py b/azure-quantum/azure/quantum/_workspace_connection_params.py index 55f395e05..291743192 100644 --- a/azure-quantum/azure/quantum/_workspace_connection_params.py +++ b/azure-quantum/azure/quantum/_workspace_connection_params.py @@ -12,6 +12,8 @@ Union, Any ) +from azure.core.credentials import AzureKeyCredential +from azure.core.pipeline.policies import AzureKeyCredentialPolicy from azure.quantum._authentication import _DefaultAzureCredential from azure.quantum._constants import ( EnvironmentKind, @@ -37,6 +39,17 @@ class WorkspaceConnectionParams: """, re.VERBOSE | re.IGNORECASE) + CONNECTION_STRING_REGEX = re.compile( + fr""" + ^ + SubscriptionId=(?P{GUID_REGEX_PATTERN}); + ResourceGroupName=(?P[^\s;]+); + WorkspaceName=(?P[^\s;]+); + ApiKey=(?P[^\s;]+); + QuantumEndpoint=(?Phttps://(?P[^\s\.]+).quantum(?:-test)?.azure.com/); + """, + re.VERBOSE | re.IGNORECASE) + def __init__( self, subscription_id: Optional[str] = None, @@ -53,6 +66,7 @@ def __init__( tenant_id: Optional[str] = None, client_id: Optional[str] = None, api_version: Optional[str] = None, + connection_string: Optional[str] = None, on_new_client_request: Optional[Callable] = None, ): # fields are used for these properties since @@ -75,6 +89,9 @@ def __init__( # for example, when changing the user agent self.on_new_client_request = on_new_client_request # merge the connection parameters passed + # connection_string is set first as it + # should be overridden by other parameters + self.apply_connection_string(connection_string) self.merge( api_version=api_version, arm_endpoint=arm_endpoint, @@ -162,6 +179,21 @@ def arm_endpoint(self): def arm_endpoint(self, value: str): self._arm_endpoint = value + @property + def api_key(self): + """ + The api-key stored in a AzureKeyCredential. + """ + return (self.credential.key + if isinstance(self.credential, AzureKeyCredential) + else None) + + @api_key.setter + def api_key(self, value: str): + if value: + self.credential = AzureKeyCredential(value) + self._api_key = value + def __repr__(self): """ Print all fields and properties. @@ -191,6 +223,19 @@ def apply_resource_id(self, resource_id: str): raise ValueError("Invalid resource id") self._merge_re_match(match) + def apply_connection_string(self, connection_string: str): + """ + Parses the connection_string and set the connection + parameters obtained from it. + """ + if connection_string: + match = re.search( + WorkspaceConnectionParams.CONNECTION_STRING_REGEX, + connection_string) + if not match: + raise ValueError("Invalid connection string") + self._merge_re_match(match) + def merge( self, subscription_id: Optional[str] = None, @@ -206,6 +251,7 @@ def merge( tenant_id: Optional[str] = None, client_id: Optional[str] = None, api_version: Optional[str] = None, + api_key: Optional[str] = None, ): """ Set all fields/properties with `not None` values @@ -226,6 +272,7 @@ def merge( user_agent=user_agent, user_agent_app_id=user_agent_app_id, workspace_name=workspace_name, + api_key=api_key, merge_default_mode=False, ) return self @@ -245,6 +292,7 @@ def apply_defaults( tenant_id: Optional[str] = None, client_id: Optional[str] = None, api_version: Optional[str] = None, + api_key: Optional[str] = None, ) -> WorkspaceConnectionParams: """ Set all fields/properties with `not None` values @@ -266,6 +314,7 @@ def apply_defaults( user_agent=user_agent, user_agent_app_id=user_agent_app_id, workspace_name=workspace_name, + api_key=api_key, merge_default_mode=True, ) return self @@ -286,6 +335,7 @@ def _merge( tenant_id: Optional[str] = None, client_id: Optional[str] = None, api_version: Optional[str] = None, + api_key: Optional[str] = None, ): """ Set all fields/properties with `not None` values @@ -313,6 +363,7 @@ def _get_value_or_default(old_value, new_value): self.client_id = _get_value_or_default(self.client_id, client_id) self.tenant_id = _get_value_or_default(self.tenant_id, tenant_id) self.api_version = _get_value_or_default(self.api_version, api_version) + self.api_key = _get_value_or_default(self.api_key, api_key) # for these properties that have a default value in the getter, we use # the private field as the old_value self.quantum_endpoint = _get_value_or_default(self._quantum_endpoint, quantum_endpoint) @@ -360,6 +411,16 @@ def get_credential_or_default(self) -> Any: arm_endpoint=self.arm_endpoint, tenant_id=self.tenant_id)) + def get_auth_policy(self) -> Any: + """ + Returns a AzureKeyCredentialPolicy if using an AzureKeyCredential. + Defaults to None. + """ + if isinstance(self.credential, AzureKeyCredential): + return AzureKeyCredentialPolicy(self.credential, + ConnectionConstants.QUANTUM_API_KEY_HEADER) + return None + def append_user_agent(self, value: str): """ Append a new value to the Workspace's UserAgent and re-initialize the @@ -417,6 +478,7 @@ def assert_complete(self): 1) A valid combination of location and resource ID. 2) A valid combination of location, subscription ID, resource group name, and workspace name. + 3) A valid connection string (via Workspace.from_connection_string()). """) def default_from_env_vars(self) -> WorkspaceConnectionParams: @@ -445,6 +507,18 @@ def default_from_env_vars(self) -> WorkspaceConnectionParams: # because the getter return default values self.environment = (self._environment or os.environ.get(EnvironmentVariables.QUANTUM_ENV)) + # only try to use the connection string from env var if + # we really need it + if (not self.location + or not self.subscription_id + or not self.resource_group + or not self.workspace_name + or not self.credential + ): + self._merge_connection_params( + connection_params=WorkspaceConnectionParams( + connection_string=os.environ.get(EnvironmentVariables.CONNECTION_STRING)), + merge_default_mode=True) return self @classmethod @@ -466,5 +540,6 @@ def get_value(group_name): workspace_name=get_value('workspace_name'), location=get_value('location'), quantum_endpoint=get_value('quantum_endpoint'), + api_key=get_value('api_key'), arm_endpoint=get_value('arm_endpoint'), ) diff --git a/azure-quantum/azure/quantum/workspace.py b/azure-quantum/azure/quantum/workspace.py index 2b1057ab4..f9010a828 100644 --- a/azure-quantum/azure/quantum/workspace.py +++ b/azure-quantum/azure/quantum/workspace.py @@ -58,6 +58,10 @@ class Workspace: 2. specify a valid location, subscription ID, resource group, and workspace name. + You can also use a connection string to specify the connection parameters + to a Azure Quantum Workspace by calling: + Workspace.from_connection_string() + If the Azure Quantum workspace does not have linked storage, the caller must also pass a valid Azure storage account connection string. @@ -172,6 +176,7 @@ def _create_client(self) -> QuantumClient: user_agent=connection_params.get_full_user_agent(), credential_scopes = [ConnectionConstants.DATA_PLANE_CREDENTIAL_SCOPE], endpoint=connection_params.quantum_endpoint, + authentication_policy=connection_params.get_auth_policy(), **kwargs ) return client @@ -193,6 +198,20 @@ def append_user_agent(self, value: str): """ self._connection_params.append_user_agent(value=value) + @classmethod + def from_connection_string(cls, connection_string: str, **kwargs) -> Workspace: + """ + Creates a new Azure Quantum Workspace client from a connection string. + """ + connection_params = WorkspaceConnectionParams(connection_string=connection_string) + return cls( + subscription_id=connection_params.subscription_id, + resource_group=connection_params.resource_group, + name=connection_params.workspace_name, + location=connection_params.location, + credential=connection_params.get_credential_or_default(), + **kwargs) + def _get_top_level_items_client(self) -> TopLevelItemsOperations: return self._client.top_level_items diff --git a/azure-quantum/eng/Clear-Env-Vars.ps1 b/azure-quantum/eng/Clear-Env-Vars.ps1 index 0aeccae8d..6468f99e4 100644 --- a/azure-quantum/eng/Clear-Env-Vars.ps1 +++ b/azure-quantum/eng/Clear-Env-Vars.ps1 @@ -10,3 +10,4 @@ $env:AZURE_QUANTUM_WORKSPACE_LOCATION = $null $env:AZURE_SUBSCRIPTION_ID = $env:SUBSCRIPTION_ID $env:AZURE_RESOURCE_GROUP = $env:AZURE_QUANTUM_WORKSPACE_RG $env:QUANTUM_TOKEN_FILE = $null +$env:AZURE_QUANTUM_CONNECTION_STRING = $null diff --git a/azure-quantum/tests/unit/common.py b/azure-quantum/tests/unit/common.py index feea1141e..510706c83 100644 --- a/azure-quantum/tests/unit/common.py +++ b/azure-quantum/tests/unit/common.py @@ -283,6 +283,9 @@ class CustomRecordingProcessor(RecordingProcessor): "user-agent", "www-authenticate", ] + ALLOW_SANITIZED_HEADERS = [ + ConnectionConstants.QUANTUM_API_KEY_HEADER, + ] def __init__(self, quantumTest: QuantumTestBase): self._regexes = [] @@ -405,6 +408,8 @@ def process_request(self, request): for key in request.headers: if key.lower() in self.ALLOW_HEADERS: headers[key] = self._regex_replace_all(request.headers[key]) + if key.lower() in self.ALLOW_SANITIZED_HEADERS: + headers[key] = PLACEHOLDER request.headers = headers request.uri = self._regex_replace_all(request.uri) diff --git a/azure-quantum/tests/unit/recordings/test_workspace_auth_connection_string_api_key.yaml b/azure-quantum/tests/unit/recordings/test_workspace_auth_connection_string_api_key.yaml new file mode 100644 index 000000000..8f88bc923 --- /dev/null +++ b/azure-quantum/tests/unit/recordings/test_workspace_auth_connection_string_api_key.yaml @@ -0,0 +1,66 @@ +interactions: +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - azsdk-python-quantum/0.0.1.0a1 Python/3.9.18 (Windows-10-10.0.22631-SP0) + x-ms-quantum-api-key: + - PLACEHOLDER + method: GET + uri: https://eastus.quantum.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myresourcegroup/providers/Microsoft.Quantum/workspaces/myworkspace/providerStatus?api-version=2022-09-12-preview&test-sequence-id=1 + response: + body: + string: '{"value": [{"id": "microsoft-elements", "currentAvailability": "Available", + "targets": [{"id": "microsoft.dft", "currentAvailability": "Available", "averageQueueTime": + 0, "statusPage": null}]}, {"id": "ionq", "currentAvailability": "Degraded", + "targets": [{"id": "ionq.qpu", "currentAvailability": "Available", "averageQueueTime": + 465550, "statusPage": "https://status.ionq.co"}, {"id": "ionq.qpu.aria-1", + "currentAvailability": "Available", "averageQueueTime": 763856, "statusPage": + "https://status.ionq.co"}, {"id": "ionq.qpu.aria-2", "currentAvailability": + "Unavailable", "averageQueueTime": 431102, "statusPage": "https://status.ionq.co"}, + {"id": "ionq.simulator", "currentAvailability": "Available", "averageQueueTime": + 5, "statusPage": "https://status.ionq.co"}]}, {"id": "microsoft-qc", "currentAvailability": + "Available", "targets": [{"id": "microsoft.estimator", "currentAvailability": + "Available", "averageQueueTime": 0, "statusPage": null}]}, {"id": "pasqal", + "currentAvailability": "Degraded", "targets": [{"id": "pasqal.sim.emu-tn", + "currentAvailability": "Available", "averageQueueTime": 0, "statusPage": "https://pasqal.com"}, + {"id": "pasqal.qpu.fresnel", "currentAvailability": "Degraded", "averageQueueTime": + 0, "statusPage": "https://pasqal.com"}]}, {"id": "quantinuum", "currentAvailability": + "Degraded", "targets": [{"id": "quantinuum.qpu.h1-1", "currentAvailability": + "Unavailable", "averageQueueTime": 0, "statusPage": "https://www.quantinuum.com/hardware/h1"}, + {"id": "quantinuum.sim.h1-1sc", "currentAvailability": "Available", "averageQueueTime": + 0, "statusPage": "https://www.quantinuum.com/hardware/h1"}, {"id": "quantinuum.qpu.h1-2", + "currentAvailability": "Unavailable", "averageQueueTime": 0, "statusPage": + "https://www.quantinuum.com/hardware/h1"}, {"id": "quantinuum.sim.h1-2sc", + "currentAvailability": "Unavailable", "averageQueueTime": 0, "statusPage": + "https://www.quantinuum.com/hardware/h1"}, {"id": "quantinuum.sim.h1-1e", + "currentAvailability": "Available", "averageQueueTime": 16, "statusPage": + "https://www.quantinuum.com/hardware/h1"}, {"id": "quantinuum.sim.h1-2e", + "currentAvailability": "Unavailable", "averageQueueTime": 0, "statusPage": + "https://www.quantinuum.com/hardware/h1"}]}, {"id": "rigetti", "currentAvailability": + "Degraded", "targets": [{"id": "rigetti.sim.qvm", "currentAvailability": "Available", + "averageQueueTime": 5, "statusPage": "https://rigetti.statuspage.io/"}, {"id": + "rigetti.qpu.aspen-m-3", "currentAvailability": "Unavailable", "averageQueueTime": + 0, "statusPage": null}, {"id": "rigetti.qpu.ankaa-9q-1", "currentAvailability": + "Available", "averageQueueTime": 5, "statusPage": "https://rigetti.statuspage.io/"}, + {"id": "rigetti.qpu.ankaa-2", "currentAvailability": "Available", "averageQueueTime": + 5, "statusPage": "https://rigetti.statuspage.io/"}]}], "nextLink": null}' + headers: + connection: + - keep-alive + content-length: + - '2' + content-type: + - application/json; charset=utf-8 + transfer-encoding: + - chunked + status: + code: 200 + message: OK +version: 1 diff --git a/azure-quantum/tests/unit/test_authentication.py b/azure-quantum/tests/unit/test_authentication.py index de3ea96d3..4bbf7d1fd 100644 --- a/azure-quantum/tests/unit/test_authentication.py +++ b/azure-quantum/tests/unit/test_authentication.py @@ -7,13 +7,22 @@ import json import os import time +import urllib3 import pytest -from common import QuantumTestBase +from common import ( + QuantumTestBase, + SUBSCRIPTION_ID, + RESOURCE_GROUP, + WORKSPACE, + LOCATION, + API_KEY, +) from azure.identity import ( CredentialUnavailableError, ClientSecretCredential, InteractiveBrowserCredential, ) +from azure.quantum import Workspace from azure.quantum._authentication import ( _TokenFileCredential, _DefaultAzureCredential, @@ -178,3 +187,51 @@ def test_workspace_auth_interactive_credential(self): workspace = self.create_workspace(credential=credential) targets = workspace.get_targets() self.assertGreater(len(targets), 1) + + def _get_current_primary_connection_string(self): + self.pause_recording() + http = urllib3.PoolManager() + connection_params = self.connection_params + url = (connection_params.arm_endpoint.rstrip('/') + + f"/subscriptions/{connection_params.subscription_id}" + + f"/resourceGroups/{connection_params.resource_group}" + + "/providers/Microsoft.Quantum" + + f"/workspaces/{connection_params.workspace_name}" + + "/listKeys?api-version=2023-11-13-preview") + credential = self.connection_params.get_credential_or_default() + scope = ConnectionConstants.ARM_CREDENTIAL_SCOPE + token = credential.get_token(scope).token + response = http.request( + method="POST", + url=url, + headers={ + "Authorization": f"Bearer {token}" + } + ) + self.assertEqual(response.status, 200) + connection_strings = json.loads(response.data.decode("utf-8")) + connection_string = connection_strings['primaryConnectionString'] + self.resume_recording() + return connection_string + + @pytest.mark.live_test + def test_workspace_auth_connection_string_api_key(self): + connection_string = "" + if self.is_playback: + connection_string = ConnectionConstants.VALID_CONNECTION_STRING( + subscription_id=SUBSCRIPTION_ID, + resource_group=RESOURCE_GROUP, + workspace_name=WORKSPACE, + api_key=API_KEY, + quantum_endpoint=ConnectionConstants.GET_QUANTUM_PRODUCTION_ENDPOINT(LOCATION) + ) + else: + connection_string = self._get_current_primary_connection_string() + + with patch.dict(os.environ): + self.clear_env_vars(os.environ) + workspace = Workspace.from_connection_string( + connection_string=connection_string, + ) + targets = workspace.get_targets() + self.assertGreater(len(targets), 1) diff --git a/azure-quantum/tests/unit/test_workspace.py b/azure-quantum/tests/unit/test_workspace.py index 2c7caf6db..a1e6884b6 100644 --- a/azure-quantum/tests/unit/test_workspace.py +++ b/azure-quantum/tests/unit/test_workspace.py @@ -19,6 +19,10 @@ EnvironmentVariables, ConnectionConstants, ) +from azure.core.credentials import AzureKeyCredential +from azure.core.pipeline.policies import AzureKeyCredentialPolicy +from azure.identity import EnvironmentCredential + SIMPLE_RESOURCE_ID = ConnectionConstants.VALID_RESOURCE_ID( subscription_id=SUBSCRIPTION_ID, @@ -26,6 +30,14 @@ workspace_name=WORKSPACE, ) +SIMPLE_CONNECTION_STRING = ConnectionConstants.VALID_CONNECTION_STRING( + subscription_id=SUBSCRIPTION_ID, + resource_group=RESOURCE_GROUP, + workspace_name=WORKSPACE, + api_key=API_KEY, + quantum_endpoint=ConnectionConstants.GET_QUANTUM_PRODUCTION_ENDPOINT(LOCATION) +) + class TestWorkspace(QuantumTestBase): def test_create_workspace_instance_valid(self): @@ -76,6 +88,123 @@ def test_create_workspace_locations(self): ) self.assertEqual(ws.location, "eastus") + def test_env_connection_string(self): + with mock.patch.dict(os.environ): + self.clear_env_vars(os.environ) + os.environ[EnvironmentVariables.CONNECTION_STRING] = SIMPLE_CONNECTION_STRING + + workspace = Workspace() + self.assertEqual(workspace.location, LOCATION) + self.assertEqual(workspace.subscription_id, SUBSCRIPTION_ID) + self.assertEqual(workspace.name, WORKSPACE) + self.assertEqual(workspace.resource_group, RESOURCE_GROUP) + self.assertIsInstance(workspace.credential, AzureKeyCredential) + self.assertEqual(workspace.credential.key, API_KEY) + # pylint: disable=protected-access + self.assertIsInstance( + workspace._client._config.authentication_policy, + AzureKeyCredentialPolicy) + auth_policy = workspace._client._config.authentication_policy + self.assertEqual(auth_policy._name, ConnectionConstants.QUANTUM_API_KEY_HEADER) + self.assertEqual(id(auth_policy._credential), + id(workspace.credential)) + + def test_workspace_from_connection_string(self): + with mock.patch.dict( + os.environ, + clear=True + ): + workspace = Workspace.from_connection_string(SIMPLE_CONNECTION_STRING) + self.assertEqual(workspace.location, LOCATION) + self.assertIsInstance(workspace.credential, AzureKeyCredential) + self.assertEqual(workspace.credential.key, API_KEY) + # pylint: disable=protected-access + self.assertIsInstance( + workspace._client._config.authentication_policy, + AzureKeyCredentialPolicy) + auth_policy = workspace._client._config.authentication_policy + self.assertEqual(auth_policy._name, ConnectionConstants.QUANTUM_API_KEY_HEADER) + self.assertEqual(id(auth_policy._credential), + id(workspace.credential)) + + # assert that the connection string environment variable + # does not overwrite values that were set + # via the other environment variables + with mock.patch.dict(os.environ): + self.clear_env_vars(os.environ) + + wrong_subscription_id = "00000000-2BAD-2BAD-2BAD-000000000000" + wrong_resource_group = "wrongrg" + wrong_workspace = "wrong-workspace" + wrong_location = "wrong-location" + + # make sure the values above are really different from the default values + self.assertNotEqual(wrong_subscription_id, SUBSCRIPTION_ID) + self.assertNotEqual(wrong_resource_group, RESOURCE_GROUP) + self.assertNotEqual(wrong_workspace, WORKSPACE) + self.assertNotEqual(wrong_location, LOCATION) + + wrong_connection_string = ConnectionConstants.VALID_CONNECTION_STRING( + subscription_id=wrong_subscription_id, + resource_group=wrong_resource_group, + workspace_name=wrong_workspace, + api_key=API_KEY, + quantum_endpoint=ConnectionConstants.GET_QUANTUM_PRODUCTION_ENDPOINT(wrong_location) + ) + + os.environ[EnvironmentVariables.CONNECTION_STRING] = wrong_connection_string + os.environ[EnvironmentVariables.LOCATION] = LOCATION + os.environ[EnvironmentVariables.SUBSCRIPTION_ID] = SUBSCRIPTION_ID + os.environ[EnvironmentVariables.RESOURCE_GROUP] = RESOURCE_GROUP + os.environ[EnvironmentVariables.WORKSPACE_NAME] = WORKSPACE + + workspace = Workspace() + self.assertEqual(workspace.location, LOCATION) + self.assertEqual(workspace.subscription_id, SUBSCRIPTION_ID) + self.assertEqual(workspace.resource_group, RESOURCE_GROUP) + self.assertEqual(workspace.name, WORKSPACE) + # since no credential was passed, we will use the api-key + # credential from the connection string + self.assertIsInstance(workspace.credential, AzureKeyCredential) + + # if we pass a credential, then it should be used + workspace = Workspace(credential=EnvironmentCredential()) + self.assertIsInstance(workspace.credential, EnvironmentCredential) + + # the connection string passed as a parameter should override the + # connection string from the env var + self.clear_env_vars(os.environ) + os.environ[EnvironmentVariables.CONNECTION_STRING] = wrong_connection_string + connection_string = ConnectionConstants.VALID_CONNECTION_STRING( + subscription_id=SUBSCRIPTION_ID, + resource_group=RESOURCE_GROUP, + workspace_name=WORKSPACE, + api_key=API_KEY, + quantum_endpoint=ConnectionConstants.GET_QUANTUM_PRODUCTION_ENDPOINT(LOCATION) + ) + workspace = Workspace.from_connection_string(connection_string=connection_string) + self.assertEqual(workspace.location, LOCATION) + self.assertEqual(workspace.subscription_id, SUBSCRIPTION_ID) + self.assertEqual(workspace.resource_group, RESOURCE_GROUP) + self.assertEqual(workspace.name, WORKSPACE) + + # the connection string in the env var should not be parsed if we + # don't really need it + self.clear_env_vars(os.environ) + os.environ[EnvironmentVariables.CONNECTION_STRING] = "bad-connection-string" + connection_string = ConnectionConstants.VALID_CONNECTION_STRING( + subscription_id=SUBSCRIPTION_ID, + resource_group=RESOURCE_GROUP, + workspace_name=WORKSPACE, + api_key=API_KEY, + quantum_endpoint=ConnectionConstants.GET_QUANTUM_PRODUCTION_ENDPOINT(LOCATION) + ) + workspace = Workspace.from_connection_string(connection_string=connection_string) + self.assertEqual(workspace.location, LOCATION) + self.assertEqual(workspace.subscription_id, SUBSCRIPTION_ID) + self.assertEqual(workspace.resource_group, RESOURCE_GROUP) + self.assertEqual(workspace.name, WORKSPACE) + def test_create_workspace_instance_invalid(self): def assert_value_error(exception): self.assertIn("Azure Quantum workspace not fully specified.",