motion-intelligence 0.4.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 (21) hide show
  1. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/PKG-INFO +1 -1
  2. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion/__init__.py +6 -2
  3. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion/cli.py +200 -0
  4. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion/client.py +154 -1
  5. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/PKG-INFO +1 -1
  6. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/pyproject.toml +1 -1
  7. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/README.md +0 -0
  8. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion/batch.py +0 -0
  9. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion/categories.py +0 -0
  10. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion/config.py +0 -0
  11. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion/dockerfile.py +0 -0
  12. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion/predictor.py +0 -0
  13. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion/profile.py +0 -0
  14. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion/server.py +0 -0
  15. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion/weight_upload.py +0 -0
  16. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/SOURCES.txt +0 -0
  17. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/dependency_links.txt +0 -0
  18. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/entry_points.txt +0 -0
  19. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/requires.txt +0 -0
  20. {motion_intelligence-0.4.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/top_level.txt +0 -0
  21. {motion_intelligence-0.4.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.4.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
@@ -8,11 +8,15 @@ Callers use the client to run a deployed model:
8
8
 
9
9
  from motion import MotionClient
10
10
  """
11
- from .client import MotionClient, MotionJobFailedError, MotionTimeoutError
11
+ from .client import (
12
+ MotionClient, MotionJobFailedError, MotionTimeoutError,
13
+ MotionWorkflowRunFailedError, MotionWorkflowRunTimeoutError,
14
+ )
12
15
  from .predictor import BasePredictor, Input, Path
13
16
 
14
17
  __all__ = [
15
18
  "BasePredictor", "Input", "Path",
16
19
  "MotionClient", "MotionJobFailedError", "MotionTimeoutError",
20
+ "MotionWorkflowRunFailedError", "MotionWorkflowRunTimeoutError",
17
21
  ]
18
- __version__ = "0.4.0"
22
+ __version__ = "0.5.0"
@@ -471,6 +471,170 @@ def _extract_input_default(func_node, arg_name: str) -> dict:
471
471
  return {}
472
472
 
473
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
+
474
638
  def main(argv=None):
475
639
  p = argparse.ArgumentParser(prog="motion", description="Build & deploy models for Motion inference")
476
640
  sub = p.add_subparsers(dest="command", required=True)
@@ -517,6 +681,42 @@ def main(argv=None):
517
681
  )
518
682
  d.set_defaults(func=cmd_deploy)
519
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
+
520
720
  args = p.parse_args(argv)
521
721
  return args.func(args)
522
722
 
@@ -1,4 +1,5 @@
1
- """MotionClient — call a deployed model from Python, the consumer side of `motion`.
1
+ """MotionClient — call a deployed model or workflow from Python, the consumer
2
+ side of `motion`.
2
3
 
3
4
  from motion import MotionClient
4
5
 
@@ -7,6 +8,10 @@
7
8
  result = client.wait_for_job(job["job_id"])
8
9
  print(result["artifacts"])
9
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
+
10
15
  `motion build` / `motion deploy` (cli.py) and `BasePredictor` (predictor.py) are for
11
16
  authoring and shipping a model; this module is for running one that's already
12
17
  deployed — same package, the other side of the counter.
@@ -40,6 +45,19 @@ class MotionTimeoutError(TimeoutError):
40
45
  super().__init__(f"job {job_id} did not finish within {timeout}s")
41
46
 
42
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
+
43
61
  class MotionClient:
44
62
  """Thin wrapper over the Motion inference API.
45
63
 
@@ -162,8 +180,139 @@ class MotionClient:
162
180
  return submitted
163
181
  return self.wait_for_job(submitted["job_id"], poll_interval=poll_interval, timeout=timeout)
164
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
+
165
297
  # ─── HTTP layer ──────────────────────────────────────────────────────────
166
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
+
167
316
  def _get(self, path: str, params: dict | None = None) -> Any:
168
317
  res = self._session.get(f"{self.base_url}{path}", params=params, timeout=self.timeout)
169
318
  return self._handle(res)
@@ -172,6 +321,10 @@ class MotionClient:
172
321
  res = self._session.post(f"{self.base_url}{path}", data=data, files=files, timeout=self.timeout)
173
322
  return self._handle(res)
174
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
+
175
328
  @staticmethod
176
329
  def _handle(res: requests.Response) -> Any:
177
330
  if not res.ok:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: motion-intelligence
3
- Version: 0.4.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "motion-intelligence"
3
- version = "0.4.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"