feat: Extensions for all (#4021)
Co-authored-by: alan <alan@lnbits.com> Co-authored-by: Tiago Vasconcelos <talvasconcelos@gmail.com>
This commit is contained in:
co-authored by
alan
Tiago Vasconcelos
parent
43900dd6da
commit
61ed636df0
@@ -0,0 +1,368 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
from types import UnionType
|
||||
from typing import Any, Literal, Union, get_args, get_origin
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from lnbits.core.wasm_ext import (
|
||||
ExtensionAPIMethod,
|
||||
ExtensionHostAPI,
|
||||
get_extension_api_method,
|
||||
list_extension_api_methods,
|
||||
)
|
||||
|
||||
|
||||
def generate_typescript_sdk(
|
||||
api_cls: type[ExtensionHostAPI] | None = None,
|
||||
method_ids: Sequence[str] | None = None,
|
||||
) -> str:
|
||||
api_cls = api_cls or ExtensionHostAPI
|
||||
methods = _select_methods(api_cls, method_ids)
|
||||
models = _collect_models(methods)
|
||||
|
||||
lines = [
|
||||
"/* Generated by LNbits ExtensionHostAPI codegen. */",
|
||||
"/* Do not edit by hand. */",
|
||||
"",
|
||||
"export type MaybePromise<T> = T | Promise<T>",
|
||||
"",
|
||||
]
|
||||
|
||||
for model in models:
|
||||
lines.extend(_render_model_type(model))
|
||||
lines.append("")
|
||||
|
||||
lines.extend(_render_method_metadata(methods))
|
||||
lines.append("")
|
||||
lines.extend(_render_host_type(methods))
|
||||
lines.append("")
|
||||
lines.extend(_render_sdk_type(methods))
|
||||
lines.append("")
|
||||
|
||||
lines.extend(_render_create_sdk(methods))
|
||||
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def write_typescript_sdk(
|
||||
path: str | Path,
|
||||
api_cls: type[ExtensionHostAPI] | None = None,
|
||||
method_ids: Sequence[str] | None = None,
|
||||
) -> None:
|
||||
Path(path).write_text(
|
||||
generate_typescript_sdk(api_cls, method_ids), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _select_methods(
|
||||
api_cls: type[ExtensionHostAPI], method_ids: Sequence[str] | None
|
||||
) -> list[ExtensionAPIMethod]:
|
||||
if not method_ids:
|
||||
return list_extension_api_methods(api_cls)
|
||||
return [get_extension_api_method(method_id, api_cls) for method_id in method_ids]
|
||||
|
||||
|
||||
def _collect_models(methods: Sequence[ExtensionAPIMethod]) -> list[type[BaseModel]]:
|
||||
models: dict[str, type[BaseModel]] = {}
|
||||
pending = [
|
||||
model
|
||||
for method in methods
|
||||
for model in (method.request_model, method.response_model)
|
||||
]
|
||||
|
||||
while pending:
|
||||
model = pending.pop()
|
||||
if model.__name__ in models:
|
||||
continue
|
||||
models[model.__name__] = model
|
||||
for field in model.__fields__.values():
|
||||
pending.extend(_nested_model_types(field.outer_type_))
|
||||
return [models[name] for name in sorted(models)]
|
||||
|
||||
|
||||
def _nested_model_types(type_: Any) -> list[type[BaseModel]]:
|
||||
models: list[type[BaseModel]] = []
|
||||
if _is_model_type(type_):
|
||||
models.append(type_)
|
||||
for arg in get_args(type_):
|
||||
models.extend(_nested_model_types(arg))
|
||||
return models
|
||||
|
||||
|
||||
def _render_model_type(model: type[BaseModel]) -> list[str]:
|
||||
name = _model_name(model)
|
||||
fields = model.__fields__
|
||||
if not fields:
|
||||
return [f"export type {name} = Record<string, never>"]
|
||||
|
||||
lines = [f"export type {name} = {{"]
|
||||
for field_name, field in fields.items():
|
||||
optional = "?" if not field.required else ""
|
||||
ts_type = _python_type_to_ts(field.outer_type_, field.allow_none)
|
||||
lines.append(f" {_camel(field_name)}{optional}: {ts_type}")
|
||||
lines.append("}")
|
||||
return lines
|
||||
|
||||
|
||||
def _python_type_to_ts(type_: Any, allow_none: bool = False) -> str:
|
||||
origin = get_origin(type_)
|
||||
args = get_args(type_)
|
||||
|
||||
if origin in (UnionType, Union):
|
||||
ts = " | ".join(
|
||||
_python_type_to_ts(arg) for arg in args if arg is not type(None)
|
||||
)
|
||||
if type(None) in args:
|
||||
ts = f"{ts} | null"
|
||||
return ts
|
||||
|
||||
if origin is Literal:
|
||||
return " | ".join(_literal_to_ts(arg) for arg in args)
|
||||
|
||||
if _is_model_type(type_):
|
||||
ts = _model_name(type_)
|
||||
elif origin in (list, Sequence):
|
||||
item_type = _python_type_to_ts(args[0]) if args else "unknown"
|
||||
ts = f"{item_type}[]"
|
||||
elif origin is dict:
|
||||
key_type = _python_type_to_ts(args[0]) if args else "string"
|
||||
value_type = _python_type_to_ts(args[1]) if len(args) > 1 else "unknown"
|
||||
ts = (
|
||||
f"Record<{key_type}, {value_type}>"
|
||||
if key_type == "string"
|
||||
else f"{{ [key: string]: {value_type} }}"
|
||||
)
|
||||
elif _is_subclass(type_, str):
|
||||
ts = "string"
|
||||
elif _is_subclass(type_, bool):
|
||||
ts = "boolean"
|
||||
elif _is_subclass(type_, int) or _is_subclass(type_, float):
|
||||
ts = "number"
|
||||
else:
|
||||
ts = "unknown"
|
||||
|
||||
return f"{ts} | null" if allow_none else ts
|
||||
|
||||
|
||||
def _literal_to_ts(value: Any) -> str:
|
||||
if isinstance(value, str):
|
||||
return f'"{value}"'
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
if value is None:
|
||||
return "null"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _is_subclass(type_: Any, class_: type) -> bool:
|
||||
try:
|
||||
return isinstance(type_, type) and issubclass(type_, class_)
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
|
||||
def _is_model_type(type_: Any) -> bool:
|
||||
return _is_subclass(type_, BaseModel)
|
||||
|
||||
|
||||
def _render_method_metadata(
|
||||
methods: Sequence[ExtensionAPIMethod],
|
||||
) -> list[str]:
|
||||
lines = ["export const extensionApiMethods = ["]
|
||||
for method in methods:
|
||||
permission = (
|
||||
f'"{method.required_permission}"' if method.required_permission else "null"
|
||||
)
|
||||
lines.extend(
|
||||
[
|
||||
" {",
|
||||
f' id: "{method.method_id}",',
|
||||
f' namespace: "{method.namespace}",',
|
||||
f' sdkName: "{method.sdk_name}",',
|
||||
f' pythonName: "{method.python_name}",',
|
||||
f' hostInterface: "{method.host_interface}",',
|
||||
f' hostName: "{method.host_name}",',
|
||||
f' hostJsName: "{_camel(method.host_name)}",',
|
||||
f" requiredPermission: {permission},",
|
||||
" },",
|
||||
]
|
||||
)
|
||||
lines.append("] as const")
|
||||
return lines
|
||||
|
||||
|
||||
def _render_host_type(methods: Sequence[ExtensionAPIMethod]) -> list[str]:
|
||||
lines = ["export type ExtensionHost = {"]
|
||||
for host_interface, interface_methods in _methods_by_host_interface(
|
||||
methods
|
||||
).items():
|
||||
lines.append(f" {_ts_property(host_interface)}: {{")
|
||||
for method in sorted(interface_methods, key=lambda item: item.host_name):
|
||||
request = _model_name(method.request_model)
|
||||
response = _model_name(method.response_model)
|
||||
if _is_empty_model(method.request_model):
|
||||
lines.append(
|
||||
f" {_camel(method.host_name)}(): MaybePromise<{response}>"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f" {_camel(method.host_name)}"
|
||||
f"(input: {request}): MaybePromise<{response}>"
|
||||
)
|
||||
lines.append(" }")
|
||||
lines.append("}")
|
||||
return lines
|
||||
|
||||
|
||||
def _render_sdk_type(methods: Sequence[ExtensionAPIMethod]) -> list[str]:
|
||||
lines = ["export type ExtensionSdk = {"]
|
||||
_render_sdk_type_node(lines, _namespace_tree(methods), 1)
|
||||
lines.append("}")
|
||||
return lines
|
||||
|
||||
|
||||
def _render_create_sdk(methods: Sequence[ExtensionAPIMethod]) -> list[str]:
|
||||
lines = [
|
||||
"export function createExtensionSdk(",
|
||||
" host: ExtensionHost",
|
||||
"): ExtensionSdk {",
|
||||
" return {",
|
||||
]
|
||||
_render_create_sdk_node(lines, _namespace_tree(methods), 2)
|
||||
lines.extend([" }", "}"])
|
||||
return lines
|
||||
|
||||
|
||||
def _render_sdk_type_node(lines: list[str], node: dict[str, Any], level: int) -> None:
|
||||
indent = " " * level
|
||||
for namespace, child in _iter_child_namespaces(node):
|
||||
lines.append(f"{indent}{namespace}: {{")
|
||||
_render_sdk_type_node(lines, child, level + 1)
|
||||
lines.append(f"{indent}}}")
|
||||
|
||||
for method in node.get("__methods__", []):
|
||||
request = _model_name(method.request_model)
|
||||
response = _model_name(method.response_model)
|
||||
if _is_empty_model(method.request_model):
|
||||
lines.append(f"{indent}{method.sdk_name}(): Promise<{response}>")
|
||||
else:
|
||||
lines.append(
|
||||
f"{indent}{method.sdk_name}(input: {request}): Promise<{response}>"
|
||||
)
|
||||
|
||||
|
||||
def _render_create_sdk_node(lines: list[str], node: dict[str, Any], level: int) -> None:
|
||||
indent = " " * level
|
||||
for namespace, child in _iter_child_namespaces(node):
|
||||
lines.append(f"{indent}{namespace}: {{")
|
||||
_render_create_sdk_node(lines, child, level + 1)
|
||||
lines.append(f"{indent}}},")
|
||||
|
||||
for method in node.get("__methods__", []):
|
||||
host_call_target = (
|
||||
f"host{_ts_access(method.host_interface)}"
|
||||
f"{_ts_access(_camel(method.host_name))}"
|
||||
)
|
||||
if _is_empty_model(method.request_model):
|
||||
signature = f"{method.sdk_name}()"
|
||||
host_call = f"{host_call_target}()"
|
||||
else:
|
||||
signature = f"{method.sdk_name}(input)"
|
||||
host_call = f"{host_call_target}(input)"
|
||||
lines.extend(
|
||||
[
|
||||
f"{indent}async {signature} {{",
|
||||
f"{indent} return {host_call}",
|
||||
f"{indent}}},",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _methods_by_host_interface(
|
||||
methods: Sequence[ExtensionAPIMethod],
|
||||
) -> dict[str, list[ExtensionAPIMethod]]:
|
||||
interfaces: dict[str, list[ExtensionAPIMethod]] = defaultdict(list)
|
||||
for method in sorted(
|
||||
methods, key=lambda item: (item.host_interface, item.host_name)
|
||||
):
|
||||
interfaces[method.host_interface].append(method)
|
||||
return dict(sorted(interfaces.items()))
|
||||
|
||||
|
||||
def _namespace_tree(methods: Sequence[ExtensionAPIMethod]) -> dict[str, Any]:
|
||||
tree: dict[str, Any] = {}
|
||||
for method in sorted(methods, key=lambda item: (item.namespace, item.sdk_name)):
|
||||
node = tree
|
||||
for part in method.namespace.split("."):
|
||||
node = node.setdefault(part, {})
|
||||
node.setdefault("__methods__", []).append(method)
|
||||
return tree
|
||||
|
||||
|
||||
def _iter_child_namespaces(
|
||||
node: dict[str, Any],
|
||||
) -> list[tuple[str, dict[str, Any]]]:
|
||||
return [
|
||||
(key, value)
|
||||
for key, value in sorted(node.items())
|
||||
if key != "__methods__" and isinstance(value, dict)
|
||||
]
|
||||
|
||||
|
||||
def _model_name(model: type[BaseModel]) -> str:
|
||||
return model.__name__
|
||||
|
||||
|
||||
def _camel(value: str) -> str:
|
||||
head, *tail = value.split("_")
|
||||
return head + "".join(part.capitalize() for part in tail)
|
||||
|
||||
|
||||
def _ts_property(value: str) -> str:
|
||||
if re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", value):
|
||||
return value
|
||||
return f'"{value}"'
|
||||
|
||||
|
||||
def _ts_access(value: str) -> str:
|
||||
if re.match(r"^[A-Za-z_$][A-Za-z0-9_$]*$", value):
|
||||
return f".{value}"
|
||||
return f'["{value}"]'
|
||||
|
||||
|
||||
def _is_empty_model(model: type[BaseModel]) -> bool:
|
||||
return not model.__fields__
|
||||
|
||||
|
||||
def main(argv: Sequence[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate a TypeScript SDK from the LNbits ExtensionHostAPI."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--method",
|
||||
action="append",
|
||||
dest="method_ids",
|
||||
help="ExtensionHostAPI method id to include. Can be passed multiple times.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--out",
|
||||
help="Output file. If omitted, the generated SDK is printed to stdout.",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
sdk = generate_typescript_sdk(method_ids=args.method_ids)
|
||||
if args.out:
|
||||
Path(args.out).write_text(sdk, encoding="utf-8")
|
||||
else:
|
||||
print(sdk, end="")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user