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

1"""Gateway CLI helpers: config loading and runnable gateway assembly. 

2 

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""" 

9 

10from __future__ import annotations 

11 

12from importlib import metadata 

13from pathlib import Path 

14from typing import Any 

15 

16__all__ = ["build_gateway_app", "load_gateway_config", "serve_gateway"] 

17 

18 

19def load_gateway_config(path: str | Path) -> Any: 

20 """Load and validate a gateway configuration file (JSON or TOML). 

21 

22 The file is parsed with the stdlib JSON/TOML parsers and validated 

23 through ``RelayGatewayConfig.from_mapping``. 

24 

25 Args: 

26 path: Path to the configuration file. 

27 

28 Returns: 

29 The validated ``RelayGatewayConfig``. 

30 

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 

36 

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 

45 

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 

52 

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) 

62 

63 

64def build_gateway_app(config: Any, host: str = "127.0.0.1") -> Any: 

65 """Assemble the runnable gateway ``Application`` for *config*. 

66 

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. 

71 

72 Args: 

73 config: The validated ``RelayGatewayConfig`` to serve. 

74 host: Bind host forwarded to ``WebModule.configure``. 

75 

76 Returns: 

77 A composed, not-yet-started ``Application``. 

78 

79 Raises: 

80 ModuleNotFoundError: When a required module entry point is not 

81 installed. 

82 """ 

83 from lexigram.app.base import Application 

84 

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") 

89 

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 

100 

101 

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. 

106 

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`. 

110 

111 Args: 

112 config_path: Path to the gateway configuration file. 

113 host: Bind address. 

114 port: Bind port. 

115 

116 Raises: 

117 ModuleNotFoundError: When no ``lexigram.servers`` runner named 

118 ``"web"`` is installed. 

119 """ 

120 run_server = _load_module("lexigram.servers", "web") 

121 

122 config = load_gateway_config(config_path) 

123 app = build_gateway_app(config, host=host) 

124 run_server(app, host=host, port=port) 

125 

126 

127def _load_module(group: str, name: str) -> Any: 

128 """Load one module class from *group* by entry-point *name*. 

129 

130 Args: 

131 group: The entry-point group to inspect. 

132 name: The entry-point name to load. 

133 

134 Returns: 

135 The loaded module class. 

136 

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()