Skip to content

Commit 91e2743

Browse files
committed
add replace model api script (#927)
1 parent 1e57a1b commit 91e2743

1 file changed

Lines changed: 278 additions & 0 deletions

File tree

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Submit and poll a GenTable model ID replacement task through the public API.
4+
5+
Examples:
6+
python scripts/replace_gen_table_model_ids.py one-to-one old/model new/model --yes
7+
python scripts/replace_gen_table_model_ids.py many-to-one old/a old/b --to new/model --yes
8+
python scripts/replace_gen_table_model_ids.py file mapping.json --organizations org_1,org_2 --yes
9+
python scripts/replace_gen_table_model_ids.py poll gen_table_model_replace:...
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import argparse
15+
import json
16+
import os
17+
import sys
18+
from pathlib import Path
19+
from time import perf_counter, sleep
20+
from typing import Any
21+
22+
from jamaibase import JamAI
23+
from jamaibase.types import GenTableModelReplaceRequest, ProgressState
24+
25+
26+
DEFAULT_API_BASE = os.getenv("JAMAI_API_BASE", "http://localhost:6969/api")
27+
DEFAULT_TOKEN = os.getenv("JAMAI_TOKEN") or os.getenv("OWL_SERVICE_KEY") or ""
28+
DEFAULT_USER_ID = os.getenv("JAMAI_USER_ID", "0")
29+
DEFAULT_PROJECT_ID = os.getenv("JAMAI_PROJECT_ID", "default")
30+
31+
32+
def parse_organization_ids(value: str | None) -> list[str] | None:
33+
if value is None:
34+
return None
35+
organization_ids = [item.strip() for item in value.split(",") if item.strip()]
36+
return organization_ids or None
37+
38+
39+
def load_mapping_file(path: Path) -> dict[str, str]:
40+
try:
41+
data = json.loads(path.read_text())
42+
except Exception as exc:
43+
raise SystemExit(f"Cannot read mapping file {path}: {exc}") from exc
44+
45+
if not isinstance(data, dict):
46+
raise SystemExit("Mapping file must contain a JSON object like {\"old\": \"new\"}.")
47+
return {str(old_id): str(new_id) for old_id, new_id in data.items()}
48+
49+
50+
def validate_mapping(mapping: dict[str, str]) -> None:
51+
if not mapping:
52+
raise SystemExit("Mapping is empty.")
53+
54+
self_mappings = [old_id for old_id, new_id in mapping.items() if old_id == new_id]
55+
if self_mappings:
56+
raise SystemExit(
57+
"Model replacement cannot map an ID to itself: " + ", ".join(self_mappings)
58+
)
59+
60+
61+
def format_request(mapping: dict[str, str], organization_ids: list[str] | None) -> str:
62+
return json.dumps(
63+
{
64+
"mapping": mapping,
65+
"organization_ids": organization_ids,
66+
},
67+
indent=2,
68+
sort_keys=True,
69+
)
70+
71+
72+
def make_client(args: argparse.Namespace) -> JamAI:
73+
return JamAI(
74+
api_base=args.api_base,
75+
token=args.token,
76+
user_id=args.user_id,
77+
project_id=args.project_id,
78+
)
79+
80+
81+
def print_progress(progress: dict[str, Any]) -> None:
82+
state = progress.get("state", "")
83+
error = progress.get("error")
84+
data = progress.get("data") or {}
85+
stats = data.get("stats") or {}
86+
87+
parts = [f"state={state or 'UNKNOWN'}"]
88+
if stats:
89+
parts.extend(
90+
[
91+
f"orgs={stats.get('organizations_scanned', '?')}",
92+
f"projects={stats.get('projects_scanned', '?')}",
93+
f"tables={stats.get('tables_scanned', '?')}",
94+
f"updated_tables={stats.get('tables_updated', '?')}",
95+
f"failed_tables={stats.get('tables_failed', '?')}",
96+
f"updated_columns={stats.get('updated_columns', '?')}",
97+
f"failed_columns={stats.get('failed_columns', '?')}",
98+
]
99+
)
100+
if error:
101+
parts.append(f"error={error}")
102+
print(" ".join(parts), flush=True)
103+
104+
105+
def poll_progress(
106+
client: JamAI,
107+
progress_key: str,
108+
*,
109+
initial_wait: float,
110+
max_wait: float,
111+
) -> dict[str, Any]:
112+
index = 1
113+
started_at = perf_counter()
114+
last_state_line = ""
115+
116+
while (perf_counter() - started_at) < max_wait:
117+
sleep(min(initial_wait * index, 5.0))
118+
progress = client.tasks.get_progress(progress_key)
119+
state_line = json.dumps(progress.get("data", {}).get("stats", {}), sort_keys=True)
120+
state = progress.get("state")
121+
122+
if state_line != last_state_line or state in {ProgressState.COMPLETED, ProgressState.FAILED}:
123+
print_progress(progress)
124+
last_state_line = state_line
125+
126+
if state == ProgressState.COMPLETED:
127+
return progress
128+
if state == ProgressState.FAILED:
129+
raise SystemExit(progress.get("error") or "Replacement task failed.")
130+
index += 1
131+
132+
raise SystemExit(f"Timed out waiting for progress key {progress_key!r}.")
133+
134+
135+
def confirm_submission(args: argparse.Namespace, mapping: dict[str, str]) -> None:
136+
organization_ids = parse_organization_ids(args.organizations)
137+
print("Replacement request:")
138+
print(format_request(mapping, organization_ids))
139+
print()
140+
print(f"API base: {args.api_base}")
141+
print(f"User ID: {args.user_id}")
142+
print()
143+
144+
if args.print_request:
145+
raise SystemExit(0)
146+
147+
if args.dry_run:
148+
print(
149+
"This endpoint does not support server-side dry-run. "
150+
"The request above was not submitted."
151+
)
152+
raise SystemExit(0)
153+
154+
if args.yes:
155+
return
156+
157+
answer = input("Submit this replacement task? Type 'replace' to continue: ").strip()
158+
if answer != "replace":
159+
raise SystemExit("Aborted.")
160+
161+
162+
def submit_and_poll(args: argparse.Namespace, mapping: dict[str, str]) -> None:
163+
validate_mapping(mapping)
164+
organization_ids = parse_organization_ids(args.organizations)
165+
confirm_submission(args, mapping)
166+
167+
client = make_client(args)
168+
response = client.models.replace_model_ids(
169+
GenTableModelReplaceRequest(
170+
mapping=mapping,
171+
organization_ids=organization_ids,
172+
)
173+
)
174+
print(f"Progress key: {response.progress_key}", flush=True)
175+
176+
if args.no_poll:
177+
return
178+
179+
progress = poll_progress(
180+
client,
181+
response.progress_key,
182+
initial_wait=args.initial_wait,
183+
max_wait=args.max_wait,
184+
)
185+
print("Final progress:")
186+
print(json.dumps(progress, indent=2, sort_keys=True))
187+
188+
189+
def add_common_options(parser: argparse.ArgumentParser) -> None:
190+
parser.add_argument("--api-base", default=DEFAULT_API_BASE, help="JamAI API base URL.")
191+
parser.add_argument("--token", default=DEFAULT_TOKEN, help="Bearer token or service key.")
192+
parser.add_argument("--user-id", default=DEFAULT_USER_ID, help="User ID to send as X-USER-ID.")
193+
parser.add_argument("--project-id", default=DEFAULT_PROJECT_ID, help="Project ID header.")
194+
parser.add_argument(
195+
"--organizations",
196+
"-o",
197+
help="Comma-separated organization IDs to scan. If omitted, all organizations are scanned.",
198+
)
199+
parser.add_argument("--initial-wait", type=float, default=0.5, help="Initial poll wait seconds.")
200+
parser.add_argument("--max-wait", type=float, default=30 * 60.0, help="Max poll wait seconds.")
201+
parser.add_argument("--no-poll", action="store_true", help="Submit the task and exit.")
202+
parser.add_argument("--yes", "-y", action="store_true", help="Submit without confirmation.")
203+
parser.add_argument(
204+
"--print-request",
205+
action="store_true",
206+
help="Print the API request payload and exit without submitting.",
207+
)
208+
parser.add_argument(
209+
"--dry-run",
210+
action="store_true",
211+
help="Alias for printing the request and refusing submission; the endpoint has no dry-run.",
212+
)
213+
214+
215+
def build_parser() -> argparse.ArgumentParser:
216+
parser = argparse.ArgumentParser(
217+
description="Replace GenTable model IDs through /v2/models/replace and poll progress."
218+
)
219+
subparsers = parser.add_subparsers(dest="command", required=True)
220+
221+
one_to_one = subparsers.add_parser("one-to-one", help="Replace one model ID with another.")
222+
add_common_options(one_to_one)
223+
one_to_one.add_argument("old_model_id")
224+
one_to_one.add_argument("new_model_id")
225+
226+
many_to_one = subparsers.add_parser("many-to-one", help="Replace many model IDs with one ID.")
227+
add_common_options(many_to_one)
228+
many_to_one.add_argument("old_model_ids", nargs="+")
229+
many_to_one.add_argument("--to", "-t", required=True, dest="new_model_id")
230+
231+
file_parser = subparsers.add_parser("file", help="Read {old_id: new_id} mapping from JSON.")
232+
add_common_options(file_parser)
233+
file_parser.add_argument("mapping_file", type=Path)
234+
235+
poll_parser = subparsers.add_parser("poll", help="Poll an existing replacement progress key.")
236+
poll_parser.add_argument("progress_key")
237+
poll_parser.add_argument("--api-base", default=DEFAULT_API_BASE, help="JamAI API base URL.")
238+
poll_parser.add_argument("--token", default=DEFAULT_TOKEN, help="Bearer token or service key.")
239+
poll_parser.add_argument("--user-id", default=DEFAULT_USER_ID, help="User ID for X-USER-ID.")
240+
poll_parser.add_argument("--project-id", default=DEFAULT_PROJECT_ID, help="Project ID header.")
241+
poll_parser.add_argument(
242+
"--initial-wait", type=float, default=0.5, help="Initial poll wait seconds."
243+
)
244+
poll_parser.add_argument("--max-wait", type=float, default=30 * 60.0, help="Max poll wait.")
245+
246+
return parser
247+
248+
249+
def main() -> None:
250+
parser = build_parser()
251+
args = parser.parse_args()
252+
253+
if args.command == "one-to-one":
254+
mapping = {args.old_model_id: args.new_model_id}
255+
submit_and_poll(args, mapping)
256+
elif args.command == "many-to-one":
257+
mapping = {old_model_id: args.new_model_id for old_model_id in args.old_model_ids}
258+
submit_and_poll(args, mapping)
259+
elif args.command == "file":
260+
mapping = load_mapping_file(args.mapping_file)
261+
submit_and_poll(args, mapping)
262+
elif args.command == "poll":
263+
client = make_client(args)
264+
progress = poll_progress(
265+
client,
266+
args.progress_key,
267+
initial_wait=args.initial_wait,
268+
max_wait=args.max_wait,
269+
)
270+
print("Final progress:")
271+
print(json.dumps(progress, indent=2, sort_keys=True))
272+
else:
273+
parser.print_help()
274+
sys.exit(2)
275+
276+
277+
if __name__ == "__main__":
278+
main()

0 commit comments

Comments
 (0)