qubac 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.
qubac-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,55 @@
1
+ Metadata-Version: 2.4
2
+ Name: qubac
3
+ Version: 1.0.0
4
+ Summary: The official Python SDK for SAM by Qubac
5
+ Project-URL: Homepage, https://qubac.com
6
+ Project-URL: Documentation, https://docs.qubac.com
7
+ Project-URL: Repository, https://github.com/qubac/qubac-python
8
+ Author-email: Qubac <sdk@qubac.com>
9
+ License: MIT
10
+ Keywords: agent,ai,autonomous,qubac,sam
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Typing :: Typed
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: anyio>=3.0.0
22
+ Requires-Dist: httpx>=0.25.0
23
+ Provides-Extra: dev
24
+ Requires-Dist: black; extra == 'dev'
25
+ Requires-Dist: mypy; extra == 'dev'
26
+ Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
27
+ Requires-Dist: pytest>=7.0; extra == 'dev'
28
+ Requires-Dist: respx>=0.20; extra == 'dev'
29
+ Requires-Dist: ruff; extra == 'dev'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # Qubac Python SDK
33
+
34
+ The official Python library for SAM by Qubac.
35
+
36
+ ```python
37
+ import qubac
38
+
39
+ client = qubac.Client(api_key="sk-...")
40
+ response = client.tasks.create(
41
+ model="sam-1",
42
+ task="what is the speed of light"
43
+ )
44
+ print(response.output)
45
+ ```
46
+
47
+ ## Installation
48
+
49
+ ```bash
50
+ pip install qubac
51
+ ```
52
+
53
+ ## Docs
54
+
55
+ https://docs.qubac.com
qubac-1.0.0/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # Qubac Python SDK
2
+
3
+ The official Python library for SAM by Qubac.
4
+
5
+ ```python
6
+ import qubac
7
+
8
+ client = qubac.Client(api_key="sk-...")
9
+ response = client.tasks.create(
10
+ model="sam-1",
11
+ task="what is the speed of light"
12
+ )
13
+ print(response.output)
14
+ ```
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install qubac
20
+ ```
21
+
22
+ ## Docs
23
+
24
+ https://docs.qubac.com
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "qubac"
7
+ version = "1.0.0"
8
+ description = "The official Python SDK for SAM by Qubac"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Qubac", email = "sdk@qubac.com" }]
12
+ keywords = ["qubac", "sam", "ai", "agent", "autonomous"]
13
+ classifiers = [
14
+ "Development Status :: 5 - Production/Stable",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.9",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Typing :: Typed",
23
+ ]
24
+ requires-python = ">=3.9"
25
+ dependencies = [
26
+ "httpx>=0.25.0",
27
+ "anyio>=3.0.0",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "pytest>=7.0",
33
+ "pytest-asyncio>=0.21",
34
+ "respx>=0.20",
35
+ "black",
36
+ "mypy",
37
+ "ruff",
38
+ ]
39
+
40
+ [project.urls]
41
+ Homepage = "https://qubac.com"
42
+ Documentation = "https://docs.qubac.com"
43
+ Repository = "https://github.com/qubac/qubac-python"
44
+
45
+ [tool.hatch.build.targets.wheel]
46
+ packages = ["qubac"]
47
+
48
+ [tool.mypy]
49
+ strict = true
50
+ python_version = "3.9"
51
+
52
+ [tool.pytest.ini_options]
53
+ asyncio_mode = "auto"
@@ -0,0 +1,32 @@
1
+ """
2
+ Qubac SDK — Python client for SAM
3
+
4
+ Install: pip install qubac
5
+ Docs: https://docs.qubac.com
6
+
7
+ Quick start:
8
+ import qubac
9
+ client = qubac.Client(api_key="sk-...")
10
+ result = client.tasks.create(model="sam-1", task="what is 2+2")
11
+ print(result.output)
12
+ """
13
+ from __future__ import annotations
14
+ from ._client import AsyncClient, Client
15
+ from ._exceptions import (
16
+ APIConnectionError, APIError, APITimeoutError, ApprovalRequiredError,
17
+ AuthenticationError, InternalServerError, NotFoundError,
18
+ PermissionDeniedError, QubacError, RateLimitError,
19
+ TaskFailedError, TaskTimeoutError,
20
+ )
21
+ from .types import Model, StreamEvent, TaskAsyncResponse, TaskResponse
22
+
23
+ __version__ = "1.0.0"
24
+ __all__ = [
25
+ "Client", "AsyncClient",
26
+ "TaskResponse", "TaskAsyncResponse", "StreamEvent", "Model",
27
+ "QubacError", "APIError", "APIConnectionError", "APITimeoutError",
28
+ "AuthenticationError", "PermissionDeniedError", "NotFoundError",
29
+ "RateLimitError", "InternalServerError",
30
+ "TaskTimeoutError", "TaskFailedError", "ApprovalRequiredError",
31
+ "__version__",
32
+ ]
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+ import os
3
+ from typing import Any, Optional
4
+ from ._exceptions import AuthenticationError
5
+ from ._http import AsyncHttpClient, SyncHttpClient
6
+ from .resources.tasks import AsyncTasks, Tasks
7
+
8
+ __all__ = ["Client", "AsyncClient"]
9
+
10
+ _DEFAULT_BASE_URL = "https://api.qubac.com"
11
+ _DEFAULT_TIMEOUT = 130.0
12
+ _DEFAULT_MAX_RETRIES = 2
13
+
14
+ class Client:
15
+ """
16
+ Synchronous Qubac client.
17
+
18
+ Usage:
19
+ import qubac
20
+ client = qubac.Client(api_key="sk-...")
21
+ result = client.tasks.create(model="sam-1", task="what is 2+2")
22
+ print(result.output)
23
+ """
24
+ tasks: Tasks
25
+
26
+ def __init__(self, api_key: Optional[str] = None, *, base_url: str = _DEFAULT_BASE_URL,
27
+ timeout: float = _DEFAULT_TIMEOUT, max_retries: int = _DEFAULT_MAX_RETRIES) -> None:
28
+ resolved = api_key or os.environ.get("QUBAC_API_KEY", "")
29
+ if not resolved:
30
+ raise AuthenticationError(
31
+ "No API key provided. Pass api_key= or set QUBAC_API_KEY.\n"
32
+ "Get your key at https://console.qubac.com/configure"
33
+ )
34
+ self._http = SyncHttpClient(base_url=base_url, api_key=resolved,
35
+ timeout=timeout, max_retries=max_retries)
36
+ self.tasks = Tasks(self._http)
37
+
38
+ def close(self) -> None:
39
+ self._http.close()
40
+
41
+ def __enter__(self) -> "Client":
42
+ return self
43
+
44
+ def __exit__(self, *_: Any) -> None:
45
+ self.close()
46
+
47
+ def __repr__(self) -> str:
48
+ return f"qubac.Client(base_url={self._http._base_url!r})"
49
+
50
+ def with_options(self, *, api_key: Optional[str] = None, base_url: Optional[str] = None,
51
+ timeout: Optional[float] = None, max_retries: Optional[int] = None) -> "Client":
52
+ """Return a new Client with overridden options."""
53
+ return Client(
54
+ api_key=api_key or self._http._api_key,
55
+ base_url=base_url or self._http._base_url,
56
+ timeout=timeout if timeout is not None else self._http._timeout,
57
+ max_retries=max_retries if max_retries is not None else self._http._max_retries,
58
+ )
59
+
60
+ class AsyncClient:
61
+ """
62
+ Asynchronous Qubac client.
63
+
64
+ Usage:
65
+ import asyncio, qubac
66
+ async def main():
67
+ async with qubac.AsyncClient(api_key="sk-...") as client:
68
+ result = await client.tasks.create(model="sam-1", task="what is 2+2")
69
+ print(result.output)
70
+ asyncio.run(main())
71
+ """
72
+ tasks: AsyncTasks
73
+
74
+ def __init__(self, api_key: Optional[str] = None, *, base_url: str = _DEFAULT_BASE_URL,
75
+ timeout: float = _DEFAULT_TIMEOUT, max_retries: int = _DEFAULT_MAX_RETRIES) -> None:
76
+ resolved = api_key or os.environ.get("QUBAC_API_KEY", "")
77
+ if not resolved:
78
+ raise AuthenticationError(
79
+ "No API key provided. Pass api_key= or set QUBAC_API_KEY.\n"
80
+ "Get your key at https://console.qubac.com/configure"
81
+ )
82
+ self._http = AsyncHttpClient(base_url=base_url, api_key=resolved,
83
+ timeout=timeout, max_retries=max_retries)
84
+ self.tasks = AsyncTasks(self._http)
85
+
86
+ async def aclose(self) -> None:
87
+ await self._http.aclose()
88
+
89
+ async def __aenter__(self) -> "AsyncClient":
90
+ return self
91
+
92
+ async def __aexit__(self, *_: Any) -> None:
93
+ await self.aclose()
94
+
95
+ def __repr__(self) -> str:
96
+ return f"qubac.AsyncClient(base_url={self._http._base_url!r})"
97
+
98
+ def with_options(self, *, api_key: Optional[str] = None, base_url: Optional[str] = None,
99
+ timeout: Optional[float] = None, max_retries: Optional[int] = None) -> "AsyncClient":
100
+ """Return a new AsyncClient with overridden options."""
101
+ return AsyncClient(
102
+ api_key=api_key or self._http._api_key,
103
+ base_url=base_url or self._http._base_url,
104
+ timeout=timeout if timeout is not None else self._http._timeout,
105
+ max_retries=max_retries if max_retries is not None else self._http._max_retries,
106
+ )
@@ -0,0 +1,71 @@
1
+ from __future__ import annotations
2
+ from typing import Any, Dict, Optional
3
+
4
+ __all__ = [
5
+ "QubacError", "APIError", "APIConnectionError", "APITimeoutError",
6
+ "AuthenticationError", "PermissionDeniedError", "NotFoundError",
7
+ "RateLimitError", "InternalServerError",
8
+ "TaskTimeoutError", "TaskFailedError", "ApprovalRequiredError",
9
+ ]
10
+
11
+ class QubacError(Exception):
12
+ def __init__(self, message: str, *, status_code: Optional[int] = None) -> None:
13
+ super().__init__(message)
14
+ self.message = message
15
+ self.status_code = status_code
16
+
17
+ class APIError(QubacError): pass
18
+
19
+ class AuthenticationError(APIError):
20
+ def __init__(self, message: str = "Authentication failed. Check your API key.") -> None:
21
+ super().__init__(message, status_code=401)
22
+
23
+ class PermissionDeniedError(APIError):
24
+ def __init__(self, message: str) -> None:
25
+ super().__init__(message, status_code=403)
26
+
27
+ class NotFoundError(APIError):
28
+ def __init__(self, message: str) -> None:
29
+ super().__init__(message, status_code=404)
30
+
31
+ class RateLimitError(APIError):
32
+ def __init__(self, message: str, *, reset_at: Optional[str] = None, retry_after: Optional[int] = None) -> None:
33
+ super().__init__(message, status_code=429)
34
+ self.reset_at = reset_at
35
+ self.retry_after = retry_after
36
+
37
+ class InternalServerError(APIError):
38
+ def __init__(self, message: str, *, status_code: int = 500) -> None:
39
+ super().__init__(message, status_code=status_code)
40
+
41
+ class APIConnectionError(QubacError):
42
+ def __init__(self, message: str = "Could not connect to the Qubac API.") -> None:
43
+ super().__init__(message)
44
+
45
+ class APITimeoutError(QubacError):
46
+ def __init__(self, timeout: float) -> None:
47
+ super().__init__(f"Request timed out after {timeout}s.")
48
+ self.timeout = timeout
49
+
50
+ class TaskTimeoutError(QubacError):
51
+ def __init__(self, task_id: str, timeout: int) -> None:
52
+ super().__init__(
53
+ f"Task '{task_id}' did not complete within {timeout}s. "
54
+ f"Use client.tasks.get('{task_id}') to poll for the result."
55
+ )
56
+ self.task_id = task_id
57
+ self.timeout = timeout
58
+
59
+ class TaskFailedError(QubacError):
60
+ def __init__(self, task_id: str, reason: Optional[str] = None) -> None:
61
+ super().__init__(reason or f"Task '{task_id}' failed during execution.")
62
+ self.task_id = task_id
63
+ self.reason = reason
64
+
65
+ class ApprovalRequiredError(QubacError):
66
+ def __init__(self, task_id: str, detail: Optional[str] = None) -> None:
67
+ super().__init__(
68
+ detail or f"Task '{task_id}' requires approval. "
69
+ "Review it at https://console.qubac.com/approvals"
70
+ )
71
+ self.task_id = task_id
@@ -0,0 +1,170 @@
1
+ from __future__ import annotations
2
+ import asyncio, json, logging, random, time
3
+ from typing import Any, AsyncGenerator, Dict, Generator, Optional
4
+ import httpx
5
+ from ._exceptions import (
6
+ APIConnectionError, APIError, APITimeoutError, AuthenticationError,
7
+ InternalServerError, NotFoundError, PermissionDeniedError, RateLimitError,
8
+ )
9
+
10
+ log = logging.getLogger("qubac")
11
+ _SDK_VERSION = "1.0.0"
12
+ _RETRYABLE = {429, 500, 502, 503, 504}
13
+ _BACKOFF_BASE = 0.5
14
+ _BACKOFF_MAX = 8.0
15
+
16
+ def _user_agent() -> str:
17
+ import platform
18
+ return f"qubac-python/{_SDK_VERSION} Python/{platform.python_version()}"
19
+
20
+ def _headers(api_key: str) -> Dict[str, str]:
21
+ return {
22
+ "Authorization": f"Bearer {api_key}",
23
+ "Content-Type": "application/json",
24
+ "Accept": "application/json",
25
+ "User-Agent": _user_agent(),
26
+ "X-Qubac-SDK-Version": _SDK_VERSION,
27
+ }
28
+
29
+ def _backoff(attempt: int) -> float:
30
+ delay = min(_BACKOFF_BASE * (2 ** attempt), _BACKOFF_MAX)
31
+ return delay * (0.5 + random.random() * 0.5)
32
+
33
+ def _raise(status: int, body: Dict[str, Any]) -> None:
34
+ detail = str(body.get("detail") or body.get("message") or "Unknown error")
35
+ if status == 401: raise AuthenticationError(f"Authentication failed: {detail}")
36
+ if status == 403: raise PermissionDeniedError(f"Permission denied: {detail}")
37
+ if status == 404: raise NotFoundError(f"Not found: {detail}")
38
+ if status in (429, 402):
39
+ raise RateLimitError(f"Rate limit: {detail}", reset_at=body.get("reset_at"))
40
+ if 500 <= status < 600:
41
+ raise InternalServerError(f"Server error {status}: {detail}", status_code=status)
42
+ raise APIError(f"API error {status}: {detail}", status_code=status)
43
+
44
+ class SyncHttpClient:
45
+ def __init__(self, base_url: str, api_key: str, timeout: float, max_retries: int) -> None:
46
+ self._base_url = base_url.rstrip("/")
47
+ self._api_key = api_key
48
+ self._timeout = timeout
49
+ self._max_retries = max_retries
50
+ self._client = httpx.Client(
51
+ base_url=self._base_url,
52
+ headers=_headers(api_key),
53
+ timeout=httpx.Timeout(timeout),
54
+ follow_redirects=True,
55
+ )
56
+
57
+ def close(self) -> None:
58
+ self._client.close()
59
+
60
+ def __enter__(self) -> "SyncHttpClient":
61
+ return self
62
+
63
+ def __exit__(self, *_: Any) -> None:
64
+ self.close()
65
+
66
+ def request(self, method: str, path: str, *, json_body: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None) -> Dict[str, Any]:
67
+ kwargs: Dict[str, Any] = {"json": json_body}
68
+ if timeout is not None:
69
+ kwargs["timeout"] = httpx.Timeout(timeout)
70
+ for attempt in range(self._max_retries + 1):
71
+ try:
72
+ resp = self._client.request(method, path, **kwargs)
73
+ if resp.status_code in _RETRYABLE and attempt < self._max_retries:
74
+ time.sleep(_backoff(attempt))
75
+ continue
76
+ try: body = resp.json()
77
+ except Exception: body = {}
78
+ if not resp.is_success:
79
+ _raise(resp.status_code, body)
80
+ return body
81
+ except httpx.TimeoutException as exc:
82
+ if attempt < self._max_retries: time.sleep(_backoff(attempt)); continue
83
+ raise APITimeoutError(timeout or self._timeout) from exc
84
+ except httpx.NetworkError as exc:
85
+ if attempt < self._max_retries: time.sleep(_backoff(attempt)); continue
86
+ raise APIConnectionError(f"Network error: {exc}") from exc
87
+ raise APIConnectionError("Request failed after retries")
88
+
89
+ def stream_sse(self, path: str, *, timeout: Optional[float] = None) -> Generator[Dict[str, Any], None, None]:
90
+ hdrs = {**_headers(self._api_key), "Accept": "text/event-stream"}
91
+ t = httpx.Timeout(timeout or self._timeout)
92
+ with self._client.stream("GET", path, headers=hdrs, timeout=t) as resp:
93
+ if not resp.is_success:
94
+ try: body = resp.json()
95
+ except Exception: body = {}
96
+ _raise(resp.status_code, body)
97
+ buf = ""
98
+ for chunk in resp.iter_text():
99
+ buf += chunk
100
+ while "\n" in buf:
101
+ line, buf = buf.split("\n", 1)
102
+ if line.strip().startswith("data:"):
103
+ raw = line.strip()[5:].strip()
104
+ if raw:
105
+ try: yield json.loads(raw)
106
+ except json.JSONDecodeError: pass
107
+
108
+ class AsyncHttpClient:
109
+ def __init__(self, base_url: str, api_key: str, timeout: float, max_retries: int) -> None:
110
+ self._base_url = base_url.rstrip("/")
111
+ self._api_key = api_key
112
+ self._timeout = timeout
113
+ self._max_retries = max_retries
114
+ self._client = httpx.AsyncClient(
115
+ base_url=self._base_url,
116
+ headers=_headers(api_key),
117
+ timeout=httpx.Timeout(timeout),
118
+ follow_redirects=True,
119
+ )
120
+
121
+ async def aclose(self) -> None:
122
+ await self._client.aclose()
123
+
124
+ async def __aenter__(self) -> "AsyncHttpClient":
125
+ return self
126
+
127
+ async def __aexit__(self, *_: Any) -> None:
128
+ await self.aclose()
129
+
130
+ async def request(self, method: str, path: str, *, json_body: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None) -> Dict[str, Any]:
131
+ kwargs: Dict[str, Any] = {"json": json_body}
132
+ if timeout is not None:
133
+ kwargs["timeout"] = httpx.Timeout(timeout)
134
+ for attempt in range(self._max_retries + 1):
135
+ try:
136
+ resp = await self._client.request(method, path, **kwargs)
137
+ if resp.status_code in _RETRYABLE and attempt < self._max_retries:
138
+ await asyncio.sleep(_backoff(attempt))
139
+ continue
140
+ try: body = resp.json()
141
+ except Exception: body = {}
142
+ if not resp.is_success:
143
+ _raise(resp.status_code, body)
144
+ return body
145
+ except httpx.TimeoutException as exc:
146
+ if attempt < self._max_retries: await asyncio.sleep(_backoff(attempt)); continue
147
+ raise APITimeoutError(timeout or self._timeout) from exc
148
+ except httpx.NetworkError as exc:
149
+ if attempt < self._max_retries: await asyncio.sleep(_backoff(attempt)); continue
150
+ raise APIConnectionError(f"Network error: {exc}") from exc
151
+ raise APIConnectionError("Request failed after retries")
152
+
153
+ async def stream_sse(self, path: str, *, timeout: Optional[float] = None) -> AsyncGenerator[Dict[str, Any], None]:
154
+ hdrs = {**_headers(self._api_key), "Accept": "text/event-stream"}
155
+ t = httpx.Timeout(timeout or self._timeout)
156
+ async with self._client.stream("GET", path, headers=hdrs, timeout=t) as resp:
157
+ if not resp.is_success:
158
+ try: body = resp.json()
159
+ except Exception: body = {}
160
+ _raise(resp.status_code, body)
161
+ buf = ""
162
+ async for chunk in resp.aiter_text():
163
+ buf += chunk
164
+ while "\n" in buf:
165
+ line, buf = buf.split("\n", 1)
166
+ if line.strip().startswith("data:"):
167
+ raw = line.strip()[5:].strip()
168
+ if raw:
169
+ try: yield json.loads(raw)
170
+ except json.JSONDecodeError: pass
File without changes
@@ -0,0 +1,2 @@
1
+ from .tasks import Tasks, AsyncTasks, SyncStreamContext, AsyncStreamContext
2
+ __all__ = ["Tasks", "AsyncTasks", "SyncStreamContext", "AsyncStreamContext"]
@@ -0,0 +1,188 @@
1
+ from __future__ import annotations
2
+ from typing import Any, AsyncGenerator, Dict, Iterator, List, Optional
3
+ from .._exceptions import TaskFailedError, TaskTimeoutError
4
+ from .._http import AsyncHttpClient, SyncHttpClient
5
+ from ..types import Model, StreamEvent, TaskAsyncResponse, TaskResponse
6
+
7
+ __all__ = ["Tasks", "AsyncTasks", "SyncStreamContext", "AsyncStreamContext"]
8
+
9
+ _DEFAULT_MODEL = "sam-1"
10
+ _DEFAULT_TIMEOUT = 120
11
+ _DEFAULT_PRIORITY = "normal"
12
+
13
+ def _parse(data: Dict[str, Any]) -> TaskResponse:
14
+ return TaskResponse(
15
+ id=data["id"], model=data.get("model", _DEFAULT_MODEL),
16
+ status=data.get("status", "unknown"),
17
+ output=data.get("output"), nodes_used=data.get("nodes_used", 0),
18
+ execution_ms=data.get("execution_ms"), created_at=data.get("created_at", ""),
19
+ metadata=data.get("metadata"), error=data.get("error"),
20
+ )
21
+
22
+ def _parse_event(data: Dict[str, Any], task_id: str) -> StreamEvent:
23
+ return StreamEvent(
24
+ type=data.get("type", "task.update"), task_id=data.get("task_id", task_id),
25
+ summary=data.get("summary", ""), timestamp=data.get("timestamp"),
26
+ node=data.get("node"), output=data.get("output"),
27
+ nodes_used=data.get("nodes_used"), error=data.get("error"), raw=data,
28
+ )
29
+
30
+ def _create_body(task: str, model: str, priority: str, max_nodes: Optional[int], timeout: int, metadata: Optional[Dict]) -> Dict[str, Any]:
31
+ b: Dict[str, Any] = {"task": task, "model": model, "priority": priority, "timeout": timeout}
32
+ if max_nodes is not None: b["max_nodes"] = max_nodes
33
+ if metadata is not None: b["metadata"] = metadata
34
+ return b
35
+
36
+ def _submit_body(task: str, model: str, priority: str, max_nodes: Optional[int], metadata: Optional[Dict]) -> Dict[str, Any]:
37
+ b: Dict[str, Any] = {"task": task, "model": model, "priority": priority}
38
+ if max_nodes is not None: b["max_nodes"] = max_nodes
39
+ if metadata is not None: b["metadata"] = metadata
40
+ return b
41
+
42
+ class SyncStreamContext:
43
+ def __init__(self, http: SyncHttpClient, task_id: str, timeout: Optional[float] = None) -> None:
44
+ self._http = http
45
+ self._task_id = task_id
46
+ self._timeout = timeout
47
+ self._final: Optional[TaskResponse] = None
48
+
49
+ def __enter__(self) -> "SyncStreamContext":
50
+ return self
51
+
52
+ def __exit__(self, *_: Any) -> None:
53
+ pass
54
+
55
+ def __iter__(self) -> Iterator[StreamEvent]:
56
+ for data in self._http.stream_sse(f"/v1/sam/tasks/{self._task_id}/stream", timeout=self._timeout):
57
+ evt = _parse_event(data, self._task_id)
58
+ if evt.type == "stream.end": break
59
+ yield evt
60
+ if evt.type == "task.completed":
61
+ self._final = TaskResponse(id=evt.task_id, model=_DEFAULT_MODEL, status="completed",
62
+ output=evt.output, nodes_used=evt.nodes_used or 0,
63
+ execution_ms=None, created_at=evt.timestamp or "")
64
+ break
65
+ if evt.type == "task.failed":
66
+ self._final = TaskResponse(id=evt.task_id, model=_DEFAULT_MODEL, status="failed",
67
+ output=None, nodes_used=0, execution_ms=None,
68
+ created_at=evt.timestamp or "", error=evt.error)
69
+ break
70
+
71
+ @property
72
+ def final_response(self) -> Optional[TaskResponse]:
73
+ return self._final
74
+
75
+ class AsyncStreamContext:
76
+ def __init__(self, http: AsyncHttpClient, task_id: str, timeout: Optional[float] = None) -> None:
77
+ self._http = http
78
+ self._task_id = task_id
79
+ self._timeout = timeout
80
+ self._final: Optional[TaskResponse] = None
81
+
82
+ async def __aenter__(self) -> "AsyncStreamContext":
83
+ return self
84
+
85
+ async def __aexit__(self, *_: Any) -> None:
86
+ pass
87
+
88
+ async def __aiter__(self):
89
+ async for data in self._http.stream_sse(f"/v1/sam/tasks/{self._task_id}/stream", timeout=self._timeout):
90
+ evt = _parse_event(data, self._task_id)
91
+ if evt.type == "stream.end": break
92
+ yield evt
93
+ if evt.type == "task.completed":
94
+ self._final = TaskResponse(id=evt.task_id, model=_DEFAULT_MODEL, status="completed",
95
+ output=evt.output, nodes_used=evt.nodes_used or 0,
96
+ execution_ms=None, created_at=evt.timestamp or "")
97
+ break
98
+ if evt.type == "task.failed":
99
+ self._final = TaskResponse(id=evt.task_id, model=_DEFAULT_MODEL, status="failed",
100
+ output=None, nodes_used=0, execution_ms=None,
101
+ created_at=evt.timestamp or "", error=evt.error)
102
+ break
103
+
104
+ @property
105
+ def final_response(self) -> Optional[TaskResponse]:
106
+ return self._final
107
+
108
+ class Tasks:
109
+ """Sync tasks resource. Access via client.tasks."""
110
+ def __init__(self, http: SyncHttpClient) -> None:
111
+ self._http = http
112
+
113
+ def create(self, task: str, *, model: str = _DEFAULT_MODEL, priority: str = _DEFAULT_PRIORITY,
114
+ max_nodes: Optional[int] = None, timeout: int = _DEFAULT_TIMEOUT,
115
+ metadata: Optional[Dict[str, Any]] = None, request_timeout: Optional[float] = None) -> TaskResponse:
116
+ """Create and execute a SAM task synchronously. Waits for completion."""
117
+ data = self._http.request("POST", "/v1/sam/tasks",
118
+ json_body=_create_body(task, model, priority, max_nodes, timeout, metadata),
119
+ timeout=request_timeout)
120
+ resp = _parse(data)
121
+ if resp.status == "running": raise TaskTimeoutError(resp.id, timeout)
122
+ if resp.status == "failed": raise TaskFailedError(resp.id, resp.error)
123
+ return resp
124
+
125
+ def submit(self, task: str, *, model: str = _DEFAULT_MODEL, priority: str = _DEFAULT_PRIORITY,
126
+ max_nodes: Optional[int] = None, metadata: Optional[Dict[str, Any]] = None,
127
+ request_timeout: Optional[float] = None) -> TaskAsyncResponse:
128
+ """Submit a SAM task and return immediately. Use get() or stream() for results."""
129
+ data = self._http.request("POST", "/v1/sam/tasks/async",
130
+ json_body=_submit_body(task, model, priority, max_nodes, metadata),
131
+ timeout=request_timeout)
132
+ return TaskAsyncResponse(id=data["id"], model=data.get("model", model),
133
+ status="running", created_at=data.get("created_at", ""))
134
+
135
+ def get(self, task_id: str, *, request_timeout: Optional[float] = None) -> TaskResponse:
136
+ """Poll a task for its current status and result."""
137
+ return _parse(self._http.request("GET", f"/v1/sam/tasks/{task_id}", timeout=request_timeout))
138
+
139
+ def stream(self, task_id: str, *, timeout: Optional[float] = None) -> SyncStreamContext:
140
+ """Stream execution events for a task. Returns a context manager."""
141
+ return SyncStreamContext(self._http, task_id, timeout=timeout)
142
+
143
+ def list_models(self) -> List[Model]:
144
+ """List all available SAM models."""
145
+ data = self._http.request("GET", "/v1/sam/models")
146
+ return [Model(id=m["id"], name=m["name"], description=m["description"],
147
+ max_nodes=m.get("max_nodes", 10)) for m in data.get("models", [])]
148
+
149
+ class AsyncTasks:
150
+ """Async tasks resource. Access via async_client.tasks."""
151
+ def __init__(self, http: AsyncHttpClient) -> None:
152
+ self._http = http
153
+
154
+ async def create(self, task: str, *, model: str = _DEFAULT_MODEL, priority: str = _DEFAULT_PRIORITY,
155
+ max_nodes: Optional[int] = None, timeout: int = _DEFAULT_TIMEOUT,
156
+ metadata: Optional[Dict[str, Any]] = None, request_timeout: Optional[float] = None) -> TaskResponse:
157
+ """Async version of Tasks.create()."""
158
+ data = await self._http.request("POST", "/v1/sam/tasks",
159
+ json_body=_create_body(task, model, priority, max_nodes, timeout, metadata),
160
+ timeout=request_timeout)
161
+ resp = _parse(data)
162
+ if resp.status == "running": raise TaskTimeoutError(resp.id, timeout)
163
+ if resp.status == "failed": raise TaskFailedError(resp.id, resp.error)
164
+ return resp
165
+
166
+ async def submit(self, task: str, *, model: str = _DEFAULT_MODEL, priority: str = _DEFAULT_PRIORITY,
167
+ max_nodes: Optional[int] = None, metadata: Optional[Dict[str, Any]] = None,
168
+ request_timeout: Optional[float] = None) -> TaskAsyncResponse:
169
+ """Async version of Tasks.submit()."""
170
+ data = await self._http.request("POST", "/v1/sam/tasks/async",
171
+ json_body=_submit_body(task, model, priority, max_nodes, metadata),
172
+ timeout=request_timeout)
173
+ return TaskAsyncResponse(id=data["id"], model=data.get("model", model),
174
+ status="running", created_at=data.get("created_at", ""))
175
+
176
+ async def get(self, task_id: str, *, request_timeout: Optional[float] = None) -> TaskResponse:
177
+ """Async version of Tasks.get()."""
178
+ return _parse(await self._http.request("GET", f"/v1/sam/tasks/{task_id}", timeout=request_timeout))
179
+
180
+ def stream(self, task_id: str, *, timeout: Optional[float] = None) -> AsyncStreamContext:
181
+ """Async version of Tasks.stream()."""
182
+ return AsyncStreamContext(self._http, task_id, timeout=timeout)
183
+
184
+ async def list_models(self) -> List[Model]:
185
+ """List all available SAM models."""
186
+ data = await self._http.request("GET", "/v1/sam/models")
187
+ return [Model(id=m["id"], name=m["name"], description=m["description"],
188
+ max_nodes=m.get("max_nodes", 10)) for m in data.get("models", [])]
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from typing import Any, Dict, Literal, Optional
4
+
5
+ __all__ = ["TaskResponse", "TaskAsyncResponse", "StreamEvent", "Model"]
6
+
7
+ TaskStatus = Literal["completed", "failed", "running", "cancelled"]
8
+ StreamEventType = Literal[
9
+ "task.created", "task.planned", "node.started", "node.completed",
10
+ "node.failed", "task.completed", "task.failed", "task.update", "stream.end",
11
+ ]
12
+
13
+ @dataclass(frozen=True)
14
+ class TaskResponse:
15
+ """Response from client.tasks.create() or client.tasks.get()."""
16
+ id: str
17
+ model: str
18
+ status: TaskStatus
19
+ output: Optional[str]
20
+ nodes_used: int
21
+ execution_ms: Optional[int]
22
+ created_at: str
23
+ metadata: Optional[Dict[str, Any]] = None
24
+ error: Optional[str] = None
25
+
26
+ def __repr__(self) -> str:
27
+ preview = (self.output or "")[:60]
28
+ if len(self.output or "") > 60:
29
+ preview += "..."
30
+ return f"TaskResponse(id={self.id!r}, status={self.status!r}, output={preview!r})"
31
+
32
+ @dataclass(frozen=True)
33
+ class TaskAsyncResponse:
34
+ """Response from client.tasks.submit() — task accepted, not yet complete."""
35
+ id: str
36
+ model: str
37
+ status: TaskStatus
38
+ created_at: str
39
+
40
+ @dataclass(frozen=True)
41
+ class StreamEvent:
42
+ """A single event from a streaming task execution."""
43
+ type: StreamEventType
44
+ task_id: str
45
+ summary: str = ""
46
+ timestamp: Optional[str] = None
47
+ node: Optional[str] = None
48
+ output: Optional[str] = None
49
+ nodes_used: Optional[int] = None
50
+ error: Optional[str] = None
51
+ raw: Dict[str, Any] = field(default_factory=dict)
52
+
53
+ def __repr__(self) -> str:
54
+ if self.type == "task.completed":
55
+ return f"StreamEvent(type='task.completed', output={str(self.output or '')[:50]!r})"
56
+ if self.type in ("node.started", "node.completed", "node.failed"):
57
+ return f"StreamEvent(type={self.type!r}, node={self.node!r})"
58
+ return f"StreamEvent(type={self.type!r}, summary={self.summary[:40]!r})"
59
+
60
+ @dataclass(frozen=True)
61
+ class Model:
62
+ """A SAM model available for task execution."""
63
+ id: str
64
+ name: str
65
+ description: str
66
+ max_nodes: int
67
+
68
+ def __repr__(self) -> str:
69
+ return f"Model(id={self.id!r}, name={self.name!r})"
File without changes
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+ import json, pytest, respx, httpx
3
+ from typing import Any, Dict
4
+ import qubac
5
+ from qubac import (
6
+ Client, AsyncClient, TaskResponse, TaskAsyncResponse, StreamEvent,
7
+ AuthenticationError, RateLimitError, TaskTimeoutError, TaskFailedError, NotFoundError,
8
+ )
9
+
10
+ BASE = "https://api.qubac.com"
11
+ KEY = "sk-test"
12
+
13
+ DONE: Dict[str, Any] = {
14
+ "id": "tsk_abc123def456789012345678901234",
15
+ "model": "sam-1", "status": "completed",
16
+ "output": "The speed of light is 299,792,458 m/s.",
17
+ "nodes_used": 1, "execution_ms": 3200,
18
+ "created_at": "2026-04-29T00:00:00Z", "metadata": None, "error": None,
19
+ }
20
+ FAIL: Dict[str, Any] = {**DONE, "status": "failed", "output": None, "error": "Task failed."}
21
+ RUN: Dict[str, Any] = {**DONE, "status": "running", "output": None, "execution_ms": None}
22
+ ASYNC_R: Dict[str, Any] = {"id": "tsk_asyncid123456789012345678", "model": "sam-1",
23
+ "status": "running", "created_at": "2026-04-29T00:00:00Z"}
24
+ MODELS: Dict[str, Any] = {"models": [
25
+ {"id": "sam-1", "name": "SAM 1", "description": "Default", "max_nodes": 10},
26
+ {"id": "sam-1-fast", "name": "SAM 1 Fast", "description": "Fast", "max_nodes": 5},
27
+ {"id": "sam-1-pro", "name": "SAM 1 Pro", "description": "Pro", "max_nodes": 20},
28
+ ], "default": "sam-1"}
29
+
30
+ class TestClient:
31
+ def test_requires_api_key(self):
32
+ with pytest.raises(AuthenticationError): Client()
33
+
34
+ def test_env_variable(self, monkeypatch):
35
+ monkeypatch.setenv("QUBAC_API_KEY", "sk-env")
36
+ c = Client(); assert c._http._api_key == "sk-env"; c.close()
37
+
38
+ def test_repr(self):
39
+ c = Client(api_key=KEY, base_url=BASE)
40
+ assert "qubac.Client" in repr(c); c.close()
41
+
42
+ def test_context_manager(self):
43
+ with Client(api_key=KEY, base_url=BASE) as c:
44
+ assert c._http is not None
45
+
46
+ @respx.mock
47
+ def test_create_success(self):
48
+ respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(200, json=DONE))
49
+ with Client(api_key=KEY, base_url=BASE) as c:
50
+ r = c.tasks.create(model="sam-1", task="test")
51
+ assert isinstance(r, TaskResponse)
52
+ assert r.status == "completed"
53
+ assert r.output == "The speed of light is 299,792,458 m/s."
54
+ assert r.nodes_used == 1
55
+
56
+ @respx.mock
57
+ def test_create_raises_failed(self):
58
+ respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(200, json=FAIL))
59
+ with Client(api_key=KEY, base_url=BASE) as c:
60
+ with pytest.raises(TaskFailedError) as e:
61
+ c.tasks.create(model="sam-1", task="test")
62
+ assert e.value.task_id == FAIL["id"]
63
+
64
+ @respx.mock
65
+ def test_create_raises_timeout(self):
66
+ respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(200, json=RUN))
67
+ with Client(api_key=KEY, base_url=BASE) as c:
68
+ with pytest.raises(TaskTimeoutError) as e:
69
+ c.tasks.create(model="sam-1", task="test", timeout=30)
70
+ assert e.value.timeout == 30
71
+
72
+ @respx.mock
73
+ def test_submit(self):
74
+ respx.post(f"{BASE}/v1/sam/tasks/async").mock(return_value=httpx.Response(200, json=ASYNC_R))
75
+ with Client(api_key=KEY, base_url=BASE) as c:
76
+ r = c.tasks.submit(model="sam-1", task="test")
77
+ assert isinstance(r, TaskAsyncResponse)
78
+ assert r.status == "running"
79
+
80
+ @respx.mock
81
+ def test_get(self):
82
+ respx.get(f"{BASE}/v1/sam/tasks/{DONE['id']}").mock(return_value=httpx.Response(200, json=DONE))
83
+ with Client(api_key=KEY, base_url=BASE) as c:
84
+ r = c.tasks.get(DONE["id"])
85
+ assert r.status == "completed"
86
+
87
+ @respx.mock
88
+ def test_list_models(self):
89
+ respx.get(f"{BASE}/v1/sam/models").mock(return_value=httpx.Response(200, json=MODELS))
90
+ with Client(api_key=KEY, base_url=BASE) as c:
91
+ models = c.tasks.list_models()
92
+ assert len(models) == 3
93
+ assert models[0].id == "sam-1"
94
+
95
+ @respx.mock
96
+ def test_with_options(self):
97
+ respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(200, json=DONE))
98
+ with Client(api_key=KEY, base_url=BASE) as c:
99
+ r = c.with_options(timeout=60).tasks.create(model="sam-1", task="test")
100
+ assert r.status == "completed"
101
+
102
+ class TestErrors:
103
+ @respx.mock
104
+ def test_auth_error(self):
105
+ respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(401, json={"detail": "Invalid key"}))
106
+ with Client(api_key=KEY, base_url=BASE) as c:
107
+ with pytest.raises(AuthenticationError) as e: c.tasks.create(model="sam-1", task="test")
108
+ assert e.value.status_code == 401
109
+
110
+ @respx.mock
111
+ def test_rate_limit(self):
112
+ respx.post(f"{BASE}/v1/sam/tasks").mock(
113
+ return_value=httpx.Response(429, json={"detail": "Exhausted", "reset_at": "2026-04-30T00:00:00Z"}))
114
+ with Client(api_key=KEY, base_url=BASE) as c:
115
+ with pytest.raises(RateLimitError) as e: c.tasks.create(model="sam-1", task="test")
116
+ assert e.value.reset_at == "2026-04-30T00:00:00Z"
117
+
118
+ @respx.mock
119
+ def test_retries_on_500(self):
120
+ calls = 0
121
+ def handler(req):
122
+ nonlocal calls; calls += 1
123
+ return httpx.Response(200, json=DONE) if calls >= 3 else httpx.Response(500, json={"detail": "err"})
124
+ respx.post(f"{BASE}/v1/sam/tasks").mock(side_effect=handler)
125
+ with Client(api_key=KEY, base_url=BASE, max_retries=2) as c:
126
+ r = c.tasks.create(model="sam-1", task="test")
127
+ assert r.status == "completed"
128
+ assert calls == 3
129
+
130
+ @respx.mock
131
+ def test_metadata_passthrough(self):
132
+ meta = {"user_id": "u1", "source": "test"}
133
+ respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(200, json={**DONE, "metadata": meta}))
134
+ with Client(api_key=KEY, base_url=BASE) as c:
135
+ r = c.tasks.create(model="sam-1", task="test", metadata=meta)
136
+ assert r.metadata == meta
137
+
138
+ class TestAsync:
139
+ @pytest.mark.asyncio
140
+ @respx.mock
141
+ async def test_create(self):
142
+ respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(200, json=DONE))
143
+ async with AsyncClient(api_key=KEY, base_url=BASE) as c:
144
+ r = await c.tasks.create(model="sam-1", task="test")
145
+ assert r.status == "completed"
146
+
147
+ @pytest.mark.asyncio
148
+ @respx.mock
149
+ async def test_parallel(self):
150
+ import asyncio
151
+ respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(200, json=DONE))
152
+ async with AsyncClient(api_key=KEY, base_url=BASE) as c:
153
+ results = await asyncio.gather(
154
+ c.tasks.create(model="sam-1", task="t1"),
155
+ c.tasks.create(model="sam-1", task="t2"),
156
+ c.tasks.create(model="sam-1", task="t3"),
157
+ )
158
+ assert all(r.status == "completed" for r in results)
159
+
160
+ class TestStreaming:
161
+ @respx.mock
162
+ def test_stream(self):
163
+ task_id = "tsk_streamtest1234567890123456"
164
+ evts = [
165
+ {"type": "task.created", "task_id": task_id, "summary": "Received"},
166
+ {"type": "task.planned", "task_id": task_id, "summary": "Planned"},
167
+ {"type": "node.started", "task_id": task_id, "summary": "", "node": "Direct response"},
168
+ {"type": "node.completed", "task_id": task_id, "summary": "", "node": "Direct response"},
169
+ {"type": "task.completed", "task_id": task_id, "summary": "", "output": "42", "nodes_used": 1},
170
+ {"type": "stream.end", "task_id": task_id, "summary": ""},
171
+ ]
172
+ body = "\n".join(f"data: {json.dumps(e)}\n" for e in evts)
173
+ respx.get(f"{BASE}/v1/sam/tasks/{task_id}/stream").mock(
174
+ return_value=httpx.Response(200, text=body, headers={"content-type": "text/event-stream"}))
175
+ with qubac.Client(api_key=KEY, base_url=BASE) as c:
176
+ received = []
177
+ with c.tasks.stream(task_id) as s:
178
+ for e in s: received.append(e)
179
+ assert received[-1].type == "task.completed"
180
+ assert received[-1].output == "42"
181
+ assert s.final_response is not None
182
+ assert s.final_response.status == "completed"
183
+
184
+ class TestTypes:
185
+ def test_frozen(self):
186
+ r = TaskResponse(id="tsk_x", model="sam-1", status="completed",
187
+ output="hi", nodes_used=1, execution_ms=100, created_at="")
188
+ with pytest.raises(Exception): r.output = "changed" # type: ignore
189
+
190
+ def test_repr(self):
191
+ r = TaskResponse(id="tsk_x", model="sam-1", status="completed",
192
+ output="hello world", nodes_used=1, execution_ms=100, created_at="")
193
+ assert "TaskResponse" in repr(r) and "completed" in repr(r)
194
+
195
+ def test_version(self):
196
+ assert qubac.__version__ == "1.0.0"