Coverage for src/lexigram/ai/cli/gateway.py: 83%
48 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 07:19 +0800
1"""Gateway CLI helpers: config loading and runnable gateway assembly.
3The orchestrator package discovers the relay, http, and web modules
4through their entry-point groups (never direct imports), so the served
5application is the same composition a user's own ``Application`` would
6build: ``RelayModule`` + ``RelayGatewayModule`` (with the file-backed
7configuration) + ``HTTPModule`` + ``WebModule``.
8"""
10from __future__ import annotations
12from importlib import metadata
13from pathlib import Path
14from typing import Any
16__all__ = ["build_gateway_app", "load_gateway_config", "serve_gateway"]
19def load_gateway_config(path: str | Path) -> Any:
20 """Load and validate a gateway configuration file (JSON or TOML).
22 The file is parsed with the stdlib JSON/TOML parsers and validated
23 through ``RelayGatewayConfig.from_mapping``.
25 Args:
26 path: Path to the configuration file.
28 Returns:
29 The validated ``RelayGatewayConfig``.
31 Raises:
32 ValueError: When the file is unreadable or the content is not a
33 valid gateway configuration.
34 """
35 from lexigram.ai.relay.gateway.config import RelayGatewayConfig
37 file_path = Path(path).expanduser()
38 try:
39 raw = file_path.read_text(encoding="utf-8")
40 except OSError as exc:
41 raise ValueError(f"cannot read gateway config file: {file_path}") from exc
42 suffix = file_path.suffix.lower()
43 if suffix == ".json":
44 from lexigram.serialization import loads
46 try:
47 data = loads(raw)
48 except ValueError as exc:
49 raise ValueError(f"invalid JSON in {file_path}: {exc}") from exc
50 elif suffix in (".toml", ".tml"):
51 import tomllib
53 try:
54 data = tomllib.loads(raw)
55 except tomllib.TOMLDecodeError as exc:
56 raise ValueError(f"invalid TOML in {file_path}: {exc}") from exc
57 else:
58 raise ValueError("gateway config must be a .json or .toml file")
59 if not isinstance(data, dict):
60 raise TypeError("gateway config must be a JSON/TOML object")
61 return RelayGatewayConfig.from_mapping(data)
64def build_gateway_app(config: Any, host: str = "127.0.0.1") -> Any:
65 """Assemble the runnable gateway ``Application`` for *config*.
67 The relay, relay-gateway, http, and web modules are loaded from the
68 ``lexigram.ai.modules`` and ``lexigram.modules`` entry-point groups;
69 a missing module raises ``ModuleNotFoundError`` naming the group and
70 entry.
72 Args:
73 config: The validated ``RelayGatewayConfig`` to serve.
74 host: Bind host forwarded to ``WebModule.configure``.
76 Returns:
77 A composed, not-yet-started ``Application``.
79 Raises:
80 ModuleNotFoundError: When a required module entry point is not
81 installed.
82 """
83 from lexigram.app.base import Application
85 relay_module = _load_module("lexigram.ai.modules", "relay")
86 gateway_module = _load_module("lexigram.ai.modules", "relay-gateway")
87 http_module = _load_module("lexigram.modules", "http")
88 web_module = _load_module("lexigram.modules", "web")
90 app = Application(name="lexigram-relay-gateway")
91 app.add_modules(
92 [
93 relay_module.configure(),
94 gateway_module.configure(config=config),
95 http_module.configure(),
96 web_module.configure(host=host),
97 ]
98 )
99 return app
102def serve_gateway(
103 config_path: str | Path, host: str = "127.0.0.1", port: int = 8000
104) -> None:
105 """Load the gateway config and serve the composed application.
107 The server runner is loaded from the ``lexigram.servers`` entry-point
108 group (name ``"web"``, provided by ``lexigram-web``) — never imported
109 directly, mirroring the module composition in :func:`build_gateway_app`.
111 Args:
112 config_path: Path to the gateway configuration file.
113 host: Bind address.
114 port: Bind port.
116 Raises:
117 ModuleNotFoundError: When no ``lexigram.servers`` runner named
118 ``"web"`` is installed.
119 """
120 run_server = _load_module("lexigram.servers", "web")
122 config = load_gateway_config(config_path)
123 app = build_gateway_app(config, host=host)
124 run_server(app, host=host, port=port)
127def _load_module(group: str, name: str) -> Any:
128 """Load one module class from *group* by entry-point *name*.
130 Args:
131 group: The entry-point group to inspect.
132 name: The entry-point name to load.
134 Returns:
135 The loaded module class.
137 Raises:
138 ModuleNotFoundError: When the entry point is not registered.
139 """
140 matches = [ep for ep in metadata.entry_points(group=group) if ep.name == name]
141 if not matches:
142 raise ModuleNotFoundError(
143 f"module entry point {name!r} not found in group {group!r}; "
144 "is the package installed?"
145 )
146 return matches[0].load()