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

1"""Small, deliberately limited value wire format for the notebook UI. 

2 

3Four kinds go over the wire: 

4 

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. 

15 

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

21 

22from __future__ import annotations 

23 

24import math 

25from collections.abc import Callable, Mapping, Sequence 

26from typing import Any 

27 

28import numpy as np 

29import pandas as pd 

30 

31 

32class ValueWireError(ValueError): 

33 """Raised when an edited UI value has an invalid wire representation.""" 

34 

35 

36_FLOAT_SENTINELS = { 

37 "NaN": float("nan"), 

38 "Infinity": float("inf"), 

39 "-Infinity": float("-inf"), 

40} 

41 

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 

45 

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 

50 

51#: Bounds on a nested dict or list view. 

52MAX_TREE_DEPTH = 4 

53MAX_TREE_CHILDREN = 50 

54 

55#: Longest repr used for a single table cell or tree leaf. 

56MAX_CELL_LENGTH = 120 

57 

58_ELLIPSIS = "..." 

59 

60 

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 

68 

69 

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 

75 

76 

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) 

84 

85 

86def _unwrap(value: Any) -> Any: 

87 """Convert a NumPy or pandas scalar to its plain Python equivalent. 

88 

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 

99 

100 

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) 

114 

115 

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" 

127 

128 

129def _frame_to_wire(frame: pd.DataFrame, *, type_name: str, editable: bool) -> dict[str, Any]: 

130 """Describe a window onto a DataFrame. 

131 

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 } 

152 

153 

154def _table_to_wire(value: Any) -> dict[str, Any] | None: 

155 """Describe a tabular value, or return None if this is not one. 

156 

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 

171 

172 

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__} 

200 

201 

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 

207 

208 

209def to_wire(value: Any) -> dict[str, Any]: 

210 """Describe a value without serializing arbitrary Python objects. 

211 

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

232 

233 

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 

240 

241 

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 

248 

249 

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 

256 

257 

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) 

266 

267 

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 

274 

275 

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} 

285 

286 

287def from_wire(data: Any) -> Any: 

288 """Decode a scalar value produced by :func:`to_wire`. 

289 

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

304 

305 

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. 

308 

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. 

312 

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) 

328 

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) 

333 

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) 

343 

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