Coverage for src/loman/ui/builder.py: 100%

124 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-07 00:32 +0000

1"""Turning a node definition typed in the browser into a real Loman node. 

2 

3The widget's other requests name something that already exists --- a rendered 

4node, an open block, a cell of a value. These ones do not: a definition arrives 

5as text, and this module is what turns that text into the arguments 

6:meth:`~loman.computeengine.Computation.add_node` takes. 

7 

8Two shapes of definition go over the wire: 

9 

10``input`` 

11 A name, and optionally a scalar to seed it with. Without one the node is 

12 created UNINITIALIZED, which is the ordinary way to declare an input you 

13 will supply later. 

14``calc`` 

15 A name, a list of inputs and a Python expression. The inputs become the 

16 function's parameters and the graph's edges; the expression becomes its 

17 body. 

18 

19Names are read relative to whatever the widget is rooted on, so a name typed 

20while focused on ``market`` lands inside ``market``. A leading ``/`` escapes 

21that and names a node from the top of the computation, which is how a block's 

22node depends on something outside it. 

23 

24The compiled function is a real function with real source: the text is 

25registered with :mod:`linecache` under the node's own filename, so 

26:meth:`Computation.get_source` shows the user what they typed rather than 

27reporting that the source is unavailable. It is not, however, *importable* --- 

28a UI-built function is a lambda by another name, so a computation containing 

29one cannot round-trip its functions through :meth:`Computation.save`. 

30""" 

31 

32from __future__ import annotations 

33 

34import keyword 

35import linecache 

36import re 

37from dataclasses import dataclass, field 

38from textwrap import indent 

39from typing import TYPE_CHECKING, Any 

40 

41from loman.computeengine import ConstantValue 

42from loman.consts import NodeAttributes 

43from loman.nodekey import Name, NodeKey, to_nodekey 

44 

45from .value import from_wire 

46 

47if TYPE_CHECKING: 

48 from collections.abc import Callable, Mapping, Sequence 

49 

50 from loman.computeengine import Computation 

51 

52 

53class GraphBuildError(ValueError): 

54 """Raised when a node definition from the browser cannot be built.""" 

55 

56 

57#: Longest expression the form accepts. Generous for the one-liners this is 

58#: for, and small enough that a runaway paste is refused rather than compiled. 

59MAX_EXPRESSION_LENGTH = 4_000 

60 

61#: Most inputs one definition may declare. A node with more parameters than 

62#: this is not being written in a text box. 

63MAX_INPUTS = 64 

64 

65#: Name given to the compiled function when the node's own label is not a 

66#: usable identifier --- ``market/1`` and ``portfolio value`` both land here. 

67_FALLBACK_FUNC_NAME = "node" 

68 

69_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z") 

70 

71 

72def _is_identifier(text: str) -> bool: 

73 """Report whether ``text`` can be used as a Python parameter name.""" 

74 return bool(_IDENTIFIER.match(text)) and not keyword.iskeyword(text) 

75 

76 

77def default_parameter(node_key: NodeKey) -> str: 

78 """Return the parameter name a node lends its own dependent, or ``""``. 

79 

80 A node's last path part is what a function would naturally call it, and it 

81 is what Loman itself uses when resolving a parameter to a sibling node. A 

82 part that is not an identifier --- because it is numeric, quoted, or has a 

83 space in it --- lends nothing, and the definition has to name a parameter 

84 explicitly. 

85 

86 :param node_key: The node being depended on. 

87 :return: The implied parameter name, or ``""`` if it has none. 

88 """ 

89 label = node_key.label 

90 return label if _is_identifier(label) else "" 

91 

92 

93def resolve_name(text: str, root: NodeKey | None = None) -> NodeKey: 

94 """Resolve a name typed in the browser against the view's root. 

95 

96 :param text: The name as typed. A leading ``/`` makes it absolute; anything 

97 else is relative to ``root``. 

98 :param root: The block the widget is currently rooted on, or ``None`` for 

99 the whole computation. 

100 :return: The full computation key. 

101 :raises GraphBuildError: If the name is blank. 

102 """ 

103 text = text.strip() 

104 if not text: 

105 msg = "A node needs a name" 

106 raise GraphBuildError(msg) 

107 if root is None or text.startswith("/"): 

108 return to_nodekey(text) 

109 return to_nodekey(root).join(to_nodekey(text)) 

110 

111 

112def relative_name(node_key: NodeKey, root: NodeKey | None = None) -> str: 

113 """Render a node's key the way :func:`resolve_name` would read it back. 

114 

115 :param node_key: The node to name. 

116 :param root: The block the widget is currently rooted on. 

117 :return: A relative path when the node is inside ``root``, and an absolute 

118 one, marked with a leading ``/``, when it is not. 

119 """ 

120 if root is None: 

121 return str(node_key) 

122 root_key = to_nodekey(root) 

123 inside = node_key.drop_root(root_key) 

124 return str(inside) if inside is not None and not inside.is_root else f"/{node_key}" 

125 

126 

127def parse_inputs(entries: Sequence[Any], root: NodeKey | None = None) -> dict[str, NodeKey]: 

128 """Resolve the input declarations of a calculation node. 

129 

130 Each entry is either a node name, in which case the parameter is named 

131 after the node, or ``parameter=node``, which is what a name that cannot be 

132 a parameter --- or two inputs whose last path parts collide --- needs. 

133 

134 :param entries: One declaration per entry. Blank entries are skipped, so 

135 the browser can send a textarea's lines without tidying them. 

136 :param root: The block the widget is currently rooted on. 

137 :return: Parameter name to node key, in the order declared. 

138 :raises GraphBuildError: If a declaration is malformed, names an unusable 

139 parameter, repeats one, or there are more than :data:`MAX_INPUTS`. 

140 """ 

141 kwds: dict[str, NodeKey] = {} 

142 for entry in entries: 

143 text = str(entry).strip() 

144 if not text: 

145 continue 

146 if len(kwds) >= MAX_INPUTS: 

147 msg = f"A node built here may take at most {MAX_INPUTS} inputs" 

148 raise GraphBuildError(msg) 

149 parameter, separator, path = text.partition("=") 

150 if not separator: 

151 parameter, path = "", text 

152 node_key = resolve_name(path, root) 

153 parameter = parameter.strip() or default_parameter(node_key) 

154 if not parameter: 

155 msg = f"{path.strip()} cannot be a parameter name, so give it one, as in value={path.strip()}" 

156 raise GraphBuildError(msg) 

157 if not _is_identifier(parameter): 

158 msg = f"{parameter} is not a usable parameter name" 

159 raise GraphBuildError(msg) 

160 if parameter in kwds: 

161 msg = f"Two inputs both arrive as {parameter}; name one of them, as in other_{parameter}=..." 

162 raise GraphBuildError(msg) 

163 kwds[parameter] = node_key 

164 return kwds 

165 

166 

167def _register_source(filename: str, source: str) -> None: 

168 """Make ``source`` visible to :mod:`inspect` under a filename with no file. 

169 

170 ``inspect.getsource`` reads through :mod:`linecache`, and consults it for a 

171 filename that does not exist on disk --- which is how a UI-built function 

172 can still show its own source in the detail panel. The ``None`` where a 

173 modification time belongs is what stops ``linecache.checkcache`` from 

174 dropping the entry when it fails to stat the file. 

175 """ 

176 linecache.cache[filename] = (len(source), None, source.splitlines(keepends=True), filename) 

177 

178 

179def compile_expression( 

180 expression: str, 

181 parameters: Sequence[str], 

182 *, 

183 node_key: NodeKey, 

184 namespace: dict[str, Any] | None = None, 

185) -> Callable[..., Any]: 

186 """Compile one expression into the function a calculation node runs. 

187 

188 :param expression: Python expression, evaluated with the parameters bound 

189 to the values of the nodes they came from. 

190 :param parameters: Parameter names, in order. 

191 :param node_key: The node being defined, which names the function and the 

192 pseudo-file its source is registered under. 

193 :param namespace: Globals the expression is evaluated against, so it can 

194 use the notebook's own imports. Pass ``globals()`` for that; ``None`` 

195 gives an empty namespace with builtins available. 

196 :return: The compiled function, carrying the expression it came from. 

197 :raises GraphBuildError: If the expression is blank, over 

198 :data:`MAX_EXPRESSION_LENGTH`, or does not parse as an expression. 

199 """ 

200 text = expression.strip() 

201 if not text: 

202 msg = "A calculation node needs an expression" 

203 raise GraphBuildError(msg) 

204 if len(text) > MAX_EXPRESSION_LENGTH: 

205 msg = f"That expression is {len(text)} characters, over the limit of {MAX_EXPRESSION_LENGTH}" 

206 raise GraphBuildError(msg) 

207 filename = f"<loman node {node_key}>" 

208 try: 

209 # Checked as an expression first, so a pasted statement is refused here 

210 # with its own error rather than as a puzzling one about a `return`. 

211 compile(text, filename, "eval") 

212 except SyntaxError as exc: 

213 msg = f"That expression does not parse: {exc.msg}" 

214 raise GraphBuildError(msg) from exc 

215 

216 func_name = node_key.label if _is_identifier(node_key.label) else _FALLBACK_FUNC_NAME 

217 # Wrapped in brackets and indented, so a multi-line expression stays one 

218 # expression: inside brackets Python joins lines and ignores indentation. 

219 source = f"def {func_name}({', '.join(parameters)}):\n return (\n{indent(text, ' ' * 8)}\n )\n" 

220 _register_source(filename, source) 

221 module_globals: dict[str, Any] = {} if namespace is None else namespace 

222 # Defining the function in its own locals rather than in ``module_globals`` 

223 # keeps the notebook's namespace clean while leaving the function's globals 

224 # pointing at the live mapping, so it sees imports made after it was built. 

225 defined: dict[str, Any] = {} 

226 # Running text as code is what this feature is, not something it does by 

227 # accident, and the caller has already agreed to it: the widget only 

228 # reaches here with ``buildable=True``, which is off by default and 

229 # documented as running browser-written code in the kernel. 

230 exec(compile(source, filename, "exec"), module_globals, defined) # noqa: S102 # nosec B102 

231 func = defined[func_name] 

232 # Kept so the form can be reopened on this node with what was typed in it. 

233 # Recovering it from the source would mean unpicking the wrapper above. 

234 func.__loman_expression__ = text 

235 return func 

236 

237 

238@dataclass(frozen=True) 

239class NodeDefinition: 

240 """A node the browser asked for, resolved against the computation.""" 

241 

242 key: NodeKey 

243 func: Callable[..., Any] | None = None 

244 kwds: dict[str, NodeKey] = field(default_factory=dict) 

245 value: Any = None 

246 has_value: bool = False 

247 

248 def apply(self, computation: Computation) -> None: 

249 """Add this node to ``computation``, replacing any node of that name. 

250 

251 :param computation: The computation to build in. 

252 """ 

253 extra = {"value": self.value} if self.has_value else {} 

254 computation.add_node(self.key, self.func, kwds=dict(self.kwds) or None, **extra) 

255 

256 

257def build_definition( 

258 request: Mapping[str, Any], 

259 *, 

260 root: NodeKey | None = None, 

261 namespace: dict[str, Any] | None = None, 

262) -> NodeDefinition: 

263 """Turn one browser definition payload into a node ready to be added. 

264 

265 :param request: The payload, which is untrusted: ``name``, ``kind``, and 

266 then ``value`` for an input node or ``inputs`` and ``expression`` for a 

267 calculation node. 

268 :param root: The block the widget is currently rooted on. 

269 :param namespace: Globals a compiled expression is evaluated against. 

270 :return: The resolved definition. 

271 :raises GraphBuildError: If the payload does not describe a node. 

272 :raises ValueWireError: If an input node's value is not a scalar this 

273 format supports. 

274 """ 

275 node_key = resolve_name(str(request.get("name", "")), root) 

276 kind = request.get("kind", "input") 

277 if kind == "input": 

278 wire = request.get("value") 

279 if wire is None: 

280 return NodeDefinition(key=node_key) 

281 return NodeDefinition(key=node_key, value=from_wire(wire), has_value=True) 

282 if kind != "calc": 

283 msg = f"A node is either an input or a calculation, not {kind!r}" 

284 raise GraphBuildError(msg) 

285 kwds = parse_inputs(request.get("inputs") or [], root) 

286 func = compile_expression(str(request.get("expression", "")), list(kwds), node_key=node_key, namespace=namespace) 

287 return NodeDefinition(key=node_key, func=func, kwds=kwds) 

288 

289 

290def format_inputs(kwds: Mapping[str, Name], root: NodeKey | None = None) -> list[str]: 

291 """Render a node's parameter mapping the way :func:`parse_inputs` reads it. 

292 

293 :param kwds: Parameter name to source node, as 

294 :meth:`Computation.get_definition_args_kwds` reports it. 

295 :param root: The block the widget is currently rooted on. 

296 :return: One declaration per input, sorted by parameter name. 

297 """ 

298 entries = [] 

299 for parameter, source in sorted(kwds.items()): 

300 if isinstance(source, ConstantValue): 

301 continue 

302 node_key = to_nodekey(source) 

303 text = relative_name(node_key, root) 

304 entries.append(text if default_parameter(node_key) == parameter else f"{parameter}={text}") 

305 return entries 

306 

307 

308def describe_definition(computation: Computation, node_key: NodeKey, root: NodeKey | None = None) -> dict[str, Any]: 

309 """Describe how a node is defined, so the form can be reopened on it. 

310 

311 ``editable`` says whether this form can put the node back the way it found 

312 it. It cannot for a node that takes positional or constant arguments, which 

313 the form has no field for, nor for a function written in Python, whose body 

314 is not an expression this form could reproduce --- offering to edit one 

315 would mean offering to replace it with something else. 

316 

317 :param computation: The computation the node belongs to. 

318 :param node_key: The node to describe. 

319 :param root: The block the widget is currently rooted on. 

320 :return: The payload the browser's node form reads. 

321 """ 

322 func = computation.dag.nodes[node_key].get(NodeAttributes.FUNC) 

323 args, kwds = computation.get_definition_args_kwds(node_key) 

324 expression = getattr(func, "__loman_expression__", None) 

325 constants = [source for source in kwds.values() if isinstance(source, ConstantValue)] 

326 return { 

327 # The node's own name in the form the node form reads, which is what 

328 # lets the form be reopened on it while the view is rooted on a block. 

329 "name": relative_name(node_key, root), 

330 "kind": "calc" if func is not None else "input", 

331 "inputs": format_inputs(kwds, root), 

332 "expression": expression, 

333 "editable": not args and not constants and (func is None or expression is not None), 

334 }