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.
Files changed (112) hide show
  1. {qbraid_core-0.3.2/qbraid_core.egg-info → qbraid_core-0.3.4}/PKG-INFO +1 -1
  2. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/pyproject.toml +1 -1
  3. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/compute/__init__.py +9 -0
  4. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/compute/client.py +336 -0
  5. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/compute/schemas.py +69 -0
  6. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/compute/ssh.py +32 -0
  7. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/client.py +81 -5
  8. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/schemas/__init__.py +6 -0
  9. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/schemas/device.py +38 -1
  10. {qbraid_core-0.3.2 → qbraid_core-0.3.4/qbraid_core.egg-info}/PKG-INFO +1 -1
  11. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/LICENSE +0 -0
  12. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/MANIFEST.in +0 -0
  13. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/README.md +0 -0
  14. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/docs/_static/favicon.ico +0 -0
  15. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/docs/_static/logo.png +0 -0
  16. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/docs/api/qbraid_core.rst +0 -0
  17. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/docs/api/qbraid_core.services.rst +0 -0
  18. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/docs/conf.py +0 -0
  19. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/docs/index.rst +0 -0
  20. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/__init__.py +0 -0
  21. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/_build_info.py +0 -0
  22. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/_compat.py +0 -0
  23. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/_import.py +0 -0
  24. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/_version.py +0 -0
  25. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/annotations.py +0 -0
  26. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/client.py +0 -0
  27. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/config.py +0 -0
  28. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/context.py +0 -0
  29. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/decimal.py +0 -0
  30. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/__init__.py +0 -0
  31. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/auth.py +0 -0
  32. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/config.py +0 -0
  33. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/environments.py +0 -0
  34. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/jupyterlab.py +0 -0
  35. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/models.py +0 -0
  36. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/network.py +0 -0
  37. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/runner.py +0 -0
  38. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/diagnostics/system.py +0 -0
  39. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/exceptions.py +0 -0
  40. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/py.typed +0 -0
  41. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/registry.py +0 -0
  42. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/retry.py +0 -0
  43. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/__init__.py +0 -0
  44. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/__init__.py +0 -0
  45. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/client.py +0 -0
  46. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/constants.py +0 -0
  47. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/discovery.py +0 -0
  48. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/exceptions.py +0 -0
  49. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/hooks.py +0 -0
  50. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/launcher.py +0 -0
  51. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/messaging.py +0 -0
  52. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/opencode_api.py +0 -0
  53. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/permission_hook.py +0 -0
  54. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/permissions.py +0 -0
  55. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/registry.py +0 -0
  56. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/schemas.py +0 -0
  57. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/transcripts.py +0 -0
  58. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/agents/types.py +0 -0
  59. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/compute/exceptions.py +0 -0
  60. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/__init__.py +0 -0
  61. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/client.py +0 -0
  62. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/create.py +0 -0
  63. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/exceptions.py +0 -0
  64. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/kernels.py +0 -0
  65. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/magic.py +0 -0
  66. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/packages.py +0 -0
  67. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/paths.py +0 -0
  68. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/preflight.py +0 -0
  69. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/python.py +0 -0
  70. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/registry.py +0 -0
  71. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/schema.py +0 -0
  72. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/state.py +0 -0
  73. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/sync_progress.py +0 -0
  74. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/utils/__init__.py +0 -0
  75. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/validate.py +0 -0
  76. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/environments/venv.py +0 -0
  77. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/projects/__init__.py +0 -0
  78. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/projects/client.py +0 -0
  79. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/projects/exceptions.py +0 -0
  80. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/quantum/__init__.py +0 -0
  81. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/quantum/runner.py +0 -0
  82. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/__init__.py +0 -0
  83. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/exceptions.py +0 -0
  84. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/schemas/enums.py +0 -0
  85. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/schemas/group.py +0 -0
  86. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/schemas/job.py +0 -0
  87. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/runtime/schemas/result.py +0 -0
  88. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/skills/__init__.py +0 -0
  89. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/skills/client.py +0 -0
  90. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/skills/exceptions.py +0 -0
  91. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/skills/schemas.py +0 -0
  92. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/storage/__init__.py +0 -0
  93. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/storage/disk_usage_client.py +0 -0
  94. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/storage/exceptions.py +0 -0
  95. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/services/storage/types.py +0 -0
  96. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/sessions.py +0 -0
  97. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/__init__.py +0 -0
  98. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/exceptions.py +0 -0
  99. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/executables.py +0 -0
  100. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/filemanager.py +0 -0
  101. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/generic.py +0 -0
  102. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/magic/__init__.py +0 -0
  103. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/magic/manager.py +0 -0
  104. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/magic/qbraid_magic.py +0 -0
  105. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/packages.py +0 -0
  106. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core/system/versions.py +0 -0
  107. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core.egg-info/SOURCES.txt +0 -0
  108. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core.egg-info/dependency_links.txt +0 -0
  109. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core.egg-info/requires.txt +0 -0
  110. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/qbraid_core.egg-info/top_level.txt +0 -0
  111. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/setup.cfg +0 -0
  112. {qbraid_core-0.3.2 → qbraid_core-0.3.4}/tests/test_mypy_call_arg_validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qbraid-core
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Python library with core abstractions for software development in the qBraid ecosystem.
5
5
  Author: qBraid Development Team
6
6
  Author-email: contact@qbraid.com
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qbraid-core"
7
- version = "0.3.2"
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
- response = self.session.get("/devices")
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
- response = self.session.get("/devices")
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]]]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: qbraid-core
3
- Version: 0.3.2
3
+ Version: 0.3.4
4
4
  Summary: Python library with core abstractions for software development in the qBraid ecosystem.
5
5
  Author: qBraid Development Team
6
6
  Author-email: contact@qbraid.com
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes