commutator-sdk 0.1.0__tar.gz
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.
- commutator_sdk-0.1.0/PKG-INFO +16 -0
- commutator_sdk-0.1.0/commutator/__init__.py +19 -0
- commutator_sdk-0.1.0/commutator/client.py +155 -0
- commutator_sdk-0.1.0/commutator/exceptions.py +106 -0
- commutator_sdk-0.1.0/commutator/fleet.py +72 -0
- commutator_sdk-0.1.0/commutator/identity.py +91 -0
- commutator_sdk-0.1.0/commutator/jobs.py +159 -0
- commutator_sdk-0.1.0/commutator/optimizer.py +66 -0
- commutator_sdk-0.1.0/commutator_sdk.egg-info/PKG-INFO +16 -0
- commutator_sdk-0.1.0/commutator_sdk.egg-info/SOURCES.txt +19 -0
- commutator_sdk-0.1.0/commutator_sdk.egg-info/dependency_links.txt +1 -0
- commutator_sdk-0.1.0/commutator_sdk.egg-info/requires.txt +10 -0
- commutator_sdk-0.1.0/commutator_sdk.egg-info/top_level.txt +1 -0
- commutator_sdk-0.1.0/pyproject.toml +41 -0
- commutator_sdk-0.1.0/setup.cfg +4 -0
- commutator_sdk-0.1.0/tests/test_client.py +98 -0
- commutator_sdk-0.1.0/tests/test_exceptions.py +67 -0
- commutator_sdk-0.1.0/tests/test_fleet.py +74 -0
- commutator_sdk-0.1.0/tests/test_identity.py +95 -0
- commutator_sdk-0.1.0/tests/test_jobs.py +125 -0
- commutator_sdk-0.1.0/tests/test_optimizer.py +43 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: commutator-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Commutator Studios Python SDK
|
|
5
|
+
Author-email: Hadi <hadi@commutatorstudios.com>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests>=2.32.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
12
|
+
Requires-Dist: responses>=0.23.0; extra == "dev"
|
|
13
|
+
Requires-Dist: black>=24.0.0; extra == "dev"
|
|
14
|
+
Requires-Dist: mypy>=1.9.0; extra == "dev"
|
|
15
|
+
Requires-Dist: rich>=13.0.0; extra == "dev"
|
|
16
|
+
Requires-Dist: pre-commit>=3.5.0; extra == "dev"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Copyright (c) 2026. Commutator Studios GmbH.
|
|
2
|
+
|
|
3
|
+
from commutator.client import CommutatorClient
|
|
4
|
+
from commutator.exceptions import (
|
|
5
|
+
APIError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
CommutatorError,
|
|
8
|
+
NotFoundError,
|
|
9
|
+
ValidationError,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"CommutatorClient",
|
|
14
|
+
"CommutatorError",
|
|
15
|
+
"APIError",
|
|
16
|
+
"AuthenticationError",
|
|
17
|
+
"ValidationError",
|
|
18
|
+
"NotFoundError",
|
|
19
|
+
]
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# Copyright (c) 2026. Commutator Studios GmbH.
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import typing
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
from commutator.exceptions import raise_for_status
|
|
9
|
+
from commutator.fleet import FleetModule
|
|
10
|
+
from commutator.identity import IdentityModule
|
|
11
|
+
from commutator.jobs import JobsModule
|
|
12
|
+
from commutator.optimizer import OptimizerModule
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Environment(str, Enum):
|
|
16
|
+
"""The target environment for the Commutator SDK."""
|
|
17
|
+
|
|
18
|
+
PROD = "prod"
|
|
19
|
+
STAGE = "stage"
|
|
20
|
+
DEMO = "demo"
|
|
21
|
+
LOCAL = "local"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CommutatorClient:
|
|
25
|
+
"""The central client for interacting with Commutator Studios Platform."""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
api_key: typing.Optional[str] = None,
|
|
31
|
+
token: typing.Optional[str] = None,
|
|
32
|
+
organization_id: typing.Optional[str] = None,
|
|
33
|
+
_internal_base_url: typing.Optional[str] = None,
|
|
34
|
+
):
|
|
35
|
+
"""Initializes the Commutator SDK Client.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
api_key: The user's API Key. If not provided, will look for the
|
|
39
|
+
'COMMUTATOR_API_KEY' environment variable.
|
|
40
|
+
token: A JWT bearer token retrieved via login (if not using API Key).
|
|
41
|
+
organization_id: The organization ID. If not provided, will look for the
|
|
42
|
+
'COMMUTATOR_ORGANIZATION_ID' environment variable.
|
|
43
|
+
"""
|
|
44
|
+
self._api_key = api_key or os.environ.get("COMMUTATOR_API_KEY")
|
|
45
|
+
self._token = token
|
|
46
|
+
self._organization_id = organization_id or os.environ.get("COMMUTATOR_ORGANIZATION_ID")
|
|
47
|
+
|
|
48
|
+
if not self._api_key and not self._token:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"Either api_key or token must be provided, or "
|
|
51
|
+
"COMMUTATOR_API_KEY environment variable must be set."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
if _internal_base_url:
|
|
55
|
+
self.base_url = _internal_base_url
|
|
56
|
+
elif self._api_key:
|
|
57
|
+
if self._api_key.startswith("cs_loc_"):
|
|
58
|
+
self.base_url = "http://localhost:3000"
|
|
59
|
+
elif self._api_key.startswith("cs_stage_"):
|
|
60
|
+
self.base_url = "https://commutatorstaging.com"
|
|
61
|
+
elif self._api_key.startswith("cs_demo_"):
|
|
62
|
+
self.base_url = "https://commutator.tech"
|
|
63
|
+
else:
|
|
64
|
+
self.base_url = "https://commutator.app"
|
|
65
|
+
else:
|
|
66
|
+
self.base_url = "https://commutator.app"
|
|
67
|
+
|
|
68
|
+
# Initialize submodules
|
|
69
|
+
self.identity = IdentityModule(client=self)
|
|
70
|
+
self.admin = self.identity # Alias for documentation consistency
|
|
71
|
+
self.jobs = JobsModule(client=self)
|
|
72
|
+
self.fleet = FleetModule(client=self)
|
|
73
|
+
self.optimizer = OptimizerModule(client=self)
|
|
74
|
+
|
|
75
|
+
@classmethod
|
|
76
|
+
def login_with_api_key(cls, *, api_key: str) -> "CommutatorClient":
|
|
77
|
+
"""Initialize the client using an explicit API key.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
api_key: The user's API Key.
|
|
81
|
+
"""
|
|
82
|
+
return cls(api_key=api_key)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def login_with_email(
|
|
86
|
+
cls,
|
|
87
|
+
*,
|
|
88
|
+
email: str,
|
|
89
|
+
password: str,
|
|
90
|
+
environment: Environment = Environment.PROD,
|
|
91
|
+
) -> "CommutatorClient":
|
|
92
|
+
"""Authenticate using email & password to retrieve a JWT token.
|
|
93
|
+
|
|
94
|
+
POST /api/v1/identity/login
|
|
95
|
+
"""
|
|
96
|
+
env_map = {
|
|
97
|
+
Environment.PROD: "https://commutator.app",
|
|
98
|
+
Environment.STAGE: "https://commutatorstaging.com",
|
|
99
|
+
Environment.DEMO: "https://commutator.tech",
|
|
100
|
+
Environment.LOCAL: "http://localhost:3000",
|
|
101
|
+
}
|
|
102
|
+
target_url = env_map.get(environment, "https://commutator.app")
|
|
103
|
+
|
|
104
|
+
response = requests.post(
|
|
105
|
+
f"{target_url}/api/v1/identity/login",
|
|
106
|
+
json={"email": email, "password": password},
|
|
107
|
+
timeout=60,
|
|
108
|
+
)
|
|
109
|
+
raise_for_status(response)
|
|
110
|
+
|
|
111
|
+
auth_response = response.json()
|
|
112
|
+
data_block = auth_response.get("data", {})
|
|
113
|
+
token = data_block.get("token")
|
|
114
|
+
|
|
115
|
+
if not token:
|
|
116
|
+
raise ValueError("Authentication did not return a valid token.")
|
|
117
|
+
|
|
118
|
+
user_info = data_block.get("user", {})
|
|
119
|
+
org_id = user_info.get("organizationId") or user_info.get("organization_id")
|
|
120
|
+
|
|
121
|
+
return cls(token=token, organization_id=org_id, _internal_base_url=target_url)
|
|
122
|
+
|
|
123
|
+
def _request(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
method: str,
|
|
127
|
+
path: str,
|
|
128
|
+
params: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
129
|
+
json: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
130
|
+
data: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
131
|
+
files: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
132
|
+
headers: typing.Optional[typing.Dict[str, typing.Any]] = None,
|
|
133
|
+
) -> requests.Response:
|
|
134
|
+
"""Sends an HTTP request with authenticating headers."""
|
|
135
|
+
url = f"{self.base_url}{path}"
|
|
136
|
+
|
|
137
|
+
request_headers = headers or {}
|
|
138
|
+
if self._token:
|
|
139
|
+
request_headers["Authorization"] = f"Bearer {self._token}"
|
|
140
|
+
elif self._api_key:
|
|
141
|
+
request_headers["X-API-Key"] = self._api_key
|
|
142
|
+
|
|
143
|
+
if self._organization_id:
|
|
144
|
+
request_headers["X-Organization-Id"] = self._organization_id
|
|
145
|
+
|
|
146
|
+
return requests.request(
|
|
147
|
+
method=method,
|
|
148
|
+
url=url,
|
|
149
|
+
params=params,
|
|
150
|
+
json=json,
|
|
151
|
+
data=data,
|
|
152
|
+
files=files,
|
|
153
|
+
headers=request_headers,
|
|
154
|
+
timeout=60,
|
|
155
|
+
)
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Copyright (c) 2026. Commutator Studios GmbH.
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CommutatorError(Exception):
|
|
7
|
+
"""Base exception for all Commutator SDK errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, *, message: str):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.message = message
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class APIError(CommutatorError):
|
|
15
|
+
"""Exception raised when the API returns an error status code."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
status_code: int,
|
|
21
|
+
code: str,
|
|
22
|
+
message: str,
|
|
23
|
+
details: typing.Optional[typing.List[typing.Dict[str, typing.Any]]] = None,
|
|
24
|
+
request_id: typing.Optional[str] = None,
|
|
25
|
+
documentation_url: typing.Optional[str] = None,
|
|
26
|
+
):
|
|
27
|
+
super().__init__(message=message)
|
|
28
|
+
self.status_code = status_code
|
|
29
|
+
self.code = code
|
|
30
|
+
self.details = details or []
|
|
31
|
+
self.request_id = request_id
|
|
32
|
+
self.documentation_url = documentation_url
|
|
33
|
+
|
|
34
|
+
def __str__(self) -> str:
|
|
35
|
+
err_str = f"[{self.status_code} {self.code}] {self.message}"
|
|
36
|
+
if self.request_id:
|
|
37
|
+
err_str += f" (Request ID: {self.request_id})"
|
|
38
|
+
return err_str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class AuthenticationError(APIError):
|
|
42
|
+
"""Raised on authentication failure (e.g. invalid API key or login)."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ValidationError(APIError):
|
|
46
|
+
"""Raised when the API returns validation issues."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NotFoundError(APIError):
|
|
50
|
+
"""Raised when a resource (e.g., job) does not exist."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def raise_for_status(response: typing.Any) -> None:
|
|
54
|
+
"""Helper to parse error JSON and raise appropriate APIError."""
|
|
55
|
+
if response.status_code >= 400:
|
|
56
|
+
try:
|
|
57
|
+
err_data = response.json()
|
|
58
|
+
status_code = err_data.get("status", response.status_code)
|
|
59
|
+
code = err_data.get("code", "API_ERROR")
|
|
60
|
+
message = err_data.get("message", response.text)
|
|
61
|
+
details = err_data.get("details", [])
|
|
62
|
+
request_id = err_data.get("requestId")
|
|
63
|
+
documentation_url = err_data.get("documentationUrl")
|
|
64
|
+
except ValueError:
|
|
65
|
+
status_code = response.status_code
|
|
66
|
+
code = "API_ERROR"
|
|
67
|
+
message = response.text
|
|
68
|
+
details = []
|
|
69
|
+
request_id = None
|
|
70
|
+
documentation_url = None
|
|
71
|
+
|
|
72
|
+
if status_code == 401:
|
|
73
|
+
raise AuthenticationError(
|
|
74
|
+
status_code=status_code,
|
|
75
|
+
code=code,
|
|
76
|
+
message=message,
|
|
77
|
+
details=details,
|
|
78
|
+
request_id=request_id,
|
|
79
|
+
documentation_url=documentation_url,
|
|
80
|
+
)
|
|
81
|
+
if status_code == 400:
|
|
82
|
+
raise ValidationError(
|
|
83
|
+
status_code=status_code,
|
|
84
|
+
code=code,
|
|
85
|
+
message=message,
|
|
86
|
+
details=details,
|
|
87
|
+
request_id=request_id,
|
|
88
|
+
documentation_url=documentation_url,
|
|
89
|
+
)
|
|
90
|
+
if status_code == 404:
|
|
91
|
+
raise NotFoundError(
|
|
92
|
+
status_code=status_code,
|
|
93
|
+
code=code,
|
|
94
|
+
message=message,
|
|
95
|
+
details=details,
|
|
96
|
+
request_id=request_id,
|
|
97
|
+
documentation_url=documentation_url,
|
|
98
|
+
)
|
|
99
|
+
raise APIError(
|
|
100
|
+
status_code=status_code,
|
|
101
|
+
code=code,
|
|
102
|
+
message=message,
|
|
103
|
+
details=details,
|
|
104
|
+
request_id=request_id,
|
|
105
|
+
documentation_url=documentation_url,
|
|
106
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Copyright (c) 2026. Commutator Studios GmbH.
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
from commutator.exceptions import raise_for_status
|
|
6
|
+
|
|
7
|
+
if typing.TYPE_CHECKING:
|
|
8
|
+
from commutator.client import CommutatorClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FleetModule:
|
|
12
|
+
"""Module for querying active quantum hardware backends."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, *, client: "CommutatorClient"):
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
def list_backends(self) -> typing.List[typing.Dict[str, typing.Any]]:
|
|
18
|
+
"""Get available quantum backends and fleet status.
|
|
19
|
+
|
|
20
|
+
GET /api/v1/fleet
|
|
21
|
+
"""
|
|
22
|
+
response = self._client._request(method="GET", path="/api/v1/backends")
|
|
23
|
+
raise_for_status(response)
|
|
24
|
+
return typing.cast(
|
|
25
|
+
typing.List[typing.Dict[str, typing.Any]], response.json().get("data", [])
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def update_backend(self, *, backend_id: str, enabled: bool) -> typing.Dict[str, typing.Any]:
|
|
29
|
+
"""Update fleet configurations or backend preferences.
|
|
30
|
+
|
|
31
|
+
PATCH /api/v1/fleet
|
|
32
|
+
"""
|
|
33
|
+
payload = {"id": backend_id, "enabled": enabled}
|
|
34
|
+
response = self._client._request(method="PATCH", path="/api/v1/backends", json=payload)
|
|
35
|
+
raise_for_status(response)
|
|
36
|
+
return typing.cast(typing.Dict[str, typing.Any], response.json().get("data", {}))
|
|
37
|
+
|
|
38
|
+
def discover(self) -> typing.List[typing.Dict[str, typing.Any]]:
|
|
39
|
+
"""Discover detailed topology and calibration data.
|
|
40
|
+
|
|
41
|
+
POST /api/v1/backends/discovery
|
|
42
|
+
"""
|
|
43
|
+
response = self._client._request(method="POST", path="/api/v1/backends/discovery")
|
|
44
|
+
raise_for_status(response)
|
|
45
|
+
# Fetch and return the newly synchronized detailed list
|
|
46
|
+
return self.list_backends()
|
|
47
|
+
|
|
48
|
+
def activate(self, *, backend_id: str) -> bool:
|
|
49
|
+
"""Request exclusive access or activation of a specific reserved backend.
|
|
50
|
+
|
|
51
|
+
PATCH /api/v1/backend/[id]/activate
|
|
52
|
+
"""
|
|
53
|
+
response = self._client._request(
|
|
54
|
+
method="PATCH", path=f"/api/v1/backend/{backend_id}/activate", json={"isActive": True}
|
|
55
|
+
)
|
|
56
|
+
if response.status_code in (200, 204):
|
|
57
|
+
return True
|
|
58
|
+
raise_for_status(response)
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
def deactivate(self, *, backend_id: str) -> bool:
|
|
62
|
+
"""Deactivate a specific reserved backend.
|
|
63
|
+
|
|
64
|
+
PATCH /api/v1/backend/[id]/activate
|
|
65
|
+
"""
|
|
66
|
+
response = self._client._request(
|
|
67
|
+
method="PATCH", path=f"/api/v1/backend/{backend_id}/activate", json={"isActive": False}
|
|
68
|
+
)
|
|
69
|
+
if response.status_code in (200, 204):
|
|
70
|
+
return True
|
|
71
|
+
raise_for_status(response)
|
|
72
|
+
return False
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Copyright (c) 2026. Commutator Studios GmbH.
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
from commutator.exceptions import raise_for_status
|
|
6
|
+
|
|
7
|
+
if typing.TYPE_CHECKING:
|
|
8
|
+
from commutator.client import CommutatorClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class IdentityModule:
|
|
12
|
+
"""Module for identity and authentication-related APIs."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, *, client: "CommutatorClient"):
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
def list_api_keys(self) -> typing.List[typing.Dict[str, typing.Any]]:
|
|
18
|
+
"""Retrieve a list of the user's API keys.
|
|
19
|
+
|
|
20
|
+
GET /api/v1/identity/keys
|
|
21
|
+
"""
|
|
22
|
+
response = self._client._request(method="GET", path="/api/v1/identity/keys")
|
|
23
|
+
raise_for_status(response)
|
|
24
|
+
return typing.cast(
|
|
25
|
+
typing.List[typing.Dict[str, typing.Any]], response.json().get("data", [])
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def create_api_key(
|
|
29
|
+
self, *, name: str, expires_in_days: int = 30
|
|
30
|
+
) -> typing.Dict[str, typing.Any]:
|
|
31
|
+
"""Generate a new API key.
|
|
32
|
+
|
|
33
|
+
POST /api/v1/identity/keys
|
|
34
|
+
"""
|
|
35
|
+
payload = {"name": name, "expiresInDays": expires_in_days}
|
|
36
|
+
response = self._client._request(method="POST", path="/api/v1/identity/keys", json=payload)
|
|
37
|
+
raise_for_status(response)
|
|
38
|
+
return typing.cast(typing.Dict[str, typing.Any], response.json().get("data", {}))
|
|
39
|
+
|
|
40
|
+
def revoke_api_key(self, *, key_id: str) -> bool:
|
|
41
|
+
"""Revoke an active API key immediately.
|
|
42
|
+
|
|
43
|
+
DELETE /api/v1/identity/keys/[id]
|
|
44
|
+
"""
|
|
45
|
+
response = self._client._request(method="DELETE", path=f"/api/v1/identity/keys/{key_id}")
|
|
46
|
+
if response.status_code in (200, 204):
|
|
47
|
+
return True
|
|
48
|
+
raise_for_status(response)
|
|
49
|
+
return False
|
|
50
|
+
|
|
51
|
+
def get_team(self) -> typing.List[typing.Dict[str, typing.Any]]:
|
|
52
|
+
"""Get details about the user's team.
|
|
53
|
+
|
|
54
|
+
GET /api/v1/identity/team
|
|
55
|
+
"""
|
|
56
|
+
response = self._client._request(method="GET", path="/api/v1/identity/team")
|
|
57
|
+
raise_for_status(response)
|
|
58
|
+
return typing.cast(
|
|
59
|
+
typing.List[typing.Dict[str, typing.Any]], response.json().get("data", [])
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def get_audit_logs(self, *, limit: int = 50) -> typing.List[typing.Dict[str, typing.Any]]:
|
|
63
|
+
"""Retrieve recent security and access audit logs for the team.
|
|
64
|
+
|
|
65
|
+
GET /api/v1/audit/logs
|
|
66
|
+
"""
|
|
67
|
+
response = self._client._request(
|
|
68
|
+
method="GET", path="/api/v1/audit/logs", params={"limit": limit}
|
|
69
|
+
)
|
|
70
|
+
raise_for_status(response)
|
|
71
|
+
return typing.cast(
|
|
72
|
+
typing.List[typing.Dict[str, typing.Any]], response.json().get("data", [])
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def add_ibm_key(
|
|
76
|
+
self, *, api_key: str, name: str = "IBM API Key"
|
|
77
|
+
) -> typing.Union[typing.Dict[str, typing.Any], str]:
|
|
78
|
+
"""Set your IBM API key for quantum simulations.
|
|
79
|
+
|
|
80
|
+
POST /api/v1/identity/credentials
|
|
81
|
+
"""
|
|
82
|
+
payload = {"provider": "IBM", "name": name, "config": {"apiToken": api_key, "crns": []}}
|
|
83
|
+
response = self._client._request(
|
|
84
|
+
method="POST", path="/api/v1/identity/credentials", json=payload
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if response.status_code == 409:
|
|
88
|
+
return "this key is already set"
|
|
89
|
+
|
|
90
|
+
raise_for_status(response)
|
|
91
|
+
return typing.cast(typing.Dict[str, typing.Any], response.json().get("data", {}))
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# Copyright (c) 2026. Commutator Studios GmbH.
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
from commutator.exceptions import raise_for_status
|
|
6
|
+
|
|
7
|
+
if typing.TYPE_CHECKING:
|
|
8
|
+
from commutator.client import CommutatorClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class JobsModule:
|
|
12
|
+
"""Module for job-related APIs."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, *, client: "CommutatorClient"):
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
def list(self) -> typing.List[typing.Dict[str, typing.Any]]:
|
|
18
|
+
"""List job history.
|
|
19
|
+
|
|
20
|
+
GET /api/v1/jobs
|
|
21
|
+
"""
|
|
22
|
+
response = self._client._request(method="GET", path="/api/v1/jobs")
|
|
23
|
+
raise_for_status(response)
|
|
24
|
+
return typing.cast(
|
|
25
|
+
typing.List[typing.Dict[str, typing.Any]], response.json().get("data", [])
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def create(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
name: str,
|
|
32
|
+
algorithm: str,
|
|
33
|
+
file_path: str,
|
|
34
|
+
organization_id: typing.Optional[str] = None,
|
|
35
|
+
) -> typing.Dict[str, typing.Any]:
|
|
36
|
+
"""Submit a new quantum job via file configuration.
|
|
37
|
+
|
|
38
|
+
POST /api/v1/job
|
|
39
|
+
"""
|
|
40
|
+
data = {
|
|
41
|
+
"name": name,
|
|
42
|
+
"algorithm": algorithm,
|
|
43
|
+
}
|
|
44
|
+
if organization_id:
|
|
45
|
+
data["organization_id"] = organization_id
|
|
46
|
+
with open(file_path, "rb") as f:
|
|
47
|
+
files = {"file": (file_path, f, "application/json")}
|
|
48
|
+
response = self._client._request(
|
|
49
|
+
method="POST", path="/api/v1/job", data=data, files=files
|
|
50
|
+
)
|
|
51
|
+
raise_for_status(response)
|
|
52
|
+
return typing.cast(typing.Dict[str, typing.Any], response.json().get("data", {}))
|
|
53
|
+
|
|
54
|
+
def get(self, *, job_id: str) -> typing.Dict[str, typing.Any]:
|
|
55
|
+
"""Get details for a specific job by its ID.
|
|
56
|
+
|
|
57
|
+
GET /api/v1/job/[id]
|
|
58
|
+
"""
|
|
59
|
+
response = self._client._request(method="GET", path=f"/api/v1/job/{job_id}")
|
|
60
|
+
raise_for_status(response)
|
|
61
|
+
return typing.cast(typing.Dict[str, typing.Any], response.json().get("data", {}))
|
|
62
|
+
|
|
63
|
+
def update(self, *, job_id: str, **kwargs: typing.Any) -> typing.Dict[str, typing.Any]:
|
|
64
|
+
"""Updates the job details.
|
|
65
|
+
|
|
66
|
+
PATCH /api/v1/jobs/[id]
|
|
67
|
+
"""
|
|
68
|
+
payload = {}
|
|
69
|
+
for k, v in kwargs.items():
|
|
70
|
+
camel_k = "".join(x.capitalize() or "_" for x in k.split("_"))
|
|
71
|
+
camel_k = camel_k[0].lower() + camel_k[1:]
|
|
72
|
+
payload[camel_k] = v
|
|
73
|
+
|
|
74
|
+
response = self._client._request(method="PATCH", path=f"/api/v1/job/{job_id}", json=payload)
|
|
75
|
+
raise_for_status(response)
|
|
76
|
+
return typing.cast(typing.Dict[str, typing.Any], response.json().get("data", {}))
|
|
77
|
+
|
|
78
|
+
def list_algorithms(self) -> typing.List[typing.Dict[str, typing.Any]]:
|
|
79
|
+
"""Retrieve the list of supported quantum algorithms available for execution.
|
|
80
|
+
|
|
81
|
+
GET /api/v1/job/algorithms
|
|
82
|
+
"""
|
|
83
|
+
response = self._client._request(method="GET", path="/api/v1/job/algorithms")
|
|
84
|
+
raise_for_status(response)
|
|
85
|
+
return typing.cast(
|
|
86
|
+
typing.List[typing.Dict[str, typing.Any]], response.json().get("data", [])
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def execute(
|
|
90
|
+
self,
|
|
91
|
+
*,
|
|
92
|
+
job_id: str,
|
|
93
|
+
backend: str,
|
|
94
|
+
optimization_level: int = 3,
|
|
95
|
+
dynamic_decoupling: bool = False,
|
|
96
|
+
max_iterations: typing.Optional[int] = None,
|
|
97
|
+
convergence: typing.Optional[float] = None,
|
|
98
|
+
predicted_shots: typing.Optional[int] = None,
|
|
99
|
+
**kwargs: typing.Any,
|
|
100
|
+
) -> typing.Dict[str, typing.Any]:
|
|
101
|
+
"""Executes a previously created quantum job on target hardware.
|
|
102
|
+
|
|
103
|
+
PATCH /api/v1/job/[id]
|
|
104
|
+
"""
|
|
105
|
+
payload: typing.Dict[str, typing.Any] = {
|
|
106
|
+
"status": "Pending",
|
|
107
|
+
"hardware": backend,
|
|
108
|
+
"optimizationLevel": optimization_level,
|
|
109
|
+
"dynamicDecoupling": dynamic_decoupling,
|
|
110
|
+
}
|
|
111
|
+
if max_iterations is not None:
|
|
112
|
+
payload["maxIterations"] = max_iterations
|
|
113
|
+
if convergence is not None:
|
|
114
|
+
payload["convergence"] = convergence
|
|
115
|
+
if predicted_shots is not None:
|
|
116
|
+
payload["predictedShots"] = predicted_shots
|
|
117
|
+
|
|
118
|
+
for k, v in kwargs.items():
|
|
119
|
+
camel_k = "".join(x.capitalize() or "_" for x in k.split("_"))
|
|
120
|
+
camel_k = camel_k[0].lower() + camel_k[1:]
|
|
121
|
+
if camel_k not in payload:
|
|
122
|
+
payload[camel_k] = v
|
|
123
|
+
|
|
124
|
+
response = self._client._request(method="PATCH", path=f"/api/v1/job/{job_id}", json=payload)
|
|
125
|
+
raise_for_status(response)
|
|
126
|
+
return typing.cast(typing.Dict[str, typing.Any], response.json().get("data", {}))
|
|
127
|
+
|
|
128
|
+
def cancel(self, *, job_id: str) -> bool:
|
|
129
|
+
"""Attempt to cancel a pending or queued job.
|
|
130
|
+
|
|
131
|
+
DELETE /api/v1/job/[id]
|
|
132
|
+
"""
|
|
133
|
+
response = self._client._request(method="DELETE", path=f"/api/v1/job/{job_id}")
|
|
134
|
+
if response.status_code in (200, 204):
|
|
135
|
+
return True
|
|
136
|
+
raise_for_status(response)
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
def get_download_url(self, *, job_id: str) -> str:
|
|
140
|
+
"""Retrieves a presigned URL to download the job results.
|
|
141
|
+
|
|
142
|
+
GET /api/v1/jobs/download-url?jobId=[id]
|
|
143
|
+
"""
|
|
144
|
+
response = self._client._request(
|
|
145
|
+
method="GET", path="/api/v1/jobs/download-url", params={"jobId": job_id}
|
|
146
|
+
)
|
|
147
|
+
raise_for_status(response)
|
|
148
|
+
return typing.cast(str, response.json().get("url", ""))
|
|
149
|
+
|
|
150
|
+
def download_results(self, *, job_id: str) -> typing.Dict[str, typing.Any]:
|
|
151
|
+
"""Downloads the job results data.
|
|
152
|
+
|
|
153
|
+
GET /api/v1/jobs/download-data?jobId=[id]
|
|
154
|
+
"""
|
|
155
|
+
response = self._client._request(
|
|
156
|
+
method="GET", path="/api/v1/jobs/download-data", params={"jobId": job_id}
|
|
157
|
+
)
|
|
158
|
+
raise_for_status(response)
|
|
159
|
+
return typing.cast(typing.Dict[str, typing.Any], response.json())
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Copyright (c) 2026. Commutator Studios GmbH.
|
|
2
|
+
|
|
3
|
+
import typing
|
|
4
|
+
|
|
5
|
+
from commutator.exceptions import raise_for_status
|
|
6
|
+
|
|
7
|
+
if typing.TYPE_CHECKING:
|
|
8
|
+
from commutator.client import CommutatorClient
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OptimizerModule:
|
|
12
|
+
"""Module for mathematical optimization and hardware recommendation engines."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, *, client: "CommutatorClient"):
|
|
15
|
+
self._client = client
|
|
16
|
+
|
|
17
|
+
def get_metadata(self) -> typing.List[typing.Dict[str, typing.Any]]:
|
|
18
|
+
"""Get current optimizer algorithm metadata.
|
|
19
|
+
|
|
20
|
+
GET /api/v1/optimizer
|
|
21
|
+
"""
|
|
22
|
+
response = self._client._request(method="GET", path="/api/v1/optimizer")
|
|
23
|
+
raise_for_status(response)
|
|
24
|
+
return typing.cast(
|
|
25
|
+
typing.List[typing.Dict[str, typing.Any]], response.json().get("data", [])
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
def run(
|
|
29
|
+
self, *, algorithm: str, qubits: int, circuit_depth: int, **kwargs: typing.Any
|
|
30
|
+
) -> typing.Dict[str, typing.Any]:
|
|
31
|
+
"""Configure or create a new optimizer run.
|
|
32
|
+
|
|
33
|
+
POST /api/v1/optimizer
|
|
34
|
+
"""
|
|
35
|
+
payload = {"algorithm": algorithm, "qubits": qubits, "circuit_depth": circuit_depth}
|
|
36
|
+
payload.update(kwargs)
|
|
37
|
+
response = self._client._request(method="POST", path="/api/v1/optimizer", json=payload)
|
|
38
|
+
raise_for_status(response)
|
|
39
|
+
return typing.cast(typing.Dict[str, typing.Any], response.json().get("data", {}))
|
|
40
|
+
|
|
41
|
+
def recommend(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
execution_id: str,
|
|
45
|
+
target_backend: typing.Optional[str] = None,
|
|
46
|
+
target_dd: bool = True,
|
|
47
|
+
plan_only: bool = True,
|
|
48
|
+
) -> typing.List[typing.Dict[str, typing.Any]]:
|
|
49
|
+
"""Request optimization and hardware recommendations.
|
|
50
|
+
|
|
51
|
+
POST /api/v1/optimizer/recommendations
|
|
52
|
+
"""
|
|
53
|
+
payload = {
|
|
54
|
+
"executionId": execution_id,
|
|
55
|
+
"targetBackend": target_backend,
|
|
56
|
+
"targetDD": target_dd,
|
|
57
|
+
"planOnly": plan_only,
|
|
58
|
+
}
|
|
59
|
+
response = self._client._request(
|
|
60
|
+
method="POST", path="/api/v1/optimizer/recommendations", json=payload
|
|
61
|
+
)
|
|
62
|
+
raise_for_status(response)
|
|
63
|
+
return typing.cast(
|
|
64
|
+
typing.List[typing.Dict[str, typing.Any]],
|
|
65
|
+
response.json().get("data", {}).get("recommendations", []),
|
|
66
|
+
)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: commutator-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Commutator Studios Python SDK
|
|
5
|
+
Author-email: Hadi <hadi@commutatorstudios.com>
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: requests>=2.32.0
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
11
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
12
|
+
Requires-Dist: responses>=0.23.0; extra == "dev"
|
|
13
|
+
Requires-Dist: black>=24.0.0; extra == "dev"
|
|
14
|
+
Requires-Dist: mypy>=1.9.0; extra == "dev"
|
|
15
|
+
Requires-Dist: rich>=13.0.0; extra == "dev"
|
|
16
|
+
Requires-Dist: pre-commit>=3.5.0; extra == "dev"
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
commutator/__init__.py
|
|
3
|
+
commutator/client.py
|
|
4
|
+
commutator/exceptions.py
|
|
5
|
+
commutator/fleet.py
|
|
6
|
+
commutator/identity.py
|
|
7
|
+
commutator/jobs.py
|
|
8
|
+
commutator/optimizer.py
|
|
9
|
+
commutator_sdk.egg-info/PKG-INFO
|
|
10
|
+
commutator_sdk.egg-info/SOURCES.txt
|
|
11
|
+
commutator_sdk.egg-info/dependency_links.txt
|
|
12
|
+
commutator_sdk.egg-info/requires.txt
|
|
13
|
+
commutator_sdk.egg-info/top_level.txt
|
|
14
|
+
tests/test_client.py
|
|
15
|
+
tests/test_exceptions.py
|
|
16
|
+
tests/test_fleet.py
|
|
17
|
+
tests/test_identity.py
|
|
18
|
+
tests/test_jobs.py
|
|
19
|
+
tests/test_optimizer.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
commutator
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "commutator-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Commutator Studios Python SDK"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{name = "Hadi", email = "hadi@commutatorstudios.com"}
|
|
8
|
+
]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"requests>=2.32.0",
|
|
11
|
+
]
|
|
12
|
+
requires-python = ">=3.11"
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = [
|
|
16
|
+
"pytest>=8.0.0",
|
|
17
|
+
"pytest-cov>=4.0.0",
|
|
18
|
+
"responses>=0.23.0",
|
|
19
|
+
"black>=24.0.0",
|
|
20
|
+
"mypy>=1.9.0",
|
|
21
|
+
"rich>=13.0.0",
|
|
22
|
+
"pre-commit>=3.5.0",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[build-system]
|
|
26
|
+
requires = ["setuptools>=61.0"]
|
|
27
|
+
build-backend = "setuptools.build_meta"
|
|
28
|
+
|
|
29
|
+
[tool.black]
|
|
30
|
+
line-length = 100
|
|
31
|
+
target-version = ['py311']
|
|
32
|
+
|
|
33
|
+
[tool.mypy]
|
|
34
|
+
python_version = "3.11"
|
|
35
|
+
strict = true
|
|
36
|
+
warn_return_any = true
|
|
37
|
+
warn_unused_configs = true
|
|
38
|
+
|
|
39
|
+
[tool.pytest.ini_options]
|
|
40
|
+
testpaths = ["tests"]
|
|
41
|
+
python_files = ["test_*.py"]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Copyright (c) 2026. Commutator Studios GmbH.
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import responses
|
|
5
|
+
from commutator.client import CommutatorClient, Environment
|
|
6
|
+
from commutator.exceptions import AuthenticationError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_client_init_prefixes():
|
|
10
|
+
client_prod = CommutatorClient(api_key="cs_prod_123")
|
|
11
|
+
assert client_prod.base_url == "https://commutator.app"
|
|
12
|
+
|
|
13
|
+
client_stage = CommutatorClient(api_key="cs_stage_123")
|
|
14
|
+
assert client_stage.base_url == "https://commutatorstaging.com"
|
|
15
|
+
|
|
16
|
+
client_demo = CommutatorClient(api_key="cs_demo_123")
|
|
17
|
+
assert client_demo.base_url == "https://commutator.tech"
|
|
18
|
+
|
|
19
|
+
client_loc = CommutatorClient(api_key="cs_loc_123")
|
|
20
|
+
assert client_loc.base_url == "http://localhost:3000"
|
|
21
|
+
|
|
22
|
+
client_legacy = CommutatorClient(api_key="legacy_key_123")
|
|
23
|
+
assert client_legacy.base_url == "https://commutator.app"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_client_missing_creds():
|
|
27
|
+
with pytest.raises(ValueError):
|
|
28
|
+
CommutatorClient()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_client_env_vars(monkeypatch):
|
|
32
|
+
monkeypatch.setenv("COMMUTATOR_API_KEY", "cs_loc_123")
|
|
33
|
+
monkeypatch.setenv("COMMUTATOR_ORGANIZATION_ID", "org_123")
|
|
34
|
+
|
|
35
|
+
client = CommutatorClient()
|
|
36
|
+
assert client._api_key == "cs_loc_123"
|
|
37
|
+
assert client._organization_id == "org_123"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_login_api_key_cls():
|
|
41
|
+
client = CommutatorClient.login_with_api_key(api_key="cs_loc_123")
|
|
42
|
+
assert client._api_key == "cs_loc_123"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@responses.activate
|
|
46
|
+
def test_client_login_success_prod():
|
|
47
|
+
responses.add(
|
|
48
|
+
responses.POST,
|
|
49
|
+
"https://commutator.app/api/v1/identity/login",
|
|
50
|
+
json={"data": {"token": "mocked_jwt_token", "user": {"organizationId": "123"}}},
|
|
51
|
+
status=200,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
client = CommutatorClient.login_with_email(email="test@example.com", password="password123")
|
|
55
|
+
assert client._token == "mocked_jwt_token"
|
|
56
|
+
assert client.base_url == "https://commutator.app"
|
|
57
|
+
assert client._organization_id == "123"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@responses.activate
|
|
61
|
+
def test_client_login_success_local():
|
|
62
|
+
responses.add(
|
|
63
|
+
responses.POST,
|
|
64
|
+
"http://localhost:3000/api/v1/identity/login",
|
|
65
|
+
json={"data": {"token": "mocked_local_token", "user": {"id": "123"}}},
|
|
66
|
+
status=200,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
client = CommutatorClient.login_with_email(
|
|
70
|
+
email="test@example.com", password="password123", environment=Environment.LOCAL
|
|
71
|
+
)
|
|
72
|
+
assert client._token == "mocked_local_token"
|
|
73
|
+
assert client.base_url == "http://localhost:3000"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@responses.activate
|
|
77
|
+
def test_client_login_no_token():
|
|
78
|
+
responses.add(
|
|
79
|
+
responses.POST,
|
|
80
|
+
"https://commutator.app/api/v1/identity/login",
|
|
81
|
+
json={"data": {}},
|
|
82
|
+
status=200,
|
|
83
|
+
)
|
|
84
|
+
with pytest.raises(ValueError):
|
|
85
|
+
CommutatorClient.login_with_email(email="test@example.com", password="password123")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@responses.activate
|
|
89
|
+
def test_client_login_failure():
|
|
90
|
+
responses.add(
|
|
91
|
+
responses.POST,
|
|
92
|
+
"https://commutator.app/api/v1/identity/login",
|
|
93
|
+
json={"detail": "Invalid credentials"},
|
|
94
|
+
status=401,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
with pytest.raises(AuthenticationError):
|
|
98
|
+
CommutatorClient.login_with_email(email="test@example.com", password="wrongpassword")
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Copyright (c) 2026. Commutator Studios GmbH.
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
import requests
|
|
5
|
+
from commutator.exceptions import (
|
|
6
|
+
APIError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
CommutatorError,
|
|
9
|
+
NotFoundError,
|
|
10
|
+
ValidationError,
|
|
11
|
+
raise_for_status,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_commutator_error():
|
|
16
|
+
err = CommutatorError(message="test")
|
|
17
|
+
assert str(err) == "test"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_api_error_str():
|
|
21
|
+
err = APIError(status_code=500, code="ERR", message="Oops", request_id="req123")
|
|
22
|
+
assert str(err) == "[500 ERR] Oops (Request ID: req123)"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_raise_for_status_auth():
|
|
26
|
+
resp = requests.Response()
|
|
27
|
+
resp.status_code = 401
|
|
28
|
+
resp._content = b'{"status": 401, "code": "AUTH", "message": "unauth"}'
|
|
29
|
+
|
|
30
|
+
with pytest.raises(AuthenticationError):
|
|
31
|
+
raise_for_status(resp)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_raise_for_status_valid():
|
|
35
|
+
resp = requests.Response()
|
|
36
|
+
resp.status_code = 400
|
|
37
|
+
resp._content = b'{"status": 400, "code": "VAL", "message": "bad"}'
|
|
38
|
+
|
|
39
|
+
with pytest.raises(ValidationError):
|
|
40
|
+
raise_for_status(resp)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_raise_for_status_not_found():
|
|
44
|
+
resp = requests.Response()
|
|
45
|
+
resp.status_code = 404
|
|
46
|
+
resp._content = b'{"status": 404, "code": "NOT_FOUND", "message": "missing"}'
|
|
47
|
+
|
|
48
|
+
with pytest.raises(NotFoundError):
|
|
49
|
+
raise_for_status(resp)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_raise_for_status_api():
|
|
53
|
+
resp = requests.Response()
|
|
54
|
+
resp.status_code = 500
|
|
55
|
+
resp._content = b'{"status": 500, "code": "API", "message": "server err"}'
|
|
56
|
+
|
|
57
|
+
with pytest.raises(APIError):
|
|
58
|
+
raise_for_status(resp)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_raise_for_status_bad_json():
|
|
62
|
+
resp = requests.Response()
|
|
63
|
+
resp.status_code = 500
|
|
64
|
+
resp._content = b"not json"
|
|
65
|
+
|
|
66
|
+
with pytest.raises(APIError):
|
|
67
|
+
raise_for_status(resp)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Copyright (c) 2026. Commutator Studios GmbH.
|
|
2
|
+
|
|
3
|
+
import responses
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@responses.activate
|
|
7
|
+
def test_fleet_list_backends(mock_client):
|
|
8
|
+
responses.add(
|
|
9
|
+
responses.GET,
|
|
10
|
+
"https://commutatorstaging.com/api/v1/backends",
|
|
11
|
+
json={"data": [{"name": "ibm_kyiv", "status": "online"}]},
|
|
12
|
+
status=200,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
backends = mock_client.fleet.list_backends()
|
|
16
|
+
assert backends is not None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@responses.activate
|
|
20
|
+
def test_fleet_update_backend(mock_client):
|
|
21
|
+
responses.add(
|
|
22
|
+
responses.PATCH,
|
|
23
|
+
"https://commutatorstaging.com/api/v1/backends",
|
|
24
|
+
json={"data": {"id": "backend_1", "enabled": False}},
|
|
25
|
+
status=200,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
result = mock_client.fleet.update_backend(backend_id="backend_1", enabled=False)
|
|
29
|
+
assert result["enabled"] is False
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@responses.activate
|
|
33
|
+
def test_fleet_discover(mock_client):
|
|
34
|
+
responses.add(
|
|
35
|
+
responses.POST,
|
|
36
|
+
"https://commutatorstaging.com/api/v1/backends/discovery",
|
|
37
|
+
status=204,
|
|
38
|
+
body="",
|
|
39
|
+
)
|
|
40
|
+
responses.add(
|
|
41
|
+
responses.GET,
|
|
42
|
+
"https://commutatorstaging.com/api/v1/backends",
|
|
43
|
+
json={"data": [{"name": "ibm_kyiv", "status": "online"}]},
|
|
44
|
+
status=200,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
result = mock_client.fleet.discover()
|
|
48
|
+
assert len(result) == 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@responses.activate
|
|
52
|
+
def test_fleet_activate(mock_client):
|
|
53
|
+
responses.add(
|
|
54
|
+
responses.PATCH,
|
|
55
|
+
"https://commutatorstaging.com/api/v1/backend/backend_1/activate",
|
|
56
|
+
status=204,
|
|
57
|
+
body="",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
result = mock_client.fleet.activate(backend_id="backend_1")
|
|
61
|
+
assert result is True
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@responses.activate
|
|
65
|
+
def test_fleet_deactivate(mock_client):
|
|
66
|
+
responses.add(
|
|
67
|
+
responses.PATCH,
|
|
68
|
+
"https://commutatorstaging.com/api/v1/backend/backend_1/activate",
|
|
69
|
+
status=204,
|
|
70
|
+
body="",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
result = mock_client.fleet.deactivate(backend_id="backend_1")
|
|
74
|
+
assert result is True
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Copyright (c) 2026. Commutator Studios GmbH.
|
|
2
|
+
|
|
3
|
+
import responses
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@responses.activate
|
|
7
|
+
def test_identity_get_keys(mock_client):
|
|
8
|
+
responses.add(
|
|
9
|
+
responses.GET,
|
|
10
|
+
"https://commutatorstaging.com/api/v1/identity/keys",
|
|
11
|
+
json={"data": []},
|
|
12
|
+
status=200,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
profile = mock_client.identity.list_api_keys()
|
|
16
|
+
assert profile is not None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@responses.activate
|
|
20
|
+
def test_identity_create_api_key(mock_client):
|
|
21
|
+
responses.add(
|
|
22
|
+
responses.POST,
|
|
23
|
+
"https://commutatorstaging.com/api/v1/identity/keys",
|
|
24
|
+
json={"data": {"id": "key_123"}},
|
|
25
|
+
status=200,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
result = mock_client.identity.create_api_key(name="Test Key")
|
|
29
|
+
assert result["id"] == "key_123"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@responses.activate
|
|
33
|
+
def test_identity_revoke_api_key(mock_client):
|
|
34
|
+
responses.add(
|
|
35
|
+
responses.DELETE,
|
|
36
|
+
"https://commutatorstaging.com/api/v1/identity/keys/key_123",
|
|
37
|
+
status=204,
|
|
38
|
+
body="",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
result = mock_client.identity.revoke_api_key(key_id="key_123")
|
|
42
|
+
assert result is True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@responses.activate
|
|
46
|
+
def test_identity_get_team(mock_client):
|
|
47
|
+
responses.add(
|
|
48
|
+
responses.GET,
|
|
49
|
+
"https://commutatorstaging.com/api/v1/identity/team",
|
|
50
|
+
json={"data": [{"id": "team_1"}]},
|
|
51
|
+
status=200,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
team = mock_client.identity.get_team()
|
|
55
|
+
assert len(team) == 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@responses.activate
|
|
59
|
+
def test_identity_get_audit_logs(mock_client):
|
|
60
|
+
responses.add(
|
|
61
|
+
responses.GET,
|
|
62
|
+
"https://commutatorstaging.com/api/v1/audit/logs?limit=50",
|
|
63
|
+
json={"data": [{"action": "login"}]},
|
|
64
|
+
status=200,
|
|
65
|
+
match=[responses.matchers.query_param_matcher({"limit": "50"})],
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
logs = mock_client.identity.get_audit_logs()
|
|
69
|
+
assert len(logs) == 1
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@responses.activate
|
|
73
|
+
def test_identity_add_ibm_key(mock_client):
|
|
74
|
+
responses.add(
|
|
75
|
+
responses.POST,
|
|
76
|
+
"https://commutatorstaging.com/api/v1/identity/credentials",
|
|
77
|
+
json={"data": {"id": "cred_123"}},
|
|
78
|
+
status=200,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
result = mock_client.identity.add_ibm_key(api_key="ibm_123")
|
|
82
|
+
assert isinstance(result, dict)
|
|
83
|
+
assert result["id"] == "cred_123"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@responses.activate
|
|
87
|
+
def test_identity_add_ibm_key_conf(mock_client):
|
|
88
|
+
responses.add(
|
|
89
|
+
responses.POST,
|
|
90
|
+
"https://commutatorstaging.com/api/v1/identity/credentials",
|
|
91
|
+
status=409,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
result = mock_client.identity.add_ibm_key(api_key="ibm_123")
|
|
95
|
+
assert result == "this key is already set"
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Copyright (c) 2026. Commutator Studios GmbH.
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import responses
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@responses.activate
|
|
8
|
+
def test_jobs_list(mock_client):
|
|
9
|
+
responses.add(
|
|
10
|
+
responses.GET,
|
|
11
|
+
"https://commutatorstaging.com/api/v1/jobs",
|
|
12
|
+
json={"data": [{"id": "job_1", "status": "COMPLETED"}]},
|
|
13
|
+
status=200,
|
|
14
|
+
)
|
|
15
|
+
jobs = mock_client.jobs.list()
|
|
16
|
+
assert len(jobs) == 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@responses.activate
|
|
20
|
+
def test_jobs_create(mock_client, tmp_path):
|
|
21
|
+
responses.add(
|
|
22
|
+
responses.POST,
|
|
23
|
+
"https://commutatorstaging.com/api/v1/job",
|
|
24
|
+
json={"data": {"id": "job_1"}},
|
|
25
|
+
status=200,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
file_path = tmp_path / "test.json"
|
|
29
|
+
file_path.write_text("{}")
|
|
30
|
+
|
|
31
|
+
job = mock_client.jobs.create(
|
|
32
|
+
name="test", algorithm="QAOA", file_path=str(file_path), organization_id="org_1"
|
|
33
|
+
)
|
|
34
|
+
assert job["id"] == "job_1"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@responses.activate
|
|
38
|
+
def test_jobs_get(mock_client):
|
|
39
|
+
responses.add(
|
|
40
|
+
responses.GET,
|
|
41
|
+
"https://commutatorstaging.com/api/v1/job/job_1",
|
|
42
|
+
json={"data": {"id": "job_1"}},
|
|
43
|
+
status=200,
|
|
44
|
+
)
|
|
45
|
+
job = mock_client.jobs.get(job_id="job_1")
|
|
46
|
+
assert job["id"] == "job_1"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@responses.activate
|
|
50
|
+
def test_jobs_update(mock_client):
|
|
51
|
+
responses.add(
|
|
52
|
+
responses.PATCH,
|
|
53
|
+
"https://commutatorstaging.com/api/v1/job/job_1",
|
|
54
|
+
json={"data": {"id": "job_1", "name": "updated"}},
|
|
55
|
+
status=200,
|
|
56
|
+
)
|
|
57
|
+
job = mock_client.jobs.update(job_id="job_1", name="updated", test_key="val")
|
|
58
|
+
assert job["name"] == "updated"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@responses.activate
|
|
62
|
+
def test_jobs_list_algorithms(mock_client):
|
|
63
|
+
responses.add(
|
|
64
|
+
responses.GET,
|
|
65
|
+
"https://commutatorstaging.com/api/v1/job/algorithms",
|
|
66
|
+
json={"data": [{"name": "QAOA"}]},
|
|
67
|
+
status=200,
|
|
68
|
+
)
|
|
69
|
+
algs = mock_client.jobs.list_algorithms()
|
|
70
|
+
assert len(algs) == 1
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@responses.activate
|
|
74
|
+
def test_jobs_execute(mock_client):
|
|
75
|
+
responses.add(
|
|
76
|
+
responses.PATCH,
|
|
77
|
+
"https://commutatorstaging.com/api/v1/job/job_1",
|
|
78
|
+
json={"data": {"id": "job_1", "status": "Pending"}},
|
|
79
|
+
status=200,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
job = mock_client.jobs.execute(
|
|
83
|
+
job_id="job_1",
|
|
84
|
+
backend="ibm_kyiv",
|
|
85
|
+
dynamic_decoupling=True,
|
|
86
|
+
optimization_level=3,
|
|
87
|
+
extra_arg=True,
|
|
88
|
+
)
|
|
89
|
+
assert job["id"] == "job_1"
|
|
90
|
+
assert job["status"] == "Pending"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@responses.activate
|
|
94
|
+
def test_jobs_cancel_success(mock_client):
|
|
95
|
+
responses.add(
|
|
96
|
+
responses.DELETE,
|
|
97
|
+
"https://commutatorstaging.com/api/v1/job/job_1",
|
|
98
|
+
status=200,
|
|
99
|
+
)
|
|
100
|
+
result = mock_client.jobs.cancel(job_id="job_1")
|
|
101
|
+
assert result is True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@responses.activate
|
|
105
|
+
def test_jobs_get_download_url(mock_client):
|
|
106
|
+
responses.add(
|
|
107
|
+
responses.GET,
|
|
108
|
+
"https://commutatorstaging.com/api/v1/jobs/download-url?jobId=job_1",
|
|
109
|
+
json={"url": "https://example.com/download"},
|
|
110
|
+
status=200,
|
|
111
|
+
)
|
|
112
|
+
url = mock_client.jobs.get_download_url(job_id="job_1")
|
|
113
|
+
assert url == "https://example.com/download"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@responses.activate
|
|
117
|
+
def test_jobs_download_results(mock_client):
|
|
118
|
+
responses.add(
|
|
119
|
+
responses.GET,
|
|
120
|
+
"https://commutatorstaging.com/api/v1/jobs/download-data?jobId=job_1",
|
|
121
|
+
json={"result": "data"},
|
|
122
|
+
status=200,
|
|
123
|
+
)
|
|
124
|
+
data = mock_client.jobs.download_results(job_id="job_1")
|
|
125
|
+
assert data["result"] == "data"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Copyright (c) 2026. Commutator Studios GmbH.
|
|
2
|
+
|
|
3
|
+
import responses
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@responses.activate
|
|
7
|
+
def test_optimizer_get_metadata(mock_client):
|
|
8
|
+
responses.add(
|
|
9
|
+
responses.GET,
|
|
10
|
+
"https://commutatorstaging.com/api/v1/optimizer",
|
|
11
|
+
json={"data": [{"name": "QAOA"}]},
|
|
12
|
+
status=200,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
metadata = mock_client.optimizer.get_metadata()
|
|
16
|
+
assert len(metadata) == 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@responses.activate
|
|
20
|
+
def test_optimizer_run(mock_client):
|
|
21
|
+
responses.add(
|
|
22
|
+
responses.POST,
|
|
23
|
+
"https://commutatorstaging.com/api/v1/optimizer",
|
|
24
|
+
json={"data": {"id": "opt_run_1"}},
|
|
25
|
+
status=200,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
result = mock_client.optimizer.run(algorithm="QAOA", qubits=4, circuit_depth=10)
|
|
29
|
+
assert result["id"] == "opt_run_1"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@responses.activate
|
|
33
|
+
def test_optimizer_recommend(mock_client):
|
|
34
|
+
responses.add(
|
|
35
|
+
responses.POST,
|
|
36
|
+
"https://commutatorstaging.com/api/v1/optimizer/recommendations",
|
|
37
|
+
json={"data": {"recommendations": [{"backend": "ibm_kyiv"}]}},
|
|
38
|
+
status=200,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
config = mock_client.optimizer.recommend(execution_id="circ_1")
|
|
42
|
+
assert len(config) == 1
|
|
43
|
+
assert config[0]["backend"] == "ibm_kyiv"
|