motion-intelligence 0.2.0__tar.gz → 0.4.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.
Files changed (23) hide show
  1. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/PKG-INFO +1 -1
  2. motion_intelligence-0.4.0/motion/__init__.py +18 -0
  3. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/motion/batch.py +101 -14
  4. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/motion/cli.py +26 -4
  5. motion_intelligence-0.4.0/motion/client.py +183 -0
  6. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/motion/config.py +12 -0
  7. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/motion/dockerfile.py +12 -1
  8. motion_intelligence-0.4.0/motion/server.py +238 -0
  9. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/motion_intelligence.egg-info/PKG-INFO +1 -1
  10. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/motion_intelligence.egg-info/SOURCES.txt +1 -0
  11. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/pyproject.toml +1 -1
  12. motion_intelligence-0.2.0/motion/__init__.py +0 -10
  13. motion_intelligence-0.2.0/motion/server.py +0 -138
  14. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/README.md +0 -0
  15. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/motion/categories.py +0 -0
  16. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/motion/predictor.py +0 -0
  17. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/motion/profile.py +0 -0
  18. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/motion/weight_upload.py +0 -0
  19. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/motion_intelligence.egg-info/dependency_links.txt +0 -0
  20. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/motion_intelligence.egg-info/entry_points.txt +0 -0
  21. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/motion_intelligence.egg-info/requires.txt +0 -0
  22. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/motion_intelligence.egg-info/top_level.txt +0 -0
  23. {motion_intelligence-0.2.0 → motion_intelligence-0.4.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: motion-intelligence
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Author, build & deploy Python models for the Motion inference platform
5
5
  Project-URL: Homepage, https://github.com/devathub9/Motion-packages
6
6
  Project-URL: Repository, https://github.com/devathub9/Motion-packages
@@ -0,0 +1,18 @@
1
+ """motion — build, deploy, and call models on the Motion inference platform.
2
+
3
+ Authors import from here in their predict.py:
4
+
5
+ from motion import BasePredictor, Input, Path
6
+
7
+ Callers use the client to run a deployed model:
8
+
9
+ from motion import MotionClient
10
+ """
11
+ from .client import MotionClient, MotionJobFailedError, MotionTimeoutError
12
+ from .predictor import BasePredictor, Input, Path
13
+
14
+ __all__ = [
15
+ "BasePredictor", "Input", "Path",
16
+ "MotionClient", "MotionJobFailedError", "MotionTimeoutError",
17
+ ]
18
+ __version__ = "0.4.0"
@@ -6,12 +6,14 @@ Environment variables (set by the Lambda dispatcher):
6
6
  INPUT_KEY — S3 key of the input file (video/image)
7
7
  CALLBACK_URL — backend URL to POST result to
8
8
  CALLBACK_SECRET — shared secret for the callback
9
+ MOTION_PARAMS — optional JSON object of predict() kwargs beyond the input file
9
10
 
10
11
  The container exits 0 on success, non-zero on failure.
11
12
  The backend marks the job completed/failed via the callback.
12
13
  """
13
14
  from __future__ import annotations
14
15
 
16
+ import inspect
15
17
  import json
16
18
  import os
17
19
  import sys
@@ -21,6 +23,8 @@ from pathlib import Path
21
23
 
22
24
  import boto3
23
25
 
26
+ from .predictor import Input
27
+
24
28
 
25
29
  def _env(name: str, required: bool = True, default: str = "") -> str:
26
30
  val = os.environ.get(name, default).strip()
@@ -79,6 +83,98 @@ def _content_type(ext: str) -> str:
79
83
  }.get(ext.lower(), "application/octet-stream")
80
84
 
81
85
 
86
+ def _submitted_params() -> dict:
87
+ """Parse MOTION_PARAMS into a dict, tolerating absence and malformed JSON.
88
+
89
+ A bad value must not fail the job on its own — the signature defaults are still
90
+ a valid way to run — so this warns and falls back to {}.
91
+ """
92
+ raw = os.environ.get("MOTION_PARAMS", "").strip()
93
+ if not raw:
94
+ return {}
95
+ try:
96
+ parsed = json.loads(raw)
97
+ except ValueError as e:
98
+ print(f"[batch] WARN: MOTION_PARAMS is not valid JSON ({e}); using defaults")
99
+ return {}
100
+ if not isinstance(parsed, dict):
101
+ print(f"[batch] WARN: MOTION_PARAMS is {type(parsed).__name__}, expected object; using defaults")
102
+ return {}
103
+ return parsed
104
+
105
+
106
+ def _coerce_to_annotation(name: str, value, annotation):
107
+ """Cast a JSON-decoded value to the parameter's annotated type.
108
+
109
+ MOTION_PARAMS can carry strings for numeric fields (form-encoded UIs commonly
110
+ do), and Input.validate compares with < / >, which raises TypeError on a str.
111
+ Coercing first means range checks work and predict() sees the type it declared.
112
+ """
113
+ if annotation is inspect.Parameter.empty or value is None:
114
+ return value
115
+ if annotation is bool:
116
+ if isinstance(value, str):
117
+ return value.strip().lower() in ("1", "true", "yes", "on")
118
+ return bool(value)
119
+ if annotation is int:
120
+ try:
121
+ return int(float(value))
122
+ except (TypeError, ValueError):
123
+ raise TypeError(f"{name}={value!r} is not a valid int")
124
+ if annotation is float:
125
+ try:
126
+ return float(value)
127
+ except (TypeError, ValueError):
128
+ raise TypeError(f"{name}={value!r} is not a valid float")
129
+ if annotation is str:
130
+ return str(value)
131
+ return value
132
+
133
+
134
+ def _build_kwargs(predict, input_path: Path) -> dict:
135
+ """Assemble the full predict() call: the input file plus the caller's params.
136
+
137
+ The first parameter always receives the downloaded input, coerced to the
138
+ annotated type. Every other parameter is filled from MOTION_PARAMS, falling
139
+ back to its Input(...) default. Values are cast to the annotated type and then
140
+ validated through the Input descriptor, so a bad number is rejected here naming
141
+ the param rather than deep inside user code.
142
+
143
+ Params the signature does not declare are dropped — passing them through would
144
+ raise TypeError and fail the job.
145
+ """
146
+ sig = inspect.signature(predict)
147
+ params = list(sig.parameters.values())
148
+ if not params:
149
+ raise TypeError("predict() must accept at least one argument (the input file)")
150
+
151
+ first, rest = params[0], params[1:]
152
+ annotation = first.annotation
153
+ if annotation is Path or str(annotation) == "<class 'pathlib.Path'>":
154
+ kwargs: dict = {first.name: input_path}
155
+ else:
156
+ kwargs = {first.name: str(input_path)}
157
+
158
+ given = _submitted_params()
159
+ for p in rest:
160
+ meta = p.default if isinstance(p.default, Input) else None
161
+ if p.name in given:
162
+ value = _coerce_to_annotation(p.name, given[p.name], p.annotation)
163
+ if meta is not None:
164
+ value = meta.validate(p.name, value)
165
+ kwargs[p.name] = value
166
+ elif meta is not None and not meta.required:
167
+ kwargs[p.name] = meta.default
168
+ elif meta is not None and meta.required:
169
+ raise TypeError(f"predict() requires '{p.name}' but no value was supplied")
170
+ # else: a plain Python default — let the signature apply it.
171
+
172
+ unknown = set(given) - {p.name for p in rest}
173
+ if unknown:
174
+ print(f"[batch] WARN: ignoring params not in predict() signature: {sorted(unknown)}")
175
+ return kwargs
176
+
177
+
82
178
  def main() -> int:
83
179
  job_id = _env("JOB_ID")
84
180
  bucket = _env("S3_BUCKET")
@@ -104,20 +200,11 @@ def main() -> int:
104
200
  # 3. Download input from S3
105
201
  _download_s3(bucket, input_key, input_path)
106
202
 
107
- # 4. Run predict() — pass input as a Path
108
- import inspect
109
- sig = inspect.signature(predictor.predict)
110
- first_param = next(iter(sig.parameters.values()))
111
-
112
- # Coerce input to the annotated type (Path or str)
113
- annotation = first_param.annotation
114
- if annotation is Path or str(annotation) == "<class 'pathlib.Path'>":
115
- input_arg = input_path
116
- else:
117
- input_arg = str(input_path)
118
-
119
- print(f"[batch] running predict({first_param.name}={input_path})")
120
- result = predictor.predict(**{first_param.name: input_arg})
203
+ # 4. Run predict() — input file as the first arg, MOTION_PARAMS for the rest
204
+ kwargs = _build_kwargs(predictor.predict, input_path)
205
+ shown = {k: v for k, v in kwargs.items() if not isinstance(v, Path)}
206
+ print(f"[batch] running predict(input={input_path}, {shown})")
207
+ result = predictor.predict(**kwargs)
121
208
 
122
209
  # 5. Upload output to S3
123
210
  output_path = Path(str(result))
@@ -20,14 +20,13 @@ import subprocess
20
20
  import sys
21
21
 
22
22
  from .categories import resolve_category_id
23
- from .config import MotionConfig
23
+ from .config import MotionConfig, resolve_api_url
24
24
  from .dockerfile import generate_dockerfile
25
25
  from .profile import resolve_compute_profile
26
26
  from .weight_upload import upload_weight_via_backend
27
27
 
28
28
  _PKG_DIR = os.path.dirname(os.path.abspath(__file__))
29
- _RUNTIME_FILES = ("__init__.py", "predictor.py", "server.py", "batch.py")
30
- _DEFAULT_API_URL = "https://adwrzdrdes.us-east-1.awsapprunner.com"
29
+ _RUNTIME_FILES = ("__init__.py", "predictor.py", "server.py", "batch.py", "client.py", "config.py")
31
30
 
32
31
 
33
32
  def _c(msg, code="36"):
@@ -35,7 +34,7 @@ def _c(msg, code="36"):
35
34
 
36
35
 
37
36
  def _api_url():
38
- return os.environ.get("MOTION_API_URL", _DEFAULT_API_URL).rstrip("/")
37
+ return resolve_api_url()
39
38
 
40
39
 
41
40
  def cmd_build(args):
@@ -146,6 +145,7 @@ def cmd_deploy(args):
146
145
  import requests
147
146
 
148
147
  project_dir = os.path.abspath(args.path)
148
+ org_id = (getattr(args, "organization_id", None) or os.environ.get("MOTION_ORGANIZATION_ID") or "").strip()
149
149
  print(_c(f"▶ motion deploy ({project_dir})"))
150
150
 
151
151
  # 1. Parse motion.yml
@@ -306,6 +306,9 @@ def cmd_deploy(args):
306
306
  register_payload["category_code"] = cfg.category
307
307
  if getattr(args, "service_type", None):
308
308
  register_payload["service_type"] = args.service_type
309
+ org_for_register = org_id or (cfg.organization_id or "").strip()
310
+ if org_for_register:
311
+ register_payload["organization_id"] = org_for_register
309
312
 
310
313
  try:
311
314
  reg = requests.post(
@@ -331,6 +334,12 @@ def cmd_deploy(args):
331
334
  print(_c(f"\n✓ model {action}: {model_code} (id={reg_data['model_type_id']})", "32"))
332
335
  print(f" ECR image: {reg_data['ecr_image']}")
333
336
  print(f" model_code: {reg_data['model_code']}")
337
+ if reg_data.get("organization_id"):
338
+ print(f" organization: {reg_data['organization_id']}")
339
+ elif org_for_register:
340
+ print(f" organization: {org_for_register}")
341
+ else:
342
+ print(" scope: personal")
334
343
  if resolved_category_id is not None:
335
344
  print(f" category: id={resolved_category_id}")
336
345
  elif cfg.category:
@@ -380,9 +389,16 @@ def _parse_predict_inputs(project_dir: str, cfg: "MotionConfig") -> list:
380
389
  for item in node.body:
381
390
  if not isinstance(item, ast.FunctionDef) or item.name != "predict":
382
391
  continue
392
+ seen_file_arg = False
383
393
  for i, arg in enumerate(item.args.args):
384
394
  if arg.arg == "self":
385
395
  continue
396
+ # predict()'s first non-self arg is always the input file (see
397
+ # BasePredictor / motion.batch._build_kwargs) — it's already
398
+ # represented by input_type, not a caller-supplied "param".
399
+ if not seen_file_arg:
400
+ seen_file_arg = True
401
+ continue
386
402
  # Determine type annotation
387
403
  annotation = item.args.annotations.get(arg.arg) if hasattr(item.args, "annotations") else None
388
404
  type_name = ""
@@ -493,6 +509,12 @@ def main(argv=None):
493
509
  "(warm host), or dedicated_instance (disabled for now). "
494
510
  "Omit to keep the default/existing value.",
495
511
  )
512
+ d.add_argument(
513
+ "--organization-id",
514
+ dest="organization_id",
515
+ default="",
516
+ help="org UUID for org-scoped deploy (or MOTION_ORGANIZATION_ID / motion.yml organization_id)",
517
+ )
496
518
  d.set_defaults(func=cmd_deploy)
497
519
 
498
520
  args = p.parse_args(argv)
@@ -0,0 +1,183 @@
1
+ """MotionClient — call a deployed model from Python, the consumer side of `motion`.
2
+
3
+ from motion import MotionClient
4
+
5
+ client = MotionClient(api_key="motion_live_...")
6
+ job = client.submit_job("jay/pose-estimation", "video.mp4", params={"fps": 24})
7
+ result = client.wait_for_job(job["job_id"])
8
+ print(result["artifacts"])
9
+
10
+ `motion build` / `motion deploy` (cli.py) and `BasePredictor` (predictor.py) are for
11
+ authoring and shipping a model; this module is for running one that's already
12
+ deployed — same package, the other side of the counter.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import mimetypes
17
+ import os
18
+ import time
19
+ from typing import Any, BinaryIO
20
+
21
+ import requests
22
+
23
+ from .config import resolve_api_url
24
+
25
+ API_PREFIX = "/api/v1"
26
+ _DEFAULT_POLL_INTERVAL = 3.0
27
+ _DEFAULT_WAIT_TIMEOUT = 600.0
28
+
29
+
30
+ class MotionJobFailedError(RuntimeError):
31
+ def __init__(self, job_id: str, error_message: str | None):
32
+ self.job_id = job_id
33
+ self.error_message = error_message
34
+ super().__init__(f"job {job_id} failed: {error_message or 'no error message'}")
35
+
36
+
37
+ class MotionTimeoutError(TimeoutError):
38
+ def __init__(self, job_id: str, timeout: float):
39
+ self.job_id = job_id
40
+ super().__init__(f"job {job_id} did not finish within {timeout}s")
41
+
42
+
43
+ class MotionClient:
44
+ """Thin wrapper over the Motion inference API.
45
+
46
+ `api_key` falls back to the `MOTION_API_KEY` env var; `base_url` falls back to
47
+ `MOTION_API_URL` env var, then the platform default (same resolution `motion`
48
+ CLI uses for `build`/`deploy`).
49
+ """
50
+
51
+ def __init__(self, api_key: str | None = None, base_url: str | None = None,
52
+ timeout: float = 120.0):
53
+ self.api_key = (api_key or os.environ.get("MOTION_API_KEY", "")).strip()
54
+ if not self.api_key:
55
+ raise ValueError(
56
+ "MotionClient: `api_key` is required (or set MOTION_API_KEY). "
57
+ "Get your key from the Motion dashboard."
58
+ )
59
+ self.base_url = resolve_api_url(base_url)
60
+ self.timeout = timeout
61
+ self._session = requests.Session()
62
+ self._session.headers["X-API-Key"] = self.api_key
63
+
64
+ # ─── Models ──────────────────────────────────────────────────────────────
65
+
66
+ def list_models(self) -> list[dict]:
67
+ """Public catalog — every active platform and deployed model."""
68
+ return self._get(f"{API_PREFIX}/motion/models")
69
+
70
+ def get_model_params(self, model_code: str) -> dict:
71
+ """Input params (name/type/default/constraints) for one model_code."""
72
+ return self._get(f"{API_PREFIX}/motion/models/{model_code}/params")
73
+
74
+ # ─── Jobs ────────────────────────────────────────────────────────────────
75
+
76
+ def submit_job(
77
+ self,
78
+ model_name: str,
79
+ file: str | bytes | BinaryIO,
80
+ task: str = "",
81
+ outputs: list[str] | None = None,
82
+ params: dict[str, Any] | None = None,
83
+ filename: str | None = None,
84
+ ) -> dict:
85
+ """Submit a job. `file` is a local path, raw bytes, or an open file object.
86
+
87
+ `params` fills any extra fields the model's predict() declares (e.g.
88
+ `{"fps": 24}`) beyond the input file — see GET /motion/models/{code}/params
89
+ for what a given model accepts.
90
+ """
91
+ if not model_name or not model_name.strip():
92
+ raise ValueError("`model_name` must be a non-empty string")
93
+
94
+ data = {"model_name": model_name.strip(), "task": task}
95
+ if outputs:
96
+ data["outputs"] = ",".join(outputs)
97
+ for key, value in (params or {}).items():
98
+ data[key] = str(value)
99
+
100
+ opened = None
101
+ try:
102
+ if isinstance(file, (str, os.PathLike)):
103
+ opened = open(file, "rb")
104
+ upload_name = filename or os.path.basename(str(file))
105
+ upload_obj: Any = opened
106
+ elif isinstance(file, (bytes, bytearray)):
107
+ upload_name = filename or "input"
108
+ upload_obj = file
109
+ else:
110
+ upload_name = filename or getattr(file, "name", "input")
111
+ upload_obj = file
112
+
113
+ mime = mimetypes.guess_type(upload_name)[0] or "application/octet-stream"
114
+ files = {"video": (upload_name, upload_obj, mime)}
115
+ return self._post(f"{API_PREFIX}/motion/submit", data=data, files=files)
116
+ finally:
117
+ if opened is not None:
118
+ opened.close()
119
+
120
+ def get_job(self, job_id: str) -> dict:
121
+ return self._get(f"{API_PREFIX}/motion/jobs/{job_id}")
122
+
123
+ def list_jobs(self, limit: int = 20, offset: int = 0) -> dict:
124
+ return self._get(f"{API_PREFIX}/motion/jobs", params={"limit": limit, "offset": offset})
125
+
126
+ def wait_for_job(
127
+ self,
128
+ job_id: str,
129
+ poll_interval: float = _DEFAULT_POLL_INTERVAL,
130
+ timeout: float = _DEFAULT_WAIT_TIMEOUT,
131
+ on_poll=None,
132
+ ) -> dict:
133
+ """Poll `get_job` until the job reaches `completed` or `failed`."""
134
+ deadline = time.monotonic() + timeout
135
+ while True:
136
+ job = self.get_job(job_id)
137
+ if on_poll is not None:
138
+ on_poll(job)
139
+ if job.get("status") == "completed":
140
+ return job
141
+ if job.get("status") == "failed":
142
+ raise MotionJobFailedError(job_id, job.get("error_message"))
143
+ remaining = deadline - time.monotonic()
144
+ if remaining <= 0:
145
+ raise MotionTimeoutError(job_id, timeout)
146
+ time.sleep(min(poll_interval, remaining))
147
+
148
+ def process(
149
+ self,
150
+ model_name: str,
151
+ file: str | bytes | BinaryIO,
152
+ task: str = "",
153
+ outputs: list[str] | None = None,
154
+ params: dict[str, Any] | None = None,
155
+ wait: bool = True,
156
+ poll_interval: float = _DEFAULT_POLL_INTERVAL,
157
+ timeout: float = _DEFAULT_WAIT_TIMEOUT,
158
+ ) -> dict:
159
+ """`submit_job` + `wait_for_job` in one call — the fire-and-get-result path."""
160
+ submitted = self.submit_job(model_name, file, task=task, outputs=outputs, params=params)
161
+ if not wait:
162
+ return submitted
163
+ return self.wait_for_job(submitted["job_id"], poll_interval=poll_interval, timeout=timeout)
164
+
165
+ # ─── HTTP layer ──────────────────────────────────────────────────────────
166
+
167
+ def _get(self, path: str, params: dict | None = None) -> Any:
168
+ res = self._session.get(f"{self.base_url}{path}", params=params, timeout=self.timeout)
169
+ return self._handle(res)
170
+
171
+ def _post(self, path: str, data: dict | None = None, files: dict | None = None) -> Any:
172
+ res = self._session.post(f"{self.base_url}{path}", data=data, files=files, timeout=self.timeout)
173
+ return self._handle(res)
174
+
175
+ @staticmethod
176
+ def _handle(res: requests.Response) -> Any:
177
+ if not res.ok:
178
+ try:
179
+ detail = res.json().get("detail", res.text)
180
+ except Exception:
181
+ detail = res.text
182
+ raise requests.HTTPError(f"{res.status_code} {res.reason}: {detail}", response=res)
183
+ return res.json()
@@ -8,6 +8,16 @@ try:
8
8
  except ImportError: # pragma: no cover
9
9
  raise SystemExit("PyYAML is required: pip install pyyaml")
10
10
 
11
+ # Shared by the build/deploy CLI (cli.py) and the consumer client (client.py) so
12
+ # there is exactly one place that knows the platform's default API host.
13
+ DEFAULT_API_URL = "https://adwrzdrdes.us-east-1.awsapprunner.com"
14
+
15
+
16
+ def resolve_api_url(explicit: Optional[str] = None) -> str:
17
+ """`explicit` arg > MOTION_API_URL env var > DEFAULT_API_URL, trailing slash stripped."""
18
+ url = explicit or os.environ.get("MOTION_API_URL", DEFAULT_API_URL)
19
+ return url.rstrip("/")
20
+
11
21
 
12
22
  @dataclass
13
23
  class BuildSpec:
@@ -48,6 +58,7 @@ class MotionConfig:
48
58
  category_id: Optional[int] = None # explicit id; wins over category code
49
59
  inference_model: str = "" # inference_model slug; defaults to `name`
50
60
  sample: str = "" # sample input used by deploy profiling
61
+ organization_id: str = "" # omit = personal deploy; set = org-scoped model
51
62
  input_type: str = "video" # video | image | text
52
63
  output_type: str = "mp4" # mp4 | glb | ...
53
64
  build: BuildSpec = field(default_factory=BuildSpec)
@@ -132,6 +143,7 @@ class MotionConfig:
132
143
  category_id=int(raw["category_id"]) if raw.get("category_id") is not None else None,
133
144
  inference_model=str(raw.get("inference_model", "") or "").strip(),
134
145
  sample=str(raw.get("sample", "") or "").strip(),
146
+ organization_id=str(raw.get("organization_id", "") or os.environ.get("MOTION_ORGANIZATION_ID", "") or "").strip(),
135
147
  input_type=str(raw.get("input_type", "video") or "video"),
136
148
  output_type=str(raw.get("output_type", "mp4") or "mp4"),
137
149
  build=build,
@@ -10,12 +10,23 @@ def generate_dockerfile(cfg: MotionConfig) -> str:
10
10
  base = f"nvidia/cuda:{b.cuda}.0-cudnn8-runtime-ubuntu22.04"
11
11
  # Ubuntu 22.04 ships only python3.10; the deadsnakes PPA provides any
12
12
  # other version, then we bootstrap pip for that interpreter via get-pip.
13
+ # `distutils` was removed from the stdlib in 3.12 (PEP 632) and deadsnakes
14
+ # does not publish a `python3.12+-distutils` package at all — requesting
15
+ # it 404s the whole apt-get install. Only pull it for <3.12 interpreters,
16
+ # where it's still a separate package pip's bootstrap can depend on.
17
+ try:
18
+ needs_distutils = tuple(int(p) for p in py.split(".")[:2]) < (3, 12)
19
+ except ValueError:
20
+ needs_distutils = True
21
+ py_pkgs_line = f" python{py} python{py}-dev" + (
22
+ f" python{py}-distutils \\\n" if needs_distutils else " \\\n"
23
+ )
13
24
  python_setup = (
14
25
  "RUN apt-get update && apt-get install -y --no-install-recommends \\\n"
15
26
  " software-properties-common gnupg2 curl ca-certificates \\\n"
16
27
  " && add-apt-repository -y ppa:deadsnakes/ppa \\\n"
17
28
  " && apt-get update && apt-get install -y --no-install-recommends \\\n"
18
- f" python{py} python{py}-dev python{py}-distutils \\\n"
29
+ f"{py_pkgs_line}"
19
30
  f" && ln -sf /usr/bin/python{py} /usr/bin/python \\\n"
20
31
  f" && curl -sS https://bootstrap.pypa.io/get-pip.py | python{py} \\\n"
21
32
  " && rm -rf /var/lib/apt/lists/*"
@@ -0,0 +1,238 @@
1
+ """Uniform HTTP server baked into every model image.
2
+
3
+ At boot it reads MOTION_PREDICT ("predict.py:ClassName"), imports the user's
4
+ predictor, runs setup() once, then serves /predict per request. Identical for
5
+ every model — that's the whole point of the packaging contract.
6
+
7
+ Run with: python -m motion.server (this is the container CMD)
8
+ """
9
+ import importlib.util
10
+ import inspect
11
+ import os
12
+ import tempfile
13
+ import threading
14
+ import time
15
+ from pathlib import Path
16
+
17
+ from .predictor import BasePredictor, Input
18
+
19
+
20
+ def _load_predictor() -> BasePredictor:
21
+ spec_str = os.environ.get("MOTION_PREDICT")
22
+ if not spec_str or ":" not in spec_str:
23
+ raise SystemExit("MOTION_PREDICT must be set to 'file.py:ClassName'")
24
+ file_part, class_name = spec_str.split(":", 1)
25
+ file_path = os.path.join("/src", file_part)
26
+
27
+ spec = importlib.util.spec_from_file_location("motion_user_predict", file_path)
28
+ module = importlib.util.module_from_spec(spec)
29
+ spec.loader.exec_module(module)
30
+ klass = getattr(module, class_name)
31
+ return klass()
32
+
33
+
34
+ def _materialize_file(raw_value, suffix_hint: str = "") -> Path:
35
+ """Turn a JSON-encodable value into a local file path.
36
+
37
+ Accepts, in order of preference for a live control loop:
38
+ - "data:<mime>;base64,<...>" inline bytes, no extra round trip
39
+ - {"b64": "...", "ext": ".png"} explicit inline bytes
40
+ - "/local/path" already on disk (bind mount / baked asset)
41
+ - "http(s)://..." downloaded (the original behaviour)
42
+
43
+ Inline forms matter because a per-timestep loop cannot afford to upload each
44
+ frame somewhere just to hand back a URL.
45
+ """
46
+ import base64
47
+
48
+ # {"b64": ..., "ext": ...}
49
+ if isinstance(raw_value, dict) and "b64" in raw_value:
50
+ ext = raw_value.get("ext") or suffix_hint or ""
51
+ fd, local = tempfile.mkstemp(suffix=ext)
52
+ with os.fdopen(fd, "wb") as f:
53
+ f.write(base64.b64decode(raw_value["b64"]))
54
+ return Path(local)
55
+
56
+ value = str(raw_value)
57
+
58
+ # data: URI
59
+ if value.startswith("data:"):
60
+ header, _, b64 = value.partition(",")
61
+ mime = header[5:].split(";")[0]
62
+ ext = suffix_hint or {
63
+ "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp",
64
+ "video/mp4": ".mp4", "application/json": ".json",
65
+ }.get(mime, "")
66
+ fd, local = tempfile.mkstemp(suffix=ext)
67
+ with os.fdopen(fd, "wb") as f:
68
+ f.write(base64.b64decode(b64))
69
+ return Path(local)
70
+
71
+ # already a local file
72
+ if not value.startswith(("http://", "https://")) and os.path.exists(value):
73
+ return Path(value)
74
+
75
+ # remote URL
76
+ import requests
77
+ suffix = os.path.splitext(value.split("?")[0])[1] or suffix_hint or ""
78
+ fd, local = tempfile.mkstemp(suffix=suffix)
79
+ os.close(fd)
80
+ with requests.get(value, stream=True) as r:
81
+ r.raise_for_status()
82
+ with open(local, "wb") as f:
83
+ for chunk in r.iter_content(chunk_size=1 << 20):
84
+ f.write(chunk)
85
+ return Path(local)
86
+
87
+
88
+ def _coerce(param, raw_value):
89
+ """Materialize Path inputs; validate via the Input descriptor."""
90
+ meta = param.default if isinstance(param.default, Input) else None
91
+ value = raw_value
92
+ is_path = param.annotation is Path or (
93
+ meta and meta.description and "path" in str(param.annotation)
94
+ )
95
+ # An optional Path sent explicitly as null (or the string a text-column default
96
+ # round-trips to) means "not supplied". Without this the URL branch below tried
97
+ # to GET "None" and predict() failed with a confusing MissingSchema.
98
+ if is_path and (
99
+ raw_value is None
100
+ or (isinstance(raw_value, str) and raw_value.strip().lower() in ("", "none", "null"))
101
+ ):
102
+ if meta and not meta.required:
103
+ return meta.default
104
+ raise ValueError(f"'{param.name}' is required but was sent as null")
105
+ if is_path:
106
+ value = _materialize_file(raw_value)
107
+ if meta:
108
+ value = meta.validate(param.name, value)
109
+ return value
110
+
111
+
112
+ def _jsonify(result):
113
+ """Represent predict()'s return value for an HTTP response.
114
+
115
+ A Path pointing at JSON is inlined so a caller does not need a second fetch
116
+ to read an action chunk — the whole point of a low-latency serving path.
117
+ Anything already JSON-encodable is passed through untouched; previously this
118
+ was `str(result)`, which turned a dict into a Python repr.
119
+ """
120
+ import json as _json
121
+
122
+ if isinstance(result, Path) or (
123
+ isinstance(result, str) and os.path.exists(str(result))
124
+ ):
125
+ path = Path(str(result))
126
+ if path.suffix.lower() == ".json":
127
+ try:
128
+ with open(path) as f:
129
+ return {"output": _json.load(f), "output_path": str(path)}
130
+ except (ValueError, OSError):
131
+ pass # fall through to the path form
132
+ return {"output": str(path), "output_path": str(path)}
133
+
134
+ if isinstance(result, (dict, list, int, float, bool)) or result is None:
135
+ return {"output": result}
136
+ return {"output": str(result)}
137
+
138
+
139
+ def _fetch_weights():
140
+ """Download weights declared in motion.yml `weights:` before setup().
141
+
142
+ Kept out of the image to avoid bloat / slow cold starts; pulled here at boot
143
+ into /src/<dest> (skipped if already present, e.g. a mounted volume).
144
+ """
145
+ manifest = "/opt/motion-runtime/weights.json"
146
+ if not os.path.exists(manifest):
147
+ return
148
+ import json
149
+ with open(manifest) as f:
150
+ items = json.load(f)
151
+ for w in items:
152
+ dest = os.path.join("/src", w["dest"])
153
+ if os.path.exists(dest):
154
+ print(f"[motion] weight present, skipping: {w['dest']}")
155
+ continue
156
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
157
+ url = w["url"]
158
+ print(f"[motion] fetching weight {url} -> {dest}")
159
+ if url.startswith("s3://"):
160
+ import boto3
161
+ bucket, key = url[5:].split("/", 1)
162
+ boto3.client("s3").download_file(bucket, key, dest)
163
+ else:
164
+ import requests
165
+ with requests.get(url, stream=True) as r:
166
+ r.raise_for_status()
167
+ with open(dest, "wb") as out:
168
+ for chunk in r.iter_content(chunk_size=1 << 20):
169
+ out.write(chunk)
170
+ print("[motion] weights ready")
171
+
172
+
173
+ def build_app():
174
+ from fastapi import FastAPI, HTTPException
175
+
176
+ _fetch_weights() # ← pull declared weights before the model loads
177
+ predictor = _load_predictor()
178
+ predictor.setup() # ← runs exactly once, before any request
179
+ sig = inspect.signature(predictor.predict)
180
+ # Most predictors hold non-reentrant state (a CUDA context, an ACT action
181
+ # queue). FastAPI runs sync handlers in a threadpool, so without this two
182
+ # concurrent calls would interleave inside one predictor.
183
+ predict_lock = threading.Lock()
184
+
185
+ app = FastAPI(title=os.environ.get("MOTION_NAME", "motion-model"))
186
+
187
+ @app.get("/health")
188
+ def health():
189
+ return {"status": "ok", "model": os.environ.get("MOTION_NAME")}
190
+
191
+ @app.get("/schema")
192
+ def schema():
193
+ out = {}
194
+ for name, p in sig.parameters.items():
195
+ m = p.default if isinstance(p.default, Input) else None
196
+ out[name] = {
197
+ "type": getattr(p.annotation, "__name__", str(p.annotation)),
198
+ "required": m.required if m else p.default is inspect._empty,
199
+ "default": None if (m is None or m.required) else m.default,
200
+ "description": m.description if m else None,
201
+ }
202
+ return out
203
+
204
+ @app.post("/predict")
205
+ def predict(payload: dict):
206
+ given = payload.get("input", payload) or {}
207
+ kwargs = {}
208
+ for name, p in sig.parameters.items():
209
+ m = p.default if isinstance(p.default, Input) else None
210
+ if name in given:
211
+ try:
212
+ kwargs[name] = _coerce(p, given[name])
213
+ except ValueError as e:
214
+ # A bad input is the caller's fault; 400 says so instead of
215
+ # surfacing as an opaque "prediction failed".
216
+ raise HTTPException(400, str(e))
217
+ elif m and not m.required:
218
+ kwargs[name] = m.default
219
+ elif m and m.required:
220
+ raise HTTPException(400, f"missing required input '{name}'")
221
+ started = time.time()
222
+ try:
223
+ with predict_lock:
224
+ result = predictor.predict(**kwargs)
225
+ except HTTPException:
226
+ raise
227
+ except Exception as e:
228
+ raise HTTPException(500, f"prediction failed: {e}")
229
+ body = _jsonify(result)
230
+ body["inference_ms"] = round((time.time() - started) * 1000, 1)
231
+ return body
232
+
233
+ return app
234
+
235
+
236
+ if __name__ == "__main__":
237
+ import uvicorn
238
+ uvicorn.run(build_app(), host="0.0.0.0", port=8000)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: motion-intelligence
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Author, build & deploy Python models for the Motion inference platform
5
5
  Project-URL: Homepage, https://github.com/devathub9/Motion-packages
6
6
  Project-URL: Repository, https://github.com/devathub9/Motion-packages
@@ -4,6 +4,7 @@ motion/__init__.py
4
4
  motion/batch.py
5
5
  motion/categories.py
6
6
  motion/cli.py
7
+ motion/client.py
7
8
  motion/config.py
8
9
  motion/dockerfile.py
9
10
  motion/predictor.py
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "motion-intelligence"
3
- version = "0.2.0"
3
+ version = "0.4.0"
4
4
  description = "Author, build & deploy Python models for the Motion inference platform"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -1,10 +0,0 @@
1
- """motion — package & deploy models to the Motion inference platform.
2
-
3
- Authors import from here in their predict.py:
4
-
5
- from motion import BasePredictor, Input, Path
6
- """
7
- from .predictor import BasePredictor, Input, Path
8
-
9
- __all__ = ["BasePredictor", "Input", "Path"]
10
- __version__ = "0.1.0"
@@ -1,138 +0,0 @@
1
- """Uniform HTTP server baked into every model image.
2
-
3
- At boot it reads MOTION_PREDICT ("predict.py:ClassName"), imports the user's
4
- predictor, runs setup() once, then serves /predict per request. Identical for
5
- every model — that's the whole point of the packaging contract.
6
-
7
- Run with: python -m motion.server (this is the container CMD)
8
- """
9
- import importlib.util
10
- import inspect
11
- import os
12
- import tempfile
13
- from pathlib import Path
14
-
15
- from .predictor import BasePredictor, Input
16
-
17
-
18
- def _load_predictor() -> BasePredictor:
19
- spec_str = os.environ.get("MOTION_PREDICT")
20
- if not spec_str or ":" not in spec_str:
21
- raise SystemExit("MOTION_PREDICT must be set to 'file.py:ClassName'")
22
- file_part, class_name = spec_str.split(":", 1)
23
- file_path = os.path.join("/src", file_part)
24
-
25
- spec = importlib.util.spec_from_file_location("motion_user_predict", file_path)
26
- module = importlib.util.module_from_spec(spec)
27
- spec.loader.exec_module(module)
28
- klass = getattr(module, class_name)
29
- return klass()
30
-
31
-
32
- def _coerce(param, raw_value):
33
- """Download Path inputs; validate via the Input descriptor."""
34
- meta = param.default if isinstance(param.default, Input) else None
35
- value = raw_value
36
- if param.annotation is Path or (meta and meta.description and "path" in str(param.annotation)):
37
- # input is a file → fetch URL to a local temp path
38
- import requests
39
- suffix = os.path.splitext(str(raw_value).split("?")[0])[1] or ""
40
- fd, local = tempfile.mkstemp(suffix=suffix)
41
- os.close(fd)
42
- with requests.get(raw_value, stream=True) as r:
43
- r.raise_for_status()
44
- with open(local, "wb") as f:
45
- for chunk in r.iter_content(chunk_size=1 << 20):
46
- f.write(chunk)
47
- value = Path(local)
48
- if meta:
49
- value = meta.validate(param.name, value)
50
- return value
51
-
52
-
53
- def _fetch_weights():
54
- """Download weights declared in motion.yml `weights:` before setup().
55
-
56
- Kept out of the image to avoid bloat / slow cold starts; pulled here at boot
57
- into /src/<dest> (skipped if already present, e.g. a mounted volume).
58
- """
59
- manifest = "/opt/motion-runtime/weights.json"
60
- if not os.path.exists(manifest):
61
- return
62
- import json
63
- with open(manifest) as f:
64
- items = json.load(f)
65
- for w in items:
66
- dest = os.path.join("/src", w["dest"])
67
- if os.path.exists(dest):
68
- print(f"[motion] weight present, skipping: {w['dest']}")
69
- continue
70
- os.makedirs(os.path.dirname(dest), exist_ok=True)
71
- url = w["url"]
72
- print(f"[motion] fetching weight {url} -> {dest}")
73
- if url.startswith("s3://"):
74
- import boto3
75
- bucket, key = url[5:].split("/", 1)
76
- boto3.client("s3").download_file(bucket, key, dest)
77
- else:
78
- import requests
79
- with requests.get(url, stream=True) as r:
80
- r.raise_for_status()
81
- with open(dest, "wb") as out:
82
- for chunk in r.iter_content(chunk_size=1 << 20):
83
- out.write(chunk)
84
- print("[motion] weights ready")
85
-
86
-
87
- def build_app():
88
- from fastapi import FastAPI, HTTPException
89
-
90
- _fetch_weights() # ← pull declared weights before the model loads
91
- predictor = _load_predictor()
92
- predictor.setup() # ← runs exactly once, before any request
93
- sig = inspect.signature(predictor.predict)
94
-
95
- app = FastAPI(title=os.environ.get("MOTION_NAME", "motion-model"))
96
-
97
- @app.get("/health")
98
- def health():
99
- return {"status": "ok", "model": os.environ.get("MOTION_NAME")}
100
-
101
- @app.get("/schema")
102
- def schema():
103
- out = {}
104
- for name, p in sig.parameters.items():
105
- m = p.default if isinstance(p.default, Input) else None
106
- out[name] = {
107
- "type": getattr(p.annotation, "__name__", str(p.annotation)),
108
- "required": m.required if m else p.default is inspect._empty,
109
- "default": None if (m is None or m.required) else m.default,
110
- "description": m.description if m else None,
111
- }
112
- return out
113
-
114
- @app.post("/predict")
115
- def predict(payload: dict):
116
- given = payload.get("input", payload) or {}
117
- kwargs = {}
118
- for name, p in sig.parameters.items():
119
- m = p.default if isinstance(p.default, Input) else None
120
- if name in given:
121
- kwargs[name] = _coerce(p, given[name])
122
- elif m and not m.required:
123
- kwargs[name] = m.default
124
- elif m and m.required:
125
- raise HTTPException(400, f"missing required input '{name}'")
126
- try:
127
- result = predictor.predict(**kwargs)
128
- except Exception as e:
129
- raise HTTPException(500, f"prediction failed: {e}")
130
- # Phase 0: return local path. Phase 'deploy' will upload to S3 here.
131
- return {"output": str(result)}
132
-
133
- return app
134
-
135
-
136
- if __name__ == "__main__":
137
- import uvicorn
138
- uvicorn.run(build_app(), host="0.0.0.0", port=8000)