qbraid-core 0.3.2__tar.gz → 0.3.4__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.
- {qbraid_core-0.3.2/qbraid_core.egg-info → qbraid_core-0.3.4}/PKG-INFO +1 -1
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/pyproject.toml +1 -1
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/compute/__init__.py +9 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/compute/client.py +336 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/compute/schemas.py +69 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/compute/ssh.py +32 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/client.py +81 -5
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/schemas/__init__.py +6 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/schemas/device.py +38 -1
- {qbraid_core-0.3.2 → qbraid_core-0.3.4/qbraid_core.egg-info}/PKG-INFO +1 -1
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/LICENSE +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/MANIFEST.in +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/README.md +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/docs/_static/favicon.ico +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/docs/_static/logo.png +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/docs/api/qbraid_core.rst +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/docs/api/qbraid_core.services.rst +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/docs/conf.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/docs/index.rst +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/__init__.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/_build_info.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/_compat.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/_import.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/_version.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/annotations.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/client.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/config.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/context.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/decimal.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/__init__.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/auth.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/config.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/environments.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/jupyterlab.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/models.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/network.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/runner.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/system.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/exceptions.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/py.typed +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/registry.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/retry.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/__init__.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/__init__.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/client.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/constants.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/discovery.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/exceptions.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/hooks.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/launcher.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/messaging.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/opencode_api.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/permission_hook.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/permissions.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/registry.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/schemas.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/transcripts.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/types.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/compute/exceptions.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/__init__.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/client.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/create.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/exceptions.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/kernels.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/magic.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/packages.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/paths.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/preflight.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/python.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/registry.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/schema.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/state.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/sync_progress.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/utils/__init__.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/validate.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/venv.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/projects/__init__.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/projects/client.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/projects/exceptions.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/quantum/__init__.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/quantum/runner.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/__init__.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/exceptions.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/schemas/enums.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/schemas/group.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/schemas/job.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/schemas/result.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/skills/__init__.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/skills/client.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/skills/exceptions.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/skills/schemas.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/storage/__init__.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/storage/disk_usage_client.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/storage/exceptions.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/storage/types.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/sessions.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/__init__.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/exceptions.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/executables.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/filemanager.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/generic.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/magic/__init__.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/magic/manager.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/magic/qbraid_magic.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/packages.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/versions.py +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core.egg-info/SOURCES.txt +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core.egg-info/dependency_links.txt +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core.egg-info/requires.txt +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core.egg-info/top_level.txt +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/setup.cfg +0 -0
- {qbraid_core-0.3.2 → qbraid_core-0.3.4}/tests/test_mypy_call_arg_validation.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "qbraid-core"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.4"
|
|
8
8
|
authors = [{name = "qBraid Development Team"}, {email = "contact@qbraid.com"}]
|
|
9
9
|
description = "Python library with core abstractions for software development in the qBraid ecosystem."
|
|
10
10
|
readme = "README.md"
|
|
@@ -34,6 +34,9 @@ from .exceptions import (
|
|
|
34
34
|
ComputeServiceRequestError,
|
|
35
35
|
)
|
|
36
36
|
from .schemas import (
|
|
37
|
+
BMAInstance,
|
|
38
|
+
BMAProgress,
|
|
39
|
+
BMAStatus,
|
|
37
40
|
BuildInfo,
|
|
38
41
|
BuildStatus,
|
|
39
42
|
ComputeProfile,
|
|
@@ -69,6 +72,7 @@ from .ssh import (
|
|
|
69
72
|
get_sshd_proxy_url,
|
|
70
73
|
list_pod_ssh_configs,
|
|
71
74
|
list_registered_keys,
|
|
75
|
+
parse_lab_url,
|
|
72
76
|
read_public_key,
|
|
73
77
|
register_public_key,
|
|
74
78
|
remove_pod_ssh_config,
|
|
@@ -96,6 +100,10 @@ __all__ = [
|
|
|
96
100
|
"ServerStopResponse",
|
|
97
101
|
"SessionToken",
|
|
98
102
|
"SessionTokenResponse",
|
|
103
|
+
# BMA on-demand instances
|
|
104
|
+
"BMAInstance",
|
|
105
|
+
"BMAProgress",
|
|
106
|
+
"BMAStatus",
|
|
99
107
|
# Enums
|
|
100
108
|
"ProfileType",
|
|
101
109
|
"BuildStatus",
|
|
@@ -132,4 +140,5 @@ __all__ = [
|
|
|
132
140
|
"add_sshd_proxy_config",
|
|
133
141
|
"get_sshd_proxy_url",
|
|
134
142
|
"get_proxy_command",
|
|
143
|
+
"parse_lab_url",
|
|
135
144
|
]
|
|
@@ -21,6 +21,8 @@ from .exceptions import (
|
|
|
21
21
|
ComputeServiceRequestError,
|
|
22
22
|
)
|
|
23
23
|
from .schemas import (
|
|
24
|
+
BMAInstance,
|
|
25
|
+
BMAStatus,
|
|
24
26
|
BuildStatusResponse,
|
|
25
27
|
ComputeProfile,
|
|
26
28
|
ComputeSession,
|
|
@@ -57,6 +59,8 @@ class ComputeClient(QbraidClientV1):
|
|
|
57
59
|
def __init__(self, *args, **kwargs):
|
|
58
60
|
"""Initialize the ComputeClient."""
|
|
59
61
|
super().__init__(*args, **kwargs)
|
|
62
|
+
# Cache of docker-vm (BMA) profile slugs, populated lazily.
|
|
63
|
+
self._bma_slugs: Optional[set[str]] = None
|
|
60
64
|
|
|
61
65
|
def _handle_error(self, err: RequestsApiError, operation: str) -> NoReturn:
|
|
62
66
|
"""Handle API errors and raise appropriate exceptions."""
|
|
@@ -646,7 +650,25 @@ class ComputeClient(QbraidClientV1):
|
|
|
646
650
|
>>> response = client.start_server("2vCPU_4GB")
|
|
647
651
|
>>> print(response.status)
|
|
648
652
|
'starting'
|
|
653
|
+
|
|
654
|
+
Note:
|
|
655
|
+
On-demand ``docker-vm`` (BMA) profiles are routed transparently to
|
|
656
|
+
the BMA provision endpoint; the returned ServerStartResponse carries
|
|
657
|
+
the new instanceId in its ``message``. Use list_bma_instances() or
|
|
658
|
+
get_bma_instance() to track the instance afterward.
|
|
649
659
|
"""
|
|
660
|
+
if self.is_bma_profile(profile_slug):
|
|
661
|
+
instance = self.provision_bma_instance(profile_slug)
|
|
662
|
+
return ServerStartResponse.model_validate(
|
|
663
|
+
{
|
|
664
|
+
"message": f"Provisioning instance {instance.instance_id}",
|
|
665
|
+
"clusterId": cluster_id or "bma",
|
|
666
|
+
"profile": profile_slug,
|
|
667
|
+
"status": instance.status.value,
|
|
668
|
+
"instanceId": instance.instance_id,
|
|
669
|
+
}
|
|
670
|
+
)
|
|
671
|
+
|
|
650
672
|
body = {"profileSlug": profile_slug}
|
|
651
673
|
if cluster_id is not None:
|
|
652
674
|
body["clusterId"] = cluster_id
|
|
@@ -811,6 +833,320 @@ class ComputeClient(QbraidClientV1):
|
|
|
811
833
|
**ssh_config,
|
|
812
834
|
}
|
|
813
835
|
|
|
836
|
+
# =========================================================================
|
|
837
|
+
# BMA Instances (on-demand PAYG compute)
|
|
838
|
+
#
|
|
839
|
+
# These back the docker-vm profiles. The lifecycle is fire-and-forget:
|
|
840
|
+
# every action returns immediately with a transient status, and callers
|
|
841
|
+
# poll get_bma_instance() (or use wait_for_bma_instance()) until terminal.
|
|
842
|
+
# =========================================================================
|
|
843
|
+
|
|
844
|
+
_BMA_BASE = "/compute/bma/instances"
|
|
845
|
+
|
|
846
|
+
def is_bma_profile(self, profile_slug: str) -> bool:
|
|
847
|
+
"""Whether a profile slug provisions an on-demand BMA instance.
|
|
848
|
+
|
|
849
|
+
``docker-vm`` profiles route through the BMA lifecycle; all other
|
|
850
|
+
profiles use the legacy K8s server path. Used internally to keep
|
|
851
|
+
``start_server``/``stop_server``/SSH transparent to the caller.
|
|
852
|
+
|
|
853
|
+
Detection uses the profile listing (the per-slug detail endpoint is not
|
|
854
|
+
reliably available across deployments). The slug set is cached for the
|
|
855
|
+
lifetime of the client on success; on transient errors we leave the
|
|
856
|
+
cache empty so the next call retries instead of permanently treating
|
|
857
|
+
every BMA profile as legacy.
|
|
858
|
+
|
|
859
|
+
Args:
|
|
860
|
+
profile_slug: Profile slug to classify.
|
|
861
|
+
|
|
862
|
+
Returns:
|
|
863
|
+
True if the profile is a docker-vm BMA profile.
|
|
864
|
+
"""
|
|
865
|
+
slugs = getattr(self, "_bma_slugs", None)
|
|
866
|
+
if slugs is None:
|
|
867
|
+
try:
|
|
868
|
+
slugs = {p.slug for p in self.list_profiles() if p.spawner_type == "docker-vm"}
|
|
869
|
+
except ComputeServiceRequestError:
|
|
870
|
+
# Don't poison the cache; return False (treat as legacy) for
|
|
871
|
+
# this call and retry detection on the next invocation.
|
|
872
|
+
return False
|
|
873
|
+
self._bma_slugs = slugs
|
|
874
|
+
return profile_slug in slugs
|
|
875
|
+
|
|
876
|
+
def provision_bma_instance(self, profile_slug: str) -> BMAInstance:
|
|
877
|
+
"""Provision (launch) a new on-demand BMA instance.
|
|
878
|
+
|
|
879
|
+
Fire-and-forget: returns immediately with status ``provisioning``.
|
|
880
|
+
Poll get_bma_instance() until ``running`` (open ``url``) or ``error``.
|
|
881
|
+
|
|
882
|
+
Args:
|
|
883
|
+
profile_slug: A docker-vm profile slug with a catalogSelector.
|
|
884
|
+
|
|
885
|
+
Returns:
|
|
886
|
+
BMAInstance with the new instanceId and transient status.
|
|
887
|
+
"""
|
|
888
|
+
try:
|
|
889
|
+
response = self.session.post(self._BMA_BASE, json={"profileSlug": profile_slug})
|
|
890
|
+
resp_json = response.json()
|
|
891
|
+
return BMAInstance.model_validate(resp_json.get("data", resp_json))
|
|
892
|
+
except RequestsApiError as err:
|
|
893
|
+
self._handle_error(err, f"provision BMA instance '{profile_slug}'")
|
|
894
|
+
return None # pragma: no cover
|
|
895
|
+
|
|
896
|
+
def list_bma_instances(self) -> list[BMAInstance]:
|
|
897
|
+
"""List the caller's non-terminated BMA instances.
|
|
898
|
+
|
|
899
|
+
Returns:
|
|
900
|
+
List of BMAInstance (running, stopping, stopped, provisioning, error).
|
|
901
|
+
"""
|
|
902
|
+
try:
|
|
903
|
+
response = self.session.get(self._BMA_BASE)
|
|
904
|
+
resp_json = response.json()
|
|
905
|
+
data = resp_json.get("data", resp_json)
|
|
906
|
+
return [BMAInstance.model_validate(item) for item in (data or [])]
|
|
907
|
+
except RequestsApiError as err:
|
|
908
|
+
self._handle_error(err, "list BMA instances")
|
|
909
|
+
return None # pragma: no cover
|
|
910
|
+
|
|
911
|
+
def get_bma_instance(self, instance_id: str) -> BMAInstance:
|
|
912
|
+
"""Get a single BMA instance (poll target).
|
|
913
|
+
|
|
914
|
+
Args:
|
|
915
|
+
instance_id: The BMA instance ID.
|
|
916
|
+
|
|
917
|
+
Returns:
|
|
918
|
+
Full BMAInstance object (including lastError).
|
|
919
|
+
"""
|
|
920
|
+
try:
|
|
921
|
+
response = self.session.get(f"{self._BMA_BASE}/{instance_id}")
|
|
922
|
+
resp_json = response.json()
|
|
923
|
+
return BMAInstance.model_validate(resp_json.get("data", resp_json))
|
|
924
|
+
except RequestsApiError as err:
|
|
925
|
+
self._handle_error(err, f"get BMA instance '{instance_id}'")
|
|
926
|
+
return None # pragma: no cover
|
|
927
|
+
|
|
928
|
+
def stop_bma_instance(self, instance_id: str) -> BMAInstance:
|
|
929
|
+
"""Stop (pause) a running BMA instance, preserving its disk.
|
|
930
|
+
|
|
931
|
+
Fire-and-forget: flips to ``stopping`` and returns. Poll until
|
|
932
|
+
``stopped``. Only a ``running`` instance can be stopped (else BAD_STATE).
|
|
933
|
+
|
|
934
|
+
Args:
|
|
935
|
+
instance_id: The BMA instance ID.
|
|
936
|
+
|
|
937
|
+
Returns:
|
|
938
|
+
BMAInstance with transient ``stopping`` status.
|
|
939
|
+
"""
|
|
940
|
+
try:
|
|
941
|
+
response = self.session.post(f"{self._BMA_BASE}/{instance_id}/stop")
|
|
942
|
+
resp_json = response.json()
|
|
943
|
+
return BMAInstance.model_validate(resp_json.get("data", resp_json))
|
|
944
|
+
except RequestsApiError as err:
|
|
945
|
+
self._handle_error(err, f"stop BMA instance '{instance_id}'")
|
|
946
|
+
return None # pragma: no cover
|
|
947
|
+
|
|
948
|
+
def start_bma_instance(self, instance_id: str) -> BMAInstance:
|
|
949
|
+
"""Resume a stopped BMA instance.
|
|
950
|
+
|
|
951
|
+
Fire-and-forget: returns ``provisioning`` with a stable ``url`` that is
|
|
952
|
+
not live until status reaches ``running`` — poll first. Only a
|
|
953
|
+
``stopped`` instance can resume (else BAD_STATE).
|
|
954
|
+
|
|
955
|
+
Args:
|
|
956
|
+
instance_id: The BMA instance ID.
|
|
957
|
+
|
|
958
|
+
Returns:
|
|
959
|
+
BMAInstance with transient status.
|
|
960
|
+
"""
|
|
961
|
+
try:
|
|
962
|
+
response = self.session.post(f"{self._BMA_BASE}/{instance_id}/start")
|
|
963
|
+
resp_json = response.json()
|
|
964
|
+
return BMAInstance.model_validate(resp_json.get("data", resp_json))
|
|
965
|
+
except RequestsApiError as err:
|
|
966
|
+
self._handle_error(err, f"resume BMA instance '{instance_id}'")
|
|
967
|
+
return None # pragma: no cover
|
|
968
|
+
|
|
969
|
+
def terminate_bma_instance(self, instance_id: str) -> BMAInstance:
|
|
970
|
+
"""Terminate (delete) a BMA instance and its disk. Destructive.
|
|
971
|
+
|
|
972
|
+
Fire-and-forget but terminal immediately: the record flips to
|
|
973
|
+
``terminated`` at once. Terminating an already-terminated instance is a
|
|
974
|
+
no-op.
|
|
975
|
+
|
|
976
|
+
Args:
|
|
977
|
+
instance_id: The BMA instance ID.
|
|
978
|
+
|
|
979
|
+
Returns:
|
|
980
|
+
BMAInstance with ``terminated`` status.
|
|
981
|
+
"""
|
|
982
|
+
try:
|
|
983
|
+
response = self.session.delete(f"{self._BMA_BASE}/{instance_id}")
|
|
984
|
+
resp_json = response.json()
|
|
985
|
+
return BMAInstance.model_validate(resp_json.get("data", resp_json))
|
|
986
|
+
except RequestsApiError as err:
|
|
987
|
+
self._handle_error(err, f"terminate BMA instance '{instance_id}'")
|
|
988
|
+
return None # pragma: no cover
|
|
989
|
+
|
|
990
|
+
def update_bma_cutoff(
|
|
991
|
+
self,
|
|
992
|
+
instance_id: str,
|
|
993
|
+
auto_stop_idle_minutes: Optional[int] = None,
|
|
994
|
+
max_session_minutes: Optional[int] = None,
|
|
995
|
+
) -> BMAInstance:
|
|
996
|
+
"""Update the auto-cutoff policy for a BMA instance.
|
|
997
|
+
|
|
998
|
+
At least one field must be provided. Values are integer minutes; ``0``
|
|
999
|
+
disables that cutoff.
|
|
1000
|
+
|
|
1001
|
+
Args:
|
|
1002
|
+
instance_id: The BMA instance ID.
|
|
1003
|
+
auto_stop_idle_minutes: Stop after this much idle (max 7 days).
|
|
1004
|
+
max_session_minutes: Hard wall-clock cap from runningAt (max 30 days).
|
|
1005
|
+
|
|
1006
|
+
Returns:
|
|
1007
|
+
BMAInstance reflecting the updated policy.
|
|
1008
|
+
|
|
1009
|
+
Raises:
|
|
1010
|
+
ValueError: If neither field is provided.
|
|
1011
|
+
"""
|
|
1012
|
+
body: dict[str, int] = {}
|
|
1013
|
+
if auto_stop_idle_minutes is not None:
|
|
1014
|
+
body["autoStopIdleMinutes"] = auto_stop_idle_minutes
|
|
1015
|
+
if max_session_minutes is not None:
|
|
1016
|
+
body["maxSessionMinutes"] = max_session_minutes
|
|
1017
|
+
if not body:
|
|
1018
|
+
raise ValueError(
|
|
1019
|
+
"Provide at least one of auto_stop_idle_minutes or max_session_minutes."
|
|
1020
|
+
)
|
|
1021
|
+
|
|
1022
|
+
try:
|
|
1023
|
+
response = self.session.patch(f"{self._BMA_BASE}/{instance_id}", json=body)
|
|
1024
|
+
resp_json = response.json()
|
|
1025
|
+
return BMAInstance.model_validate(resp_json.get("data", resp_json))
|
|
1026
|
+
except RequestsApiError as err:
|
|
1027
|
+
self._handle_error(err, f"update cutoff for BMA instance '{instance_id}'")
|
|
1028
|
+
return None # pragma: no cover
|
|
1029
|
+
|
|
1030
|
+
def wait_for_bma_instance(
|
|
1031
|
+
self,
|
|
1032
|
+
instance_id: str,
|
|
1033
|
+
target_status: BMAStatus = BMAStatus.RUNNING,
|
|
1034
|
+
poll_interval: float = 4.0,
|
|
1035
|
+
timeout: Optional[float] = 600.0,
|
|
1036
|
+
) -> BMAInstance:
|
|
1037
|
+
"""Poll a BMA instance until it reaches a terminal status.
|
|
1038
|
+
|
|
1039
|
+
Resolves when the instance hits ``target_status`` (or any terminal
|
|
1040
|
+
status, or ``error``). GPU provisioning can take several minutes, so the
|
|
1041
|
+
default timeout is generous.
|
|
1042
|
+
|
|
1043
|
+
Args:
|
|
1044
|
+
instance_id: The BMA instance ID.
|
|
1045
|
+
target_status: Desired terminal status (default: running).
|
|
1046
|
+
poll_interval: Seconds between polls (default: 4).
|
|
1047
|
+
timeout: Maximum seconds to wait (default: 600). None waits forever.
|
|
1048
|
+
|
|
1049
|
+
Returns:
|
|
1050
|
+
The BMAInstance once it reaches a terminal/target status.
|
|
1051
|
+
|
|
1052
|
+
Raises:
|
|
1053
|
+
TimeoutError: If the instance doesn't settle within timeout.
|
|
1054
|
+
ComputeServiceRequestError: If the instance enters ``error``.
|
|
1055
|
+
"""
|
|
1056
|
+
import time
|
|
1057
|
+
|
|
1058
|
+
terminal = {
|
|
1059
|
+
BMAStatus.RUNNING,
|
|
1060
|
+
BMAStatus.STOPPED,
|
|
1061
|
+
BMAStatus.TERMINATED,
|
|
1062
|
+
BMAStatus.ERROR,
|
|
1063
|
+
}
|
|
1064
|
+
start_time = time.time()
|
|
1065
|
+
while True:
|
|
1066
|
+
instance = self.get_bma_instance(instance_id)
|
|
1067
|
+
if instance.status == target_status:
|
|
1068
|
+
return instance
|
|
1069
|
+
if instance.status == BMAStatus.ERROR:
|
|
1070
|
+
msg = instance.last_error or (
|
|
1071
|
+
instance.progress.message if instance.progress else "unknown error"
|
|
1072
|
+
)
|
|
1073
|
+
raise ComputeServiceRequestError(
|
|
1074
|
+
f"BMA instance '{instance_id}' entered error state: {msg}"
|
|
1075
|
+
)
|
|
1076
|
+
if instance.status in terminal:
|
|
1077
|
+
# Reached a different terminal state than requested; return it.
|
|
1078
|
+
return instance
|
|
1079
|
+
if timeout is not None and (time.time() - start_time) > timeout:
|
|
1080
|
+
raise TimeoutError(
|
|
1081
|
+
f"BMA instance '{instance_id}' did not reach "
|
|
1082
|
+
f"'{target_status.value}' within {timeout}s "
|
|
1083
|
+
f"(last status: {instance.status.value})."
|
|
1084
|
+
)
|
|
1085
|
+
time.sleep(poll_interval)
|
|
1086
|
+
|
|
1087
|
+
@staticmethod
|
|
1088
|
+
def bma_ssh_alias(instance_id: str) -> str:
|
|
1089
|
+
"""Derive a stable SSH config alias for a BMA instance.
|
|
1090
|
+
|
|
1091
|
+
Uses the instance ID's trailing hash so multiple instances of the same
|
|
1092
|
+
profile get distinct aliases, e.g. ``bma-gpu-h100-sxm-016df951`` →
|
|
1093
|
+
``qbraid-bma-016df951``.
|
|
1094
|
+
|
|
1095
|
+
Args:
|
|
1096
|
+
instance_id: The BMA instance ID.
|
|
1097
|
+
|
|
1098
|
+
Returns:
|
|
1099
|
+
SSH config alias string.
|
|
1100
|
+
"""
|
|
1101
|
+
suffix = instance_id.rsplit("-", 1)[-1] if "-" in instance_id else instance_id
|
|
1102
|
+
return f"qbraid-bma-{suffix}"
|
|
1103
|
+
|
|
1104
|
+
def configure_ssh_for_instance(self, instance_id: str) -> dict:
|
|
1105
|
+
"""Configure SSH access for a single running BMA instance.
|
|
1106
|
+
|
|
1107
|
+
Fetches the instance, extracts the hub domain / username / token from
|
|
1108
|
+
its live URL, and writes an SSH config entry with a per-instance alias
|
|
1109
|
+
(see bma_ssh_alias). Multiple instances can be configured independently,
|
|
1110
|
+
each reachable via its own alias.
|
|
1111
|
+
|
|
1112
|
+
Args:
|
|
1113
|
+
instance_id: The BMA instance ID (must be running).
|
|
1114
|
+
|
|
1115
|
+
Returns:
|
|
1116
|
+
Dict with SSH configuration details (alias, identity_file, commands).
|
|
1117
|
+
|
|
1118
|
+
Raises:
|
|
1119
|
+
ComputeServiceRequestError: If the instance isn't running, has no
|
|
1120
|
+
live URL, or the URL can't be parsed for SSH parts.
|
|
1121
|
+
"""
|
|
1122
|
+
from .ssh import parse_lab_url, setup_qbraid_ssh
|
|
1123
|
+
|
|
1124
|
+
instance = self.get_bma_instance(instance_id)
|
|
1125
|
+
if instance.status != BMAStatus.RUNNING or not instance.url:
|
|
1126
|
+
raise ComputeServiceRequestError(
|
|
1127
|
+
f"Instance '{instance_id}' is not running (status: "
|
|
1128
|
+
f"{instance.status.value}); SSH can only be configured for a "
|
|
1129
|
+
"running instance."
|
|
1130
|
+
)
|
|
1131
|
+
|
|
1132
|
+
parts = parse_lab_url(instance.url)
|
|
1133
|
+
hub_domain = parts["hub_domain"]
|
|
1134
|
+
username = parts["username"]
|
|
1135
|
+
token = parts["token"]
|
|
1136
|
+
if not (hub_domain and username and token):
|
|
1137
|
+
raise ComputeServiceRequestError(
|
|
1138
|
+
f"Could not extract SSH connection details from instance " f"'{instance_id}' URL."
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
ssh_config = setup_qbraid_ssh(
|
|
1142
|
+
username=username,
|
|
1143
|
+
token=token,
|
|
1144
|
+
host_alias=self.bma_ssh_alias(instance_id),
|
|
1145
|
+
ide=parts["ide"] or "lab",
|
|
1146
|
+
hub_domain=hub_domain,
|
|
1147
|
+
)
|
|
1148
|
+
return {"instance_id": instance_id, **ssh_config}
|
|
1149
|
+
|
|
814
1150
|
# =========================================================================
|
|
815
1151
|
# Compute Sessions
|
|
816
1152
|
# =========================================================================
|
|
@@ -60,6 +60,11 @@ class ComputeProfile(BaseModel):
|
|
|
60
60
|
ide: str = Field(default="lab")
|
|
61
61
|
has_capacity: bool = Field(default=True, alias="hasCapacity")
|
|
62
62
|
|
|
63
|
+
# Spawner routing: "docker-vm" profiles are on-demand BMA instances;
|
|
64
|
+
# everything else uses the legacy K8s server path.
|
|
65
|
+
spawner_type: Optional[str] = Field(default=None, alias="spawnerType")
|
|
66
|
+
catalog_selector: Optional[dict[str, Any]] = Field(default=None, alias="catalogSelector")
|
|
67
|
+
|
|
63
68
|
# Resource configuration
|
|
64
69
|
cpu_guarantee: Optional[float] = Field(default=None, alias="cpuGuarantee")
|
|
65
70
|
cpu_limit: Optional[float] = Field(default=None, alias="cpuLimit")
|
|
@@ -175,11 +180,18 @@ class ProfileDetails(BaseModel):
|
|
|
175
180
|
ide: str = Field(default="jupyterlab")
|
|
176
181
|
cluster_id: Optional[str] = Field(default=None, alias="clusterId")
|
|
177
182
|
has_capacity: bool = Field(default=True, alias="hasCapacity")
|
|
183
|
+
spawner_type: Optional[str] = Field(default=None, alias="spawnerType")
|
|
184
|
+
catalog_selector: Optional[dict[str, Any]] = Field(default=None, alias="catalogSelector")
|
|
178
185
|
resources: Optional[ProfileResources] = None
|
|
179
186
|
pricing: Optional[ProfilePricing] = None
|
|
180
187
|
|
|
181
188
|
model_config = ConfigDict(populate_by_name=True)
|
|
182
189
|
|
|
190
|
+
@property
|
|
191
|
+
def is_bma(self) -> bool:
|
|
192
|
+
"""Whether this profile provisions an on-demand BMA instance."""
|
|
193
|
+
return self.spawner_type == "docker-vm"
|
|
194
|
+
|
|
183
195
|
|
|
184
196
|
class SessionToken(BaseModel):
|
|
185
197
|
"""JupyterHub session token."""
|
|
@@ -255,6 +267,9 @@ class ServerStartResponse(BaseModel):
|
|
|
255
267
|
cluster_id: str = Field(..., alias="clusterId")
|
|
256
268
|
profile: str
|
|
257
269
|
status: str
|
|
270
|
+
# Set when the profile routed to the BMA layer (docker-vm); the new
|
|
271
|
+
# instance ID for tracking/SSH. None for legacy K8s servers.
|
|
272
|
+
instance_id: Optional[str] = Field(default=None, alias="instanceId")
|
|
258
273
|
|
|
259
274
|
model_config = ConfigDict(populate_by_name=True)
|
|
260
275
|
|
|
@@ -359,3 +374,57 @@ class ComputeUsage(BaseModel):
|
|
|
359
374
|
disk_usage: Optional[dict[str, Any]] = Field(default=None, alias="diskUsage")
|
|
360
375
|
|
|
361
376
|
model_config = ConfigDict(populate_by_name=True)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# =========================================================================
|
|
380
|
+
# BMA Instances (on-demand PAYG compute)
|
|
381
|
+
# =========================================================================
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class BMAStatus(str, Enum):
|
|
385
|
+
"""Lifecycle status of a BMA instance."""
|
|
386
|
+
|
|
387
|
+
PROVISIONING = "provisioning"
|
|
388
|
+
RUNNING = "running"
|
|
389
|
+
STOPPING = "stopping"
|
|
390
|
+
STOPPED = "stopped"
|
|
391
|
+
TERMINATED = "terminated"
|
|
392
|
+
ERROR = "error"
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class BMAProgress(BaseModel):
|
|
396
|
+
"""Progress object emitted at each lifecycle stage to drive a spinner/bar."""
|
|
397
|
+
|
|
398
|
+
phase: Optional[str] = (
|
|
399
|
+
None # e.g. queued|creating|booting|starting|ready|resuming|stopping|error
|
|
400
|
+
)
|
|
401
|
+
percent: Optional[int] = None
|
|
402
|
+
message: Optional[str] = None
|
|
403
|
+
|
|
404
|
+
model_config = ConfigDict(populate_by_name=True)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
class BMAInstance(BaseModel):
|
|
408
|
+
"""An on-demand BMA instance (docker-vm profile).
|
|
409
|
+
|
|
410
|
+
Returned by the ``/compute/bma/instances`` routes. The BMA layer hides the
|
|
411
|
+
underlying cloud vendor; the profile + specs are the user-facing identity.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
instance_id: str = Field(..., alias="instanceId")
|
|
415
|
+
status: BMAStatus
|
|
416
|
+
profile_slug: Optional[str] = Field(default=None, alias="profileSlug")
|
|
417
|
+
url: Optional[str] = None # live only when status == "running"
|
|
418
|
+
last_error: Optional[str] = Field(default=None, alias="lastError") # GET-one only
|
|
419
|
+
created_at: Optional[datetime] = Field(default=None, alias="createdAt")
|
|
420
|
+
running_at: Optional[datetime] = Field(default=None, alias="runningAt")
|
|
421
|
+
stopped_at: Optional[datetime] = Field(default=None, alias="stoppedAt")
|
|
422
|
+
progress: Optional[BMAProgress] = None
|
|
423
|
+
ready: bool = Field(default=False) # true only when running AND lab answers
|
|
424
|
+
pending: Optional[str] = None # "spawn" | "stop" | None
|
|
425
|
+
auto_stop_idle_minutes: Optional[int] = Field(default=None, alias="autoStopIdleMinutes")
|
|
426
|
+
max_session_minutes: Optional[int] = Field(default=None, alias="maxSessionMinutes")
|
|
427
|
+
running_credits_per_min: Optional[float] = Field(default=None, alias="runningCreditsPerMin")
|
|
428
|
+
stopped_credits_per_min: Optional[float] = Field(default=None, alias="stoppedCreditsPerMin")
|
|
429
|
+
|
|
430
|
+
model_config = ConfigDict(populate_by_name=True, extra="ignore")
|
|
@@ -868,6 +868,38 @@ def read_public_key(key_path: Optional[Path] = None) -> str:
|
|
|
868
868
|
return pub_path.read_text().strip()
|
|
869
869
|
|
|
870
870
|
|
|
871
|
+
def parse_lab_url(url: str) -> dict[str, Optional[str]]:
|
|
872
|
+
"""Extract SSH connection parts from a lab/instance URL.
|
|
873
|
+
|
|
874
|
+
Handles both legacy server URLs and BMA instance URLs of the form
|
|
875
|
+
``https://<host>/user/<username>/[<session>/]<ide>?token=<token>`` where
|
|
876
|
+
``<username>`` may itself contain a session segment (e.g.
|
|
877
|
+
``507f1f-kanavs/s-0e146b01``).
|
|
878
|
+
|
|
879
|
+
Args:
|
|
880
|
+
url: The lab URL (e.g. from ServerStatus.url or BMAInstance.url).
|
|
881
|
+
|
|
882
|
+
Returns:
|
|
883
|
+
Dict with keys ``hub_domain``, ``username``, ``token``, ``ide`` (any may
|
|
884
|
+
be None if not present in the URL).
|
|
885
|
+
"""
|
|
886
|
+
import re
|
|
887
|
+
from urllib.parse import parse_qs, urlparse
|
|
888
|
+
|
|
889
|
+
parsed = urlparse(url)
|
|
890
|
+
token = parse_qs(parsed.query).get("token", [None])[0]
|
|
891
|
+
hub_domain = parsed.hostname
|
|
892
|
+
|
|
893
|
+
username: Optional[str] = None
|
|
894
|
+
ide: Optional[str] = None
|
|
895
|
+
match = re.search(r"/user/(.+?)/(lab|ssh|tree|vscode)(?:/|\?|$)", parsed.path)
|
|
896
|
+
if match:
|
|
897
|
+
username = match.group(1)
|
|
898
|
+
ide = match.group(2)
|
|
899
|
+
|
|
900
|
+
return {"hub_domain": hub_domain, "username": username, "token": token, "ide": ide}
|
|
901
|
+
|
|
902
|
+
|
|
871
903
|
def _get_ssh_key_api_url(hub_domain: str, username: str, ide: str = "lab") -> str:
|
|
872
904
|
"""Get the URL for the SSH key management API.
|
|
873
905
|
|
|
@@ -15,7 +15,7 @@ from qbraid_core.exceptions import RequestsApiError
|
|
|
15
15
|
from qbraid_core.registry import register_client
|
|
16
16
|
|
|
17
17
|
from .exceptions import QuantumRuntimeServiceRequestError
|
|
18
|
-
from .schemas.device import PlatformDevice, RuntimeDevice, RuntimeProvider
|
|
18
|
+
from .schemas.device import DeviceCalibration, PlatformDevice, RuntimeDevice, RuntimeProvider
|
|
19
19
|
from .schemas.group import GroupJob
|
|
20
20
|
from .schemas.job import (
|
|
21
21
|
JobRequest,
|
|
@@ -34,16 +34,21 @@ logger = logging.getLogger(__name__)
|
|
|
34
34
|
class QuantumRuntimeClient(QbraidClientV1):
|
|
35
35
|
"""Client for interacting with the qBraid Runtime API."""
|
|
36
36
|
|
|
37
|
-
def list_devices(self) -> list[RuntimeDevice]:
|
|
37
|
+
def list_devices(self, include_retired: bool = False) -> list[RuntimeDevice]:
|
|
38
38
|
"""Returns a list of all quantum devices.
|
|
39
39
|
|
|
40
|
+
Args:
|
|
41
|
+
include_retired: If True, include devices with RETIRED status.
|
|
42
|
+
Defaults to False since retired devices cannot accept jobs.
|
|
43
|
+
|
|
40
44
|
Per-device validation: if a single device fails to validate (e.g. the
|
|
41
45
|
API added a new field shape or a value that doesn't match the schema),
|
|
42
46
|
we log a warning and skip that device rather than failing the entire
|
|
43
47
|
call. This keeps the panel/CLI working when new machines are added.
|
|
44
48
|
"""
|
|
45
49
|
try:
|
|
46
|
-
|
|
50
|
+
params = {} if include_retired else {"retired": "false"}
|
|
51
|
+
response = self.session.get("/devices", params=params)
|
|
47
52
|
resp_data: list[dict[str, Any]] = response.json()["data"]
|
|
48
53
|
except RequestsApiError as err:
|
|
49
54
|
raise QuantumRuntimeServiceRequestError(f"Failed to retrieve devices: {err}") from err
|
|
@@ -57,18 +62,23 @@ class QuantumRuntimeClient(QbraidClientV1):
|
|
|
57
62
|
logger.warning("Skipping device %s due to validation error: %s", qrn, err)
|
|
58
63
|
return validated
|
|
59
64
|
|
|
60
|
-
def list_platform_devices(self) -> list[PlatformDevice]:
|
|
65
|
+
def list_platform_devices(self, include_retired: bool = False) -> list[PlatformDevice]:
|
|
61
66
|
"""Returns a list of all quantum devices with full platform metadata.
|
|
62
67
|
|
|
63
68
|
Unlike list_devices() which returns RuntimeDevice (minimal schema),
|
|
64
69
|
this returns PlatformDevice which includes providerId, visibility,
|
|
65
70
|
verification status, and other platform-specific fields.
|
|
66
71
|
|
|
72
|
+
Args:
|
|
73
|
+
include_retired: If True, include devices with RETIRED status.
|
|
74
|
+
Defaults to False since retired devices cannot accept jobs.
|
|
75
|
+
|
|
67
76
|
Per-device validation: if a single device fails to validate,
|
|
68
77
|
we log a warning and skip that device rather than failing the entire call.
|
|
69
78
|
"""
|
|
70
79
|
try:
|
|
71
|
-
|
|
80
|
+
params = {} if include_retired else {"retired": "false"}
|
|
81
|
+
response = self.session.get("/devices", params=params)
|
|
72
82
|
resp_data: list[dict[str, Any]] = response.json()["data"]
|
|
73
83
|
except RequestsApiError as err:
|
|
74
84
|
raise QuantumRuntimeServiceRequestError(f"Failed to retrieve devices: {err}") from err
|
|
@@ -303,6 +313,17 @@ class QuantumRuntimeClient(QbraidClientV1):
|
|
|
303
313
|
f"Failed to retrieve device '{device_qrn}': {err}"
|
|
304
314
|
) from err
|
|
305
315
|
|
|
316
|
+
def get_device_calibrations(self, device_qrn: str) -> Optional[DeviceCalibration]:
|
|
317
|
+
"""Returns calibration data for a device, or None if unavailable."""
|
|
318
|
+
try:
|
|
319
|
+
response = self.session.get(f"/devices/{device_qrn}/calibrations")
|
|
320
|
+
resp_data = response.json().get("data")
|
|
321
|
+
if resp_data is None:
|
|
322
|
+
return None
|
|
323
|
+
return DeviceCalibration.model_validate(resp_data)
|
|
324
|
+
except RequestsApiError:
|
|
325
|
+
return None
|
|
326
|
+
|
|
306
327
|
def create_job(self, request: JobRequest) -> RuntimeJob:
|
|
307
328
|
"""Submits a new quantum job."""
|
|
308
329
|
try:
|
|
@@ -481,6 +502,61 @@ class QuantumRuntimeClient(QbraidClientV1):
|
|
|
481
502
|
f"Failed to cancel group '{group_qrn}': {err}"
|
|
482
503
|
) from err
|
|
483
504
|
|
|
505
|
+
def delete_group(self, group_qrn: str) -> None:
|
|
506
|
+
"""Delete a group record.
|
|
507
|
+
|
|
508
|
+
Deletes the group's metadata; the underlying jobs are not affected (use
|
|
509
|
+
``cancel_group`` first if you want to stop them). Idempotent — deleting
|
|
510
|
+
an already-deleted group is a 404 we ignore here.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
group_qrn: The group job QRN.
|
|
514
|
+
"""
|
|
515
|
+
try:
|
|
516
|
+
self.session.delete(f"/jobs/group/{group_qrn}")
|
|
517
|
+
except RequestsApiError as err:
|
|
518
|
+
# Idempotent: a 404 means the group is already gone. Prefer the
|
|
519
|
+
# underlying HTTP status from the wrapped RequestException over
|
|
520
|
+
# substring-matching the error message (which would false-positive
|
|
521
|
+
# on unrelated errors like "schema validator not found").
|
|
522
|
+
cause = getattr(err, "__cause__", None)
|
|
523
|
+
response = getattr(cause, "response", None)
|
|
524
|
+
if response is not None and response.status_code == 404:
|
|
525
|
+
return
|
|
526
|
+
raise QuantumRuntimeServiceRequestError(
|
|
527
|
+
f"Failed to delete group '{group_qrn}': {err}"
|
|
528
|
+
) from err
|
|
529
|
+
|
|
530
|
+
def get_group_jobs(self, group_qrn: str) -> list[RuntimeJob]:
|
|
531
|
+
"""List the jobs that belong to a group.
|
|
532
|
+
|
|
533
|
+
Returns RuntimeJob (minimal schema, matching ``list_jobs``) so the call
|
|
534
|
+
succeeds whether the API serves the lean or platform-denormalized form.
|
|
535
|
+
|
|
536
|
+
Args:
|
|
537
|
+
group_qrn: The group job QRN.
|
|
538
|
+
|
|
539
|
+
Returns:
|
|
540
|
+
List of RuntimeJob objects in the group (any status).
|
|
541
|
+
"""
|
|
542
|
+
try:
|
|
543
|
+
response = self.session.get(f"/jobs/group/{group_qrn}/jobs")
|
|
544
|
+
# Tolerate `"data": null` (treat as empty) and skip individual jobs
|
|
545
|
+
# that fail validation rather than dropping the whole list — keeps
|
|
546
|
+
# callers working when one job has unexpected/forward-compat fields.
|
|
547
|
+
resp_data: list[dict[str, Any]] = response.json().get("data") or []
|
|
548
|
+
jobs: list[RuntimeJob] = []
|
|
549
|
+
for job in resp_data:
|
|
550
|
+
try:
|
|
551
|
+
jobs.append(RuntimeJob.model_validate(job))
|
|
552
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
553
|
+
continue
|
|
554
|
+
return jobs
|
|
555
|
+
except RequestsApiError as err:
|
|
556
|
+
raise QuantumRuntimeServiceRequestError(
|
|
557
|
+
f"Failed to list jobs for group '{group_qrn}': {err}"
|
|
558
|
+
) from err
|
|
559
|
+
|
|
484
560
|
def list_groups(
|
|
485
561
|
self,
|
|
486
562
|
status: Optional[str] = None,
|
|
@@ -6,10 +6,13 @@ Pydantic schemas for the qBraid Runtime API.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
from .device import (
|
|
9
|
+
DeviceCalibration,
|
|
9
10
|
DevicePricing,
|
|
10
11
|
DeviceVerification,
|
|
12
|
+
EdgeEntry,
|
|
11
13
|
PlatformDevice,
|
|
12
14
|
ProviderLogo,
|
|
15
|
+
QubitCalibration,
|
|
13
16
|
RuntimeDevice,
|
|
14
17
|
RuntimeProvider,
|
|
15
18
|
)
|
|
@@ -32,10 +35,13 @@ from .result import BatchResult, Result
|
|
|
32
35
|
__all__ = [
|
|
33
36
|
"GroupJob",
|
|
34
37
|
"GroupStatus",
|
|
38
|
+
"DeviceCalibration",
|
|
35
39
|
"DevicePricing",
|
|
36
40
|
"DeviceVerification",
|
|
41
|
+
"EdgeEntry",
|
|
37
42
|
"PlatformDevice",
|
|
38
43
|
"ProviderLogo",
|
|
44
|
+
"QubitCalibration",
|
|
39
45
|
"RuntimeDevice",
|
|
40
46
|
"RuntimeProvider",
|
|
41
47
|
"DeviceStatus",
|
|
@@ -12,7 +12,7 @@ from decimal import Decimal
|
|
|
12
12
|
from enum import Enum
|
|
13
13
|
from typing import TYPE_CHECKING, Any, Literal, Optional, Union
|
|
14
14
|
|
|
15
|
-
from pydantic import BaseModel, ConfigDict, field_serializer, field_validator
|
|
15
|
+
from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
|
|
16
16
|
|
|
17
17
|
from qbraid_core.decimal import Credits
|
|
18
18
|
|
|
@@ -118,6 +118,7 @@ class RuntimeDevice(BaseModel):
|
|
|
118
118
|
pricing: Optional[DevicePricing] = None # only applies to fixed pricing model
|
|
119
119
|
directAccess: bool = True
|
|
120
120
|
batchJobSupport: bool = False
|
|
121
|
+
topology: Optional[dict[str, Any]] = None
|
|
121
122
|
|
|
122
123
|
|
|
123
124
|
class DeviceVerification(Enum):
|
|
@@ -182,3 +183,39 @@ class RuntimeProvider(BaseModel):
|
|
|
182
183
|
devices: list[str] = []
|
|
183
184
|
logo: Optional[ProviderLogo] = None
|
|
184
185
|
qrn: Optional[str] = None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class EdgeEntry(BaseModel):
|
|
189
|
+
"""Single two-qubit gate calibration measurement."""
|
|
190
|
+
|
|
191
|
+
model_config = ConfigDict(extra="ignore")
|
|
192
|
+
|
|
193
|
+
source: int
|
|
194
|
+
target: int
|
|
195
|
+
value: float
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class QubitCalibration(BaseModel):
|
|
199
|
+
"""Per-qubit calibration metrics."""
|
|
200
|
+
|
|
201
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
202
|
+
|
|
203
|
+
t1: Optional[float] = None
|
|
204
|
+
t2: Optional[float] = None
|
|
205
|
+
frequency: Optional[float] = None
|
|
206
|
+
readout_error: Optional[float] = Field(None, alias="readoutError")
|
|
207
|
+
gate_error: Optional[dict[str, float]] = Field(None, alias="gateError")
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class DeviceCalibration(BaseModel):
|
|
211
|
+
"""Device calibration snapshot."""
|
|
212
|
+
|
|
213
|
+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
|
|
214
|
+
|
|
215
|
+
physical_device_id: str = Field(alias="physicalDeviceId")
|
|
216
|
+
device_qrns: list[str] = Field(alias="deviceQRNs")
|
|
217
|
+
provider: str
|
|
218
|
+
last_calibrated: str = Field(alias="lastCalibrated")
|
|
219
|
+
fetched_at: str = Field(alias="fetchedAt")
|
|
220
|
+
qubits: dict[str, QubitCalibration]
|
|
221
|
+
edges: dict[str, dict[str, list[EdgeEntry]]]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|