nqct 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nqct/__init__.py +42 -0
- nqct/_version.py +3 -0
- nqct/auth/__init__.py +21 -0
- nqct/auth/credentials.py +83 -0
- nqct/client.py +296 -0
- nqct/exceptions.py +55 -0
- nqct/execution/__init__.py +1 -0
- nqct/execution/polling.py +37 -0
- nqct/httpss/__init__.py +5 -0
- nqct/httpss/pagination.py +29 -0
- nqct/httpss/session.py +186 -0
- nqct/models/__init__.py +30 -0
- nqct/models/_http.py +15 -0
- nqct/models/backend.py +65 -0
- nqct/models/execution.py +117 -0
- nqct/models/function.py +86 -0
- nqct/models/job.py +153 -0
- nqct/py.typed +0 -0
- nqct/resources/__init__.py +11 -0
- nqct/resources/backends.py +63 -0
- nqct/resources/functions.py +46 -0
- nqct/resources/jobs.py +180 -0
- nqct/utils/QuantumSession.py +183 -0
- nqct/utils/__init__.py +1 -0
- nqct-0.2.0.dist-info/METADATA +127 -0
- nqct-0.2.0.dist-info/RECORD +28 -0
- nqct-0.2.0.dist-info/WHEEL +4 -0
- nqct-0.2.0.dist-info/licenses/LICENSE +203 -0
nqct/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""NQCT Cloud Python client."""
|
|
2
|
+
|
|
3
|
+
from nqct._version import __version__
|
|
4
|
+
from nqct.client import NQCTClient
|
|
5
|
+
from nqct.exceptions import (
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
AuthorizationError,
|
|
8
|
+
ConflictError,
|
|
9
|
+
JobFailedError,
|
|
10
|
+
JobNotCompleteError,
|
|
11
|
+
JobTimeoutError,
|
|
12
|
+
NotFoundError,
|
|
13
|
+
NQCTError,
|
|
14
|
+
RateLimitError,
|
|
15
|
+
ServerError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
from nqct.models import Backend, Function, Job
|
|
19
|
+
|
|
20
|
+
Client = NQCTClient
|
|
21
|
+
Runtime = NQCTClient
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"NQCTClient",
|
|
25
|
+
"Client",
|
|
26
|
+
"Runtime",
|
|
27
|
+
"Backend",
|
|
28
|
+
"Function",
|
|
29
|
+
"Job",
|
|
30
|
+
"NQCTError",
|
|
31
|
+
"AuthenticationError",
|
|
32
|
+
"AuthorizationError",
|
|
33
|
+
"NotFoundError",
|
|
34
|
+
"ValidationError",
|
|
35
|
+
"ConflictError",
|
|
36
|
+
"RateLimitError",
|
|
37
|
+
"ServerError",
|
|
38
|
+
"JobFailedError",
|
|
39
|
+
"JobNotCompleteError",
|
|
40
|
+
"JobTimeoutError",
|
|
41
|
+
"__version__",
|
|
42
|
+
]
|
nqct/_version.py
ADDED
nqct/auth/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Credential helpers."""
|
|
2
|
+
|
|
3
|
+
from nqct.auth.credentials import (
|
|
4
|
+
CREDENTIALS_DIR,
|
|
5
|
+
CREDENTIALS_FILE,
|
|
6
|
+
DEFAULT_PROFILE,
|
|
7
|
+
credentials_path,
|
|
8
|
+
delete_profile,
|
|
9
|
+
load_profile,
|
|
10
|
+
save_profile,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"CREDENTIALS_DIR",
|
|
15
|
+
"CREDENTIALS_FILE",
|
|
16
|
+
"DEFAULT_PROFILE",
|
|
17
|
+
"credentials_path",
|
|
18
|
+
"delete_profile",
|
|
19
|
+
"load_profile",
|
|
20
|
+
"save_profile",
|
|
21
|
+
]
|
nqct/auth/credentials.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Load and save ~/.nqct/credentials.json profiles."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
CREDENTIALS_DIR = Path.home() / ".nqct"
|
|
11
|
+
CREDENTIALS_FILE = CREDENTIALS_DIR / "credentials.json"
|
|
12
|
+
DEFAULT_PROFILE = "default"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def credentials_path() -> Path:
|
|
16
|
+
return CREDENTIALS_FILE
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_profile(name: str = DEFAULT_PROFILE) -> dict[str, str]:
|
|
20
|
+
"""Return a named credentials profile or raise ``FileNotFoundError``."""
|
|
21
|
+
path = credentials_path()
|
|
22
|
+
if path.is_file() and path.stat().st_mode & 0o077:
|
|
23
|
+
import warnings
|
|
24
|
+
|
|
25
|
+
warnings.warn(f"Credentials file {path} is world/group accessible", stacklevel=2)
|
|
26
|
+
if not path.is_file():
|
|
27
|
+
raise FileNotFoundError(
|
|
28
|
+
f"No credentials file at {path}. Call NQCTClient.save_account() or set NQCT_API_KEY."
|
|
29
|
+
)
|
|
30
|
+
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
31
|
+
if name not in data:
|
|
32
|
+
raise KeyError(f"Profile {name!r} not found in {path}")
|
|
33
|
+
profile = data[name]
|
|
34
|
+
if not isinstance(profile, dict):
|
|
35
|
+
raise ValueError(f"Profile {name!r} is not an object")
|
|
36
|
+
return {str(k): str(v) for k, v in profile.items()}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def save_profile(
|
|
40
|
+
*,
|
|
41
|
+
url: str,
|
|
42
|
+
api_key: str | None = None,
|
|
43
|
+
token: str | None = None,
|
|
44
|
+
refresh_token: str | None = None,
|
|
45
|
+
name: str = DEFAULT_PROFILE,
|
|
46
|
+
) -> Path:
|
|
47
|
+
"""Persist a credentials profile with file mode ``0600``."""
|
|
48
|
+
CREDENTIALS_DIR.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
path = credentials_path()
|
|
50
|
+
|
|
51
|
+
existing: dict[str, Any] = {}
|
|
52
|
+
if path.is_file():
|
|
53
|
+
existing = json.loads(path.read_text(encoding="utf-8"))
|
|
54
|
+
|
|
55
|
+
profile: dict[str, str] = {"url": url.rstrip("/")}
|
|
56
|
+
if api_key is not None:
|
|
57
|
+
profile["api_key"] = api_key
|
|
58
|
+
if token is not None:
|
|
59
|
+
profile["token"] = token
|
|
60
|
+
if refresh_token is not None:
|
|
61
|
+
profile["refresh_token"] = refresh_token
|
|
62
|
+
|
|
63
|
+
existing[name] = profile
|
|
64
|
+
path.write_text(json.dumps(existing, indent=2) + "\n", encoding="utf-8")
|
|
65
|
+
os.chmod(path, 0o600)
|
|
66
|
+
if path.stat().st_mode & 0o077:
|
|
67
|
+
import warnings
|
|
68
|
+
|
|
69
|
+
warnings.warn(f"Credentials file {path} is world/group accessible", stacklevel=2)
|
|
70
|
+
return path
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def delete_profile(name: str = DEFAULT_PROFILE) -> None:
|
|
74
|
+
path = credentials_path()
|
|
75
|
+
if not path.is_file():
|
|
76
|
+
return
|
|
77
|
+
data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
|
|
78
|
+
data.pop(name, None)
|
|
79
|
+
if data:
|
|
80
|
+
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
81
|
+
os.chmod(path, 0o600)
|
|
82
|
+
else:
|
|
83
|
+
path.unlink(missing_ok=True)
|
nqct/client.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
"""NQCT Cloud client entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import Any
|
|
7
|
+
from uuid import UUID
|
|
8
|
+
|
|
9
|
+
from nqct.auth.credentials import DEFAULT_PROFILE, delete_profile, load_profile, save_profile
|
|
10
|
+
from nqct.exceptions import AuthenticationError
|
|
11
|
+
from nqct.httpss.session import HTTPSession
|
|
12
|
+
from nqct.models.backend import Backend
|
|
13
|
+
from nqct.models.function import Function
|
|
14
|
+
from nqct.models.job import Job
|
|
15
|
+
from nqct.resources.backends import BackendsManager
|
|
16
|
+
from nqct.resources.functions import FunctionsManager
|
|
17
|
+
from nqct.resources.jobs import JobsManager, JobSubmitSource, QubitMapping
|
|
18
|
+
|
|
19
|
+
_ENV_URL = "NQCT_URL"
|
|
20
|
+
_ENV_API_KEY = "NQCT_API_KEY"
|
|
21
|
+
_ENV_TOKEN = "NQCT_TOKEN"
|
|
22
|
+
_ENV_REFRESH_TOKEN = "NQCT_REFRESH_TOKEN"
|
|
23
|
+
_ENV_ACCOUNT_NAME = "NQCT_ACCOUNT_NAME"
|
|
24
|
+
_ENV_VERIFY_SSL = "NQCT_VERIFY_SSL"
|
|
25
|
+
|
|
26
|
+
DEFAULT_API_URL = "https://api.nqct.org/api/v1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NQCTClient:
|
|
30
|
+
"""Client for the NQCT Cloud REST API.
|
|
31
|
+
|
|
32
|
+
Maps to platform routes under ``/api/v1``. See the product spec in the
|
|
33
|
+
sibling ``nqct-cloud`` repo: ``references/specs/14-python-sdk.md``.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
url: API base URL including ``/api/v1``. Defaults to production
|
|
37
|
+
(``https://api.nqct.org/api/v1``). Override for local ``nqct start``
|
|
38
|
+
(``http://localhost:8000/api/v1``) or via ``NQCT_URL``.
|
|
39
|
+
api_key: ``X-API-Key`` value (``nqct_`` prefix). Preferred for automation.
|
|
40
|
+
token: JWT access token (optional; notebook use).
|
|
41
|
+
refresh_token: JWT refresh token (optional).
|
|
42
|
+
account_name: Named profile in ``~/.nqct/credentials.json``.
|
|
43
|
+
verify_ssl: Verify TLS certificates (default ``True``).
|
|
44
|
+
Set env ``NQCT_VERIFY_SSL=false`` to disable.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
url: str | None = None,
|
|
51
|
+
api_key: str | None = None,
|
|
52
|
+
token: str | None = None,
|
|
53
|
+
refresh_token: str | None = None,
|
|
54
|
+
account_name: str | None = None,
|
|
55
|
+
verify_ssl: bool | None = None,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._refresh_token = refresh_token
|
|
58
|
+
resolved = _resolve_credentials(
|
|
59
|
+
url=url,
|
|
60
|
+
api_key=api_key,
|
|
61
|
+
token=token,
|
|
62
|
+
refresh_token=refresh_token,
|
|
63
|
+
account_name=account_name,
|
|
64
|
+
)
|
|
65
|
+
self.url = resolved["url"]
|
|
66
|
+
self.api_key = resolved.get("api_key")
|
|
67
|
+
self.token = resolved.get("token")
|
|
68
|
+
self._refresh_token = resolved.get("refresh_token") or self._refresh_token
|
|
69
|
+
if not self.api_key and not self.token:
|
|
70
|
+
raise AuthenticationError(
|
|
71
|
+
"No credentials provided. Set NQCT_API_KEY, pass api_key=, or call save_account()."
|
|
72
|
+
)
|
|
73
|
+
verify = verify_ssl if verify_ssl is not None else _env_verify_ssl()
|
|
74
|
+
self._http = HTTPSession(
|
|
75
|
+
self.url,
|
|
76
|
+
api_key=self.api_key,
|
|
77
|
+
token=self.token,
|
|
78
|
+
verify_ssl=verify,
|
|
79
|
+
)
|
|
80
|
+
self._backends = BackendsManager(self._http)
|
|
81
|
+
self._jobs = JobsManager(self._http)
|
|
82
|
+
self._functions = FunctionsManager(self._http)
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def save_account(
|
|
86
|
+
cls,
|
|
87
|
+
*,
|
|
88
|
+
api_key: str | None = None,
|
|
89
|
+
url: str | None = None,
|
|
90
|
+
token: str | None = None,
|
|
91
|
+
refresh_token: str | None = None,
|
|
92
|
+
name: str = DEFAULT_PROFILE,
|
|
93
|
+
) -> None:
|
|
94
|
+
"""Save credentials to ``~/.nqct/credentials.json`` (mode ``0600``).
|
|
95
|
+
|
|
96
|
+
``url`` defaults to production (``DEFAULT_API_URL``). Pass a different
|
|
97
|
+
URL for local development.
|
|
98
|
+
"""
|
|
99
|
+
save_profile(
|
|
100
|
+
url=url or DEFAULT_API_URL,
|
|
101
|
+
api_key=api_key,
|
|
102
|
+
token=token,
|
|
103
|
+
refresh_token=refresh_token,
|
|
104
|
+
name=name,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@classmethod
|
|
108
|
+
def delete_account(cls, *, name: str = DEFAULT_PROFILE) -> None:
|
|
109
|
+
"""Remove a named profile from the credentials file."""
|
|
110
|
+
delete_profile(name=name)
|
|
111
|
+
|
|
112
|
+
def close(self) -> None:
|
|
113
|
+
self._http.close()
|
|
114
|
+
|
|
115
|
+
def __enter__(self) -> NQCTClient:
|
|
116
|
+
return self
|
|
117
|
+
|
|
118
|
+
def __exit__(self, *args: object) -> None:
|
|
119
|
+
self.close()
|
|
120
|
+
|
|
121
|
+
def me(self) -> dict[str, Any]:
|
|
122
|
+
"""``GET /auth/me`` — current user profile."""
|
|
123
|
+
payload: dict[str, Any] = self._http.get("/auth/me").json()
|
|
124
|
+
return payload
|
|
125
|
+
|
|
126
|
+
def backends(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
skip: int = 0,
|
|
130
|
+
limit: int = 100,
|
|
131
|
+
type: str | None = None,
|
|
132
|
+
status: str | None = None,
|
|
133
|
+
provider: str | None = None,
|
|
134
|
+
search: str | None = None,
|
|
135
|
+
) -> list[Backend]:
|
|
136
|
+
"""``GET /backends`` — list backends with optional filters."""
|
|
137
|
+
return self._backends.list(
|
|
138
|
+
skip=skip,
|
|
139
|
+
limit=limit,
|
|
140
|
+
type=type,
|
|
141
|
+
status=status,
|
|
142
|
+
provider=provider,
|
|
143
|
+
search=search,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def backend(self, backend_id: str) -> Backend:
|
|
147
|
+
"""``GET /backends/{id}`` — fetch a single backend."""
|
|
148
|
+
return self._backends.get(backend_id)
|
|
149
|
+
|
|
150
|
+
def least_busy(self, *, type: str = "simulator") -> Backend:
|
|
151
|
+
"""Return the online backend with the lowest queue depth."""
|
|
152
|
+
return self._backends.least_busy(type=type)
|
|
153
|
+
|
|
154
|
+
def jobs(
|
|
155
|
+
self,
|
|
156
|
+
*,
|
|
157
|
+
skip: int = 0,
|
|
158
|
+
limit: int = 100,
|
|
159
|
+
status: str | None = None,
|
|
160
|
+
function_id: str | None = None,
|
|
161
|
+
backend_id: str | None = None,
|
|
162
|
+
source: str | None = None,
|
|
163
|
+
user_id: UUID | str | None = None,
|
|
164
|
+
) -> list[Job]:
|
|
165
|
+
"""``GET /jobs`` — list jobs with optional filters."""
|
|
166
|
+
return self._jobs.list(
|
|
167
|
+
skip=skip,
|
|
168
|
+
limit=limit,
|
|
169
|
+
status=status,
|
|
170
|
+
function_id=function_id,
|
|
171
|
+
backend_id=backend_id,
|
|
172
|
+
source=source,
|
|
173
|
+
user_id=user_id,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def submit_job(
|
|
177
|
+
self,
|
|
178
|
+
*,
|
|
179
|
+
qasm: str,
|
|
180
|
+
backend_id: str,
|
|
181
|
+
shots: int = 1024,
|
|
182
|
+
priority: int = 5,
|
|
183
|
+
source: JobSubmitSource = "direct_qasm",
|
|
184
|
+
execution_config: dict[str, Any] | None = None,
|
|
185
|
+
metadata: dict[str, Any] | None = None,
|
|
186
|
+
fake_backend_name: str | None = None,
|
|
187
|
+
optimization_level: int = 1,
|
|
188
|
+
custom_noise_model: dict[str, Any] | None = None,
|
|
189
|
+
qubit_mapping: QubitMapping | None = None,
|
|
190
|
+
gate_substitutions: dict[str, Any] | None = None,
|
|
191
|
+
acquisition_type: str | None = None,
|
|
192
|
+
averaging: str | None = None,
|
|
193
|
+
shot_repeat: int | None = None,
|
|
194
|
+
readout_mapping: dict[str, Any] | None = None,
|
|
195
|
+
pulse_calibration_id: str | None = None,
|
|
196
|
+
) -> Job:
|
|
197
|
+
"""``POST /jobs`` — submit OpenQASM 3 for async execution on a managed backend.
|
|
198
|
+
|
|
199
|
+
Use ``source="api"`` for automation clients that distinguish SDK submits.
|
|
200
|
+
Use ``source="pulse_designer"`` when submitting regenerated OpenPulse QASM
|
|
201
|
+
for hardware execution. If ``execution_config`` is passed explicitly, it is
|
|
202
|
+
sent as-is and the hardware/simulator kwargs (``fake_backend_name``,
|
|
203
|
+
``optimization_level``, ``custom_noise_model``, ``qubit_mapping``,
|
|
204
|
+
``gate_substitutions``, ``acquisition_type``, ``averaging``,
|
|
205
|
+
``shot_repeat``, ``readout_mapping``, ``pulse_calibration_id``) are ignored.
|
|
206
|
+
"""
|
|
207
|
+
return self._jobs.submit(
|
|
208
|
+
qasm=qasm,
|
|
209
|
+
backend_id=backend_id,
|
|
210
|
+
shots=shots,
|
|
211
|
+
priority=priority,
|
|
212
|
+
source=source,
|
|
213
|
+
execution_config=execution_config,
|
|
214
|
+
metadata=metadata,
|
|
215
|
+
fake_backend_name=fake_backend_name,
|
|
216
|
+
optimization_level=optimization_level,
|
|
217
|
+
custom_noise_model=custom_noise_model,
|
|
218
|
+
qubit_mapping=qubit_mapping,
|
|
219
|
+
gate_substitutions=gate_substitutions,
|
|
220
|
+
acquisition_type=acquisition_type,
|
|
221
|
+
averaging=averaging,
|
|
222
|
+
shot_repeat=shot_repeat,
|
|
223
|
+
readout_mapping=readout_mapping,
|
|
224
|
+
pulse_calibration_id=pulse_calibration_id,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
def job(self, job_id: UUID | str) -> Job:
|
|
228
|
+
"""``GET /jobs/{id}`` — fetch a single job."""
|
|
229
|
+
return self._jobs.get(job_id)
|
|
230
|
+
|
|
231
|
+
def functions(
|
|
232
|
+
self,
|
|
233
|
+
*,
|
|
234
|
+
skip: int = 0,
|
|
235
|
+
limit: int = 100,
|
|
236
|
+
status: str | None = None,
|
|
237
|
+
sdk_type: str | None = None,
|
|
238
|
+
search: str | None = None,
|
|
239
|
+
) -> list[Function]:
|
|
240
|
+
"""``GET /functions`` — list own and public functions."""
|
|
241
|
+
return self._functions.list(
|
|
242
|
+
skip=skip,
|
|
243
|
+
limit=limit,
|
|
244
|
+
status=status,
|
|
245
|
+
sdk_type=sdk_type,
|
|
246
|
+
search=search,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def function(self, function_id: UUID | str) -> Function:
|
|
250
|
+
"""``GET /functions/{id}`` — fetch a single function."""
|
|
251
|
+
return self._functions.get(function_id)
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def http(self) -> HTTPSession:
|
|
255
|
+
"""Low-level HTTP session (advanced use)."""
|
|
256
|
+
return self._http
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _resolve_credentials(
|
|
260
|
+
*,
|
|
261
|
+
url: str | None,
|
|
262
|
+
api_key: str | None,
|
|
263
|
+
token: str | None,
|
|
264
|
+
refresh_token: str | None,
|
|
265
|
+
account_name: str | None,
|
|
266
|
+
) -> dict[str, str]:
|
|
267
|
+
profile_name = account_name or os.environ.get(_ENV_ACCOUNT_NAME, DEFAULT_PROFILE)
|
|
268
|
+
|
|
269
|
+
if url is None and api_key is None and token is None:
|
|
270
|
+
try:
|
|
271
|
+
profile = load_profile(profile_name)
|
|
272
|
+
url = profile.get("url")
|
|
273
|
+
api_key = api_key or profile.get("api_key")
|
|
274
|
+
token = token or profile.get("token")
|
|
275
|
+
refresh_token = refresh_token or profile.get("refresh_token")
|
|
276
|
+
except (FileNotFoundError, KeyError):
|
|
277
|
+
pass
|
|
278
|
+
|
|
279
|
+
url = url or os.environ.get(_ENV_URL) or DEFAULT_API_URL
|
|
280
|
+
api_key = api_key or os.environ.get(_ENV_API_KEY)
|
|
281
|
+
token = token or os.environ.get(_ENV_TOKEN)
|
|
282
|
+
refresh_token = refresh_token or os.environ.get(_ENV_REFRESH_TOKEN)
|
|
283
|
+
|
|
284
|
+
result: dict[str, str] = {"url": url}
|
|
285
|
+
if api_key:
|
|
286
|
+
result["api_key"] = api_key
|
|
287
|
+
if token:
|
|
288
|
+
result["token"] = token
|
|
289
|
+
if refresh_token:
|
|
290
|
+
result["refresh_token"] = refresh_token
|
|
291
|
+
return result
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _env_verify_ssl() -> bool:
|
|
295
|
+
raw = os.environ.get(_ENV_VERIFY_SSL, "true").lower()
|
|
296
|
+
return raw not in {"0", "false", "no"}
|
nqct/exceptions.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""NQCT SDK exception hierarchy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NQCTError(Exception):
|
|
9
|
+
"""Base error for all NQCT SDK failures."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, message: str, *, status_code: int | None = None, detail: Any = None) -> None:
|
|
12
|
+
super().__init__(message)
|
|
13
|
+
self.message = message
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
self.detail = detail
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class AuthenticationError(NQCTError):
|
|
19
|
+
"""Raised when credentials are missing, invalid, or expired (HTTP 401)."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class AuthorizationError(NQCTError):
|
|
23
|
+
"""Raised when the user lacks permission (HTTP 403)."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NotFoundError(NQCTError):
|
|
27
|
+
"""Raised when a resource does not exist (HTTP 404)."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ValidationError(NQCTError):
|
|
31
|
+
"""Raised when request validation fails (HTTP 422)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ConflictError(NQCTError):
|
|
35
|
+
"""Raised on resource conflicts such as booking overlap (HTTP 409)."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RateLimitError(NQCTError):
|
|
39
|
+
"""Raised when the API rate limit is exceeded (HTTP 429)."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ServerError(NQCTError):
|
|
43
|
+
"""Raised on server-side failures (HTTP 5xx)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class JobFailedError(NQCTError):
|
|
47
|
+
"""Raised when a job reaches ``failed`` status."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class JobNotCompleteError(NQCTError):
|
|
51
|
+
"""Raised when results are requested before a job is ``done``."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class JobTimeoutError(NQCTError):
|
|
55
|
+
"""Raised when ``job.wait()`` exceeds the timeout."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Sync circuit execution and job polling (Phase 1–2)."""
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Job polling helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from nqct.exceptions import JobFailedError, JobTimeoutError
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from nqct.models.job import Job
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def wait_for_terminal_status(
|
|
15
|
+
job: Job,
|
|
16
|
+
*,
|
|
17
|
+
timeout: float | None,
|
|
18
|
+
interval: float,
|
|
19
|
+
) -> Job:
|
|
20
|
+
"""Poll until the job reaches a terminal status or times out."""
|
|
21
|
+
deadline = time.monotonic() + timeout if timeout is not None else None
|
|
22
|
+
current = job
|
|
23
|
+
|
|
24
|
+
while not current.is_terminal:
|
|
25
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
26
|
+
raise JobTimeoutError(
|
|
27
|
+
f"Job {current.id} did not complete within {timeout} seconds "
|
|
28
|
+
f"(last status: {current.status!r})."
|
|
29
|
+
)
|
|
30
|
+
time.sleep(interval)
|
|
31
|
+
current = current.refresh()
|
|
32
|
+
|
|
33
|
+
if current.status == "failed":
|
|
34
|
+
message = current.error_message or f"Job {current.id} failed."
|
|
35
|
+
raise JobFailedError(message)
|
|
36
|
+
|
|
37
|
+
return current
|
nqct/httpss/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Pagination helpers for list endpoints (Phase 1)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from typing import Any, Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
T = TypeVar("T")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PageIterator(Generic[T]):
|
|
12
|
+
"""Iterate paginated API list responses.
|
|
13
|
+
|
|
14
|
+
Concrete resource managers will populate this in Phase 1.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, items: list[T]) -> None:
|
|
18
|
+
self._items = items
|
|
19
|
+
|
|
20
|
+
def __iter__(self) -> Iterator[T]:
|
|
21
|
+
return iter(self._items)
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_payload(cls, payload: Any) -> PageIterator[Any]:
|
|
25
|
+
if isinstance(payload, list):
|
|
26
|
+
return cls(payload)
|
|
27
|
+
if isinstance(payload, dict) and isinstance(payload.get("items"), list):
|
|
28
|
+
return cls(payload["items"])
|
|
29
|
+
return cls([])
|