haaslab 0.1.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.
@@ -0,0 +1,3 @@
1
+ dist/
2
+ __pycache__/
3
+ .pytest_cache/
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ ## v0.1.0 (2026-10-13)
4
+
5
+ Initial release of the `haaslab` Python SDK, the first published to PyPI.
6
+
7
+ ### Added
8
+ - `Client` with `jobs` and `tokens` namespaces; `Client()` reads `HAASLAB_TOKEN` and `HAASLAB_BASE_URL` when `token` or `base_url` is not passed
9
+ - `jobs.create(firmware)` — multipart firmware upload
10
+ - `jobs.get(job_id)` — fetch job by ID
11
+ - `jobs.list(limit, page)` — paginated job listing
12
+ - `jobs.serial(job_id)` — full archived serial log
13
+ - `jobs.stream_serial(job_id)` — live SSE serial stream iterator
14
+ - `jobs.wait(job_id, timeout)` — poll until terminal status
15
+ - `tokens.list()` — list PATs (no plaintext)
16
+ - `tokens.create(name)` — create PAT (plaintext shown once)
17
+ - `tokens.revoke(id)` — revoke a PAT
18
+ - `HaasLabError`, `AuthError`, `NotFoundError`, `RateLimitError`, `ServerError` exceptions
19
+ - `Job`, `Token`, `CreatedToken`, `JobList`, `Pagination` dataclasses (synced from /api/v1/openapi.json)
haaslab-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.5
2
+ Name: haaslab
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the haas-lab hardware-as-a-service API
5
+ Project-URL: Homepage, https://haas-lab.com
6
+ Project-URL: Documentation, https://haas-lab.com/docs
7
+ License: MIT
8
+ Requires-Python: >=3.9
9
+ Requires-Dist: httpx-sse>=0.4.0
10
+ Requires-Dist: httpx>=0.27.0
11
+ Provides-Extra: dev
12
+ Requires-Dist: pytest-httpx>=0.30.0; extra == 'dev'
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # haaslab Python SDK
17
+
18
+ Python client for the [haas-lab](https://haas-lab.com) hardware-as-a-service API.
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install haaslab
24
+ ```
25
+
26
+ Requires Python 3.9+. You need a Personal Access Token: create one at
27
+ https://haas-lab.com/dashboard/tokens after signing in.
28
+
29
+ ## Quickstart
30
+
31
+ ```python
32
+ from haaslab import Client
33
+
34
+ c = Client(token="pat_xxx") # or set HAASLAB_TOKEN and call Client()
35
+ job = c.jobs.create("blink.bin") # multipart upload
36
+ job = c.jobs.wait(job.id, timeout=120) # poll until terminal
37
+ print(c.jobs.serial(job.id)) # full archived log
38
+ ```
39
+
40
+ ## Live streaming
41
+
42
+ ```python
43
+ for line in c.jobs.stream_serial(job.id):
44
+ print(line)
45
+ ```
46
+
47
+ ## Token management
48
+
49
+ ```python
50
+ tok = c.tokens.create("ci-bot")
51
+ print(tok.token) # plaintext PAT — shown once
52
+
53
+ c.tokens.list() # list all tokens
54
+ c.tokens.revoke(tok.id) # revoke a token
55
+ ```
56
+
57
+ ## API Reference
58
+
59
+ ### `Client(token, base_url, timeout)`
60
+
61
+ | Param | Default | Description |
62
+ |---|---|---|
63
+ | `token` | `$HAASLAB_TOKEN` | Personal Access Token (`pat_xxx`); `ValueError` if neither is set |
64
+ | `base_url` | `$HAASLAB_BASE_URL`, else `https://haas-lab.com` | Base URL of the server |
65
+ | `timeout` | `30.0` | HTTP request timeout in seconds |
66
+
67
+ ### `client.jobs`
68
+
69
+ | Method | Returns | Description |
70
+ |---|---|---|
71
+ | `create(firmware, name=None, *, profile_key=None, device_id=None, expected_runtime_s=None)` | `Job` | Upload a firmware `.bin` and queue it; `profile_key` asks for any board of that profile, `device_id` for one board, `expected_runtime_s` is the run time you book |
72
+ | `get(job_id)` | `Job` | Fetch job by ID |
73
+ | `list(limit=20, page=1)` | `list[Job]` | List jobs (paginated) |
74
+ | `serial(job_id)` | `str` | Full archived serial log |
75
+ | `stream_serial(job_id)` | `Iterator[str]` | Live SSE serial lines |
76
+ | `wait(job_id, timeout=120)` | `Job` | Poll until COMPLETED/FAILED/CANCELLED |
77
+
78
+ ### `client.tokens`
79
+
80
+ | Method | Returns | Description |
81
+ |---|---|---|
82
+ | `list()` | `list[Token]` | List PATs (no plaintext) |
83
+ | `create(name)` | `CreatedToken` | Create PAT (plaintext in `.token`, shown once) |
84
+ | `revoke(id)` | `None` | Revoke a PAT |
85
+
86
+ ### Exceptions
87
+
88
+ | Exception | HTTP status |
89
+ |---|---|
90
+ | `AuthError` | 401 |
91
+ | `NotFoundError` | 404 |
92
+ | `RateLimitError` | 429 |
93
+ | `ServerError` | 5xx |
94
+ | `HaasLabError` | base class / other 4xx |
95
+
96
+ ## Environment variables
97
+
98
+ `Client()` reads these when `token` or `base_url` is not passed:
99
+
100
+ ```bash
101
+ export HAASLAB_TOKEN=pat_xxx # your Personal Access Token
102
+ export HAASLAB_BASE_URL=http://localhost:3000 # optional; defaults to https://haas-lab.com
103
+ ```
104
+
105
+ ## Development
106
+
107
+ ```bash
108
+ pip install -e ".[dev]"
109
+ pytest -v
110
+ ```
@@ -0,0 +1,95 @@
1
+ # haaslab Python SDK
2
+
3
+ Python client for the [haas-lab](https://haas-lab.com) hardware-as-a-service API.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install haaslab
9
+ ```
10
+
11
+ Requires Python 3.9+. You need a Personal Access Token: create one at
12
+ https://haas-lab.com/dashboard/tokens after signing in.
13
+
14
+ ## Quickstart
15
+
16
+ ```python
17
+ from haaslab import Client
18
+
19
+ c = Client(token="pat_xxx") # or set HAASLAB_TOKEN and call Client()
20
+ job = c.jobs.create("blink.bin") # multipart upload
21
+ job = c.jobs.wait(job.id, timeout=120) # poll until terminal
22
+ print(c.jobs.serial(job.id)) # full archived log
23
+ ```
24
+
25
+ ## Live streaming
26
+
27
+ ```python
28
+ for line in c.jobs.stream_serial(job.id):
29
+ print(line)
30
+ ```
31
+
32
+ ## Token management
33
+
34
+ ```python
35
+ tok = c.tokens.create("ci-bot")
36
+ print(tok.token) # plaintext PAT — shown once
37
+
38
+ c.tokens.list() # list all tokens
39
+ c.tokens.revoke(tok.id) # revoke a token
40
+ ```
41
+
42
+ ## API Reference
43
+
44
+ ### `Client(token, base_url, timeout)`
45
+
46
+ | Param | Default | Description |
47
+ |---|---|---|
48
+ | `token` | `$HAASLAB_TOKEN` | Personal Access Token (`pat_xxx`); `ValueError` if neither is set |
49
+ | `base_url` | `$HAASLAB_BASE_URL`, else `https://haas-lab.com` | Base URL of the server |
50
+ | `timeout` | `30.0` | HTTP request timeout in seconds |
51
+
52
+ ### `client.jobs`
53
+
54
+ | Method | Returns | Description |
55
+ |---|---|---|
56
+ | `create(firmware, name=None, *, profile_key=None, device_id=None, expected_runtime_s=None)` | `Job` | Upload a firmware `.bin` and queue it; `profile_key` asks for any board of that profile, `device_id` for one board, `expected_runtime_s` is the run time you book |
57
+ | `get(job_id)` | `Job` | Fetch job by ID |
58
+ | `list(limit=20, page=1)` | `list[Job]` | List jobs (paginated) |
59
+ | `serial(job_id)` | `str` | Full archived serial log |
60
+ | `stream_serial(job_id)` | `Iterator[str]` | Live SSE serial lines |
61
+ | `wait(job_id, timeout=120)` | `Job` | Poll until COMPLETED/FAILED/CANCELLED |
62
+
63
+ ### `client.tokens`
64
+
65
+ | Method | Returns | Description |
66
+ |---|---|---|
67
+ | `list()` | `list[Token]` | List PATs (no plaintext) |
68
+ | `create(name)` | `CreatedToken` | Create PAT (plaintext in `.token`, shown once) |
69
+ | `revoke(id)` | `None` | Revoke a PAT |
70
+
71
+ ### Exceptions
72
+
73
+ | Exception | HTTP status |
74
+ |---|---|
75
+ | `AuthError` | 401 |
76
+ | `NotFoundError` | 404 |
77
+ | `RateLimitError` | 429 |
78
+ | `ServerError` | 5xx |
79
+ | `HaasLabError` | base class / other 4xx |
80
+
81
+ ## Environment variables
82
+
83
+ `Client()` reads these when `token` or `base_url` is not passed:
84
+
85
+ ```bash
86
+ export HAASLAB_TOKEN=pat_xxx # your Personal Access Token
87
+ export HAASLAB_BASE_URL=http://localhost:3000 # optional; defaults to https://haas-lab.com
88
+ ```
89
+
90
+ ## Development
91
+
92
+ ```bash
93
+ pip install -e ".[dev]"
94
+ pytest -v
95
+ ```
@@ -0,0 +1,26 @@
1
+ """Quickstart: upload firmware, wait for completion, print serial log."""
2
+ import os
3
+ import sys
4
+ from haaslab import Client
5
+
6
+ token = os.environ.get("HAASLAB_TOKEN")
7
+ if not token:
8
+ print("Set HAASLAB_TOKEN environment variable", file=sys.stderr)
9
+ sys.exit(1)
10
+
11
+ base_url = os.environ.get("HAASLAB_BASE_URL", "https://haas-lab.com")
12
+
13
+ c = Client(token=token, base_url=base_url)
14
+
15
+ firmware_path = sys.argv[1] if len(sys.argv) > 1 else "blink.bin"
16
+
17
+ job = c.jobs.create(firmware_path)
18
+ print(f"Created job {job.id} (status: {job.status})")
19
+
20
+ job = c.jobs.wait(job.id, timeout=120)
21
+ print(f"Job finished with status: {job.status}")
22
+
23
+ if job.status == "COMPLETED":
24
+ log = c.jobs.serial(job.id)
25
+ print("--- Serial log ---")
26
+ print(log)
@@ -0,0 +1,23 @@
1
+ """Stream live serial output from a running job via SSE."""
2
+ import os
3
+ import sys
4
+ from haaslab import Client
5
+
6
+ token = os.environ.get("HAASLAB_TOKEN")
7
+ if not token:
8
+ print("Set HAASLAB_TOKEN environment variable", file=sys.stderr)
9
+ sys.exit(1)
10
+
11
+ base_url = os.environ.get("HAASLAB_BASE_URL", "https://haas-lab.com")
12
+
13
+ if len(sys.argv) < 2:
14
+ print("Usage: python 02_stream_serial.py <job-id>", file=sys.stderr)
15
+ sys.exit(1)
16
+
17
+ job_id = sys.argv[1]
18
+ c = Client(token=token, base_url=base_url)
19
+
20
+ print(f"Streaming serial output for job {job_id}...")
21
+ for line in c.jobs.stream_serial(job_id):
22
+ print(line)
23
+ print("Stream ended.")
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "haaslab"
7
+ version = "0.1.0"
8
+ description = "Python SDK for the haas-lab hardware-as-a-service API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ dependencies = [
13
+ "httpx>=0.27.0",
14
+ "httpx-sse>=0.4.0",
15
+ ]
16
+
17
+ [project.urls]
18
+ Homepage = "https://haas-lab.com"
19
+ Documentation = "https://haas-lab.com/docs"
20
+
21
+ [project.optional-dependencies]
22
+ dev = [
23
+ "pytest>=8.0",
24
+ "pytest-httpx>=0.30.0",
25
+ ]
26
+
27
+ # Only the SDK's own files go into the sdist. Hatchling also always copies the
28
+ # nearest .gitignore into it, which is why this folder has its own: without it,
29
+ # the published sdist carried the repository's root .gitignore (HAAS-388).
30
+ [tool.hatch.build.targets.sdist]
31
+ only-include = ["src", "tests", "examples", "README.md", "CHANGELOG.md"]
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/haaslab"]
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
@@ -0,0 +1,19 @@
1
+ from .client import Client
2
+ from .models import Job, Token, CreatedToken, JobList, Pagination
3
+ from .exceptions import HaasLabError, AuthError, NotFoundError, RateLimitError, ServerError
4
+
5
+ __all__ = [
6
+ "Client",
7
+ "Job",
8
+ "Token",
9
+ "CreatedToken",
10
+ "JobList",
11
+ "Pagination",
12
+ "HaasLabError",
13
+ "AuthError",
14
+ "NotFoundError",
15
+ "RateLimitError",
16
+ "ServerError",
17
+ ]
18
+
19
+ __version__ = "0.1.0"
@@ -0,0 +1,56 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ import httpx
6
+
7
+ from .jobs import JobsNamespace
8
+ from .tokens import TokensNamespace
9
+
10
+ DEFAULT_BASE_URL = "https://haas-lab.com"
11
+ _SDK_VERSION = "0.1.0"
12
+
13
+
14
+ class Client:
15
+ """haas-lab API client.
16
+
17
+ Usage::
18
+
19
+ from haaslab import Client
20
+ c = Client(token="pat_xxx")
21
+ job = c.jobs.create("blink.bin")
22
+ job = c.jobs.wait(job.id)
23
+ print(job.serial) # not on Job; call c.jobs.serial(job.id)
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ token: str | None = None,
29
+ base_url: str | None = None,
30
+ timeout: float = 30.0,
31
+ ) -> None:
32
+ # The docs page and the README promise these two variables (HAAS-389).
33
+ token = token or os.environ.get("HAASLAB_TOKEN")
34
+ if not token:
35
+ raise ValueError("no token: pass Client(token=...) or set HAASLAB_TOKEN")
36
+ base_url = base_url or os.environ.get("HAASLAB_BASE_URL") or DEFAULT_BASE_URL
37
+ api_base = base_url.rstrip("/") + "/api/v1"
38
+ self._http = httpx.Client(
39
+ base_url=api_base,
40
+ headers={
41
+ "Authorization": f"Bearer {token}",
42
+ "User-Agent": f"haaslab-python/{_SDK_VERSION}",
43
+ },
44
+ timeout=timeout,
45
+ )
46
+ self.jobs = JobsNamespace(self._http)
47
+ self.tokens = TokensNamespace(self._http)
48
+
49
+ def close(self) -> None:
50
+ self._http.close()
51
+
52
+ def __enter__(self) -> "Client":
53
+ return self
54
+
55
+ def __exit__(self, *_) -> None:
56
+ self.close()
@@ -0,0 +1,20 @@
1
+ class HaasLabError(Exception):
2
+ def __init__(self, message: str, status_code: int | None = None):
3
+ super().__init__(message)
4
+ self.status_code = status_code
5
+
6
+
7
+ class AuthError(HaasLabError):
8
+ """401 Unauthorized — invalid or missing PAT."""
9
+
10
+
11
+ class NotFoundError(HaasLabError):
12
+ """404 Not Found."""
13
+
14
+
15
+ class RateLimitError(HaasLabError):
16
+ """429 Too Many Requests."""
17
+
18
+
19
+ class ServerError(HaasLabError):
20
+ """5xx Server Error."""
@@ -0,0 +1,117 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Iterator, Union
6
+
7
+ import httpx
8
+
9
+ from .models import Job, JobList, Pagination, TERMINAL_STATUSES
10
+ from .exceptions import HaasLabError
11
+
12
+
13
+ class JobsNamespace:
14
+ def __init__(self, http: httpx.Client) -> None:
15
+ self._http = http
16
+
17
+ def create(
18
+ self,
19
+ firmware: Union[str, Path, bytes],
20
+ name: str | None = None,
21
+ *,
22
+ profile_key: str | None = None,
23
+ device_id: str | None = None,
24
+ expected_runtime_s: int | None = None,
25
+ ) -> Job:
26
+ """Submit a firmware and book its window.
27
+
28
+ ``expected_runtime_s`` is the run time you book; the job's booked window
29
+ is that plus the profile's flash allowance, and it is what the escrow
30
+ is reserved against. ``profile_key`` asks for any board of that
31
+ profile, ``device_id`` for one board in particular; neither means any
32
+ board that is online. Returns the Job in QUEUED status.
33
+ """
34
+ if isinstance(firmware, (str, Path)):
35
+ path = Path(firmware)
36
+ data = path.read_bytes()
37
+ filename = path.name
38
+ else:
39
+ data = firmware
40
+ filename = name or "firmware.bin"
41
+
42
+ files = {"firmware": (filename, data, "application/octet-stream")}
43
+ fields: dict[str, str] = {}
44
+ if profile_key is not None:
45
+ fields["profile_key"] = profile_key
46
+ if device_id is not None:
47
+ fields["device_id"] = device_id
48
+ if expected_runtime_s is not None:
49
+ fields["expected_runtime_s"] = str(expected_runtime_s)
50
+ resp = self._http.post("/jobs", files=files, data=fields)
51
+ _raise_for_status(resp)
52
+ return Job.from_dict(resp.json())
53
+
54
+ def get(self, job_id: str) -> Job:
55
+ resp = self._http.get(f"/jobs/{job_id}")
56
+ _raise_for_status(resp)
57
+ return Job.from_dict(resp.json())
58
+
59
+ def list(self, limit: int = 20, page: int = 1) -> list[Job]:
60
+ resp = self._http.get("/jobs", params={"page": page})
61
+ _raise_for_status(resp)
62
+ body = resp.json()
63
+ jobs = [Job.from_dict(j) for j in body["data"]]
64
+ return jobs[:limit]
65
+
66
+ def serial(self, job_id: str) -> str:
67
+ """Return the full archived serial log as a string."""
68
+ resp = self._http.get(f"/jobs/{job_id}/serial")
69
+ _raise_for_status(resp)
70
+ return resp.text
71
+
72
+ def stream_serial(self, job_id: str) -> Iterator[str]:
73
+ """Yield lines from the live SSE serial stream."""
74
+ from httpx_sse import connect_sse
75
+
76
+ with connect_sse(self._http, "GET", f"/jobs/{job_id}/stream") as event_source:
77
+ for event in event_source.iter_sse():
78
+ if event.event == "done":
79
+ break
80
+ if event.data:
81
+ yield event.data
82
+
83
+ def wait(self, job_id: str, timeout: float = 120, poll_interval: float = 2.0) -> Job:
84
+ """Poll until the job reaches a terminal status or timeout expires."""
85
+ deadline = time.monotonic() + timeout
86
+ while True:
87
+ job = self.get(job_id)
88
+ if job.is_terminal:
89
+ return job
90
+ remaining = deadline - time.monotonic()
91
+ if remaining <= 0:
92
+ raise HaasLabError(
93
+ f"Job {job_id} did not reach terminal status within {timeout}s "
94
+ f"(current status: {job.status})"
95
+ )
96
+ time.sleep(min(poll_interval, remaining))
97
+
98
+
99
+ def _raise_for_status(resp: httpx.Response) -> None:
100
+ from .exceptions import AuthError, NotFoundError, RateLimitError, ServerError
101
+
102
+ if resp.status_code < 400:
103
+ return
104
+ try:
105
+ msg = resp.json().get("error", resp.text)
106
+ except Exception:
107
+ msg = resp.text
108
+
109
+ if resp.status_code == 401:
110
+ raise AuthError(msg, resp.status_code)
111
+ if resp.status_code == 404:
112
+ raise NotFoundError(msg, resp.status_code)
113
+ if resp.status_code == 429:
114
+ raise RateLimitError(msg, resp.status_code)
115
+ if resp.status_code >= 500:
116
+ raise ServerError(msg, resp.status_code)
117
+ raise HaasLabError(msg, resp.status_code)
@@ -0,0 +1,145 @@
1
+ # synced from /api/v1/openapi.json
2
+ from __future__ import annotations
3
+ from dataclasses import dataclass
4
+ from typing import Literal, Optional
5
+
6
+
7
+ # Every status the server can answer with, from the schema's JobStatus enum;
8
+ # the six the API document used to list were a subset, and SETTLED — the
9
+ # state a successful job actually ends in, written by settlement in the same
10
+ # request as completion — was not among them, so `wait` never returned on
11
+ # success (review, 2026-09-05).
12
+ JobStatus = Literal[
13
+ "DRAFT",
14
+ "STATIC_CHECK",
15
+ "STATIC_CHECK_FAILED",
16
+ "ESTIMATED",
17
+ "ESCROW_HELD",
18
+ "QUEUED",
19
+ "CLAIMED",
20
+ "FLASHING",
21
+ "RUNNING",
22
+ "COMPLETED",
23
+ "FAILED",
24
+ "CANCELLED",
25
+ "DAMAGE_REVIEW",
26
+ "SETTLED",
27
+ ]
28
+
29
+ # Nothing moves a job out of these. COMPLETED is included because a job that
30
+ # has no escrow to settle stays there; SETTLED is where a settled one ends.
31
+ TERMINAL_STATUSES = {"COMPLETED", "FAILED", "CANCELLED", "SETTLED", "STATIC_CHECK_FAILED"}
32
+
33
+
34
+ @dataclass
35
+ class Job:
36
+ id: str
37
+ userId: str
38
+ status: JobStatus
39
+ firmwareUrl: str
40
+ firmwareSize: int
41
+ serialLogUrl: Optional[str]
42
+ createdAt: str
43
+ startedAt: Optional[str]
44
+ completedAt: Optional[str]
45
+ # One-based place among the jobs waiting for the same boards while the job is
46
+ # QUEUED; None in every other status, and None from a server older than the
47
+ # field. Jobs are taken oldest first, so it only falls, except when a job
48
+ # created earlier is confirmed late or requeued after a board failed it.
49
+ queuePosition: Optional[int] = None
50
+ # The booked window (HAAS-20): the board seconds chosen at submit, the
51
+ # moment the window closes once the job is on a board, and why it ended.
52
+ # None from a server older than the fields, and for jobs from before them.
53
+ bookedSeconds: Optional[int] = None
54
+ expiresAt: Optional[str] = None
55
+ endedReason: Optional[str] = None
56
+
57
+ @classmethod
58
+ def from_dict(cls, d: dict) -> "Job":
59
+ return cls(
60
+ id=d["id"],
61
+ userId=d["userId"],
62
+ status=d["status"],
63
+ firmwareUrl=d["firmwareUrl"],
64
+ firmwareSize=d["firmwareSize"],
65
+ serialLogUrl=d.get("serialLogUrl"),
66
+ createdAt=d["createdAt"],
67
+ startedAt=d.get("startedAt"),
68
+ completedAt=d.get("completedAt"),
69
+ queuePosition=d.get("queuePosition"),
70
+ bookedSeconds=d.get("bookedSeconds"),
71
+ expiresAt=d.get("expiresAt"),
72
+ endedReason=d.get("endedReason"),
73
+ )
74
+
75
+ @property
76
+ def is_terminal(self) -> bool:
77
+ return self.status in TERMINAL_STATUSES
78
+
79
+ @property
80
+ def is_queued(self) -> bool:
81
+ return self.status == "QUEUED"
82
+
83
+
84
+ @dataclass
85
+ class Token:
86
+ id: str
87
+ name: str
88
+ createdAt: str
89
+ lastUsedAt: Optional[str]
90
+ revokedAt: Optional[str]
91
+
92
+ @classmethod
93
+ def from_dict(cls, d: dict) -> "Token":
94
+ return cls(
95
+ id=d["id"],
96
+ name=d["name"],
97
+ createdAt=d["createdAt"],
98
+ lastUsedAt=d.get("lastUsedAt"),
99
+ revokedAt=d.get("revokedAt"),
100
+ )
101
+
102
+
103
+ @dataclass
104
+ class CreatedToken:
105
+ """Returned once on token creation — includes the plaintext PAT."""
106
+ id: str
107
+ name: str
108
+ createdAt: str
109
+ lastUsedAt: Optional[str]
110
+ revokedAt: Optional[str]
111
+ token: str
112
+
113
+ @classmethod
114
+ def from_dict(cls, d: dict) -> "CreatedToken":
115
+ return cls(
116
+ id=d["id"],
117
+ name=d["name"],
118
+ createdAt=d["createdAt"],
119
+ lastUsedAt=d.get("lastUsedAt"),
120
+ revokedAt=d.get("revokedAt"),
121
+ token=d["token"],
122
+ )
123
+
124
+
125
+ @dataclass
126
+ class Pagination:
127
+ page: int
128
+ pageSize: int
129
+ total: int
130
+ totalPages: int
131
+
132
+ @classmethod
133
+ def from_dict(cls, d: dict) -> "Pagination":
134
+ return cls(
135
+ page=d["page"],
136
+ pageSize=d["pageSize"],
137
+ total=d["total"],
138
+ totalPages=d["totalPages"],
139
+ )
140
+
141
+
142
+ @dataclass
143
+ class JobList:
144
+ data: list[Job]
145
+ meta: Pagination
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ import httpx
4
+
5
+ from .models import Token, CreatedToken
6
+ from .jobs import _raise_for_status
7
+
8
+
9
+ class TokensNamespace:
10
+ def __init__(self, http: httpx.Client) -> None:
11
+ self._http = http
12
+
13
+ def list(self) -> list[Token]:
14
+ resp = self._http.get("/tokens")
15
+ _raise_for_status(resp)
16
+ return [Token.from_dict(t) for t in resp.json()]
17
+
18
+ def create(self, name: str) -> CreatedToken:
19
+ """Create a new PAT. The plaintext token is returned once in CreatedToken.token."""
20
+ resp = self._http.post("/tokens", json={"name": name})
21
+ _raise_for_status(resp)
22
+ return CreatedToken.from_dict(resp.json())
23
+
24
+ def revoke(self, token_id: str) -> None:
25
+ resp = self._http.delete(f"/tokens/{token_id}")
26
+ _raise_for_status(resp)
@@ -0,0 +1,228 @@
1
+ """Tests for haaslab SDK using httpx mock transport."""
2
+ import pytest
3
+ import httpx
4
+ from haaslab import Client, Job, Token, CreatedToken
5
+ from haaslab.exceptions import AuthError, NotFoundError, ServerError, HaasLabError
6
+
7
+ # Fixtures for canonical API shapes (synced from /api/v1/openapi.json)
8
+ JOB_QUEUED = {
9
+ "id": "job_abc123",
10
+ "userId": "user_1",
11
+ "status": "QUEUED",
12
+ "firmwareUrl": "jobs/job_abc123/firmware.bin",
13
+ "firmwareSize": 42000,
14
+ "serialLogUrl": None,
15
+ "createdAt": "2024-01-01T00:00:00Z",
16
+ "startedAt": None,
17
+ "completedAt": None,
18
+ }
19
+
20
+ JOB_COMPLETED = {**JOB_QUEUED, "status": "COMPLETED", "serialLogUrl": "jobs/job_abc123/serial.log", "completedAt": "2024-01-01T00:01:00Z"}
21
+
22
+ TOKEN = {
23
+ "id": "tok_1",
24
+ "name": "my-token",
25
+ "createdAt": "2024-01-01T00:00:00Z",
26
+ "lastUsedAt": None,
27
+ "revokedAt": None,
28
+ }
29
+
30
+ CREATED_TOKEN = {**TOKEN, "token": "pat_deadbeef" * 2}
31
+
32
+
33
+ def make_client(handler) -> Client:
34
+ transport = httpx.MockTransport(handler)
35
+ c = Client.__new__(Client)
36
+ c._http = httpx.Client(
37
+ base_url="http://localhost:3000/api/v1",
38
+ headers={"Authorization": "Bearer pat_test"},
39
+ transport=transport,
40
+ )
41
+ from haaslab.jobs import JobsNamespace
42
+ from haaslab.tokens import TokensNamespace
43
+ c.jobs = JobsNamespace(c._http)
44
+ c.tokens = TokensNamespace(c._http)
45
+ return c
46
+
47
+
48
+ # ── jobs.create ──────────────────────────────────────────────────────────────
49
+
50
+ def test_jobs_create_multipart():
51
+ def handler(request: httpx.Request) -> httpx.Response:
52
+ assert request.method == "POST"
53
+ assert "/jobs" in str(request.url)
54
+ assert "multipart/form-data" in request.headers.get("content-type", "")
55
+ return httpx.Response(201, json=JOB_QUEUED)
56
+
57
+ c = make_client(handler)
58
+ job = c.jobs.create(b"\x00\x01\x02", name="blink.bin")
59
+ assert isinstance(job, Job)
60
+ assert job.id == "job_abc123"
61
+ assert job.status == "QUEUED"
62
+
63
+
64
+ # ── jobs.get ─────────────────────────────────────────────────────────────────
65
+
66
+ def test_jobs_get():
67
+ def handler(request: httpx.Request) -> httpx.Response:
68
+ assert request.method == "GET"
69
+ assert "/jobs/job_abc123" in str(request.url)
70
+ return httpx.Response(200, json=JOB_QUEUED)
71
+
72
+ c = make_client(handler)
73
+ job = c.jobs.get("job_abc123")
74
+ assert job.id == "job_abc123"
75
+
76
+
77
+ def test_jobs_get_not_found():
78
+ def handler(request: httpx.Request) -> httpx.Response:
79
+ return httpx.Response(404, json={"error": "not found"})
80
+
81
+ c = make_client(handler)
82
+ with pytest.raises(NotFoundError):
83
+ c.jobs.get("missing")
84
+
85
+
86
+ # ── jobs.list ─────────────────────────────────────────────────────────────────
87
+
88
+ def test_jobs_list():
89
+ def handler(request: httpx.Request) -> httpx.Response:
90
+ assert "page=1" in str(request.url)
91
+ return httpx.Response(200, json={
92
+ "data": [JOB_QUEUED],
93
+ "meta": {"page": 1, "pageSize": 20, "total": 1, "totalPages": 1},
94
+ })
95
+
96
+ c = make_client(handler)
97
+ jobs = c.jobs.list()
98
+ assert len(jobs) == 1
99
+ assert jobs[0].status == "QUEUED"
100
+
101
+
102
+ # ── jobs.serial ───────────────────────────────────────────────────────────────
103
+
104
+ def test_jobs_serial():
105
+ def handler(request: httpx.Request) -> httpx.Response:
106
+ assert "/jobs/job_abc123/serial" in str(request.url)
107
+ return httpx.Response(200, text="Hello World\nDone\n", headers={"content-type": "text/plain"})
108
+
109
+ c = make_client(handler)
110
+ log = c.jobs.serial("job_abc123")
111
+ assert "Hello World" in log
112
+
113
+
114
+ # ── jobs.wait ─────────────────────────────────────────────────────────────────
115
+
116
+ def test_jobs_wait_already_terminal():
117
+ def handler(request: httpx.Request) -> httpx.Response:
118
+ return httpx.Response(200, json=JOB_COMPLETED)
119
+
120
+ c = make_client(handler)
121
+ job = c.jobs.wait("job_abc123", timeout=10)
122
+ assert job.status == "COMPLETED"
123
+ assert job.is_terminal
124
+
125
+
126
+ def test_jobs_wait_timeout():
127
+ call_count = 0
128
+
129
+ def handler(request: httpx.Request) -> httpx.Response:
130
+ nonlocal call_count
131
+ call_count += 1
132
+ return httpx.Response(200, json=JOB_QUEUED) # always QUEUED
133
+
134
+ c = make_client(handler)
135
+ with pytest.raises(HaasLabError, match="did not reach terminal"):
136
+ c.jobs.wait("job_abc123", timeout=0.01, poll_interval=0.001)
137
+
138
+
139
+ # ── tokens ───────────────────────────────────────────────────────────────────
140
+
141
+ def test_tokens_list():
142
+ def handler(request: httpx.Request) -> httpx.Response:
143
+ assert request.method == "GET"
144
+ assert "/tokens" in str(request.url)
145
+ return httpx.Response(200, json=[TOKEN])
146
+
147
+ c = make_client(handler)
148
+ tokens = c.tokens.list()
149
+ assert len(tokens) == 1
150
+ assert isinstance(tokens[0], Token)
151
+ assert tokens[0].name == "my-token"
152
+
153
+
154
+ def test_tokens_create():
155
+ def handler(request: httpx.Request) -> httpx.Response:
156
+ assert request.method == "POST"
157
+ assert "/tokens" in str(request.url)
158
+ import json
159
+ body = json.loads(request.content)
160
+ assert body["name"] == "ci"
161
+ return httpx.Response(201, json=CREATED_TOKEN)
162
+
163
+ c = make_client(handler)
164
+ tok = c.tokens.create("ci")
165
+ assert isinstance(tok, CreatedToken)
166
+ assert tok.token.startswith("pat_")
167
+
168
+
169
+ def test_tokens_revoke():
170
+ def handler(request: httpx.Request) -> httpx.Response:
171
+ assert request.method == "DELETE"
172
+ assert "/tokens/tok_1" in str(request.url)
173
+ return httpx.Response(204)
174
+
175
+ c = make_client(handler)
176
+ c.tokens.revoke("tok_1") # no exception = success
177
+
178
+
179
+ # ── auth errors ───────────────────────────────────────────────────────────────
180
+
181
+ def test_auth_error_on_401():
182
+ def handler(request: httpx.Request) -> httpx.Response:
183
+ return httpx.Response(401, json={"error": "unauthorized"})
184
+
185
+ c = make_client(handler)
186
+ with pytest.raises(AuthError):
187
+ c.jobs.list()
188
+
189
+
190
+ def test_server_error_on_500():
191
+ def handler(request: httpx.Request) -> httpx.Response:
192
+ return httpx.Response(500, json={"error": "internal server error"})
193
+
194
+ c = make_client(handler)
195
+ with pytest.raises(ServerError):
196
+ c.jobs.list()
197
+
198
+
199
+ # ── configuration from the environment (HAAS-389) ────────────────────────────
200
+
201
+ def test_client_reads_token_from_haaslab_token(monkeypatch):
202
+ monkeypatch.setenv("HAASLAB_TOKEN", "pat_from_env")
203
+ c = Client()
204
+ assert c._http.headers["Authorization"] == "Bearer pat_from_env"
205
+
206
+
207
+ def test_explicit_token_wins_over_haaslab_token(monkeypatch):
208
+ monkeypatch.setenv("HAASLAB_TOKEN", "pat_from_env")
209
+ c = Client(token="pat_explicit")
210
+ assert c._http.headers["Authorization"] == "Bearer pat_explicit"
211
+
212
+
213
+ def test_client_without_any_token_raises_naming_the_variable(monkeypatch):
214
+ monkeypatch.delenv("HAASLAB_TOKEN", raising=False)
215
+ with pytest.raises(ValueError, match="HAASLAB_TOKEN"):
216
+ Client()
217
+
218
+
219
+ def test_client_reads_base_url_from_haaslab_base_url(monkeypatch):
220
+ monkeypatch.setenv("HAASLAB_BASE_URL", "http://localhost:3000/")
221
+ c = Client(token="pat_test")
222
+ assert str(c._http.base_url) == "http://localhost:3000/api/v1/"
223
+
224
+
225
+ def test_client_defaults_to_haas_lab_com(monkeypatch):
226
+ monkeypatch.delenv("HAASLAB_BASE_URL", raising=False)
227
+ c = Client(token="pat_test")
228
+ assert str(c._http.base_url) == "https://haas-lab.com/api/v1/"
@@ -0,0 +1,44 @@
1
+ """The Job model, against what the v1 API sends (HAAS-141)."""
2
+
3
+ from haaslab.models import Job
4
+
5
+ JOB = {
6
+ "id": "job-1",
7
+ "userId": "user-1",
8
+ "status": "QUEUED",
9
+ "firmwareUrl": "jobs/job-1/firmware.bin",
10
+ "firmwareSize": 4096,
11
+ "serialLogUrl": None,
12
+ "createdAt": "2026-09-04T08:00:00.000Z",
13
+ "startedAt": None,
14
+ "completedAt": None,
15
+ }
16
+
17
+
18
+ def test_a_queued_job_carries_its_place_in_line() -> None:
19
+ job = Job.from_dict({**JOB, "queuePosition": 3})
20
+ assert job.queuePosition == 3
21
+ assert job.is_queued
22
+ assert not job.is_terminal
23
+
24
+
25
+ def test_a_job_that_is_no_longer_waiting_has_no_position() -> None:
26
+ job = Job.from_dict({**JOB, "status": "RUNNING", "queuePosition": None})
27
+ assert job.queuePosition is None
28
+ assert not job.is_queued
29
+
30
+
31
+ def test_a_server_older_than_the_field_is_read_as_no_position() -> None:
32
+ # The SDK must keep working against a server that does not send it.
33
+ job = Job.from_dict(JOB)
34
+ assert job.queuePosition is None
35
+
36
+
37
+ def test_a_settled_job_is_terminal_so_wait_returns_on_success() -> None:
38
+ # SETTLED is the state a successful job ends in: settlement writes it in
39
+ # the same request as completion. It was missing from the terminal set, so
40
+ # the documented wait-then-read flow timed out on every success (review,
41
+ # 2026-09-05).
42
+ job = Job.from_dict({"id": "j1", "userId": "u1", "status": "SETTLED", "firmwareUrl": "x",
43
+ "firmwareSize": 1, "createdAt": "2026-09-05T00:00:00Z"})
44
+ assert job.is_terminal