babon 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.
babon/__init__.py ADDED
@@ -0,0 +1,267 @@
1
+ """Babon — kinematics-as-a-service client for Python.
2
+
3
+ pip install babon
4
+
5
+ from babon import Babon
6
+ Babon("bk_...").analyze("trial.mp4").save("out.zip")
7
+
8
+ That's it.
9
+
10
+ Internally this wraps `api.babon.eu/v1/*` with `requests`. Idempotency,
11
+ polling, error envelope decoding, file handling: all handled. Customer
12
+ sees one class and three methods.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import os
19
+ import time
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Iterable, Iterator, Optional, Union
23
+
24
+ import requests
25
+
26
+
27
+ __all__ = [
28
+ "Babon",
29
+ "Run",
30
+ "RunBatch",
31
+ "BabonError",
32
+ "BabonAuthError",
33
+ "BabonQuotaExceeded",
34
+ "BabonInvalidVideo",
35
+ "BabonInvalidLabel",
36
+ "BabonRunFailed",
37
+ "BabonTimeout",
38
+ ]
39
+
40
+
41
+ DEFAULT_BASE_URL = "https://api.babon.eu"
42
+ DEFAULT_POLL_INTERVAL_S = 30
43
+ DEFAULT_WAIT_TIMEOUT_S = 60 * 60 * 6 # 6 hours, covers an overnight batch
44
+
45
+
46
+ # ─────────────────── exceptions ───────────────────
47
+
48
+ class BabonError(Exception):
49
+ """Base class for everything the client raises."""
50
+
51
+ def __init__(self, message: str, *, code: str = "", request_id: str = ""):
52
+ super().__init__(message)
53
+ self.code = code
54
+ self.request_id = request_id
55
+
56
+
57
+ class BabonAuthError(BabonError): pass
58
+ class BabonQuotaExceeded(BabonError): pass
59
+ class BabonInvalidVideo(BabonError): pass
60
+ class BabonInvalidLabel(BabonError): pass
61
+ class BabonRunFailed(BabonError): pass
62
+ class BabonTimeout(BabonError): pass
63
+
64
+
65
+ _CODE_TO_EXC: dict[str, type[BabonError]] = {
66
+ "MISSING_AUTH": BabonAuthError,
67
+ "INVALID_AUTH": BabonAuthError,
68
+ "WRONG_ENVIRONMENT": BabonAuthError,
69
+ "DPA_NOT_ACCEPTED": BabonAuthError,
70
+ "QUOTA_EXCEEDED": BabonQuotaExceeded,
71
+ "CONCURRENT_LIMIT": BabonQuotaExceeded,
72
+ "RATE_LIMITED": BabonQuotaExceeded,
73
+ "INVALID_VIDEO": BabonInvalidVideo,
74
+ "VIDEO_REQUIRED": BabonInvalidVideo,
75
+ "INVALID_LABEL": BabonInvalidLabel,
76
+ }
77
+
78
+
79
+ def _raise_for_envelope(response: requests.Response) -> None:
80
+ if response.ok:
81
+ return
82
+ try:
83
+ body = response.json()
84
+ except Exception:
85
+ raise BabonError(f"HTTP {response.status_code}: {response.text[:200]}")
86
+ err = (body or {}).get("error") or {}
87
+ code = err.get("code", "")
88
+ message = err.get("message", f"HTTP {response.status_code}")
89
+ request_id = err.get("request_id", "")
90
+ exc_cls = _CODE_TO_EXC.get(code, BabonError)
91
+ raise exc_cls(message, code=code, request_id=request_id)
92
+
93
+
94
+ def _auto_idempotency_key(file_path: Path, key: str) -> str:
95
+ """Stable hash of (api key + filename + size + first 4 KB of file).
96
+
97
+ First 4 KB is enough to discriminate distinct uploads without
98
+ re-reading the whole file. Same input, same upload → same idem key →
99
+ server replays the prior response.
100
+ """
101
+ h = hashlib.sha256()
102
+ h.update(key.encode())
103
+ h.update(b"\x00")
104
+ h.update(str(file_path).encode())
105
+ h.update(b"\x00")
106
+ h.update(str(file_path.stat().st_size).encode())
107
+ h.update(b"\x00")
108
+ with open(file_path, "rb") as f:
109
+ h.update(f.read(4096))
110
+ return h.hexdigest()
111
+
112
+
113
+ # ─────────────────── public surface ───────────────────
114
+
115
+ @dataclass
116
+ class Run:
117
+ """A submitted analysis. Returned from `Babon.analyze(...)`.
118
+
119
+ Methods you care about:
120
+ .wait() block until the run terminates
121
+ .save(path) block + download the bundle.zip
122
+ .data block + return clinical_report.json as a dict
123
+ .id, .status introspection
124
+ """
125
+
126
+ id: str
127
+ _client: "Babon"
128
+ _status: str = "queued"
129
+
130
+ @property
131
+ def status(self) -> str:
132
+ return self._status
133
+
134
+ def refresh(self) -> "Run":
135
+ body = self._client._get(f"/api/v1/runs/{self.id}")
136
+ self._status = body.get("status", "unknown")
137
+ return self
138
+
139
+ def wait(
140
+ self,
141
+ *,
142
+ timeout_s: int = DEFAULT_WAIT_TIMEOUT_S,
143
+ poll_every_s: int = DEFAULT_POLL_INTERVAL_S,
144
+ ) -> "Run":
145
+ """Block until the run reaches a terminal state."""
146
+ started = time.monotonic()
147
+ while True:
148
+ self.refresh()
149
+ if self._status == "completed":
150
+ return self
151
+ if self._status == "failed":
152
+ raise BabonRunFailed(f"run {self.id} failed", code="RUN_FAILED")
153
+ if time.monotonic() - started > timeout_s:
154
+ raise BabonTimeout(
155
+ f"run {self.id} did not complete within {timeout_s}s "
156
+ f"(last status: {self._status})",
157
+ code="TIMEOUT",
158
+ )
159
+ time.sleep(poll_every_s)
160
+
161
+ def save(self, path: Union[str, os.PathLike], *, include_preview: bool = False) -> Path:
162
+ """Wait for completion and write the bundle to `path`."""
163
+ self.wait()
164
+ url = f"/api/v1/runs/{self.id}/bundle.zip"
165
+ if include_preview:
166
+ url += "?include_preview=true"
167
+ out = Path(path)
168
+ out.write_bytes(self._client._get_bytes(url))
169
+ return out
170
+
171
+ @property
172
+ def data(self) -> dict:
173
+ """Wait for completion and return clinical_report.json as a dict."""
174
+ self.wait()
175
+ body = self._client._get_bytes(f"/api/v1/runs/{self.id}/files/clinical_report.json")
176
+ import json as _json
177
+ return _json.loads(body)
178
+
179
+
180
+ @dataclass
181
+ class RunBatch:
182
+ runs: list[Run]
183
+
184
+ def __iter__(self) -> Iterator[Run]:
185
+ return iter(self.runs)
186
+
187
+ def __len__(self) -> int:
188
+ return len(self.runs)
189
+
190
+ def wait(self, **kwargs) -> "RunBatch":
191
+ for run in self.runs:
192
+ run.wait(**kwargs)
193
+ return self
194
+
195
+ def save(self, directory: Union[str, os.PathLike], *, include_preview: bool = False) -> list[Path]:
196
+ out_dir = Path(directory)
197
+ out_dir.mkdir(parents=True, exist_ok=True)
198
+ paths = []
199
+ for run in self.runs:
200
+ paths.append(run.save(out_dir / f"{run.id}.zip", include_preview=include_preview))
201
+ return paths
202
+
203
+
204
+ class Babon:
205
+ """Babon client. One key per organisation.
206
+
207
+ Babon("bk_...").analyze("trial.mp4").save("out.zip")
208
+ """
209
+
210
+ def __init__(self, key: str, *, base_url: str = DEFAULT_BASE_URL, timeout_s: int = 600):
211
+ if not key:
212
+ raise BabonAuthError("api key is required", code="MISSING_AUTH")
213
+ self._key = key
214
+ self._base_url = base_url.rstrip("/")
215
+ self._timeout_s = timeout_s
216
+
217
+ # public surface
218
+ def analyze(
219
+ self,
220
+ video: Union[str, os.PathLike, Iterable[Union[str, os.PathLike]]],
221
+ *,
222
+ label: Optional[str] = None,
223
+ ) -> Union[Run, RunBatch]:
224
+ """Submit one video or a list. Returns a Run or a RunBatch.
225
+
226
+ analyze("trial.mp4") -> Run
227
+ analyze(["a.mp4", "b.mp4"]) -> RunBatch
228
+ analyze("trial.mp4", label="cohort-3") -> Run with custom label
229
+ """
230
+ if isinstance(video, (str, os.PathLike)):
231
+ return self._submit_one(Path(video), label=label)
232
+ return RunBatch([self._submit_one(Path(v), label=label) for v in video])
233
+
234
+ # internals
235
+ def _headers(self) -> dict:
236
+ return {"Authorization": f"Bearer {self._key}"}
237
+
238
+ def _submit_one(self, path: Path, *, label: Optional[str]) -> Run:
239
+ if not path.is_file():
240
+ raise FileNotFoundError(f"video not found: {path}")
241
+ headers = self._headers()
242
+ headers["Idempotency-Key"] = _auto_idempotency_key(path, self._key)
243
+ data: dict = {}
244
+ if label:
245
+ data["label"] = label
246
+ with open(path, "rb") as f:
247
+ files = {"video": (path.name, f, "application/octet-stream")}
248
+ resp = requests.post(
249
+ f"{self._base_url}/api/v1/runs",
250
+ headers=headers,
251
+ data=data,
252
+ files=files,
253
+ timeout=self._timeout_s,
254
+ )
255
+ _raise_for_envelope(resp)
256
+ body = resp.json()
257
+ return Run(id=body["run_id"], _client=self, _status=body.get("status", "queued"))
258
+
259
+ def _get(self, path: str) -> dict:
260
+ resp = requests.get(f"{self._base_url}{path}", headers=self._headers(), timeout=self._timeout_s)
261
+ _raise_for_envelope(resp)
262
+ return resp.json()
263
+
264
+ def _get_bytes(self, path: str) -> bytes:
265
+ resp = requests.get(f"{self._base_url}{path}", headers=self._headers(), timeout=self._timeout_s)
266
+ _raise_for_envelope(resp)
267
+ return resp.content
babon/cli.py ADDED
@@ -0,0 +1,147 @@
1
+ """Babon CLI — `babon <command>`.
2
+
3
+ Installed as a console_scripts entry point, so `pip install babon` puts
4
+ a `babon` binary on the user's PATH. The customer never writes glue
5
+ scripts; they just run:
6
+
7
+ babon analyze trial.mp4
8
+ babon analyze ./videos/
9
+ babon status <run_id>
10
+ babon download <run_id>
11
+ babon resume
12
+
13
+ The key comes from `BABON_KEY` in the environment (or `--key`).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ from babon import Babon, Run, BabonError
24
+
25
+
26
+ def _key(args) -> str:
27
+ key = args.key or os.environ.get("BABON_KEY", "")
28
+ if not key:
29
+ print("set BABON_KEY=bk_... or pass --key", file=sys.stderr)
30
+ sys.exit(2)
31
+ return key
32
+
33
+
34
+ def _collect_videos(target: Path) -> list[Path]:
35
+ if target.is_file():
36
+ return [target]
37
+ if target.is_dir():
38
+ return sorted(p for p in target.glob("*.mp4") if p.is_file())
39
+ print(f"not found: {target}", file=sys.stderr)
40
+ sys.exit(2)
41
+
42
+
43
+ def cmd_analyze(args) -> int:
44
+ bb = Babon(_key(args))
45
+ videos = _collect_videos(Path(args.path))
46
+ if not videos:
47
+ print(f"no .mp4 files in {args.path}", file=sys.stderr)
48
+ return 2
49
+
50
+ out_dir = Path(args.out)
51
+ out_dir.mkdir(parents=True, exist_ok=True)
52
+ print(f"submitting {len(videos)} video(s)...", flush=True)
53
+ runs = bb.analyze([str(v) for v in videos], label=args.label)
54
+ if isinstance(runs, Run):
55
+ runs_list = [runs]
56
+ else:
57
+ runs_list = list(runs)
58
+
59
+ for run, video in zip(runs_list, videos):
60
+ print(f" {video.name:30s} -> {run.id}", flush=True)
61
+
62
+ print("waiting for completion, then downloading...", flush=True)
63
+ for run, video in zip(runs_list, videos):
64
+ out_path = out_dir / f"{video.stem}_{run.id}.zip"
65
+ print(f" {run.id}: polling ...", flush=True, end="")
66
+ run.save(str(out_path), include_preview=args.include_preview)
67
+ size_mb = out_path.stat().st_size / 1e6
68
+ print(f" saved {out_path.name} ({size_mb:.1f} MB)", flush=True)
69
+ return 0
70
+
71
+
72
+ def cmd_status(args) -> int:
73
+ bb = Babon(_key(args))
74
+ body = bb._get(f"/api/v1/runs/{args.run_id}")
75
+ for k in ("run_id", "status", "stage", "progress", "created_at", "last_update"):
76
+ if k in body:
77
+ print(f"{k:14s} {body[k]}")
78
+ if body.get("status") == "failed" and body.get("error"):
79
+ print(f"error {body['error']}")
80
+ return 0
81
+
82
+
83
+ def cmd_download(args) -> int:
84
+ bb = Babon(_key(args))
85
+ out = Path(args.out or f"{args.run_id}.zip")
86
+ run = Run(id=args.run_id, _client=bb)
87
+ run.save(str(out), include_preview=args.include_preview)
88
+ print(f"saved {out} ({out.stat().st_size / 1e6:.1f} MB)")
89
+ return 0
90
+
91
+
92
+ def cmd_resume(args) -> int:
93
+ """Pick up any non-terminal runs for this tenant and wait+download them.
94
+
95
+ Reads the usage endpoint to find the org id, then walks recent
96
+ submitted runs. For the pilot tier with low volume this is fine; a
97
+ cohort-volume tenant would page through /v1/runs (list endpoint, to
98
+ be added) instead.
99
+ """
100
+ print("usage endpoint provides totals but not run ids; ask Babon for the list endpoint", file=sys.stderr)
101
+ return 3
102
+
103
+
104
+ def _add_key(p: argparse.ArgumentParser) -> None:
105
+ p.add_argument("--key", help="API key (or set BABON_KEY env)")
106
+
107
+
108
+ def main() -> int:
109
+ parser = argparse.ArgumentParser(prog="babon")
110
+ _add_key(parser)
111
+ sub = parser.add_subparsers(dest="command", required=True)
112
+
113
+ p_analyze = sub.add_parser("analyze", help="submit one video or all .mp4 in a directory, wait, download")
114
+ _add_key(p_analyze)
115
+ p_analyze.add_argument("path", help="path to a .mp4 file or a directory of them")
116
+ p_analyze.add_argument("--out", default="./out", help="output directory (default ./out)")
117
+ p_analyze.add_argument("--label", default=None, help="optional opaque identifier")
118
+ p_analyze.add_argument("--include-preview", action="store_true", help="include rendered preview mp4 in the bundle")
119
+ p_analyze.set_defaults(fn=cmd_analyze)
120
+
121
+ p_status = sub.add_parser("status", help="print lifecycle state for one run")
122
+ _add_key(p_status)
123
+ p_status.add_argument("run_id")
124
+ p_status.set_defaults(fn=cmd_status)
125
+
126
+ p_download = sub.add_parser("download", help="download the bundle for a completed run")
127
+ _add_key(p_download)
128
+ p_download.add_argument("run_id")
129
+ p_download.add_argument("--out", default=None, help="output file (default <run_id>.zip)")
130
+ p_download.add_argument("--include-preview", action="store_true")
131
+ p_download.set_defaults(fn=cmd_download)
132
+
133
+ p_resume = sub.add_parser("resume", help="wait + download any in-flight runs for this tenant")
134
+ _add_key(p_resume)
135
+ p_resume.set_defaults(fn=cmd_resume)
136
+
137
+ args = parser.parse_args()
138
+ try:
139
+ return args.fn(args)
140
+ except BabonError as exc:
141
+ print(f"ERROR: {type(exc).__name__}: {exc} (code={exc.code} req={exc.request_id})",
142
+ file=sys.stderr)
143
+ return 4
144
+
145
+
146
+ if __name__ == "__main__":
147
+ sys.exit(main())
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: babon
3
+ Version: 0.1.0
4
+ Summary: Babon kinematics-as-a-service client. Video in, movement data out.
5
+ Author-email: "Babon Innovations B.V." <daan@babon.eu>
6
+ License: MIT
7
+ Project-URL: Homepage, https://babon.eu
8
+ Project-URL: Documentation, https://api.babon.eu/api/v1/docs
9
+ Keywords: biomechanics,kinematics,gait,video-analysis
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ Requires-Dist: requests>=2.28
18
+
19
+ # babon
20
+
21
+ Kinematics-as-a-service client. Video in, movement data out.
22
+
23
+ ```bash
24
+ pip install babon
25
+ ```
26
+
27
+ ```python
28
+ from babon import Babon
29
+
30
+ Babon("bk_...").analyze("trial.mp4").save("out.zip")
31
+ ```
32
+
33
+ That's it.
34
+
35
+ ## A bit more
36
+
37
+ ```python
38
+ from babon import Babon
39
+
40
+ bb = Babon("bk_...")
41
+
42
+ # get the clinical_report.json dict directly
43
+ report = bb.analyze("trial.mp4").data
44
+ print(report["gait_parameters"]["gait_velocity_m_s"])
45
+
46
+ # batch
47
+ bb.analyze(["a.mp4", "b.mp4", "c.mp4"]).save("./results/")
48
+
49
+ # tag with your own identifier
50
+ bb.analyze("trial.mp4", label="cohort-3-day-7").save("out.zip")
51
+ ```
52
+
53
+ ## What's in the bundle
54
+
55
+ `clinical_report.json` (gait params, GRF, symmetry), 14 per-joint angle CSVs (ISB convention), raw rotation matrices, 3D joint positions, `bodymodel.npz`, `grf_waveform.json`, `manifest.json` with SHA-256 per file.
56
+
57
+ ## Errors
58
+
59
+ The client raises typed exceptions:
60
+
61
+ - `BabonAuthError` — bad key, wrong environment, DPA not accepted.
62
+ - `BabonQuotaExceeded` — monthly limit, concurrent limit, or rate-limited.
63
+ - `BabonInvalidVideo` — file is not a recognised container.
64
+ - `BabonInvalidLabel` — label looks like a real name (ADR-0002).
65
+ - `BabonRunFailed` — pipeline failed.
66
+ - `BabonTimeout` — `wait()` exceeded its timeout.
67
+
68
+ All carry `.code` (the API error code) and `.request_id` (the support handle).
69
+
70
+ ## What we are and are not
71
+
72
+ - Measurement only. Output is kinematics; clinical interpretation is yours.
73
+ - EU-cloud (Scaleway fr-par). Videos never leave the EU.
74
+ - Don't put real patient names in `label` or in your filenames. We reject name-shaped labels server-side.
75
+
76
+ ## Contact
77
+
78
+ daan@babon.eu — onboarding, quota, bugs.
@@ -0,0 +1,7 @@
1
+ babon/__init__.py,sha256=UJrkLXZnto2of7m1Sk7BrmkeObCL6Adijjm9bmybDFw,8675
2
+ babon/cli.py,sha256=TtGUQKbrWB99vddStBuUTObgb9TYQDPxVQYXfZV2-ac,5035
3
+ babon-0.1.0.dist-info/METADATA,sha256=P0uBUSL-gqOjfzdpwmQDYF66_opkReQdDntdo0pLcak,2352
4
+ babon-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ babon-0.1.0.dist-info/entry_points.txt,sha256=5Anw46ZXM1FfdNrJp7DXBzMEh1vB3XkyNGYGXRv8pF0,41
6
+ babon-0.1.0.dist-info/top_level.txt,sha256=_cCy65oLW-aQqSN4vIkmaEppHT5ozZSb1QurDAtl1qQ,6
7
+ babon-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ babon = babon.cli:main
@@ -0,0 +1 @@
1
+ babon