myosdk 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,42 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
11
+
12
+ tmp
13
+ *.zip
14
+ *.tar.gz
15
+
16
+ .env
17
+
18
+ .clinerules
19
+ .kiro
20
+ memory-bank
21
+ tmp-assets
22
+ _backoffice
23
+
24
+ # Terraform
25
+ *.tfstate
26
+ *.tfstate.*
27
+ .terraform/
28
+ .terraform.lock.hcl
29
+ terraform.tfvars
30
+ *.auto.tfvars
31
+ infra/terraform/envs/*.tfvars
32
+ infra/.ecr_url
33
+ infra/deployment_outputs.env
34
+ infra/sagemaker_vpc_config.txt
35
+ infra/terraform/keys/
36
+
37
+ # Docs
38
+ site/
39
+
40
+ vendored/
41
+
42
+ .cursor/
myosdk-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: myosdk
3
+ Version: 0.1.0
4
+ Summary: Python SDK for MyoSapiens API
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.28.1
7
+ Provides-Extra: dev
8
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == 'dev'
9
+ Requires-Dist: pytest-mock>=3.12.0; extra == 'dev'
10
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
11
+ Description-Content-Type: text/markdown
12
+
13
+ # MyoSapiens Python SDK (MyoSDK)
14
+
15
+ A lightweight Python client for the MyoSapiens API.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ pip install myosdk
21
+ ```
22
+
23
+ ## Quick Start
24
+
25
+ ### Prerequisites
26
+ Before installing the SDK, ensure you have:
27
+
28
+ - **Python 3.10+**
29
+ - **A MyoSapiens API key**, available at [dev.myolab.ai](dev.myolab.ai)
30
+
31
+ ### Basic Usage
32
+ ``` python
33
+ from myosdk import Client
34
+
35
+ client = Client(api_key="ak_live_xxx")
36
+
37
+ # Upload a C3D motion capture file
38
+ c3d_asset = client.assets.upload_file(<c3d_path>)
39
+
40
+ # Upload markerset XML file
41
+ markerset_asset = client.assets.upload_file(<markerset_path>)
42
+
43
+ # Run tracking
44
+ job = client.jobs.start_retarget(
45
+ tracker_asset_id=c3d_asset["asset_id"],
46
+ markerset_asset_id=markerset_asset["asset_id"],
47
+ )
48
+
49
+ # Wait for completion
50
+ result = client.jobs.wait(job["job_id"])
51
+
52
+ # Download the resulting joint angles and joint names(.npz)
53
+ joint_angle_asset_id = result["output"]["retarget_output_asset_id"]
54
+ client.assets.download(joint_angle_asset_id, <output_npy_path>)
55
+ ```
56
+
57
+ ## Examples and Tutorials
58
+ See examples and tutorials at [https://github.com/myolab/myosdk_tutorials](https://github.com/myolab/myosdk_tutorials)
59
+
60
+ ## SDK Documentation
61
+ Full SDK and API documentation is available at [docs.myolab.ai](docs.myolab.ai).
myosdk-0.1.0/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # MyoSapiens Python SDK (MyoSDK)
2
+
3
+ A lightweight Python client for the MyoSapiens API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install myosdk
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ### Prerequisites
14
+ Before installing the SDK, ensure you have:
15
+
16
+ - **Python 3.10+**
17
+ - **A MyoSapiens API key**, available at [dev.myolab.ai](dev.myolab.ai)
18
+
19
+ ### Basic Usage
20
+ ``` python
21
+ from myosdk import Client
22
+
23
+ client = Client(api_key="ak_live_xxx")
24
+
25
+ # Upload a C3D motion capture file
26
+ c3d_asset = client.assets.upload_file(<c3d_path>)
27
+
28
+ # Upload markerset XML file
29
+ markerset_asset = client.assets.upload_file(<markerset_path>)
30
+
31
+ # Run tracking
32
+ job = client.jobs.start_retarget(
33
+ tracker_asset_id=c3d_asset["asset_id"],
34
+ markerset_asset_id=markerset_asset["asset_id"],
35
+ )
36
+
37
+ # Wait for completion
38
+ result = client.jobs.wait(job["job_id"])
39
+
40
+ # Download the resulting joint angles and joint names(.npz)
41
+ joint_angle_asset_id = result["output"]["retarget_output_asset_id"]
42
+ client.assets.download(joint_angle_asset_id, <output_npy_path>)
43
+ ```
44
+
45
+ ## Examples and Tutorials
46
+ See examples and tutorials at [https://github.com/myolab/myosdk_tutorials](https://github.com/myolab/myosdk_tutorials)
47
+
48
+ ## SDK Documentation
49
+ Full SDK and API documentation is available at [docs.myolab.ai](docs.myolab.ai).
@@ -0,0 +1,25 @@
1
+ """MyoSapiens Python SDK."""
2
+
3
+ from myosdk.characters import Characters
4
+ from myosdk.client import Client
5
+ from myosdk.exceptions import (
6
+ APIError,
7
+ AuthenticationError,
8
+ NotFoundError,
9
+ RateLimitError,
10
+ ServerError,
11
+ ValidationError,
12
+ )
13
+
14
+ __all__ = [
15
+ "Client",
16
+ "APIError",
17
+ "AuthenticationError",
18
+ "NotFoundError",
19
+ "RateLimitError",
20
+ "ServerError",
21
+ "ValidationError",
22
+ "Characters",
23
+ ]
24
+
25
+ __version__ = "0.1.0"
@@ -0,0 +1,350 @@
1
+ """Assets resource for managing file uploads and downloads."""
2
+
3
+ import mimetypes
4
+ import time
5
+ from io import BufferedReader
6
+ from pathlib import Path
7
+ from typing import Any, BinaryIO
8
+
9
+ from myosdk.http import HTTPClient
10
+
11
+
12
+ class Assets:
13
+ """Assets resource for managing file uploads and downloads."""
14
+
15
+ def __init__(self, http_client: HTTPClient):
16
+ """Initialize Assets resource.
17
+
18
+ Args:
19
+ http_client: HTTP client instance
20
+ """
21
+ self._http = http_client
22
+
23
+ def initiate(
24
+ self,
25
+ purpose: str,
26
+ filename: str,
27
+ content_type: str = "application/octet-stream",
28
+ expected_size_bytes: int | None = None,
29
+ metadata: dict | None = None,
30
+ ) -> dict:
31
+ """Initiate an asset upload.
32
+
33
+ Args:
34
+ purpose: Asset purpose (e.g., "video", "trackers")
35
+ filename: Original filename
36
+ content_type: MIME type of the file
37
+ expected_size_bytes: Optional expected file size
38
+ metadata: Optional metadata dict
39
+
40
+ Returns:
41
+ Dict with asset_id, upload_url, fields, expires_at
42
+ """
43
+ payload = {
44
+ "purpose": purpose,
45
+ "filename": filename,
46
+ "content_type": content_type,
47
+ }
48
+ if expected_size_bytes is not None:
49
+ payload["expected_size_bytes"] = expected_size_bytes
50
+ if metadata is not None:
51
+ payload["metadata"] = metadata
52
+
53
+ return self._http.post("/v1/assets/initiate", json=payload)
54
+
55
+ def upload(
56
+ self, asset: dict | str, file_like: BinaryIO | BufferedReader | Path | str
57
+ ) -> dict:
58
+ """Upload a file to a presigned URL.
59
+
60
+ Args:
61
+ asset: Asset dict from initiate() (must contain upload_url and fields)
62
+ file_like: File-like object, Path, or file path string
63
+
64
+ Returns:
65
+ Dict with upload_time_seconds
66
+
67
+ Raises:
68
+ ValueError: If asset dict is missing required fields
69
+ """
70
+ # Asset dict from initiate() should have upload_url and fields directly
71
+ if isinstance(asset, str):
72
+ raise ValueError(
73
+ "upload() requires asset dict from initiate(), not asset_id. Use initiate() first."
74
+ )
75
+
76
+ upload_url = asset.get("upload_url")
77
+ fields = asset.get("fields", {})
78
+
79
+ if not upload_url:
80
+ raise ValueError(
81
+ "Asset dict missing upload_url. Make sure to use the dict returned by initiate()."
82
+ )
83
+
84
+ # Handle file input
85
+ if isinstance(file_like, str | Path):
86
+ file_path = Path(file_like)
87
+ file_obj = open(file_path, "rb")
88
+ filename = file_path.name
89
+ should_close = True
90
+ else:
91
+ file_obj = file_like
92
+ filename = getattr(file_obj, "name", "file")
93
+ should_close = False
94
+
95
+ try:
96
+ # Upload to presigned POST URL
97
+ # For presigned POST, we need to send fields + file as multipart/form-data
98
+ # httpx accepts files in format: {"file": (filename, file_obj, content_type)} or {"file": (filename, file_obj)}
99
+ content_type = (
100
+ getattr(file_obj, "content_type", None) or "application/octet-stream"
101
+ )
102
+ files = {"file": (filename, file_obj, content_type)}
103
+
104
+ start_time = time.perf_counter()
105
+ response = self._http.post_multipart(upload_url, data=fields, files=files)
106
+ upload_time = time.perf_counter() - start_time
107
+
108
+ # Presigned POST typically returns 204 or 201, but we don't need to parse JSON
109
+ # Just verify it was successful
110
+ if response.status_code not in (200, 201, 204):
111
+ raise ValueError(f"Upload failed with status {response.status_code}")
112
+
113
+ return {"upload_time_seconds": upload_time}
114
+ finally:
115
+ if should_close:
116
+ file_obj.close()
117
+
118
+ def complete(self, asset_id: str) -> dict:
119
+ """Complete an asset upload.
120
+
121
+ Args:
122
+ asset_id: Asset identifier
123
+
124
+ Returns:
125
+ Dict with asset_id, verified, size_bytes, checksum_sha256, message
126
+ """
127
+ return self._http.post(f"/v1/assets/{asset_id}/complete")
128
+
129
+ def get(self, asset_id: str) -> dict:
130
+ """Get asset details.
131
+
132
+ Args:
133
+ asset_id: Asset identifier
134
+
135
+ Returns:
136
+ Dict with asset details including download_url
137
+ """
138
+ return self._http.get(f"/v1/assets/{asset_id}")
139
+
140
+ def download(self, asset: str | dict, destination: str | Path) -> dict:
141
+ """Download an asset to a local file.
142
+
143
+ Args:
144
+ asset: Asset identifier (string) or asset dict from get()
145
+ destination: Destination file path
146
+
147
+ Returns:
148
+ Dict with download_time_seconds and size_bytes
149
+
150
+ Raises:
151
+ ValueError: If asset doesn't have a download_url
152
+ """
153
+ # Accept either asset_id string or asset dict for convenience
154
+ if isinstance(asset, dict):
155
+ asset_data = asset
156
+ asset_id = asset.get("asset_id")
157
+ else:
158
+ asset_id = asset
159
+ asset_data = self.get(asset_id)
160
+
161
+ download_url = asset_data.get("download_url")
162
+
163
+ if not download_url:
164
+ raise ValueError(
165
+ f"Asset {asset_id} does not have a download_url (might not be ready yet)"
166
+ )
167
+
168
+ # Download from presigned URL
169
+ start_time = time.perf_counter()
170
+ response = self._http.get_raw(download_url)
171
+ response.raise_for_status()
172
+
173
+ # Write to file
174
+ destination_path = Path(destination)
175
+ destination_path.parent.mkdir(parents=True, exist_ok=True)
176
+ with open(destination_path, "wb") as f:
177
+ f.write(response.content)
178
+
179
+ download_time = time.perf_counter() - start_time
180
+ size_bytes = len(response.content)
181
+
182
+ return {
183
+ "download_time_seconds": download_time,
184
+ "size_bytes": size_bytes,
185
+ }
186
+
187
+ def list(
188
+ self,
189
+ purpose: str | None = None,
190
+ reference_count: int | None = None,
191
+ limit: int = 50,
192
+ offset: int = 0,
193
+ ) -> dict:
194
+ """List assets.
195
+
196
+ Args:
197
+ purpose: Filter by purpose
198
+ reference_count: Filter by reference count
199
+ limit: Items per page
200
+ offset: Items to skip
201
+
202
+ Returns:
203
+ Dict with assets list and pagination info
204
+ """
205
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
206
+ if purpose is not None:
207
+ params["purpose"] = purpose
208
+ if reference_count is not None:
209
+ params["reference_count"] = reference_count
210
+
211
+ return self._http.get("/v1/assets", params=params)
212
+
213
+ def delete(self, asset_id: str) -> None:
214
+ """Delete an asset.
215
+
216
+ Args:
217
+ asset_id: Asset identifier
218
+ """
219
+ self._http.delete(f"/v1/assets/{asset_id}")
220
+
221
+ def upload_file(
222
+ self,
223
+ file_path: str | Path,
224
+ purpose: str | None = None,
225
+ metadata: dict[str, Any] | None = None,
226
+ ) -> dict:
227
+ """Upload a file in one step (convenience method).
228
+
229
+ This is a high-level method that handles initiate → upload → complete automatically.
230
+ It auto-detects filename, content_type, and purpose from the file.
231
+
232
+ Args:
233
+ file_path: Path to the file to upload
234
+ purpose: Asset purpose (auto-detected if not provided: video files → "video", .pkl → "trackers")
235
+ metadata: Optional metadata dict
236
+
237
+ Returns:
238
+ Dict with completed asset details including asset_id, verified, size_bytes, checksum_sha256,
239
+ and timings dict with initiate_seconds, upload_seconds, complete_seconds, total_seconds
240
+
241
+ Example:
242
+ >>> asset = client.assets.upload_file("walk.mp4")
243
+ >>> print(asset["asset_id"])
244
+ >>> print(asset["timings"]["total_seconds"])
245
+ """
246
+ path = Path(file_path)
247
+ if not path.exists():
248
+ raise FileNotFoundError(f"File not found: {file_path}")
249
+
250
+ # Auto-detect purpose from file extension if not provided
251
+ if purpose is None:
252
+ purpose = self._detect_purpose(path)
253
+
254
+ # Auto-detect content type from file extension
255
+ content_type = self._detect_content_type(path)
256
+
257
+ # Get file size for validation
258
+ file_size = path.stat().st_size
259
+
260
+ # Step 1: Initiate upload
261
+ initiate_start = time.perf_counter()
262
+ asset = self.initiate(
263
+ purpose=purpose,
264
+ filename=path.name,
265
+ content_type=content_type,
266
+ expected_size_bytes=file_size,
267
+ metadata=metadata,
268
+ )
269
+ initiate_time = time.perf_counter() - initiate_start
270
+
271
+ # Step 2: Upload file
272
+ with open(path, "rb") as f:
273
+ upload_result = self.upload(asset, f)
274
+ upload_time = upload_result["upload_time_seconds"]
275
+
276
+ # Step 3: Complete upload
277
+ complete_start = time.perf_counter()
278
+ result = self.complete(asset["asset_id"])
279
+ complete_time = time.perf_counter() - complete_start
280
+
281
+ # Add timing breakdown to result
282
+ total_time = initiate_time + upload_time + complete_time
283
+ result["timings"] = {
284
+ "initiate_seconds": initiate_time,
285
+ "upload_seconds": upload_time,
286
+ "complete_seconds": complete_time,
287
+ "total_seconds": total_time,
288
+ }
289
+
290
+ return result
291
+
292
+ def _detect_purpose(self, path: Path) -> str:
293
+ """Detect asset purpose from file extension.
294
+
295
+ Args:
296
+ path: File path
297
+
298
+ Returns:
299
+ Purpose string ("video", "trackers", or "retarget")
300
+ """
301
+ extension = path.suffix.lower()
302
+
303
+ # Video extensions
304
+ video_extensions = {".mp4", ".mov", ".avi", ".webm", ".mkv", ".flv", ".wmv"}
305
+ if extension in video_extensions:
306
+ return "video"
307
+
308
+ # Tracker/pickle files
309
+ tracker_extensions = {".pkl", ".pickle"}
310
+ if extension in tracker_extensions:
311
+ return "trackers"
312
+
313
+ # Retarget input files (C3D/TRC motion capture, markerset XML, parquet trackers)
314
+ retarget_extensions = {".c3d", ".trc", ".xml", ".parquet"}
315
+ if extension in retarget_extensions:
316
+ return "retarget"
317
+
318
+ # Default to video for unknown types (conservative choice)
319
+ return "video"
320
+
321
+ def _detect_content_type(self, path: Path) -> str:
322
+ """Detect MIME type from file extension.
323
+
324
+ Args:
325
+ path: File path
326
+
327
+ Returns:
328
+ MIME type string
329
+ """
330
+ # Try to guess from extension
331
+ content_type, _ = mimetypes.guess_type(str(path))
332
+
333
+ if content_type:
334
+ return content_type
335
+
336
+ # Fallback for common cases
337
+ extension = path.suffix.lower()
338
+ fallback_types = {
339
+ ".mp4": "video/mp4",
340
+ ".mov": "video/quicktime",
341
+ ".avi": "video/x-msvideo",
342
+ ".webm": "video/webm",
343
+ ".pkl": "application/octet-stream",
344
+ ".pickle": "application/octet-stream",
345
+ ".parquet": "application/parquet",
346
+ ".c3d": "application/octet-stream",
347
+ ".trc": "text/plain",
348
+ }
349
+
350
+ return fallback_types.get(extension, "application/octet-stream")
@@ -0,0 +1,53 @@
1
+ """Characters resource for browsing available characters and versions."""
2
+
3
+ from typing import Any
4
+
5
+ from myosdk.http import HTTPClient
6
+
7
+
8
+ class Characters:
9
+ """Read-only character catalog."""
10
+
11
+ def __init__(self, http_client: HTTPClient):
12
+ """Initialize Characters resource."""
13
+ self._http = http_client
14
+
15
+ def list(
16
+ self,
17
+ name_contains: str | None = None,
18
+ has_ready_versions: bool | None = None,
19
+ limit: int = 50,
20
+ offset: int = 0,
21
+ ) -> dict[str, Any]:
22
+ """List available characters with optional filtering.
23
+
24
+ Args:
25
+ name_contains: Filter by substring match on name
26
+ has_ready_versions: Only include characters that have READY versions
27
+ limit: Items per page
28
+ offset: Items to skip
29
+
30
+ Returns:
31
+ Dict containing character list and pagination info
32
+ """
33
+ params: dict[str, Any] = {"limit": limit, "offset": offset}
34
+ if name_contains:
35
+ params["name_contains"] = name_contains
36
+ if has_ready_versions is not None:
37
+ params["has_ready_versions"] = has_ready_versions
38
+
39
+ return self._http.get("/v1/characters", params=params)
40
+
41
+ def get(self, character_id: str) -> dict[str, Any]:
42
+ """Get details for a single character."""
43
+ return self._http.get(f"/v1/characters/{character_id}")
44
+
45
+ def validate_manifest(self, character_id: str, version: str) -> dict[str, Any]:
46
+ """Validate that a character manifest exists in storage."""
47
+ if not version:
48
+ raise ValueError("version is required")
49
+
50
+ return self._http.post(
51
+ f"/v1/characters/{character_id}/validate-manifest",
52
+ params={"version": version},
53
+ )
@@ -0,0 +1,55 @@
1
+ """Main client class for the SDK."""
2
+
3
+ from myosdk.assets import Assets
4
+ from myosdk.characters import Characters
5
+ from myosdk.http import HTTPClient
6
+ from myosdk.jobs import Jobs
7
+
8
+
9
+ class Client:
10
+ """Main client for interacting with the MyoSapiens API."""
11
+
12
+ def __init__(
13
+ self,
14
+ api_key: str,
15
+ base_url: str = "https://v2m-alb-us-east-1.myolab.ai",
16
+ timeout: float = 30.0,
17
+ ):
18
+ """Initialize the client.
19
+
20
+ Args:
21
+ api_key: API key for authentication
22
+ base_url: Base URL of the API (default: https://v2m-alb-us-east-1.myolab.ai)
23
+ timeout: Request timeout in seconds (default: 30.0)
24
+ """
25
+ self._http = HTTPClient(api_key=api_key, base_url=base_url, timeout=timeout)
26
+ self._assets = Assets(self._http)
27
+ self._jobs = Jobs(self._http)
28
+ self._characters = Characters(self._http)
29
+
30
+ @property
31
+ def assets(self) -> Assets:
32
+ """Access the assets resource."""
33
+ return self._assets
34
+
35
+ @property
36
+ def jobs(self) -> Jobs:
37
+ """Access the jobs resource."""
38
+ return self._jobs
39
+
40
+ @property
41
+ def characters(self) -> Characters:
42
+ """Access the characters catalog."""
43
+ return self._characters
44
+
45
+ def close(self) -> None:
46
+ """Close the HTTP client and release resources."""
47
+ self._http.close()
48
+
49
+ def __enter__(self):
50
+ """Context manager entry."""
51
+ return self
52
+
53
+ def __exit__(self, exc_type, exc_val, exc_tb):
54
+ """Context manager exit."""
55
+ self.close()