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.
@@ -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,10 @@
1
+ requests>=2.32.0
2
+
3
+ [dev]
4
+ pytest>=8.0.0
5
+ pytest-cov>=4.0.0
6
+ responses>=0.23.0
7
+ black>=24.0.0
8
+ mypy>=1.9.0
9
+ rich>=13.0.0
10
+ pre-commit>=3.5.0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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"