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.
- haaslab-0.1.0/.gitignore +3 -0
- haaslab-0.1.0/CHANGELOG.md +19 -0
- haaslab-0.1.0/PKG-INFO +110 -0
- haaslab-0.1.0/README.md +95 -0
- haaslab-0.1.0/examples/01_quickstart.py +26 -0
- haaslab-0.1.0/examples/02_stream_serial.py +23 -0
- haaslab-0.1.0/pyproject.toml +37 -0
- haaslab-0.1.0/src/haaslab/__init__.py +19 -0
- haaslab-0.1.0/src/haaslab/client.py +56 -0
- haaslab-0.1.0/src/haaslab/exceptions.py +20 -0
- haaslab-0.1.0/src/haaslab/jobs.py +117 -0
- haaslab-0.1.0/src/haaslab/models.py +145 -0
- haaslab-0.1.0/src/haaslab/tokens.py +26 -0
- haaslab-0.1.0/tests/test_client.py +228 -0
- haaslab-0.1.0/tests/test_models.py +44 -0
haaslab-0.1.0/.gitignore
ADDED
|
@@ -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
|
+
```
|
haaslab-0.1.0/README.md
ADDED
|
@@ -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
|