iqm-station-control-client 11.3.1__py3-none-any.whl → 12.0.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.
Files changed (44) hide show
  1. iqm/station_control/client/authentication.py +239 -0
  2. iqm/station_control/client/iqm_server/error.py +0 -30
  3. iqm/station_control/client/iqm_server/grpc_utils.py +0 -156
  4. iqm/station_control/client/iqm_server/iqm_server_client.py +0 -489
  5. iqm/station_control/client/list_models.py +16 -11
  6. iqm/station_control/client/qon.py +1 -1
  7. iqm/station_control/client/serializers/run_serializers.py +5 -4
  8. iqm/station_control/client/serializers/struct_serializer.py +1 -1
  9. iqm/station_control/client/station_control.py +140 -154
  10. iqm/station_control/client/utils.py +4 -42
  11. iqm/station_control/interface/models/__init__.py +21 -2
  12. iqm/station_control/interface/models/circuit.py +348 -0
  13. iqm/station_control/interface/models/dynamic_quantum_architecture.py +61 -3
  14. iqm/station_control/interface/models/jobs.py +41 -12
  15. iqm/station_control/interface/models/observation_set.py +28 -4
  16. iqm/station_control/interface/models/run.py +8 -8
  17. iqm/station_control/interface/models/sweep.py +7 -1
  18. iqm/station_control/interface/models/type_aliases.py +1 -2
  19. iqm/station_control/interface/station_control.py +1 -1
  20. {iqm_station_control_client-11.3.1.dist-info → iqm_station_control_client-12.0.0.dist-info}/METADATA +3 -3
  21. iqm_station_control_client-12.0.0.dist-info/RECORD +42 -0
  22. iqm/station_control/client/iqm_server/__init__.py +0 -14
  23. iqm/station_control/client/iqm_server/proto/__init__.py +0 -43
  24. iqm/station_control/client/iqm_server/proto/calibration_pb2.py +0 -48
  25. iqm/station_control/client/iqm_server/proto/calibration_pb2.pyi +0 -45
  26. iqm/station_control/client/iqm_server/proto/calibration_pb2_grpc.py +0 -152
  27. iqm/station_control/client/iqm_server/proto/common_pb2.py +0 -43
  28. iqm/station_control/client/iqm_server/proto/common_pb2.pyi +0 -32
  29. iqm/station_control/client/iqm_server/proto/common_pb2_grpc.py +0 -17
  30. iqm/station_control/client/iqm_server/proto/job_pb2.py +0 -57
  31. iqm/station_control/client/iqm_server/proto/job_pb2.pyi +0 -107
  32. iqm/station_control/client/iqm_server/proto/job_pb2_grpc.py +0 -436
  33. iqm/station_control/client/iqm_server/proto/qc_pb2.py +0 -51
  34. iqm/station_control/client/iqm_server/proto/qc_pb2.pyi +0 -57
  35. iqm/station_control/client/iqm_server/proto/qc_pb2_grpc.py +0 -163
  36. iqm/station_control/client/iqm_server/proto/uuid_pb2.py +0 -39
  37. iqm/station_control/client/iqm_server/proto/uuid_pb2.pyi +0 -26
  38. iqm/station_control/client/iqm_server/proto/uuid_pb2_grpc.py +0 -17
  39. iqm/station_control/client/iqm_server/testing/__init__.py +0 -13
  40. iqm/station_control/client/iqm_server/testing/iqm_server_mock.py +0 -102
  41. iqm_station_control_client-11.3.1.dist-info/RECORD +0 -59
  42. {iqm_station_control_client-11.3.1.dist-info → iqm_station_control_client-12.0.0.dist-info}/LICENSE.txt +0 -0
  43. {iqm_station_control_client-11.3.1.dist-info → iqm_station_control_client-12.0.0.dist-info}/WHEEL +0 -0
  44. {iqm_station_control_client-11.3.1.dist-info → iqm_station_control_client-12.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,239 @@
1
+ # Copyright 2024 IQM client developers
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """This module contains user authentication related classes and functions required by IQMClient."""
15
+
16
+ from abc import ABC, abstractmethod
17
+ from base64 import b64decode
18
+ from collections.abc import Callable
19
+ import json
20
+ import os
21
+ import time
22
+ from typing import Any, TypeAlias
23
+
24
+ from iqm.iqm_server_client.errors import ClientAuthenticationError, ClientConfigurationError
25
+
26
+ REFRESH_MARGIN_SECONDS = 60
27
+
28
+
29
+ AuthHeaderCallback: TypeAlias = Callable[[], str]
30
+ """Function that returns an authorization header containing a bearer token."""
31
+
32
+
33
+ class TokenManager:
34
+ """TokenManager manages the access token required for user authentication.
35
+
36
+ Args:
37
+ token: Long-lived IQM token in plain text format.
38
+ tokens_file: Path to a tokens file used for authentication.
39
+ auth_header_callback: Callback function that returns an Authorization header containing a bearer token.
40
+ use_env_vars: Iff True, the first two parameters can also be read from the environment variables
41
+ :envvar:`IQM_TOKEN` and :envvar:`IQM_TOKENS_FILE`, respectively.
42
+ Environment variables can not be mixed with initialisation arguments.
43
+
44
+ At most one auth parameter should be given.
45
+
46
+ """
47
+
48
+ @staticmethod
49
+ def time_left_seconds(token: Any) -> int:
50
+ """Check how much time is left until the token expires.
51
+
52
+ Returns:
53
+ Time left on token in seconds.
54
+
55
+ """
56
+ if not token or not isinstance(token, str):
57
+ return 0
58
+ parts = token.split(".", 2)
59
+ if len(parts) != 3:
60
+ return 0
61
+ # Add padding to adjust body length to a multiple of 4 chars as required by base64 decoding
62
+ try:
63
+ body = parts[1] + ("=" * (-len(parts[1]) % 4))
64
+ exp_time = int(json.loads(b64decode(body)).get("exp", "0"))
65
+ return max(0, exp_time - int(time.time()))
66
+ except (UnicodeDecodeError, json.decoder.JSONDecodeError, ValueError, TypeError):
67
+ return 0
68
+
69
+ def __init__(
70
+ self,
71
+ token: str | None = None,
72
+ tokens_file: str | None = None,
73
+ auth_header_callback: AuthHeaderCallback | None = None,
74
+ *,
75
+ use_env_vars: bool = True,
76
+ ):
77
+ def _format_names(variable_names: list[str]) -> str:
78
+ """Format a list of variable names"""
79
+ return ", ".join(f'"{name}"' for name in variable_names)
80
+
81
+ auth_parameters: dict[str, str] = {}
82
+
83
+ init_parameters = {"token": token, "tokens_file": tokens_file}
84
+ init_params_given = [key for key, value in init_parameters.items() if value]
85
+ if auth_header_callback:
86
+ init_params_given.append("auth_header_callback")
87
+
88
+ if use_env_vars:
89
+ env_variables = {"token": "IQM_TOKEN", "tokens_file": "IQM_TOKENS_FILE"}
90
+ env_vars_given = [name for name in env_variables.values() if os.environ.get(name)]
91
+ else:
92
+ env_variables = {}
93
+ env_vars_given = []
94
+
95
+ if init_params_given and env_vars_given:
96
+ raise ClientConfigurationError(
97
+ "Authentication parameters given both as initialisation args and as environment variables: "
98
+ + f"initialisation args {_format_names(init_params_given)}, "
99
+ + f"environment variables {_format_names(env_vars_given)}."
100
+ + " Parameter sources must not be mixed."
101
+ )
102
+ auth_params_given = init_params_given + env_vars_given
103
+ if len(auth_params_given) > 1:
104
+ raise ClientConfigurationError(
105
+ f"No more than one authentication parameter may be given, received {auth_params_given}"
106
+ )
107
+
108
+ self._token_provider: TokenProviderInterface | None = None
109
+ self._auth_header_callback: AuthHeaderCallback | None = None
110
+ self._access_token: str | None = None
111
+
112
+ if auth_header_callback:
113
+ self._auth_header_callback = auth_header_callback
114
+ return
115
+
116
+ if env_vars_given:
117
+ auth_parameters = {key: value for key, name in env_variables.items() if (value := os.environ.get(name))}
118
+ else:
119
+ auth_parameters = {key: value for key, value in init_parameters.items() if value}
120
+
121
+ if not auth_parameters:
122
+ return # no authentication
123
+ if set(auth_parameters) == {"token"}:
124
+ # This is not necessarily a JWT token
125
+ self._token_provider = ExternalToken(auth_parameters["token"])
126
+ elif set(auth_parameters) == {"tokens_file"}:
127
+ self._token_provider = TokensFileReader(auth_parameters["tokens_file"])
128
+ else:
129
+ raise ClientConfigurationError("Not possible.")
130
+
131
+ def get_auth_header_callback(self) -> AuthHeaderCallback | None:
132
+ """Return a callback providing an Authorization header, or None if we cannot provide it."""
133
+ return self._get_bearer_token if self._token_provider else self._auth_header_callback
134
+
135
+ def _get_bearer_token(self, retries: int = 1) -> str:
136
+ """Return a valid bearer token.
137
+
138
+ Raises:
139
+ ClientAuthenticationError: getting the token failed
140
+ RuntimeError: There is no token provider (authentication disabled)
141
+
142
+ """
143
+ if self._token_provider is None:
144
+ raise RuntimeError("There is no token provider.")
145
+
146
+ # Use the existing access token if it is still valid
147
+ if TokenManager.time_left_seconds(self._access_token) > REFRESH_MARGIN_SECONDS:
148
+ return f"Bearer {self._access_token}"
149
+
150
+ # Otherwise, get a new access token from token provider
151
+ try:
152
+ self._access_token = self._token_provider.get_token()
153
+ return f"Bearer {self._access_token}"
154
+ except ClientAuthenticationError:
155
+ if retries < 1:
156
+ raise
157
+
158
+ # Try again
159
+ return self._get_bearer_token(retries - 1)
160
+
161
+ def close(self) -> bool:
162
+ """Close the configured token provider.
163
+
164
+ Returns:
165
+ True if closing was successful
166
+
167
+ Raises:
168
+ ClientAuthenticationError: closing failed
169
+
170
+ """
171
+ if self._token_provider is None:
172
+ return False
173
+
174
+ self._token_provider.close()
175
+ self._token_provider = None
176
+ return True
177
+
178
+
179
+ class TokenProviderInterface(ABC):
180
+ """Interface to token provider"""
181
+
182
+ @abstractmethod
183
+ def get_token(self) -> str:
184
+ """Return a valid access token.
185
+
186
+ Raises:
187
+ ClientAuthenticationError: acquiring the token failed
188
+
189
+ """
190
+
191
+ @abstractmethod
192
+ def close(self) -> None:
193
+ """Close the authentication session.
194
+
195
+ Raises:
196
+ ClientAuthenticationError: closing the session failed
197
+
198
+ """
199
+
200
+
201
+ class ExternalToken(TokenProviderInterface):
202
+ """Holds an external token"""
203
+
204
+ def __init__(self, token: str):
205
+ self._token: str | None = token
206
+
207
+ def get_token(self) -> str:
208
+ if self._token is None:
209
+ raise ClientAuthenticationError("No external token available")
210
+ return self._token
211
+
212
+ def close(self) -> None:
213
+ self._token = None
214
+ raise ClientAuthenticationError("Can not close externally managed auth session")
215
+
216
+
217
+ class TokensFileReader(TokenProviderInterface):
218
+ """Reads token from a file"""
219
+
220
+ def __init__(self, tokens_file: str):
221
+ self._path: str | None = tokens_file
222
+
223
+ def get_token(self) -> str:
224
+ try:
225
+ if self._path is None:
226
+ raise ClientAuthenticationError("No tokens file available")
227
+ with open(self._path, encoding="utf-8") as file:
228
+ raw_data = file.read()
229
+ json_data = json.loads(raw_data)
230
+ token = json_data.get("access_token")
231
+ if TokenManager.time_left_seconds(token) <= 0:
232
+ raise ClientAuthenticationError("Access token in file has expired or is not valid")
233
+ except (FileNotFoundError, IsADirectoryError, json.decoder.JSONDecodeError) as e:
234
+ raise ClientAuthenticationError(rf"Failed to read access token from file '{self._path}': {e}") from e
235
+ return token
236
+
237
+ def close(self) -> None:
238
+ self._path = None
239
+ raise ClientAuthenticationError("Can not close externally managed auth session")
@@ -1,30 +0,0 @@
1
- # Copyright 2025 IQM
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
-
15
- from exa.common.errors.station_control_errors import StationControlError
16
-
17
-
18
- class IqmServerError(StationControlError):
19
- def __init__(self, message: str, status_code: str, error_code: str | None = None, details: dict | None = None):
20
- super().__init__(message)
21
- self.status_code = status_code
22
- self.error_code = error_code
23
- self.details = details
24
-
25
- def __str__(self):
26
- s = f"{self.message} (status_code = {self.status_code}, error_code = {self.error_code}"
27
- if details := self.details:
28
- s += f", details = {details}"
29
- s += ")"
30
- return s
@@ -1,156 +0,0 @@
1
- # Copyright 2025 IQM
2
- #
3
- # Licensed under the Apache License, Version 2.0 (the "License");
4
- # you may not use this file except in compliance with the License.
5
- # You may obtain a copy of the License at
6
- #
7
- # http://www.apache.org/licenses/LICENSE-2.0
8
- #
9
- # Unless required by applicable law or agreed to in writing, software
10
- # distributed under the License is distributed on an "AS IS" BASIS,
11
- # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
- # See the License for the specific language governing permissions and
13
- # limitations under the License.
14
- """Internal utility functions used by IqmServerClient."""
15
-
16
- from collections.abc import Callable, Iterable
17
- from dataclasses import dataclass
18
- from datetime import datetime
19
- import uuid
20
-
21
- from google.protobuf import json_format, struct_pb2, timestamp_pb2
22
- import grpc
23
- from grpc import Compression
24
- from pydantic import HttpUrl
25
-
26
- from iqm.station_control.client.iqm_server import proto
27
- from iqm.station_control.client.iqm_server.error import IqmServerError
28
- from iqm.station_control.interface.models.type_aliases import StrUUID
29
-
30
-
31
- class ClientCallDetails(grpc.ClientCallDetails):
32
- def __init__(self, details): # noqa: ANN001
33
- self.method = details.method
34
- self.metadata = list(details.metadata or [])
35
- self.timeout = details.timeout
36
- self.credentials = details.credentials
37
- self.wait_for_ready = details.wait_for_ready
38
- self.compression = details.compression
39
-
40
-
41
- class ApiTokenAuth(grpc.UnaryUnaryClientInterceptor, grpc.UnaryStreamClientInterceptor):
42
- def __init__(self, get_token_callback: Callable[[], str]):
43
- self.get_token_callback = get_token_callback
44
-
45
- def _add_auth_header(self, client_call_details) -> ClientCallDetails: # noqa: ANN001
46
- details = ClientCallDetails(client_call_details)
47
- details.metadata.append(("authorization", self.get_token_callback()))
48
- return details
49
-
50
- def intercept_unary_stream(self, continuation, client_call_details, request): # noqa: ANN001, ANN201
51
- return continuation(self._add_auth_header(client_call_details), request)
52
-
53
- def intercept_unary_unary(self, continuation, client_call_details, request): # noqa: ANN001, ANN201
54
- return continuation(self._add_auth_header(client_call_details), request)
55
-
56
-
57
- @dataclass(frozen=True, kw_only=True)
58
- class ConnectionParameters:
59
- server_address: str
60
- is_secure: bool
61
- quantum_computer: str
62
- use_timeslot: bool
63
-
64
-
65
- def parse_connection_params(qc_url: str) -> ConnectionParameters:
66
- # Security measure: mitigate UTF-8 read order control character
67
- # exploits by allowing only ASCII urls
68
- if not qc_url.isascii():
69
- raise ValueError("Invalid quantum computer URL")
70
-
71
- # IQM Server QC urls are now form "https://cocos.<server_base_url>/<qc_name>[:timeslot]"
72
- # In the future, "cocos." subdomain will be dropped. The parsing logic should work with
73
- # the both url formats
74
- url = HttpUrl(qc_url)
75
- qc_name = (url.path or "").split("/")[-1].removesuffix(":timeslot")
76
- use_timeslot = qc_url.endswith(":timeslot")
77
- if not qc_name:
78
- raise ValueError("Invalid quantum computer URL: device name is missing")
79
-
80
- is_secure = url.scheme == "https"
81
- hostname = (url.host or "").removeprefix("cocos.")
82
- port = url.port or (443 if is_secure else 80)
83
-
84
- return ConnectionParameters(
85
- server_address=f"{hostname}:{port}",
86
- is_secure=is_secure,
87
- quantum_computer=qc_name,
88
- use_timeslot=use_timeslot,
89
- )
90
-
91
-
92
- def create_channel(
93
- connection_params: ConnectionParameters,
94
- get_token_callback: Callable[[], str] | None = None,
95
- enable_compression: bool = True,
96
- ) -> grpc.Channel:
97
- compression = Compression.Gzip if enable_compression else None
98
- options = [
99
- # Let's try to parametrize this at least when we're merging station-control-client and iqm-client
100
- ("grpc.keepalive_time_ms", 5000),
101
- ("grpc.keepalive_permit_without_calls", 1),
102
- ("grpc.http2.max_pings_without_data", 0),
103
- ("grpc.keepalive_timeout_ms", 1000),
104
- ]
105
- address = connection_params.server_address
106
- channel = (
107
- grpc.secure_channel(
108
- address, credentials=grpc.ssl_channel_credentials(), options=options, compression=compression
109
- )
110
- if connection_params.is_secure
111
- else grpc.insecure_channel(address, options=options, compression=compression)
112
- )
113
- if get_token_callback is not None:
114
- channel = grpc.intercept_channel(channel, ApiTokenAuth(get_token_callback))
115
- return channel
116
-
117
-
118
- def to_proto_uuid(value: StrUUID) -> proto.Uuid:
119
- if isinstance(value, str):
120
- value = uuid.UUID(value)
121
- return proto.Uuid(raw=value.bytes)
122
-
123
-
124
- def from_proto_uuid(value: proto.Uuid) -> uuid.UUID:
125
- if value.WhichOneof("data") == "str":
126
- return uuid.UUID(hex=value.str)
127
- return uuid.UUID(bytes=value.raw)
128
-
129
-
130
- def to_datetime(timestamp: timestamp_pb2.Timestamp) -> datetime:
131
- return timestamp.ToDatetime()
132
-
133
-
134
- def load_all(chunks: Iterable[proto.DataChunk]) -> bytes:
135
- result = bytearray()
136
- for chunk in chunks:
137
- result.extend(chunk.data)
138
- return bytes(result)
139
-
140
-
141
- def extract_error(error: grpc.RpcError, title: str | None = None) -> IqmServerError:
142
- message = error.details()
143
- status_code = str(error.code().name)
144
- metadata = {k: v for k, v in list(error.initial_metadata()) + list(error.trailing_metadata())}
145
- error_code = str(metadata.get("error_code")) if "error_code" in metadata else None
146
- details = None
147
- if details_bin := metadata.get("grpc-status-details-bin"):
148
- value_proto = struct_pb2.Value()
149
- value_proto.ParseFromString(details_bin)
150
- details = json_format.MessageToJson(value_proto)
151
- return IqmServerError(
152
- message=f"{title}: {message}" if title else message,
153
- status_code=status_code,
154
- error_code=error_code,
155
- details=details, # type: ignore[arg-type]
156
- )