iqm-station-control-client 8.1.0__py3-none-any.whl → 9.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 (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 +276 -45
  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 +162 -443
  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.0.0.dist-info}/METADATA +2 -2
  32. {iqm_station_control_client-8.1.0.dist-info → iqm_station_control_client-9.0.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.0.0.dist-info}/LICENSE.txt +0 -0
  35. {iqm_station_control_client-8.1.0.dist-info → iqm_station_control_client-9.0.0.dist-info}/WHEEL +0 -0
  36. {iqm_station_control_client-8.1.0.dist-info → iqm_station_control_client-9.0.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,73 @@ 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 StationControlClient(StationControlInterface):
104
+ """Client implementation for station control service REST API.
103
105
 
104
106
  Args:
105
107
  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.
108
+ get_token_callback: A callback function that returns a token (str)
109
+ which will be passed in Authorization header in all requests.
150
110
 
151
111
  """
152
112
 
153
- def __init__(self, root_url: str, get_token_callback: Callable[[], str] | None = None):
113
+ def __init__(
114
+ self, root_url: str, *, get_token_callback: Callable[[], str] | None = None, client_signature: str | None = None
115
+ ):
154
116
  self.root_url = root_url
155
117
  self._enable_opentelemetry = os.environ.get("JAEGER_OPENTELEMETRY_COLLECTOR_ENDPOINT", None) is not None
156
118
  self._get_token_callback = get_token_callback
119
+ self._signature = self._create_signature(client_signature)
157
120
  # TODO SW-1387: Remove this when using v1 API, not needed
158
121
  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
122
+ qcm_data_url = os.environ.get("CHIP_DESIGN_RECORD_FALLBACK_URL", None)
123
+ self._qcm_data_client = QCMDataClient(qcm_data_url) if qcm_data_url else None
161
124
 
162
125
  @property
163
126
  def version(self) -> str:
164
127
  """Return the version of the station control API this client is using."""
165
128
  return "v1"
166
129
 
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.
170
-
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.
175
-
176
- 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.
181
-
182
- """
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
192
-
193
- # Must be imported here in order to avoid circular dependencies
194
- from iqm.station_control.client.iqm_server.iqm_server_client import IqmServerClient
195
-
196
- return IqmServerClient(root_url, get_token_callback, **kwargs)
197
- # Using direct station control by default
198
- return StationControlClient(root_url, get_token_callback)
199
-
200
- except Exception as e:
201
- raise StationControlError("Failed to connect to the remote server") from e
202
-
203
130
  @cache
204
131
  def get_about(self) -> dict:
205
- """Return information about the station control."""
206
132
  response = self._send_request(requests.get, "about")
207
133
  return response.json()
208
134
 
135
+ def get_health(self) -> dict:
136
+ response = self._send_request(requests.get, "health")
137
+ return response.json()
138
+
209
139
  @cache
210
140
  def get_configuration(self) -> dict:
211
- """Return the configuration of the station control."""
212
141
  response = self._send_request(requests.get, "configuration")
213
142
  return response.json()
214
143
 
215
144
  @cache
216
145
  def get_exa_configuration(self) -> str:
217
- """Return the recommended EXA configuration from the server."""
218
146
  response = self._send_request(requests.get, "exa/configuration")
219
147
  return response.content.decode("utf-8")
220
148
 
221
149
  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
150
  # FIXME: We don't have information if the object was created or fetched. Thus, server always responds 200 (OK).
224
151
  json_str = json.dumps(software_version_set)
225
152
  response = self._send_request(requests.post, "software-version-sets", json_str=json_str)
226
153
  return int(response.content)
227
154
 
228
155
  def get_settings(self) -> SettingNode:
229
- """Return a tree representation of the default settings as defined in the configuration file."""
230
156
  return self._get_cached_settings().model_copy()
231
157
 
232
158
  @cache
@@ -236,7 +162,6 @@ class StationControlClient:
236
162
 
237
163
  @cache
238
164
  def get_chip_design_record(self, dut_label: str) -> dict:
239
- """Get a raw chip design record matching the given chip label."""
240
165
  try:
241
166
  response = self._send_request(requests.get, f"chip-design-records/{dut_label}")
242
167
  except StationControlError as err:
@@ -247,17 +172,8 @@ class StationControlClient:
247
172
 
248
173
  @cache
249
174
  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
175
  headers = {"accept": "application/octet-stream"}
260
- response = self._send_request(requests.get, "channel-properties/", headers=headers)
176
+ response = self._send_request(requests.get, "channel-properties", headers=headers)
261
177
  decoded_dict = unpack_channel_properties(response.content)
262
178
  return decoded_dict
263
179
 
@@ -265,49 +181,17 @@ class StationControlClient:
265
181
  self,
266
182
  sweep_definition: SweepDefinition,
267
183
  ) -> 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
184
  data = serialize_sweep_job_request(sweep_definition, queue_name="sweeps")
290
185
  return self._send_request(requests.post, "sweeps", octets=data).json()
291
186
 
292
- def get_sweep(self, sweep_id: uuid.UUID) -> SweepData:
293
- """Get N-dimensional sweep data from the database."""
187
+ def get_sweep(self, sweep_id: StrUUID) -> SweepData:
294
188
  response = self._send_request(requests.get, f"sweeps/{sweep_id}")
295
189
  return deserialize_sweep_data(response.json())
296
190
 
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."""
191
+ def delete_sweep(self, sweep_id: StrUUID) -> None:
307
192
  self._send_request(requests.delete, f"sweeps/{sweep_id}")
308
193
 
309
- def get_sweep_results(self, sweep_id: uuid.UUID) -> SweepResults:
310
- """Get N-dimensional sweep results from the database."""
194
+ def get_sweep_results(self, sweep_id: StrUUID) -> SweepResults:
311
195
  response = self._send_request(requests.get, f"sweeps/{sweep_id}/results")
312
196
  return deserialize_sweep_results(response.content)
313
197
 
@@ -317,7 +201,6 @@ class StationControlClient:
317
201
  update_progress_callback: Callable[[Statuses], None] | None = None,
318
202
  wait_job_completion: bool = True,
319
203
  ) -> bool:
320
- """Execute an N-dimensional sweep of selected variables and save run, sweep and results."""
321
204
  data = serialize_run_job_request(run_definition, queue_name="sweeps")
322
205
 
323
206
  response = self._send_request(requests.post, "runs", octets=data)
@@ -325,61 +208,21 @@ class StationControlClient:
325
208
  return self._wait_job_completion(response.json()["job_id"], update_progress_callback)
326
209
  return False
327
210
 
328
- def get_run(self, run_id: uuid.UUID) -> RunData:
329
- """Get run data from the database."""
211
+ def get_run(self, run_id: StrUUID) -> RunData:
330
212
  response = self._send_request(requests.get, f"runs/{run_id}")
331
213
  return deserialize_run_data(response.json())
332
214
 
333
215
  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
216
  params = self._clean_query_parameters(RunData, **kwargs)
364
217
  response = self._send_request(requests.get, "runs", params=params)
365
- return self._create_list_with_meta(response, RunLiteList)
218
+ return self._deserialize_response(response, RunLiteList, list_with_meta=True)
366
219
 
367
220
  def create_observations(
368
221
  self, observation_definitions: Sequence[ObservationDefinition]
369
222
  ) -> 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
223
  json_str = self._serialize_model(ObservationDefinitionList(observation_definitions))
381
224
  response = self._send_request(requests.post, "observations", json_str=json_str)
382
- return self._create_list_with_meta(response, ObservationDataList)
225
+ return self._deserialize_response(response, ObservationDataList, list_with_meta=True)
383
226
 
384
227
  def get_observations(
385
228
  self,
@@ -389,40 +232,10 @@ class StationControlClient:
389
232
  dut_field: str | None = None,
390
233
  tags: list[str] | None = None,
391
234
  invalid: bool | None = False,
392
- run_ids: list[uuid.UUID] | None = None,
393
- sequence_ids: list[uuid.UUID] | None = None,
235
+ run_ids: list[StrUUID] | None = None,
236
+ sequence_ids: list[StrUUID] | None = None,
394
237
  limit: int | None = None,
395
238
  ) -> 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
239
  kwargs = {
427
240
  "mode": mode,
428
241
  "dut_label": dut_label,
@@ -435,249 +248,125 @@ class StationControlClient:
435
248
  }
436
249
  params = self._clean_query_parameters(ObservationData, **kwargs)
437
250
  response = self._send_request(requests.get, "observations", params=params)
438
- return ObservationDataList.model_validate(response.json())
251
+ return self._deserialize_response(response, ObservationDataList)
439
252
 
440
253
  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
254
  params = self._clean_query_parameters(ObservationData, **kwargs)
465
255
  response = self._send_request(requests.get, "observations", params=params)
466
- return self._create_list_with_meta(response, ObservationDataList)
256
+ return self._deserialize_response(response, ObservationDataList, list_with_meta=True)
467
257
 
468
258
  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
259
  json_str = self._serialize_model(ObservationUpdateList(observation_updates))
480
260
  response = self._send_request(requests.patch, "observations", json_str=json_str)
481
- return ObservationDataList.model_validate(response.json())
261
+ return self._deserialize_response(response, ObservationDataList)
482
262
 
483
263
  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
264
  params = self._clean_query_parameters(ObservationSetData, **kwargs)
505
265
  response = self._send_request(requests.get, "observation-sets", params=params)
506
- return self._create_list_with_meta(response, ObservationSetDataList)
266
+ return self._deserialize_response(response, ObservationSetDataList, list_with_meta=True)
507
267
 
508
268
  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
269
  json_str = self._serialize_model(observation_set_definition)
522
270
  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.
271
+ return self._deserialize_response(response, ObservationSetData)
536
272
 
537
- """
273
+ def get_observation_set(self, observation_set_id: StrUUID) -> ObservationSetData:
538
274
  response = self._send_request(requests.get, f"observation-sets/{observation_set_id}")
539
- return ObservationSetData.model_validate(response.json())
275
+ return self._deserialize_response(response, ObservationSetData)
540
276
 
541
277
  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
278
  json_str = self._serialize_model(observation_set_update)
555
279
  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.
280
+ return self._deserialize_response(response, ObservationSetData)
568
281
 
569
- """
282
+ def finalize_observation_set(self, observation_set_id: StrUUID) -> None:
570
283
  self._send_request(requests.post, f"observation-sets/{observation_set_id}/finalize")
571
284
 
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
- """
285
+ def get_observation_set_observations(self, observation_set_id: StrUUID) -> list[ObservationLite]:
582
286
  response = self._send_request(requests.get, f"observation-sets/{observation_set_id}/observations")
583
- return ObservationLiteList.model_validate(response.json())
287
+ return self._deserialize_response(response, ObservationLiteList)
584
288
 
585
- def get_calibration_set_values(self, calibration_set_id: uuid.UUID) -> dict[str, ObservationValue]:
586
- """Get saved calibration set observations by UUID
289
+ def get_default_calibration_set(self) -> ObservationSetData:
290
+ response = self._send_request(requests.get, "calibration-sets/default")
291
+ return self._deserialize_response(response, ObservationSetData)
587
292
 
588
- Args:
589
- calibration_set_id: UUID of the calibration set to retrieve.
590
-
591
- Returns:
592
- Dictionary of observations belonging to the given calibration set.
593
-
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)
293
+ def get_default_calibration_set_observations(self) -> list[ObservationLite]:
294
+ response = self._send_request(requests.get, "calibration-sets/default/observations")
295
+ return self._deserialize_response(response, ObservationLiteList)
600
296
 
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.
297
+ def get_default_dynamic_quantum_architecture(self) -> DynamicQuantumArchitecture:
298
+ response = self._send_request(requests.get, "calibration-sets/default/dynamic-quantum-architecture")
299
+ return self._deserialize_response(response, DynamicQuantumArchitecture)
603
300
 
604
- Args:
605
- dut_label: Target DUT label
301
+ @cache
302
+ def get_dynamic_quantum_architecture(self, calibration_set_id: StrUUID) -> DynamicQuantumArchitecture:
303
+ response = self._send_request(
304
+ requests.get, f"calibration-sets/{calibration_set_id}/dynamic-quantum-architecture"
305
+ )
306
+ return self._deserialize_response(response, DynamicQuantumArchitecture)
606
307
 
607
- Returns:
608
- UUID of the latest saved calibration set.
308
+ def get_default_calibration_set_quality_metrics(self) -> QualityMetrics:
309
+ response = self._send_request(requests.get, "calibration-sets/default/metrics")
310
+ return self._deserialize_response(response, QualityMetrics)
609
311
 
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
312
+ def get_calibration_set_quality_metrics(self, calibration_set_id: StrUUID) -> QualityMetrics:
313
+ response = self._send_request(requests.get, f"calibration-sets/{calibration_set_id}/metrics")
314
+ return self._deserialize_response(response, QualityMetrics)
620
315
 
621
316
  def get_duts(self) -> list[DutData]:
622
- """Get DUTs of the station control."""
623
317
  response = self._send_request(requests.get, "duts")
624
- return DutList.model_validate(response.json())
318
+ return self._deserialize_response(response, DutList)
625
319
 
626
320
  def get_dut_fields(self, dut_label: str) -> list[DutFieldData]:
627
- """Get DUT fields for the specified DUT label from the database."""
628
321
  params = {"dut_label": dut_label}
629
322
  response = self._send_request(requests.get, "dut-fields", params=params)
630
- return DutFieldDataList.model_validate(response.json())
323
+ return self._deserialize_response(response, DutFieldDataList)
631
324
 
632
325
  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
326
  params = self._clean_query_parameters(SequenceMetadataData, **kwargs)
647
327
  response = self._send_request(requests.get, "sequence-metadatas", params=params)
648
- return self._create_list_with_meta(response, SequenceMetadataDataList)
328
+ return self._deserialize_response(response, SequenceMetadataDataList, list_with_meta=True)
649
329
 
650
330
  def create_sequence_metadata(
651
331
  self, sequence_metadata_definition: SequenceMetadataDefinition
652
332
  ) -> SequenceMetadataData:
653
- """Create sequence metadata in the database."""
654
333
  json_str = self._serialize_model(sequence_metadata_definition)
655
334
  response = self._send_request(requests.post, "sequence-metadatas", json_str=json_str)
656
- return SequenceMetadataData.model_validate(response.json())
335
+ return self._deserialize_response(response, SequenceMetadataData)
657
336
 
658
337
  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
338
  # FIXME: We don't have information if the object was created or updated. Thus, server always responds 200 (OK).
666
339
  json_str = self._serialize_model(sequence_result_definition)
667
340
  response = self._send_request(
668
341
  requests.put, f"sequence-results/{sequence_result_definition.sequence_id}", json_str=json_str
669
342
  )
670
- return SequenceResultData.model_validate(response.json())
343
+ return self._deserialize_response(response, SequenceResultData)
671
344
 
672
- def get_sequence_result(self, sequence_id: uuid.UUID) -> SequenceResultData:
673
- """Get sequence result from the database."""
345
+ def get_sequence_result(self, sequence_id: StrUUID) -> SequenceResultData:
674
346
  response = self._send_request(requests.get, f"sequence-results/{sequence_id}")
675
- return SequenceResultData.model_validate(response.json())
347
+ return self._deserialize_response(response, SequenceResultData)
348
+
349
+ @cache
350
+ def get_static_quantum_architecture(self, dut_label: str) -> StaticQuantumArchitecture:
351
+ response = self._send_request(requests.get, f"static-quantum-architectures/{dut_label}")
352
+ return self._deserialize_response(response, StaticQuantumArchitecture)
676
353
 
677
- def get_job(self, job_id: uuid.UUID) -> JobData:
678
- """Get job data."""
354
+ def get_job(self, job_id: StrUUID) -> JobData:
679
355
  response = self._send_request(requests.get, f"jobs/{job_id}")
680
- return JobData.model_validate(response.json())
356
+ return self._deserialize_response(response, JobData)
357
+
358
+ def abort_job(self, job_id: StrUUID) -> None:
359
+ self._send_request(requests.post, f"jobs/{job_id}/abort")
360
+
361
+ @staticmethod
362
+ def _create_signature(client_signature: str) -> str:
363
+ signature = f"{platform.platform(terse=True)}"
364
+ signature += f", python {platform.python_version()}"
365
+ version_string = "iqm-station-control-client"
366
+ signature += f", StationControlClient {version_string} {version(version_string)}"
367
+ if client_signature:
368
+ signature += f", {client_signature}"
369
+ return signature
681
370
 
682
371
  def _wait_job_completion(self, job_id: str, update_progress_callback: Callable[[Statuses], None] | None) -> bool:
683
372
  logger.info("Waiting for job ID: %s", job_id)
@@ -727,7 +416,7 @@ class StationControlClient:
727
416
 
728
417
  def _poll_job(self, job_id: str) -> JobData:
729
418
  response = self._send_request(requests.get, f"jobs/{job_id}")
730
- job = JobData.model_validate(response.json())
419
+ job = self._deserialize_response(response, JobData)
731
420
  if job.job_status == JobExecutorStatus.FAILED:
732
421
  raise InternalServerError(f"Job: {job.job_id}\n{job.job_error}")
733
422
  return job
@@ -738,8 +427,6 @@ class StationControlClient:
738
427
 
739
428
  All Pydantic models should be serialized using this method, to keep the client behavior uniform.
740
429
 
741
- TODO add a corresponding deserialization method.
742
-
743
430
  Args:
744
431
  model: Pydantic model to JSON-serialize.
745
432
 
@@ -760,8 +447,9 @@ class StationControlClient:
760
447
  octets: bytes | None = None,
761
448
  params: dict[str, Any] | None = None,
762
449
  headers: dict[str, str] | None = None,
450
+ timeout: int = 120,
763
451
  ) -> requests.Response:
764
- """Send a HTTP request.
452
+ """Send an HTTP request.
765
453
 
766
454
  Parameters ``json_str``, ``octets`` and ``params`` are mutually exclusive.
767
455
  The first non-None argument (in this order) will be used to construct the body of the request.
@@ -782,37 +470,15 @@ class StationControlClient:
782
470
 
783
471
  """
784
472
  # 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
473
+ # http_method should be any of requests.[post|get|put|head|delete|patch|options]
811
474
 
475
+ request_kwargs = self._build_request_kwargs(
476
+ json_str=json_str, octets=octets, params=params or {}, headers=headers or {}, timeout=timeout
477
+ )
812
478
  url = f"{self.root_url}/{url_path}"
813
479
  # TODO SW-1387: Use v1 API
814
480
  # url = f"{self.root_url}/{self.version}/{url_path}"
815
- response = http_method(url, **http_request_options)
481
+ response = http_method(url, **request_kwargs)
816
482
  if not response.ok:
817
483
  try:
818
484
  response_json = response.json()
@@ -820,7 +486,11 @@ class StationControlClient:
820
486
  except json.JSONDecodeError:
821
487
  error_message = response.text
822
488
 
823
- error_class = map_from_status_code_to_error(response.status_code)
489
+ try:
490
+ error_class = map_from_status_code_to_error(response.status_code)
491
+ except KeyError:
492
+ raise RuntimeError(f"Unexpected response status code {response.status_code}: {error_message}")
493
+
824
494
  raise error_class(error_message)
825
495
  return response
826
496
 
@@ -859,18 +529,67 @@ class StationControlClient:
859
529
  def _get_client_api_version() -> Version:
860
530
  return parse(version("iqm-station-control-client"))
861
531
 
532
+ def _build_request_kwargs(self, **kwargs):
533
+ kwargs.setdefault("headers", {"User-Agent": self._signature})
534
+
535
+ params = kwargs.get("params", {})
536
+ headers = kwargs["headers"]
537
+
538
+ if kwargs["json_str"] is not None:
539
+ # Must be able to handle JSON strings with arbitrary unicode characters, so we use an explicit
540
+ # encoding into bytes, and set the headers so the recipient can decode the request body correctly.
541
+ data = kwargs["json_str"].encode("utf-8")
542
+ headers["Content-Type"] = "application/json; charset=UTF-8"
543
+ elif kwargs["octets"] is not None:
544
+ data = kwargs["octets"]
545
+ headers["Content-Type"] = "application/octet-stream"
546
+ else:
547
+ data = None
548
+ if self._enable_opentelemetry:
549
+ parent_span_context = trace.set_span_in_context(trace.get_current_span())
550
+ propagate.inject(carrier=headers, context=parent_span_context)
551
+
552
+ # If token callback exists, use it to retrieve the token and add it to the headers
553
+ if self._get_token_callback:
554
+ headers["Authorization"] = self._get_token_callback()
555
+
556
+ kwargs = {
557
+ "params": params,
558
+ "data": data,
559
+ "headers": headers,
560
+ "timeout": kwargs["timeout"],
561
+ }
562
+ return _remove_empty_values(kwargs)
563
+
862
564
  @staticmethod
863
565
  def _clean_query_parameters(model: Any, **kwargs) -> dict[str, Any]:
864
566
  if issubclass(model, PydanticBase) and "invalid" in model.model_fields.keys() and "invalid" not in kwargs:
865
567
  # Get only valid items by default, "invalid=None" would return also invalid ones.
866
568
  # This default has to be set on the client side, server side uses default "None".
867
569
  kwargs["invalid"] = False
868
- # Remove None and {} values
869
- return {key: value for key, value in kwargs.items() if value not in [None, {}]}
570
+ return _remove_empty_values(kwargs)
870
571
 
871
572
  @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)
573
+ def _deserialize_response(
574
+ response: requests.Response,
575
+ model_class: type[TypePydanticBase] | type[ListModel[list[TypePydanticBase]]],
576
+ *,
577
+ list_with_meta: bool = False,
578
+ ) -> TypePydanticBase | ListWithMeta[TypePydanticBase]:
579
+ # Use "model_validate_json(response.text)" instead of "model_validate(response.json())".
580
+ # This validates the provided data as a JSON string or bytes object.
581
+ # If your incoming data is a JSON payload, this is generally considered faster.
582
+ if list_with_meta:
583
+ response_with_meta = ResponseWithMeta.model_validate_json(response.text)
584
+ if response_with_meta.meta and response_with_meta.meta.errors:
585
+ logger.warning(
586
+ "Errors in station control response:\n - %s", "\n - ".join(response_with_meta.meta.errors)
587
+ )
588
+ return ListWithMeta(model_class.model_validate(response_with_meta.items), meta=response_with_meta.meta)
589
+ model = model_class.model_validate_json(response.text)
590
+ return model
591
+
592
+
593
+ def _remove_empty_values(kwargs):
594
+ # Remove None and {} values
595
+ return {key: value for key, value in kwargs.items() if value not in [None, {}]}