motion-intelligence 0.2.0__tar.gz → 0.5.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.5.0}/PKG-INFO +1 -1
  2. motion_intelligence-0.5.0/motion/__init__.py +22 -0
  3. {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/batch.py +101 -14
  4. {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/cli.py +226 -4
  5. motion_intelligence-0.5.0/motion/client.py +336 -0
  6. {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/config.py +12 -0
  7. {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/dockerfile.py +12 -1
  8. motion_intelligence-0.5.0/motion/server.py +238 -0
  9. {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/PKG-INFO +1 -1
  10. {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/SOURCES.txt +1 -0
  11. {motion_intelligence-0.2.0 → motion_intelligence-0.5.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.5.0}/README.md +0 -0
  15. {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/categories.py +0 -0
  16. {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/predictor.py +0 -0
  17. {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/profile.py +0 -0
  18. {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/weight_upload.py +0 -0
  19. {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/dependency_links.txt +0 -0
  20. {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/entry_points.txt +0 -0
  21. {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/requires.txt +0 -0
  22. {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/top_level.txt +0 -0
  23. {motion_intelligence-0.2.0 → motion_intelligence-0.5.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.5.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,22 @@
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 (
12
+ MotionClient, MotionJobFailedError, MotionTimeoutError,
13
+ MotionWorkflowRunFailedError, MotionWorkflowRunTimeoutError,
14
+ )
15
+ from .predictor import BasePredictor, Input, Path
16
+
17
+ __all__ = [
18
+ "BasePredictor", "Input", "Path",
19
+ "MotionClient", "MotionJobFailedError", "MotionTimeoutError",
20
+ "MotionWorkflowRunFailedError", "MotionWorkflowRunTimeoutError",
21
+ ]
22
+ __version__ = "0.5.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 = ""
@@ -455,6 +471,170 @@ def _extract_input_default(func_node, arg_name: str) -> dict:
455
471
  return {}
456
472
 
457
473
 
474
+ def _parse_kv_list(pairs):
475
+ """['fps=24', 'steps=20'] -> {'fps': '24', 'steps': '20'}."""
476
+ out = {}
477
+ for item in pairs or []:
478
+ if "=" not in item:
479
+ print(_c(f" ✗ invalid '{item}' — expected KEY=VALUE", "31"))
480
+ raise SystemExit(1)
481
+ key, _, value = item.partition("=")
482
+ out[key] = value
483
+ return out
484
+
485
+
486
+ def _print_result(result):
487
+ for artifact in result.get("artifacts") or []:
488
+ url = artifact.get("url") if isinstance(artifact, dict) else artifact
489
+ if url:
490
+ print(f" artifact: {url}")
491
+ if result.get("output_url"):
492
+ print(f" output: {result['output_url']}")
493
+
494
+
495
+ def cmd_run_model(args):
496
+ from .client import MotionClient
497
+
498
+ print(_c(f"▶ motion run model ({args.model_code})"))
499
+ try:
500
+ client = MotionClient()
501
+ except ValueError as e:
502
+ print(_c(f" ✗ {e}", "31"))
503
+ return 1
504
+
505
+ outputs = args.outputs.split(",") if args.outputs else None
506
+ try:
507
+ submitted = client.submit_job(
508
+ args.model_code, args.file, task=args.task or "",
509
+ outputs=outputs, params=_parse_kv_list(args.param),
510
+ )
511
+ except Exception as e:
512
+ print(_c(f" ✗ submit failed: {e}", "31"))
513
+ return 1
514
+ print(f" ✓ submitted job_id={submitted['job_id']}")
515
+
516
+ if args.no_wait:
517
+ print(f" poll: GET /api/v1/motion/jobs/{submitted['job_id']}")
518
+ return 0
519
+
520
+ print(" → waiting for the job to finish …")
521
+ try:
522
+ result = client.wait_for_job(
523
+ submitted["job_id"], on_poll=lambda j: print(f" {j.get('status')}…"),
524
+ )
525
+ except Exception as e:
526
+ print(_c(f" ✗ {e}", "31"))
527
+ return 1
528
+
529
+ print(_c("\n✓ job completed", "32"))
530
+ _print_result(result)
531
+ return 0
532
+
533
+
534
+ def cmd_run_workflow(args):
535
+ from .client import MotionClient
536
+
537
+ print(_c(f"▶ motion run workflow ({args.workflow_id})"))
538
+ try:
539
+ client = MotionClient()
540
+ except ValueError as e:
541
+ print(_c(f" ✗ {e}", "31"))
542
+ return 1
543
+
544
+ try:
545
+ started = client.run_workflow(
546
+ args.workflow_id, file=args.file, texts=_parse_kv_list(args.text),
547
+ )
548
+ except Exception as e:
549
+ print(_c(f" ✗ run failed: {e}", "31"))
550
+ return 1
551
+
552
+ run_id = started.get("run_id")
553
+ job_id = started.get("job_id")
554
+ print(f" ✓ started {'run_id=' + run_id if run_id else 'job_id=' + job_id}")
555
+
556
+ if args.no_wait:
557
+ return 0
558
+
559
+ print(" → waiting for the workflow to finish …")
560
+ try:
561
+ if run_id:
562
+ result = client.wait_for_workflow_run(
563
+ run_id,
564
+ on_poll=lambda r: print(
565
+ f" step {r.get('current_step', 0) + 1}/{r.get('step_count', 1)} ({r.get('status')})…"
566
+ ),
567
+ )
568
+ else:
569
+ result = client.wait_for_job(job_id, on_poll=lambda j: print(f" {j.get('status')}…"))
570
+ except Exception as e:
571
+ print(_c(f" ✗ {e}", "31"))
572
+ return 1
573
+
574
+ print(_c("\n✓ workflow completed", "32"))
575
+ _print_result(result)
576
+ return 0
577
+
578
+
579
+ def cmd_workflow_deploy(args):
580
+ from .client import MotionClient
581
+
582
+ print(_c(f"▶ motion workflow deploy ({args.workflow_id})"))
583
+ try:
584
+ client = MotionClient()
585
+ result = client.deploy_workflow(args.workflow_id, notes=args.notes)
586
+ except Exception as e:
587
+ print(_c(f" ✗ {e}", "31"))
588
+ return 1
589
+
590
+ print(_c(f"\n✓ deployed v{result['version']}", "32"))
591
+ if result.get("notes"):
592
+ print(f" notes: {result['notes']}")
593
+ print(f" deployed_at: {result['deployed_at']}")
594
+ print(" API-key calls to this workflow's /run now use this version.")
595
+ return 0
596
+
597
+
598
+ def cmd_workflow_deployments(args):
599
+ from .client import MotionClient
600
+
601
+ try:
602
+ client = MotionClient()
603
+ result = client.list_workflow_deployments(args.workflow_id)
604
+ except Exception as e:
605
+ print(_c(f"✗ {e}", "31"))
606
+ return 1
607
+
608
+ deployments = result.get("deployments", [])
609
+ if not deployments:
610
+ print("No deployments yet — run `motion workflow deploy <workflow_id>` first.")
611
+ return 0
612
+
613
+ print(_c(f"▶ deploy history ({args.workflow_id})"))
614
+ for d in deployments:
615
+ marker = _c(" (current)", "32") if d.get("is_current") else ""
616
+ rollback_note = f" [rollback of v{d['rollback_of_version']}]" if d.get("rollback_of_version") else ""
617
+ print(f" v{d['version']}{marker} {d['deployed_at']}{rollback_note}")
618
+ if d.get("notes"):
619
+ print(f" {d['notes']}")
620
+ return 0
621
+
622
+
623
+ def cmd_workflow_rollback(args):
624
+ from .client import MotionClient
625
+
626
+ print(_c(f"▶ motion workflow rollback ({args.workflow_id} → v{args.version})"))
627
+ try:
628
+ client = MotionClient()
629
+ result = client.rollback_workflow(args.workflow_id, args.version)
630
+ except Exception as e:
631
+ print(_c(f" ✗ {e}", "31"))
632
+ return 1
633
+
634
+ print(_c(f"\n✓ rolled back — now live as v{result['version']}", "32"))
635
+ return 0
636
+
637
+
458
638
  def main(argv=None):
459
639
  p = argparse.ArgumentParser(prog="motion", description="Build & deploy models for Motion inference")
460
640
  sub = p.add_subparsers(dest="command", required=True)
@@ -493,8 +673,50 @@ def main(argv=None):
493
673
  "(warm host), or dedicated_instance (disabled for now). "
494
674
  "Omit to keep the default/existing value.",
495
675
  )
676
+ d.add_argument(
677
+ "--organization-id",
678
+ dest="organization_id",
679
+ default="",
680
+ help="org UUID for org-scoped deploy (or MOTION_ORGANIZATION_ID / motion.yml organization_id)",
681
+ )
496
682
  d.set_defaults(func=cmd_deploy)
497
683
 
684
+ r = sub.add_parser("run", help="run a deployed model or workflow and wait for the result")
685
+ run_sub = r.add_subparsers(dest="run_target", required=True)
686
+
687
+ rm = run_sub.add_parser("model", help="run a deployed model")
688
+ rm.add_argument("model_code", help="e.g. jay/pose-estimation")
689
+ rm.add_argument("file", help="local path to the input file")
690
+ rm.add_argument("--task", default="", help="task/prompt text, if the model takes one")
691
+ rm.add_argument("--param", action="append", metavar="KEY=VALUE", help="extra predict() param (repeatable)")
692
+ rm.add_argument("--outputs", help="comma-separated output names to request")
693
+ rm.add_argument("--no-wait", action="store_true", help="submit and exit, don't wait for completion")
694
+ rm.set_defaults(func=cmd_run_model)
695
+
696
+ rw = run_sub.add_parser("workflow", help="run a deployed workflow (see `motion workflow deploy` first)")
697
+ rw.add_argument("workflow_id")
698
+ rw.add_argument("file", nargs="?", default=None, help="local path to the input file, if the workflow needs one")
699
+ rw.add_argument("--text", action="append", metavar="KEY=VALUE", help="named text input (repeatable)")
700
+ rw.add_argument("--no-wait", action="store_true", help="start and exit, don't wait for completion")
701
+ rw.set_defaults(func=cmd_run_workflow)
702
+
703
+ w = sub.add_parser("workflow", help="deploy, version, and roll back a workflow")
704
+ workflow_sub = w.add_subparsers(dest="workflow_action", required=True)
705
+
706
+ wd = workflow_sub.add_parser("deploy", help="deploy the current draft as a new live version")
707
+ wd.add_argument("workflow_id")
708
+ wd.add_argument("--notes", help="optional changelog note for this deploy")
709
+ wd.set_defaults(func=cmd_workflow_deploy)
710
+
711
+ wl = workflow_sub.add_parser("deployments", help="list a workflow's deploy/version history")
712
+ wl.add_argument("workflow_id")
713
+ wl.set_defaults(func=cmd_workflow_deployments)
714
+
715
+ wb = workflow_sub.add_parser("rollback", help="roll back to an earlier deployed version")
716
+ wb.add_argument("workflow_id")
717
+ wb.add_argument("version", type=int)
718
+ wb.set_defaults(func=cmd_workflow_rollback)
719
+
498
720
  args = p.parse_args(argv)
499
721
  return args.func(args)
500
722
 
@@ -0,0 +1,336 @@
1
+ """MotionClient — call a deployed model or workflow from Python, the consumer
2
+ side of `motion`.
3
+
4
+ from motion import MotionClient
5
+
6
+ client = MotionClient(api_key="motion_live_...")
7
+ job = client.submit_job("jay/pose-estimation", "video.mp4", params={"fps": 24})
8
+ result = client.wait_for_job(job["job_id"])
9
+ print(result["artifacts"])
10
+
11
+ # A workflow built in the dashboard, once deployed there (or via
12
+ # client.deploy_workflow(...)):
13
+ result = client.process_workflow(workflow_id, "video.mp4")
14
+
15
+ `motion build` / `motion deploy` (cli.py) and `BasePredictor` (predictor.py) are for
16
+ authoring and shipping a model; this module is for running one that's already
17
+ deployed — same package, the other side of the counter.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import mimetypes
22
+ import os
23
+ import time
24
+ from typing import Any, BinaryIO
25
+
26
+ import requests
27
+
28
+ from .config import resolve_api_url
29
+
30
+ API_PREFIX = "/api/v1"
31
+ _DEFAULT_POLL_INTERVAL = 3.0
32
+ _DEFAULT_WAIT_TIMEOUT = 600.0
33
+
34
+
35
+ class MotionJobFailedError(RuntimeError):
36
+ def __init__(self, job_id: str, error_message: str | None):
37
+ self.job_id = job_id
38
+ self.error_message = error_message
39
+ super().__init__(f"job {job_id} failed: {error_message or 'no error message'}")
40
+
41
+
42
+ class MotionTimeoutError(TimeoutError):
43
+ def __init__(self, job_id: str, timeout: float):
44
+ self.job_id = job_id
45
+ super().__init__(f"job {job_id} did not finish within {timeout}s")
46
+
47
+
48
+ class MotionWorkflowRunFailedError(RuntimeError):
49
+ def __init__(self, run_id: str, error_message: str | None):
50
+ self.run_id = run_id
51
+ self.error_message = error_message
52
+ super().__init__(f"workflow run {run_id} failed: {error_message or 'no error message'}")
53
+
54
+
55
+ class MotionWorkflowRunTimeoutError(TimeoutError):
56
+ def __init__(self, run_id: str, timeout: float):
57
+ self.run_id = run_id
58
+ super().__init__(f"workflow run {run_id} did not finish within {timeout}s")
59
+
60
+
61
+ class MotionClient:
62
+ """Thin wrapper over the Motion inference API.
63
+
64
+ `api_key` falls back to the `MOTION_API_KEY` env var; `base_url` falls back to
65
+ `MOTION_API_URL` env var, then the platform default (same resolution `motion`
66
+ CLI uses for `build`/`deploy`).
67
+ """
68
+
69
+ def __init__(self, api_key: str | None = None, base_url: str | None = None,
70
+ timeout: float = 120.0):
71
+ self.api_key = (api_key or os.environ.get("MOTION_API_KEY", "")).strip()
72
+ if not self.api_key:
73
+ raise ValueError(
74
+ "MotionClient: `api_key` is required (or set MOTION_API_KEY). "
75
+ "Get your key from the Motion dashboard."
76
+ )
77
+ self.base_url = resolve_api_url(base_url)
78
+ self.timeout = timeout
79
+ self._session = requests.Session()
80
+ self._session.headers["X-API-Key"] = self.api_key
81
+
82
+ # ─── Models ──────────────────────────────────────────────────────────────
83
+
84
+ def list_models(self) -> list[dict]:
85
+ """Public catalog — every active platform and deployed model."""
86
+ return self._get(f"{API_PREFIX}/motion/models")
87
+
88
+ def get_model_params(self, model_code: str) -> dict:
89
+ """Input params (name/type/default/constraints) for one model_code."""
90
+ return self._get(f"{API_PREFIX}/motion/models/{model_code}/params")
91
+
92
+ # ─── Jobs ────────────────────────────────────────────────────────────────
93
+
94
+ def submit_job(
95
+ self,
96
+ model_name: str,
97
+ file: str | bytes | BinaryIO,
98
+ task: str = "",
99
+ outputs: list[str] | None = None,
100
+ params: dict[str, Any] | None = None,
101
+ filename: str | None = None,
102
+ ) -> dict:
103
+ """Submit a job. `file` is a local path, raw bytes, or an open file object.
104
+
105
+ `params` fills any extra fields the model's predict() declares (e.g.
106
+ `{"fps": 24}`) beyond the input file — see GET /motion/models/{code}/params
107
+ for what a given model accepts.
108
+ """
109
+ if not model_name or not model_name.strip():
110
+ raise ValueError("`model_name` must be a non-empty string")
111
+
112
+ data = {"model_name": model_name.strip(), "task": task}
113
+ if outputs:
114
+ data["outputs"] = ",".join(outputs)
115
+ for key, value in (params or {}).items():
116
+ data[key] = str(value)
117
+
118
+ opened = None
119
+ try:
120
+ if isinstance(file, (str, os.PathLike)):
121
+ opened = open(file, "rb")
122
+ upload_name = filename or os.path.basename(str(file))
123
+ upload_obj: Any = opened
124
+ elif isinstance(file, (bytes, bytearray)):
125
+ upload_name = filename or "input"
126
+ upload_obj = file
127
+ else:
128
+ upload_name = filename or getattr(file, "name", "input")
129
+ upload_obj = file
130
+
131
+ mime = mimetypes.guess_type(upload_name)[0] or "application/octet-stream"
132
+ files = {"video": (upload_name, upload_obj, mime)}
133
+ return self._post(f"{API_PREFIX}/motion/submit", data=data, files=files)
134
+ finally:
135
+ if opened is not None:
136
+ opened.close()
137
+
138
+ def get_job(self, job_id: str) -> dict:
139
+ return self._get(f"{API_PREFIX}/motion/jobs/{job_id}")
140
+
141
+ def list_jobs(self, limit: int = 20, offset: int = 0) -> dict:
142
+ return self._get(f"{API_PREFIX}/motion/jobs", params={"limit": limit, "offset": offset})
143
+
144
+ def wait_for_job(
145
+ self,
146
+ job_id: str,
147
+ poll_interval: float = _DEFAULT_POLL_INTERVAL,
148
+ timeout: float = _DEFAULT_WAIT_TIMEOUT,
149
+ on_poll=None,
150
+ ) -> dict:
151
+ """Poll `get_job` until the job reaches `completed` or `failed`."""
152
+ deadline = time.monotonic() + timeout
153
+ while True:
154
+ job = self.get_job(job_id)
155
+ if on_poll is not None:
156
+ on_poll(job)
157
+ if job.get("status") == "completed":
158
+ return job
159
+ if job.get("status") == "failed":
160
+ raise MotionJobFailedError(job_id, job.get("error_message"))
161
+ remaining = deadline - time.monotonic()
162
+ if remaining <= 0:
163
+ raise MotionTimeoutError(job_id, timeout)
164
+ time.sleep(min(poll_interval, remaining))
165
+
166
+ def process(
167
+ self,
168
+ model_name: str,
169
+ file: str | bytes | BinaryIO,
170
+ task: str = "",
171
+ outputs: list[str] | None = None,
172
+ params: dict[str, Any] | None = None,
173
+ wait: bool = True,
174
+ poll_interval: float = _DEFAULT_POLL_INTERVAL,
175
+ timeout: float = _DEFAULT_WAIT_TIMEOUT,
176
+ ) -> dict:
177
+ """`submit_job` + `wait_for_job` in one call — the fire-and-get-result path."""
178
+ submitted = self.submit_job(model_name, file, task=task, outputs=outputs, params=params)
179
+ if not wait:
180
+ return submitted
181
+ return self.wait_for_job(submitted["job_id"], poll_interval=poll_interval, timeout=timeout)
182
+
183
+ # ─── Workflows ───────────────────────────────────────────────────────────
184
+ # A workflow is built visually in the dashboard, then deployed (see
185
+ # deploy_workflow below) — running it here always executes that deployed
186
+ # snapshot, never a draft still being edited.
187
+
188
+ def run_workflow(
189
+ self,
190
+ workflow_id: str,
191
+ file: str | bytes | BinaryIO | None = None,
192
+ files: dict[str, str | bytes | BinaryIO] | None = None,
193
+ text: str | None = None,
194
+ texts: dict[str, str] | None = None,
195
+ ) -> dict:
196
+ """Run a deployed workflow. Returns `{"job_id": ...}` for a single-model
197
+ workflow — poll with `wait_for_job` — or `{"run_id": ...}` for a
198
+ multi-model pipeline — poll with `wait_for_workflow_run`. See
199
+ `process_workflow` for the version that waits for you either way.
200
+
201
+ `file`/`text` cover the common single-input case; `files`/`texts` (each
202
+ a dict keyed by the workflow's input name) cover workflows with more
203
+ than one named input — one file per name.
204
+ """
205
+ if not workflow_id or not workflow_id.strip():
206
+ raise ValueError("`workflow_id` must be a non-empty string")
207
+
208
+ data: dict[str, str] = dict(texts or {})
209
+ if text is not None and "text" not in data:
210
+ data["text"] = text
211
+
212
+ opened: list[Any] = []
213
+ try:
214
+ multipart_files = {}
215
+ for key, f in (files or {}).items():
216
+ multipart_files[key] = self._prepare_file(f, opened)
217
+ if file is not None and "file" not in multipart_files:
218
+ multipart_files["file"] = self._prepare_file(file, opened)
219
+
220
+ if not multipart_files and not data:
221
+ raise ValueError("Provide at least one input file (file/files) or text (text/texts).")
222
+
223
+ return self._post(
224
+ f"{API_PREFIX}/workflow/{workflow_id.strip()}/run", data=data, files=multipart_files,
225
+ )
226
+ finally:
227
+ for f in opened:
228
+ f.close()
229
+
230
+ def get_workflow_run(self, run_id: str) -> dict:
231
+ return self._get(f"{API_PREFIX}/workflow/runs/{run_id}")
232
+
233
+ def wait_for_workflow_run(
234
+ self,
235
+ run_id: str,
236
+ poll_interval: float = _DEFAULT_POLL_INTERVAL,
237
+ timeout: float = _DEFAULT_WAIT_TIMEOUT,
238
+ on_poll=None,
239
+ ) -> dict:
240
+ """Poll `get_workflow_run` until the run reaches `completed` or `failed`."""
241
+ deadline = time.monotonic() + timeout
242
+ while True:
243
+ run = self.get_workflow_run(run_id)
244
+ if on_poll is not None:
245
+ on_poll(run)
246
+ if run.get("status") == "completed":
247
+ return run
248
+ if run.get("status") == "failed":
249
+ raise MotionWorkflowRunFailedError(run_id, run.get("error_message"))
250
+ remaining = deadline - time.monotonic()
251
+ if remaining <= 0:
252
+ raise MotionWorkflowRunTimeoutError(run_id, timeout)
253
+ time.sleep(min(poll_interval, remaining))
254
+
255
+ def process_workflow(
256
+ self,
257
+ workflow_id: str,
258
+ file: str | bytes | BinaryIO | None = None,
259
+ files: dict[str, str | bytes | BinaryIO] | None = None,
260
+ text: str | None = None,
261
+ texts: dict[str, str] | None = None,
262
+ wait: bool = True,
263
+ poll_interval: float = _DEFAULT_POLL_INTERVAL,
264
+ timeout: float = _DEFAULT_WAIT_TIMEOUT,
265
+ ) -> dict:
266
+ """`run_workflow` + wait for it in one call — the fire-and-get-result path.
267
+
268
+ Waits on whichever the run started — `run_id` for a multi-model
269
+ pipeline, `job_id` for a single model — so callers don't need to know
270
+ which ahead of time.
271
+ """
272
+ started = self.run_workflow(workflow_id, file=file, files=files, text=text, texts=texts)
273
+ if not wait:
274
+ return started
275
+ if started.get("run_id"):
276
+ return self.wait_for_workflow_run(started["run_id"], poll_interval=poll_interval, timeout=timeout)
277
+ return self.wait_for_job(started["job_id"], poll_interval=poll_interval, timeout=timeout)
278
+
279
+ # ─── Workflow deployments ────────────────────────────────────────────────
280
+
281
+ def deploy_workflow(self, workflow_id: str, notes: str | None = None) -> dict:
282
+ """Snapshot the workflow's current draft (as last saved in the dashboard)
283
+ as a new live version. `run_workflow`/`process_workflow` execute this
284
+ snapshot until the workflow is redeployed or rolled back."""
285
+ body = {"notes": notes} if notes is not None else {}
286
+ return self._post_json(f"{API_PREFIX}/workflow/{workflow_id}/deploy", json=body)
287
+
288
+ def list_workflow_deployments(self, workflow_id: str) -> dict:
289
+ """Version history: `{"deployments": [...]}`, newest first."""
290
+ return self._get(f"{API_PREFIX}/workflow/{workflow_id}/deployments")
291
+
292
+ def rollback_workflow(self, workflow_id: str, version: int) -> dict:
293
+ """Restore an earlier deployed version as a brand-new version (history
294
+ is never rewritten)."""
295
+ return self._post_json(f"{API_PREFIX}/workflow/{workflow_id}/deployments/{version}/rollback")
296
+
297
+ # ─── HTTP layer ──────────────────────────────────────────────────────────
298
+
299
+ @staticmethod
300
+ def _prepare_file(file: str | bytes | BinaryIO, opened: list[Any]) -> tuple[str, Any, str]:
301
+ """Normalize a path/bytes/file-object into a `requests`-style file tuple.
302
+ Any opened file handle is appended to `opened` so the caller can close it."""
303
+ if isinstance(file, (str, os.PathLike)):
304
+ obj: Any = open(file, "rb")
305
+ opened.append(obj)
306
+ name = os.path.basename(str(file))
307
+ elif isinstance(file, (bytes, bytearray)):
308
+ obj = file
309
+ name = "input"
310
+ else:
311
+ obj = file
312
+ name = getattr(file, "name", "input")
313
+ mime = mimetypes.guess_type(name)[0] or "application/octet-stream"
314
+ return name, obj, mime
315
+
316
+ def _get(self, path: str, params: dict | None = None) -> Any:
317
+ res = self._session.get(f"{self.base_url}{path}", params=params, timeout=self.timeout)
318
+ return self._handle(res)
319
+
320
+ def _post(self, path: str, data: dict | None = None, files: dict | None = None) -> Any:
321
+ res = self._session.post(f"{self.base_url}{path}", data=data, files=files, timeout=self.timeout)
322
+ return self._handle(res)
323
+
324
+ def _post_json(self, path: str, json: dict | None = None) -> Any:
325
+ res = self._session.post(f"{self.base_url}{path}", json=json, timeout=self.timeout)
326
+ return self._handle(res)
327
+
328
+ @staticmethod
329
+ def _handle(res: requests.Response) -> Any:
330
+ if not res.ok:
331
+ try:
332
+ detail = res.json().get("detail", res.text)
333
+ except Exception:
334
+ detail = res.text
335
+ raise requests.HTTPError(f"{res.status_code} {res.reason}: {detail}", response=res)
336
+ 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.5.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.5.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)