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:
Vlad Stan
2026-07-15 13:50:19 +03:00
committed by GitHub
co-authored by alan Tiago Vasconcelos
parent 43900dd6da
commit 61ed636df0
84 changed files with 14304 additions and 96 deletions
+368
View File
@@ -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())