iqm-client 27.1.0__py3-none-any.whl → 29.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.
@@ -15,10 +15,10 @@
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
- from collections.abc import Callable, Iterable
19
18
  from datetime import datetime
19
+ from functools import lru_cache
20
+ from http import HTTPStatus
20
21
  from importlib.metadata import version
21
- import itertools
22
22
  import json
23
23
  import os
24
24
  import platform
@@ -27,7 +27,7 @@ from typing import Any, TypeVar
27
27
  from uuid import UUID
28
28
  import warnings
29
29
 
30
- from iqm.iqm_client.api import APIConfig, APIEndpoint, APIVariant
30
+ from iqm.iqm_client.api import APIConfig, APIEndpoint
31
31
  from iqm.iqm_client.authentication import TokenManager
32
32
  from iqm.iqm_client.errors import (
33
33
  APITimeoutError,
@@ -39,19 +39,13 @@ from iqm.iqm_client.errors import (
39
39
  JobAbortionError,
40
40
  )
41
41
  from iqm.iqm_client.models import (
42
- _SUPPORTED_OPERATIONS,
43
42
  CalibrationSet,
44
- Circuit,
45
43
  CircuitBatch,
46
44
  CircuitCompilationOptions,
47
45
  ClientLibrary,
48
46
  ClientLibraryDict,
49
- DictDict,
50
47
  DynamicQuantumArchitecture,
51
- Instruction,
52
- MoveGateValidationMode,
53
48
  QualityMetricSet,
54
- QuantumArchitecture,
55
49
  QuantumArchitectureSpecification,
56
50
  RunCounts,
57
51
  RunRequest,
@@ -62,12 +56,18 @@ from iqm.iqm_client.models import (
62
56
  serialize_qubit_mapping,
63
57
  validate_circuit,
64
58
  )
59
+ from iqm.iqm_client.validation import validate_circuit_instructions, validate_qubit_mapping
60
+ from iqm.models.channel_properties import AWGProperties
65
61
  from packaging.version import parse
66
- from pydantic import BaseModel
67
- from pydantic import ValidationError as PydanticValidationError
62
+ from pydantic import BaseModel, ValidationError
68
63
  import requests
69
64
  from requests import HTTPError
70
65
 
66
+ from iqm.station_control.client.iqm_server.iqm_server_client import IqmServerClient
67
+ from iqm.station_control.client.utils import init_station_control
68
+ from iqm.station_control.interface.models import ObservationLite
69
+ from iqm.station_control.interface.station_control import StationControlInterface
70
+
71
71
  T_BaseModel = TypeVar("T_BaseModel", bound=BaseModel)
72
72
 
73
73
  REQUESTS_TIMEOUT = float(os.environ.get("IQM_CLIENT_REQUESTS_TIMEOUT", 120.0))
@@ -92,8 +92,6 @@ class IQMClient:
92
92
  If ``auth_server_url`` is given also ``username`` and ``password`` must be given.
93
93
  username: Username to log in to authentication server.
94
94
  password: Password to log in to authentication server.
95
- api_variant: API variant to use. Default is ``APIVariant.V1``.
96
- Configurable also by environment variable ``IQM_CLIENT_API_VARIANT``.
97
95
 
98
96
  Alternatively, the user authentication related keyword arguments can also be given in
99
97
  environment variables :envvar:`IQM_TOKEN`, :envvar:`IQM_TOKENS_FILE`, :envvar:`IQM_AUTH_SERVER`,
@@ -113,7 +111,6 @@ class IQMClient:
113
111
  auth_server_url: str | None = None,
114
112
  username: str | None = None,
115
113
  password: str | None = None,
116
- api_variant: APIVariant | None = None,
117
114
  ):
118
115
  if not url.startswith(("http:", "https:")):
119
116
  raise ClientConfigurationError(f"The URL schema has to be http or https. Incorrect schema in URL: {url}")
@@ -134,104 +131,29 @@ class IQMClient:
134
131
  self._static_architecture: StaticQuantumArchitecture | None = None
135
132
  self._dynamic_architectures: dict[UUID, DynamicQuantumArchitecture] = {}
136
133
 
137
- if api_variant is None:
138
- env_var = os.environ.get("IQM_CLIENT_API_VARIANT")
139
- api_variant = APIVariant(env_var) if env_var else APIVariant.V1
140
- self._api = APIConfig(api_variant, url)
134
+ self._station_control: StationControlInterface = init_station_control(
135
+ root_url=url,
136
+ get_token_callback=self._token_manager.get_bearer_token,
137
+ client_signature=client_signature,
138
+ )
139
+ self._api = APIConfig(url)
141
140
  if (version_incompatibility_msg := self._check_versions()) is not None:
142
141
  warnings.warn(version_incompatibility_msg)
143
142
 
144
143
  def __del__(self):
145
144
  try:
146
- # try our best to close the auth session, doesn't matter if it fails,
145
+ # Try our best to close the auth session, doesn't matter if it fails
147
146
  self.close_auth_session()
148
147
  except Exception:
149
148
  pass
150
149
 
151
- def _retry_request_on_error(self, request: Callable[[], requests.Response]) -> requests.Response:
152
- """Temporary workaround for 502 errors.
153
-
154
- The current implementation of the server side can run out of network connections
155
- and silently drop incoming connections making IQM Client to fail with 502 errors.
156
- """
157
- while True:
158
- result = request()
159
- if result.status_code == 502:
160
- time.sleep(SECONDS_BETWEEN_CALLS)
161
- continue
162
- break
163
-
164
- return result
150
+ def get_about(self) -> dict:
151
+ """Return information about the IQM client."""
152
+ return self._station_control.get_about()
165
153
 
166
- def _get_request(
167
- self,
168
- api_endpoint: APIEndpoint,
169
- endpoint_args: tuple[str, ...] = (),
170
- *,
171
- timeout: float,
172
- retry: bool = False,
173
- allow_errors: bool = False,
174
- ) -> requests.Response:
175
- """Make a HTTP GET request to an IQM server endpoint.
176
-
177
- Contains all the boilerplate code for making a simple GET request.
178
-
179
- Args:
180
- api_endpoint: API endpoint to GET.
181
- endpoint_args: Arguments for the endpoint.
182
- timeout: HTTP request timeout (in seconds).
183
- retry: Iff True, keep trying if you get a 502 error.
184
- allow_errors: Iff true, don't raise exceptions for error responses.
185
-
186
- Returns:
187
- HTTP response to the request.
188
-
189
- Raises:
190
- ClientAuthenticationError: No valid authentication provided.
191
- HTTPError: Various HTTP exceptions.
192
-
193
- """
194
- url = self._api.url(api_endpoint, *endpoint_args)
195
-
196
- def request():
197
- return requests.get(
198
- url,
199
- headers=self._default_headers(),
200
- timeout=timeout,
201
- )
202
-
203
- response = self._retry_request_on_error(request) if retry else request()
204
- if not allow_errors:
205
- self._check_not_found_error(response)
206
- self._check_authentication_errors(response)
207
- response.raise_for_status()
208
- return response
209
-
210
- def _deserialize_response(
211
- self,
212
- response: requests.Response,
213
- model_class: type[T_BaseModel],
214
- ) -> T_BaseModel:
215
- """Deserialize a HTTP endpoint response.
216
-
217
- Args:
218
- response: HTTP response data.
219
- model_class: Pydantic model to deserialize the data into.
220
-
221
- Returns:
222
- Deserialized endpoint response.
223
-
224
- Raises:
225
- EndpointRequestError: Did not understand the endpoint response.
226
-
227
- """
228
- try:
229
- model = model_class.model_validate(response.json())
230
- # TODO this would be faster but MockJsonResponse.text in our unit tests cannot handle UUID
231
- # model = model_class.model_validate_json(response.text)
232
- except json.decoder.JSONDecodeError as e:
233
- raise EndpointRequestError(f"Invalid response: {response.text}, {e!r}") from e
234
- return model
154
+ def get_health(self) -> dict:
155
+ """Return the status of the station control service."""
156
+ return self._station_control.get_health()
235
157
 
236
158
  def submit_circuits(
237
159
  self,
@@ -317,19 +239,20 @@ class IQMClient:
317
239
  e.__traceback__
318
240
  )
319
241
 
320
- architecture = self.get_dynamic_quantum_architecture(calibration_set_id)
321
-
322
- self._validate_qubit_mapping(architecture, circuits, qubit_mapping)
323
- serialized_qubit_mapping = serialize_qubit_mapping(qubit_mapping) if qubit_mapping else None
242
+ dynamic_quantum_architecture = self.get_dynamic_quantum_architecture(calibration_set_id)
324
243
 
244
+ validate_qubit_mapping(dynamic_quantum_architecture, circuits, qubit_mapping)
325
245
  # validate the circuit against the calibration-dependent dynamic quantum architecture
326
- self._validate_circuit_instructions(
327
- architecture,
246
+ validate_circuit_instructions(
247
+ dynamic_quantum_architecture,
328
248
  circuits,
329
249
  qubit_mapping,
330
250
  validate_moves=options.move_gate_validation,
331
251
  must_close_sandwiches=False,
332
252
  )
253
+
254
+ serialized_qubit_mapping = serialize_qubit_mapping(qubit_mapping) if qubit_mapping else None
255
+
333
256
  return RunRequest(
334
257
  qubit_mapping=serialized_qubit_mapping,
335
258
  circuits=circuits,
@@ -362,25 +285,24 @@ class IQMClient:
362
285
  **self._default_headers(),
363
286
  }
364
287
  try:
365
- # check if someone is trying to profile us with OpenTelemetry
288
+ # Check if someone is trying to profile us with OpenTelemetry
366
289
  from opentelemetry import propagate
367
290
 
368
291
  propagate.inject(headers)
369
292
  except ImportError as _:
370
- # no OpenTelemetry, no problem
293
+ # No OpenTelemetry, no problem
371
294
  pass
372
295
 
373
296
  if os.environ.get("IQM_CLIENT_DEBUG") == "1":
374
297
  print(f"\nIQM CLIENT DEBUGGING ENABLED\nSUBMITTING RUN REQUEST:\n{run_request}\n")
375
298
 
376
- # use UTF-8 encoding for the JSON payload
377
- result = self._retry_request_on_error(
378
- lambda: requests.post(
379
- self._api.url(APIEndpoint.SUBMIT_JOB),
380
- data=run_request.model_dump_json(exclude_none=True).encode("utf-8"),
381
- headers=headers | {"Content-Type": "application/json; charset=UTF-8"},
382
- timeout=REQUESTS_TIMEOUT,
383
- )
299
+ # Use UTF-8 encoding for the JSON payload
300
+ result = requests.post(
301
+ # TODO SW-1434: Use station control client
302
+ self._api.url(APIEndpoint.SUBMIT_JOB),
303
+ data=run_request.model_dump_json(exclude_none=True).encode("utf-8"),
304
+ headers=headers | {"Content-Type": "application/json; charset=UTF-8"},
305
+ timeout=REQUESTS_TIMEOUT,
384
306
  )
385
307
 
386
308
  self._check_not_found_error(result)
@@ -399,275 +321,21 @@ class IQMClient:
399
321
  except (json.decoder.JSONDecodeError, KeyError) as e:
400
322
  raise CircuitExecutionError(f"Invalid response: {result.text}, {e}") from e
401
323
 
402
- @staticmethod
403
- def _validate_qubit_mapping(
404
- architecture: DynamicQuantumArchitecture,
405
- circuits: CircuitBatch,
406
- qubit_mapping: dict[str, str] | None = None,
407
- ) -> None:
408
- """Validate the given qubit mapping.
409
-
410
- Args:
411
- architecture: Quantum architecture to check against.
412
- circuits: Circuits to be checked.
413
- qubit_mapping: Mapping of logical qubit names to physical qubit names.
414
- Can be set to ``None`` if all ``circuits`` already use physical qubit names.
415
- Note that the ``qubit_mapping`` is used for all ``circuits``.
416
-
417
- Raises:
418
- CircuitValidationError: There was something wrong with ``circuits``.
419
-
420
- """
421
- if qubit_mapping is None:
422
- return
423
-
424
- # check if qubit mapping is injective
425
- target_qubits = set(qubit_mapping.values())
426
- if not len(target_qubits) == len(qubit_mapping):
427
- raise CircuitValidationError("Multiple logical qubits map to the same physical qubit.")
428
-
429
- # check if qubit mapping covers all qubits in the circuits
430
- for i, circuit in enumerate(circuits):
431
- diff = circuit.all_qubits() - set(qubit_mapping)
432
- if diff:
433
- raise CircuitValidationError(
434
- f"The qubits {diff} in circuit '{circuit.name}' at index {i} "
435
- f"are not found in the provided qubit mapping."
436
- )
437
-
438
- # check that each mapped qubit is defined in the quantum architecture
439
- for _logical, physical in qubit_mapping.items():
440
- if physical not in architecture.components:
441
- raise CircuitValidationError(f"Component {physical} not present in dynamic quantum architecture")
442
-
443
- @staticmethod
444
- def _validate_circuit_instructions(
445
- architecture: DynamicQuantumArchitecture,
446
- circuits: CircuitBatch,
447
- qubit_mapping: dict[str, str] | None = None,
448
- validate_moves: MoveGateValidationMode = MoveGateValidationMode.STRICT,
449
- *,
450
- must_close_sandwiches: bool = True,
451
- ) -> None:
452
- """Validate the given circuits against the given quantum architecture.
453
-
454
- Args:
455
- architecture: Quantum architecture to check against.
456
- circuits: Circuits to be checked.
457
- qubit_mapping: Mapping of logical qubit names to physical qubit names.
458
- Can be set to ``None`` if all ``circuits`` already use physical qubit names.
459
- Note that the ``qubit_mapping`` is used for all ``circuits``.
460
- validate_moves: Determines how MOVE gate validation works.
461
- must_close_sandwiches: Iff True, MOVE sandwiches cannot be left open when the circuit ends.
462
-
463
- Raises:
464
- CircuitValidationError: validation failed
465
-
466
- """
467
- for index, circuit in enumerate(circuits):
468
- measurement_keys: set[str] = set()
469
- for instr in circuit.instructions:
470
- IQMClient._validate_instruction(architecture, instr, qubit_mapping)
471
- # check measurement key uniqueness
472
- if instr.name in {"measure", "measurement"}:
473
- key = instr.args["key"]
474
- if key in measurement_keys:
475
- raise CircuitValidationError(f"Circuit {index}: {instr!r} has a non-unique measurement key.")
476
- measurement_keys.add(key)
477
- IQMClient._validate_circuit_moves(
478
- architecture,
479
- circuit,
480
- qubit_mapping,
481
- validate_moves=validate_moves,
482
- must_close_sandwiches=must_close_sandwiches,
483
- )
484
-
485
- @staticmethod
486
- def _validate_instruction(
487
- architecture: DynamicQuantumArchitecture,
488
- instruction: Instruction,
489
- qubit_mapping: dict[str, str] | None = None,
490
- ) -> None:
491
- """Validate an instruction against the dynamic quantum quantum architecture.
492
-
493
- Checks that the instruction uses a valid implementation, and targets a valid locus.
324
+ def get_run(self, job_id: UUID, *, timeout_secs: float = REQUESTS_TIMEOUT) -> RunResult:
325
+ """Query the status and results of a submitted job.
494
326
 
495
327
  Args:
496
- architecture: Quantum architecture to check against.
497
- instruction: Instruction to check.
498
- qubit_mapping: Mapping of logical qubit names to physical qubit names.
499
- Can be set to ``None`` if ``instruction`` already uses physical qubit names.
500
-
501
- Raises:
502
- CircuitValidationError: validation failed
503
-
504
- """
505
- op_info = _SUPPORTED_OPERATIONS.get(instruction.name)
506
- if op_info is None:
507
- raise CircuitValidationError(f"Unknown quantum operation '{instruction.name}'.")
508
-
509
- # apply the qubit mapping if any
510
- mapped_qubits = tuple(qubit_mapping[q] for q in instruction.qubits) if qubit_mapping else instruction.qubits
511
-
512
- def check_locus_components(allowed_components: Iterable[str], msg: str) -> None:
513
- """Checks that the instruction locus consists of the allowed components only."""
514
- for q, mapped_q in zip(instruction.qubits, mapped_qubits):
515
- if mapped_q not in allowed_components:
516
- raise CircuitValidationError(
517
- f"{instruction!r}: Component {q} = {mapped_q} {msg}."
518
- if qubit_mapping
519
- else f"{instruction!r}: Component {q} {msg}."
520
- )
521
-
522
- if op_info.no_calibration_needed:
523
- # all QPU loci are allowed
524
- check_locus_components(architecture.components, msg="does not exist on the QPU")
525
- return
526
-
527
- gate_info = architecture.gates.get(instruction.name)
528
- if gate_info is None:
529
- raise CircuitValidationError(
530
- f"Operation '{instruction.name}' is not supported by the dynamic quantum architecture."
531
- )
532
-
533
- if instruction.implementation is not None:
534
- # specific implementation requested
535
- impl_info = gate_info.implementations.get(instruction.implementation)
536
- if impl_info is None:
537
- raise CircuitValidationError(
538
- f"Operation '{instruction.name}' implementation '{instruction.implementation}' "
539
- f"is not supported by the dynamic quantum architecture."
540
- )
541
- allowed_loci = impl_info.loci
542
- instruction_name = f"{instruction.name}.{instruction.implementation}"
543
- else:
544
- # any implementation is fine
545
- allowed_loci = gate_info.loci
546
- instruction_name = f"{instruction.name}"
547
-
548
- if op_info.factorizable:
549
- # Check that all the locus components are allowed by the architecture
550
- check_locus_components(
551
- {q for locus in allowed_loci for q in locus}, msg=f"is not allowed as locus for '{instruction_name}'"
552
- )
553
- return
554
-
555
- # Check that locus matches one of the allowed loci
556
- all_loci = (
557
- tuple(tuple(x) for locus in allowed_loci for x in itertools.permutations(locus))
558
- if op_info.symmetric
559
- else allowed_loci
560
- )
561
- if mapped_qubits not in all_loci:
562
- raise CircuitValidationError(
563
- f"{instruction.qubits} = {tuple(mapped_qubits)} is not allowed as locus for '{instruction_name}'"
564
- if qubit_mapping
565
- else f"{instruction.qubits} is not allowed as locus for '{instruction_name}'"
566
- )
567
-
568
- @staticmethod
569
- def _validate_circuit_moves(
570
- architecture: DynamicQuantumArchitecture,
571
- circuit: Circuit,
572
- qubit_mapping: dict[str, str] | None = None,
573
- validate_moves: MoveGateValidationMode = MoveGateValidationMode.STRICT,
574
- *,
575
- must_close_sandwiches: bool = True,
576
- ) -> None:
577
- """Raise an error if the MOVE gates in the circuit are not valid in the given architecture.
328
+ job_id: ID of the job to query.
329
+ timeout_secs: Network request timeout (seconds).
578
330
 
579
- Args:
580
- architecture: Quantum architecture to check against.
581
- circuit: Quantum circuit to validate.
582
- qubit_mapping: Mapping of logical qubit names to physical qubit names.
583
- Can be set to ``None`` if the ``circuit`` already uses physical qubit names.
584
- validate_moves: Option for bypassing full or partial MOVE gate validation.
585
- must_close_sandwiches: Iff True, MOVE sandwiches cannot be left open when the circuit ends.
331
+ Returns:
332
+ Result of the job (can be pending).
586
333
 
587
334
  Raises:
588
- CircuitValidationError: validation failed
589
-
590
- """
591
- if validate_moves == MoveGateValidationMode.NONE:
592
- return
593
- move_gate = "move"
594
- # Check if MOVE gates are allowed on this architecture
595
- if move_gate not in architecture.gates:
596
- if any(i.name == move_gate for i in circuit.instructions):
597
- raise CircuitValidationError("MOVE instruction is not supported by the given device architecture.")
598
- return
599
-
600
- # some gates are allowed in MOVE sandwiches
601
- allowed_gates = {"barrier"}
602
- if validate_moves == MoveGateValidationMode.ALLOW_PRX:
603
- allowed_gates.add("prx")
604
-
605
- all_resonators = set(architecture.computational_resonators)
606
- all_qubits = set(architecture.qubits)
607
- if qubit_mapping:
608
- reverse_mapping = {phys: log for log, phys in qubit_mapping.items()}
609
- all_resonators = {reverse_mapping[q] if q in reverse_mapping else q for q in all_resonators}
610
- all_qubits = {reverse_mapping[q] if q in reverse_mapping else q for q in all_qubits}
611
-
612
- # Mapping from resonator to the qubit whose state it holds. Resonators not in the map hold no qubit state.
613
- resonator_occupations: dict[str, str] = {}
614
- # Qubits whose states are currently moved to a resonator
615
- moved_qubits: set[str] = set()
616
-
617
- for inst in circuit.instructions:
618
- if inst.name == "move":
619
- qubit, resonator = inst.qubits
620
- if not (qubit in all_qubits and resonator in all_resonators):
621
- raise CircuitValidationError(
622
- f"MOVE instructions are only allowed between qubit and resonator, not {inst.qubits}."
623
- )
624
-
625
- if (resonator_qubit := resonator_occupations.get(resonator)) is None:
626
- # Beginning MOVE: check that the qubit hasn't been moved to another resonator
627
- if qubit in moved_qubits:
628
- raise CircuitValidationError(
629
- f"MOVE instruction {inst.qubits}: state of {qubit} is "
630
- f"in another resonator: {resonator_occupations}."
631
- )
632
- resonator_occupations[resonator] = qubit
633
- moved_qubits.add(qubit)
634
- else:
635
- # Ending MOVE: check that the qubit matches to the qubit that was moved to the resonator
636
- if resonator_qubit != qubit:
637
- raise CircuitValidationError(
638
- f"MOVE instruction {inst.qubits} to an already occupied resonator: {resonator_occupations}."
639
- )
640
- del resonator_occupations[resonator]
641
- moved_qubits.remove(qubit)
642
- elif moved_qubits:
643
- # Validate that moved qubits are not used during MOVE operations
644
- if inst.name not in allowed_gates:
645
- if overlap := set(inst.qubits) & moved_qubits:
646
- raise CircuitValidationError(
647
- f"Instruction {inst.name} acts on {inst.qubits} while the state(s) of {overlap} "
648
- f"are in a resonator. Current resonator occupation: {resonator_occupations}."
649
- )
650
-
651
- # Finally validate that all MOVE sandwiches have been ended before the circuit ends
652
- if must_close_sandwiches and resonator_occupations:
653
- raise CircuitValidationError(
654
- f"Circuit ends while qubit state(s) are still in a resonator: {resonator_occupations}."
655
- )
335
+ CircuitExecutionError: IQM server specific exceptions
336
+ HTTPException: HTTP exceptions
656
337
 
657
- def _get_run_v1(self, job_id: UUID, timeout_secs: float = REQUESTS_TIMEOUT) -> RunResult:
658
- """V1 API (Cocos circuit execution and Resonance) has an inefficient `GET /jobs/<id>` endpoint
659
- that returns the full job status, including the result and the original request, in a single call.
660
338
  """
661
- response = self._get_request(
662
- APIEndpoint.GET_JOB_RESULT,
663
- (str(job_id),),
664
- timeout=timeout_secs,
665
- retry=True,
666
- )
667
- return self._deserialize_response(response, RunResult)
668
-
669
- def _get_run_v2(self, job_id: UUID, timeout_secs: float = REQUESTS_TIMEOUT) -> RunResult:
670
- """V2 API (Station-based circuit execution) has granular endpoints for job status and result."""
671
339
  status_response = self._get_request(
672
340
  APIEndpoint.GET_JOB_STATUS,
673
341
  (str(job_id),),
@@ -677,9 +345,7 @@ class IQMClient:
677
345
  if Status(status["status"]) not in Status.terminal_statuses():
678
346
  return RunResult.from_dict({"status": status["status"], "metadata": {}})
679
347
 
680
- result = self._get_request(
681
- APIEndpoint.GET_JOB_RESULT, (str(job_id),), timeout=timeout_secs, retry=True, allow_errors=True
682
- )
348
+ result = self._get_request(APIEndpoint.GET_JOB_RESULT, (str(job_id),), timeout=timeout_secs, allow_errors=True)
683
349
 
684
350
  error_log_response = self._get_request(
685
351
  APIEndpoint.GET_JOB_ERROR_LOG, (str(job_id),), timeout=timeout_secs, allow_errors=True
@@ -696,63 +362,46 @@ class IQMClient:
696
362
  error_message = None
697
363
 
698
364
  if result.status_code == 404:
699
- return RunResult.from_dict({"status": status["status"], "message": error_message, "metadata": {}})
365
+ run_result = RunResult.from_dict({"status": status["status"], "message": error_message, "metadata": {}})
700
366
  else:
701
367
  result.raise_for_status()
702
368
 
703
- measurements = result.json()
704
- request_parameters = self._get_request(
705
- APIEndpoint.GET_JOB_REQUEST_PARAMETERS, (str(job_id),), timeout=timeout_secs, allow_errors=True
706
- ).json()
707
- calibration_set_id = self._get_request(
708
- APIEndpoint.GET_JOB_CALIBRATION_SET_ID, (str(job_id),), timeout=timeout_secs, allow_errors=True
709
- ).json()
710
- circuits_batch = self._get_request(
711
- APIEndpoint.GET_JOB_CIRCUITS_BATCH, (str(job_id),), timeout=timeout_secs, allow_errors=True
712
- ).json()
713
- timeline = self._get_request(
714
- APIEndpoint.GET_JOB_TIMELINE, (str(job_id),), timeout=timeout_secs, allow_errors=True
715
- ).json()
716
-
717
- return RunResult.from_dict(
718
- {
719
- "measurements": measurements,
720
- "status": status["status"],
721
- "message": error_message,
722
- "metadata": {
723
- "calibration_set_id": calibration_set_id,
724
- "circuits_batch": circuits_batch,
725
- "parameters": {
726
- "shots": request_parameters["shots"],
727
- "max_circuit_duration_over_t2": request_parameters["max_circuit_duration_over_t2"],
728
- "heralding_mode": request_parameters["heralding_mode"],
729
- "move_validation_mode": request_parameters["move_validation_mode"],
730
- "move_gate_frame_tracking_mode": request_parameters["move_gate_frame_tracking_mode"],
369
+ measurements = result.json()
370
+ request_parameters = self._get_request(
371
+ APIEndpoint.GET_JOB_REQUEST_PARAMETERS, (str(job_id),), timeout=timeout_secs, allow_errors=True
372
+ ).json()
373
+ calibration_set_id = self._get_request(
374
+ APIEndpoint.GET_JOB_CALIBRATION_SET_ID, (str(job_id),), timeout=timeout_secs, allow_errors=True
375
+ ).json()
376
+ circuits_batch = self._get_request(
377
+ APIEndpoint.GET_JOB_CIRCUITS_BATCH, (str(job_id),), timeout=timeout_secs, allow_errors=True
378
+ ).json()
379
+ timeline = self._get_request(
380
+ APIEndpoint.GET_JOB_TIMELINE, (str(job_id),), timeout=timeout_secs, allow_errors=True
381
+ ).json()
382
+
383
+ run_result = RunResult.from_dict(
384
+ {
385
+ "measurements": measurements,
386
+ "status": status["status"],
387
+ "message": error_message,
388
+ "metadata": {
389
+ "calibration_set_id": calibration_set_id,
390
+ "circuits_batch": circuits_batch,
391
+ "parameters": {
392
+ "shots": request_parameters["shots"],
393
+ "max_circuit_duration_over_t2": request_parameters.get(
394
+ "max_circuit_duration_over_t2", None
395
+ ),
396
+ "heralding_mode": request_parameters["heralding_mode"],
397
+ "move_validation_mode": request_parameters["move_validation_mode"],
398
+ "move_gate_frame_tracking_mode": request_parameters["move_gate_frame_tracking_mode"],
399
+ },
400
+ "timestamps": {datapoint["status"]: datapoint["timestamp"] for datapoint in timeline},
731
401
  },
732
- "timestamps": {datapoint["status"]: datapoint["timestamp"] for datapoint in timeline},
733
- },
734
- }
735
- )
736
-
737
- def get_run(self, job_id: UUID, *, timeout_secs: float = REQUESTS_TIMEOUT) -> RunResult:
738
- """Query the status and results of a submitted job.
739
-
740
- Args:
741
- job_id: ID of the job to query.
742
- timeout_secs: Network request timeout (seconds).
743
-
744
- Returns:
745
- Result of the job (can be pending).
746
-
747
- Raises:
748
- CircuitExecutionError: IQM server specific exceptions
749
- HTTPException: HTTP exceptions
750
-
751
- """
752
- if self._api.variant == APIVariant.V2:
753
- run_result = self._get_run_v2(job_id, timeout_secs)
754
- else:
755
- run_result = self._get_run_v1(job_id, timeout_secs)
402
+ "warnings": status.get("warnings", []),
403
+ }
404
+ )
756
405
 
757
406
  if run_result.warnings:
758
407
  for warning in run_result.warnings:
@@ -780,7 +429,6 @@ class IQMClient:
780
429
  APIEndpoint.GET_JOB_STATUS,
781
430
  (str(job_id),),
782
431
  timeout=timeout_secs,
783
- retry=True,
784
432
  )
785
433
  run_status = self._deserialize_response(response, RunStatus)
786
434
 
@@ -806,7 +454,7 @@ class IQMClient:
806
454
  start_time = datetime.now()
807
455
  while (datetime.now() - start_time).total_seconds() < timeout_secs:
808
456
  status = self.get_run_status(job_id).status
809
- if status in Status.terminal_statuses() | {Status.PENDING_EXECUTION, Status.COMPILED}:
457
+ if status in Status.terminal_statuses() | {Status.PENDING_EXECUTION, Status.COMPILATION_ENDED}:
810
458
  return self.get_run(job_id)
811
459
  time.sleep(SECONDS_BETWEEN_CALLS)
812
460
  raise APITimeoutError(f"The job compilation didn't finish in {timeout_secs} seconds.")
@@ -856,44 +504,11 @@ class IQMClient:
856
504
  if result.status_code not in (200, 204): # CoCos returns 200, station-control 204.
857
505
  raise JobAbortionError(result.text)
858
506
 
859
- def get_quantum_architecture(self, *, timeout_secs: float = REQUESTS_TIMEOUT) -> QuantumArchitectureSpecification:
860
- """Retrieve quantum architecture from server.
861
-
862
- Caches the result and returns it on later invocations.
863
-
864
- Args:
865
- timeout_secs: Network request timeout (seconds).
866
-
867
- Returns:
868
- Quantum architecture of the server.
869
-
870
- Raises:
871
- EndpointRequestError: did not understand the endpoint response
872
- ClientAuthenticationError: no valid authentication provided
873
- HTTPException: HTTP exceptions
874
-
875
- """
876
- if self._architecture:
877
- return self._architecture
878
-
879
- response = self._get_request(
880
- APIEndpoint.QUANTUM_ARCHITECTURE,
881
- timeout=timeout_secs,
882
- )
883
- qa = self._deserialize_response(response, QuantumArchitecture).quantum_architecture
884
-
885
- # Cache architecture so that later invocations do not need to query it again
886
- self._architecture = qa
887
- return qa
888
-
889
- def get_static_quantum_architecture(self, *, timeout_secs: float = REQUESTS_TIMEOUT) -> StaticQuantumArchitecture:
507
+ def get_static_quantum_architecture(self) -> StaticQuantumArchitecture:
890
508
  """Retrieve the static quantum architecture (SQA) from the server.
891
509
 
892
510
  Caches the result and returns it on later invocations.
893
511
 
894
- Args:
895
- timeout_secs: Network request timeout (seconds).
896
-
897
512
  Returns:
898
513
  Static quantum architecture of the server.
899
514
 
@@ -906,25 +521,17 @@ class IQMClient:
906
521
  if self._static_architecture:
907
522
  return self._static_architecture
908
523
 
909
- response = self._get_request(
910
- APIEndpoint.STATIC_QUANTUM_ARCHITECTURE,
911
- timeout=timeout_secs,
912
- )
913
- sqa = self._deserialize_response(response, StaticQuantumArchitecture)
914
-
915
- # Cache the architecture so that later invocations do not need to query it again
916
- self._static_architecture = sqa
917
- return sqa
524
+ dut_label = self._get_dut_label()
525
+ static_quantum_architecture = self._station_control.get_static_quantum_architecture(dut_label)
526
+ self._static_architecture = StaticQuantumArchitecture(**static_quantum_architecture.model_dump())
527
+ return self._static_architecture
918
528
 
919
- def get_quality_metric_set(
920
- self, calibration_set_id: UUID | None = None, *, timeout_secs: float = REQUESTS_TIMEOUT
921
- ) -> QualityMetricSet:
529
+ def get_quality_metric_set(self, calibration_set_id: UUID | None = None) -> QualityMetricSet:
922
530
  """Retrieve the latest quality metric set for the given calibration set from the server.
923
531
 
924
532
  Args:
925
533
  calibration_set_id: ID of the calibration set for which the quality metrics are returned.
926
534
  If ``None``, the current default calibration set is used.
927
- timeout_secs: Network request timeout (seconds).
928
535
 
929
536
  Returns:
930
537
  Requested quality metric set.
@@ -935,27 +542,53 @@ class IQMClient:
935
542
  HTTPException: HTTP exceptions
936
543
 
937
544
  """
938
- if calibration_set_id is None:
939
- calibration_set_id_str = "default"
940
- else:
941
- calibration_set_id_str = str(calibration_set_id)
545
+ if isinstance(self._station_control, IqmServerClient):
546
+ raise ValueError("'get_quality_metric_set' method is not supported for IqmServerClient.")
942
547
 
943
- response = self._get_request(
944
- APIEndpoint.QUALITY_METRICS,
945
- (calibration_set_id_str,),
946
- timeout=timeout_secs,
548
+ if not calibration_set_id:
549
+ quality_metrics = self._station_control.get_default_calibration_set_quality_metrics()
550
+ else:
551
+ quality_metrics = self._station_control.get_calibration_set_quality_metrics(calibration_set_id)
552
+
553
+ calibration_set = quality_metrics.calibration_set
554
+
555
+ return QualityMetricSet(
556
+ **{
557
+ "calibration_set_id": calibration_set.observation_set_id,
558
+ "calibration_set_dut_label": calibration_set.dut_label,
559
+ "calibration_set_number_of_observations": len(calibration_set.observation_ids),
560
+ "calibration_set_created_timestamp": str(calibration_set.created_timestamp.isoformat()),
561
+ "calibration_set_end_timestamp": None
562
+ if calibration_set.end_timestamp is None
563
+ else str(calibration_set.end_timestamp.isoformat()),
564
+ "calibration_set_is_invalid": calibration_set.invalid,
565
+ "quality_metric_set_id": quality_metrics.observation_set_id,
566
+ "quality_metric_set_dut_label": quality_metrics.dut_label,
567
+ "quality_metric_set_created_timestamp": str(quality_metrics.created_timestamp.isoformat()),
568
+ "quality_metric_set_end_timestamp": None
569
+ if quality_metrics.end_timestamp is None
570
+ else str(quality_metrics.end_timestamp.isoformat()),
571
+ "quality_metric_set_is_invalid": quality_metrics.invalid,
572
+ "metrics": {
573
+ observation.dut_field: {
574
+ "value": str(observation.value),
575
+ "unit": observation.unit,
576
+ "uncertainty": str(observation.uncertainty),
577
+ # created_timestamp is the interesting one, since observations are effectively immutable
578
+ "timestamp": str(observation.created_timestamp.isoformat()),
579
+ }
580
+ for observation in quality_metrics.observations
581
+ if not observation.invalid
582
+ },
583
+ }
947
584
  )
948
- return self._deserialize_response(response, QualityMetricSet)
949
585
 
950
- def get_calibration_set(
951
- self, calibration_set_id: UUID | None = None, *, timeout_secs: float = REQUESTS_TIMEOUT
952
- ) -> CalibrationSet:
586
+ def get_calibration_set(self, calibration_set_id: UUID | None = None) -> CalibrationSet:
953
587
  """Retrieve the given calibration set from the server.
954
588
 
955
589
  Args:
956
590
  calibration_set_id: ID of the calibration set to retrieve.
957
591
  If ``None``, the current default calibration set is retrieved.
958
- timeout_secs: Network request timeout (seconds).
959
592
 
960
593
  Returns:
961
594
  Requested calibration set.
@@ -966,21 +599,36 @@ class IQMClient:
966
599
  HTTPException: HTTP exceptions
967
600
 
968
601
  """
969
- if calibration_set_id is None:
970
- calibration_set_id_str = "default"
971
- else:
972
- calibration_set_id_str = str(calibration_set_id)
973
602
 
974
- response = self._get_request(
975
- APIEndpoint.CALIBRATION,
976
- (calibration_set_id_str,),
977
- timeout=timeout_secs,
603
+ def _observation_lite_to_json(obs: ObservationLite) -> dict[str, Any]:
604
+ """Convert ObservationLite to JSON serializable dictionary."""
605
+ json_dict = obs.model_dump()
606
+ json_dict["created_timestamp"] = obs.created_timestamp.isoformat(timespec="microseconds")
607
+ json_dict["modified_timestamp"] = obs.modified_timestamp.isoformat(timespec="microseconds")
608
+ return json_dict
609
+
610
+ if isinstance(self._station_control, IqmServerClient):
611
+ raise ValueError("'get_calibration_set' method is not supported for IqmServerClient.")
612
+
613
+ if not calibration_set_id:
614
+ calibration_set = self._station_control.get_default_calibration_set()
615
+ else:
616
+ calibration_set = self._station_control.get_observation_set(calibration_set_id)
617
+
618
+ observations = self._station_control.get_observation_set_observations(calibration_set.observation_set_id)
619
+
620
+ return CalibrationSet(
621
+ calibration_set_id=calibration_set.observation_set_id,
622
+ calibration_set_dut_label=calibration_set.dut_label,
623
+ calibration_set_created_timestamp=str(calibration_set.created_timestamp.isoformat()),
624
+ calibration_set_end_timestamp=str(calibration_set.end_timestamp.isoformat()),
625
+ calibration_set_is_invalid=calibration_set.invalid,
626
+ observations={
627
+ observation.dut_field: _observation_lite_to_json(observation) for observation in observations
628
+ },
978
629
  )
979
- return self._deserialize_response(response, CalibrationSet)
980
630
 
981
- def get_dynamic_quantum_architecture(
982
- self, calibration_set_id: UUID | None = None, *, timeout_secs: float = REQUESTS_TIMEOUT
983
- ) -> DynamicQuantumArchitecture:
631
+ def get_dynamic_quantum_architecture(self, calibration_set_id: UUID | None = None) -> DynamicQuantumArchitecture:
984
632
  """Retrieve the dynamic quantum architecture (DQA) for the given calibration set from the server.
985
633
 
986
634
  Caches the result and returns the same result on later invocations, unless ``calibration_set_id`` is ``None``.
@@ -990,7 +638,6 @@ class IQMClient:
990
638
  Args:
991
639
  calibration_set_id: ID of the calibration set for which the DQA is retrieved.
992
640
  If ``None``, use current default calibration set on the server.
993
- timeout_secs: Network request timeout (seconds).
994
641
 
995
642
  Returns:
996
643
  Dynamic quantum architecture corresponding to the given calibration set.
@@ -1001,35 +648,28 @@ class IQMClient:
1001
648
  HTTPException: HTTP exceptions
1002
649
 
1003
650
  """
1004
- if calibration_set_id is None:
1005
- calibration_set_id_str = "default"
1006
- elif calibration_set_id in self._dynamic_architectures:
651
+ if calibration_set_id in self._dynamic_architectures:
1007
652
  return self._dynamic_architectures[calibration_set_id]
1008
- else:
1009
- calibration_set_id_str = str(calibration_set_id)
1010
653
 
1011
- response = self._get_request(
1012
- APIEndpoint.CALIBRATED_GATES,
1013
- (calibration_set_id_str,),
1014
- timeout=timeout_secs,
1015
- )
1016
- dqa = self._deserialize_response(response, DynamicQuantumArchitecture)
654
+ if not calibration_set_id:
655
+ if isinstance(self._station_control, IqmServerClient):
656
+ dut_label = self._get_dut_label()
657
+ calibration_set_id = self._station_control.get_latest_calibration_set_id(dut_label)
658
+ else:
659
+ calibration_set_id = self._station_control.get_default_calibration_set().observation_set_id
660
+ data = self._station_control.get_dynamic_quantum_architecture(calibration_set_id)
661
+ dynamic_quantum_architecture = DynamicQuantumArchitecture(**data.model_dump())
1017
662
 
1018
663
  # Cache architecture so that later invocations do not need to query it again
1019
- self._dynamic_architectures[dqa.calibration_set_id] = dqa
1020
- return dqa
664
+ self._dynamic_architectures[dynamic_quantum_architecture.calibration_set_id] = dynamic_quantum_architecture
665
+ return dynamic_quantum_architecture
1021
666
 
1022
- def get_feedback_groups(self, *, timeout_secs: float = REQUESTS_TIMEOUT) -> tuple[frozenset[str], ...]:
667
+ def get_feedback_groups(self) -> tuple[frozenset[str], ...]:
1023
668
  """Retrieve groups of qubits that can receive real-time feedback signals from each other.
1024
669
 
1025
670
  Real-time feedback enables conditional gates such as `cc_prx`.
1026
671
  Some hardware configurations support routing real-time feedback only between certain qubits.
1027
672
 
1028
- This method is only supported for the API variant V2.
1029
-
1030
- Args:
1031
- timeout_secs: Network request timeout (seconds).
1032
-
1033
673
  Returns:
1034
674
  Feedback groups. Within a group, any qubit can receive real-time feedback from any other qubit in
1035
675
  the same group. A qubit can belong to multiple groups.
@@ -1041,16 +681,9 @@ class IQMClient:
1041
681
  HTTPException: HTTP exceptions
1042
682
 
1043
683
  """
1044
- response = self._get_request(
1045
- APIEndpoint.CHANNEL_PROPERTIES,
1046
- timeout=timeout_secs,
1047
- )
1048
- try:
1049
- channel_properties = DictDict.validate_json(response.text)
1050
- except PydanticValidationError as e:
1051
- raise EndpointRequestError(f"Invalid response: {response.text}, {e!r}") from e
684
+ channel_properties = self._station_control.get_channel_properties()
1052
685
 
1053
- all_qubits = self.get_quantum_architecture().qubits
686
+ all_qubits = self.get_static_quantum_architecture().qubits
1054
687
  groups: dict[str, set[str]] = {}
1055
688
  # All qubits that can read from the same source belong to the same group.
1056
689
  # A qubit may belong to multiple groups.
@@ -1059,13 +692,70 @@ class IQMClient:
1059
692
  qubit = channel_name.split("__")[0]
1060
693
  if qubit not in all_qubits:
1061
694
  continue
1062
- for source in properties.get("fast_feedback_sources", ()):
1063
- groups.setdefault(source, set()).add(qubit)
695
+ if isinstance(properties, AWGProperties):
696
+ for source in properties.fast_feedback_sources:
697
+ groups.setdefault(source, set()).add(qubit)
1064
698
  # Merge identical groups
1065
699
  unique_groups: set[frozenset[str]] = {frozenset(group) for group in groups.values()}
1066
700
  # Sort by group size
1067
701
  return tuple(sorted(unique_groups, key=len, reverse=True))
1068
702
 
703
+ def get_run_counts(self, job_id: UUID, *, timeout_secs: float = REQUESTS_TIMEOUT) -> RunCounts:
704
+ """Query the counts of an executed job.
705
+
706
+ Args:
707
+ job_id: ID of the job to query.
708
+ timeout_secs: Network request timeout (seconds).
709
+
710
+ Returns:
711
+ Measurement results of the job in histogram representation.
712
+
713
+ Raises:
714
+ EndpointRequestError: did not understand the endpoint response
715
+ ClientAuthenticationError: no valid authentication provided
716
+ HTTPException: HTTP exceptions
717
+
718
+ """
719
+ response = self._get_request(
720
+ APIEndpoint.GET_JOB_COUNTS,
721
+ (str(job_id),),
722
+ timeout=timeout_secs,
723
+ )
724
+ return self._deserialize_response(response, RunCounts)
725
+
726
+ def get_supported_client_libraries(self, timeout_secs: float = REQUESTS_TIMEOUT) -> dict[str, ClientLibrary] | None:
727
+ """Retrieve information about supported client libraries from the server.
728
+
729
+ Args:
730
+ timeout_secs: Network request timeout (seconds).
731
+
732
+ Returns:
733
+ Mapping from library identifiers to their metadata.
734
+
735
+ Raises:
736
+ EndpointRequestError: did not understand the endpoint response
737
+ ClientAuthenticationError: no valid authentication provided
738
+ HTTPException: HTTP exceptions
739
+
740
+ """
741
+ # TODO: Remove "client-libraries" usage after using versioned URLs in station control
742
+ # Version incompatibility shouldn't be a problem after that anymore,
743
+ # so we can delete this "client-libraries" implementation and usage.
744
+ response = requests.get(
745
+ # "/info/client-libraries" is implemented by Nginx so it won't work on locally running service.
746
+ # We will simply give warning in that case, so that IQMClient can be initialized also locally.
747
+ # "/station" is set by Nginx, so we will drop it to get the correct root for "/info/client-libraries".
748
+ self._api.station_control_url.replace("/station", "") + "/info/client-libraries",
749
+ headers=self._default_headers(),
750
+ timeout=timeout_secs,
751
+ )
752
+ if response.status_code == HTTPStatus.NOT_FOUND:
753
+ return None
754
+ try:
755
+ return ClientLibraryDict.validate_json(response.text)
756
+ except ValidationError as e:
757
+ raise EndpointRequestError(f"Invalid response: {response.text}, {e!r}") from e
758
+
1069
759
  def close_auth_session(self) -> bool:
1070
760
  """Terminate session with authentication server if there is one.
1071
761
 
@@ -1119,6 +809,8 @@ class IQMClient:
1119
809
  """
1120
810
  try:
1121
811
  libraries = self.get_supported_client_libraries()
812
+ if not libraries:
813
+ return "Got 'Not found' response from server. Couldn't check version compatibility."
1122
814
  compatible_iqm_client = libraries.get(
1123
815
  "iqm-client",
1124
816
  libraries.get("iqm_client"),
@@ -1140,50 +832,73 @@ class IQMClient:
1140
832
  check_error = e
1141
833
  return f"Could not verify IQM Client compatibility with the server. You might encounter issues. {check_error}"
1142
834
 
1143
- def get_run_counts(self, job_id: UUID, *, timeout_secs: float = REQUESTS_TIMEOUT) -> RunCounts:
1144
- """Query the counts of an executed job.
835
+ @lru_cache(maxsize=1)
836
+ def _get_dut_label(self) -> str:
837
+ duts = self._station_control.get_duts()
838
+ if len(duts) != 1:
839
+ raise RuntimeError(f"Expected exactly 1 DUT, but got {len(duts)}.")
840
+ return duts[0].label
841
+
842
+ def _get_request(
843
+ self,
844
+ api_endpoint: APIEndpoint,
845
+ endpoint_args: tuple[str, ...] = (),
846
+ *,
847
+ timeout: float,
848
+ headers: dict | None = None,
849
+ allow_errors: bool = False,
850
+ ) -> requests.Response:
851
+ """Make an HTTP GET request to an IQM server endpoint.
852
+
853
+ Contains all the boilerplate code for making a simple GET request.
1145
854
 
1146
855
  Args:
1147
- job_id: ID of the job to query.
1148
- timeout_secs: Network request timeout (seconds).
856
+ api_endpoint: API endpoint to GET.
857
+ endpoint_args: Arguments for the endpoint.
858
+ timeout: HTTP request timeout (in seconds).
1149
859
 
1150
860
  Returns:
1151
- Measurement results of the job in histogram representation.
861
+ HTTP response to the request.
1152
862
 
1153
863
  Raises:
1154
- EndpointRequestError: did not understand the endpoint response
1155
- ClientAuthenticationError: no valid authentication provided
1156
- HTTPException: HTTP exceptions
864
+ ClientAuthenticationError: No valid authentication provided.
865
+ HTTPError: Various HTTP exceptions.
1157
866
 
1158
867
  """
1159
- response = self._get_request(
1160
- APIEndpoint.GET_JOB_COUNTS,
1161
- (str(job_id),),
1162
- timeout=timeout_secs,
1163
- retry=True,
868
+ url = self._api.url(api_endpoint, *endpoint_args)
869
+ response = requests.get(
870
+ url,
871
+ headers=headers or self._default_headers(),
872
+ timeout=timeout,
1164
873
  )
1165
- return self._deserialize_response(response, RunCounts)
874
+ if not allow_errors:
875
+ self._check_not_found_error(response)
876
+ self._check_authentication_errors(response)
877
+ response.raise_for_status()
878
+ return response
1166
879
 
1167
- def get_supported_client_libraries(self, timeout_secs: float = REQUESTS_TIMEOUT) -> dict[str, ClientLibrary]:
1168
- """Retrieve information about supported client libraries from the server.
880
+ @staticmethod
881
+ def _deserialize_response(
882
+ response: requests.Response,
883
+ model_class: type[T_BaseModel],
884
+ ) -> T_BaseModel:
885
+ """Deserialize a HTTP endpoint response.
1169
886
 
1170
887
  Args:
1171
- timeout_secs: Network request timeout (seconds).
888
+ response: HTTP response data.
889
+ model_class: Pydantic model to deserialize the data into.
1172
890
 
1173
891
  Returns:
1174
- Mapping from library identifiers to their metadata.
892
+ Deserialized endpoint response.
1175
893
 
1176
894
  Raises:
1177
- EndpointRequestError: did not understand the endpoint response
1178
- ClientAuthenticationError: no valid authentication provided
1179
- HTTPException: HTTP exceptions
895
+ EndpointRequestError: Did not understand the endpoint response.
1180
896
 
1181
897
  """
1182
- response = self._get_request(
1183
- APIEndpoint.CLIENT_LIBRARIES,
1184
- timeout=timeout_secs,
1185
- )
1186
898
  try:
1187
- return ClientLibraryDict.validate_json(response.text)
1188
- except PydanticValidationError as e:
899
+ model = model_class.model_validate(response.json())
900
+ # TODO this would be faster but MockJsonResponse.text in our unit tests cannot handle UUID
901
+ # model = model_class.model_validate_json(response.text)
902
+ except json.decoder.JSONDecodeError as e:
1189
903
  raise EndpointRequestError(f"Invalid response: {response.text}, {e!r}") from e
904
+ return model