Coverage for src/loman/ui/value.py: 100%
185 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"""Small, deliberately limited value wire format for the notebook UI.
3Four kinds go over the wire:
5``scalar``
6 ``int``, ``float``, ``str``, ``bool`` and ``None``, carried losslessly and
7 editable in both directions.
8``table``
9 A window onto a DataFrame, Series or 2-D array. Frames and Series are
10 editable cell by cell; arrays are shown read-only.
11``tree``
12 A bounded view of nested dicts and lists.
13``repr``
14 Everything else, as read-only text.
16The windows are the point. The widget's scaling rule is never to serialize node
17values in bulk, so a table sends its first :data:`MAX_TABLE_ROWS` rows and
18:data:`MAX_TABLE_COLS` columns plus the true shape, and a tree is bounded by
19depth and breadth. Anything larger stays in Python, where it belongs.
20"""
22from __future__ import annotations
24import math
25from collections.abc import Callable, Mapping, Sequence
26from typing import Any
28import numpy as np
29import pandas as pd
32class ValueWireError(ValueError):
33 """Raised when an edited UI value has an invalid wire representation."""
36_FLOAT_SENTINELS = {
37 "NaN": float("nan"),
38 "Infinity": float("inf"),
39 "-Infinity": float("-inf"),
40}
42#: Longest ``repr`` the detail panel will carry. Anything larger is truncated:
43#: the panel is for orientation, and the real object stays in Python.
44MAX_REPR_LENGTH = 2_000
46#: Window sent for tabular values. Sized so the payload stays in the tens of
47#: kilobytes even for wide frames, which is the same order as the state map.
48MAX_TABLE_ROWS = 50
49MAX_TABLE_COLS = 20
51#: Bounds on a nested dict or list view.
52MAX_TREE_DEPTH = 4
53MAX_TREE_CHILDREN = 50
55#: Longest repr used for a single table cell or tree leaf.
56MAX_CELL_LENGTH = 120
58_ELLIPSIS = "..."
61def _float_to_wire(value: float) -> float | str:
62 """Convert non-finite floats to JSON-safe sentinel strings."""
63 if math.isnan(value):
64 return "NaN"
65 if math.isinf(value):
66 return "Infinity" if value > 0 else "-Infinity"
67 return value
70def _truncate(text: str, limit: int) -> str:
71 """Shorten text to ``limit`` characters, marking where it was cut."""
72 if len(text) <= limit:
73 return text
74 return text[: limit - len(_ELLIPSIS)] + _ELLIPSIS
77def _safe_repr(value: Any, limit: int = MAX_REPR_LENGTH) -> str:
78 """Return a bounded repr, tolerating a broken ``__repr__``."""
79 try:
80 text = repr(value)
81 except Exception: # a broken __repr__ must not break the detail panel
82 text = f"<{type(value).__name__}: repr unavailable>"
83 return _truncate(text, limit)
86def _unwrap(value: Any) -> Any:
87 """Convert a NumPy or pandas scalar to its plain Python equivalent.
89 ``np.int64`` is not a Python ``int`` on every platform, so cells have to be
90 unwrapped before the scalar checks can recognise them.
91 """
92 item = getattr(value, "item", None)
93 if callable(item) and getattr(value, "shape", ()) == ():
94 try:
95 return item()
96 except (ValueError, TypeError): # pragma: no cover - exotic dtypes only
97 return value
98 return value
101def _cell_to_wire(value: Any) -> Any:
102 """Render one table cell or tree leaf as a JSON-safe plain value."""
103 value = _unwrap(value)
104 if value is None or isinstance(value, (bool, int, str)):
105 return value
106 if isinstance(value, float):
107 return _float_to_wire(value)
108 try:
109 if pd.isna(value):
110 return None
111 except (TypeError, ValueError):
112 pass
113 return _safe_repr(value, MAX_CELL_LENGTH)
116def _column_kind(dtype: Any) -> str:
117 """Classify a column so the browser knows how to render and edit it."""
118 if pd.api.types.is_bool_dtype(dtype):
119 return "bool"
120 if pd.api.types.is_integer_dtype(dtype):
121 return "int"
122 if pd.api.types.is_float_dtype(dtype):
123 return "float"
124 if pd.api.types.is_string_dtype(dtype) or pd.api.types.is_object_dtype(dtype):
125 return "str"
126 return "other"
129def _frame_to_wire(frame: pd.DataFrame, *, type_name: str, editable: bool) -> dict[str, Any]:
130 """Describe a window onto a DataFrame.
132 The window is the **tail**, because rows are usually appended and the recent
133 end is the interesting one. ``row_offset`` is where the window starts in the
134 full frame: cell edits address absolute positions, so the browser has to add
135 it back before asking for one.
136 """
137 rows, cols = frame.shape
138 row_offset = max(rows - MAX_TABLE_ROWS, 0)
139 window = frame.iloc[row_offset:, :MAX_TABLE_COLS]
140 return {
141 "kind": "table",
142 "type": type_name,
143 "columns": [str(column) for column in window.columns],
144 "index": [_cell_to_wire(label) for label in window.index],
145 "rows": [[_cell_to_wire(cell) for cell in record] for record in window.itertuples(index=False, name=None)],
146 "column_kinds": [_column_kind(window.dtypes.iloc[i]) for i in range(window.shape[1])],
147 "shape": [int(rows), int(cols)],
148 "shown": [int(window.shape[0]), int(window.shape[1])],
149 "row_offset": int(row_offset),
150 "editable": editable,
151 }
154def _table_to_wire(value: Any) -> dict[str, Any] | None:
155 """Describe a tabular value, or return None if this is not one.
157 Frames and Series are editable cell by cell. Arrays are not: NumPy coerces
158 silently on assignment, so an edit could change a value without saying so.
159 """
160 if isinstance(value, pd.DataFrame):
161 return _frame_to_wire(value, type_name="DataFrame", editable=True)
162 if isinstance(value, pd.Series):
163 name = str(value.name) if value.name is not None else "value"
164 return _frame_to_wire(value.to_frame(name=name), type_name="Series", editable=True)
165 if isinstance(value, np.ndarray) and value.ndim in (1, 2):
166 frame = pd.DataFrame(value if value.ndim == 2 else value.reshape(-1, 1))
167 wire = _frame_to_wire(frame, type_name="ndarray", editable=False)
168 wire["shape"] = [int(size) for size in value.shape]
169 return wire
170 return None
173def _tree_node(value: Any, depth: int) -> dict[str, Any]:
174 """Describe one node of a nested dict or list, bounded by depth and breadth."""
175 if isinstance(value, Mapping):
176 items = list(value.items())
177 node: dict[str, Any] = {"type": "dict", "size": len(items)}
178 if depth >= MAX_TREE_DEPTH:
179 node["truncated"] = True
180 return node
181 node["children"] = [
182 {"key": _truncate(str(key), MAX_CELL_LENGTH), **_tree_node(child, depth + 1)}
183 for key, child in items[:MAX_TREE_CHILDREN]
184 ]
185 node["truncated"] = len(items) > MAX_TREE_CHILDREN
186 return node
187 if isinstance(value, (list, tuple)) and not isinstance(value, str):
188 items = list(value)
189 node = {"type": "list", "size": len(items)}
190 if depth >= MAX_TREE_DEPTH:
191 node["truncated"] = True
192 return node
193 node["children"] = [
194 {"key": str(position), **_tree_node(child, depth + 1)}
195 for position, child in enumerate(items[:MAX_TREE_CHILDREN])
196 ]
197 node["truncated"] = len(items) > MAX_TREE_CHILDREN
198 return node
199 return {"type": "leaf", "value": _cell_to_wire(value), "repr": type(_unwrap(value)).__name__}
202def _tree_to_wire(value: Any) -> dict[str, Any] | None:
203 """Describe a nested dict or list, or return None if this is neither."""
204 if isinstance(value, Mapping) or (isinstance(value, Sequence) and not isinstance(value, (str, bytes))):
205 return {"kind": "tree", "type": type(value).__name__, "root": _tree_node(value, 0)}
206 return None
209def to_wire(value: Any) -> dict[str, Any]:
210 """Describe a value without serializing arbitrary Python objects.
212 :param value: Any node value.
213 :return: A JSON-safe description; see the module docstring for the kinds.
214 """
215 if value is None:
216 return {"kind": "scalar", "type": "none", "value": None}
217 if isinstance(value, bool):
218 return {"kind": "scalar", "type": "bool", "value": value}
219 if isinstance(value, int):
220 return {"kind": "scalar", "type": "int", "value": value}
221 if isinstance(value, float):
222 return {"kind": "scalar", "type": "float", "value": _float_to_wire(value)}
223 if isinstance(value, str):
224 return {"kind": "scalar", "type": "str", "value": value}
225 table = _table_to_wire(value)
226 if table is not None:
227 return table
228 tree = _tree_to_wire(value)
229 if tree is not None:
230 return tree
231 return {"kind": "repr", "type": type(value).__name__, "repr": _safe_repr(value)}
234def _decode_none(value: Any) -> None:
235 """Decode a JSON null."""
236 if value is not None:
237 msg = "A none value must contain JSON null"
238 raise ValueWireError(msg)
239 return None
242def _decode_bool(value: Any) -> bool:
243 """Decode a JSON boolean."""
244 if not isinstance(value, bool):
245 msg = "A bool value must contain a boolean"
246 raise ValueWireError(msg)
247 return value
250def _decode_int(value: Any) -> int:
251 """Decode a JSON integer, rejecting the bool that would pass isinstance."""
252 if isinstance(value, bool) or not isinstance(value, int):
253 msg = "An int value must contain an integer"
254 raise ValueWireError(msg)
255 return value
258def _decode_float(value: Any) -> float:
259 """Decode a JSON number, or one of the non-finite sentinel strings."""
260 if isinstance(value, str) and value in _FLOAT_SENTINELS:
261 return _FLOAT_SENTINELS[value]
262 if isinstance(value, bool) or not isinstance(value, (int, float)):
263 msg = "A float value must contain a number"
264 raise ValueWireError(msg)
265 return float(value)
268def _decode_str(value: Any) -> str:
269 """Decode a JSON string."""
270 if not isinstance(value, str):
271 msg = "A str value must contain text"
272 raise ValueWireError(msg)
273 return value
276#: One decoder per supported scalar type. Each validates strictly rather than
277#: coercing, so a browser cannot silently change a node's Python type.
278_DECODERS: dict[str, Callable[[Any], Any]] = {
279 "none": _decode_none,
280 "bool": _decode_bool,
281 "int": _decode_int,
282 "float": _decode_float,
283 "str": _decode_str,
284}
287def from_wire(data: Any) -> Any:
288 """Decode a scalar value produced by :func:`to_wire`.
290 :param data: A payload from the browser, which is untrusted.
291 :return: The decoded Python value.
292 :raises ValueWireError: If the payload is not a scalar this format supports,
293 or does not match the type it declares.
294 """
295 if not isinstance(data, dict) or data.get("kind") != "scalar":
296 msg = "Only scalar UI values can be edited"
297 raise ValueWireError(msg)
298 value_type = data.get("type")
299 decoder = _DECODERS.get(value_type) if isinstance(value_type, str) else None
300 if decoder is None:
301 msg = f"Unsupported scalar type: {value_type!r}"
302 raise ValueWireError(msg)
303 return decoder(data.get("value"))
306def apply_cell_edit(value: Any, row: int, column: int, cell: Any) -> Any:
307 """Return a copy of a tabular value with one cell replaced.
309 The original is never mutated. Loman's :meth:`Computation.copy` is shallow,
310 so a node's value can be shared with another computation; editing in place
311 would change both.
313 :param value: The current node value, a DataFrame or Series.
314 :param row: Absolute row position, as counted in the full value.
315 :param column: Absolute column position. Ignored for a Series.
316 :param cell: Decoded replacement, from :func:`from_wire`.
317 :return: A new DataFrame or Series with the cell replaced.
318 :raises ValueWireError: If the value is not editable, the address is out of
319 range, or the replacement does not fit the column's type.
320 """
321 if isinstance(value, pd.Series):
322 frame, was_series, name = value.to_frame(), True, value.name
323 elif isinstance(value, pd.DataFrame):
324 frame, was_series, name = value, False, None
325 else:
326 msg = f"Cells of a {type(value).__name__} cannot be edited"
327 raise ValueWireError(msg)
329 rows, cols = frame.shape
330 if not (0 <= row < rows) or not (0 <= column < cols):
331 msg = f"Cell ({row}, {column}) is outside a {rows} by {cols} table"
332 raise ValueWireError(msg)
334 kind = _column_kind(frame.dtypes.iloc[column])
335 if kind == "other":
336 msg = f"Column {frame.columns[column]!r} holds values this editor cannot set"
337 raise ValueWireError(msg)
338 if cell is not None and kind != "str":
339 expected = {"int": int, "float": (int, float), "bool": bool}[kind]
340 if isinstance(cell, bool) is not (kind == "bool") or not isinstance(cell, expected):
341 msg = f"Column {frame.columns[column]!r} holds {kind} values"
342 raise ValueWireError(msg)
344 updated = frame.copy()
345 try:
346 updated.isetitem(column, updated.iloc[:, column].astype(object))
347 updated.iloc[row, column] = cell
348 updated.isetitem(column, updated.iloc[:, column].astype(frame.dtypes.iloc[column]))
349 except (TypeError, ValueError) as exc:
350 # A None into an int column, or a value the dtype cannot hold.
351 msg = f"Cannot put that value in column {frame.columns[column]!r}: {exc}"
352 raise ValueWireError(msg) from exc
353 return updated.iloc[:, 0].rename(name) if was_series else updated