Coverage for src/loman/serialization/computation.py: 100%

337 statements  

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

1"""Serialization for Computation graphs to/from JSON.""" 

2 

3from __future__ import annotations 

4 

5import contextvars 

6import io 

7import json 

8import warnings 

9import zipfile 

10from pathlib import Path 

11from typing import TYPE_CHECKING, Any, ClassVar, NamedTuple, TextIO 

12 

13from loman.consts import EdgeAttributes, NodeAttributes, States, SystemTags 

14from loman.exception import SerializationError 

15from loman.nodekey import parse_nodekey 

16 

17from .blobs import ( 

18 CONTAINER_STORE, 

19 MANIFEST_NAME, 

20 BlobReader, 

21 BlobStore, 

22 BlobWriter, 

23 DirBlobStore, 

24 ZipBlobStore, 

25 read_dir_manifest, 

26 read_zip_manifest, 

27 write_dir_container, 

28 write_zip_container, 

29) 

30from .profile import READABLE, SerializationProfile, resolve_profile 

31from .transformer import ( 

32 DataFrameTransformer, 

33 DefinitionMethodTransformer, 

34 DillFunctionTransformer, 

35 EnumTransformer, 

36 FunctionRefTransformer, 

37 NdArrayTransformer, 

38 NodeKeyTransformer, 

39 SeriesTransformer, 

40 Transformer, 

41 UntransformableTypeError, 

42) 

43from .values import register_value_transformers 

44 

45if TYPE_CHECKING: 

46 pass 

47 

48# Serialization format version — bump when the schema changes. 

49# 

50# This is version 1: the first version of the format that is meant to be kept. 

51# The shapes written by pre-release versions of loman are still read, on a 

52# best-effort basis, by the reader branches marked "legacy" below --- an escaped 

53# dict under "data" rather than "items", a row-major DataFrame, an index written 

54# as a list of elements. Those predate any published guarantee and are not 

55# themselves versioned, which is why the number restarts here rather than 

56# continuing a sequence that never meant anything to anyone outside the repo. 

57# 

58# Note that the reader has never inspected this field, so a bump cannot make an 

59# older release reject a newer file. Forward compatibility comes from keeping 

60# changes additive, not from this number. 

61FORMAT_VERSION = 1 

62 

63# Sentinel for a constant the policy dropped, distinct from a legitimate None. 

64_DROPPED = object() 

65 

66_CONSTANT_POLICIES = frozenset({"raise", "drop"}) 

67 

68 

69class UnserializableConstantWarning(UserWarning): 

70 """A constant argument was dropped, so the saved graph cannot be recalculated.""" 

71 

72 

73class UnserializableFunctionWarning(UserWarning): 

74 """A node's function could not be encoded, so that node cannot be recalculated. 

75 

76 The node's *value* is still saved. What is lost is the ability to recompute 

77 it: the reloaded node holds what it last produced and stays out of date 

78 forever, because there is nothing left to call. 

79 

80 The common cause is a function that is not importable by name --- a lambda, a 

81 closure, or a bound method of a class built by 

82 :func:`~loman.computeengine.computation_factory`. 

83 """ 

84 

85 

86CONTAINERS = ("zip", "dir", "json") 

87 

88 

89class _SaveState(NamedTuple): 

90 """The blob writer and profile in force for one save.""" 

91 

92 writer: Any 

93 profile: Any 

94 

95 

96# Per-save state lives in a ContextVar rather than on the serializer, because a 

97# serializer is a natural thing to build once and reuse --- and instance 

98# attributes would mean two concurrent saves through the same one silently 

99# writing into each other's archive. Measured: 11 of 12 threads failed that way. 

100_SAVE_STATE: contextvars.ContextVar[_SaveState | None] = contextvars.ContextVar("loman_save_state", default=None) 

101 

102# Zip local file header. Every .loman archive starts with it. 

103_ZIP_MAGIC = b"PK\x03\x04" 

104 

105# Pickle protocol 2+ opening opcode, as written by dill. Recognised only so a 

106# write_dill file gets a useful error instead of "not valid JSON". 

107_PICKLE_MAGIC = b"\x80" 

108 

109 

110def infer_container_from_path(path: Path) -> str: 

111 """Return the container implied by *path*'s suffix. 

112 

113 ``.json`` means the single readable document; everything else means a 

114 ``.loman`` zip, which is the default because a single file is what people 

115 move around. 

116 """ 

117 return "json" if path.suffix.lower() == ".json" else "zip" 

118 

119 

120def _resolve_profile_for_container( 

121 profile: str | SerializationProfile | None, 

122 container: str, 

123 stores: dict[str, Any] | None = None, 

124) -> SerializationProfile: 

125 """Return the profile to use, rejecting the one impossible combination.""" 

126 if container not in CONTAINERS: 

127 msg = f"Unknown container {container!r}; expected one of {list(CONTAINERS)}" 

128 raise ValueError(msg) 

129 

130 if container == "json": 

131 if profile is None: 

132 return READABLE 

133 resolved = resolve_profile(profile) 

134 # A single JSON document holds no blobs of its own. It can still carry 

135 # out-of-line values when an external store is supplied, so the refusal 

136 # only applies when there is genuinely nowhere for them to go. 

137 if resolved.inline_max_bytes is not None and not stores: 

138 msg = ( 

139 f"The {resolved.name!r} profile writes values out of line, and a single JSON " 

140 "document has nowhere to put them. Use container='zip' (or a .loman path) to " 

141 "keep this profile, profile='readable' to keep the single document, or pass " 

142 "stores={...} to hold the values somewhere of your own." 

143 ) 

144 raise ValueError(msg) 

145 return resolved 

146 

147 return resolve_profile(profile) 

148 

149 

150def sniff_container(path: Path) -> str: 

151 """Return which container *path* holds, by looking at it. 

152 

153 Order matters: a directory is checked first because it cannot be read as 

154 bytes, then the zip magic number, then a leading brace for the single 

155 document. A dill pickle is recognised only to say so, since "not valid JSON" 

156 would be a poor description of that mistake. 

157 """ 

158 if path.is_dir(): 

159 return "dir" 

160 if not path.exists(): 

161 msg = f"No such file or directory: {str(path)!r}" 

162 raise FileNotFoundError(msg) 

163 

164 with path.open("rb") as f: 

165 head = f.read(4) 

166 

167 if head.startswith(_ZIP_MAGIC): 

168 return "zip" 

169 stripped = head.lstrip() 

170 if stripped.startswith(b"{"): 

171 return "json" 

172 if head.startswith(_PICKLE_MAGIC): 

173 msg = ( 

174 f"{str(path)!r} looks like a pickle written by write_dill, not a loman container. " 

175 "Use Computation.read_dill to read it." 

176 ) 

177 raise SerializationError(msg) 

178 

179 msg = ( 

180 f"Cannot tell what {str(path)!r} is. Expected a .loman archive, a directory " 

181 f"containing {MANIFEST_NAME}, or a JSON document." 

182 ) 

183 raise SerializationError(msg) 

184 

185 

186def default_computation_transformer() -> Transformer: 

187 """Create a Transformer pre-registered with all types needed for Computation serialization.""" 

188 t = Transformer() 

189 

190 # Numeric arrays 

191 t.register(NdArrayTransformer()) 

192 

193 # Enums: register the States enum so node states roundtrip correctly. 

194 enum_t = EnumTransformer() 

195 enum_t.register_enum(States) 

196 t.register(enum_t) 

197 

198 # Importable callables (module-level functions). Lambdas / closures raise. 

199 t.register(FunctionRefTransformer()) 

200 

201 # Calc nodes declared on a @ComputationFactory class, which are bound methods 

202 # of the definition object and so have no importable path of their own. 

203 t.register(DefinitionMethodTransformer()) 

204 

205 # Pandas 

206 t.register(DataFrameTransformer()) 

207 t.register(SeriesTransformer()) 

208 

209 # NodeKey (hierarchical node names) 

210 t.register(NodeKeyTransformer()) 

211 

212 # Dates, indexes, numpy scalars, sets, bytes, decimals. 

213 register_value_transformers(t) 

214 

215 # Per-node execution timing. Imported here rather than at module scope 

216 # because computeengine reaches back into this module to serialize. 

217 from loman.computeengine import TimingData 

218 

219 t.register(TimingData) 

220 

221 return t 

222 

223 

224def dill_computation_transformer() -> Transformer: 

225 """Create a Transformer that serializes all callables — including lambdas and closures — via dill. 

226 

227 Identical to :func:`default_computation_transformer` except that 

228 :class:`~loman.serialization.transformer.DillFunctionTransformer` replaces 

229 :class:`~loman.serialization.transformer.FunctionRefTransformer`, so lambdas 

230 and locally-defined closures are serialized as base64-encoded dill blobs 

231 rather than raising :class:`~loman.exception.SerializationError`. 

232 """ 

233 t = Transformer() 

234 

235 t.register(NdArrayTransformer()) 

236 

237 enum_t = EnumTransformer() 

238 enum_t.register_enum(States) 

239 t.register(enum_t) 

240 

241 # Dill-based callable serializer — handles lambdas and closures. 

242 t.register(DillFunctionTransformer()) 

243 

244 t.register(DataFrameTransformer()) 

245 t.register(SeriesTransformer()) 

246 t.register(NodeKeyTransformer()) 

247 

248 # Dates, indexes, numpy scalars, sets, bytes, decimals. 

249 register_value_transformers(t) 

250 

251 # Per-node execution timing. Imported here rather than at module scope 

252 # because computeengine reaches back into this module to serialize. 

253 from loman.computeengine import TimingData 

254 

255 t.register(TimingData) 

256 

257 return t 

258 

259 

260class ComputationSerializer: 

261 """Serialize and deserialize a :class:`~loman.computeengine.Computation` graph to JSON. 

262 

263 The serialized format is a JSON object with the following top-level keys: 

264 

265 - ``version``: integer format version 

266 - ``nodes``: list of node objects 

267 - ``edges``: list of edge objects 

268 

269 Each **node** object has: 

270 

271 - ``key``: string representation of the NodeKey 

272 - ``state``: name of the :class:`~loman.consts.States` enum member (or ``null``) 

273 - ``value``: transformer-encoded value (or ``null`` when absent / not serialized) 

274 - ``has_value``: bool — false when the node has no meaningful value to restore 

275 - ``func``: transformer-encoded callable (or ``null``) 

276 - ``args``: transformer-encoded constant positional arguments, keyed by 

277 stringified positional index 

278 - ``kwds``: transformer-encoded constant keyword arguments, keyed by parameter name 

279 - ``serialize``: bool — whether the node has the ``__serialize__`` tag 

280 - ``tags``: list of non-system tags 

281 

282 Each **edge** object has: 

283 

284 - ``src``: string key of the source node 

285 - ``dst``: string key of the destination node 

286 - ``param_type``: ``"arg"`` or ``"kwd"`` 

287 - ``param``: positional index (int) for args, parameter name (str) for kwds 

288 

289 Arguments taken from other nodes are recorded on **edges**, while arguments 

290 given as :class:`~loman.computeengine.ConstantValue` are held on the node 

291 itself and recorded in ``args`` and ``kwds``. Both are needed to call a node's 

292 function, so a graph that dropped its constants would raise a 

293 :class:`TypeError` the next time the node was calculated. 

294 

295 Parameters 

296 ---------- 

297 transformer: 

298 Custom :class:`~loman.serialization.transformer.Transformer` instance. 

299 If ``None``, a default transformer is built based on *use_dill_for_functions*. 

300 use_dill_for_functions: 

301 When ``True``, lambdas and closures are serialized as base64-encoded dill 

302 blobs rather than raising :class:`~loman.exception.SerializationError`. 

303 Has no effect when a custom *transformer* is supplied. Defaults to ``False``. 

304 on_unserializable_constant: 

305 What to do when a constant argument cannot be encoded. ``"raise"``, the 

306 default, refuses to write a graph that could not be recalculated. 

307 ``"drop"`` omits the constant and emits 

308 :class:`UnserializableConstantWarning`, restoring the behaviour of 

309 releases before constants were recorded — where such a graph saved 

310 silently and then raised :class:`TypeError` from the missing argument on 

311 the first recalculation. It exists so an existing codebase can keep 

312 writing files while it is fixed, not as a setting to leave in place. 

313 """ 

314 

315 # States that never carry a value worth writing. Everything else is 

316 # serialized when the node actually holds a value --- notably STALE, whose 

317 # value the in-memory computation keeps and whose intermediates are the 

318 # whole point of saving a graph for post-mortem inspection. 

319 _VALUELESS_STATES: ClassVar[set[States]] = {States.PLACEHOLDER, States.UNINITIALIZED} 

320 

321 def __init__( 

322 self, 

323 transformer: Transformer | None = None, 

324 *, 

325 use_dill_for_functions: bool = False, 

326 on_unserializable_constant: str = "raise", 

327 ) -> None: 

328 """Initialise with an optional custom transformer.""" 

329 if on_unserializable_constant not in _CONSTANT_POLICIES: 

330 msg = ( 

331 f"on_unserializable_constant must be one of {sorted(_CONSTANT_POLICIES)}, " 

332 f"got {on_unserializable_constant!r}" 

333 ) 

334 raise ValueError(msg) 

335 if transformer is None: 

336 transformer = ( 

337 dill_computation_transformer() if use_dill_for_functions else default_computation_transformer() 

338 ) 

339 self._t = transformer 

340 self._use_dill_for_functions = use_dill_for_functions 

341 self._on_unserializable_constant = on_unserializable_constant 

342 # Set per load() / loads() call; see the allow_code parameter there. 

343 self._allow_code = True 

344 

345 def register(self, t: Any) -> None: 

346 """Register a transformer or type with this serializer's transformer. 

347 

348 Accepts anything :meth:`~loman.serialization.transformer.Transformer.register` 

349 accepts: a :class:`~loman.serialization.transformer.CustomTransformer` 

350 instance, a :class:`~loman.serialization.transformer.Transformable` 

351 subclass, an attrs class, or a dataclass. 

352 

353 The same serializer instance must be used for both writing and reading, 

354 since the registration lives on the instance:: 

355 

356 s = ComputationSerializer() 

357 s.register(my_transformer) 

358 comp.write_json('comp.json', serializer=s) 

359 comp2 = Computation.read_json('comp.json', serializer=s) 

360 """ 

361 self._t.register(t) 

362 

363 # ------------------------------------------------------------------ 

364 # Containers 

365 # ------------------------------------------------------------------ 

366 

367 def save( 

368 self, 

369 comp: Any, 

370 path: str | Path, 

371 *, 

372 profile: str | SerializationProfile | None = None, 

373 container: str | None = None, 

374 stores: dict[str, BlobStore] | None = None, 

375 ) -> None: 

376 """Write *comp* to *path*. 

377 

378 :param path: Destination. A ``.json`` suffix implies the single-document 

379 container; anything else defaults to a ``.loman`` zip. 

380 :param profile: ``"readable"``, ``"efficient"``, or a 

381 :class:`~loman.serialization.profile.SerializationProfile`. Defaults 

382 to efficient, except in the ``json`` container where only readable is 

383 possible. 

384 :param container: ``"zip"``, ``"dir"`` or ``"json"``. Inferred from 

385 *path* when omitted. 

386 :param stores: Named :class:`~loman.serialization.blobs.BlobStore` 

387 instances that nodes may be routed to. A node names a store through 

388 ``add_node(store=...)`` or a profile override. 

389 """ 

390 path = Path(path) 

391 container = container or infer_container_from_path(path) 

392 resolved = _resolve_profile_for_container(profile, container, stores) 

393 external = dict(stores or {}) 

394 

395 if container == "json": 

396 with path.open("w", encoding="utf-8") as f: 

397 self._dump_document(comp, f, resolved, external) 

398 return 

399 

400 writer = write_zip_container if container == "zip" else write_dir_container 

401 writer( 

402 path, 

403 lambda container_stores: self._build_manifest(comp, {**container_stores, **external}, resolved, container), 

404 ) 

405 

406 @staticmethod 

407 def load_path( 

408 path: str | Path, 

409 *, 

410 serializer: ComputationSerializer | None = None, 

411 allow_code: bool = True, 

412 stores: dict[str, BlobStore] | None = None, 

413 ) -> Any: 

414 """Read a computation from *path*, whatever container it uses. 

415 

416 :param stores: Named stores for blobs held outside the container. A 

417 saved file records a store's name but never its configuration, so a 

418 file with external blobs cannot resolve them unaided. 

419 """ 

420 s = serializer if serializer is not None else ComputationSerializer() 

421 path = Path(path) 

422 container = sniff_container(path) 

423 external = dict(stores or {}) 

424 

425 if container == "json": 

426 manifest = json.loads(path.read_text(encoding="utf-8")) 

427 return s._read_manifest(manifest, external, allow_code=allow_code) 

428 

429 if container == "dir": 

430 manifest = read_dir_manifest(path) 

431 all_stores = {CONTAINER_STORE: DirBlobStore(path), **external} 

432 return s._read_manifest(manifest, all_stores, allow_code=allow_code) 

433 

434 with zipfile.ZipFile(path) as zf: 

435 manifest = read_zip_manifest(zf) 

436 all_stores = {CONTAINER_STORE: ZipBlobStore(zf), **external} 

437 return s._read_manifest(manifest, all_stores, allow_code=allow_code) 

438 

439 def _read_manifest(self, manifest: dict[str, Any], stores: dict[str, BlobStore], *, allow_code: bool) -> Any: 

440 """Rebuild a computation from *manifest*, resolving blobs against *stores*.""" 

441 reader = BlobReader(manifest.get("blobs", []), stores) 

442 with self._t.reading(reader): 

443 return self._from_dict(manifest, allow_code=allow_code) 

444 

445 def _build_manifest( 

446 self, 

447 comp: Any, 

448 stores: dict[str, BlobStore], 

449 profile: SerializationProfile, 

450 container: str, 

451 ) -> dict[str, Any]: 

452 """Return the manifest for *comp*, writing any blobs into *stores*.""" 

453 writer = BlobWriter( 

454 stores, 

455 compression=profile.compression, 

456 dedupe=profile.dedupe, 

457 checksums=profile.checksums, 

458 ) 

459 token = _SAVE_STATE.set(_SaveState(writer=writer, profile=profile)) 

460 try: 

461 manifest = self._to_dict(comp) 

462 finally: 

463 _SAVE_STATE.reset(token) 

464 manifest["container"] = container 

465 manifest["profile"] = profile.name 

466 manifest["blobs"] = writer.table() 

467 return manifest 

468 

469 def _dump_document( 

470 self, 

471 comp: Any, 

472 fp: TextIO, 

473 profile: SerializationProfile, 

474 stores: dict[str, BlobStore] | None = None, 

475 ) -> None: 

476 """Write the single-document form. 

477 

478 The container itself holds no blobs, but an external store still can, so 

479 a readable manifest can sit alongside data held elsewhere. 

480 """ 

481 data = self._build_manifest(comp, dict(stores or {}), profile, "json") 

482 json.dump(data, fp, allow_nan=False) 

483 

484 def dump(self, comp: Any, fp: TextIO) -> None: 

485 """Serialize *comp* to *fp* (a text-mode file-like object).""" 

486 self._dump_document(comp, fp, READABLE) 

487 

488 def dumps(self, comp: Any) -> str: 

489 """Serialize *comp* and return a JSON string. 

490 

491 Always the readable single-document form: a string has nowhere to put 

492 out-of-line bytes. 

493 """ 

494 buf = io.StringIO() 

495 self._dump_document(comp, buf, READABLE) 

496 return buf.getvalue() 

497 

498 def _serialize_node_value(self, node_key: Any, state: States | None, node_data: dict[str, Any]) -> tuple[Any, bool]: 

499 """Return ``(encoded_value, has_value)`` for a node that should be serialized. 

500 

501 Raises :class:`~loman.exception.SerializationError` if the value cannot 

502 be encoded. 

503 """ 

504 from loman.computeengine import Error 

505 

506 if state in self._VALUELESS_STATES or NodeAttributes.VALUE not in node_data: 

507 return None, False 

508 

509 raw_value = node_data[NodeAttributes.VALUE] 

510 # Keyed off the value's type, not the node's state. A node that failed 

511 # and then went STALE --- because one of its inputs was replaced --- is no 

512 # longer in ERROR state but still holds the Error it produced. Testing 

513 # the state instead sent that value down the generic path, where an 

514 # exception object has no encoding, and failed the entire save. 

515 if isinstance(raw_value, Error): 

516 exception_type = type(raw_value.exception) 

517 return ( 

518 { 

519 "__loman_error__": True, 

520 "exception_type": exception_type.__name__, 

521 "exception_module": exception_type.__module__, 

522 "exception_str": str(raw_value.exception), 

523 "traceback": raw_value.traceback, 

524 }, 

525 True, 

526 ) 

527 

528 try: 

529 return self._t.to_dict(raw_value), True 

530 except (UntransformableTypeError, ValueError) as exc: 

531 msg = f"Cannot serialize value of node {node_key!r}: {exc}" 

532 raise SerializationError(msg) from exc 

533 

534 def _serialize_node_func(self, node_key: Any, raw_func: Any) -> Any: 

535 """Return the encoded function for a node, or ``None`` if it cannot be serialized. 

536 

537 Lambdas raise :class:`~loman.exception.SerializationError` unless 

538 ``use_dill_for_functions`` is enabled. Other non-importable callables 

539 (e.g. framework closures from ``add_block``) are silently stored as ``null``. 

540 """ 

541 qualname = getattr(raw_func, "__qualname__", "") or "" 

542 if not self._use_dill_for_functions and "<lambda>" in qualname: 

543 msg = ( 

544 f"Cannot serialize lambda function on node {node_key!r}. " 

545 "Use a module-level importable function, serialize=False, " 

546 "or ComputationSerializer(use_dill_for_functions=True)." 

547 ) 

548 raise SerializationError(msg) 

549 try: 

550 return self._t.to_dict(raw_func) 

551 except (UntransformableTypeError, ValueError, TypeError) as exc: 

552 # The value is still saved; only the ability to recalculate is lost. 

553 # That used to happen silently, so a graph could be reloaded, look 

554 # complete, and never update again with nothing to explain why. 

555 warnings.warn( 

556 f"Cannot serialize the function on node {node_key!r} ({exc}). Its value is still " 

557 "saved, but the reloaded node will have no function and so can never be " 

558 "recalculated. Use a module-level importable function, or " 

559 "ComputationSerializer(use_dill_for_functions=True).", 

560 UnserializableFunctionWarning, 

561 stacklevel=2, 

562 ) 

563 return None 

564 

565 def _serialize_constant(self, node_key: Any, param: Any, value: Any) -> Any: 

566 """Encode one constant argument held on a node. 

567 

568 Unlike a node function, a constant argument has no fallback: dropping it 

569 would leave the node callable with the wrong number of arguments, so an 

570 unrepresentable constant is an error rather than a ``null``. 

571 """ 

572 try: 

573 return self._t.to_dict(value) 

574 except (UntransformableTypeError, ValueError, TypeError) as e: 

575 msg = ( 

576 f"Cannot serialize constant argument {param!r} on node {node_key!r} ({e}). " 

577 "Constant arguments are needed to call the node's function, so they cannot " 

578 "be skipped: register a transformer for the type, set serialize=False on the " 

579 "node, or use ComputationSerializer(use_dill_for_functions=True) for callables." 

580 ) 

581 if self._on_unserializable_constant == "raise": 

582 raise SerializationError(msg) from e 

583 warnings.warn( 

584 f"{msg} Dropping it, because on_unserializable_constant='drop'. The saved " 

585 "graph will raise TypeError from the missing argument when this node is " 

586 "recalculated.", 

587 UnserializableConstantWarning, 

588 stacklevel=2, 

589 ) 

590 return _DROPPED 

591 

592 def _serialize_node_constants( 

593 self, node_key: Any, node_data: dict[str, Any] 

594 ) -> tuple[dict[str, Any], dict[str, Any]]: 

595 """Return the encoded constant positional and keyword arguments for a node. 

596 

597 A constant the policy drops is omitted from the mapping entirely, so the 

598 reloaded node looks exactly as it did before constants were recorded. 

599 """ 

600 encoded_args = { 

601 str(index): encoded 

602 for index, value in node_data.get(NodeAttributes.ARGS, {}).items() 

603 if (encoded := self._serialize_constant(node_key, index, value)) is not _DROPPED 

604 } 

605 encoded_kwds = { 

606 name: encoded 

607 for name, value in node_data.get(NodeAttributes.KWDS, {}).items() 

608 if (encoded := self._serialize_constant(node_key, name, value)) is not _DROPPED 

609 } 

610 return encoded_args, encoded_kwds 

611 

612 def _serialize_node(self, node_key: Any, node_data: dict[str, Any]) -> dict[str, Any]: 

613 """Return the serialized dict for a single node. 

614 

615 The whole node is encoded inside one write scope, so any value that asks 

616 for out-of-line storage --- a node's value, or a constant argument that 

617 happens to be a large array --- is attributed to this node in the blob 

618 table. 

619 """ 

620 state = _SAVE_STATE.get() 

621 if state is None: 

622 # save()/dumps() establish this; only reached if a caller drives 

623 # _serialize_node directly. 

624 return self._serialize_node_inner(node_key, node_data) # pragma: no cover 

625 tags: set[str] = node_data.get(NodeAttributes.TAG, set()) 

626 with self._t.writing( 

627 state.writer, 

628 state.profile, 

629 node=str(node_key), 

630 tags=frozenset(tags), 

631 store=node_data.get(NodeAttributes.STORE), 

632 ): 

633 return self._serialize_node_inner(node_key, node_data) 

634 

635 def _serialize_node_inner(self, node_key: Any, node_data: dict[str, Any]) -> dict[str, Any]: 

636 """Return the serialized dict for a single node, inside a write scope.""" 

637 state: States | None = node_data.get(NodeAttributes.STATE) 

638 tags: set[str] = node_data.get(NodeAttributes.TAG, set()) 

639 serialize_flag: bool = SystemTags.SERIALIZE in tags 

640 

641 serialized_state: States | None 

642 if not serialize_flag: 

643 serialized_state = States.UNINITIALIZED 

644 encoded_value = None 

645 has_value = False 

646 else: 

647 serialized_state = state 

648 encoded_value, has_value = self._serialize_node_value(node_key, state, node_data) 

649 

650 raw_func = node_data.get(NodeAttributes.FUNC) 

651 encoded_func = ( 

652 self._serialize_node_func(node_key, raw_func) if raw_func is not None and serialize_flag else None 

653 ) 

654 encoded_args, encoded_kwds = ( 

655 self._serialize_node_constants(node_key, node_data) if encoded_func is not None else ({}, {}) 

656 ) 

657 

658 user_tags = [t for t in tags if not t.startswith("__")] 

659 

660 out = { 

661 "key": str(node_key), 

662 "state": serialized_state.name if serialized_state is not None else None, 

663 "value": encoded_value, 

664 "has_value": has_value, 

665 "func": encoded_func, 

666 "args": encoded_args, 

667 "kwds": encoded_kwds, 

668 "serialize": serialize_flag, 

669 "tags": user_tags, 

670 } 

671 out.update(self._serialize_node_attributes(node_key, node_data, serialize_flag=serialize_flag)) 

672 return out 

673 

674 def _serialize_node_attributes( 

675 self, node_key: Any, node_data: dict[str, Any], *, serialize_flag: bool 

676 ) -> dict[str, Any]: 

677 """Return the presentational and execution attributes of a node. 

678 

679 Group, style and executor are plain strings describing how a node is 

680 drawn and where it runs. They were previously dropped on load and 

681 rebuilt as ``None``, so a reloaded graph rendered differently from the 

682 one that was saved. The converter is a callable and follows the same 

683 rules as the node's function; timing is written for the record and 

684 restored as data. 

685 """ 

686 attributes: dict[str, Any] = {} 

687 for field, attr in (("group", NodeAttributes.GROUP), ("style", NodeAttributes.STYLE)): 

688 value = node_data.get(attr) 

689 if value is not None: 

690 attributes[field] = self._t.to_dict(value) 

691 

692 executor = node_data.get(NodeAttributes.EXECUTOR) 

693 if executor is not None: 

694 attributes["executor"] = self._t.to_dict(executor) 

695 

696 store = node_data.get(NodeAttributes.STORE) 

697 if store is not None: 

698 attributes["store"] = self._t.to_dict(store) 

699 

700 converter = node_data.get(NodeAttributes.CONVERTER) 

701 if converter is not None and serialize_flag: 

702 encoded = self._serialize_node_func(node_key, converter) 

703 if encoded is not None: 

704 attributes["converter"] = encoded 

705 

706 timing = node_data.get(NodeAttributes.TIMING) 

707 if timing is not None: 

708 attributes["timing"] = self._t.to_dict(timing) 

709 

710 return attributes 

711 

712 def _restore_node_attributes(self, node_info: dict[str, Any], node_data: dict[str, Any]) -> None: 

713 """Apply the presentational and execution attributes from *node_info*.""" 

714 node_data[NodeAttributes.GROUP] = self._t.from_dict(node_info.get("group")) 

715 node_data[NodeAttributes.STYLE] = self._t.from_dict(node_info.get("style")) 

716 node_data[NodeAttributes.EXECUTOR] = self._t.from_dict(node_info.get("executor")) 

717 node_data[NodeAttributes.STORE] = self._t.from_dict(node_info.get("store")) 

718 

719 encoded_converter = node_info.get("converter") 

720 node_data[NodeAttributes.CONVERTER] = self._decode_callable(encoded_converter) 

721 

722 encoded_timing = node_info.get("timing") 

723 if encoded_timing is not None: 

724 node_data[NodeAttributes.TIMING] = self._t.from_dict(encoded_timing) 

725 

726 def _serialize_edge(self, src: Any, dst: Any, edge_data: dict[str, Any]) -> dict[str, Any]: 

727 """Return the serialized dict for a single edge.""" 

728 param = edge_data.get(EdgeAttributes.PARAM) 

729 if param is None: 

730 return {"src": str(src), "dst": str(dst), "param_type": None, "param": None} 

731 

732 from loman.computeengine import _ParameterType 

733 

734 param_type, param_val = param 

735 return { 

736 "src": str(src), 

737 "dst": str(dst), 

738 "param_type": "kwd" if param_type == _ParameterType.KWD else "arg", 

739 "param": param_val, 

740 } 

741 

742 def _to_dict(self, comp: Any) -> dict[str, Any]: 

743 """Convert a Computation to a JSON-serializable dict.""" 

744 nodes_out = [self._serialize_node(k, comp.dag.nodes[k]) for k in comp.dag.nodes()] 

745 edges_out = [self._serialize_edge(src, dst, data) for src, dst, data in comp.dag.edges(data=True)] 

746 return { 

747 "version": FORMAT_VERSION, 

748 "nodes": nodes_out, 

749 "edges": edges_out, 

750 "metadata": self._serialize_metadata(comp), 

751 } 

752 

753 def _serialize_metadata(self, comp: Any) -> dict[str, Any]: 

754 """Return the computation's per-node metadata, keyed by node-key string. 

755 

756 Metadata is held on the computation rather than on dag nodes, so it needs 

757 a map of its own. The root computation's own metadata is keyed by the 

758 empty string. 

759 """ 

760 from loman.nodekey import NodeKey 

761 

762 out: dict[str, Any] = {} 

763 for node_key, metadata in getattr(comp, "_metadata", {}).items(): 

764 key = "" if node_key == NodeKey.root() else str(node_key) 

765 out[key] = self._t.to_dict(metadata) 

766 return out 

767 

768 def _restore_metadata(self, comp: Any, encoded: dict[str, Any]) -> None: 

769 """Apply a serialized metadata map back onto *comp*.""" 

770 from loman.nodekey import NodeKey 

771 

772 for key, metadata in encoded.items(): 

773 node_key = NodeKey.root() if key == "" else parse_nodekey(key) 

774 comp._metadata[node_key] = self._t.from_dict(metadata) 

775 

776 def load(self, fp: TextIO, *, allow_code: bool = True) -> Any: 

777 """Deserialize a Computation from *fp* (a text-mode file-like object). 

778 

779 :param allow_code: When false, node functions and converters are not 

780 restored. See :meth:`loads`. 

781 """ 

782 data = json.load(fp) 

783 return self._from_dict(data, allow_code=allow_code) 

784 

785 def loads(self, s: str, *, allow_code: bool = True) -> Any: 

786 """Deserialize a Computation from a JSON string. 

787 

788 :param allow_code: When false, encoded callables are skipped rather than 

789 resolved, and every node's function and converter comes back as 

790 ``None``. Restoring a callable means importing the module the file 

791 names, or unpickling a dill blob out of it --- both of which run code 

792 the file chose. Values, structure, states and tags still load, which 

793 is enough to inspect a graph from an untrusted source. Defaults to 

794 true, preserving existing behaviour. 

795 """ 

796 data = json.loads(s) 

797 return self._from_dict(data, allow_code=allow_code) 

798 

799 def _decode_callable(self, encoded: Any) -> Any: 

800 """Resolve an encoded callable, or ``None`` when code loading is refused.""" 

801 if encoded is None or not self._allow_code: 

802 return None 

803 return self._t.from_dict(encoded) 

804 

805 def _decode_error_value(self, encoded: dict[str, Any]) -> Any: 

806 """Rebuild an ``Error`` from its recorded exception. 

807 

808 Builtin exception types are reconstructed so that ``except ValueError`` 

809 still matches after a round-trip. Anything else becomes a 

810 :class:`~loman.exception.DeserializedError` carrying the original name: 

811 importing the module a file names in order to rebuild its exception 

812 would be running code chosen by the file. 

813 """ 

814 import builtins 

815 

816 from loman.computeengine import Error 

817 from loman.exception import DeserializedError 

818 

819 type_name = encoded.get("exception_type", "Exception") 

820 module = encoded.get("exception_module") 

821 message = encoded["exception_str"] 

822 

823 exception: Exception | None = None 

824 if module in (None, "builtins"): 

825 candidate = getattr(builtins, type_name, None) 

826 # Exception, not BaseException: rebuilding a KeyboardInterrupt or a 

827 # SystemExit as a node value would be a surprising thing to hand back. 

828 if isinstance(candidate, type) and issubclass(candidate, Exception): 

829 try: 

830 exception = candidate(message) 

831 except Exception: # pragma: no cover - exotic __init__ 

832 exception = None 

833 if exception is None: 

834 exception = DeserializedError(message, exception_type=type_name, exception_module=module) 

835 

836 return Error(exception=exception, traceback=encoded["traceback"]) 

837 

838 def _from_dict(self, data: dict[str, Any], *, allow_code: bool = True) -> Any: 

839 """Reconstruct a Computation from a deserialized dict.""" 

840 from loman.computeengine import Computation, _ParameterType 

841 

842 self._allow_code = allow_code 

843 comp = Computation() 

844 

845 for node_info in data["nodes"]: 

846 raw_key = node_info["key"] 

847 node_key = parse_nodekey(raw_key) 

848 state_name = node_info["state"] 

849 state = States[state_name] if state_name is not None else None 

850 serialize_flag: bool = node_info.get("serialize", True) 

851 has_value: bool = node_info.get("has_value", False) 

852 user_tags: list[str] = node_info.get("tags", []) 

853 

854 func = self._decode_callable(node_info.get("func")) 

855 

856 encoded_value = node_info.get("value") 

857 if has_value: 

858 # Decode even when the encoded value is null: a node whose value 

859 # is legitimately None is not the same as a node with no value, 

860 # and has_value is what distinguishes them. 

861 if isinstance(encoded_value, dict) and encoded_value.get("__loman_error__"): 

862 value = self._decode_error_value(encoded_value) 

863 else: 

864 value = self._t.from_dict(encoded_value) 

865 else: 

866 value = None 

867 

868 comp.dag.add_node(node_key) 

869 node_data = comp.dag.nodes[node_key] 

870 node_data[NodeAttributes.STATE] = state if state is not None else States.UNINITIALIZED 

871 node_data[NodeAttributes.VALUE] = value if has_value else None 

872 node_data[NodeAttributes.FUNC] = func 

873 node_data[NodeAttributes.ARGS] = { 

874 int(index): self._t.from_dict(encoded) for index, encoded in node_info.get("args", {}).items() 

875 } 

876 node_data[NodeAttributes.KWDS] = { 

877 name: self._t.from_dict(encoded) for name, encoded in node_info.get("kwds", {}).items() 

878 } 

879 node_data[NodeAttributes.TAG] = set() 

880 self._restore_node_attributes(node_info, node_data) 

881 

882 if serialize_flag: 

883 node_data[NodeAttributes.TAG].add(SystemTags.SERIALIZE) 

884 for tag in user_tags: 

885 node_data[NodeAttributes.TAG].add(tag) 

886 

887 for edge_info in data["edges"]: 

888 src_key = parse_nodekey(edge_info["src"]) 

889 dst_key = parse_nodekey(edge_info["dst"]) 

890 param_type_str = edge_info.get("param_type") 

891 param_val = edge_info.get("param") 

892 

893 if param_type_str is not None: 

894 param_type = _ParameterType.KWD if param_type_str == "kwd" else _ParameterType.ARG 

895 comp.dag.add_edge(src_key, dst_key, **{EdgeAttributes.PARAM: (param_type, param_val)}) 

896 else: 

897 comp.dag.add_edge(src_key, dst_key) 

898 

899 self._restore_metadata(comp, data.get("metadata", {})) 

900 comp._refresh_maps() 

901 

902 return comp