wrapper_generator.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687
  1. #!/usr/bin/env python3
  2. import argparse
  3. import json
  4. import struct
  5. from datetime import datetime
  6. from pathlib import Path
  7. DEFAULT_OFFICIAL_MODEL_YAML = Path(r"~\hub\models\qwen\qwen3.6-35b-a3b\model.yaml")
  8. DEFAULT_OFFICIAL_MANIFEST = Path(r"~\hub\models\qwen\qwen3.6-35b-a3b\manifest.json")
  9. DEFAULT_OUTPUT_ROOT = Path("artifacts/lmstudio-model-wrappers")
  10. DEFAULT_TARGET_MODEL_KEY = "qwen3.6-35b-a3b-uncensored-hauhaucs-aggressive"
  11. DEFAULT_WRAPPER_OWNER = "local"
  12. DEFAULT_WRAPPER_NAME = "qwen3.6-35b-a3b-hauhaucs-aggressive"
  13. DEFAULT_MODELS_ROOT = Path.home() / ".lmstudio" / "models"
  14. DEFAULT_HUB_ROOT = Path.home() / ".lmstudio" / "hub" / "models"
  15. GGUF_VALUE_TYPES = {
  16. 0: ("<B", 1),
  17. 1: ("<b", 1),
  18. 2: ("<H", 2),
  19. 3: ("<h", 2),
  20. 4: ("<I", 4),
  21. 5: ("<i", 4),
  22. 6: ("<f", 4),
  23. 7: ("<?", 1),
  24. 10: ("<Q", 8),
  25. 11: ("<q", 8),
  26. 12: ("<d", 8),
  27. }
  28. def _bool_text(value: bool) -> str:
  29. return "true" if value else "false"
  30. def _read_struct(data: bytes, offset: int, fmt: str) -> tuple[object, int]:
  31. size = struct.calcsize(fmt)
  32. if offset + size > len(data):
  33. raise ValueError("Unexpected end of GGUF metadata")
  34. value = struct.unpack_from(fmt, data, offset)[0]
  35. return value, offset + size
  36. def _read_gguf_string(data: bytes, offset: int) -> tuple[str, int]:
  37. length, offset = _read_struct(data, offset, "<Q")
  38. end = offset + int(length)
  39. if end > len(data):
  40. raise ValueError("Invalid GGUF string length")
  41. return data[offset:end].decode("utf-8", errors="replace"), end
  42. def _read_gguf_value(data: bytes, offset: int, value_type: int) -> tuple[object, int]:
  43. if value_type == 8:
  44. return _read_gguf_string(data, offset)
  45. if value_type == 9:
  46. nested_type, offset = _read_struct(data, offset, "<I")
  47. count, offset = _read_struct(data, offset, "<Q")
  48. values = []
  49. for _ in range(int(count)):
  50. item, offset = _read_gguf_value(data, offset, int(nested_type))
  51. values.append(item)
  52. return values, offset
  53. if value_type not in GGUF_VALUE_TYPES:
  54. raise ValueError(f"Unsupported GGUF metadata value type: {value_type}")
  55. fmt, _ = GGUF_VALUE_TYPES[value_type]
  56. return _read_struct(data, offset, fmt)
  57. def read_gguf_metadata(path: Path) -> dict[str, object]:
  58. data = path.read_bytes()
  59. if len(data) < 24 or data[:4] != b"GGUF":
  60. raise ValueError(f"Not a GGUF file: {path}")
  61. version, offset = _read_struct(data, 4, "<I")
  62. if int(version) not in (2, 3):
  63. raise ValueError(f"Unsupported GGUF version: {version}")
  64. _, offset = _read_struct(data, offset, "<Q")
  65. metadata_count, offset = _read_struct(data, offset, "<Q")
  66. metadata: dict[str, object] = {}
  67. for _ in range(int(metadata_count)):
  68. key, offset = _read_gguf_string(data, offset)
  69. value_type, offset = _read_struct(data, offset, "<I")
  70. value, offset = _read_gguf_value(data, offset, int(value_type))
  71. metadata[key] = value
  72. return metadata
  73. def extract_architecture_family(value: object) -> str:
  74. text = str(value or "").strip().lower()
  75. if not text:
  76. return ""
  77. head = []
  78. for char in text:
  79. if char.isalpha():
  80. head.append(char)
  81. continue
  82. if head:
  83. break
  84. return "".join(head) or text
  85. def infer_model_identity(
  86. models_root: Path, gguf_path: Path, metadata: dict[str, object]
  87. ) -> tuple[str, str]:
  88. try:
  89. relative = gguf_path.relative_to(models_root)
  90. parts = list(relative.parts[:-1])
  91. except ValueError:
  92. parts = []
  93. owner = ""
  94. name = ""
  95. if len(parts) >= 2:
  96. owner = parts[0]
  97. name = parts[1]
  98. elif len(parts) == 1:
  99. owner = "local"
  100. name = parts[0]
  101. if not owner:
  102. owner = "local"
  103. if not name:
  104. name = str(metadata.get("general.name") or gguf_path.stem)
  105. owner = owner.strip().replace("\\", "-").replace("/", "-")
  106. name = name.strip().replace("\\", "-").replace("/", "-")
  107. return owner, name
  108. def infer_model_key(gguf_path: Path) -> str:
  109. return gguf_path.stem
  110. def _metadata_bool(metadata: dict[str, object], keys: list[str]) -> bool | None:
  111. for key in keys:
  112. if key in metadata and isinstance(metadata[key], bool):
  113. return bool(metadata[key])
  114. return None
  115. def extract_capabilities_from_metadata(
  116. metadata: dict[str, object],
  117. ) -> dict[str, bool | None]:
  118. reasoning = _metadata_bool(
  119. metadata,
  120. [
  121. "capabilities.reasoning",
  122. "general.reasoning",
  123. "reasoning",
  124. ],
  125. )
  126. if reasoning is None:
  127. chat_template = metadata.get("tokenizer.chat_template")
  128. if isinstance(chat_template, str) and "enable_thinking" in chat_template:
  129. reasoning = True
  130. vision = _metadata_bool(
  131. metadata,
  132. [
  133. "capabilities.vision",
  134. "general.vision",
  135. "vision",
  136. ],
  137. )
  138. tools = _metadata_bool(
  139. metadata,
  140. [
  141. "capabilities.tools",
  142. "general.tools",
  143. "tools",
  144. ],
  145. )
  146. return {
  147. "reasoning": reasoning,
  148. "vision": vision,
  149. "tools": tools,
  150. }
  151. def extract_capabilities_from_template(model_yaml_text: str) -> dict[str, bool | None]:
  152. lowered = model_yaml_text.lower()
  153. reasoning = None
  154. if "reasoning: true" in lowered or "key: enablethinking" in lowered:
  155. reasoning = True
  156. elif "reasoning: false" in lowered:
  157. reasoning = False
  158. vision = True if "vision: true" in lowered else None
  159. tools = True if "tools: true" in lowered else None
  160. return {
  161. "reasoning": reasoning,
  162. "vision": vision,
  163. "tools": tools,
  164. }
  165. def merge_capabilities(
  166. metadata_capabilities: dict[str, bool | None],
  167. template_capabilities: dict[str, bool | None],
  168. ) -> dict[str, bool | None]:
  169. merged: dict[str, bool | None] = {}
  170. for key in ("reasoning", "vision", "tools"):
  171. merged[key] = (
  172. metadata_capabilities[key]
  173. if metadata_capabilities.get(key) is not None
  174. else template_capabilities.get(key)
  175. )
  176. return merged
  177. def find_template_package(hub_root: Path, metadata: dict[str, object]) -> Path | None:
  178. family = extract_architecture_family(metadata.get("general.architecture"))
  179. if not family or not hub_root.exists():
  180. return None
  181. best_match: tuple[int, Path] | None = None
  182. for model_yaml_path in hub_root.rglob("model.yaml"):
  183. package_dir = model_yaml_path.parent
  184. manifest_path = package_dir / "manifest.json"
  185. if not manifest_path.exists():
  186. continue
  187. try:
  188. relative = package_dir.relative_to(hub_root)
  189. except ValueError:
  190. continue
  191. if not relative.parts:
  192. continue
  193. owner = relative.parts[0].lower()
  194. name = relative.parts[-1].lower()
  195. if owner == "lmstudio-community":
  196. continue
  197. score = 0
  198. if owner == family:
  199. score += 100
  200. elif family in owner:
  201. score += 70
  202. if family in name:
  203. score += 40
  204. if score <= 0:
  205. continue
  206. if best_match is None or score > best_match[0]:
  207. best_match = (score, package_dir)
  208. if best_match is None:
  209. return None
  210. return best_match[1]
  211. def _find_line_index(lines: list[str], prefix: str) -> int | None:
  212. for index, line in enumerate(lines):
  213. if line.strip().startswith(prefix):
  214. return index
  215. return None
  216. def _find_section_bounds(lines: list[str], header: str) -> tuple[int, int] | None:
  217. for index, line in enumerate(lines):
  218. if line.strip() != header:
  219. continue
  220. indent = len(line) - len(line.lstrip(" "))
  221. end = len(lines)
  222. for probe in range(index + 1, len(lines)):
  223. candidate = lines[probe]
  224. stripped = candidate.strip()
  225. if not stripped:
  226. continue
  227. candidate_indent = len(candidate) - len(candidate.lstrip(" "))
  228. if candidate_indent <= indent:
  229. end = probe
  230. break
  231. return index, end
  232. return None
  233. def _replace_base_block(lines: list[str], target_model_key: str) -> list[str]:
  234. result: list[str] = []
  235. index = 0
  236. replaced = False
  237. while index < len(lines):
  238. line = lines[index]
  239. if not replaced and line.strip().startswith("base:"):
  240. base_indent = len(line) - len(line.lstrip(" "))
  241. result.append(f'base: "{target_model_key}"')
  242. replaced = True
  243. index += 1
  244. while index < len(lines):
  245. candidate = lines[index]
  246. stripped = candidate.strip()
  247. if not stripped:
  248. index += 1
  249. continue
  250. candidate_indent = len(candidate) - len(candidate.lstrip(" "))
  251. if candidate_indent <= base_indent:
  252. break
  253. index += 1
  254. continue
  255. result.append(line)
  256. index += 1
  257. if not replaced:
  258. raise ValueError("Official model.yaml is missing a base declaration")
  259. return result
  260. def _upsert_metadata_bool(lines: list[str], key: str, value: bool | None) -> list[str]:
  261. if value is None:
  262. return lines
  263. bounds = _find_section_bounds(lines, "metadataOverrides:")
  264. if bounds is None:
  265. insert_at = _find_line_index(lines, "config:")
  266. metadata_block = [
  267. "metadataOverrides:",
  268. f" {key}: {_bool_text(value)}",
  269. ]
  270. if insert_at is None:
  271. return lines + metadata_block
  272. return lines[:insert_at] + metadata_block + lines[insert_at:]
  273. start, end = bounds
  274. child_prefix = " "
  275. for index in range(start + 1, end):
  276. if lines[index].strip().startswith(f"{key}:"):
  277. lines[index] = f"{child_prefix}{key}: {_bool_text(value)}"
  278. return lines
  279. return lines[:end] + [f"{child_prefix}{key}: {_bool_text(value)}"] + lines[end:]
  280. def build_wrapper_model_yaml(
  281. official_model_yaml_text: str,
  282. target_model_key: str,
  283. wrapper_owner: str,
  284. wrapper_name: str,
  285. capabilities: dict[str, bool | None] | None = None,
  286. ) -> str:
  287. lines = official_model_yaml_text.splitlines()
  288. model_index = _find_line_index(lines, "model:")
  289. if model_index is None:
  290. raise ValueError("Official model.yaml is missing a model declaration")
  291. lines[model_index] = f"model: {wrapper_owner}/{wrapper_name}"
  292. lines = _replace_base_block(lines, target_model_key)
  293. if capabilities:
  294. for key in ("reasoning", "vision", "tools"):
  295. lines = _upsert_metadata_bool(lines, key, capabilities.get(key))
  296. return "\n".join(lines).rstrip() + "\n"
  297. def build_wrapper_manifest(
  298. official_manifest: dict,
  299. target_model_key: str,
  300. wrapper_owner: str,
  301. wrapper_name: str,
  302. ) -> dict:
  303. manifest = {
  304. "type": "model",
  305. "owner": wrapper_owner,
  306. "name": wrapper_name,
  307. "dependencies": [
  308. {
  309. "type": "model",
  310. "purpose": "baseModel",
  311. "modelKeys": [target_model_key],
  312. }
  313. ],
  314. "revision": int(official_manifest.get("revision", 1)),
  315. }
  316. source_dep = (official_manifest.get("dependencies") or [{}])[0]
  317. if source_dep.get("sources"):
  318. manifest["dependencies"][0]["sources"] = source_dep["sources"]
  319. return manifest
  320. def build_readme(
  321. target_model_key: str,
  322. wrapper_owner: str,
  323. wrapper_name: str,
  324. ) -> str:
  325. return (
  326. f"# {wrapper_owner}/{wrapper_name}\n\n"
  327. "这个目录由 `wrapper_generator.py` 自动生成,用于补全 LM Studio 可识别的模型元数据。\n\n"
  328. "## 源模型\n\n"
  329. f"- 本地模型 key: `{target_model_key}`\n\n"
  330. "## 说明\n\n"
  331. "- 不修改 GGUF 文件本身\n"
  332. "- 复用 LM Studio 官方模型包结构\n"
  333. "- 用于补全模型能力标注与基础依赖关系\n"
  334. )
  335. def build_wrapper_files(
  336. target_model_key: str,
  337. wrapper_owner: str,
  338. wrapper_name: str,
  339. official_model_yaml_text: str,
  340. official_manifest: dict,
  341. capabilities: dict[str, bool | None] | None = None,
  342. ) -> dict:
  343. model_yaml = build_wrapper_model_yaml(
  344. official_model_yaml_text=official_model_yaml_text,
  345. target_model_key=target_model_key,
  346. wrapper_owner=wrapper_owner,
  347. wrapper_name=wrapper_name,
  348. capabilities=capabilities,
  349. )
  350. manifest = build_wrapper_manifest(
  351. official_manifest=official_manifest,
  352. target_model_key=target_model_key,
  353. wrapper_owner=wrapper_owner,
  354. wrapper_name=wrapper_name,
  355. )
  356. readme = build_readme(
  357. target_model_key=target_model_key,
  358. wrapper_owner=wrapper_owner,
  359. wrapper_name=wrapper_name,
  360. )
  361. return {
  362. "model.yaml": model_yaml,
  363. "manifest.json": json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
  364. "README.md": readme,
  365. }
  366. def generate_wrapper(
  367. output_dir: Path,
  368. target_model_key: str,
  369. wrapper_owner: str,
  370. wrapper_name: str,
  371. official_model_yaml_text: str,
  372. official_manifest: dict,
  373. dry_run: bool,
  374. capabilities: dict[str, bool | None] | None = None,
  375. ) -> dict:
  376. files = build_wrapper_files(
  377. target_model_key=target_model_key,
  378. wrapper_owner=wrapper_owner,
  379. wrapper_name=wrapper_name,
  380. official_model_yaml_text=official_model_yaml_text,
  381. official_manifest=official_manifest,
  382. capabilities=capabilities,
  383. )
  384. summary = {
  385. "generated_at": datetime.now().isoformat(),
  386. "dry_run": dry_run,
  387. "output_dir": str(output_dir),
  388. "wrapper_model": f"{wrapper_owner}/{wrapper_name}",
  389. "target_model_key": target_model_key,
  390. "files": sorted(files.keys()),
  391. }
  392. if dry_run:
  393. return summary
  394. output_dir.mkdir(parents=True, exist_ok=True)
  395. for name, content in files.items():
  396. (output_dir / name).write_text(content, encoding="utf-8")
  397. (output_dir / "summary.json").write_text(
  398. json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
  399. encoding="utf-8",
  400. )
  401. return summary
  402. def load_inputs(model_yaml_path: Path, manifest_path: Path) -> tuple[str, dict]:
  403. return (
  404. model_yaml_path.read_text(encoding="utf-8"),
  405. json.loads(manifest_path.read_text(encoding="utf-8")),
  406. )
  407. def scan_gguf_files(models_root: Path) -> list[Path]:
  408. if not models_root.exists():
  409. return []
  410. return sorted(models_root.rglob("*.gguf"))
  411. def _skip_reason_for_model(
  412. models_root: Path, gguf_path: Path, owner: str
  413. ) -> str | None:
  414. try:
  415. relative_parts = {
  416. part.lower() for part in gguf_path.relative_to(models_root).parts
  417. }
  418. except ValueError:
  419. relative_parts = {part.lower() for part in gguf_path.parts}
  420. if "lmstudio-community" in relative_parts or owner.lower() == "lmstudio-community":
  421. return "lmstudio-community"
  422. return None
  423. def collect_model_job(
  424. models_root: Path,
  425. hub_root: Path,
  426. output_root: Path,
  427. gguf_path: Path,
  428. ) -> dict:
  429. metadata = read_gguf_metadata(gguf_path)
  430. owner, name = infer_model_identity(models_root, gguf_path, metadata)
  431. skip_reason = _skip_reason_for_model(models_root, gguf_path, owner)
  432. if skip_reason:
  433. return {
  434. "source_gguf": str(gguf_path),
  435. "owner": owner,
  436. "name": name,
  437. "reason": skip_reason,
  438. }
  439. template_dir = find_template_package(hub_root, metadata)
  440. if template_dir is None:
  441. return {
  442. "source_gguf": str(gguf_path),
  443. "owner": owner,
  444. "name": name,
  445. "reason": "template-not-found",
  446. }
  447. output_dir = output_root / owner / name
  448. if output_dir == template_dir:
  449. return {
  450. "source_gguf": str(gguf_path),
  451. "owner": owner,
  452. "name": name,
  453. "reason": "output-conflicts-with-template",
  454. }
  455. official_model_yaml_text, official_manifest = load_inputs(
  456. template_dir / "model.yaml",
  457. template_dir / "manifest.json",
  458. )
  459. metadata_capabilities = extract_capabilities_from_metadata(metadata)
  460. template_capabilities = extract_capabilities_from_template(official_model_yaml_text)
  461. capabilities = merge_capabilities(metadata_capabilities, template_capabilities)
  462. model_key = infer_model_key(gguf_path)
  463. return {
  464. "source_gguf": str(gguf_path),
  465. "owner": owner,
  466. "name": name,
  467. "model_key": model_key,
  468. "template_path": str(template_dir),
  469. "output_dir": str(output_dir),
  470. "will_overwrite": any(
  471. (output_dir / file_name).exists()
  472. for file_name in ("model.yaml", "manifest.json", "README.md")
  473. ),
  474. "capabilities": capabilities,
  475. "official_model_yaml_text": official_model_yaml_text,
  476. "official_manifest": official_manifest,
  477. }
  478. def generate_wrappers_for_models(
  479. models_root: Path,
  480. hub_root: Path,
  481. output_root: Path,
  482. dry_run: bool,
  483. ) -> dict:
  484. generated = []
  485. skipped = []
  486. for gguf_path in scan_gguf_files(models_root):
  487. job = collect_model_job(models_root, hub_root, output_root, gguf_path)
  488. if "reason" in job:
  489. skipped.append(job)
  490. continue
  491. output_dir = Path(job["output_dir"])
  492. summary_item = {
  493. "source_gguf": job["source_gguf"],
  494. "owner": job["owner"],
  495. "name": job["name"],
  496. "model_key": job["model_key"],
  497. "template_path": job["template_path"],
  498. "output_dir": job["output_dir"],
  499. "will_overwrite": job["will_overwrite"],
  500. "capabilities": job["capabilities"],
  501. }
  502. if not dry_run:
  503. generate_wrapper(
  504. output_dir=output_dir,
  505. target_model_key=job["model_key"],
  506. wrapper_owner=job["owner"],
  507. wrapper_name=job["name"],
  508. official_model_yaml_text=job["official_model_yaml_text"],
  509. official_manifest=job["official_manifest"],
  510. dry_run=False,
  511. capabilities=job["capabilities"],
  512. )
  513. generated.append(summary_item)
  514. return {
  515. "generated_at": datetime.now().isoformat(),
  516. "dry_run": dry_run,
  517. "models_root": str(models_root),
  518. "hub_root": str(hub_root),
  519. "output_root": str(output_root),
  520. "generated": generated,
  521. "skipped": skipped,
  522. }
  523. def _requested_legacy_single_mode(args: argparse.Namespace) -> bool:
  524. return any(
  525. [
  526. args.target_model_key,
  527. args.wrapper_owner,
  528. args.wrapper_name,
  529. args.official_model_yaml,
  530. args.official_manifest,
  531. args.output_dir,
  532. ]
  533. )
  534. def main() -> int:
  535. parser = argparse.ArgumentParser()
  536. parser.add_argument("--models-root", default=str(DEFAULT_MODELS_ROOT))
  537. parser.add_argument("--hub-root", default=str(DEFAULT_HUB_ROOT))
  538. parser.add_argument("--output-root", default=str(DEFAULT_HUB_ROOT))
  539. parser.add_argument("--dry-run", action="store_true")
  540. parser.add_argument("--target-model-key", default="")
  541. parser.add_argument("--wrapper-owner", default="")
  542. parser.add_argument("--wrapper-name", default="")
  543. parser.add_argument("--official-model-yaml", default="")
  544. parser.add_argument("--official-manifest", default="")
  545. parser.add_argument("--output-dir", default="")
  546. args = parser.parse_args()
  547. if _requested_legacy_single_mode(args):
  548. target_model_key = args.target_model_key or DEFAULT_TARGET_MODEL_KEY
  549. wrapper_owner = args.wrapper_owner or DEFAULT_WRAPPER_OWNER
  550. wrapper_name = args.wrapper_name or DEFAULT_WRAPPER_NAME
  551. official_model_yaml_path = Path(
  552. args.official_model_yaml or DEFAULT_OFFICIAL_MODEL_YAML
  553. )
  554. official_manifest_path = Path(
  555. args.official_manifest or DEFAULT_OFFICIAL_MANIFEST
  556. )
  557. official_model_yaml_text, official_manifest = load_inputs(
  558. official_model_yaml_path,
  559. official_manifest_path,
  560. )
  561. if args.output_dir:
  562. output_dir = Path(args.output_dir)
  563. else:
  564. output_dir = (
  565. Path(args.output_root or DEFAULT_OUTPUT_ROOT)
  566. / wrapper_owner
  567. / wrapper_name
  568. )
  569. summary = generate_wrapper(
  570. output_dir=output_dir,
  571. target_model_key=target_model_key,
  572. wrapper_owner=wrapper_owner,
  573. wrapper_name=wrapper_name,
  574. official_model_yaml_text=official_model_yaml_text,
  575. official_manifest=official_manifest,
  576. dry_run=args.dry_run,
  577. )
  578. else:
  579. summary = generate_wrappers_for_models(
  580. models_root=Path(args.models_root).expanduser(),
  581. hub_root=Path(args.hub_root).expanduser(),
  582. output_root=Path(args.output_root).expanduser(),
  583. dry_run=args.dry_run,
  584. )
  585. print(json.dumps(summary, ensure_ascii=False, indent=2))
  586. return 0
  587. if __name__ == "__main__":
  588. raise SystemExit(main())