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 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
@@ -0,0 +1,3 @@
1
+ """Package version."""
2
+
3
+ __version__ = "0.2.0"
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
+ ]
@@ -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
@@ -0,0 +1,5 @@
1
+ """HTTP transport for NQCT Cloud REST API."""
2
+
3
+ from nqct.httpss.session import HTTPSession
4
+
5
+ __all__ = ["HTTPSession"]
@@ -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([])