mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(gateway): generate desktop REST contracts
This commit is contained in:
parent
a1c30df654
commit
164c9dfe49
5 changed files with 1379 additions and 1003 deletions
File diff suppressed because it is too large
Load diff
163
hermes_cli/contract_types.py
Normal file
163
hermes_cli/contract_types.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
"""ts-type extensions for documented Python gateway contracts.
|
||||
|
||||
Python keeps PEP 257 attribute docstrings in source ASTs but discards them at
|
||||
runtime. This module restores those docs while `ts-type` renders our stdlib
|
||||
``TypedDict`` contracts, making Python the canonical source for both wire
|
||||
shape and TypeScript documentation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from functools import lru_cache
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
from typing import Any, get_args, get_origin, get_type_hints, NotRequired, Required
|
||||
|
||||
import ts_type as ts
|
||||
|
||||
|
||||
class OpaqueValue:
|
||||
"""A JSON value whose structure is intentionally not part of the contract."""
|
||||
|
||||
|
||||
def _unwrap_typed_dict_marker(value: Any) -> Any:
|
||||
"""Strip Python-only requiredness markers before ts-type renders a field."""
|
||||
if get_origin(value) in {NotRequired, Required}:
|
||||
return get_args(value)[0]
|
||||
return value
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _typed_dict_docs(contract: type) -> tuple[str | None, dict[str, str]]:
|
||||
"""Return the PEP 257 class and attribute docs for a contract source class."""
|
||||
source_path = inspect.getsourcefile(contract)
|
||||
if source_path is None:
|
||||
return None, {}
|
||||
|
||||
module = ast.parse(Path(source_path).read_text(encoding="utf-8"))
|
||||
node: ast.ClassDef | ast.Module = module
|
||||
for name in contract.__qualname__.split("."):
|
||||
if name == "<locals>":
|
||||
continue
|
||||
child = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in node.body
|
||||
if isinstance(candidate, ast.ClassDef) and candidate.name == name
|
||||
),
|
||||
None,
|
||||
)
|
||||
if child is None:
|
||||
return None, {}
|
||||
node = child
|
||||
|
||||
class_doc = ast.get_docstring(node)
|
||||
field_docs: dict[str, str] = {}
|
||||
for index, child in enumerate(node.body[:-1]):
|
||||
next_child = node.body[index + 1]
|
||||
if not (
|
||||
isinstance(child, ast.AnnAssign)
|
||||
and isinstance(child.target, ast.Name)
|
||||
and isinstance(next_child, ast.Expr)
|
||||
and isinstance(next_child.value, ast.Constant)
|
||||
and isinstance(next_child.value.value, str)
|
||||
):
|
||||
continue
|
||||
field_docs[child.target.id] = inspect.cleandoc(next_child.value.value)
|
||||
return class_doc, field_docs
|
||||
|
||||
|
||||
def _render_jsdoc(doc: str, indent: str = "") -> str:
|
||||
"""Render a Python docstring as an escaped TypeScript JSDoc block."""
|
||||
lines = inspect.cleandoc(doc).replace("*/", "*\\/").splitlines() or [""]
|
||||
if len(lines) == 1:
|
||||
return f"{indent}/** {lines[0]} */"
|
||||
return "\n".join(
|
||||
[f"{indent}/**", *[f"{indent} * {line}" for line in lines], f"{indent} */"]
|
||||
)
|
||||
|
||||
|
||||
class DocumentedObject(ts.Object):
|
||||
"""A TypeScript object node which retains PEP 257 field docs."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: Any,
|
||||
class_doc: str | None,
|
||||
field_docs: dict[str, str],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.class_doc = class_doc
|
||||
self.field_docs = field_docs
|
||||
|
||||
def render(self, context: ts.RenderContext) -> str:
|
||||
child_context = context.clone(indent_level=context.indent_level + 1)
|
||||
fields: list[str] = []
|
||||
for key, value in self.attrs.items():
|
||||
if doc := self.field_docs.get(key):
|
||||
fields.append(_render_jsdoc(doc, child_context.indent))
|
||||
fields.append(
|
||||
"".join(
|
||||
[
|
||||
child_context.indent,
|
||||
"readonly " if key in self.readonly else "",
|
||||
f'"{key}"',
|
||||
"?" if key in self.omissible else "",
|
||||
": ",
|
||||
value.render(child_context),
|
||||
";",
|
||||
]
|
||||
)
|
||||
)
|
||||
return "\n".join(["{", *fields, f"{context.indent}}}"])
|
||||
|
||||
|
||||
class DocumentedContractBuilder(ts.NodeBuilder):
|
||||
"""Render ``TypedDict`` contracts with PEP 257 class and field docs."""
|
||||
|
||||
def handle_unknown_type(self, value: Any) -> ts.TypeNode:
|
||||
if value is OpaqueValue:
|
||||
return ts.Unknown()
|
||||
return super().handle_unknown_type(value)
|
||||
|
||||
def typeddict_to_node(self, contract: type) -> ts.TypeNode:
|
||||
class_doc, field_docs = _typed_dict_docs(contract)
|
||||
annotations = get_type_hints(contract, include_extras=True)
|
||||
omissible = contract.__optional_keys__ | {
|
||||
key
|
||||
for key, value in annotations.items()
|
||||
if get_origin(value) is NotRequired
|
||||
}
|
||||
return self.define_ref_node(
|
||||
contract,
|
||||
lambda: DocumentedObject(
|
||||
attrs={
|
||||
key: self.type_to_node(_unwrap_typed_dict_marker(value))
|
||||
for key, value in annotations.items()
|
||||
},
|
||||
omissible=omissible,
|
||||
class_doc=class_doc,
|
||||
field_docs=field_docs,
|
||||
),
|
||||
)
|
||||
|
||||
def render(self, refs_to_export: set[str] | None = None) -> str:
|
||||
context = ts.RenderContext(self.definitions)
|
||||
to_export = refs_to_export or set(self.definitions)
|
||||
names = [name for name in self.definitions if name in to_export] + [
|
||||
name for name in self.definitions if name not in to_export
|
||||
]
|
||||
|
||||
def render(name: str) -> str:
|
||||
node = context.definitions[name]
|
||||
prefix = ""
|
||||
if name in to_export and isinstance(node, ts.Reference):
|
||||
resolved = context.resolve_ref(node)
|
||||
if isinstance(resolved, DocumentedObject) and resolved.class_doc:
|
||||
prefix = _render_jsdoc(resolved.class_doc) + "\n"
|
||||
export = "export " if name in to_export else ""
|
||||
return f"{prefix}{export}type {ts.Reference(name).render(context)} = {node.render(context)};"
|
||||
|
||||
return "\n\n".join(render(name) for name in names)
|
||||
1061
hermes_cli/web_contracts.py
Normal file
1061
hermes_cli/web_contracts.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -8,6 +8,8 @@ from pathlib import Path
|
|||
from tempfile import NamedTemporaryFile
|
||||
|
||||
import ts_type as ts
|
||||
from hermes_cli.contract_types import DocumentedContractBuilder
|
||||
from hermes_cli.web_contracts import EXPORTED_CONTRACTS
|
||||
from tui_gateway.contracts import (
|
||||
GatewayMcpServerStatus,
|
||||
GatewayProjectInfo,
|
||||
|
|
@ -27,8 +29,10 @@ def main() -> None:
|
|||
GatewaySessionRuntimeInfo,
|
||||
):
|
||||
ts.generator.add(contract, "gateway", contract.__name__)
|
||||
for name, contract in EXPORTED_CONTRACTS.items():
|
||||
ts.generator.add(contract, "gateway", name)
|
||||
|
||||
source = ts.generator.render()["gateway"]
|
||||
source = ts.generator.render(builder_cls=DocumentedContractBuilder)["gateway"]
|
||||
output = "// Generated by scripts/generate_gateway_types.py. Do not edit.\n\n" + source + "\n"
|
||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
|
|
|||
45
tests/hermes_cli/test_contract_types.py
Normal file
45
tests/hermes_cli/test_contract_types.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""Behavioral tests for documented shared-contract generation."""
|
||||
|
||||
from typing import NotRequired, TypedDict
|
||||
|
||||
import ts_type as ts
|
||||
|
||||
from hermes_cli.contract_types import DocumentedContractBuilder, OpaqueValue
|
||||
|
||||
|
||||
class DocumentedContract(TypedDict, total=False):
|
||||
"""A contract-level docstring with a slash: */ must stay safe."""
|
||||
|
||||
name: str
|
||||
"""A human-readable name."""
|
||||
payload: OpaqueValue
|
||||
"""Data whose schema Hermes intentionally does not promise."""
|
||||
|
||||
|
||||
class MixedRequirednessContract(TypedDict):
|
||||
required: str
|
||||
optional: NotRequired[int]
|
||||
|
||||
|
||||
def test_documented_contract_builder_renders_pep_257_docs_and_unknown_values():
|
||||
generator = ts.TypeDefinitionGenerator()
|
||||
generator.add(DocumentedContract, "gateway", "DocumentedContract")
|
||||
|
||||
source = generator.render(builder_cls=DocumentedContractBuilder)["gateway"]
|
||||
|
||||
assert "/** A contract-level docstring with a slash: *\\/ must stay safe. */" in source
|
||||
assert " /** A human-readable name. */\n \"name\"?: string;" in source
|
||||
assert (
|
||||
" /** Data whose schema Hermes intentionally does not promise. */\n"
|
||||
' "payload"?: unknown;'
|
||||
) in source
|
||||
|
||||
|
||||
def test_documented_contract_builder_unwraps_python_optional_field_markers():
|
||||
generator = ts.TypeDefinitionGenerator()
|
||||
generator.add(MixedRequirednessContract, "gateway", "MixedRequirednessContract")
|
||||
|
||||
source = generator.render(builder_cls=DocumentedContractBuilder)["gateway"]
|
||||
|
||||
assert '"required": string;' in source
|
||||
assert '"optional"?: number;' in source
|
||||
Loading…
Add table
Add a link
Reference in a new issue