qi-compute-api-client 0.42.0__py3-none-any.whl → 0.43.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of qi-compute-api-client might be problematic. Click here for more details.

qi2_shared/__init__.py ADDED
File without changes
@@ -0,0 +1,65 @@
1
+ import time
2
+ from typing import Any, Tuple, cast
3
+
4
+ import requests
5
+
6
+ from qi2_shared.settings import ApiSettings, TokenInfo, Url
7
+
8
+
9
+ class AuthorisationError(Exception):
10
+ """Indicates that the authorisation permanently went wrong."""
11
+ pass
12
+
13
+
14
+ class IdentityProvider:
15
+ """Class for interfacing with the IdentityProvider."""
16
+
17
+ def __init__(self, well_known_endpoint: str):
18
+ self._well_known_endpoint = well_known_endpoint
19
+ self._token_endpoint, self._device_endpoint = self._get_endpoints()
20
+ self._headers = {"Content-Type": "application/x-www-form-urlencoded"}
21
+
22
+ def _get_endpoints(self) -> Tuple[str, str]:
23
+ response = requests.get(self._well_known_endpoint)
24
+ response.raise_for_status()
25
+ config = response.json()
26
+ return config["token_endpoint"], config["device_authorization_endpoint"]
27
+
28
+ def refresh_access_token(self, client_id: str, refresh_token: str) -> dict[str, Any]:
29
+ data = {
30
+ "grant_type": "refresh_token",
31
+ "client_id": client_id,
32
+ "refresh_token": refresh_token,
33
+ }
34
+ response = requests.post(self._token_endpoint, headers=self._headers, data=data)
35
+ response.raise_for_status()
36
+ return cast(dict[str, Any], response.json())
37
+
38
+
39
+ class OauthDeviceSession:
40
+ """Class for storing OAuth session information and refreshing tokens when needed."""
41
+
42
+ def __init__(self, host: Url, settings: ApiSettings, identity_provider: IdentityProvider):
43
+ self._api_settings = settings
44
+ _auth_settings = settings.auths[host]
45
+ self._host = host
46
+ self._client_id = _auth_settings.client_id
47
+ self._token_info = _auth_settings.tokens
48
+ self._refresh_time_reduction = 5 # the number of seconds to refresh the expiration time
49
+ self._identity_provider = identity_provider
50
+
51
+ def refresh(self) -> TokenInfo:
52
+ if self._token_info is None:
53
+ raise AuthorisationError("You should authenticate first before you can refresh")
54
+
55
+ if self._token_info.access_expires_at > time.time() + self._refresh_time_reduction:
56
+ return self._token_info
57
+
58
+ try:
59
+ self._token_info = TokenInfo(
60
+ **self._identity_provider.refresh_access_token(self._client_id, self._token_info.refresh_token)
61
+ )
62
+ self._api_settings.store_tokens(self._host, self._token_info)
63
+ return self._token_info
64
+ except requests.HTTPError as e:
65
+ raise AuthorisationError(f"An error occurred during token refresh: {e}")
qi2_shared/client.py ADDED
@@ -0,0 +1,52 @@
1
+ from typing import Any, Optional
2
+
3
+ import compute_api_client
4
+
5
+ from qi2_shared.authentication import IdentityProvider, OauthDeviceSession
6
+ from qi2_shared.settings import ApiSettings
7
+
8
+
9
+ class Configuration(compute_api_client.Configuration): # type: ignore[misc]
10
+ """Original Configuration class in compute_api_client does not handle refreshing bearer tokens, so we need to add
11
+ some functionality."""
12
+
13
+ def __init__(self, host: str, oauth_session: OauthDeviceSession, **kwargs: Any):
14
+ self._oauth_session = oauth_session
15
+ super().__init__(host=host, **kwargs)
16
+
17
+ def auth_settings(self) -> Any:
18
+ token_info = self._oauth_session.refresh()
19
+ self.access_token = token_info.access_token
20
+ return super().auth_settings()
21
+
22
+
23
+ _config: Optional[Configuration] = None
24
+
25
+
26
+ def connect() -> None:
27
+ """Set connection configuration for the Quantum Inspire API.
28
+
29
+ Call after logging in with the CLI. Will remove old configuration.
30
+ """
31
+ global _config
32
+ settings = ApiSettings.from_config_file()
33
+
34
+ tokens = settings.auths[settings.default_host].tokens
35
+
36
+ if tokens is None:
37
+ raise ValueError("No access token found for the default host. Please connect to Quantum Inspire using the CLI.")
38
+
39
+ host = settings.default_host
40
+ _config = Configuration(
41
+ host=host,
42
+ oauth_session=OauthDeviceSession(host, settings, IdentityProvider(settings.auths[host].well_known_endpoint)),
43
+ )
44
+
45
+
46
+ def config() -> Configuration:
47
+ global _config
48
+ if _config is None:
49
+ connect()
50
+
51
+ assert _config is not None
52
+ return _config
@@ -0,0 +1,44 @@
1
+ from typing import Any, Awaitable, Callable, Generic, List, Optional, TypeVar, Union, cast
2
+
3
+ from pydantic import BaseModel, Field
4
+ from typing_extensions import Annotated
5
+
6
+ PageType = TypeVar("PageType")
7
+ ItemType = TypeVar("ItemType")
8
+
9
+
10
+ class PageInterface(BaseModel, Generic[ItemType]):
11
+ """The page models in the generated API client don't inherit from a common base class, so we have to trick the
12
+ typing system a bit with this fake base class."""
13
+
14
+ items: List[ItemType]
15
+ total: Optional[Annotated[int, Field(strict=True, ge=0)]]
16
+ page: Optional[Annotated[int, Field(strict=True, ge=1)]]
17
+ size: Optional[Annotated[int, Field(strict=True, ge=1)]]
18
+ pages: Optional[Annotated[int, Field(strict=True, ge=0)]] = None
19
+
20
+
21
+ class PageReader(Generic[PageType, ItemType]):
22
+ """Helper class for reading fastapi-pagination style pages returned by the compute_api_client."""
23
+
24
+ async def get_all(self, api_call: Callable[..., Awaitable[PageType]], **kwargs: Any) -> List[ItemType]:
25
+ """Get all items from an API call that supports paging."""
26
+ items: List[ItemType] = []
27
+ page = 1
28
+
29
+ while True:
30
+ response = cast(PageInterface[ItemType], await api_call(page=page, **kwargs))
31
+
32
+ items.extend(response.items)
33
+ page += 1
34
+ if response.pages is None or page > response.pages:
35
+ break
36
+ return items
37
+
38
+ async def get_single(self, api_call: Callable[..., Awaitable[PageType]], **kwargs: Any) -> Union[ItemType, None]:
39
+ """Get a single item from an API call that supports paging."""
40
+ response = cast(PageInterface[ItemType], await api_call(**kwargs))
41
+ if len(response.items) > 1:
42
+ raise RuntimeError(f"Response contains more than one item -> {kwargs}.")
43
+
44
+ return response.items[0] if response.items else None
qi2_shared/settings.py ADDED
@@ -0,0 +1,68 @@
1
+ """Module containing the handler for the Quantum Inspire persistent configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from pathlib import Path
7
+ from typing import Dict, Optional
8
+
9
+ from pydantic import BaseModel, BeforeValidator, Field, HttpUrl
10
+ from typing_extensions import Annotated
11
+
12
+ Url = Annotated[str, BeforeValidator(lambda value: str(HttpUrl(value)).rstrip("/"))]
13
+ API_SETTINGS_FILE = Path.joinpath(Path.home(), ".quantuminspire", "config.json")
14
+
15
+
16
+ class TokenInfo(BaseModel):
17
+ """A pydantic model for storing all information regarding oauth access and refresh tokens."""
18
+
19
+ access_token: str
20
+ expires_in: int # [s]
21
+ refresh_token: str
22
+ refresh_expires_in: Optional[int] = None # [s]
23
+ generated_at: float = Field(default_factory=time.time)
24
+
25
+ @property
26
+ def access_expires_at(self) -> float:
27
+ """Unix timestamp containing the time when the access token will expire."""
28
+ return self.generated_at + self.expires_in
29
+
30
+
31
+ class AuthSettings(BaseModel):
32
+ """Pydantic model for storing all auth related settings for a given host."""
33
+
34
+ client_id: str
35
+ code_challenge_method: str
36
+ code_verifyer_length: int
37
+ well_known_endpoint: Url
38
+ tokens: Optional[TokenInfo]
39
+ team_member_id: Optional[int]
40
+
41
+
42
+ class ApiSettings(BaseModel):
43
+ """The settings class for the Quantum Inspire persistent configuration."""
44
+
45
+ auths: Dict[Url, AuthSettings]
46
+ default_host: Url
47
+
48
+ def store_tokens(self, host: Url, tokens: TokenInfo, path: Path = API_SETTINGS_FILE) -> None:
49
+ """Stores the team_member_id, access and refresh tokens in the config.json file.
50
+
51
+ Args:
52
+ host: The hostname of the API for which the tokens are intended.
53
+ tokens: OAuth access and refresh tokens.
54
+ path: The path to the config.json file. Defaults to API_SETTINGS_FILE.
55
+ Returns:
56
+ None
57
+ """
58
+ self.auths[host].tokens = tokens
59
+ path.write_text(self.model_dump_json(indent=2))
60
+
61
+ @classmethod
62
+ def from_config_file(cls, path: Path = API_SETTINGS_FILE) -> ApiSettings:
63
+ """Load the configuration from a file."""
64
+ if not path.is_file():
65
+ raise FileNotFoundError("No configuration file found. Please connect to Quantum Inspire using the CLI.")
66
+
67
+ api_settings = path.read_text()
68
+ return ApiSettings.model_validate_json(api_settings)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: qi-compute-api-client
3
- Version: 0.42.0
3
+ Version: 0.43.0
4
4
  Summary: An API client for the Compute Job Manager of Quantum Inspire.
5
5
  Home-page: https://github.com/QuTech-Delft/compute-api-client
6
6
  License: Apache-2.0
@@ -17,7 +17,9 @@ Classifier: Programming Language :: Python :: 3.11
17
17
  Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Requires-Dist: aiohttp (>=3.10.5,<4.0.0)
20
+ Requires-Dist: pydantic (>=2.10.4,<3.0.0)
20
21
  Requires-Dist: python-dateutil (>=2.8.2,<3.0.0)
22
+ Requires-Dist: requests (>=2.32.3,<3.0.0)
21
23
  Requires-Dist: urllib3 (>=2.0.0,<3.0.0)
22
24
  Project-URL: Repository, https://github.com/QuTech-Delft/compute-api-client
23
25
  Description-Content-Type: text/markdown
@@ -184,7 +184,12 @@ compute_api_client/models/user.py,sha256=WD1QyiqRlyObnK61QRE2NUCyY5Oc9ejeNcOGopm
184
184
  compute_api_client/models/user_in.py,sha256=gMWrBylb3ihoc-bm-K7AG9w3iWGn_ROV14wZnGlJU30,3330
185
185
  compute_api_client/models/validation_error.py,sha256=HWliEU5aNn3CkND3c0MxJf0D8bUJmE3HKncfUCKDk5o,2941
186
186
  compute_api_client/rest.py,sha256=-N7fantOYHH5Ot97NOHm2ouAUuw4kdqbnOvCtf25H7g,5836
187
- qi_compute_api_client-0.42.0.dist-info/LICENSE.md,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
188
- qi_compute_api_client-0.42.0.dist-info/METADATA,sha256=2wADK8A17dlrCqrhQ-a1--wULpT15mRdisKRPydqQsA,20573
189
- qi_compute_api_client-0.42.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
190
- qi_compute_api_client-0.42.0.dist-info/RECORD,,
187
+ qi2_shared/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
188
+ qi2_shared/authentication.py,sha256=zTnbB9SIGowSZgetlyXj8NNM3p2beQ3fQo4R0lTrjk8,2550
189
+ qi2_shared/client.py,sha256=vK_VC5V9p2cy02GeVXh0lKxqhMrM3lzUSRtts1CvF-A,1591
190
+ qi2_shared/pagination.py,sha256=sypb9tSsi_7A1D9WoaUEuo7Xyfci8gG2QCX8XSIb7rI,1844
191
+ qi2_shared/settings.py,sha256=x2wNv8nzftMFb-5K7wKvhfJVxQn0YYlZ_Guu6DHm_qU,2384
192
+ qi_compute_api_client-0.43.0.dist-info/LICENSE.md,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
193
+ qi_compute_api_client-0.43.0.dist-info/METADATA,sha256=W9XGJv0kL7P1swJ-gw3TqBa8JT0WXP-x3Xe9Si0XaJ8,20657
194
+ qi_compute_api_client-0.43.0.dist-info/WHEEL,sha256=Nq82e9rUAnEjt98J6MlVmMCZb-t9cYE2Ir1kpBmnWfs,88
195
+ qi_compute_api_client-0.43.0.dist-info/RECORD,,