haaslab 0.1.0__py3-none-any.whl

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.
haaslab/__init__.py ADDED
@@ -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"
haaslab/client.py ADDED
@@ -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()
haaslab/exceptions.py ADDED
@@ -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."""
haaslab/jobs.py ADDED
@@ -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)
haaslab/models.py ADDED
@@ -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
haaslab/tokens.py ADDED
@@ -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,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,9 @@
1
+ haaslab/__init__.py,sha256=vTQwD0MGrIofmvkMg5HsiCNUO8IDjfRTAoGHd-qCwB0,432
2
+ haaslab/client.py,sha256=yRp_vix1T2Sl38aM7p5S01w1jGONl7bSXezeXLM1V_s,1590
3
+ haaslab/exceptions.py,sha256=jLGBMAcgWEes-ykXGcQFJCY8zfXdhnAy6lAXrfV2mJw,478
4
+ haaslab/jobs.py,sha256=HNufo6tk754KO3NNf5v2npT8AzfahCwQk0FD5MC3XjI,4308
5
+ haaslab/models.py,sha256=EgX_8xK1pgOd0ArrQjyJo-VoGzfttnv0OGkfYPup544,4201
6
+ haaslab/tokens.py,sha256=9F5NArBmu4hT2dSAfCMJbfVncKgLXbz5Uu5OcT_DnrE,843
7
+ haaslab-0.1.0.dist-info/METADATA,sha256=d0ghaQYagKsQxuNGhoLgQ8kKJG24iK63Ojk1wgAEwso,3170
8
+ haaslab-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
9
+ haaslab-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any