iqm-station-control-client 8.1.0__py3-none-any.whl → 9.1.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 (36) hide show
  1. iqm/station_control/client/__init__.py +1 -1
  2. iqm/station_control/client/iqm_server/error.py +1 -1
  3. iqm/station_control/client/iqm_server/grpc_utils.py +5 -3
  4. iqm/station_control/client/iqm_server/iqm_server_client.py +179 -46
  5. iqm/station_control/client/list_models.py +18 -12
  6. iqm/station_control/client/serializers/__init__.py +1 -1
  7. iqm/station_control/client/serializers/channel_property_serializer.py +12 -6
  8. iqm/station_control/client/serializers/datetime_serializers.py +1 -1
  9. iqm/station_control/client/serializers/playlist_serializers.py +1 -1
  10. iqm/station_control/client/serializers/run_serializers.py +1 -1
  11. iqm/station_control/client/serializers/setting_node_serializer.py +1 -1
  12. iqm/station_control/client/serializers/struct_serializer.py +1 -1
  13. iqm/station_control/client/serializers/sweep_serializers.py +2 -3
  14. iqm/station_control/client/serializers/task_serializers.py +1 -1
  15. iqm/station_control/client/station_control.py +245 -487
  16. iqm/station_control/client/utils.py +44 -17
  17. iqm/station_control/interface/__init__.py +1 -1
  18. iqm/station_control/interface/list_with_meta.py +1 -1
  19. iqm/station_control/interface/models/__init__.py +13 -1
  20. iqm/station_control/interface/models/dut.py +1 -1
  21. iqm/station_control/interface/models/dynamic_quantum_architecture.py +98 -0
  22. iqm/station_control/interface/models/observation.py +1 -1
  23. iqm/station_control/interface/models/observation_set.py +15 -1
  24. iqm/station_control/interface/models/run.py +1 -1
  25. iqm/station_control/interface/models/sequence.py +1 -1
  26. iqm/station_control/interface/models/static_quantum_architecture.py +40 -0
  27. iqm/station_control/interface/models/sweep.py +1 -1
  28. iqm/station_control/interface/models/type_aliases.py +7 -1
  29. iqm/station_control/interface/pydantic_base.py +1 -1
  30. iqm/station_control/interface/station_control.py +511 -0
  31. {iqm_station_control_client-8.1.0.dist-info → iqm_station_control_client-9.1.0.dist-info}/METADATA +2 -2
  32. {iqm_station_control_client-8.1.0.dist-info → iqm_station_control_client-9.1.0.dist-info}/RECORD +35 -33
  33. iqm/station_control/client/iqm_server/meta_class.py +0 -38
  34. {iqm_station_control_client-8.1.0.dist-info → iqm_station_control_client-9.1.0.dist-info}/LICENSE.txt +0 -0
  35. {iqm_station_control_client-8.1.0.dist-info → iqm_station_control_client-9.1.0.dist-info}/WHEEL +0 -0
  36. {iqm_station_control_client-8.1.0.dist-info → iqm_station_control_client-9.1.0.dist-info}/top_level.txt +0 -0
@@ -11,7 +11,7 @@
11
11
  # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
12
  # See the License for the specific language governing permissions and
13
13
  # limitations under the License.
14
- """Station control client implementation."""
14
+ """Client implementation for station control service REST API."""
15
15
 
16
16
  from __future__ import annotations
17
17
 
@@ -21,6 +21,7 @@ from importlib.metadata import version
21
21
  import json
22
22
  import logging
23
23
  import os
24
+ import platform
24
25
  from time import sleep
25
26
  from typing import Any, TypeVar
26
27
  import uuid
@@ -32,8 +33,7 @@ from pydantic import BaseModel
32
33
  import requests
33
34
 
34
35
  from exa.common.data.setting_node import SettingNode
35
- from exa.common.data.value import ObservationValue
36
- from exa.common.errors.server_errors import (
36
+ from exa.common.errors.station_control_errors import (
37
37
  InternalServerError,
38
38
  NotFoundError,
39
39
  StationControlError,
@@ -62,12 +62,14 @@ from iqm.station_control.client.serializers import (
62
62
  from iqm.station_control.client.serializers.channel_property_serializer import unpack_channel_properties
63
63
  from iqm.station_control.client.serializers.setting_node_serializer import deserialize_setting_node
64
64
  from iqm.station_control.client.serializers.sweep_serializers import deserialize_sweep_data
65
- from iqm.station_control.client.utils import calset_from_observations
66
65
  from iqm.station_control.interface.list_with_meta import ListWithMeta
67
66
  from iqm.station_control.interface.models import (
68
67
  DutData,
69
68
  DutFieldData,
69
+ DynamicQuantumArchitecture,
70
70
  GetObservationsMode,
71
+ JobData,
72
+ JobExecutorStatus,
71
73
  ObservationData,
72
74
  ObservationDefinition,
73
75
  ObservationLite,
@@ -75,6 +77,7 @@ from iqm.station_control.interface.models import (
75
77
  ObservationSetDefinition,
76
78
  ObservationSetUpdate,
77
79
  ObservationUpdate,
80
+ QualityMetrics,
78
81
  RunData,
79
82
  RunDefinition,
80
83
  RunLite,
@@ -83,150 +86,210 @@ from iqm.station_control.interface.models import (
83
86
  SequenceResultData,
84
87
  SequenceResultDefinition,
85
88
  SoftwareVersionSet,
89
+ StaticQuantumArchitecture,
86
90
  Statuses,
87
91
  SweepData,
88
92
  SweepDefinition,
89
93
  SweepResults,
90
94
  )
91
- from iqm.station_control.interface.models.jobs import JobData, JobExecutorStatus
95
+ from iqm.station_control.interface.models.type_aliases import StrUUID
92
96
  from iqm.station_control.interface.pydantic_base import PydanticBase
97
+ from iqm.station_control.interface.station_control import StationControlInterface
93
98
 
94
99
  logger = logging.getLogger(__name__)
95
- T = TypeVar("T")
100
+ TypePydanticBase = TypeVar("TypePydanticBase", bound=PydanticBase)
96
101
 
97
102
 
98
- class StationControlClient:
99
- """Station control client implementation.
100
-
101
- Current implementation uses HTTP calls to the remote station control service,
102
- that is controlling the station control instance.
103
+ class _StationControlClientBase(StationControlInterface):
104
+ """Shared functionality for StationControlClient and IqmServerClient.
103
105
 
104
106
  Args:
105
- root_url: Remote station control service URL.
106
- get_token_callback: A callback function that returns a token (str) which will be passed in Authorization header
107
- in all requests.
108
-
109
- Station control client implements generic query methods for certain objects,
110
- like :meth:`query_observations`, :meth:`query_observation_sets`, and :meth:`query_sequence_metadatas`.
111
- These methods accept only keyword arguments as parameters, which are based on the syntax ``field__lookup=value``.
112
- Note double-underscore in the name, to separate field names like ``dut_field`` from lookup types like ``in``.
113
- The syntax is based on Django implementation, documented
114
- `here <https://docs.djangoproject.com/en/5.0/ref/models/querysets/#field-lookups>`__ and
115
- `here <https://docs.djangoproject.com/en/5.0/ref/contrib/postgres/fields/#querying-arrayfield>`__.
116
-
117
- As a convenience, when no lookup type is provided (like in ``dut_label="foo"``),
118
- the lookup type is assumed to be exact (``dut_label__exact="foo"``). Other supported lookup types are:
119
-
120
- - range: Range test (inclusive).
121
- For example, ``created_timestamp__range=(datetime(2023, 10, 12), datetime(2024, 10, 14))``
122
- - in: In a given iterable; often a list, tuple, or queryset.
123
- For example, ``dut_field__in=["QB1.frequency", "gates.measure.constant.QB2.frequency"]``
124
- - icontains: Case-insensitive containment test.
125
- For example, ``origin_uri__icontains="local"``
126
- - overlap: Returns objects where the data shares any results with the values passed.
127
- For example, ``tags__overlap=["calibration=good", "2023-12-04"]``
128
- - contains: The returned objects will be those where the values passed are a subset of the data.
129
- For example, ``tags__contains=["calibration=good", "2023-12-04"]``
130
- - isnull: Takes either True or False, which correspond to SQL queries of IS NULL and IS NOT NULL, respectively.
131
- For example, ``end_timestamp__isnull=False``
132
-
133
- In addition to model fields (like "dut_label", "dut_field", "created_timestamp", "invalid", etc.),
134
- all of our generic query methods accept also following shared query parameters:
135
-
136
- - latest: str. Return only the latest item for this field, based on "created_timestamp".
137
- For example, ``latest="invalid"`` would return only one result (latest "created_timestamp")
138
- for each different "invalid" value in the database. Thus, maximum three results would be returned,
139
- one for each invalid value of `True`, `False`, and `None`.
140
- - order_by: str. Prefix with "-" for descending order, for example "-created_timestamp".
141
- - limit: int: Default 20. If 0 (or negative number) is given, then pagination is not used, i.e. limit=infinity.
142
- - offset: int. Default 0.
143
-
144
- Our generic query methods are not fully generalized yet, thus not all fields and lookup types are supported.
145
- Check query methods own documentation for details about currently supported query parameters.
146
-
147
- Generic query methods will return a list of objects, but with additional (optional) "meta" attribute,
148
- which contains metadata, like pagination details. The client can ignore this data,
149
- or use it to implement pagination logic for example to fetch all results available.
107
+ root_url: Remote server URL.
108
+ get_token_callback: A callback function that returns a token
109
+ which will be passed in Authorization header in all requests.
110
+ client_signature: String that is added to the User-Agent header of requests
111
+ sent to the server.
112
+ enable_opentelemetry: Iff True, enable Jaeger/OpenTelemetry tracing.
150
113
 
151
114
  """
152
115
 
153
- def __init__(self, root_url: str, get_token_callback: Callable[[], str] | None = None):
116
+ def __init__(
117
+ self,
118
+ root_url: str,
119
+ *,
120
+ get_token_callback: Callable[[], str] | None = None,
121
+ client_signature: str | None = None,
122
+ enable_opentelemetry: bool = False,
123
+ ):
154
124
  self.root_url = root_url
155
- self._enable_opentelemetry = os.environ.get("JAEGER_OPENTELEMETRY_COLLECTOR_ENDPOINT", None) is not None
156
125
  self._get_token_callback = get_token_callback
157
- # TODO SW-1387: Remove this when using v1 API, not needed
158
- self._check_api_versions()
159
- qcm_url = os.environ.get("CHIP_DESIGN_RECORD_FALLBACK_URL", None)
160
- self._qcm_data_client = QCMDataClient(qcm_url) if qcm_url else None
126
+ self._signature = self._create_signature(client_signature)
127
+ self._enable_opentelemetry = enable_opentelemetry
128
+
129
+ @classmethod
130
+ def _create_signature(cls, client_signature: str | None) -> str:
131
+ signature = f"{platform.platform(terse=True)}"
132
+ signature += f", python {platform.python_version()}"
133
+ dist_pkg_name = "iqm-station-control-client"
134
+ signature += f", {cls.__name__} {dist_pkg_name} {version(dist_pkg_name)}"
135
+ if client_signature:
136
+ signature += f", {client_signature}"
137
+ return signature
161
138
 
162
- @property
163
- def version(self) -> str:
164
- """Return the version of the station control API this client is using."""
165
- return "v1"
166
-
167
- @staticmethod
168
- def init(root_url: str, get_token_callback: Callable[[], str] | None = None, **kwargs) -> StationControlClient:
169
- """Initialize a new station control client instance connected to the given remote.
139
+ def _send_request(
140
+ self,
141
+ http_method: Callable[..., requests.Response],
142
+ url_path: str,
143
+ *,
144
+ headers: dict[str, str] | None = None,
145
+ params: dict[str, Any] | None = None,
146
+ json_str: str | None = None,
147
+ octets: bytes | None = None,
148
+ timeout: int = 120,
149
+ ) -> requests.Response:
150
+ """Send an HTTP request.
170
151
 
171
- Client implementation is selected automatically based on the remote station: if the remote station
172
- is running the IQM Server software stack, then the IQM Server client implementation (with a limited
173
- feature set) is chosen. If the remote station is running the SC software stack, then the Station
174
- Control client implementation (with the full feature set) is chosen.
152
+ Parameters ``json_str`` and ``octets`` are mutually exclusive.
153
+ The first non-None argument (in this order) will be used to construct the body of the request.
175
154
 
176
155
  Args:
177
- root_url: Remote station control service URL. For IQM Server remotes, this is the "Quantum Computer URL"
178
- value from the web dashboard.
179
- get_token_callback: A callback function that returns a token (str) which will be passed in Authorization
180
- header in all requests.
156
+ http_method: HTTP method to use for the request, any of requests.[post|get|put|head|delete|patch|options].
157
+ url_path: URL for the request.
158
+ headers: Additional HTTP headers for the request. Some may be overridden.
159
+ params: HTTP query parameters to store in the query string of the request URL.
160
+ json_str: JSON string to store in the body, may contain arbitrary Unicode characters.
161
+ octets: Pre-serialized binary data to store in the body.
162
+
163
+ Returns:
164
+ Response to the request.
165
+
166
+ Raises:
167
+ StationControlError: Request was not successful.
181
168
 
182
169
  """
183
- try:
184
- headers = {"Authorization": f"Bearer {get_token_callback()}"} if get_token_callback else {}
185
- response = requests.get(f"{root_url}/about", headers=headers)
186
- response.raise_for_status()
187
- about = response.json()
188
- if isinstance(about, dict) and about.get("iqm_server") is True:
189
- # If about information has iqm_server flag, it means that we're communicating
190
- # with IQM server instead of direct Station Control service, hence we need to
191
- # use the specialized client
170
+ # Will raise an error if respectively an error response code is returned.
171
+ # http_method should be any of requests.[post|get|put|head|delete|patch|options]
172
+
173
+ request_kwargs = self._build_request_kwargs(
174
+ headers=headers or {}, params=params or {}, json_str=json_str, octets=octets, timeout=timeout
175
+ )
176
+ url = f"{self.root_url}/{url_path}"
177
+ # TODO SW-1387: Use v1 API
178
+ # url = f"{self.root_url}/{self.version}/{url_path}"
179
+ response = http_method(url, **request_kwargs)
180
+ if not response.ok:
181
+ try:
182
+ response_json = response.json()
183
+ error_message = response_json["detail"]
184
+ except json.JSONDecodeError:
185
+ error_message = response.text
186
+
187
+ try:
188
+ error_class = map_from_status_code_to_error(response.status_code)
189
+ except KeyError:
190
+ raise RuntimeError(f"Unexpected response status code {response.status_code}: {error_message}")
192
191
 
193
- # Must be imported here in order to avoid circular dependencies
194
- from iqm.station_control.client.iqm_server.iqm_server_client import IqmServerClient
192
+ raise error_class(error_message)
193
+ return response
195
194
 
196
- return IqmServerClient(root_url, get_token_callback, **kwargs)
197
- # Using direct station control by default
198
- return StationControlClient(root_url, get_token_callback)
195
+ def _build_request_kwargs(
196
+ self,
197
+ *,
198
+ headers: dict[str, str],
199
+ params: dict[str, Any],
200
+ json_str: str | None = None,
201
+ octets: bytes | None = None,
202
+ timeout: int,
203
+ ) -> dict[str, Any]:
204
+ """Prepare the keyword arguments for an HTTP request."""
205
+ # add default headers
206
+ headers["User-Agent"] = self._signature
207
+
208
+ # json_str and octets are mutually exclusive
209
+ data: bytes | None = None
210
+ if json_str is not None:
211
+ # Must be able to handle JSON strings with arbitrary unicode characters, so we use an explicit
212
+ # encoding into bytes, and set the headers so the recipient can decode the request body correctly.
213
+ data = json_str.encode("utf-8")
214
+ headers["Content-Type"] = "application/json; charset=UTF-8"
215
+ elif octets is not None:
216
+ data = octets
217
+ headers["Content-Type"] = "application/octet-stream"
199
218
 
200
- except Exception as e:
201
- raise StationControlError("Failed to connect to the remote server") from e
219
+ if self._enable_opentelemetry:
220
+ parent_span_context = trace.set_span_in_context(trace.get_current_span())
221
+ propagate.inject(carrier=headers, context=parent_span_context)
222
+
223
+ # If token callback exists, use it to retrieve the token and add it to the headers
224
+ if self._get_token_callback:
225
+ headers["Authorization"] = self._get_token_callback()
226
+
227
+ kwargs = {
228
+ "headers": headers,
229
+ "params": params,
230
+ "data": data,
231
+ "timeout": timeout,
232
+ }
233
+ return _remove_empty_values(kwargs)
234
+
235
+
236
+ class StationControlClient(_StationControlClientBase):
237
+ """Client implementation for station control service REST API.
238
+
239
+ Args:
240
+ root_url: Remote station control service URL.
241
+ get_token_callback: A callback function that returns a token (str)
242
+ which will be passed in Authorization header in all requests.
243
+ client_signature: String that is added to the User-Agent header of requests
244
+ sent to the server.
245
+
246
+ """
247
+
248
+ def __init__(
249
+ self, root_url: str, *, get_token_callback: Callable[[], str] | None = None, client_signature: str | None = None
250
+ ):
251
+ super().__init__(
252
+ root_url,
253
+ get_token_callback=get_token_callback,
254
+ client_signature=client_signature,
255
+ enable_opentelemetry=os.environ.get("JAEGER_OPENTELEMETRY_COLLECTOR_ENDPOINT", None) is not None,
256
+ )
257
+ # TODO SW-1387: Remove this when using v1 API, not needed
258
+ self._check_api_versions()
259
+ qcm_data_url = os.environ.get("CHIP_DESIGN_RECORD_FALLBACK_URL", None)
260
+ self._qcm_data_client = QCMDataClient(qcm_data_url) if qcm_data_url else None
261
+
262
+ @property
263
+ def version(self) -> str:
264
+ """Return the version of the station control API this client is using."""
265
+ return "v1"
202
266
 
203
267
  @cache
204
268
  def get_about(self) -> dict:
205
- """Return information about the station control."""
206
269
  response = self._send_request(requests.get, "about")
207
270
  return response.json()
208
271
 
272
+ def get_health(self) -> dict:
273
+ response = self._send_request(requests.get, "health")
274
+ return response.json()
275
+
209
276
  @cache
210
277
  def get_configuration(self) -> dict:
211
- """Return the configuration of the station control."""
212
278
  response = self._send_request(requests.get, "configuration")
213
279
  return response.json()
214
280
 
215
281
  @cache
216
282
  def get_exa_configuration(self) -> str:
217
- """Return the recommended EXA configuration from the server."""
218
283
  response = self._send_request(requests.get, "exa/configuration")
219
284
  return response.content.decode("utf-8")
220
285
 
221
286
  def get_or_create_software_version_set(self, software_version_set: SoftwareVersionSet) -> int:
222
- """Get software version set ID from the database, or create if it doesn't exist."""
223
287
  # FIXME: We don't have information if the object was created or fetched. Thus, server always responds 200 (OK).
224
288
  json_str = json.dumps(software_version_set)
225
289
  response = self._send_request(requests.post, "software-version-sets", json_str=json_str)
226
290
  return int(response.content)
227
291
 
228
292
  def get_settings(self) -> SettingNode:
229
- """Return a tree representation of the default settings as defined in the configuration file."""
230
293
  return self._get_cached_settings().model_copy()
231
294
 
232
295
  @cache
@@ -236,7 +299,6 @@ class StationControlClient:
236
299
 
237
300
  @cache
238
301
  def get_chip_design_record(self, dut_label: str) -> dict:
239
- """Get a raw chip design record matching the given chip label."""
240
302
  try:
241
303
  response = self._send_request(requests.get, f"chip-design-records/{dut_label}")
242
304
  except StationControlError as err:
@@ -247,17 +309,8 @@ class StationControlClient:
247
309
 
248
310
  @cache
249
311
  def get_channel_properties(self) -> dict[str, ChannelProperties]:
250
- """Get channel properties from the station.
251
-
252
- Channel properties contain information regarding hardware limitations e.g. sampling rate, granularity
253
- and supported instructions.
254
-
255
- Returns:
256
- Mapping from channel name to AWGProperties or ReadoutProperties
257
-
258
- """
259
312
  headers = {"accept": "application/octet-stream"}
260
- response = self._send_request(requests.get, "channel-properties/", headers=headers)
313
+ response = self._send_request(requests.get, "channel-properties", headers=headers)
261
314
  decoded_dict = unpack_channel_properties(response.content)
262
315
  return decoded_dict
263
316
 
@@ -265,49 +318,17 @@ class StationControlClient:
265
318
  self,
266
319
  sweep_definition: SweepDefinition,
267
320
  ) -> dict:
268
- """Execute an N-dimensional sweep of selected variables and save sweep and results.
269
-
270
- The raw data for each spot in the sweep is saved as numpy arrays,
271
- and the complete data for the whole sweep is saved as an x-array dataset
272
- which has the `sweep_definition.sweeps` as coordinates and
273
- data of `sweep_definition.return_parameters` data as DataArrays.
274
-
275
- The values of `sweep_definition.playlist` will be uploaded to the controllers given by the keys of
276
- `sweep_definition.playlist`.
277
-
278
- Args:
279
- sweep_definition: The content of the sweep to be created.
280
-
281
- Returns:
282
- Dict containing the job ID and sweep ID, and corresponding hrefs, of a successful sweep execution
283
- in monolithic mode or successful submission to the job queue in remote mode.
284
-
285
- Raises:
286
- ExaError if submitting a sweep failed.
287
-
288
- """
289
321
  data = serialize_sweep_job_request(sweep_definition, queue_name="sweeps")
290
322
  return self._send_request(requests.post, "sweeps", octets=data).json()
291
323
 
292
- def get_sweep(self, sweep_id: uuid.UUID) -> SweepData:
293
- """Get N-dimensional sweep data from the database."""
324
+ def get_sweep(self, sweep_id: StrUUID) -> SweepData:
294
325
  response = self._send_request(requests.get, f"sweeps/{sweep_id}")
295
326
  return deserialize_sweep_data(response.json())
296
327
 
297
- def abort_job(self, job_id: uuid.UUID) -> None:
298
- """Either remove a job from the queue, or abort it gracefully if it's already executing.
299
-
300
- The status of the job will be set to ``JobStatus.ABORTED``.
301
- If the job is not found or is already finished nothing happens.
302
- """
303
- self._send_request(requests.post, f"jobs/{job_id}/abort")
304
-
305
- def delete_sweep(self, sweep_id: uuid.UUID) -> None:
306
- """Delete sweep in the database."""
328
+ def delete_sweep(self, sweep_id: StrUUID) -> None:
307
329
  self._send_request(requests.delete, f"sweeps/{sweep_id}")
308
330
 
309
- def get_sweep_results(self, sweep_id: uuid.UUID) -> SweepResults:
310
- """Get N-dimensional sweep results from the database."""
331
+ def get_sweep_results(self, sweep_id: StrUUID) -> SweepResults:
311
332
  response = self._send_request(requests.get, f"sweeps/{sweep_id}/results")
312
333
  return deserialize_sweep_results(response.content)
313
334
 
@@ -317,7 +338,6 @@ class StationControlClient:
317
338
  update_progress_callback: Callable[[Statuses], None] | None = None,
318
339
  wait_job_completion: bool = True,
319
340
  ) -> bool:
320
- """Execute an N-dimensional sweep of selected variables and save run, sweep and results."""
321
341
  data = serialize_run_job_request(run_definition, queue_name="sweeps")
322
342
 
323
343
  response = self._send_request(requests.post, "runs", octets=data)
@@ -325,61 +345,21 @@ class StationControlClient:
325
345
  return self._wait_job_completion(response.json()["job_id"], update_progress_callback)
326
346
  return False
327
347
 
328
- def get_run(self, run_id: uuid.UUID) -> RunData:
329
- """Get run data from the database."""
348
+ def get_run(self, run_id: StrUUID) -> RunData:
330
349
  response = self._send_request(requests.get, f"runs/{run_id}")
331
350
  return deserialize_run_data(response.json())
332
351
 
333
352
  def query_runs(self, **kwargs) -> ListWithMeta[RunLite]:
334
- """Query runs from the database.
335
-
336
- Runs are queried by the given query parameters. Currently supported query parameters:
337
- - run_id: uuid.UUID
338
- - run_id__in: list[uuid.UUID]
339
- - sweep_id: uuid.UUID
340
- - sweep_id__in: list[uuid.UUID]
341
- - username: str
342
- - username__in: list[str]
343
- - username__contains: str
344
- - username__icontains: str
345
- - experiment_label: str
346
- - experiment_label__in: list[str]
347
- - experiment_label__contains: str
348
- - experiment_label__icontains: str
349
- - experiment_name: str
350
- - experiment_name__in: list[str]
351
- - experiment_name__contains: str
352
- - experiment_name__icontains: str
353
- - software_version_set_id: int
354
- - software_version_set_id__in: list[int]
355
- - begin_timestamp__range: tuple[datetime, datetime]
356
- - end_timestamp__range: tuple[datetime, datetime]
357
- - end_timestamp__isnull: bool
358
-
359
- Returns:
360
- Queried runs with some query related metadata.
361
-
362
- """
363
353
  params = self._clean_query_parameters(RunData, **kwargs)
364
354
  response = self._send_request(requests.get, "runs", params=params)
365
- return self._create_list_with_meta(response, RunLiteList)
355
+ return self._deserialize_response(response, RunLiteList, list_with_meta=True)
366
356
 
367
357
  def create_observations(
368
358
  self, observation_definitions: Sequence[ObservationDefinition]
369
359
  ) -> ListWithMeta[ObservationData]:
370
- """Create observations in the database.
371
-
372
- Args:
373
- observation_definitions: A sequence of observation definitions,
374
- each containing the content of the observation which will be created.
375
-
376
- Returns:
377
- Created observations, each including also the database created fields like ID and timestamps.
378
-
379
- """
380
360
  json_str = self._serialize_model(ObservationDefinitionList(observation_definitions))
381
361
  response = self._send_request(requests.post, "observations", json_str=json_str)
382
- return self._create_list_with_meta(response, ObservationDataList)
362
+ return self._deserialize_response(response, ObservationDataList, list_with_meta=True)
383
363
 
384
364
  def get_observations(
385
365
  self,
@@ -389,40 +369,10 @@ class StationControlClient:
389
369
  dut_field: str | None = None,
390
370
  tags: list[str] | None = None,
391
371
  invalid: bool | None = False,
392
- run_ids: list[uuid.UUID] | None = None,
393
- sequence_ids: list[uuid.UUID] | None = None,
372
+ run_ids: list[StrUUID] | None = None,
373
+ sequence_ids: list[StrUUID] | None = None,
394
374
  limit: int | None = None,
395
375
  ) -> list[ObservationData]:
396
- """Get observations from the database.
397
-
398
- Observations are queried by the given query parameters.
399
-
400
- Args:
401
- mode: The "mode" used to query the observations. Possible values "all_latest", "tags_and", or "tags_or".
402
-
403
- - "all_latest":Query all the latest observations for the given ``dut_label``.
404
- No other query parameters are accepted.
405
- - "tags_and": Query observations. Query all the observations that have all the given ``tags``.
406
- By default, only valid observations are included.
407
- All other query parameters can be used to narrow down the query,
408
- expect "run_ids" and "sequence_ids".
409
- - "tags_or": Query all the latest observations that have at least one of the given ``tags``.
410
- Additionally, ``dut_label`` must be given. No other query parameters are used.
411
- - "sequence": Query observations originating from a list of run and/or sequence IDs.
412
- No other query parameters are accepted.
413
- dut_label: DUT label of the device the observations pertain to.
414
- dut_field: Name of the property the observation is about.
415
- tags: Human-readable tags of the observation.
416
- invalid: Flag indicating if the object is invalid. Automated systems must not use invalid objects.
417
- If ``None``, both valid and invalid objects are included.
418
- run_ids: The run IDs for which to query the observations.
419
- sequence_ids: The sequence IDs for which to query the observations.
420
- limit: Indicates the maximum number of items to return.
421
-
422
- Returns:
423
- Observations, each including also the database created fields like ID and timestamps.
424
-
425
- """
426
376
  kwargs = {
427
377
  "mode": mode,
428
378
  "dut_label": dut_label,
@@ -435,249 +385,115 @@ class StationControlClient:
435
385
  }
436
386
  params = self._clean_query_parameters(ObservationData, **kwargs)
437
387
  response = self._send_request(requests.get, "observations", params=params)
438
- return ObservationDataList.model_validate(response.json())
388
+ return self._deserialize_response(response, ObservationDataList)
439
389
 
440
390
  def query_observations(self, **kwargs) -> ListWithMeta[ObservationData]:
441
- """Query observations from the database.
442
-
443
- Observations are queried by the given query parameters. Currently supported query parameters:
444
- - observation_id: int
445
- - observation_id__in: list[int]
446
- - dut_label: str
447
- - dut_field: str
448
- - dut_field__in: list[str]
449
- - tags__overlap: list[str]
450
- - tags__contains: list[str]
451
- - invalid: bool
452
- - source__run_id__in: list[uuid.UUID]
453
- - source__sequence_id__in: list[uuid.UUID]
454
- - source__type: str
455
- - uncertainty__isnull: bool
456
- - created_timestamp__range: tuple[datetime, datetime]
457
- - observation_set_ids__overlap: list[uuid.UUID]
458
- - observation_set_ids__contains: list[uuid.UUID]
459
-
460
- Returns:
461
- Queried observations with some query related metadata.
462
-
463
- """
464
391
  params = self._clean_query_parameters(ObservationData, **kwargs)
465
392
  response = self._send_request(requests.get, "observations", params=params)
466
- return self._create_list_with_meta(response, ObservationDataList)
393
+ return self._deserialize_response(response, ObservationDataList, list_with_meta=True)
467
394
 
468
395
  def update_observations(self, observation_updates: Sequence[ObservationUpdate]) -> list[ObservationData]:
469
- """Update observations in the database.
470
-
471
- Args:
472
- observation_updates: A sequence of observation updates,
473
- each containing the content of the observation which will be updated.
474
-
475
- Returns:
476
- Updated observations, each including also the database created fields like ID and timestamps.
477
-
478
- """
479
396
  json_str = self._serialize_model(ObservationUpdateList(observation_updates))
480
397
  response = self._send_request(requests.patch, "observations", json_str=json_str)
481
- return ObservationDataList.model_validate(response.json())
398
+ return self._deserialize_response(response, ObservationDataList)
482
399
 
483
400
  def query_observation_sets(self, **kwargs) -> ListWithMeta[ObservationSetData]:
484
- """Query observation sets from the database.
485
-
486
- Observation sets are queried by the given query parameters. Currently supported query parameters:
487
- - observation_set_id: UUID
488
- - observation_set_id__in: list[UUID]
489
- - observation_set_type: Literal["calibration-set", "generic-set", "quality-metric-set"]
490
- - observation_ids__overlap: list[int]
491
- - observation_ids__contains: list[int]
492
- - describes_id: UUID
493
- - describes_id__in: list[UUID]
494
- - invalid: bool
495
- - created_timestamp__range: tuple[datetime, datetime]
496
- - end_timestamp__isnull: bool
497
- - dut_label: str
498
- - dut_label__in: list[str]
499
-
500
- Returns:
501
- Queried observation sets with some query related metadata
502
-
503
- """
504
401
  params = self._clean_query_parameters(ObservationSetData, **kwargs)
505
402
  response = self._send_request(requests.get, "observation-sets", params=params)
506
- return self._create_list_with_meta(response, ObservationSetDataList)
403
+ return self._deserialize_response(response, ObservationSetDataList, list_with_meta=True)
507
404
 
508
405
  def create_observation_set(self, observation_set_definition: ObservationSetDefinition) -> ObservationSetData:
509
- """Create an observation set in the database.
510
-
511
- Args:
512
- observation_set_definition: The content of the observation set to be created.
513
-
514
- Returns:
515
- The content of the observation set.
516
-
517
- Raises:
518
- ExaError: If creation failed.
519
-
520
- """
521
406
  json_str = self._serialize_model(observation_set_definition)
522
407
  response = self._send_request(requests.post, "observation-sets", json_str=json_str)
523
- return ObservationSetData.model_validate(response.json())
524
-
525
- def get_observation_set(self, observation_set_id: uuid.UUID) -> ObservationSetData:
526
- """Get an observation set from the database.
527
-
528
- Args:
529
- observation_set_id: Observation set to retrieve.
530
-
531
- Returns:
532
- The content of the observation set.
533
-
534
- Raises:
535
- ExaError: If retrieval failed.
408
+ return self._deserialize_response(response, ObservationSetData)
536
409
 
537
- """
410
+ def get_observation_set(self, observation_set_id: StrUUID) -> ObservationSetData:
538
411
  response = self._send_request(requests.get, f"observation-sets/{observation_set_id}")
539
- return ObservationSetData.model_validate(response.json())
412
+ return self._deserialize_response(response, ObservationSetData)
540
413
 
541
414
  def update_observation_set(self, observation_set_update: ObservationSetUpdate) -> ObservationSetData:
542
- """Update an observation set in the database.
543
-
544
- Args:
545
- observation_set_update: The content of the observation set to be updated.
546
-
547
- Returns:
548
- The content of the observation set.
549
-
550
- Raises:
551
- ExaError: If updating failed.
552
-
553
- """
554
415
  json_str = self._serialize_model(observation_set_update)
555
416
  response = self._send_request(requests.patch, "observation-sets", json_str=json_str)
556
- return ObservationSetData.model_validate(response.json())
557
-
558
- def finalize_observation_set(self, observation_set_id: uuid.UUID) -> None:
559
- """Finalize an observation set in the database.
560
-
561
- A finalized set is nearly immutable, allowing to change only ``invalid`` flag after finalization.
562
-
563
- Args:
564
- observation_set_id: Observation set to finalize.
565
-
566
- Raises:
567
- ExaError: If finalization failed.
417
+ return self._deserialize_response(response, ObservationSetData)
568
418
 
569
- """
419
+ def finalize_observation_set(self, observation_set_id: StrUUID) -> None:
570
420
  self._send_request(requests.post, f"observation-sets/{observation_set_id}/finalize")
571
421
 
572
- def get_observation_set_observations(self, observation_set_id: uuid.UUID) -> list[ObservationLite]:
573
- """Get the constituent observations of an observation set from the database.
574
-
575
- Args:
576
- observation_set_id: UUID of the observation set to retrieve.
577
-
578
- Returns:
579
- Observations belonging to the given observation set.
580
-
581
- """
422
+ def get_observation_set_observations(self, observation_set_id: StrUUID) -> list[ObservationLite]:
582
423
  response = self._send_request(requests.get, f"observation-sets/{observation_set_id}/observations")
583
- return ObservationLiteList.model_validate(response.json())
584
-
585
- def get_calibration_set_values(self, calibration_set_id: uuid.UUID) -> dict[str, ObservationValue]:
586
- """Get saved calibration set observations by UUID
587
-
588
- Args:
589
- calibration_set_id: UUID of the calibration set to retrieve.
424
+ return self._deserialize_response(response, ObservationLiteList)
590
425
 
591
- Returns:
592
- Dictionary of observations belonging to the given calibration set.
426
+ def get_default_calibration_set(self) -> ObservationSetData:
427
+ response = self._send_request(requests.get, "calibration-sets/default")
428
+ return self._deserialize_response(response, ObservationSetData)
593
429
 
594
- """
595
- observation_set = self.get_observation_set(calibration_set_id)
596
- if observation_set.observation_set_type != "calibration-set":
597
- raise ValueError("Observation set type is not 'calibration-set'")
598
- observations = self.get_observation_set_observations(calibration_set_id)
599
- return calset_from_observations(observations)
430
+ def get_default_calibration_set_observations(self) -> list[ObservationLite]:
431
+ response = self._send_request(requests.get, "calibration-sets/default/observations")
432
+ return self._deserialize_response(response, ObservationLiteList)
600
433
 
601
- def get_latest_calibration_set_id(self, dut_label: str) -> uuid.UUID:
602
- """Get UUID of the latest saved calibration set for the given dut_label.
434
+ def get_default_dynamic_quantum_architecture(self) -> DynamicQuantumArchitecture:
435
+ response = self._send_request(requests.get, "calibration-sets/default/dynamic-quantum-architecture")
436
+ return self._deserialize_response(response, DynamicQuantumArchitecture)
603
437
 
604
- Args:
605
- dut_label: Target DUT label
438
+ @cache
439
+ def get_dynamic_quantum_architecture(self, calibration_set_id: StrUUID) -> DynamicQuantumArchitecture:
440
+ response = self._send_request(
441
+ requests.get, f"calibration-sets/{calibration_set_id}/dynamic-quantum-architecture"
442
+ )
443
+ return self._deserialize_response(response, DynamicQuantumArchitecture)
606
444
 
607
- Returns:
608
- UUID of the latest saved calibration set.
445
+ def get_default_calibration_set_quality_metrics(self) -> QualityMetrics:
446
+ response = self._send_request(requests.get, "calibration-sets/default/metrics")
447
+ return self._deserialize_response(response, QualityMetrics)
609
448
 
610
- """
611
- observation_sets = self.query_observation_sets(
612
- observation_set_type="calibration-set",
613
- dut_label=dut_label,
614
- invalid=False,
615
- end_timestamp__isnull=False, # Finalized
616
- order_by="-end_timestamp", # This requires SC version > 35.15
617
- limit=1,
618
- )
619
- return observation_sets[0].observation_set_id
449
+ def get_calibration_set_quality_metrics(self, calibration_set_id: StrUUID) -> QualityMetrics:
450
+ response = self._send_request(requests.get, f"calibration-sets/{calibration_set_id}/metrics")
451
+ return self._deserialize_response(response, QualityMetrics)
620
452
 
621
453
  def get_duts(self) -> list[DutData]:
622
- """Get DUTs of the station control."""
623
454
  response = self._send_request(requests.get, "duts")
624
- return DutList.model_validate(response.json())
455
+ return self._deserialize_response(response, DutList)
625
456
 
626
457
  def get_dut_fields(self, dut_label: str) -> list[DutFieldData]:
627
- """Get DUT fields for the specified DUT label from the database."""
628
458
  params = {"dut_label": dut_label}
629
459
  response = self._send_request(requests.get, "dut-fields", params=params)
630
- return DutFieldDataList.model_validate(response.json())
460
+ return self._deserialize_response(response, DutFieldDataList)
631
461
 
632
462
  def query_sequence_metadatas(self, **kwargs) -> ListWithMeta[SequenceMetadataData]:
633
- """Query sequence metadatas from the database.
634
-
635
- Sequence metadatas are queried by the given query parameters. Currently supported query parameters:
636
- - origin_id: str
637
- - origin_id__in: list[str]
638
- - origin_uri: str
639
- - origin_uri__icontains: str
640
- - created_timestamp__range: tuple[datetime, datetime]
641
-
642
- Returns:
643
- Sequence metadatas with some query related metadata.
644
-
645
- """
646
463
  params = self._clean_query_parameters(SequenceMetadataData, **kwargs)
647
464
  response = self._send_request(requests.get, "sequence-metadatas", params=params)
648
- return self._create_list_with_meta(response, SequenceMetadataDataList)
465
+ return self._deserialize_response(response, SequenceMetadataDataList, list_with_meta=True)
649
466
 
650
467
  def create_sequence_metadata(
651
468
  self, sequence_metadata_definition: SequenceMetadataDefinition
652
469
  ) -> SequenceMetadataData:
653
- """Create sequence metadata in the database."""
654
470
  json_str = self._serialize_model(sequence_metadata_definition)
655
471
  response = self._send_request(requests.post, "sequence-metadatas", json_str=json_str)
656
- return SequenceMetadataData.model_validate(response.json())
472
+ return self._deserialize_response(response, SequenceMetadataData)
657
473
 
658
474
  def save_sequence_result(self, sequence_result_definition: SequenceResultDefinition) -> SequenceResultData:
659
- """Save sequence result in the database.
660
-
661
- This method creates the object if it doesn't exist and completely replaces the "data" and "final" if it does.
662
- Timestamps are assigned by the database. "modified_timestamp" is not set on initial creation,
663
- but it's updated on each subsequent call.
664
- """
665
475
  # FIXME: We don't have information if the object was created or updated. Thus, server always responds 200 (OK).
666
476
  json_str = self._serialize_model(sequence_result_definition)
667
477
  response = self._send_request(
668
478
  requests.put, f"sequence-results/{sequence_result_definition.sequence_id}", json_str=json_str
669
479
  )
670
- return SequenceResultData.model_validate(response.json())
480
+ return self._deserialize_response(response, SequenceResultData)
671
481
 
672
- def get_sequence_result(self, sequence_id: uuid.UUID) -> SequenceResultData:
673
- """Get sequence result from the database."""
482
+ def get_sequence_result(self, sequence_id: StrUUID) -> SequenceResultData:
674
483
  response = self._send_request(requests.get, f"sequence-results/{sequence_id}")
675
- return SequenceResultData.model_validate(response.json())
484
+ return self._deserialize_response(response, SequenceResultData)
485
+
486
+ @cache
487
+ def get_static_quantum_architecture(self, dut_label: str) -> StaticQuantumArchitecture:
488
+ response = self._send_request(requests.get, f"static-quantum-architectures/{dut_label}")
489
+ return self._deserialize_response(response, StaticQuantumArchitecture)
676
490
 
677
- def get_job(self, job_id: uuid.UUID) -> JobData:
678
- """Get job data."""
491
+ def get_job(self, job_id: StrUUID) -> JobData:
679
492
  response = self._send_request(requests.get, f"jobs/{job_id}")
680
- return JobData.model_validate(response.json())
493
+ return self._deserialize_response(response, JobData)
494
+
495
+ def abort_job(self, job_id: StrUUID) -> None:
496
+ self._send_request(requests.post, f"jobs/{job_id}/abort")
681
497
 
682
498
  def _wait_job_completion(self, job_id: str, update_progress_callback: Callable[[Statuses], None] | None) -> bool:
683
499
  logger.info("Waiting for job ID: %s", job_id)
@@ -727,7 +543,7 @@ class StationControlClient:
727
543
 
728
544
  def _poll_job(self, job_id: str) -> JobData:
729
545
  response = self._send_request(requests.get, f"jobs/{job_id}")
730
- job = JobData.model_validate(response.json())
546
+ job = self._deserialize_response(response, JobData)
731
547
  if job.job_status == JobExecutorStatus.FAILED:
732
548
  raise InternalServerError(f"Job: {job.job_id}\n{job.job_error}")
733
549
  return job
@@ -738,8 +554,6 @@ class StationControlClient:
738
554
 
739
555
  All Pydantic models should be serialized using this method, to keep the client behavior uniform.
740
556
 
741
- TODO add a corresponding deserialization method.
742
-
743
557
  Args:
744
558
  model: Pydantic model to JSON-serialize.
745
559
 
@@ -751,79 +565,6 @@ class StationControlClient:
751
565
  # using the \uXXXX syntax, BaseModel.model_dump_json() keeps them in the produced JSON str.
752
566
  return model.model_dump_json()
753
567
 
754
- def _send_request(
755
- self,
756
- http_method: Callable[..., requests.Response],
757
- url_path: str,
758
- *,
759
- json_str: str | None = None,
760
- octets: bytes | None = None,
761
- params: dict[str, Any] | None = None,
762
- headers: dict[str, str] | None = None,
763
- ) -> requests.Response:
764
- """Send a HTTP request.
765
-
766
- Parameters ``json_str``, ``octets`` and ``params`` are mutually exclusive.
767
- The first non-None argument (in this order) will be used to construct the body of the request.
768
-
769
- Args:
770
- http_method: HTTP method to use for the request, any of requests.[post|get|put|head|delete|patch|options].
771
- url_path: URL for the request.
772
- json_str: JSON string to store in the body, may contain arbitrary Unicode characters.
773
- octets: Pre-serialized binary data to store in the body.
774
- params: HTTP query to store in the body.
775
- headers: Additional HTTP headers for the request. Some may be overridden.
776
-
777
- Returns:
778
- Response to the request.
779
-
780
- Raises:
781
- StationControlError: Request was not successful.
782
-
783
- """
784
- # Will raise an error if respectively an error response code is returned.
785
- headers = headers or {}
786
-
787
- if self._enable_opentelemetry:
788
- parent_span_context = trace.set_span_in_context(trace.get_current_span())
789
- propagate.inject(carrier=headers, context=parent_span_context)
790
- # If token callback exists, use it to retrieve the token and add it to the headers
791
- if self._get_token_callback:
792
- headers["Authorization"] = self._get_token_callback()
793
-
794
- # Build request options explicitly
795
- http_request_options: dict[str, Any] = {}
796
- if json_str is not None:
797
- # Must be able to handle JSON strings with arbitrary unicode characters, so we use an explicit
798
- # encoding into bytes, and set the headers so the recipient can decode the request body correctly.
799
- http_request_options["data"] = json_str.encode("utf-8")
800
- headers["Content-Type"] = "application/json; charset=UTF-8"
801
- elif octets is not None:
802
- http_request_options["data"] = octets
803
- headers["Content-Type"] = "application/octet-stream"
804
- elif params is not None:
805
- http_request_options["params"] = params
806
- # otherwise no body-related parameter will be passed to requests
807
-
808
- if headers:
809
- # do not pass empty headers dict
810
- http_request_options["headers"] = headers
811
-
812
- url = f"{self.root_url}/{url_path}"
813
- # TODO SW-1387: Use v1 API
814
- # url = f"{self.root_url}/{self.version}/{url_path}"
815
- response = http_method(url, **http_request_options)
816
- if not response.ok:
817
- try:
818
- response_json = response.json()
819
- error_message = response_json["detail"]
820
- except json.JSONDecodeError:
821
- error_message = response.text
822
-
823
- error_class = map_from_status_code_to_error(response.status_code)
824
- raise error_class(error_message)
825
- return response
826
-
827
568
  # TODO SW-1387: Remove this when using v1 API, not needed
828
569
  def _check_api_versions(self):
829
570
  client_api_version = self._get_client_api_version()
@@ -865,12 +606,29 @@ class StationControlClient:
865
606
  # Get only valid items by default, "invalid=None" would return also invalid ones.
866
607
  # This default has to be set on the client side, server side uses default "None".
867
608
  kwargs["invalid"] = False
868
- # Remove None and {} values
869
- return {key: value for key, value in kwargs.items() if value not in [None, {}]}
609
+ return _remove_empty_values(kwargs)
870
610
 
871
611
  @staticmethod
872
- def _create_list_with_meta(response: requests.Response, list_model: type[ListModel[list[T]]]) -> ListWithMeta[T]:
873
- response_with_meta = ResponseWithMeta(**response.json())
874
- if response_with_meta.meta and response_with_meta.meta.errors:
875
- logger.warning("Errors in station control response:\n - %s", "\n - ".join(response_with_meta.meta.errors))
876
- return ListWithMeta(list_model.model_validate(response_with_meta.items), meta=response_with_meta.meta)
612
+ def _deserialize_response(
613
+ response: requests.Response,
614
+ model_class: type[TypePydanticBase] | type[ListModel[list[TypePydanticBase]]],
615
+ *,
616
+ list_with_meta: bool = False,
617
+ ) -> TypePydanticBase | ListWithMeta[TypePydanticBase]:
618
+ # Use "model_validate_json(response.text)" instead of "model_validate(response.json())".
619
+ # This validates the provided data as a JSON string or bytes object.
620
+ # If your incoming data is a JSON payload, this is generally considered faster.
621
+ if list_with_meta:
622
+ response_with_meta = ResponseWithMeta.model_validate_json(response.text)
623
+ if response_with_meta.meta and response_with_meta.meta.errors:
624
+ logger.warning(
625
+ "Errors in station control response:\n - %s", "\n - ".join(response_with_meta.meta.errors)
626
+ )
627
+ return ListWithMeta(model_class.model_validate(response_with_meta.items), meta=response_with_meta.meta)
628
+ model = model_class.model_validate_json(response.text)
629
+ return model
630
+
631
+
632
+ def _remove_empty_values(kwargs: dict[str, Any]) -> dict[str, Any]:
633
+ """Return a copy of the given dict without values that are None or {}."""
634
+ return {key: value for key, value in kwargs.items() if value not in [None, {}]}