iqm-station-control-client 4.0.0__py3-none-any.whl → 4.2.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.
@@ -28,6 +28,7 @@ import uuid
28
28
  from iqm.models.channel_properties import ChannelProperties
29
29
  from opentelemetry import propagate, trace
30
30
  from packaging.version import Version, parse
31
+ from pydantic import BaseModel
31
32
  import requests
32
33
 
33
34
  from exa.common.data.setting_node import SettingNode
@@ -159,7 +160,7 @@ class StationControlClient:
159
160
  self._qcm_data_client = QCMDataClient(qcm_url) if qcm_url else None
160
161
 
161
162
  @property
162
- def version(self):
163
+ def version(self) -> str:
163
164
  """Return the version of the station control API this client is using."""
164
165
  return "v1"
165
166
 
@@ -220,8 +221,8 @@ class StationControlClient:
220
221
  def get_or_create_software_version_set(self, software_version_set: SoftwareVersionSet) -> int:
221
222
  """Get software version set ID from the database, or create if it doesn't exist."""
222
223
  # FIXME: We don't have information if the object was created or fetched. Thus, server always responds 200 (OK).
223
- data = json.dumps(software_version_set)
224
- response = self._send_request(requests.post, "software-version-sets", data=data)
224
+ json_str = json.dumps(software_version_set)
225
+ response = self._send_request(requests.post, "software-version-sets", json_str=json_str)
225
226
  return int(response.content)
226
227
 
227
228
  def get_settings(self) -> SettingNode:
@@ -230,7 +231,7 @@ class StationControlClient:
230
231
 
231
232
  @cache
232
233
  def _get_cached_settings(self) -> SettingNode:
233
- response = self._send_request(requests.get, "settings", headers={"Content-Type": "application/octet-stream"})
234
+ response = self._send_request(requests.get, "settings")
234
235
  return deserialize_setting_node(response.content)
235
236
 
236
237
  @cache
@@ -286,8 +287,7 @@ class StationControlClient:
286
287
 
287
288
  """
288
289
  data = serialize_sweep_job_request(sweep_definition, queue_name="sweeps")
289
- headers = {"Content-Type": "application/octet-stream"}
290
- return self._send_request(requests.post, "sweeps", data=data, headers=headers).json()
290
+ return self._send_request(requests.post, "sweeps", octets=data).json()
291
291
 
292
292
  def get_sweep(self, sweep_id: uuid.UUID) -> SweepData:
293
293
  """Get N-dimensional sweep data from the database."""
@@ -320,8 +320,7 @@ class StationControlClient:
320
320
  """Execute an N-dimensional sweep of selected variables and save run, sweep and results."""
321
321
  data = serialize_run_job_request(run_definition, queue_name="sweeps")
322
322
 
323
- headers = {"Content-Type": "application/octet-stream"}
324
- response = self._send_request(requests.post, "runs", data=data, headers=headers)
323
+ response = self._send_request(requests.post, "runs", octets=data)
325
324
  if wait_job_completion:
326
325
  return self._wait_job_completion(response.json()["job_id"], update_progress_callback)
327
326
  return False
@@ -378,8 +377,8 @@ class StationControlClient:
378
377
  Created observations, each including also the database created fields like ID and timestamps.
379
378
 
380
379
  """
381
- data = ObservationDefinitionList(observation_definitions).model_dump_json()
382
- response = self._send_request(requests.post, "observations", data=data)
380
+ json_str = self._serialize_model(ObservationDefinitionList(observation_definitions))
381
+ response = self._send_request(requests.post, "observations", json_str=json_str)
383
382
  return self._create_list_with_meta(response, ObservationDataList)
384
383
 
385
384
  def get_observations(
@@ -477,8 +476,8 @@ class StationControlClient:
477
476
  Updated observations, each including also the database created fields like ID and timestamps.
478
477
 
479
478
  """
480
- data = ObservationUpdateList(observation_updates).model_dump_json()
481
- response = self._send_request(requests.patch, "observations", data=data)
479
+ json_str = self._serialize_model(ObservationUpdateList(observation_updates))
480
+ response = self._send_request(requests.patch, "observations", json_str=json_str)
482
481
  return ObservationDataList.model_validate(response.json())
483
482
 
484
483
  def query_observation_sets(self, **kwargs) -> ListWithMeta[ObservationSetData]:
@@ -519,8 +518,8 @@ class StationControlClient:
519
518
  ExaError: If creation failed.
520
519
 
521
520
  """
522
- data = observation_set_definition.model_dump_json()
523
- response = self._send_request(requests.post, "observation-sets", data=data)
521
+ json_str = self._serialize_model(observation_set_definition)
522
+ response = self._send_request(requests.post, "observation-sets", json_str=json_str)
524
523
  return ObservationSetData.model_validate(response.json())
525
524
 
526
525
  def get_observation_set(self, observation_set_id: uuid.UUID) -> ObservationSetData:
@@ -552,8 +551,8 @@ class StationControlClient:
552
551
  ExaError: If updating failed.
553
552
 
554
553
  """
555
- data = observation_set_update.model_dump_json()
556
- response = self._send_request(requests.patch, "observation-sets", data=data)
554
+ json_str = self._serialize_model(observation_set_update)
555
+ response = self._send_request(requests.patch, "observation-sets", json_str=json_str)
557
556
  return ObservationSetData.model_validate(response.json())
558
557
 
559
558
  def finalize_observation_set(self, observation_set_id: uuid.UUID) -> None:
@@ -652,8 +651,8 @@ class StationControlClient:
652
651
  self, sequence_metadata_definition: SequenceMetadataDefinition
653
652
  ) -> SequenceMetadataData:
654
653
  """Create sequence metadata in the database."""
655
- data = sequence_metadata_definition.model_dump_json()
656
- response = self._send_request(requests.post, "sequence-metadatas", data=data)
654
+ json_str = self._serialize_model(sequence_metadata_definition)
655
+ response = self._send_request(requests.post, "sequence-metadatas", json_str=json_str)
657
656
  return SequenceMetadataData.model_validate(response.json())
658
657
 
659
658
  def save_sequence_result(self, sequence_result_definition: SequenceResultDefinition) -> SequenceResultData:
@@ -663,10 +662,10 @@ class StationControlClient:
663
662
  Timestamps are assigned by the database. "modified_timestamp" is not set on initial creation,
664
663
  but it's updated on each subsequent call.
665
664
  """
666
- data = sequence_result_definition.model_dump_json()
667
665
  # FIXME: We don't have information if the object was created or updated. Thus, server always responds 200 (OK).
666
+ json_str = self._serialize_model(sequence_result_definition)
668
667
  response = self._send_request(
669
- requests.put, f"sequence-results/{sequence_result_definition.sequence_id}", data=data
668
+ requests.put, f"sequence-results/{sequence_result_definition.sequence_id}", json_str=json_str
670
669
  )
671
670
  return SequenceResultData.model_validate(response.json())
672
671
 
@@ -678,7 +677,7 @@ class StationControlClient:
678
677
  def get_job(self, job_id: uuid.UUID) -> JobData:
679
678
  """Get job data."""
680
679
  response = self._send_request(requests.get, f"jobs/{job_id}")
681
- return response.json()
680
+ return JobData.model_validate(response.json())
682
681
 
683
682
  def _wait_job_completion(self, job_id: str, update_progress_callback: Callable[[Statuses], None] | None) -> bool:
684
683
  logger.info("Waiting for job ID: %s", job_id)
@@ -700,11 +699,11 @@ class StationControlClient:
700
699
  max_seen_position = 0
701
700
  while True:
702
701
  job = self._poll_job(job_id)
703
- if job["job_status"] >= JobStatus.EXECUTION_START:
702
+ if job.job_status >= JobStatus.EXECUTION_START:
704
703
  if max_seen_position:
705
704
  update_progress_callback([("Progress in queue", max_seen_position, max_seen_position)])
706
- return job["job_status"]
707
- position = job["position"]
705
+ return job.job_status
706
+ position = job.position
708
707
 
709
708
  if position == 0:
710
709
  sleep(1)
@@ -721,26 +720,70 @@ class StationControlClient:
721
720
  # Keep polling job status until it finishes, and update progress with `update_progress_callback`.
722
721
  while True:
723
722
  job = self._poll_job(job_id)
724
- update_progress_callback(job["job_result"].get("parallel_sweep_progress", []))
725
- if JobStatus(job["job_status"]) in JobStatus.terminal_statuses():
723
+ update_progress_callback(job.job_result.parallel_sweep_progress)
724
+ if job.job_status in JobStatus.terminal_statuses():
726
725
  return
727
726
  sleep(1)
728
727
 
729
728
  def _poll_job(self, job_id: str) -> JobData:
730
- job = self._send_request(requests.get, f"jobs/{job_id}").json()
731
- if job["job_status"] == JobStatus.FAILED:
732
- raise InternalServerError(f"Job: {job.get('job_id')}\n{job.get('job_error')}")
729
+ response = self._send_request(requests.get, f"jobs/{job_id}")
730
+ job = JobData.model_validate(response.json())
731
+ if job.job_status == JobStatus.FAILED:
732
+ raise InternalServerError(f"Job: {job.job_id}\n{job.job_error}")
733
733
  return job
734
734
 
735
+ @staticmethod
736
+ def _serialize_model(model: BaseModel) -> str:
737
+ """Serialize a Pydantic model into a JSON string.
738
+
739
+ All Pydantic models should be serialized using this method, to keep the client behavior uniform.
740
+
741
+ TODO add a corresponding deserialization method.
742
+
743
+ Args:
744
+ model: Pydantic model to JSON-serialize.
745
+
746
+ Returns:
747
+ Corresponding JSON string, may contain arbitrary Unicode characters.
748
+
749
+ """
750
+ # Strings in model can contain non-latin-1 characters. Unlike json.dumps which encodes non-latin-1 chars
751
+ # using the \uXXXX syntax, BaseModel.model_dump_json() keeps them in the produced JSON str.
752
+ return model.model_dump_json()
753
+
735
754
  def _send_request(
736
- self, http_method: Callable[..., requests.Response], url_path: str, **kwargs
755
+ self,
756
+ http_method: Callable[..., requests.Response],
757
+ url_path: str,
758
+ *,
759
+ json_str: str | None = None,
760
+ octets: bytes | None = None,
761
+ params: dict[str, Any] | None = None,
762
+ headers: dict[str, str] | None = None,
737
763
  ) -> requests.Response:
738
- # Send the request and return the response.
764
+ """Send a HTTP request.
765
+
766
+ Parameters ``json_str``, ``octets`` and ``params`` are mutually exclusive.
767
+ The first non-None argument (in this order) will be used to construct the body of the request.
768
+
769
+ Args:
770
+ http_method: HTTP method to use for the request, any of requests.[post|get|put|head|delete|patch|options].
771
+ url_path: URL for the request.
772
+ json_str: JSON string to store in the body, may contain arbitrary Unicode characters.
773
+ octets: Pre-serialized binary data to store in the body.
774
+ params: HTTP query to store in the body.
775
+ headers: Additional HTTP headers for the request. Some may be overridden.
776
+
777
+ Returns:
778
+ Response to the request.
779
+
780
+ Raises:
781
+ StationControlError: Request was not successful.
782
+
783
+ """
739
784
  # Will raise an error if respectively an error response code is returned.
740
- # http_method should be any of requests.[post|get|put|head|delete|patch|options]
741
- params = kwargs.get("params", {})
742
- headers = kwargs.get("headers", {})
743
- data = kwargs.get("data", None)
785
+ headers = headers or {}
786
+
744
787
  if self._enable_opentelemetry:
745
788
  parent_span_context = trace.set_span_in_context(trace.get_current_span())
746
789
  propagate.inject(carrier=headers, context=parent_span_context)
@@ -749,9 +792,23 @@ class StationControlClient:
749
792
  headers["Authorization"] = self._get_token_callback()
750
793
 
751
794
  # Build request options explicitly
752
- http_request_options = {"params": params, "data": data, "headers": headers}
753
- # Remove None and {} values
754
- http_request_options = {key: value for key, value in http_request_options.items() if value not in [None, {}]}
795
+ http_request_options: dict[str, Any] = {}
796
+ if json_str is not None:
797
+ # Must be able to handle JSON strings with arbitrary unicode characters, so we use an explicit
798
+ # encoding into bytes, and set the headers so the recipient can decode the request body correctly.
799
+ http_request_options["data"] = json_str.encode("utf-8")
800
+ headers["Content-Type"] = "application/json; charset=UTF-8"
801
+ elif octets is not None:
802
+ http_request_options["data"] = octets
803
+ headers["Content-Type"] = "application/octet-stream"
804
+ elif params is not None:
805
+ http_request_options["params"] = params
806
+ # otherwise no body-related parameter will be passed to requests
807
+
808
+ if headers:
809
+ # do not pass empty headers dict
810
+ http_request_options["headers"] = headers
811
+
755
812
  url = f"{self.root_url}/{url_path}"
756
813
  # TODO SW-1387: Use v1 API
757
814
  # url = f"{self.root_url}/{self.version}/{url_path}"
@@ -803,7 +860,7 @@ class StationControlClient:
803
860
  return parse(version("iqm-station-control-client"))
804
861
 
805
862
  @staticmethod
806
- def _clean_query_parameters(model: Any, **kwargs) -> dict:
863
+ def _clean_query_parameters(model: Any, **kwargs) -> dict[str, Any]:
807
864
  if issubclass(model, PydanticBase) and "invalid" in model.model_fields.keys() and "invalid" not in kwargs:
808
865
  # Get only valid items by default, "invalid=None" would return also invalid ones.
809
866
  # This default has to be set on the client side, server side uses default "None".
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: iqm-station-control-client
3
- Version: 4.0.0
3
+ Version: 4.2.0
4
4
  Summary: Python client for communicating with Station Control Service
5
5
  Author-email: IQM Finland Oy <info@meetiqm.com>
6
6
  License: Apache License
@@ -1,6 +1,6 @@
1
1
  iqm/station_control/client/__init__.py,sha256=BmBIBdZa10r-IWCFzZ1-0DG6GQKPIXqGXltfXop4ZeQ,942
2
2
  iqm/station_control/client/list_models.py,sha256=SjD0DbCrM9z1SSuGoQS83lyJmDLuMOatpJUoW8itW9s,2335
3
- iqm/station_control/client/station_control.py,sha256=XMLN3bNklF40N-6N1M-lF4vfG8IouX2qE1ZI_ujH3ck,37474
3
+ iqm/station_control/client/station_control.py,sha256=F9snzBp0u4xsg0f5_FzXh-21aP4BXM0a_f79aMC0ACE,39601
4
4
  iqm/station_control/client/utils.py,sha256=cpS3hXEeeIXeqd_vBnnwo3JHS83FrNpG07SiTUwUx-I,1650
5
5
  iqm/station_control/client/iqm_server/__init__.py,sha256=nLsRHN1rnOKXwuzaq_liUpAYV3sis5jkyHccSdacV7U,624
6
6
  iqm/station_control/client/iqm_server/error.py,sha256=ZLV2-gxFLHZjZVkI3L5sWcBMiay7NT-ijIEvrXgVJT8,1166
@@ -47,8 +47,8 @@ iqm/station_control/interface/models/run.py,sha256=m-iE3QMPQUOF7bsw8JCAM1Bd6bDVh
47
47
  iqm/station_control/interface/models/sequence.py,sha256=uOqMwF1x-vW6UHs2WnPD3PsuSgV3a8OTAsgn_4UENLw,2723
48
48
  iqm/station_control/interface/models/sweep.py,sha256=ZV_1zcEF5_NY0nPgC75tU7s14TE1o0croBSClIVSmCE,2493
49
49
  iqm/station_control/interface/models/type_aliases.py,sha256=3LB9viZVi8osavY5kKF8TH1crayG7-MLjgBqXDCqL2s,1018
50
- iqm_station_control_client-4.0.0.dist-info/LICENSE.txt,sha256=R6Q7eUrLyoCQgWYorQ8WJmVmWKYU3dxA3jYUp0wwQAw,11332
51
- iqm_station_control_client-4.0.0.dist-info/METADATA,sha256=P4V5yKTIBLiuLj4UJvlEoBYQmec-eH4iAqNn6UBJysU,14009
52
- iqm_station_control_client-4.0.0.dist-info/WHEEL,sha256=y4mX-SOX4fYIkonsAGA5N0Oy-8_gI4FXw5HNI1xqvWg,91
53
- iqm_station_control_client-4.0.0.dist-info/top_level.txt,sha256=NB4XRfyDS6_wG9gMsyX-9LTU7kWnTQxNvkbzIxGv3-c,4
54
- iqm_station_control_client-4.0.0.dist-info/RECORD,,
50
+ iqm_station_control_client-4.2.0.dist-info/LICENSE.txt,sha256=R6Q7eUrLyoCQgWYorQ8WJmVmWKYU3dxA3jYUp0wwQAw,11332
51
+ iqm_station_control_client-4.2.0.dist-info/METADATA,sha256=8ZkaMqyQU3no5urP6e7d2AkXoYRj2aiPb23vF_1oubA,14009
52
+ iqm_station_control_client-4.2.0.dist-info/WHEEL,sha256=y4mX-SOX4fYIkonsAGA5N0Oy-8_gI4FXw5HNI1xqvWg,91
53
+ iqm_station_control_client-4.2.0.dist-info/top_level.txt,sha256=NB4XRfyDS6_wG9gMsyX-9LTU7kWnTQxNvkbzIxGv3-c,4
54
+ iqm_station_control_client-4.2.0.dist-info/RECORD,,