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
« 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.
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.
8Two shapes of definition go over the wire:
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.
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.
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"""
32from __future__ import annotations
34import keyword
35import linecache
36import re
37from dataclasses import dataclass, field
38from textwrap import indent
39from typing import TYPE_CHECKING, Any
41from loman.computeengine import ConstantValue
42from loman.consts import NodeAttributes
43from loman.nodekey import Name, NodeKey, to_nodekey
45from .value import from_wire
47if TYPE_CHECKING:
48 from collections.abc import Callable, Mapping, Sequence
50 from loman.computeengine import Computation
53class GraphBuildError(ValueError):
54 """Raised when a node definition from the browser cannot be built."""
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
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
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"
69_IDENTIFIER = re.compile(r"[A-Za-z_][A-Za-z0-9_]*\Z")
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)
77def default_parameter(node_key: NodeKey) -> str:
78 """Return the parameter name a node lends its own dependent, or ``""``.
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.
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 ""
93def resolve_name(text: str, root: NodeKey | None = None) -> NodeKey:
94 """Resolve a name typed in the browser against the view's root.
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))
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.
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}"
127def parse_inputs(entries: Sequence[Any], root: NodeKey | None = None) -> dict[str, NodeKey]:
128 """Resolve the input declarations of a calculation node.
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.
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
167def _register_source(filename: str, source: str) -> None:
168 """Make ``source`` visible to :mod:`inspect` under a filename with no file.
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)
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.
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
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
238@dataclass(frozen=True)
239class NodeDefinition:
240 """A node the browser asked for, resolved against the computation."""
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
248 def apply(self, computation: Computation) -> None:
249 """Add this node to ``computation``, replacing any node of that name.
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)
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.
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)
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.
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
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.
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.
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 }