diff --git a/azure-quantum/azure/quantum/workspace.py b/azure-quantum/azure/quantum/workspace.py index ac7d54ca..3b791bbe 100644 --- a/azure-quantum/azure/quantum/workspace.py +++ b/azure-quantum/azure/quantum/workspace.py @@ -159,9 +159,6 @@ def __init__( self._connection_params = connection_params self._storage = storage - self._subscription_id = connection_params.subscription_id - self._resource_group = connection_params.resource_group - self._workspace_name = connection_params.workspace_name if not self._mgmt_client: credential = connection_params.get_credential_or_default() @@ -404,9 +401,9 @@ def _get_linked_storage_sas_uri( container_name=container_name, blob_name=blob_name ) container_uri = client.get_sas_uri( - self._subscription_id, - self._resource_group, - self._workspace_name, + self.subscription_id, + self.resource_group, + self.name, blob_details=blob_details) logger.debug("Container URI from service: %s", container_uri) @@ -424,9 +421,9 @@ def submit_job(self, job: Job) -> Job: """ client = self._get_jobs_client() details = client.create_or_replace( - self._subscription_id, - self._resource_group, - self._workspace_name, + self.subscription_id, + self.resource_group, + self.name, job.details.id, job.details ) @@ -445,14 +442,14 @@ def cancel_job(self, job: Job) -> Job: """ client = self._get_jobs_client() client.delete( - self._subscription_id, - self._resource_group, - self._workspace_name, + self.subscription_id, + self.resource_group, + self.name, job.details.id) details = client.get( - self._subscription_id, - self._resource_group, - self._workspace_name, + self.subscription_id, + self.resource_group, + self.name, job.id) return Job(self, details) @@ -472,9 +469,9 @@ def get_job(self, job_id: str) -> Job: client = self._get_jobs_client() details = client.get( - self._subscription_id, - self._resource_group, - self._workspace_name, + self.subscription_id, + self.resource_group, + self.name, job_id) target_factory = TargetFactory(base_cls=Target, workspace=self) # pylint: disable=protected-access @@ -557,7 +554,7 @@ def list_jobs_paginated( ) orderby = self._create_orderby(orderby_property, is_asc) - return client.list(subscription_id=self.subscription_id, resource_group_name=self.resource_group, workspace_name=self._workspace_name, filter=job_filter, orderby=orderby, top = top, skip = skip) + return client.list(subscription_id=self.subscription_id, resource_group_name=self.resource_group, workspace_name=self.name, filter=job_filter, orderby=orderby, top = top, skip = skip) def _get_target_status( self, @@ -580,9 +577,9 @@ def _get_target_status( return [ (provider.id, target) for provider in self._client.providers.list( - self._subscription_id, - self._resource_group, - self._workspace_name) + self.subscription_id, + self.resource_group, + self.name) for target in provider.targets if (provider_id is None or provider.id.lower() == provider_id.lower()) and (name is None or target.id.lower() == name.lower()) @@ -639,9 +636,9 @@ def get_quotas(self) -> List[Dict[str, Any]]: """ client = self._get_quotas_client() return [q.as_dict() for q in client.list( - self._subscription_id, - self._resource_group, - self._workspace_name + self.subscription_id, + self.resource_group, + self.name )] def list_top_level_items( @@ -712,7 +709,7 @@ def list_top_level_items_paginated( ) orderby = self._create_orderby(orderby_property, is_asc) - return client.list(subscription_id=self.subscription_id, resource_group_name=self.resource_group, workspace_name=self._workspace_name, filter=top_level_item_filter, orderby=orderby, top = top, skip = skip) + return client.list(subscription_id=self.subscription_id, resource_group_name=self.resource_group, workspace_name=self.name, filter=top_level_item_filter, orderby=orderby, top = top, skip = skip) def list_sessions( self, @@ -773,7 +770,7 @@ def list_sessions_paginated( orderby = self._create_orderby(orderby_property=orderby_property, is_asc=is_asc) - return client.list(subscription_id=self.subscription_id, resource_group_name=self.resource_group, workspace_name=self._workspace_name, filter = session_filter, orderby=orderby, skip=skip, top=top) + return client.list(subscription_id=self.subscription_id, resource_group_name=self.resource_group, workspace_name=self.name, filter = session_filter, orderby=orderby, skip=skip, top=top) def open_session( self, @@ -790,9 +787,9 @@ def open_session( """ client = self._get_sessions_client() session.details = client.create_or_replace( - self._subscription_id, - self._resource_group, - self._workspace_name, + self.subscription_id, + self.resource_group, + self.name, session.id, session.details) @@ -811,15 +808,15 @@ def close_session( client = self._get_sessions_client() if not session.is_in_terminal_state(): session.details = client.close( - self._subscription_id, - self._resource_group, - self._workspace_name, + self.subscription_id, + self.resource_group, + self.name, session_id=session.id) else: session.details = client.get( - self._subscription_id, - self._resource_group, - self._workspace_name, + self.subscription_id, + self.resource_group, + self.name, session_id=session.id) if session.target: @@ -855,9 +852,9 @@ def get_session( """ client = self._get_sessions_client() session_details = client.get( - self._subscription_id, - self._resource_group, - self._workspace_name, + self.subscription_id, + self.resource_group, + self.name, session_id=session_id) result = Session(workspace=self, details=session_details) return result @@ -919,7 +916,7 @@ def list_session_jobs_paginated( orderby = self._create_orderby(orderby_property=orderby_property, is_asc=is_asc) - return client.jobs_list(subscription_id=self.subscription_id, resource_group_name=self.resource_group, workspace_name=self._workspace_name, session_id=session_id, filter = session_job_filter, orderby=orderby, skip=skip, top=top) + return client.jobs_list(subscription_id=self.subscription_id, resource_group_name=self.resource_group, workspace_name=self.name, session_id=session_id, filter = session_job_filter, orderby=orderby, skip=skip, top=top) def get_container_uri( self, diff --git a/azure-quantum/tests/unit/recordings/test_workspace_by_name.yaml b/azure-quantum/tests/unit/recordings/test_workspace_by_name.yaml new file mode 100644 index 00000000..5d70c08d --- /dev/null +++ b/azure-quantum/tests/unit/recordings/test_workspace_by_name.yaml @@ -0,0 +1,276 @@ +interactions: +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - azsdk-python-identity/1.14.0 Python/3.13.9 (Windows-11-10.0.26200-SP0) + method: GET + uri: https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0/.well-known/openid-configuration + response: + body: + string: '{"token_endpoint": "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/v2.0/token", + "token_endpoint_auth_methods_supported": ["client_secret_post", "private_key_jwt", + "client_secret_basic"], "jwks_uri": "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/discovery/v2.0/keys", + "response_modes_supported": ["query", "fragment", "form_post"], "subject_types_supported": + ["pairwise"], "id_token_signing_alg_values_supported": ["RS256"], "response_types_supported": + ["code", "id_token", "code id_token", "id_token token"], "scopes_supported": + ["openid", "profile", "email", "offline_access"], "issuer": "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/v2.0", + "request_uri_parameter_supported": false, "userinfo_endpoint": "https://graph.microsoft.com/oidc/userinfo", + "authorization_endpoint": "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/v2.0/authorize", + "device_authorization_endpoint": "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/v2.0/devicecode", + "http_logout_supported": true, "frontchannel_logout_supported": true, "end_session_endpoint": + "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/v2.0/logout", + "claims_supported": ["sub", "iss", "cloud_instance_name", "cloud_instance_host_name", + "cloud_graph_host_name", "msgraph_host", "aud", "exp", "iat", "auth_time", + "acr", "nonce", "preferred_username", "name", "tid", "ver", "at_hash", "c_hash", + "email"], "kerberos_endpoint": "https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/kerberos", + "tenant_region_scope": "NA", "cloud_instance_name": "microsoftonline.com", + "cloud_graph_host_name": "graph.windows.net", "msgraph_host": "graph.microsoft.com", + "rbac_url": "https://pas.windows.net"}' + headers: + content-type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: 'client_id=00000000-0000-0000-0000-000000000000&grant_type=client_credentials&client_secret=PLACEHOLDER&scope=https%3A%2F%2Fmanagement.azure.com%2F.default' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '156' + Content-Type: + - application/x-www-form-urlencoded + User-Agent: + - azsdk-python-identity/1.14.0 Python/3.13.9 (Windows-11-10.0.26200-SP0) + method: POST + uri: https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/v2.0/token + response: + body: + string: '{"token_type": "Bearer", "expires_in": 3599, "ext_expires_in": 3599, + "access_token": "fake_token_for_testing_purposes_only"}' + headers: + content-type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: 'client_id=00000000-0000-0000-0000-000000000000&grant_type=client_credentials&client_secret=PLACEHOLDER&scope=https%3A%2F%2Fquantum.microsoft.com%2F.default' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '153' + Content-Type: + - application/x-www-form-urlencoded + User-Agent: + - azsdk-python-identity/1.14.0 Python/3.13.9 (Windows-11-10.0.26200-SP0) + method: POST + uri: https://login.microsoftonline.com/00000000-0000-0000-0000-000000000000/oauth2/v2.0/token + response: + body: + string: '{"token_type": "Bearer", "expires_in": 3599, "ext_expires_in": 3599, + "access_token": "fake_token_for_testing_purposes_only"}' + headers: + content-type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: 'b''{"query": "\\n Resources\\n | where type =~ \''microsoft.quantum/workspaces\''\\n | + where name =~ \''myworkspace\''\\n \\n | extend endpointUri + = tostring(properties.endpointUri)\\n | project name, subscriptionId, + resourceGroup, location, endpointUri\\n "}''' + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '325' + Content-Type: + - application/json + User-Agent: + - azsdk-python-quantum/3.6.0b1 Python/3.13.9 (Windows-11-10.0.26200-SP0) + method: POST + uri: https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2021-03-01 + response: + body: + string: '{"totalRecords": 1, "count": 1, "data": [{"name": "myworkspace", "subscriptionId": + "00000000-0000-0000-0000-000000000000", "resourceGroup": "myresourcegroup", "location": + "eastus", "endpointUri": "https://myworkspace.eastus.quantum.azure.com"}], + "facets": [], "resultTruncated": "false"}' + headers: + content-length: + - '296' + content-type: + - application/json; charset=utf-8 + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - azsdk-python-quantum/3.6.0b1 Python/3.13.9 (Windows-11-10.0.26200-SP0) + method: GET + uri: https://myworkspace.eastus.quantum.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myresourcegroup/providers/Microsoft.Quantum/workspaces/myworkspace/providerStatus?api-version=2024-10-01-preview&test-sequence-id=1 + response: + body: + string: '{"value": [{"id": "ionq", "currentAvailability": "Available", + "targets": [{"id": "ionq.simulator", "currentAvailability": "Available", + "averageQueueTime": 261128, "statusPage": "https://status.ionq.co"}, {"id": "ionq.qpu.aria-1", + "currentAvailability": "Available", "averageQueueTime": 150000, "statusPage": + "https://status.ionq.co"}]}], "nextLink": null}' + headers: + connection: + - keep-alive + content-length: + - '312' + content-type: + - application/json; charset=utf-8 + transfer-encoding: + - chunked + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - azsdk-python-quantum/3.6.0b1 Python/3.13.9 (Windows-11-10.0.26200-SP0) + method: GET + uri: https://myworkspace.eastus.quantum.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myresourcegroup/providers/Microsoft.Quantum/workspaces/myworkspace/jobs?api-version=2024-10-01-preview&skip=0&top=100&test-sequence-id=1 + response: + body: + string: '{"value": [], "nextLink": null}' + headers: + connection: + - keep-alive + content-length: + - '31' + content-type: + - application/json; charset=utf-8 + transfer-encoding: + - chunked + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - azsdk-python-quantum/3.6.0b1 Python/3.13.9 (Windows-11-10.0.26200-SP0) + method: GET + uri: https://myworkspace.eastus.quantum.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myresourcegroup/providers/Microsoft.Quantum/workspaces/myworkspace/quotas?api-version=2024-10-01-preview&test-sequence-id=1 + response: + body: + string: '{"value": [{"dimension": "qgs", "scope": "Workspace", "providerId": + "ionq", "utilization": 0.0, "holds": 0.0, "limit": 1000.0, "period": + "Monthly"}], "nextLink": null}' + headers: + connection: + - keep-alive + content-length: + - '156' + content-type: + - application/json; charset=utf-8 + transfer-encoding: + - chunked + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - azsdk-python-quantum/3.6.0b1 Python/3.13.9 (Windows-11-10.0.26200-SP0) + method: GET + uri: https://myworkspace.eastus.quantum.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myresourcegroup/providers/Microsoft.Quantum/workspaces/myworkspace/topLevelItems?api-version=2024-10-01-preview&skip=0&top=100&test-sequence-id=1 + response: + body: + string: '{"value": [], "nextLink": null}' + headers: + connection: + - keep-alive + content-length: + - '31' + content-type: + - application/json; charset=utf-8 + transfer-encoding: + - chunked + status: + code: 200 + message: OK +- request: + body: null + headers: + Accept: + - application/json + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + User-Agent: + - azsdk-python-quantum/3.6.0b1 Python/3.13.9 (Windows-11-10.0.26200-SP0) + method: GET + uri: https://myworkspace.eastus.quantum.azure.com/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/myresourcegroup/providers/Microsoft.Quantum/workspaces/myworkspace/sessions?api-version=2024-10-01-preview&skip=0&top=100&test-sequence-id=1 + response: + body: + string: '{"value": [], "nextLink": null}' + headers: + connection: + - keep-alive + content-length: + - '31' + 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 eb1d249d..d95e71fe 100644 --- a/azure-quantum/tests/unit/test_authentication.py +++ b/azure-quantum/tests/unit/test_authentication.py @@ -86,13 +86,8 @@ def _get_workspace(self, token: str): "Authorization": f"Bearer {token}" } ) - self.assertEqual(response.status, 200, - f""" - {url} failed with error code {response.status}. - Make sure the environment variables are correctly - set with the workspace connection parameters. - """) - workspace = json.loads(response.data.decode("utf-8")) + response_data = self._assert_arm_request_status(response, url, 200, "Make sure the environment variables are correctly set with the workspace connection parameters.") + workspace = json.loads(response_data) return workspace def _enable_workspace_api_keys(self, token: str, workspace: dict, enable_api_keys: bool): @@ -118,11 +113,7 @@ def _enable_workspace_api_keys(self, token: str, workspace: dict, enable_api_key }, body=workspace_json ) - self.assertEqual(response.status, 201, - f""" - {url} failed with error code {response.status}. - Failed to enable/disable api key. - """) + self._assert_arm_request_status(response, url, 201, "Failed to enable/disable api key") def _get_current_primary_connection_string(self, token: str): # list keys @@ -142,13 +133,8 @@ def _get_current_primary_connection_string(self, token: str): "Authorization": f"Bearer {token}" } ) - self.assertEqual(response.status, 200, - f""" - {url} failed with error code {response.status}. - Make sure the environment variables are correctly - set with the workspace connection parameters. - """) - connection_strings = json.loads(response.data.decode("utf-8")) + response_data = self._assert_arm_request_status(response, url, 200, "Make sure the environment variables are correctly set with the workspace connection parameters.") + connection_strings = json.loads(response_data) self.assertTrue(connection_strings['apiKeyEnabled'], f""" API-Key is not enabled in workspace {resource_id} @@ -209,3 +195,21 @@ def test_workspace_auth_connection_string_api_key(self): workspace.list_jobs_paginated().next() self.assertIn("Unauthorized", context.exception.message) + + def _assert_arm_request_status(self, response: urllib3.response.BaseHTTPResponse, url: str, expected_status: int, msg: str) -> str: + """Checks that response has the expected status and returns the response data.""" + response_data = response.data.decode("utf-8") + if response.status in (408, 429, 500, 502, 503, 504): + error_message = f""" + Request to ARM failed, please try again later. + {url} failed with code {response.status}. + Message: {response_data} + """ + else: + error_message = f""" + {url} failed with error {response.status}: {response_data}. + {msg} + """ + + self.assertEqual(response.status, expected_status, error_message) + return response_data diff --git a/azure-quantum/tests/unit/test_workspace.py b/azure-quantum/tests/unit/test_workspace.py index 8f97a6ca..4956520f 100644 --- a/azure-quantum/tests/unit/test_workspace.py +++ b/azure-quantum/tests/unit/test_workspace.py @@ -7,6 +7,7 @@ import pytest from common import ( QuantumTestBase, + TENANT_ID, SUBSCRIPTION_ID, RESOURCE_GROUP, WORKSPACE, @@ -24,7 +25,7 @@ ) from azure.core.credentials import AzureKeyCredential from azure.core.pipeline.policies import AzureKeyCredentialPolicy -from azure.identity import EnvironmentCredential +from azure.identity import EnvironmentCredential, ClientSecretCredential SIMPLE_RESOURCE_ID = ConnectionConstants.VALID_RESOURCE_ID( @@ -457,6 +458,36 @@ def test_workspace_list_jobs(self): ws = self.create_workspace() jobs = ws.list_jobs() self.assertIsInstance(jobs, list) + + @pytest.mark.live_test + def test_workspace_by_name(self): + workspace_name = os.environ.get(EnvironmentVariables.WORKSPACE_NAME) + if self.is_playback: + workspace_name = WORKSPACE + + with mock.patch.dict(os.environ): + self.clear_env_vars(os.environ) + credential = None + if self.is_playback: + credential = ClientSecretCredential( + tenant_id=TENANT_ID, + client_id=ZERO_UID, + client_secret=PLACEHOLDER) + + workspace = Workspace( + name=workspace_name, + credential=credential, + ) + targets = workspace.get_targets() + self.assertGreater(len(targets), 1) + jobs = workspace.list_jobs() + self.assertIsInstance(jobs, list) + quotas = workspace.get_quotas() + self.assertGreater(len(quotas), 0) + top_level_items = workspace.list_top_level_items() + self.assertIsInstance(top_level_items, list) + sessions = workspace.list_sessions() + self.assertIsInstance(sessions, list) def test_workspace_user_agent_appid(self): app_id = "MyEnvVarAppId"