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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+ """Client SDK for the Whisk device External API."""
2
+
3
+ from .client import WhiskClient, WhiskApiError
4
+
5
+ __all__ = ["WhiskClient", "WhiskApiError"]
@@ -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
+ requests>=2.28
@@ -0,0 +1 @@
1
+ whiskops_sdk