openquantum-sdk 0.4.2__tar.gz → 1.0.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.
Files changed (36) hide show
  1. openquantum_sdk-1.0.0/MANIFEST.in +10 -0
  2. {openquantum_sdk-0.4.2/openquantum_sdk.egg-info → openquantum_sdk-1.0.0}/PKG-INFO +35 -8
  3. openquantum_sdk-0.4.2/PKG-INFO → openquantum_sdk-1.0.0/README.md +28 -27
  4. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/openquantum_sdk/__init__.py +18 -1
  5. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/openquantum_sdk/__main__.py +23 -33
  6. openquantum_sdk-1.0.0/openquantum_sdk/accounts.py +257 -0
  7. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/openquantum_sdk/clients.py +269 -56
  8. openquantum_sdk-1.0.0/openquantum_sdk/credentials.py +104 -0
  9. openquantum_sdk-1.0.0/openquantum_sdk/exceptions.py +38 -0
  10. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/openquantum_sdk/models.py +4 -2
  11. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/openquantum_sdk/qiskit/__init__.py +12 -7
  12. openquantum_sdk-1.0.0/openquantum_sdk/session.py +352 -0
  13. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/openquantum_sdk/utils.py +26 -8
  14. openquantum_sdk-0.4.2/README.md → openquantum_sdk-1.0.0/openquantum_sdk.egg-info/PKG-INFO +54 -5
  15. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/openquantum_sdk.egg-info/SOURCES.txt +7 -1
  16. openquantum_sdk-1.0.0/openquantum_sdk.egg-info/requires.txt +21 -0
  17. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/openquantum_sdk.egg-info/top_level.txt +1 -0
  18. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/pyproject.toml +6 -4
  19. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/tests/test_backends.py +11 -0
  20. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/tests/test_clients_plan_priority.py +141 -1
  21. openquantum_sdk-1.0.0/tests/test_polling.py +84 -0
  22. openquantum_sdk-1.0.0/tests/test_qiskit_wrappers.py +207 -0
  23. openquantum_sdk-1.0.0/tests/test_session_auth.py +270 -0
  24. openquantum_sdk-0.4.2/MANIFEST.in +0 -4
  25. openquantum_sdk-0.4.2/openquantum_sdk.egg-info/requires.txt +0 -15
  26. openquantum_sdk-0.4.2/tests/test_qiskit_wrappers.py +0 -76
  27. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/openquantum_sdk/auth.py +0 -0
  28. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/openquantum_sdk/backends.py +0 -0
  29. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/openquantum_sdk/enums.py +0 -0
  30. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/openquantum_sdk.egg-info/dependency_links.txt +0 -0
  31. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/openquantum_sdk.egg-info/entry_points.txt +0 -0
  32. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/requirements-dev.txt +0 -0
  33. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/requirements.txt +0 -0
  34. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/setup.cfg +0 -0
  35. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/tests/__init__.py +0 -0
  36. {openquantum_sdk-0.4.2 → openquantum_sdk-1.0.0}/tests/test_clients.py +0 -0
@@ -0,0 +1,10 @@
1
+ # Unit tests ship in the sdist. packages.find excludes them from the wheel,
2
+ # so they are not installed into site-packages.
3
+ # plugins/ is a separate set of distributions and must not ride along.
4
+ # smoke/ is the live runner for the manual SDK smoke workflow.
5
+ include README.md
6
+ include requirements*.txt
7
+ recursive-include tests *.py
8
+ global-exclude *.pyc
9
+ prune plugins
10
+ prune smoke
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: openquantum-sdk
3
- Version: 0.4.2
3
+ Version: 1.0.0
4
4
  Summary: Python SDK for Open Quantum
5
5
  Classifier: Programming Language :: Python :: 3
6
6
  Classifier: License :: OSI Approved :: Apache Software License
@@ -16,9 +16,13 @@ Requires-Dist: requests-mock>=1.10.0; extra == "dev"
16
16
  Requires-Dist: flake8; extra == "dev"
17
17
  Requires-Dist: black; extra == "dev"
18
18
  Provides-Extra: qiskit
19
- Requires-Dist: openquantum-sdk-qiskit>=0.3.1; extra == "qiskit"
19
+ Requires-Dist: openquantum-sdk-qiskit>=1.0.0; extra == "qiskit"
20
20
  Provides-Extra: pennylane
21
- Requires-Dist: openquantum-sdk-pennylane>=0.2.1; extra == "pennylane"
21
+ Requires-Dist: openquantum-sdk-pennylane>=1.0.0; extra == "pennylane"
22
+ Provides-Extra: cirq
23
+ Requires-Dist: openquantum-sdk-cirq>=0.5.0; extra == "cirq"
24
+ Provides-Extra: qrisp
25
+ Requires-Dist: openquantum-sdk-qrisp>=0.5.0; extra == "qrisp"
22
26
 
23
27
  # Open Quantum Python SDK
24
28
 
@@ -44,6 +48,8 @@ pip install -e .
44
48
 
45
49
  Python 3.8+ required.
46
50
 
51
+ Source distributions include the `tests/` tree so the unit tests can be run from an sdist. Wheels do not install those tests into `site-packages`. The Qiskit, PennyLane, Cirq, and Qrisp plugins, and `openquantum-mcp`, use the same split. Package versions live in each `pyproject.toml` and `__version__` (core, Qiskit, and PennyLane `1.0.0`; Cirq and Qrisp `0.5.0`; MCP `0.2.0`).
52
+
47
53
  ## Authentication
48
54
 
49
55
  ### Option 1: SDK Key File (Recommended)
@@ -78,6 +84,18 @@ export OPENQUANTUM_CLIENT_ID=s_...
78
84
  export OPENQUANTUM_CLIENT_SECRET=e460c8...
79
85
  ```
80
86
 
87
+ ### `OpenQuantumClient.from_env()`
88
+
89
+ Builds a client from those environment credentials and from `OPENQUANTUM_SCHEDULER_URL`, `OPENQUANTUM_MANAGEMENT_URL`, `OPENQUANTUM_KEYCLOAK_BASE`, and `OPENQUANTUM_KEYCLOAK_REALM`. It does not autoload a saved account. It raises `AuthenticationError` when no credential resolves.
90
+
91
+ ```python
92
+ from openquantum_sdk import OpenQuantumClient
93
+
94
+ client = OpenQuantumClient.from_env()
95
+ ```
96
+
97
+ `OPENQUANTUM_ORGANIZATION_ID` is not read by the core client constructor. PennyLane, Cirq, Qrisp, `get_backend`, and the MCP server use it when `organization_id` is omitted. The old names `OQ_ORG_ID` and `OQ_SUBCATEGORY_ID` are not read.
98
+
81
99
  ## Quickstart: One-Call Submission (Recommended)
82
100
 
83
101
  ```python
@@ -282,9 +300,10 @@ finally:
282
300
  | `name` | `str` | — | **Required** |
283
301
  | `shots` | `int` | — | **Required** |
284
302
  | `configuration_data` | `Dict[str, Any]` |`None` | |
285
- | `execution_plan` | `ExecutionPlanType \| "auto"` | `"auto"` | |
286
- | `queue_priority` | `QueuePriorityType \| "auto"` | `"auto"` | |
303
+ | `execution_plan` | `str`, enum, or UUID | `"auto"` | `"auto"`, `"public"`, `"private"`, `ExecutionPlanType`, or a plan UUID. `"auto"` picks the cheapest plan |
304
+ | `queue_priority` | `str`, enum, or UUID | `"auto"` | `"auto"`, `"standard"`, `"priority"`, `"instant"`, `QueuePriorityType`, or a priority UUID. `"auto"` picks the cheapest priority |
287
305
  | `auto_approve_quote` | `bool` | `True` | Set `False` in notebooks |
306
+ | `verbose` | `bool` | `False` | Print submission progress. Same default on every plugin |
288
307
  | `job_timeout_seconds` | `int` | `86400` | 1 day |
289
308
  | `input_format` | `str` | `"qasm"` | OpenQASM by default; other values only if listed in backend `supported_formats` |
290
309
  | `submitted_with` | `str` | `"sdk"` | Client surface for analytics |
@@ -301,7 +320,10 @@ finally:
301
320
  | `upload_job_input(file_path=... or file_content=...)` | `str` | Returns upload ID |
302
321
  | `prepare_job(JobPreparationCreate)` | `JobPreparationResponse` | Starts preparation |
303
322
  | `get_preparation_result(preparation_id)` | `JobPreparationResultResponse` | Includes `.quote` |
323
+ | `wait_for_preparation(preparation_id, timeout=300, interval=2)` | `JobPreparationResultResponse` | Poll until preparation completes |
324
+ | `choose_plan_and_priority(result, execution_plan="auto", queue_priority="auto")` | `PlanSelection` | Friendly names, enums, and UUIDs. `prefer_public=True` ranks Public first when the plan is `"auto"` |
304
325
  | `create_job(JobCreate)` | `JobRead` | Submits job |
326
+ | `wait_for_job(job_id, timeout=300, interval=5)` | `JobRead` | Poll until a terminal status. `raise_on_timeout` and `raise_on_failure` default to `True` |
305
327
  | `get_job(job_id)` | `JobRead` | Fetch job status |
306
328
  | `cancel_job(job_id)` | `None` | Cancel running job |
307
329
  | `list_jobs(organization_id, limit=20, cursor=..., status=...)` | `PaginatedJobs` | List your jobs |
@@ -353,8 +375,9 @@ from openquantum_sdk.utils import poll_for_status
353
375
  | ------ | ------ | ------ | ------ |
354
376
  | `get_status_fn` | `Callable[[str], Tuple[bool, Any]]` | | Function to be polled |
355
377
  | `resource_id` | `str` | | id to be passed to status function: `get_status_fn(resource_id)` |
356
- | `interval` | `int` | `5` | Number of seconds to wait before next call of status function |
357
- | `timeout` | `int` | `300` | Number of seconds before timeout |
378
+ | `interval` | `float` | `5` | Seconds to wait before the next status call |
379
+ | `timeout` | `float` | `300` | Seconds before polling stops |
380
+ | `raise_on_timeout` | `bool` | `True` | Raise `TimeoutError`, or return the last result when `False` |
358
381
 
359
382
 
360
383
  ## Enums
@@ -455,13 +478,17 @@ for cat in cats:
455
478
  ```
456
479
 
457
480
 
481
+ ## Live smoke tests
482
+
483
+ `.github/workflows/sdk-smoke.yml` is a manual Actions workflow. It builds the core SDK and the Qiskit, PennyLane, Cirq, and Qrisp plugins from a git ref (empty means `main`), submits a Bell circuit through each one, and waits until the platform job completes. The QPU input defaults to `rigetti:cepheus-1-108q`. Choose the `dev` or `prd` environment when you run it. See [sdk/smoke/README.md](smoke/README.md).
484
+
458
485
  ## FAQ
459
486
 
460
487
  **Q: How do I get results as JSON?**
461
488
  **A:** Use `scheduler.download_job_output(job)` — returns parsed object.
462
489
 
463
490
  **Q: Why does auto mode fail on some QPUs?**
464
- **A:** `"auto"` selects the cheapest plan and priority (`PUBLIC` + `STANDARD`). If a backend only offers private execution, pass `execution_plan="private"` explicitly. Similarly, use `queue_priority="priority"` or `"instant"` for higher-priority queue access.
491
+ **A:** `"auto"` selects the cheapest plan and priority, which is often Public + Standard. It is not limited to those two. Friendly names (`"public"`, `"private"`, `"standard"`, `"priority"`, `"instant"`), enums, and UUIDs select that option exactly. If you need a specific plan or a faster queue, pass it explicitly. The MCP server passes `prefer_public=True` so Spark credits can apply; `submit_job()` does not.
465
492
 
466
493
  **Q: Can I hide the public plan citation notice?**
467
494
  **A:** Setting `OPENQUANTUM_HIDE_CITATION_NOTICE=true` will omit the citation notice from output.
@@ -1,25 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: openquantum-sdk
3
- Version: 0.4.2
4
- Summary: Python SDK for Open Quantum
5
- Classifier: Programming Language :: Python :: 3
6
- Classifier: License :: OSI Approved :: Apache Software License
7
- Classifier: Operating System :: OS Independent
8
- Requires-Python: >=3.8
9
- Description-Content-Type: text/markdown
10
- Requires-Dist: requests>=2.25.0
11
- Provides-Extra: dev
12
- Requires-Dist: pytest>=7.0; extra == "dev"
13
- Requires-Dist: pytest-cov; extra == "dev"
14
- Requires-Dist: pytest-mock; extra == "dev"
15
- Requires-Dist: requests-mock>=1.10.0; extra == "dev"
16
- Requires-Dist: flake8; extra == "dev"
17
- Requires-Dist: black; extra == "dev"
18
- Provides-Extra: qiskit
19
- Requires-Dist: openquantum-sdk-qiskit>=0.3.1; extra == "qiskit"
20
- Provides-Extra: pennylane
21
- Requires-Dist: openquantum-sdk-pennylane>=0.2.1; extra == "pennylane"
22
-
23
1
  # Open Quantum Python SDK
24
2
 
25
3
  A lightweight, modern Python SDK for the **Open Quantum Platform**.
@@ -44,6 +22,8 @@ pip install -e .
44
22
 
45
23
  Python 3.8+ required.
46
24
 
25
+ Source distributions include the `tests/` tree so the unit tests can be run from an sdist. Wheels do not install those tests into `site-packages`. The Qiskit, PennyLane, Cirq, and Qrisp plugins, and `openquantum-mcp`, use the same split. Package versions live in each `pyproject.toml` and `__version__` (core, Qiskit, and PennyLane `1.0.0`; Cirq and Qrisp `0.5.0`; MCP `0.2.0`).
26
+
47
27
  ## Authentication
48
28
 
49
29
  ### Option 1: SDK Key File (Recommended)
@@ -78,6 +58,18 @@ export OPENQUANTUM_CLIENT_ID=s_...
78
58
  export OPENQUANTUM_CLIENT_SECRET=e460c8...
79
59
  ```
80
60
 
61
+ ### `OpenQuantumClient.from_env()`
62
+
63
+ Builds a client from those environment credentials and from `OPENQUANTUM_SCHEDULER_URL`, `OPENQUANTUM_MANAGEMENT_URL`, `OPENQUANTUM_KEYCLOAK_BASE`, and `OPENQUANTUM_KEYCLOAK_REALM`. It does not autoload a saved account. It raises `AuthenticationError` when no credential resolves.
64
+
65
+ ```python
66
+ from openquantum_sdk import OpenQuantumClient
67
+
68
+ client = OpenQuantumClient.from_env()
69
+ ```
70
+
71
+ `OPENQUANTUM_ORGANIZATION_ID` is not read by the core client constructor. PennyLane, Cirq, Qrisp, `get_backend`, and the MCP server use it when `organization_id` is omitted. The old names `OQ_ORG_ID` and `OQ_SUBCATEGORY_ID` are not read.
72
+
81
73
  ## Quickstart: One-Call Submission (Recommended)
82
74
 
83
75
  ```python
@@ -282,9 +274,10 @@ finally:
282
274
  | `name` | `str` | — | **Required** |
283
275
  | `shots` | `int` | — | **Required** |
284
276
  | `configuration_data` | `Dict[str, Any]` |`None` | |
285
- | `execution_plan` | `ExecutionPlanType \| "auto"` | `"auto"` | |
286
- | `queue_priority` | `QueuePriorityType \| "auto"` | `"auto"` | |
277
+ | `execution_plan` | `str`, enum, or UUID | `"auto"` | `"auto"`, `"public"`, `"private"`, `ExecutionPlanType`, or a plan UUID. `"auto"` picks the cheapest plan |
278
+ | `queue_priority` | `str`, enum, or UUID | `"auto"` | `"auto"`, `"standard"`, `"priority"`, `"instant"`, `QueuePriorityType`, or a priority UUID. `"auto"` picks the cheapest priority |
287
279
  | `auto_approve_quote` | `bool` | `True` | Set `False` in notebooks |
280
+ | `verbose` | `bool` | `False` | Print submission progress. Same default on every plugin |
288
281
  | `job_timeout_seconds` | `int` | `86400` | 1 day |
289
282
  | `input_format` | `str` | `"qasm"` | OpenQASM by default; other values only if listed in backend `supported_formats` |
290
283
  | `submitted_with` | `str` | `"sdk"` | Client surface for analytics |
@@ -301,7 +294,10 @@ finally:
301
294
  | `upload_job_input(file_path=... or file_content=...)` | `str` | Returns upload ID |
302
295
  | `prepare_job(JobPreparationCreate)` | `JobPreparationResponse` | Starts preparation |
303
296
  | `get_preparation_result(preparation_id)` | `JobPreparationResultResponse` | Includes `.quote` |
297
+ | `wait_for_preparation(preparation_id, timeout=300, interval=2)` | `JobPreparationResultResponse` | Poll until preparation completes |
298
+ | `choose_plan_and_priority(result, execution_plan="auto", queue_priority="auto")` | `PlanSelection` | Friendly names, enums, and UUIDs. `prefer_public=True` ranks Public first when the plan is `"auto"` |
304
299
  | `create_job(JobCreate)` | `JobRead` | Submits job |
300
+ | `wait_for_job(job_id, timeout=300, interval=5)` | `JobRead` | Poll until a terminal status. `raise_on_timeout` and `raise_on_failure` default to `True` |
305
301
  | `get_job(job_id)` | `JobRead` | Fetch job status |
306
302
  | `cancel_job(job_id)` | `None` | Cancel running job |
307
303
  | `list_jobs(organization_id, limit=20, cursor=..., status=...)` | `PaginatedJobs` | List your jobs |
@@ -353,8 +349,9 @@ from openquantum_sdk.utils import poll_for_status
353
349
  | ------ | ------ | ------ | ------ |
354
350
  | `get_status_fn` | `Callable[[str], Tuple[bool, Any]]` | | Function to be polled |
355
351
  | `resource_id` | `str` | | id to be passed to status function: `get_status_fn(resource_id)` |
356
- | `interval` | `int` | `5` | Number of seconds to wait before next call of status function |
357
- | `timeout` | `int` | `300` | Number of seconds before timeout |
352
+ | `interval` | `float` | `5` | Seconds to wait before the next status call |
353
+ | `timeout` | `float` | `300` | Seconds before polling stops |
354
+ | `raise_on_timeout` | `bool` | `True` | Raise `TimeoutError`, or return the last result when `False` |
358
355
 
359
356
 
360
357
  ## Enums
@@ -455,13 +452,17 @@ for cat in cats:
455
452
  ```
456
453
 
457
454
 
455
+ ## Live smoke tests
456
+
457
+ `.github/workflows/sdk-smoke.yml` is a manual Actions workflow. It builds the core SDK and the Qiskit, PennyLane, Cirq, and Qrisp plugins from a git ref (empty means `main`), submits a Bell circuit through each one, and waits until the platform job completes. The QPU input defaults to `rigetti:cepheus-1-108q`. Choose the `dev` or `prd` environment when you run it. See [sdk/smoke/README.md](smoke/README.md).
458
+
458
459
  ## FAQ
459
460
 
460
461
  **Q: How do I get results as JSON?**
461
462
  **A:** Use `scheduler.download_job_output(job)` — returns parsed object.
462
463
 
463
464
  **Q: Why does auto mode fail on some QPUs?**
464
- **A:** `"auto"` selects the cheapest plan and priority (`PUBLIC` + `STANDARD`). If a backend only offers private execution, pass `execution_plan="private"` explicitly. Similarly, use `queue_priority="priority"` or `"instant"` for higher-priority queue access.
465
+ **A:** `"auto"` selects the cheapest plan and priority, which is often Public + Standard. It is not limited to those two. Friendly names (`"public"`, `"private"`, `"standard"`, `"priority"`, `"instant"`), enums, and UUIDs select that option exactly. If you need a specific plan or a faster queue, pass it explicitly. The MCP server passes `prefer_public=True` so Spark credits can apply; `submit_job()` does not.
465
466
 
466
467
  **Q: Can I hide the public plan citation notice?**
467
468
  **A:** Setting `OPENQUANTUM_HIDE_CITATION_NOTICE=true` will omit the citation notice from output.
@@ -1,4 +1,13 @@
1
1
  from .clients import SchedulerClient, ManagementClient
2
+ from .auth import ClientCredentials, ClientCredentialsAuth
3
+ from .credentials import resolve_env_credentials
4
+ from .session import OpenQuantumClient
5
+ from .exceptions import (
6
+ OpenQuantumError,
7
+ AuthenticationError,
8
+ CircuitValidationError,
9
+ JobError,
10
+ )
2
11
  from .models import (
3
12
  APIError,
4
13
  ApiErrorType,
@@ -10,11 +19,19 @@ from .models import (
10
19
  )
11
20
  from .utils import poll_for_status
12
21
 
13
- __version__ = "0.4.2"
22
+ __version__ = "1.0.0"
14
23
  __all__ = [
15
24
  "SchedulerClient",
16
25
  "ManagementClient",
26
+ "OpenQuantumClient",
27
+ "ClientCredentials",
28
+ "ClientCredentialsAuth",
29
+ "resolve_env_credentials",
17
30
  "poll_for_status",
31
+ "OpenQuantumError",
32
+ "AuthenticationError",
33
+ "CircuitValidationError",
34
+ "JobError",
18
35
  "APIError",
19
36
  "ApiErrorType",
20
37
  "TermsOfUseRequiredError",
@@ -1,48 +1,38 @@
1
1
  #!/usr/bin/env python
2
2
  """CLI entry point for running the full job example."""
3
3
  import sys
4
- import os
5
4
  import json
6
5
  import argparse
7
6
  import requests
8
7
  from .auth import ClientCredentials, ClientCredentialsAuth
9
8
  from .clients import SchedulerClient, ManagementClient, JobSubmissionConfig
9
+ from .credentials import resolve_env_credentials
10
10
  from .models import APIError, TermsOfUseRequiredError, EmailVerificationRequiredError
11
11
 
12
12
 
13
- def load_creds_from_file(path: str) -> ClientCredentials:
14
- with open(path, "r", encoding="utf-8") as f:
15
- data = json.load(f)
16
- try:
17
- return ClientCredentials(client_id=data["client_id"], client_secret=data["client_secret"])
18
- except KeyError as e:
19
- raise ValueError(f"SDK key file missing field: {e}")
20
-
21
-
22
13
  def resolve_credentials(args: argparse.Namespace) -> ClientCredentials:
23
- # 1) explicit file flag
24
- if args.sdk_key:
25
- return load_creds_from_file(args.sdk_key)
26
-
27
- # 2) env var file path
28
- sdk_key_env = os.getenv("OPENQUANTUM_SDK_KEY")
29
- if sdk_key_env and os.path.exists(sdk_key_env):
30
- return load_creds_from_file(sdk_key_env)
31
-
32
- # 3) direct args
33
- if args.client_id and args.client_secret:
34
- return ClientCredentials(client_id=args.client_id, client_secret=args.client_secret)
35
-
36
- # 4) env direct
37
- env_id = os.getenv("OPENQUANTUM_CLIENT_ID")
38
- env_secret = os.getenv("OPENQUANTUM_CLIENT_SECRET")
39
- if env_id and env_secret:
40
- return ClientCredentials(client_id=env_id, client_secret=env_secret)
41
-
42
- raise SystemExit(
43
- "Missing credentials. Provide --sdk-key <file.json> or --client-id/--client-secret "
44
- "or set OPENQUANTUM_SDK_KEY or OPENQUANTUM_CLIENT_ID/OPENQUANTUM_CLIENT_SECRET."
45
- )
14
+ """Resolve client credentials via the shared core resolver.
15
+
16
+ The ``--sdk-key`` flag takes precedence over ``--client-id`` /
17
+ ``--client-secret``; both fall back to the environment.
18
+ """
19
+ # Honor the --sdk-key flag ahead of direct client id/secret args.
20
+ client_id = None if args.sdk_key else args.client_id
21
+ client_secret = None if args.sdk_key else args.client_secret
22
+ try:
23
+ _token, creds = resolve_env_credentials(
24
+ client_id=client_id,
25
+ client_secret=client_secret,
26
+ sdk_key_path=args.sdk_key,
27
+ require=True,
28
+ )
29
+ except ValueError as e:
30
+ raise SystemExit(str(e))
31
+ if creds is None:
32
+ raise SystemExit(
33
+ "The SDK CLI requires client credentials (SDK key), not a token."
34
+ )
35
+ return creds
46
36
 
47
37
 
48
38
  def build_arg_parser() -> argparse.ArgumentParser:
@@ -0,0 +1,257 @@
1
+ """Account persistence for the Open Quantum SDK.
2
+
3
+ Stores named authentication accounts (bearer tokens or OAuth2 client
4
+ credentials) in a JSON file at a platform-appropriate location, with secrets
5
+ kept in the system keyring when one is available. This logic is
6
+ framework-agnostic and is shared by every SDK surface (core client, Qiskit,
7
+ PennyLane, Qrisp, CLI).
8
+
9
+ The on-disk format and keyring key scheme are intentionally stable: a file
10
+ written by an older release remains readable here.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ from pathlib import Path
18
+ from tempfile import NamedTemporaryFile
19
+ from typing import Any, Dict, Optional
20
+
21
+ from openquantum_sdk.auth import ClientCredentials
22
+
23
+ DEFAULT_KEYCLOAK_BASE = "https://id.openquantum.com"
24
+ DEFAULT_REALM = "platform"
25
+ DEFAULT_SCHEDULER_URL = "https://scheduler.openquantum.com"
26
+ DEFAULT_MANAGEMENT_URL = "https://management.openquantum.com"
27
+
28
+ _KEYRING_SERVICE = "openquantum"
29
+
30
+
31
+ def accounts_path(filename: Optional[str] = None) -> Path:
32
+ """Resolve the accounts file path.
33
+
34
+ Order of precedence: explicit ``filename`` → ``OPENQUANTUM_CONFIG_DIR`` →
35
+ the platform user config dir → ``~/.openquantum/accounts.json``.
36
+ """
37
+ if filename:
38
+ return Path(filename).expanduser()
39
+ cfg_dir = os.getenv("OPENQUANTUM_CONFIG_DIR")
40
+ if cfg_dir:
41
+ return Path(cfg_dir).expanduser() / "accounts.json"
42
+ try:
43
+ from platformdirs import PlatformDirs
44
+
45
+ d = PlatformDirs(appname="OpenQuantum", appauthor=False)
46
+ return Path(d.user_config_dir) / "accounts.json"
47
+ except Exception:
48
+ return Path(os.path.expanduser("~/.openquantum/accounts.json"))
49
+
50
+
51
+ def _ensure_cfg_dir(path: Path) -> None:
52
+ try:
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ except Exception:
55
+ pass
56
+
57
+
58
+ def save_account(
59
+ *,
60
+ name: str = "default",
61
+ token: Optional[str] = None,
62
+ creds: Optional[ClientCredentials] = None,
63
+ keycloak_base: str = DEFAULT_KEYCLOAK_BASE,
64
+ realm: str = DEFAULT_REALM,
65
+ scheduler_url: str = DEFAULT_SCHEDULER_URL,
66
+ management_url: str = DEFAULT_MANAGEMENT_URL,
67
+ filename: Optional[str] = None,
68
+ overwrite: bool = True,
69
+ use_keyring: bool = True,
70
+ ) -> None:
71
+ """Persist account credentials to disk for later reuse.
72
+
73
+ Secrets (tokens, client secrets) are stored in the system keyring when
74
+ available; otherwise they are written to the JSON file directly.
75
+
76
+ Raises:
77
+ ValueError: If both ``token`` and ``creds`` are provided, or if
78
+ ``overwrite`` is ``False`` and the account already exists.
79
+ """
80
+ if token and creds:
81
+ raise ValueError("Provide either 'token' or 'creds', not both.")
82
+ path = accounts_path(filename)
83
+ path.parent.mkdir(parents=True, exist_ok=True)
84
+ data: Dict[str, Any] = {}
85
+ if path.exists():
86
+ try:
87
+ data = json.loads(path.read_text(encoding="utf-8"))
88
+ except Exception:
89
+ if not overwrite:
90
+ raise
91
+ data = {}
92
+ if not overwrite and name in data:
93
+ raise ValueError(
94
+ f"Account '{name}' already exists. Set overwrite=True to replace."
95
+ )
96
+ entry: Dict[str, Any] = {
97
+ "auth_mode": "token" if token else "client_credentials",
98
+ "token": token,
99
+ "creds": (
100
+ {"client_id": creds.client_id, "client_secret": creds.client_secret}
101
+ if creds
102
+ else None
103
+ ),
104
+ "keycloak_base": keycloak_base,
105
+ "realm": realm,
106
+ "scheduler_url": scheduler_url,
107
+ "management_url": management_url,
108
+ }
109
+ if use_keyring:
110
+ try:
111
+ import keyring
112
+
113
+ if token:
114
+ keyring.set_password(_KEYRING_SERVICE, f"{name}:token", token)
115
+ entry["token"] = None
116
+ if creds and creds.client_secret:
117
+ keyring.set_password(
118
+ _KEYRING_SERVICE, f"{name}:client_secret", creds.client_secret
119
+ )
120
+ entry["creds"] = {
121
+ "client_id": creds.client_id,
122
+ "client_secret": None,
123
+ }
124
+ entry["keyring"] = True
125
+ except Exception:
126
+ entry.setdefault("keyring", False)
127
+ data[name] = entry
128
+
129
+ tmp_txt = json.dumps(data, indent=2)
130
+ _ensure_cfg_dir(path)
131
+ with NamedTemporaryFile(
132
+ "w", delete=False, encoding="utf-8", dir=str(path.parent)
133
+ ) as tmp:
134
+ tmp.write(tmp_txt)
135
+ tmp.flush()
136
+ os.fsync(tmp.fileno())
137
+ tmp_path = Path(tmp.name)
138
+ tmp_path.replace(path)
139
+ try:
140
+ os.chmod(path, 0o600)
141
+ except Exception:
142
+ pass
143
+
144
+
145
+ def saved_accounts(*, filename: Optional[str] = None) -> Dict[str, Any]:
146
+ """Return all saved accounts with secrets masked.
147
+
148
+ Tokens and client secrets are truncated so that only the first and last
149
+ few characters remain visible. Returns an empty dict if no accounts file
150
+ exists or it cannot be parsed.
151
+ """
152
+ path = accounts_path(filename)
153
+ if not path.exists():
154
+ return {}
155
+ try:
156
+ raw = json.loads(path.read_text(encoding="utf-8"))
157
+ masked: Dict[str, Any] = {}
158
+ for k, v in raw.items():
159
+ v = dict(v)
160
+ tok = v.get("token")
161
+ if tok:
162
+ v["token"] = tok[:6] + "…" + tok[-4:]
163
+ creds = v.get("creds") or {}
164
+ if creds.get("client_secret"):
165
+ cs = creds["client_secret"]
166
+ creds["client_secret"] = cs[:4] + "…" + cs[-4:]
167
+ v["creds"] = creds
168
+ masked[k] = v
169
+ return masked
170
+ except Exception:
171
+ return {}
172
+
173
+
174
+ def delete_account(*, name: str = "default", filename: Optional[str] = None) -> bool:
175
+ """Delete a saved account by name.
176
+
177
+ Returns ``True`` if the account was found and deleted, ``False`` otherwise.
178
+ """
179
+ path = accounts_path(filename)
180
+ if not path.exists():
181
+ return False
182
+ try:
183
+ data = json.loads(path.read_text(encoding="utf-8"))
184
+ except Exception:
185
+ return False
186
+ if name not in data:
187
+ return False
188
+ try:
189
+ import keyring
190
+
191
+ keyring.delete_password(_KEYRING_SERVICE, f"{name}:token")
192
+ keyring.delete_password(_KEYRING_SERVICE, f"{name}:client_secret")
193
+ except Exception:
194
+ pass
195
+ del data[name]
196
+ path.write_text(json.dumps(data, indent=2), encoding="utf-8")
197
+ return True
198
+
199
+
200
+ def load_account(
201
+ name: str = "default", *, filename: Optional[str] = None
202
+ ) -> Optional[Dict[str, Any]]:
203
+ """Load a saved account and resolve its secrets from the keyring.
204
+
205
+ Returns a dict with keys ``auth_mode``, ``token`` (or ``None``), ``creds``
206
+ (a :class:`ClientCredentials` or ``None``), ``keycloak_base``, ``realm``,
207
+ ``scheduler_url``, and ``management_url``. Returns ``None`` if the file or
208
+ the named account does not exist.
209
+ """
210
+ path = accounts_path(filename)
211
+ if not path.exists():
212
+ return None
213
+ try:
214
+ data = json.loads(path.read_text(encoding="utf-8"))
215
+ except Exception:
216
+ return None
217
+ if name not in data:
218
+ return None
219
+ entry = data[name]
220
+ kr = bool(entry.get("keyring", False))
221
+
222
+ token: Optional[str] = None
223
+ creds: Optional[ClientCredentials] = None
224
+ if entry.get("auth_mode") == "token":
225
+ token = entry.get("token")
226
+ if (not token) and kr:
227
+ try:
228
+ import keyring
229
+
230
+ token = keyring.get_password(_KEYRING_SERVICE, f"{name}:token")
231
+ except Exception:
232
+ pass
233
+ else:
234
+ c = entry.get("creds") or {}
235
+ if c.get("client_id"):
236
+ secret = c.get("client_secret") or ""
237
+ if (not secret) and kr:
238
+ try:
239
+ import keyring
240
+
241
+ maybe = keyring.get_password(
242
+ _KEYRING_SERVICE, f"{name}:client_secret"
243
+ )
244
+ secret = maybe or secret
245
+ except Exception:
246
+ pass
247
+ creds = ClientCredentials(client_id=c.get("client_id"), client_secret=secret)
248
+
249
+ return {
250
+ "auth_mode": entry.get("auth_mode"),
251
+ "token": token,
252
+ "creds": creds,
253
+ "keycloak_base": entry.get("keycloak_base", DEFAULT_KEYCLOAK_BASE),
254
+ "realm": entry.get("realm", DEFAULT_REALM),
255
+ "scheduler_url": entry.get("scheduler_url", DEFAULT_SCHEDULER_URL),
256
+ "management_url": entry.get("management_url", DEFAULT_MANAGEMENT_URL),
257
+ }