diff --git a/CHANGELOG.md b/CHANGELOG.md index d65704d5..955d3076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to this project will be documented in this file. +## [0.12.3] +_released `2024-10-02`_ + +### ✨ Added + - Allow unauthenticated users to access public device specifications + ## [0.12.2] - 2024-09-11 ### Changed diff --git a/pasqal_cloud/__init__.py b/pasqal_cloud/__init__.py index 47bd16ba..0ea656f4 100644 --- a/pasqal_cloud/__init__.py +++ b/pasqal_cloud/__init__.py @@ -75,6 +75,16 @@ def __init__( email/password combination or a TokenProvider instance. You may omit the password, you will then be prompted to enter one. + + The SDK can be initialized with several authentication options: + - Option 1: No arguments -> Allows unauthenticated access to public + features. + - Option 2: `username` and `password` -> Authenticated access using a + username and password. + - Option 3: `username` only -> Prompts for password during initialization. + - Option 4 (for developers): Provide a custom `token_provider` for + token-based authentication. + Args: username: Email of the user to login as. password: Password of the user to login as. @@ -85,10 +95,6 @@ def __init__( auth0: Auth0Config object to define the auth0 tenant to target. project_id: ID of the owner project of the batch. """ - - if not project_id: - raise ValueError("You need to provide a project_id") - self._client = Client( project_id=project_id, username=username, diff --git a/pasqal_cloud/_version.py b/pasqal_cloud/_version.py index 28eaf004..2e1f4a13 100644 --- a/pasqal_cloud/_version.py +++ b/pasqal_cloud/_version.py @@ -13,4 +13,4 @@ # limitations under the License. -__version__ = "0.12.2" +__version__ = "0.12.3" diff --git a/pasqal_cloud/client.py b/pasqal_cloud/client.py index 09366fd3..f28008e2 100644 --- a/pasqal_cloud/client.py +++ b/pasqal_cloud/client.py @@ -44,33 +44,41 @@ class EmptyFilter: class Client: - authenticator: AuthBase + authenticator: AuthBase | None def __init__( self, - project_id: str, + project_id: str | None = None, username: Optional[str] = None, password: Optional[str] = None, token_provider: Optional[TokenProvider] = None, endpoints: Optional[Endpoints] = None, auth0: Optional[Auth0Conf] = None, ): - if not username and not token_provider: - raise ValueError( - "At least a username or TokenProvider object should be provided." - ) + self.endpoints = self._make_endpoints(endpoints) + self._project_id = project_id + self.user_agent = f"PasqalCloudSDK/{sdk_version}" + if token_provider is not None: self._check_token_provider(token_provider) - self.endpoints = self._make_endpoints(endpoints) - if username: auth0 = self._make_auth0(auth0) token_provider = self._credential_login(username, password, auth0) - self.authenticator = HTTPBearerAuthenticator(token_provider) - self.project_id = project_id - self.user_agent = f"PasqalCloudSDK/{sdk_version}" + self.authenticator = None + if token_provider: + self.authenticator = HTTPBearerAuthenticator(token_provider) + + @property + def project_id(self) -> str: + if not self._project_id: + raise ValueError("You need to set a project_id.") + return self._project_id + + @project_id.setter + def project_id(self, project_id: str) -> None: + self._project_id = project_id @staticmethod def _make_endpoints(endpoints: Optional[Endpoints]) -> Endpoints: @@ -120,6 +128,12 @@ def _authenticated_request( payload: Optional[Union[Mapping, Sequence[Mapping]]] = None, params: Optional[Mapping[str, Any]] = None, ) -> JSendPayload: + if self.authenticator is None: + raise ValueError( + "Authentication required. Please provide your credentials when" + " initializing the client." + ) + resp = requests.request( method, url, @@ -325,7 +339,17 @@ def cancel_workload(self, workload_id: str) -> Dict[str, Any]: return response def get_device_specs_dict(self) -> Dict[str, str]: - response: Dict[str, str] = self._authenticated_request( - "GET", f"{self.endpoints.core}/api/v1/devices/specs" - )["data"] - return response + if self.authenticator is not None: + response: Dict[str, str] = self._authenticated_request( + "GET", f"{self.endpoints.core}/api/v1/devices/specs" + )["data"] + return response + return self.get_public_device_specs() + + def get_public_device_specs(self) -> Dict[str, str]: + response = requests.request( + "GET", f"{self.endpoints.core}/api/v1/devices/public-specs" + ) + response.raise_for_status() + devices = response.json()["data"] + return {device["device_type"]: device["specs"] for device in devices} diff --git a/tests/fixtures/api/v1/devices/public-specs/_.GET.json b/tests/fixtures/api/v1/devices/public-specs/_.GET.json new file mode 100644 index 00000000..39ed9ba6 --- /dev/null +++ b/tests/fixtures/api/v1/devices/public-specs/_.GET.json @@ -0,0 +1,12 @@ +{ + "code": 200, + "data": + [ + { + "device_type": "FRESNEL", + "specs": "{\"version\":\"1\",\"channels\":[],\"name\":\"device\"}" + } + ], + "message": "OK.", + "status": "success" +} diff --git a/tests/test_client.py b/tests/test_client.py index 9dd6c2ba..09316487 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,13 +7,7 @@ import requests_mock from auth0.v3.exceptions import Auth0Error -from pasqal_cloud import ( - AUTH0_CONFIG, - Auth0Conf, - Endpoints, - PASQAL_ENDPOINTS, - SDK, -) +from pasqal_cloud import AUTH0_CONFIG, Auth0Conf, Endpoints, PASQAL_ENDPOINTS, SDK from pasqal_cloud._version import __version__ as sdk_version from pasqal_cloud.authentication import TokenProvider from tests.test_doubles.authentication import ( @@ -83,6 +77,14 @@ def test_correct_new_auth0(self): auth0=new_auth0, ) + def test_module_no_project_id(self): + sdk = SDK(username=self.username, password=self.password) + with pytest.raises( + ValueError, + match="You need to set a project_id", + ): + sdk.create_batch("", []) + @patch("pasqal_cloud.client.Auth0TokenProvider", FakeAuth0AuthenticationFailure) class TestAuthFailure(TestSDKCommonAttributes): @@ -104,27 +106,20 @@ def test_module_bad_password(self): ) +@patch("pasqal_cloud.client.Auth0TokenProvider", FakeAuth0AuthenticationFailure) class TestAuthInvalidClient(TestSDKCommonAttributes): - def test_module_no_project_id(self): - with pytest.raises( - ValueError, - match="You need to provide a project_id", - ): - SDK( - username=self.username, - password=self.password, - ) - def test_module_no_user_with_password(self): + sdk = SDK( + project_id=self.project_id, + username=self.no_username, + password=self.password, + ) with pytest.raises( ValueError, - match="At least a username or TokenProvider object should be provided", + match="Authentication required. Please provide your credentials when " + "initializing the client.", ): - SDK( - project_id=self.project_id, - username=self.no_username, - password=self.password, - ) + sdk.get_batch("fake-id") @patch("pasqal_cloud.client.getpass") def test_module_no_password(self, getpass): @@ -163,11 +158,13 @@ def test_bad_auth0(self): ) def test_authentication_no_credentials_provided(self): + sdk = SDK(project_id=self.project_id) with pytest.raises( ValueError, - match="At least a username or TokenProvider object should be provided", + match="Authentication required. Please provide your credentials when " + "initializing the client.", ): - SDK(project_id=self.project_id) + sdk.get_batch("fake-id") @pytest.mark.filterwarnings( "ignore:The parameters 'endpoints' and 'auth0' are deprecated, from now use" diff --git a/tests/test_device_specs.py b/tests/test_device_specs.py index 22cf457a..f91ef532 100644 --- a/tests/test_device_specs.py +++ b/tests/test_device_specs.py @@ -4,6 +4,7 @@ from uuid import uuid4 import pytest +import requests_mock from pasqal_cloud import SDK from pasqal_cloud.errors import DeviceSpecsFetchingError @@ -44,3 +45,29 @@ def test_get_device_specs_error( mock_request_exception.last_request.url == f"{self.sdk._client.endpoints.core}/api/v1/devices/specs" ) + + def test_get_public_device_specs_success(self, mock_request: requests_mock.Mocker): + """ + Test that the SDK client can be initiated without specifying credentials. + Verify that executing `get_device_specs_dict` with an unauthenticated SDK client + will request the public endpoint, while an authenticated user will request the + private endpoint. + """ + + sdk_without_auth = SDK() + public_device_specs_dict = sdk_without_auth.get_device_specs_dict() + + assert ( + mock_request.last_request.url + == f"{sdk_without_auth._client.endpoints.core}/api/v1/devices/public-specs" + ) + + internal_device_specs_dict = self.sdk.get_device_specs_dict() + assert ( + mock_request.last_request.url + == f"{self.sdk._client.endpoints.core}/api/v1/devices/specs" + ) + + assert ( + public_device_specs_dict["FRESNEL"] != internal_device_specs_dict["FRESNEL"] + )