diff --git a/azure-quantum/azure/quantum/workspace.py b/azure-quantum/azure/quantum/workspace.py index 0ad43032..5ea1e6f9 100644 --- a/azure-quantum/azure/quantum/workspace.py +++ b/azure-quantum/azure/quantum/workspace.py @@ -10,6 +10,7 @@ from __future__ import annotations from datetime import datetime import logging +import warnings from urllib.parse import quote from typing import ( Any, @@ -71,12 +72,12 @@ class Workspace: 2. specify a valid subscription ID, resource group, and workspace name; or 3. specify a valid workspace name. - You can also use a connection string to specify the connection parameters - to an Azure Quantum Workspace by calling + You can also use an Azure Quantum workspace authentication connection + string to specify the connection parameters by calling :obj:`~ 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. + Azure Quantum workspaces require linked storage. Configure linked storage + on the workspace instead of passing a storage account connection string. :param subscription_id: The Azure subscription ID. @@ -91,9 +92,9 @@ class Workspace: Ignored if resource_id is specified. :param storage: - The Azure storage account connection string. - Required only if the specified Azure Quantum - workspace does not have linked storage. + Deprecated. The Azure storage account connection string. + Configure workspace-linked storage instead. This parameter will be + removed in azure-quantum 4.0.0. :param resource_id: The resource ID of the Azure Quantum workspace. @@ -120,6 +121,10 @@ class Workspace: _QUANTUM_ENDPOINT_PARAM = '_quantum_endpoint' _WORKSPACE_KIND_PARAM = '_workspace_kind' _MGMT_CLIENT_PARAM = '_mgmt_client' + _STORAGE_DEPRECATION_MESSAGE = ( + "Workspace(storage=...) is deprecated and will be removed in " + "azure-quantum 4.0.0. Configure workspace-linked storage instead." + ) def __init__( self, @@ -133,6 +138,13 @@ def __init__( user_agent: Optional[str] = None, **kwargs: Any, ) -> None: + if storage is not None: + warnings.warn( + Workspace._STORAGE_DEPRECATION_MESSAGE, + DeprecationWarning, + stacklevel=2, + ) + # Extract internal params before passing kwargs to WorkspaceConnectionParams # Param to track whether the workspace was created from a connection string from_connection_string = kwargs.pop(Workspace._FROM_CONNECTION_STRING_PARAM, False) @@ -254,12 +266,16 @@ def credential(self) -> Any: return self._connection_params.credential @property - def storage(self) -> str: + def storage(self) -> Optional[str]: """ - Returns the Azure Storage account name associated with the Quantum Workspace. + Returns the explicitly configured Azure storage account connection string. - :return: Azure Storage account name. - :rtype: str + This property is retained for compatibility with the deprecated + ``Workspace(storage=...)`` parameter. Configure workspace-linked storage + instead. + + :return: Azure storage account connection string, or ``None`` if one was not configured. + :rtype: typing.Optional[str] """ return self._storage @@ -312,10 +328,12 @@ def append_user_agent(self, value: str) -> None: @classmethod def from_connection_string(cls, connection_string: str, **kwargs) -> Workspace: """ - Creates a new Azure Quantum Workspace client from a connection string. + Creates a new Azure Quantum Workspace client from an Azure Quantum + workspace authentication connection string. :param connection_string: - A valid connection string, usually obtained from the + A valid Azure Quantum workspace authentication connection string, + distinct from a storage account connection string and usually obtained from the `Quantum Workspace -> Operations -> Access Keys` blade in the Azure Portal. :return: New Azure Quantum Workspace client. diff --git a/azure-quantum/tests/test_workspace.py b/azure-quantum/tests/test_workspace.py index 4fc0151f..cff17f44 100644 --- a/azure-quantum/tests/test_workspace.py +++ b/azure-quantum/tests/test_workspace.py @@ -4,11 +4,14 @@ ## import pytest +import inspect import os +import warnings from unittest import mock from azure.quantum.job.job import Job from azure.quantum._client.models import JobDetails from azure.quantum import Priority +from azure.quantum.workspace import Workspace from azure.quantum._constants import EnvironmentVariables, ConnectionConstants from azure.core.credentials import AzureKeyCredential from azure.core.pipeline.policies import AzureKeyCredentialPolicy @@ -47,6 +50,11 @@ quantum_endpoint=ConnectionConstants.GET_QUANTUM_PRODUCTION_ENDPOINT_v2(LOCATION) ) +STORAGE_DEPRECATION_WARNING = ( + "Workspace(storage=...) is deprecated and will be removed in " + "azure-quantum 4.0.0. Configure workspace-linked storage instead." +) + def test_create_workspace_instance_valid(): def assert_all_required_params(ws: WorkspaceMock): @@ -63,12 +71,13 @@ def assert_all_required_params(ws: WorkspaceMock): ) assert_all_required_params(ws) - ws = WorkspaceMock( - subscription_id=SUBSCRIPTION_ID, - resource_group=RESOURCE_GROUP, - name=WORKSPACE, - storage=STORAGE, - ) + with pytest.warns(DeprecationWarning): + ws = WorkspaceMock( + subscription_id=SUBSCRIPTION_ID, + resource_group=RESOURCE_GROUP, + name=WORKSPACE, + storage=STORAGE, + ) assert_all_required_params(ws) assert ws.storage == STORAGE @@ -77,10 +86,11 @@ def assert_all_required_params(ws: WorkspaceMock): ) assert_all_required_params(ws) - ws = WorkspaceMock( - resource_id=SIMPLE_RESOURCE_ID, - storage=STORAGE, - ) + with pytest.warns(DeprecationWarning): + ws = WorkspaceMock( + resource_id=SIMPLE_RESOURCE_ID, + storage=STORAGE, + ) assert_all_required_params(ws) assert ws.storage == STORAGE @@ -89,10 +99,11 @@ def assert_all_required_params(ws: WorkspaceMock): ) assert_all_required_params(ws) - ws = WorkspaceMock( - name=WORKSPACE, - storage=STORAGE, - ) + with pytest.warns(DeprecationWarning): + ws = WorkspaceMock( + name=WORKSPACE, + storage=STORAGE, + ) assert_all_required_params(ws) assert ws.storage == STORAGE @@ -129,6 +140,48 @@ def assert_all_required_params(ws: WorkspaceMock): assert_all_required_params(ws) +def test_workspace_storage_parameter_is_deprecated(): + with pytest.warns(DeprecationWarning) as warning_info: + workspace = WorkspaceMock( + subscription_id=SUBSCRIPTION_ID, + resource_group=RESOURCE_GROUP, + name=WORKSPACE, + storage=STORAGE, + ) + + assert str(warning_info[0].message) == STORAGE_DEPRECATION_WARNING + source_lines, start_line = inspect.getsourcelines(WorkspaceMock.__init__) + caller_offset = next( + index for index, line in enumerate(source_lines) + if "super().__init__" in line + ) + assert warning_info[0].filename == inspect.getsourcefile(WorkspaceMock.__init__) + assert warning_info[0].lineno == start_line + caller_offset + assert workspace.storage == STORAGE + + +@pytest.mark.parametrize("workspace_kwargs", [{}, {"storage": None}]) +def test_workspace_without_storage_does_not_warn(workspace_kwargs): + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + workspace = WorkspaceMock( + subscription_id=SUBSCRIPTION_ID, + resource_group=RESOURCE_GROUP, + name=WORKSPACE, + **workspace_kwargs, + ) + + assert workspace.storage is None + + +def test_workspace_from_connection_string_does_not_warn_about_storage(): + with mock.patch.dict(os.environ, clear=True), warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + workspace = WorkspaceMock.from_connection_string(SIMPLE_CONNECTION_STRING) + + assert workspace.name == WORKSPACE + + def test_create_workspace_locations(): # Location name should be normalized _mgmt_client = MockWorkspaceMgmtClient() @@ -714,3 +767,23 @@ def test_get_container_uri_uses_linked_storage_sas_when_storage_none(): assert isinstance(uri, str) assert "https://example.com/" in uri assert "sas-token" in uri + + +def test_get_container_uri_uses_explicit_storage_connection_string(): + with pytest.warns(DeprecationWarning): + ws = WorkspaceMock( + subscription_id=SUBSCRIPTION_ID, + resource_group=RESOURCE_GROUP, + name=WORKSPACE, + storage=STORAGE, + ) + + with mock.patch( + "azure.quantum.workspace.get_container_uri", + return_value="https://example.com/container?sas-token", + ) as mock_get_container_uri: + # WorkspaceMock overrides get_container_uri for offline tests; call the real implementation directly. + uri = Workspace.get_container_uri(ws, job_id="job-123") + + mock_get_container_uri.assert_called_once_with(STORAGE, "job-job-123") + assert uri == "https://example.com/container?sas-token"