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.
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/PKG-INFO +1 -1
- motion_intelligence-0.5.0/motion/__init__.py +22 -0
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/batch.py +101 -14
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/cli.py +226 -4
- motion_intelligence-0.5.0/motion/client.py +336 -0
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/config.py +12 -0
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/dockerfile.py +12 -1
- motion_intelligence-0.5.0/motion/server.py +238 -0
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/PKG-INFO +1 -1
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/SOURCES.txt +1 -0
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/pyproject.toml +1 -1
- motion_intelligence-0.2.0/motion/__init__.py +0 -10
- motion_intelligence-0.2.0/motion/server.py +0 -138
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/README.md +0 -0
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/categories.py +0 -0
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/predictor.py +0 -0
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/profile.py +0 -0
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion/weight_upload.py +0 -0
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/dependency_links.txt +0 -0
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/entry_points.txt +0 -0
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/requires.txt +0 -0
- {motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/top_level.txt +0 -0
- {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.
|
|
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() —
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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"
|
|
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)
|
{motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: motion-intelligence
|
|
3
|
-
Version: 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,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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/requires.txt
RENAMED
|
File without changes
|
{motion_intelligence-0.2.0 → motion_intelligence-0.5.0}/motion_intelligence.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|