diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 202bc6c6c..263777382 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -73,7 +73,7 @@ jobs: checks/lint_.py mypy: - name: Type check + name: Type check (mypy) runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 @@ -93,6 +93,27 @@ jobs: run: | checks/mypy_.py + ty: + name: Type check (ty) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up Python 3.14 + uses: actions/setup-python@v6 + with: + python-version: 3.14 + - uses: actions/cache@v5 + with: + path: ${{ env.pythonLocation }} + key: ${{ env.pythonLocation }}-${{ hashFiles('**/pyproject.toml', '**/*requirements.txt', '.github/workflows/ci.yml') }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ./checks-superstaq -e ./general-superstaq[dev] -e ./qiskit-superstaq[dev] -e ./cirq-superstaq[dev] -e ./supermarq-benchmarks[dev] + - name: Type check + run: | + checks/ty_.py general-superstaq + coverage: name: Pytest and Coverage check strategy: diff --git a/checks-superstaq/checks_superstaq/__init__.py b/checks-superstaq/checks_superstaq/__init__.py index 1e4b321e8..37d9b6e1b 100644 --- a/checks-superstaq/checks_superstaq/__init__.py +++ b/checks-superstaq/checks_superstaq/__init__.py @@ -26,6 +26,7 @@ mypy_, pytest_, requirements, + ty_, ) __all__ = [ @@ -41,4 +42,5 @@ "mypy_", "pytest_", "requirements", + "ty_", ] diff --git a/checks-superstaq/checks_superstaq/check_utils.py b/checks-superstaq/checks_superstaq/check_utils.py index 82fd80b80..7e2cc2f52 100644 --- a/checks-superstaq/checks_superstaq/check_utils.py +++ b/checks-superstaq/checks_superstaq/check_utils.py @@ -297,15 +297,7 @@ def get_test_files( #################################################################################################### # file parsing, incremental checks, and decorator to exit instead of returning a failing exit code -CHECK_LIST = [ - "configs", - "format", - "mypy", - "pytest", - "coverage", - "requirements", - "build_docs", -] +CHECK_LIST = ["configs", "format", "mypy", "pytest", "coverage", "requirements", "build_docs", "ty"] def get_check_parser(no_files: bool = False) -> argparse.ArgumentParser: diff --git a/checks-superstaq/checks_superstaq/checks-pyproject.toml b/checks-superstaq/checks_superstaq/checks-pyproject.toml index 0b16450a3..1f01569ad 100644 --- a/checks-superstaq/checks_superstaq/checks-pyproject.toml +++ b/checks-superstaq/checks_superstaq/checks-pyproject.toml @@ -107,18 +107,18 @@ lint.ignore = [ "TD003", # Missing todo link "TRY003", # Raise vanilla args # The following are excluded to not conflict with `ruff format`: - "COM812", # Missing trailing comma - "COM819", # Prohibited trailing comma - "D206", # Docstring tab indentation - "D300", # Triple single quotes - "E111", # Indentation with invalid multiple - "E114", # Indentation with invalid multiple comment - "E117", # Over-indented - "Q000", # Bad quotes inline string - "Q001", # Bad quotes multiline string - "Q002", # Bad quotes docstring - "Q003", # Avoidable escaped quote - "W191", # Tab indentation + "COM812", # Missing trailing comma + "COM819", # Prohibited trailing comma + "D206", # Docstring tab indentation + "D300", # Triple single quotes + "E111", # Indentation with invalid multiple + "E114", # Indentation with invalid multiple comment + "E117", # Over-indented + "Q000", # Bad quotes inline string + "Q001", # Bad quotes multiline string + "Q002", # Bad quotes docstring + "Q003", # Avoidable escaped quote + "W191", # Tab indentation ] # Allow autofix for all enabled rules (when `--fix` is passed) @@ -194,3 +194,6 @@ See the License for the specific language governing permissions and limitations under the License. """ licensee = "Infleqtion" + +[tool.ty.environment] +python-version = "3.9" diff --git a/checks-superstaq/checks_superstaq/ty_.py b/checks-superstaq/checks_superstaq/ty_.py new file mode 100644 index 000000000..900943303 --- /dev/null +++ b/checks-superstaq/checks_superstaq/ty_.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +# Copyright 2026 Infleqtion +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import subprocess +import sys +import textwrap +from collections.abc import Iterable + +from checks_superstaq import check_utils + + +@check_utils.enable_exit_on_failure +def run( + *args: str, + include: str | Iterable[str] = "*.py", + exclude: str | Iterable[str] = (), + silent: bool = False, +) -> int: + """Runs ty on the repository (typing check). + + Args: + *args: Command line arguments. + include: Glob(s) indicating which tracked files to consider (e.g. "*.py"). + exclude: Glob(s) indicating which tracked files to skip (e.g. "*integration_test.py"). + silent: If True, restrict printing to warning and error messages. + + Returns: + Terminal exit code. 0 indicates success, while any other integer indicates a test failure. + """ + parser = check_utils.get_check_parser() + parser.description = textwrap.dedent( + """ + Runs ty on the repository (typing check). + """ + ) + + parsed_args, args_to_pass = parser.parse_known_intermixed_args(args) + if "ty" in parsed_args.skip: + return 0 + + files = check_utils.extract_files(parsed_args, include, exclude, silent) + + return subprocess.call( + [sys.executable, "-m", "ty", "check", *files, *args_to_pass], cwd=check_utils.root_dir + ) + + +if __name__ == "__main__": + sys.exit(run(*sys.argv[1:])) diff --git a/checks-superstaq/requirements.txt b/checks-superstaq/requirements.txt index 5003d0f8d..a8f9758e5 100644 --- a/checks-superstaq/requirements.txt +++ b/checks-superstaq/requirements.txt @@ -15,3 +15,7 @@ ruff>=0.13.0 setuptools>=67.0.0 sphinx-autoapi>=3.2.1 sphinx-rtd-theme>=1.0.0 +ty>=0.0.29 +types-decorator>=5.2.0 +types-PyYAML>=6.0.12 +types-requests>=2.32.0 diff --git a/general-superstaq/general_superstaq/check/__init__.py b/checks/ty_.py old mode 100644 new mode 100755 similarity index 58% rename from general-superstaq/general_superstaq/check/__init__.py rename to checks/ty_.py index 6ca21019e..da768d221 --- a/general-superstaq/general_superstaq/check/__init__.py +++ b/checks/ty_.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python3 # Copyright 2026 Infleqtion # # Licensed under the Apache License, Version 2.0 (the "License"); @@ -11,30 +12,12 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -from checks_superstaq import ( # To be removed in version 0.5.0 - __version__, - all_, - build_docs, - check_utils, - configs, - coverage_, - format_, - lint_, - mypy_, - pytest_, - requirements, -) -__all__ = [ - "__version__", - "all_", - "build_docs", - "check_utils", - "configs", - "coverage_", - "format_", - "lint_", - "mypy_", - "pytest_", - "requirements", -] +from __future__ import annotations + +import sys + +import checks_superstaq as checks + +if __name__ == "__main__": + sys.exit(checks.ty_.run(*sys.argv[1:])) diff --git a/general-superstaq/general_superstaq/compiler_output.py b/general-superstaq/general_superstaq/compiler_output.py index 0defaab98..065971d16 100644 --- a/general-superstaq/general_superstaq/compiler_output.py +++ b/general-superstaq/general_superstaq/compiler_output.py @@ -163,6 +163,21 @@ def __init__( ) +def _deserialize_qubit_mappings(json_text: str) -> list[dict[int, int]]: + """Deserializes qubit mappings encoded as JSON lists of key-value pairs. + + Args: + json_text: A JSON string representing qubit mappings. + + Returns: + A list of dictionaries mapping logical qubits to physical qubits. + """ + # The service serializes mappings as key-value pairs so JSON round-tripping preserves integer + # qubit indices instead of coercing dictionary keys to strings. + serialized_mappings: list[list[tuple[int, int]]] = json.loads(json_text) + return [dict(qubit_mapping) for qubit_mapping in serialized_mappings] + + def _jaqal_programs_to_subcircuits(jaqal_programs: Sequence[str]) -> str: separator = "prepare_all" subcircuits = [jaqal_programs[0]] @@ -185,15 +200,15 @@ def read_json_jaqal( """ compiled_circuits = json.loads(json_dict["jaqal_strs"]) - initial_logical_to_physicals_list: list[dict[int, int]] = list( - map(dict, json.loads(json_dict["initial_logical_to_physicals"])) + initial_logical_to_physicals_list = _deserialize_qubit_mappings( + json_dict["initial_logical_to_physicals"] ) initial_logical_to_physicals: list[dict[int, int]] | list[list[dict[int, int]]] = ( initial_logical_to_physicals_list ) - final_logical_to_physicals_list: list[dict[int, int]] = list( - map(dict, json.loads(json_dict["final_logical_to_physicals"])) + final_logical_to_physicals_list = _deserialize_qubit_mappings( + json_dict["final_logical_to_physicals"] ) final_logical_to_physicals: list[dict[int, int]] | list[list[dict[int, int]]] = ( final_logical_to_physicals_list diff --git a/general-superstaq/general_superstaq/compiler_output_test.py b/general-superstaq/general_superstaq/compiler_output_test.py index b8dc63f08..bb2df0f9f 100644 --- a/general-superstaq/general_superstaq/compiler_output_test.py +++ b/general-superstaq/general_superstaq/compiler_output_test.py @@ -13,6 +13,7 @@ # limitations under the License. from __future__ import annotations +import json import textwrap import general_superstaq as gss @@ -78,3 +79,24 @@ def test_compiler_output_eq() -> None: ) != gss.compiler_output.CompilerOutput( [jaqal_program, jaqal_program_alt], [{0: 0}, {}], [{0: 1}, {}] ) + + +def test_read_json_jaqal_deserializes_qubit_mappings() -> None: + jaqal_program = textwrap.dedent( + """\ + register allqubits[1] + + prepare_all + measure_all + """ + ) + compiler_output = gss.compiler_output.read_json_jaqal( + { + "jaqal_strs": json.dumps([jaqal_program]), + "initial_logical_to_physicals": json.dumps([[(0, 1)], [(2, 3)]]), + "final_logical_to_physicals": json.dumps([[(0, 4)], [(2, 5)]]), + } + ) + + assert compiler_output.initial_logical_to_physicals == [{0: 1}, {2: 3}] + assert compiler_output.final_logical_to_physicals == [{0: 4}, {2: 5}] diff --git a/general-superstaq/general_superstaq/job.py b/general-superstaq/general_superstaq/job.py index b34af01dd..43f692a22 100644 --- a/general-superstaq/general_superstaq/job.py +++ b/general-superstaq/general_superstaq/job.py @@ -95,8 +95,8 @@ def _refresh_job(self) -> None: if self._job_data is not None: if all(s in gss.models.TERMINAL_CIRCUIT_STATES for s in self._job_data.statuses): return - self._job_data = gss.models.JobData( - **self._client.fetch_jobs([self._job_id])[str(self._job_id)] + self._job_data = gss.models.JobData.model_validate( + self._client.fetch_jobs([self._job_id])[str(self._job_id)] ) self._update_status_queue_info() diff --git a/general-superstaq/general_superstaq/job_test.py b/general-superstaq/general_superstaq/job_test.py index 74e09976c..070d98594 100644 --- a/general-superstaq/general_superstaq/job_test.py +++ b/general-superstaq/general_superstaq/job_test.py @@ -122,7 +122,7 @@ def test_to_dict( mock_get.return_value = _mocked_response({str(uuid.UUID(int=123)): job_dict}) job = gss.job.Job(mock_client, uuid.UUID(int=123)) - assert job.to_dict() == gss.models.JobData(**job_dict).model_dump() + assert job.to_dict() == gss.models.JobData.model_validate(job_dict).model_dump() def test_equality(mock_client: gss.superstaq_client._SuperstaqClientV3) -> None: @@ -288,23 +288,23 @@ def test_update_status_queue_info(mock_client: gss.superstaq_client._SuperstaqCl job_dict = _job_dict() job_dict["num_circuits"] = 3 job_dict["statuses"] = ["completed"] * 3 - job._job_data = gss.models.JobData(**job_dict) + job._job_data = gss.models.JobData.model_validate(job_dict) job._update_status_queue_info() assert job._overall_status == "completed" job_dict["statuses"] = ["awaiting_submission", "cancelled", "cancelled"] - job._job_data = gss.models.JobData(**job_dict) + job._job_data = gss.models.JobData.model_validate(job_dict) job._update_status_queue_info() assert job._overall_status == "awaiting_submission" job_dict["statuses"] = ["cancelled", "cancelled", "awaiting_submission"] - job._job_data = gss.models.JobData(**job_dict) + job._job_data = gss.models.JobData.model_validate(job_dict) job._update_status_queue_info() assert job._overall_status == "awaiting_submission" job_dict["statuses"] = ["completed", "completed", "failed"] - job._job_data = gss.models.JobData(**job_dict) + job._job_data = gss.models.JobData.model_validate(job_dict) job._update_status_queue_info() assert job._overall_status == "failed" diff --git a/general-superstaq/general_superstaq/machine_api.py b/general-superstaq/general_superstaq/machine_api.py index 80d29a8d7..aa1af102f 100644 --- a/general-superstaq/general_superstaq/machine_api.py +++ b/general-superstaq/general_superstaq/machine_api.py @@ -56,12 +56,12 @@ def post_result( the task result including checking if the number of shots in the bitstring is equal to the number requested and if the bitstrings match the set of qubit_readout operations requested. """ - compressed_bitstrings: dict[str, list[int]] | None = None + compressed_bitstrings: dict[str, set[int]] | None = None if bitstrings is not None: compressed_bitstrings = {} for idx, bs in enumerate(bitstrings): - bs_index_list = compressed_bitstrings.setdefault(bs, []) - bs_index_list.append(idx) + bs_index_list = compressed_bitstrings.setdefault(bs, set()) + bs_index_list.add(idx) results = gss.models.WorkerTaskResults( circuit_ref=task_id, diff --git a/general-superstaq/general_superstaq/models.py b/general-superstaq/general_superstaq/models.py index 04fe20278..49452ee0c 100644 --- a/general-superstaq/general_superstaq/models.py +++ b/general-superstaq/general_superstaq/models.py @@ -21,7 +21,7 @@ import uuid from collections.abc import Mapping, Sequence from enum import Enum -from typing import Annotated, Any +from typing import Annotated, Any, cast import pydantic.functional_validators @@ -153,7 +153,8 @@ def _validate_cq_token(cls, cq_token: object) -> object: Previously CQ tokens were specified via a dict, e.g. `cq_token={"access_token": "token"}`. """ if isinstance(cq_token, Mapping): - return cq_token.get("access_token") + cq_token_mapping = cast("Mapping[object, object]", cq_token) + return cq_token_mapping.get("access_token") return cq_token diff --git a/general-superstaq/general_superstaq/models_test.py b/general-superstaq/general_superstaq/models_test.py index dbe2c72a4..71d108a39 100644 --- a/general-superstaq/general_superstaq/models_test.py +++ b/general-superstaq/general_superstaq/models_test.py @@ -51,7 +51,7 @@ def test_external_provider_credentials() -> None: # Old-style CQ credentials options_old = {"cq_token": {"access_token": "token"}, "project_id": "123", "org_id": "456"} - credentials = gss.models.ExternalProviderCredentials(**options_old) + credentials = gss.models.ExternalProviderCredentials.model_validate(options_old) assert credentials.cq_token == "token" assert credentials.cq_project_id == "123" assert credentials.cq_org_id == "456" @@ -121,7 +121,7 @@ def test_worker_task_results_validation() -> None: circuit_ref="f76e84f7-0c65-4f0b-b2d7-14135db3900c", status=gss.models.CircuitStatus.COMPLETED, successful_shots=10, - measurements={"a": [0], "b": [1, 2]}, + measurements={"a": {0}, "b": {1, 2}}, ) with pytest.raises( @@ -132,7 +132,7 @@ def test_worker_task_results_validation() -> None: circuit_ref="f76e84f7-0c65-4f0b-b2d7-14135db3900c", status=gss.models.CircuitStatus.COMPLETED, successful_shots=10, - measurements={"101": [0], "01": [1, 2]}, + measurements={"101": {0}, "01": {1, 2}}, ) with pytest.raises( @@ -143,14 +143,14 @@ def test_worker_task_results_validation() -> None: circuit_ref="f76e84f7-0c65-4f0b-b2d7-14135db3900c", status=gss.models.CircuitStatus.COMPLETED, successful_shots=2, - measurements={"101": [0], "001": [1, 3]}, + measurements={"101": {0}, "001": {1, 3}}, ) _ = gss.models.WorkerTaskResults( circuit_ref="f76e84f7-0c65-4f0b-b2d7-14135db3900c", status=gss.models.CircuitStatus.COMPLETED, successful_shots=4, - measurements={"000": [0, 1, 3], "101": [2]}, + measurements={"000": {0, 1, 3}, "101": {2}}, ) _ = gss.models.WorkerTaskResults( diff --git a/general-superstaq/general_superstaq/superstaq_client.py b/general-superstaq/general_superstaq/superstaq_client.py index 9fca11bde..378b39f1b 100644 --- a/general-superstaq/general_superstaq/superstaq_client.py +++ b/general-superstaq/general_superstaq/superstaq_client.py @@ -36,12 +36,12 @@ import sys import textwrap import time -import urllib +import urllib.parse import uuid import warnings from abc import ABC, abstractmethod from collections.abc import Callable, Mapping, MutableMapping, Sequence -from typing import TYPE_CHECKING, Any, ClassVar, Literal, NoReturn +from typing import TYPE_CHECKING, Any, ClassVar, Literal, NoReturn, TypeVar import numpy as np import requests @@ -50,10 +50,13 @@ if TYPE_CHECKING: import numpy.typing as npt + import pydantic RECOGNISED_CIRCUIT_TYPES = Literal[gss.models.CircuitType.CIRQ, gss.models.CircuitType.QISKIT] """The circuit types that are currently implemented within the `SuperstaqClient`.""" +_T = TypeVar("_T") + class ApiVersion(str, enum.Enum): """The supported API versions.""" @@ -241,7 +244,7 @@ def request() -> requests.Response: response = self._make_request(request) return self._handle_response(response) - def _handle_response(self, response: requests.Response) -> object: + def _handle_response(self, response: requests.Response) -> pydantic.JsonValue: response_json = response.json() if isinstance(response_json, dict) and "warnings" in response_json: for warning in response_json["warnings"]: @@ -296,8 +299,10 @@ def _handle_status_codes(self, response: requests.Response) -> None: if isinstance(json_content, dict) and set(json_content.keys()).intersection( {"message", "detail"} ): - alternative: str = json_content.get("detail", "") - message: str = json_content.get("message", alternative) + detail = json_content.get("detail") + alternative = detail if isinstance(detail, str) else "" + message_content = json_content.get("message") + message = message_content if isinstance(message_content, str) else alternative else: message = str(response.text) @@ -379,6 +384,15 @@ def _extract_credentials(kwargs: dict[str, Any]) -> dict[str, str]: return credentials + @staticmethod + def _require_list_items(values: Sequence[_T | None], field_name: str) -> list[_T]: + """Ensures a response field is fully populated before it is serialized back to clients.""" + if any(value is None for value in values): + raise gss.SuperstaqException( + f"Expected all values in '{field_name}' to be populated, but found `None`." + ) + return [value for value in values if value is not None] + def __str__(self) -> str: return f"Client version {self.api_version} with host={self.url} and name={self.client_name}" @@ -1240,7 +1254,7 @@ def submit_aces( gss.validation.validate_integer_param(mirror_depth, min_val=1) gss.validation.validate_integer_param(extra_depth, min_val=0) - json_dict = { + json_dict: dict[str, Any] = { "target": target, "qubits": qubits, "shots": shots, @@ -1373,8 +1387,8 @@ def create_job( shots=repetitions, options_dict={**self.client_kwargs, **kwargs}, verbatim=verbatim, - tags=[tag] if isinstance(tag, str) else tag, - metadata=metadata or {}, + tags=[tag] if isinstance(tag, str) else list(tag), + metadata={} if metadata is None else dict(metadata), ) response = gss.models.NewJobResponse( **self.post_request("/client/job", new_job.model_dump(), **credentials) @@ -1386,7 +1400,8 @@ def cancel_jobs( job_ids: Sequence[str] | Sequence[uuid.UUID], **kwargs: object, ) -> list[str]: - query = gss.models.JobQuery(job_id=job_ids) + query = gss.models.JobQuery(job_id=job_ids) # ty: ignore[invalid-argument-type] + # Ignoring `ty` strict checking for the job-ids since pydanic will coerce str->uuid json_dict = query.model_dump(exclude_none=True) json_dict["job_id"] = list(map(str, json_dict["job_id"])) credentials = self._extract_credentials({**kwargs, **self.client_kwargs}) @@ -1400,7 +1415,8 @@ def fetch_jobs( job_ids: Sequence[str] | Sequence[uuid.UUID], **kwargs: object, ) -> dict[str, dict[str, object]]: - query = gss.models.JobQuery(job_id=job_ids) + query = gss.models.JobQuery(job_id=job_ids) # ty: ignore[invalid-argument-type] + # Ignoring `ty` strict checking for the job-ids since pydanic will coerce str->uuid credentials = self._extract_credentials({**kwargs, **self.client_kwargs}) response = self.get_request( f"/client/job/{self.circuit_type.value}", @@ -1408,7 +1424,8 @@ def fetch_jobs( **credentials, ) return { - job_id: gss.models.JobData(**data).model_dump() for (job_id, data) in response.items() + job_id: gss.models.JobData.model_validate(data).model_dump() + for (job_id, data) in response.items() } def get_balance(self) -> dict[str, float]: @@ -1468,7 +1485,7 @@ def target_info(self, target: str, **kwargs: object) -> dict[str, Any]: return response.model_dump() def add_new_user(self, json_dict: dict[str, str]) -> str: - new_user = gss.models.NewUser(**json_dict) + new_user = gss.models.NewUser.model_validate(json_dict) return self.post_request("/client/user", new_user.model_dump(exclude_none=True)) def update_user_balance(self, json_dict: dict[str, float | str]) -> str: @@ -1478,7 +1495,11 @@ def update_user_balance(self, json_dict: dict[str, float | str]) -> str: raise ValueError("Must provide a user email to update the balance of.") if new_balance is None: raise ValueError("Must provide a new balance to update the user with.") - request = gss.models.UpdateUserDetails(balance=json_dict.get("balance")) + if not isinstance(user_email, str): + raise TypeError("User email must be provided as a string.") + if not isinstance(new_balance, (int, float)): + raise TypeError("New balance must be provided as a number.") + request = gss.models.UpdateUserDetails(balance=float(new_balance)) return self.put_request(f"/client/user/{user_email}", request.model_dump(exclude_none=True)) def update_user_role(self, json_dict: dict[str, int | str]) -> str: @@ -1488,7 +1509,9 @@ def update_user_role(self, json_dict: dict[str, int | str]) -> str: raise ValueError("Must provide a user email to update the role of.") if new_role is None: raise ValueError("Must provide a new role to update the user with.") - request = gss.models.UpdateUserDetails(role=json_dict.get("role")) + if not isinstance(user_email, str): + raise TypeError("User email must be provided as a string.") + request = gss.models.UpdateUserDetails(role=str(new_role)) return self.put_request(f"/client/user/{user_email}", request.model_dump(exclude_none=True)) def resource_estimate(self, json_dict: dict[str, str]) -> dict[str, list[dict[str, int]]]: @@ -1522,10 +1545,8 @@ def compile(self, json_dict: dict[str, str]) -> dict[str, str]: **self.post_request("/client/job", new_job.model_dump()) ) job_id = str(response.job_id) - job_data = self.fetch_jobs([job_id])[job_id] - - assert isinstance(job_data["statuses"], list) - statuses: list[str] = job_data["statuses"] + job_data = gss.models.JobData.model_validate(self.fetch_jobs([job_id])[job_id]) + statuses = job_data.statuses # Poll the server until all circuits have reached a terminal state. time_waited_seconds: float = 0.0 @@ -1538,9 +1559,8 @@ def compile(self, json_dict: dict[str, str]) -> dict[str, str]: ) time.sleep(2.5) time_waited_seconds += 2.5 - job_data = self.fetch_jobs([job_id])[job_id] - assert isinstance(job_data["statuses"], list) - statuses = job_data["statuses"] + job_data = gss.models.JobData.model_validate(self.fetch_jobs([job_id])[job_id]) + statuses = job_data.statuses # Exception if any have not been successful if not all(s == gss.models.CircuitStatus.COMPLETED for s in statuses): @@ -1549,22 +1569,17 @@ def compile(self, json_dict: dict[str, str]) -> dict[str, str]: "details." ) - assert isinstance(job_data["compiled_circuits"], list) - compiled_circuits: list[str] = job_data["compiled_circuits"] - - assert isinstance(job_data["final_logical_to_physicals"], list) - final_logical_to_physicals: list[dict[int, int]] = job_data["final_logical_to_physicals"] - - assert isinstance(job_data["initial_logical_to_physicals"], list) - initial_logical_to_physicals: list[dict[int, int]] = job_data[ - "initial_logical_to_physicals" - ] - - assert isinstance(job_data["logical_qubits"], list) - logical_qubits: list[str] = job_data["logical_qubits"] - - assert isinstance(job_data["physical_qubits"], list) - physical_qubits: list[str] = job_data["physical_qubits"] + compiled_circuits = self._require_list_items( + job_data.compiled_circuits, "compiled_circuits" + ) + final_logical_to_physicals = self._require_list_items( + job_data.final_logical_to_physicals, "final_logical_to_physicals" + ) + initial_logical_to_physicals = self._require_list_items( + job_data.initial_logical_to_physicals, "initial_logical_to_physicals" + ) + logical_qubits = self._require_list_items(job_data.logical_qubits, "logical_qubits") + physical_qubits = self._require_list_items(job_data.physical_qubits, "physical_qubits") # Join circuits together in json string - TODO: make this neater. if circuit_type == gss.models.CircuitType.QISKIT: @@ -1638,7 +1653,7 @@ def submit_dfe( ) -> list[str]: self._raise_not_implemented("submit_dfe") - def submit_atom_picture(self, _bitmap: npt.ArrayLike) -> Any: + def submit_atom_picture(self, bitmap: npt.ArrayLike) -> Any: self._raise_not_implemented("submit_atom_picture") def process_dfe(self, job_ids: Sequence[str] | Sequence[uuid.UUID]) -> float: diff --git a/general-superstaq/general_superstaq/superstaq_client_test.py b/general-superstaq/general_superstaq/superstaq_client_test.py index 3f9563e81..5c834d7e3 100644 --- a/general-superstaq/general_superstaq/superstaq_client_test.py +++ b/general-superstaq/general_superstaq/superstaq_client_test.py @@ -32,6 +32,7 @@ import io import json import os +import re import secrets import textwrap import uuid @@ -42,6 +43,7 @@ import requests import general_superstaq as gss +from general_superstaq.superstaq_client import _BaseSuperstaqClient from general_superstaq.testing import RETURNED_TARGETS, TARGET_LIST EXPECTED_HEADERS = { @@ -856,6 +858,12 @@ def test_update_user_balance_invalid_v3(client_v3: gss.superstaq_client._Superst with pytest.raises(ValueError, match=r"new balance"): client_v3.update_user_balance({"email": "test@email.com"}) + with pytest.raises(TypeError, match=r"User email must be provided as a string."): + client_v3.update_user_balance({"email": 1, "balance": 1}) + + with pytest.raises(TypeError, match=r"New balance must be provided as a number."): + client_v3.update_user_balance({"email": "test@email.com", "balance": "1"}) + @pytest.mark.parametrize( ("client_name", "endpoint", "role", "expected_json", "call_type"), @@ -903,6 +911,9 @@ def test_update_user_role_invalid_v3(client_v3: gss.superstaq_client._SuperstaqC with pytest.raises(ValueError, match=r"new role"): client_v3.update_user_role({"email": "test@email.com"}) + with pytest.raises(TypeError, match=r"User email must be provided as a string."): + client_v3.update_user_role({"email": 1, "role": "genius"}) + @pytest.mark.parametrize("client_name", ["client_v2", "client_v3"]) @mock.patch("requests.Session.post") @@ -2257,3 +2268,13 @@ def test_regenerate_worker_token( mock_post.assert_called_once() assert f"cq_worker/regenerate_token/{worker_name}" in mock_post.call_args.args[0] assert mock_post.call_args.kwargs["json"] == {} + + +def test_require_list_items_error_if_none() -> None: + with pytest.raises( + gss.SuperstaqException, + match=re.escape( + "Expected all values in 'example_field' to be populated, but found `None`." + ), + ): + _BaseSuperstaqClient._require_list_items([None, 1, 2], field_name="example_field") diff --git a/general-superstaq/general_superstaq/validation.py b/general-superstaq/general_superstaq/validation.py index 60632eab0..7a1b47ccc 100644 --- a/general-superstaq/general_superstaq/validation.py +++ b/general-superstaq/general_superstaq/validation.py @@ -42,13 +42,20 @@ def validate_integer_param( ValueError: If `integer_param` is less than `min_val`. """ param_name = f"`{parameter_name}=" if parameter_name is not None else "`" - if not ( - (hasattr(integer_param, "__int__") and int(integer_param) == integer_param) - or (isinstance(integer_param, str) and integer_param.isdecimal()) - ): + integer_value: int | None = None + if isinstance(integer_param, str): + if integer_param.isdecimal(): + integer_value = int(integer_param) + elif isinstance(integer_param, numbers.Integral): + integer_value = int(integer_param) + elif isinstance(integer_param, numbers.Real) and float(integer_param).is_integer(): + # Accept integral-valued floats and numpy scalars while rejecting fractional values. + integer_value = int(float(integer_param)) + + if integer_value is None: raise TypeError(f"{param_name}{integer_param}` cannot be safely cast as an integer.") - if int(integer_param) < min_val: + if integer_value < min_val: raise ValueError(f"{param_name}{integer_param}` is less than the minimum value: {min_val}.")