mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
289 lines
9.4 KiB
Python
289 lines
9.4 KiB
Python
"""Sync generated TypeScript protocol surfaces from canonical manifests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from .events import load_event_manifest
|
|
from .slash_commands import load_slash_command_manifest
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
TUI_PROTOCOL_PATH = ROOT / "apps" / "tui" / "src" / "bridge" / "protocol.ts"
|
|
TUI_REGISTRY_PATH = ROOT / "apps" / "tui" / "src" / "commands" / "registry.ts"
|
|
|
|
EVENTS_BEGIN = "// <openspace-protocol-events:generated>"
|
|
EVENTS_END = "// </openspace-protocol-events:generated>"
|
|
EVENT_MAP_BEGIN = "// <openspace-protocol-event-data-map:generated>"
|
|
EVENT_MAP_END = "// </openspace-protocol-event-data-map:generated>"
|
|
COMMANDS_BEGIN = "// <openspace-slash-commands:generated>"
|
|
COMMANDS_END = "// </openspace-slash-commands:generated>"
|
|
|
|
|
|
def _quote(value: str) -> str:
|
|
return json.dumps(value)
|
|
|
|
|
|
def _ts_string_array(values: list[str], *, indent: str = " ") -> str:
|
|
if not values:
|
|
return "[]"
|
|
lines = ["["]
|
|
lines.extend(f"{indent}{_quote(value)}," for value in values)
|
|
lines.append("]")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _ts_type_union(values: list[str], *, indent: str = " ") -> str:
|
|
if not values:
|
|
return "never"
|
|
return "\n".join(f"{indent}| {_quote(value)}" for value in values)
|
|
|
|
|
|
def _unique(values: list[str]) -> list[str]:
|
|
seen: set[str] = set()
|
|
result: list[str] = []
|
|
for value in values:
|
|
if value in seen:
|
|
continue
|
|
seen.add(value)
|
|
result.append(value)
|
|
return result
|
|
|
|
|
|
def _replace_section(source: str, begin: str, end: str, rendered: str) -> str:
|
|
start = source.find(begin)
|
|
finish = source.find(end)
|
|
if start == -1 or finish == -1 or finish < start:
|
|
raise RuntimeError(f"missing generated section markers {begin!r} / {end!r}")
|
|
finish += len(end)
|
|
return source[:start] + rendered.rstrip() + source[finish:]
|
|
|
|
|
|
def _command_literal(command: dict[str, Any]) -> dict[str, Any]:
|
|
handler = str(command["handler"])
|
|
literal: dict[str, Any] = {
|
|
"handler": handler,
|
|
"availability": [handler],
|
|
"implemented": bool(command.get("implemented", True)),
|
|
"name": str(command["name"]),
|
|
"description": str(command["description"]),
|
|
"summary": str(command["summary"]),
|
|
"usage": str(command["usage"]),
|
|
"category": str(command["category"]),
|
|
}
|
|
aliases = [str(alias) for alias in command.get("aliases", [])]
|
|
if aliases:
|
|
literal["aliases"] = aliases
|
|
args = command.get("args", [])
|
|
if args:
|
|
literal["args"] = [
|
|
{
|
|
"name": str(arg["name"]),
|
|
"required": bool(arg["required"]),
|
|
"description": str(arg["description"]),
|
|
}
|
|
for arg in args
|
|
]
|
|
if not bool(command.get("tui_visible", True)):
|
|
literal["isHidden"] = True
|
|
return literal
|
|
|
|
|
|
def render_protocol_events_section() -> str:
|
|
manifest = load_event_manifest()
|
|
tui_to_core = [str(value) for value in manifest["tui_to_core"]]
|
|
core_to_tui = [str(value) for value in manifest["core_to_tui"]]
|
|
all_events = _unique(tui_to_core + core_to_tui)
|
|
policy = manifest.get("unknown_event", {})
|
|
warning_event = str(policy.get("event") or "notification")
|
|
warning_level = str(policy.get("level") or "warn")
|
|
warning_title = str(policy.get("title") or "Protocol warning")
|
|
|
|
return "\n".join(
|
|
[
|
|
EVENTS_BEGIN,
|
|
"// Generated by python -m openspace.protocol.codegen --write",
|
|
"export const TUI_TO_CORE_MSG_TYPES = "
|
|
+ _ts_string_array(tui_to_core)
|
|
+ " as const;",
|
|
"",
|
|
"export const CORE_TO_TUI_MSG_TYPES = "
|
|
+ _ts_string_array(core_to_tui)
|
|
+ " as const;",
|
|
"",
|
|
"export type TuiToCoreMsgType = typeof TUI_TO_CORE_MSG_TYPES[number];",
|
|
"",
|
|
"export type CoreToTuiMsgType = typeof CORE_TO_TUI_MSG_TYPES[number];",
|
|
"",
|
|
"export type EventType = TuiToCoreMsgType | CoreToTuiMsgType;",
|
|
"",
|
|
"const KNOWN_EVENT_TYPES: ReadonlySet<string> = new Set("
|
|
+ _ts_string_array(all_events)
|
|
+ ");",
|
|
"",
|
|
"export function isKnownEventType(value: string): value is EventType {",
|
|
" return KNOWN_EVENT_TYPES.has(value);",
|
|
"}",
|
|
"",
|
|
"export function makeProtocolWarningMessage(",
|
|
" eventType: string,",
|
|
" reason: string,",
|
|
"): IPCMessage<\"notification\"> {",
|
|
" return {",
|
|
f" type: {_quote(warning_event)},",
|
|
" data: {",
|
|
f" level: {_quote(warning_level)},",
|
|
f" title: {_quote(warning_title)},",
|
|
" message: `${reason}: ${eventType}`,",
|
|
" event_type: eventType,",
|
|
" },",
|
|
" };",
|
|
"}",
|
|
EVENTS_END,
|
|
]
|
|
)
|
|
|
|
|
|
def render_protocol_event_map_section() -> str:
|
|
manifest = load_event_manifest()
|
|
payload_types = manifest["payload_types"]
|
|
lines = [
|
|
EVENT_MAP_BEGIN,
|
|
"// Generated by python -m openspace.protocol.codegen --write",
|
|
"export type EventDataMap = {",
|
|
]
|
|
for event_type in _unique([*manifest["tui_to_core"], *manifest["core_to_tui"]]):
|
|
lines.append(f" {event_type}: {payload_types[event_type]};")
|
|
lines.extend(["};", EVENT_MAP_END])
|
|
return "\n".join(lines)
|
|
|
|
|
|
def render_registry_section() -> str:
|
|
manifest = load_slash_command_manifest()
|
|
commands = manifest["commands"]
|
|
categories = manifest.get("categories", {})
|
|
command_names = [str(command["name"]) for command in commands]
|
|
category_names = [str(name) for name in categories]
|
|
|
|
registry_json = json.dumps(
|
|
[_command_literal(command) for command in commands],
|
|
indent=2,
|
|
ensure_ascii=False,
|
|
)
|
|
category_json = json.dumps(categories, indent=2, ensure_ascii=False)
|
|
|
|
return "\n".join(
|
|
[
|
|
COMMANDS_BEGIN,
|
|
"// Generated by python -m openspace.protocol.codegen --write",
|
|
"export type SlashCommandName =",
|
|
_ts_type_union(command_names)
|
|
+ ";",
|
|
"",
|
|
"export type SlashCommandCategory =",
|
|
_ts_type_union(category_names)
|
|
+ ";",
|
|
"",
|
|
"export type SlashCommandArg = {",
|
|
" name: string;",
|
|
" required: boolean;",
|
|
" description: string;",
|
|
"};",
|
|
"",
|
|
"export type SlashCommandDefinition = Command & {",
|
|
" name: SlashCommandName;",
|
|
" summary: string;",
|
|
" usage: string;",
|
|
" category: SlashCommandCategory;",
|
|
" args?: SlashCommandArg[];",
|
|
" hidden?: boolean;",
|
|
" implemented?: boolean;",
|
|
"};",
|
|
"",
|
|
"export type ParsedSlashCommand = SlashCommandData & {",
|
|
" args: string[];",
|
|
" raw: string;",
|
|
" definition: SlashCommandDefinition | null;",
|
|
" isSupported: boolean;",
|
|
"};",
|
|
"",
|
|
"const SLASH_COMMAND_REGISTRY: readonly SlashCommandDefinition[] = "
|
|
+ registry_json
|
|
+ " as const;",
|
|
"",
|
|
"const CATEGORY_LABELS: Record<SlashCommandCategory, string> = "
|
|
+ category_json
|
|
+ ";",
|
|
COMMANDS_END,
|
|
]
|
|
)
|
|
|
|
|
|
def render_tui_protocol(source: str) -> str:
|
|
source = _replace_section(
|
|
source,
|
|
EVENTS_BEGIN,
|
|
EVENTS_END,
|
|
render_protocol_events_section(),
|
|
)
|
|
return _replace_section(
|
|
source,
|
|
EVENT_MAP_BEGIN,
|
|
EVENT_MAP_END,
|
|
render_protocol_event_map_section(),
|
|
)
|
|
|
|
|
|
def render_tui_registry(source: str) -> str:
|
|
return _replace_section(
|
|
source,
|
|
COMMANDS_BEGIN,
|
|
COMMANDS_END,
|
|
render_registry_section(),
|
|
)
|
|
|
|
|
|
def sync_generated_files(*, check: bool) -> list[Path]:
|
|
targets = {
|
|
TUI_PROTOCOL_PATH: render_tui_protocol(TUI_PROTOCOL_PATH.read_text(encoding="utf-8")),
|
|
TUI_REGISTRY_PATH: render_tui_registry(TUI_REGISTRY_PATH.read_text(encoding="utf-8")),
|
|
}
|
|
|
|
changed: list[Path] = []
|
|
for path, rendered in targets.items():
|
|
current = path.read_text(encoding="utf-8")
|
|
if current == rendered:
|
|
continue
|
|
changed.append(path)
|
|
if not check:
|
|
path.write_text(rendered, encoding="utf-8")
|
|
return changed
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Sync generated TypeScript protocol files from OpenSpace manifests."
|
|
)
|
|
mode = parser.add_mutually_exclusive_group()
|
|
mode.add_argument("--check", action="store_true", help="fail if generated files are stale")
|
|
mode.add_argument("--write", action="store_true", help="update generated files")
|
|
args = parser.parse_args(argv)
|
|
|
|
check = not args.write
|
|
changed = sync_generated_files(check=check)
|
|
if not changed:
|
|
print("protocol generated files are up to date")
|
|
return 0
|
|
|
|
for path in changed:
|
|
print(path.relative_to(ROOT).as_posix())
|
|
if check:
|
|
print("generated files are stale; run python -m openspace.protocol.codegen --write")
|
|
return 1
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|