whiskops-sdk 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.
- whiskops_sdk-0.1.0/PKG-INFO +46 -0
- whiskops_sdk-0.1.0/README.md +35 -0
- whiskops_sdk-0.1.0/pyproject.toml +17 -0
- whiskops_sdk-0.1.0/setup.cfg +4 -0
- whiskops_sdk-0.1.0/tests/test_client.py +76 -0
- whiskops_sdk-0.1.0/tests/test_sandbox_integration.py +60 -0
- whiskops_sdk-0.1.0/whiskops_sdk/__init__.py +5 -0
- whiskops_sdk-0.1.0/whiskops_sdk/client.py +103 -0
- whiskops_sdk-0.1.0/whiskops_sdk.egg-info/PKG-INFO +46 -0
- whiskops_sdk-0.1.0/whiskops_sdk.egg-info/SOURCES.txt +11 -0
- whiskops_sdk-0.1.0/whiskops_sdk.egg-info/dependency_links.txt +1 -0
- whiskops_sdk-0.1.0/whiskops_sdk.egg-info/requires.txt +1 -0
- whiskops_sdk-0.1.0/whiskops_sdk.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: whiskops-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client SDK for the Whisk device External API (status, turn, stop).
|
|
5
|
+
Author: Codemenschen GmbH
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: whisk,iot,device-control,sdk
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: requests>=2.28
|
|
11
|
+
|
|
12
|
+
# whiskops-sdk (Python)
|
|
13
|
+
|
|
14
|
+
Client SDK for the Whisk device External API. Wraps bearer-token auth,
|
|
15
|
+
idempotency-key generation for `turn`, retry with backoff on 429/5xx, and
|
|
16
|
+
client-side spacing between `turn` calls.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import os
|
|
22
|
+
from whiskops_sdk import WhiskClient
|
|
23
|
+
|
|
24
|
+
client = WhiskClient(api_key=os.environ["WHISK_API_KEY"])
|
|
25
|
+
|
|
26
|
+
status = client.status()
|
|
27
|
+
if status["online"]:
|
|
28
|
+
client.turn(speed=80)
|
|
29
|
+
client.stop()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Notes
|
|
33
|
+
|
|
34
|
+
- `turn()` validates `speed` is an integer 1-500 before making a request.
|
|
35
|
+
- A 409 response (active Time/Turns session) is not retried — it is raised
|
|
36
|
+
as a `WhiskApiError` since retrying would not resolve the conflict.
|
|
37
|
+
- `base_url` defaults to `https://myremotedevice.com/api/v1/external` per the
|
|
38
|
+
published API docs; pass a different one to the constructor if the real
|
|
39
|
+
production host differs.
|
|
40
|
+
|
|
41
|
+
## Tests
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
python -m venv .venv && .venv/bin/pip install -e . pytest
|
|
45
|
+
.venv/bin/pytest
|
|
46
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# whiskops-sdk (Python)
|
|
2
|
+
|
|
3
|
+
Client SDK for the Whisk device External API. Wraps bearer-token auth,
|
|
4
|
+
idempotency-key generation for `turn`, retry with backoff on 429/5xx, and
|
|
5
|
+
client-side spacing between `turn` calls.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import os
|
|
11
|
+
from whiskops_sdk import WhiskClient
|
|
12
|
+
|
|
13
|
+
client = WhiskClient(api_key=os.environ["WHISK_API_KEY"])
|
|
14
|
+
|
|
15
|
+
status = client.status()
|
|
16
|
+
if status["online"]:
|
|
17
|
+
client.turn(speed=80)
|
|
18
|
+
client.stop()
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Notes
|
|
22
|
+
|
|
23
|
+
- `turn()` validates `speed` is an integer 1-500 before making a request.
|
|
24
|
+
- A 409 response (active Time/Turns session) is not retried — it is raised
|
|
25
|
+
as a `WhiskApiError` since retrying would not resolve the conflict.
|
|
26
|
+
- `base_url` defaults to `https://myremotedevice.com/api/v1/external` per the
|
|
27
|
+
published API docs; pass a different one to the constructor if the real
|
|
28
|
+
production host differs.
|
|
29
|
+
|
|
30
|
+
## Tests
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
python -m venv .venv && .venv/bin/pip install -e . pytest
|
|
34
|
+
.venv/bin/pytest
|
|
35
|
+
```
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "whiskops-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Client SDK for the Whisk device External API (status, turn, stop)."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.9"
|
|
8
|
+
dependencies = ["requests>=2.28"]
|
|
9
|
+
authors = [{ name = "Codemenschen GmbH" }]
|
|
10
|
+
keywords = ["whisk", "iot", "device-control", "sdk"]
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["setuptools>=61"]
|
|
14
|
+
build-backend = "setuptools.build_meta"
|
|
15
|
+
|
|
16
|
+
[tool.setuptools.packages.find]
|
|
17
|
+
include = ["whiskops_sdk*"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from whiskops_sdk import WhiskClient, WhiskApiError
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class FakeResponse:
|
|
7
|
+
def __init__(self, status_code, json_data=None, text="", headers=None):
|
|
8
|
+
self.status_code = status_code
|
|
9
|
+
self._json = json_data
|
|
10
|
+
self.text = text
|
|
11
|
+
self.headers = headers or {}
|
|
12
|
+
self.content = text.encode() if text else (b"{}" if json_data is not None else b"")
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def ok(self):
|
|
16
|
+
return 200 <= self.status_code < 400
|
|
17
|
+
|
|
18
|
+
def json(self):
|
|
19
|
+
return self._json
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class FakeSession:
|
|
23
|
+
def __init__(self, responses):
|
|
24
|
+
self._responses = list(responses)
|
|
25
|
+
self.calls = []
|
|
26
|
+
|
|
27
|
+
def request(self, method, url, headers=None, json=None, timeout=None):
|
|
28
|
+
self.calls.append({"method": method, "url": url, "headers": headers, "json": json})
|
|
29
|
+
return self._responses.pop(0)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def make_client(responses, **kwargs):
|
|
33
|
+
session = FakeSession(responses)
|
|
34
|
+
client = WhiskClient(api_key="twk_test", session=session, **kwargs)
|
|
35
|
+
return client, session
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_status_sends_bearer_auth():
|
|
39
|
+
client, session = make_client([FakeResponse(200, json_data={"online": False})])
|
|
40
|
+
result = client.status()
|
|
41
|
+
assert result == {"online": False}
|
|
42
|
+
assert session.calls[0]["headers"]["Authorization"] == "Bearer twk_test"
|
|
43
|
+
assert session.calls[0]["url"] == "https://myremotedevice.com/api/v1/external/device/status"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_turn_rejects_out_of_range_speed_without_request():
|
|
47
|
+
client, session = make_client([], min_turn_interval_s=0)
|
|
48
|
+
with pytest.raises(ValueError):
|
|
49
|
+
client.turn(speed=0)
|
|
50
|
+
with pytest.raises(ValueError):
|
|
51
|
+
client.turn(speed=501)
|
|
52
|
+
assert session.calls == []
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_turn_attaches_generated_idempotency_key():
|
|
56
|
+
client, session = make_client([FakeResponse(202)], min_turn_interval_s=0)
|
|
57
|
+
client.turn(speed=50)
|
|
58
|
+
key = session.calls[0]["headers"]["Idempotency-Key"]
|
|
59
|
+
assert key
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_retries_on_500_then_succeeds():
|
|
63
|
+
client, session = make_client(
|
|
64
|
+
[FakeResponse(500, text="boom"), FakeResponse(200, json_data={"online": True})],
|
|
65
|
+
max_retries=2,
|
|
66
|
+
)
|
|
67
|
+
result = client.status()
|
|
68
|
+
assert result == {"online": True}
|
|
69
|
+
assert len(session.calls) == 2
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_does_not_retry_409():
|
|
73
|
+
client, session = make_client([FakeResponse(409, text="conflict")], min_turn_interval_s=0)
|
|
74
|
+
with pytest.raises(WhiskApiError):
|
|
75
|
+
client.turn(speed=50)
|
|
76
|
+
assert len(session.calls) == 1
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
import time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from whiskops_sdk import WhiskClient, WhiskApiError
|
|
10
|
+
|
|
11
|
+
PORT = 4179
|
|
12
|
+
TOKEN = "twk_sandbox_demo_token"
|
|
13
|
+
BASE_URL = f"http://localhost:{PORT}"
|
|
14
|
+
SANDBOX_ENTRY = Path(__file__).resolve().parents[2] / "sandbox-api" / "server.mjs"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture(scope="module")
|
|
18
|
+
def sandbox_server():
|
|
19
|
+
proc = subprocess.Popen(
|
|
20
|
+
["node", str(SANDBOX_ENTRY)],
|
|
21
|
+
env={"PORT": str(PORT), "SANDBOX_TOKEN": TOKEN, "PATH": "/usr/bin:/bin:/usr/local/bin:/opt/homebrew/bin"},
|
|
22
|
+
stdout=subprocess.DEVNULL,
|
|
23
|
+
stderr=subprocess.DEVNULL,
|
|
24
|
+
)
|
|
25
|
+
deadline = time.monotonic() + 5
|
|
26
|
+
while time.monotonic() < deadline:
|
|
27
|
+
try:
|
|
28
|
+
requests.get(f"{BASE_URL}/device/status", headers={"Authorization": f"Bearer {TOKEN}"}, timeout=1)
|
|
29
|
+
break
|
|
30
|
+
except requests.RequestException:
|
|
31
|
+
time.sleep(0.1)
|
|
32
|
+
else:
|
|
33
|
+
proc.kill()
|
|
34
|
+
raise RuntimeError("sandbox server did not start in time")
|
|
35
|
+
yield
|
|
36
|
+
proc.kill()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_full_status_turn_stop_flow(sandbox_server):
|
|
40
|
+
client = WhiskClient(api_key=TOKEN, base_url=BASE_URL, min_turn_interval_s=0)
|
|
41
|
+
|
|
42
|
+
status = client.status()
|
|
43
|
+
assert status == {"online": True}
|
|
44
|
+
|
|
45
|
+
turn_result = client.turn(speed=120, idempotency_key="py-flow-key")
|
|
46
|
+
assert turn_result["status"] == "accepted"
|
|
47
|
+
assert turn_result["speed"] == 120
|
|
48
|
+
|
|
49
|
+
stop_result = client.stop()
|
|
50
|
+
assert stop_result == {"stopped": True}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_second_turn_while_active_is_409(sandbox_server):
|
|
54
|
+
client = WhiskClient(api_key=TOKEN, base_url=BASE_URL, min_turn_interval_s=0)
|
|
55
|
+
|
|
56
|
+
client.turn(speed=30, idempotency_key="py-session-a")
|
|
57
|
+
with pytest.raises(WhiskApiError) as excinfo:
|
|
58
|
+
client.turn(speed=30, idempotency_key="py-session-b")
|
|
59
|
+
assert excinfo.value.status == 409
|
|
60
|
+
client.stop()
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
DEFAULT_BASE_URL = "https://myremotedevice.com/api/v1/external"
|
|
10
|
+
MIN_SPEED = 1
|
|
11
|
+
MAX_SPEED = 500
|
|
12
|
+
MIN_TURN_INTERVAL_S = 1.0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WhiskApiError(Exception):
|
|
16
|
+
def __init__(self, message: str, status: Optional[int] = None, body: Optional[str] = None):
|
|
17
|
+
super().__init__(message)
|
|
18
|
+
self.status = status
|
|
19
|
+
self.body = body
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class WhiskClient:
|
|
23
|
+
"""Client for the Whisk device External API.
|
|
24
|
+
|
|
25
|
+
Wraps bearer-token auth, idempotency-key generation for ``turn``, and
|
|
26
|
+
retry with exponential backoff on transient failures (429 / 5xx). A 409
|
|
27
|
+
from ``turn`` (an active Time/Turns session) is not retried and is raised
|
|
28
|
+
as a :class:`WhiskApiError`.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
api_key: str,
|
|
34
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
35
|
+
max_retries: int = 3,
|
|
36
|
+
min_turn_interval_s: float = MIN_TURN_INTERVAL_S,
|
|
37
|
+
session: Optional[requests.Session] = None,
|
|
38
|
+
):
|
|
39
|
+
if not api_key:
|
|
40
|
+
raise ValueError("WhiskClient requires api_key")
|
|
41
|
+
self.api_key = api_key
|
|
42
|
+
self.base_url = base_url.rstrip("/")
|
|
43
|
+
self.max_retries = max_retries
|
|
44
|
+
self.min_turn_interval_s = min_turn_interval_s
|
|
45
|
+
self._session = session or requests.Session()
|
|
46
|
+
self._last_turn_at = 0.0
|
|
47
|
+
|
|
48
|
+
def _request(self, method: str, path: str, headers: Optional[dict] = None, json: Optional[dict] = None) -> Any:
|
|
49
|
+
url = f"{self.base_url}{path}"
|
|
50
|
+
req_headers = {"Authorization": f"Bearer {self.api_key}", **(headers or {})}
|
|
51
|
+
|
|
52
|
+
attempt = 0
|
|
53
|
+
while True:
|
|
54
|
+
resp = self._session.request(method, url, headers=req_headers, json=json, timeout=10)
|
|
55
|
+
|
|
56
|
+
if resp.status_code == 429 or resp.status_code >= 500:
|
|
57
|
+
if attempt >= self.max_retries:
|
|
58
|
+
raise WhiskApiError(
|
|
59
|
+
f"{method} {path} failed after {attempt + 1} attempts: {resp.status_code}",
|
|
60
|
+
status=resp.status_code,
|
|
61
|
+
body=resp.text,
|
|
62
|
+
)
|
|
63
|
+
retry_after = resp.headers.get("Retry-After")
|
|
64
|
+
try:
|
|
65
|
+
backoff = float(retry_after) if retry_after else (2 ** attempt) * 0.5
|
|
66
|
+
except ValueError:
|
|
67
|
+
backoff = (2 ** attempt) * 0.5
|
|
68
|
+
time.sleep(backoff)
|
|
69
|
+
attempt += 1
|
|
70
|
+
continue
|
|
71
|
+
|
|
72
|
+
if not resp.ok:
|
|
73
|
+
raise WhiskApiError(f"{method} {path} failed: {resp.status_code}", status=resp.status_code, body=resp.text)
|
|
74
|
+
|
|
75
|
+
if resp.status_code == 204 or not resp.content:
|
|
76
|
+
return None
|
|
77
|
+
return resp.json()
|
|
78
|
+
|
|
79
|
+
def status(self) -> dict:
|
|
80
|
+
"""Read current device status. Offline devices return `{"online": false}` successfully."""
|
|
81
|
+
return self._request("GET", "/device/status")
|
|
82
|
+
|
|
83
|
+
def turn(self, speed: int, idempotency_key: Optional[str] = None) -> Any:
|
|
84
|
+
"""Start a single turn.
|
|
85
|
+
|
|
86
|
+
:param speed: Integer 1-500.
|
|
87
|
+
:param idempotency_key: Supply your own to control retries/dedup explicitly;
|
|
88
|
+
otherwise one is generated per call.
|
|
89
|
+
"""
|
|
90
|
+
if not isinstance(speed, int) or isinstance(speed, bool) or not (MIN_SPEED <= speed <= MAX_SPEED):
|
|
91
|
+
raise ValueError(f"speed must be an integer between {MIN_SPEED} and {MAX_SPEED}")
|
|
92
|
+
|
|
93
|
+
elapsed = time.monotonic() - self._last_turn_at
|
|
94
|
+
if elapsed < self.min_turn_interval_s:
|
|
95
|
+
time.sleep(self.min_turn_interval_s - elapsed)
|
|
96
|
+
self._last_turn_at = time.monotonic()
|
|
97
|
+
|
|
98
|
+
key = idempotency_key or str(uuid.uuid4())
|
|
99
|
+
return self._request("POST", "/device/turn", headers={"Idempotency-Key": key}, json={"speed": speed})
|
|
100
|
+
|
|
101
|
+
def stop(self) -> Any:
|
|
102
|
+
"""Stop the device. Also ends any active Time/Turns session."""
|
|
103
|
+
return self._request("POST", "/device/stop")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: whiskops-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Client SDK for the Whisk device External API (status, turn, stop).
|
|
5
|
+
Author: Codemenschen GmbH
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: whisk,iot,device-control,sdk
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Requires-Dist: requests>=2.28
|
|
11
|
+
|
|
12
|
+
# whiskops-sdk (Python)
|
|
13
|
+
|
|
14
|
+
Client SDK for the Whisk device External API. Wraps bearer-token auth,
|
|
15
|
+
idempotency-key generation for `turn`, retry with backoff on 429/5xx, and
|
|
16
|
+
client-side spacing between `turn` calls.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
import os
|
|
22
|
+
from whiskops_sdk import WhiskClient
|
|
23
|
+
|
|
24
|
+
client = WhiskClient(api_key=os.environ["WHISK_API_KEY"])
|
|
25
|
+
|
|
26
|
+
status = client.status()
|
|
27
|
+
if status["online"]:
|
|
28
|
+
client.turn(speed=80)
|
|
29
|
+
client.stop()
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Notes
|
|
33
|
+
|
|
34
|
+
- `turn()` validates `speed` is an integer 1-500 before making a request.
|
|
35
|
+
- A 409 response (active Time/Turns session) is not retried — it is raised
|
|
36
|
+
as a `WhiskApiError` since retrying would not resolve the conflict.
|
|
37
|
+
- `base_url` defaults to `https://myremotedevice.com/api/v1/external` per the
|
|
38
|
+
published API docs; pass a different one to the constructor if the real
|
|
39
|
+
production host differs.
|
|
40
|
+
|
|
41
|
+
## Tests
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
python -m venv .venv && .venv/bin/pip install -e . pytest
|
|
45
|
+
.venv/bin/pytest
|
|
46
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
tests/test_client.py
|
|
4
|
+
tests/test_sandbox_integration.py
|
|
5
|
+
whiskops_sdk/__init__.py
|
|
6
|
+
whiskops_sdk/client.py
|
|
7
|
+
whiskops_sdk.egg-info/PKG-INFO
|
|
8
|
+
whiskops_sdk.egg-info/SOURCES.txt
|
|
9
|
+
whiskops_sdk.egg-info/dependency_links.txt
|
|
10
|
+
whiskops_sdk.egg-info/requires.txt
|
|
11
|
+
whiskops_sdk.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
requests>=2.28
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
whiskops_sdk
|