cellgraph 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cellgraph
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: The cell graph of a notebook or script: annotations with a host-supplied vocabulary, analysis, the DAG, and the keys that say whether a cell must run.
5
5
  Author: Fangchen Li
6
6
  Author-email: Fangchen Li <fangchen.li@outlook.com>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cellgraph"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "The cell graph of a notebook or script: annotations with a host-supplied vocabulary, analysis, the DAG, and the keys that say whether a cell must run."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cellgraph"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "The cell graph of a notebook or script: annotations with a host-supplied vocabulary, analysis, the DAG, and the keys that say whether a cell must run."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -45,8 +45,10 @@ from cellgraph.keys import (
45
45
  Provenance,
46
46
  Published,
47
47
  Resolved,
48
+ Resolver,
48
49
  Run,
49
50
  Unavailable,
51
+ VariantRead,
50
52
  canonical_json,
51
53
  compute_provenance_hash,
52
54
  derive_subkey,
@@ -117,11 +119,13 @@ __all__ = [
117
119
  "PythonAnalyzer",
118
120
  "Reads",
119
121
  "Resolved",
122
+ "Resolver",
120
123
  "Run",
121
124
  "Seeds",
122
125
  "Severity",
123
126
  "SweepProducer",
124
127
  "Unavailable",
128
+ "VariantRead",
125
129
  "UnknownLanguageError",
126
130
  "ValidationContext",
127
131
  "VariantGroupResolution",
@@ -275,6 +275,8 @@ class CellAnalysis:
275
275
  annotations: dict[str, Any] = field(default_factory=dict)
276
276
  directives: list[Directive] = field(default_factory=list)
277
277
  effects: list[Effect] = field(default_factory=list)
278
+ # Per effect: the annotation key and line it came from; None for the host's hook.
279
+ origins: list[tuple[str | None, int | None]] = field(default_factory=list)
278
280
  defines: list[str] = field(default_factory=list)
279
281
  references: list[str] = field(default_factory=list)
280
282
  builtin_references: list[str] = field(default_factory=list)
@@ -282,6 +284,7 @@ class CellAnalysis:
282
284
  bound: list[str] = field(default_factory=list)
283
285
  after: list[str] = field(default_factory=list)
284
286
  seeds: dict[str, str] = field(default_factory=dict)
287
+ seed_origins: dict[str, tuple[str | None, int | None]] = field(default_factory=dict)
285
288
  member: Member | None = None
286
289
  fanout: FanOut | None = None
287
290
  kept: list[str] = field(default_factory=list)
@@ -292,12 +295,21 @@ class CellAnalysis:
292
295
  plannable: bool = True
293
296
  # The cells whose own errors keep this one from planning (itself included).
294
297
  blocked_by: list[str] = field(default_factory=list)
298
+ # Set by ``analyze`` for every cell, plannable or not.
295
299
  definition_key: str | None = None
296
300
  normalized: str | None = None
297
301
 
298
- def error(self, code: str, message: str, *, annotation: str | None = None, line=None):
302
+ def error(
303
+ self,
304
+ code: str,
305
+ message: str,
306
+ *,
307
+ annotation: str | None = None,
308
+ line=None,
309
+ args: Mapping[str, Any] | None = None,
310
+ ):
299
311
  self.diagnostics.append(
300
- Diagnostic(code, Severity.ERROR, message, self.id, annotation, line)
312
+ Diagnostic(code, Severity.ERROR, message, self.id, annotation, line, dict(args or {}))
301
313
  )
302
314
 
303
315
  def note(
@@ -308,8 +320,11 @@ class CellAnalysis:
308
320
  *,
309
321
  annotation: str | None = None,
310
322
  line=None,
323
+ args: Mapping[str, Any] | None = None,
311
324
  ):
312
- self.diagnostics.append(Diagnostic(code, severity, message, self.id, annotation, line))
325
+ self.diagnostics.append(
326
+ Diagnostic(code, severity, message, self.id, annotation, line, dict(args or {}))
327
+ )
313
328
 
314
329
  @property
315
330
  def has_error(self) -> bool:
@@ -354,6 +369,14 @@ def _first_line(directives: list[Directive], key: str) -> int | None:
354
369
  return next((d.line for d in directives if d.key == key), None)
355
370
 
356
371
 
372
+ def _naming_line(directives: list[Directive], key: str, token: str) -> int | None:
373
+ """The first *key* line with *token* among its values; else the first *key* line."""
374
+ for d in directives:
375
+ if d.key == key and token in (t.rstrip(",") for t in d.value.split()):
376
+ return d.line
377
+ return _first_line(directives, key)
378
+
379
+
357
380
  def _top_level_imports(source: str) -> set[str]:
358
381
  try:
359
382
  tree = ast.parse(source)
@@ -389,6 +412,7 @@ def _parse_phase(
389
412
  f"`@{directive.key}` is not an annotation this vocabulary knows.{hint}",
390
413
  annotation=directive.key,
391
414
  line=directive.line,
415
+ args={"key": directive.key, "suggestion": close[0] if close else None},
392
416
  )
393
417
  continue
394
418
  by_owner.setdefault(owner.key, []).append(directive)
@@ -427,14 +451,19 @@ def _parse_phase(
427
451
  f"({type(encoded).__name__}); the annotation needs an encoder",
428
452
  annotation=annotation.key,
429
453
  line=directives[0].line,
454
+ args={"type": type(encoded).__name__},
430
455
  )
431
456
  else:
432
457
  ca.key_fields[annotation.key] = encoded
433
458
  if annotation.effects is not None:
434
- ca.effects.extend(annotation.effects(value))
459
+ produced = list(annotation.effects(value))
460
+ ca.effects.extend(produced)
461
+ ca.origins.extend([(annotation.key, directives[0].line)] * len(produced))
435
462
 
436
463
  if vocabulary.cell_effects is not None:
437
- ca.effects.extend(vocabulary.cell_effects(cell.meta, dict(ca.annotations)))
464
+ hooked = list(vocabulary.cell_effects(cell.meta, dict(ca.annotations)))
465
+ ca.effects.extend(hooked)
466
+ ca.origins.extend([(None, None)] * len(hooked))
438
467
  for effect in ca.effects:
439
468
  if not isinstance(effect, EFFECT_TYPES):
440
469
  raise TypeError(f"{effect!r} is not one of the ten effects")
@@ -472,11 +501,15 @@ def _names_phase(ca: CellAnalysis, analyzed: Analyzed) -> None:
472
501
  if isinstance(effect, Binds):
473
502
  for name in effect.names:
474
503
  if name in bound:
475
- ca.error("bind_conflict", f"`{name}` is bound twice")
504
+ ca.error("bind_conflict", f"`{name}` is bound twice", args={"name": name})
476
505
  else:
477
506
  bound.append(name)
478
507
  for name in sorted(set(bound) & set(read)):
479
- ca.error("bind_read_conflict", f"`{name}` is both bound and read from upstream")
508
+ ca.error(
509
+ "bind_read_conflict",
510
+ f"`{name}` is both bound and read from upstream",
511
+ args={"name": name},
512
+ )
480
513
 
481
514
  ca.defines = defines
482
515
  ca.references = [name for name in references if name not in bound]
@@ -484,32 +517,59 @@ def _names_phase(ca: CellAnalysis, analyzed: Analyzed) -> None:
484
517
  ca.mutation_defines = list(analyzed.mutation_defines)
485
518
  ca.bound = bound
486
519
 
487
- for effect in ca.effects:
520
+ for effect, (origin, origin_line) in zip(ca.effects, ca.origins, strict=True):
488
521
  if isinstance(effect, After):
489
522
  for cell_id in effect.cell_ids:
490
523
  if cell_id not in ca.after:
491
524
  ca.after.append(cell_id)
492
525
  elif isinstance(effect, Seeds):
493
526
  if effect.name in ca.seeds and ca.seeds[effect.name] != effect.cell_id:
494
- ca.error("seed_conflict", f"`{effect.name}` is seeded from two cells")
527
+ ca.error(
528
+ "seed_conflict",
529
+ f"`{effect.name}` is seeded from two cells",
530
+ annotation=origin,
531
+ line=origin_line,
532
+ args={"name": effect.name},
533
+ )
495
534
  else:
496
535
  ca.seeds[effect.name] = effect.cell_id
536
+ ca.seed_origins[effect.name] = (origin, origin_line)
497
537
  if effect.name in bound:
498
- ca.error("bind_read_conflict", f"`{effect.name}` is both bound and seeded")
538
+ ca.error(
539
+ "bind_read_conflict",
540
+ f"`{effect.name}` is both bound and seeded",
541
+ annotation=origin,
542
+ line=origin_line,
543
+ args={"name": effect.name},
544
+ )
499
545
  elif isinstance(effect, Member):
500
546
  if ca.member is not None and ca.member != effect:
501
- ca.error("member_conflict", "the cell is a member of two variants")
547
+ ca.error(
548
+ "member_conflict",
549
+ "the cell is a member of two variants",
550
+ annotation=origin,
551
+ line=origin_line,
552
+ )
502
553
  ca.member = effect
503
554
  elif isinstance(effect, FanOut):
504
555
  if ca.fanout is not None and ca.fanout != effect:
505
- ca.error("fanout_conflict", "the cell fans out over two groups")
556
+ ca.error(
557
+ "fanout_conflict",
558
+ "the cell fans out over two groups",
559
+ annotation=origin,
560
+ line=origin_line,
561
+ )
506
562
  ca.fanout = effect
507
563
  elif isinstance(effect, Keeps):
508
564
  ca.kept.extend(name for name in effect.names if name not in ca.kept)
509
565
  elif isinstance(effect, Input):
510
566
  if any((i.kind, i.name) == (effect.kind, effect.name) for i in ca.inputs):
511
567
  ca.error(
512
- "input_conflict", f"two {effect.kind} inputs have the address {effect.name!r}"
568
+ "input_conflict",
569
+ f"two {effect.kind} inputs have the address {effect.name!r}",
570
+ annotation=origin,
571
+ line=origin_line,
572
+ args={"kind": effect.kind, "name": effect.name},
513
573
  )
514
574
  else:
515
575
  ca.inputs.append(effect)
@@ -537,7 +597,12 @@ def _resolve_groups(
537
597
  f"{cell.id[:8]} in group '{group}'"
538
598
  )
539
599
  for cid in (clash, cell.id):
540
- by_id[cid].error("variant_collision", message, annotation="variant")
600
+ by_id[cid].error(
601
+ "variant_collision",
602
+ message,
603
+ annotation="variant",
604
+ args={"group": group, "name": name, "cells": [clash, cell.id]},
605
+ )
541
606
  continue
542
607
  if not members:
543
608
  group_order.append(group)
@@ -591,6 +656,13 @@ def _variant_notes(cells: list[CellAnalysis], dag: Dag, groups: GroupConfig) ->
591
656
  f"defines {sorted(theirs)}",
592
657
  annotation="variant",
593
658
  line=line,
659
+ args={
660
+ "group": group,
661
+ "name": cell.member.name,
662
+ "defines": sorted(own),
663
+ "sibling": sibling.member.name if sibling.member else None,
664
+ "sibling_defines": sorted(theirs),
665
+ },
594
666
  )
595
667
  break
596
668
  mode = groups.modes.get(group, "switch")
@@ -601,6 +673,7 @@ def _variant_notes(cells: list[CellAnalysis], dag: Dag, groups: GroupConfig) ->
601
673
  f"group `{group}` has mode {mode!r}; it runs as a switch",
602
674
  annotation="variant",
603
675
  line=line,
676
+ args={"group": group, "mode": mode},
604
677
  )
605
678
  active = groups.active.get(group)
606
679
  if mode == "sweep" and active:
@@ -610,6 +683,7 @@ def _variant_notes(cells: list[CellAnalysis], dag: Dag, groups: GroupConfig) ->
610
683
  f"group `{group}` is a sweep, so its active selection is ignored",
611
684
  annotation="variant",
612
685
  line=line,
686
+ args={"group": group, "active": active},
613
687
  )
614
688
  if mode != "sweep" and active:
615
689
  names = [name for _, name in members[group].members]
@@ -620,6 +694,7 @@ def _variant_notes(cells: list[CellAnalysis], dag: Dag, groups: GroupConfig) ->
620
694
  f"group `{group}` selects `{active}`, which no cell provides",
621
695
  annotation="variant",
622
696
  line=line,
697
+ args={"group": group, "active": active},
623
698
  )
624
699
 
625
700
 
@@ -683,27 +758,35 @@ def _wiring_phase(cells: list[CellAnalysis], groups: GroupConfig) -> Dag:
683
758
 
684
759
  for name, seed_id in cell.seeds.items():
685
760
  seed = cell_by_id.get(seed_id)
761
+ origin, origin_line = cell.seed_origins.get(name, (None, None))
686
762
  if seed is None:
687
- cell.error("seed_unknown_cell", f"`{name}` is seeded from {seed_id!r}, no cell")
763
+ reason, message = "unknown", f"`{name}` is seeded from {seed_id!r}, no cell"
688
764
  elif seed_id == cell.id:
689
- cell.error("seed_unknown_cell", f"`{name}` is seeded from the cell itself")
765
+ reason, message = "self", f"`{name}` is seeded from the cell itself"
690
766
  elif seed_id in inactive:
691
- cell.error(
692
- "seed_unknown_cell", f"`{name}` is seeded from {seed_id}, an inactive variant"
767
+ reason, message = (
768
+ "inactive",
769
+ f"`{name}` is seeded from {seed_id}, an inactive variant",
693
770
  )
694
771
  elif name not in seed.defines:
695
- cell.error(
696
- "seed_unknown_cell",
697
- f"`{name}` is seeded from {seed_id}, which does not define it",
698
- )
772
+ reason = "undefined"
773
+ message = f"`{name}` is seeded from {seed_id}, which does not define it"
699
774
  else:
700
775
  dag.read_producers.setdefault(cell.id, {})[name] = seed_id
701
776
  _wire(dag, seed_id, cell.id, name)
777
+ continue
778
+ cell.error(
779
+ "seed_unknown_cell",
780
+ message,
781
+ annotation=origin,
782
+ line=origin_line,
783
+ args={"name": name, "cell_id": seed_id, "reason": reason},
784
+ )
702
785
 
703
786
  dag.consumed_variables[cell.id].update(v for v in cell.kept if v in cell.defines)
704
787
 
705
788
  for upstream_id in cell.after:
706
- line = _first_line(cell.directives, "after")
789
+ line = _naming_line(cell.directives, "after", upstream_id)
707
790
  if upstream_id == cell.id:
708
791
  cell.note(
709
792
  "after_self",
@@ -711,6 +794,7 @@ def _wiring_phase(cells: list[CellAnalysis], groups: GroupConfig) -> Dag:
711
794
  "`@after` names this cell",
712
795
  annotation="after",
713
796
  line=line,
797
+ args={"cell_id": upstream_id},
714
798
  )
715
799
  continue
716
800
  if upstream_id not in cell_by_id:
@@ -719,6 +803,7 @@ def _wiring_phase(cells: list[CellAnalysis], groups: GroupConfig) -> Dag:
719
803
  f"`@after {upstream_id}` names no cell",
720
804
  annotation="after",
721
805
  line=line,
806
+ args={"cell_id": upstream_id},
722
807
  )
723
808
  continue
724
809
  if upstream_id not in active_ids:
@@ -727,6 +812,7 @@ def _wiring_phase(cells: list[CellAnalysis], groups: GroupConfig) -> Dag:
727
812
  f"`@after {upstream_id}` names a variant that is not active",
728
813
  annotation="after",
729
814
  line=line,
815
+ args={"cell_id": upstream_id},
730
816
  )
731
817
  continue
732
818
  dag.edges.append(DagEdge(from_cell_id=upstream_id, to_cell_id=cell.id, variable=""))
@@ -781,7 +867,7 @@ def _wiring_phase(cells: list[CellAnalysis], groups: GroupConfig) -> Dag:
781
867
  message = "Cycle detected in DAG: " + (" → ".join(cycles[0]) if cycles else "unknown")
782
868
  for cid in active_cell_ids:
783
869
  if cid in on_cycle:
784
- cell_by_id[cid].error("cycle", message)
870
+ cell_by_id[cid].error("cycle", message, args={"cycle": cycles[0] if cycles else []})
785
871
  dag.topological_order = _kahn(dag, active_cell_ids)
786
872
  return dag
787
873
 
@@ -810,6 +896,7 @@ def _fanout_notes(
810
896
  f"`@per_variant {named}` names no sweep group the cell reads",
811
897
  annotation="per_variant",
812
898
  line=line,
899
+ args={"group": named},
813
900
  )
814
901
  return
815
902
  effective = named
@@ -830,6 +917,7 @@ def _fanout_notes(
830
917
  f"`@per_variant` reads {', '.join(sorted(read_groups))}; name one",
831
918
  annotation="per_variant",
832
919
  line=line,
920
+ args={"groups": sorted(read_groups)},
833
921
  )
834
922
  return
835
923
  effective = next(iter(read_groups))
@@ -840,6 +928,7 @@ def _fanout_notes(
840
928
  f"group `{effective}` has one variant",
841
929
  annotation="per_variant",
842
930
  line=line,
931
+ args={"group": effective},
843
932
  )
844
933
 
845
934
 
@@ -909,6 +998,20 @@ class Analysis:
909
998
 
910
999
  return execution_key(self, cell_id, **kwargs)
911
1000
 
1001
+ def variant_read(
1002
+ self, cell_id: str, upstream_id: str, var: str, instance: str | None = None
1003
+ ) -> Any:
1004
+ """See ``cellgraph.keys.variant_read``."""
1005
+ from cellgraph.keys import variant_read
1006
+
1007
+ return variant_read(self, cell_id, upstream_id, var, instance)
1008
+
1009
+ def shadowed_reads(self, cell_id: str) -> set[str]:
1010
+ """See ``cellgraph.keys.shadowed_reads``."""
1011
+ from cellgraph.keys import shadowed_reads
1012
+
1013
+ return shadowed_reads(self, cell_id)
1014
+
912
1015
  def plan(self, target: str, ready: Callable[[str], bool]) -> list[str]:
913
1016
  """See ``cellgraph.plan.plan``."""
914
1017
  from cellgraph.plan import plan
@@ -1008,6 +1111,5 @@ def analyze(
1008
1111
  digest=digest,
1009
1112
  )
1010
1113
  for ca in analyses:
1011
- if ca.plannable:
1012
- ca.normalized, ca.definition_key = definition_key(analysis, ca)
1114
+ ca.normalized, ca.definition_key = definition_key(analysis, ca)
1013
1115
  return analysis
@@ -2,8 +2,10 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from dataclasses import dataclass
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
6
7
  from enum import StrEnum
8
+ from typing import Any
7
9
 
8
10
 
9
11
  class Severity(StrEnum):
@@ -16,7 +18,11 @@ class Severity(StrEnum):
16
18
 
17
19
  @dataclass(frozen=True)
18
20
  class Diagnostic:
19
- """One finding. ``annotation`` is the directive key it is about, if any."""
21
+ """One finding. ``annotation`` is the directive key it is about, if any.
22
+
23
+ ``args`` holds what the message was built from, such as the cell id an ``@after``
24
+ names, so a host can word the finding its own way.
25
+ """
20
26
 
21
27
  code: str
22
28
  severity: Severity
@@ -24,3 +30,4 @@ class Diagnostic:
24
30
  cell: str | None = None
25
31
  annotation: str | None = None
26
32
  line: int | None = None
33
+ args: Mapping[str, Any] = field(default_factory=dict, compare=False)
@@ -142,7 +142,11 @@ class Unavailable:
142
142
 
143
143
  @dataclass(frozen=True)
144
144
  class HostKeyed:
145
- """The host computes this cell's key (``owns_key=False``)."""
145
+ """The host computes this cell's key (``owns_key=False``).
146
+
147
+ ``parts`` holds the upstream components the cell reads, as a key would, and no
148
+ inputs: those are the host's to key.
149
+ """
146
150
 
147
151
  parts: dict[str, Any] | None = None
148
152
 
@@ -174,15 +178,23 @@ def definition_key(analysis: Analysis, cell: CellAnalysis) -> tuple[str, str]:
174
178
 
175
179
 
176
180
  @dataclass(frozen=True)
177
- class _VariantRead:
181
+ class VariantRead:
182
+ """What a cell reads of one variable a sweep makes: the variants, and how.
183
+
184
+ ``scalar``: a fan-out instance's own group, read as that variant's value; otherwise
185
+ the ``{variant: value}`` dict. ``fanout``: the variants are one fan-out cell's
186
+ instances rather than the members of a group. No variants: another variant's
187
+ member, which this cell does not read.
188
+ """
189
+
178
190
  variants: tuple[str, ...]
179
191
  scalar: bool
180
192
  fanout: bool
181
193
 
182
194
 
183
- def _variant_read(
184
- analysis: Analysis, cell_id: str, upstream_id: str, var: str, instance: str | None
185
- ) -> _VariantRead | None:
195
+ def variant_read(
196
+ analysis: Analysis, cell_id: str, upstream_id: str, var: str, instance: str | None = None
197
+ ) -> VariantRead | None:
186
198
  """What *cell_id* (or its *instance*) reads of *var* from *upstream_id*, if a sweep makes it."""
187
199
  from cellgraph.analysis import SweepProducer
188
200
 
@@ -198,15 +210,33 @@ def _variant_read(
198
210
  names = (
199
211
  (instance,) if own and instance is not None else tuple(n for n, _ in producer.variants)
200
212
  )
201
- return _VariantRead(variants=names, scalar=own, fanout=True)
213
+ return VariantRead(variants=names, scalar=own, fanout=True)
202
214
  member = next((name for name, cid in producer.variants if cid == upstream_id), None)
203
215
  if member is None:
204
216
  return None
205
217
  if own:
206
- return _VariantRead(
218
+ return VariantRead(
207
219
  variants=(member,) if member == instance else (), scalar=True, fanout=False
208
220
  )
209
- return _VariantRead(variants=(member,), scalar=False, fanout=False)
221
+ return VariantRead(variants=(member,), scalar=False, fanout=False)
222
+
223
+
224
+ def shadowed_reads(analysis: Analysis, cell_id: str) -> set[str]:
225
+ """Names *cell_id* reads that an upstream defines but is not wired to give it.
226
+
227
+ Such a name is read from its wired producer only.
228
+ """
229
+ graph = analysis.graph
230
+ cell = analysis.cells[cell_id]
231
+ reads = set(cell.references) | set(cell.builtin_references)
232
+ shadowed: set[str] = set()
233
+ for upstream_id in graph.cell_upstream.get(cell_id, []):
234
+ upstream = analysis.cells.get(upstream_id)
235
+ if upstream is not None:
236
+ shadowed |= (reads & set(upstream.defines)) - graph.wired_variables(
237
+ cell_id, upstream_id
238
+ )
239
+ return shadowed
210
240
 
211
241
 
212
242
  class _Assembly:
@@ -256,14 +286,7 @@ def _upstream(
256
286
  return assembly
257
287
 
258
288
  reads = set(cell.references) | set(cell.builtin_references)
259
- # A name several upstreams define is read from its wired producer only.
260
- shadowed: set[str] = set()
261
- for upstream_id in upstream_ids:
262
- upstream = analysis.cells.get(upstream_id)
263
- if upstream is not None:
264
- shadowed |= (reads & set(upstream.defines)) - graph.wired_variables(
265
- cell_id, upstream_id
266
- )
289
+ shadowed = shadowed_reads(analysis, cell_id)
267
290
 
268
291
  sweep_buckets: dict[str, list[tuple[str, str]]] = {}
269
292
  for upstream_id in upstream_ids:
@@ -274,19 +297,17 @@ def _upstream(
274
297
  wired = graph.wired_variables(cell_id, upstream_id) if shadowed else set()
275
298
  snapshot = published(upstream_id)
276
299
 
277
- items: list[tuple[str | None, Any]] = list(snapshot.outputs.items())
278
- if not items and snapshot.cell_ref is not None:
279
- items = [(None, snapshot.cell_ref)]
300
+ names = list(snapshot.outputs)
301
+ if not names and snapshot.cell_ref is not None:
302
+ value = assembly.component(snapshot.cell_ref, by_content, f"cell:{upstream_id}")
303
+ if value is not None:
304
+ assembly.add(f"cell:{upstream_id}", value)
280
305
 
281
- for var, ref in items:
282
- if var is None:
283
- value = assembly.component(ref, by_content, f"cell:{upstream_id}")
284
- if value is not None:
285
- assembly.add(f"cell:{upstream_id}", value)
286
- continue
306
+ # An output is looked up only once it counts, so a host's mapping can be lazy.
307
+ for var in names:
287
308
  if by_content and var not in reads:
288
309
  continue
289
- read = _variant_read(analysis, cell_id, upstream_id, var, instance)
310
+ read = variant_read(analysis, cell_id, upstream_id, var, instance)
290
311
  if read is not None and read.fanout:
291
312
  parts = []
292
313
  for name in read.variants:
@@ -304,7 +325,9 @@ def _upstream(
304
325
  continue
305
326
  if read is not None and not read.variants:
306
327
  continue # another variant's member
307
- value = assembly.component(ref, by_content, f"output:{upstream_id}.{var}")
328
+ value = assembly.component(
329
+ snapshot.outputs[var], by_content, f"output:{upstream_id}.{var}"
330
+ )
308
331
  if value is None:
309
332
  continue
310
333
  if read is not None:
@@ -343,13 +366,20 @@ def execution_key(
343
366
  ``published(cell_id)`` is what an upstream published; ``resolve`` has one resolver
344
367
  per input kind. ``instance=None`` on a fan-out cell is its cell-level key.
345
368
  """
346
- from cellgraph.analysis import NotPlannableError
369
+ from cellgraph.analysis import STRUCTURAL_CODES, NotPlannableError
347
370
 
348
371
  cell = analysis.cells[cell_id]
349
- if not cell.plannable:
350
- raise NotPlannableError(cell_id, analysis.blocking(cell_id))
351
- if not analysis.analyzers[cell.language].owns_key:
352
- return HostKeyed()
372
+ # A blocked cell is keyed too: a result cached under its key is still its result.
373
+ # One a cycle or a variant collision reaches has no sound upstreams to key it on.
374
+ structural = [
375
+ d
376
+ for blocker in cell.blocked_by
377
+ for d in analysis.cells[blocker].diagnostics
378
+ if d.code in STRUCTURAL_CODES
379
+ ]
380
+ if structural:
381
+ raise NotPlannableError(cell_id, structural)
382
+ owns_key = analysis.analyzers[cell.language].owns_key
353
383
  fanout = analysis.graph.fanout_cells.get(cell_id)
354
384
  if instance is not None:
355
385
  if fanout is None:
@@ -361,7 +391,8 @@ def execution_key(
361
391
  assembly = _upstream(analysis, cell_id, instance, published)
362
392
  inputs: list[tuple[str, str, str]] = []
363
393
  resolvers = resolve or {}
364
- for spec in cell.inputs:
394
+ # A host-keyed cell's inputs are the host's to key.
395
+ for spec in cell.inputs if owns_key else []:
365
396
  if spec.kind not in resolvers:
366
397
  raise ValueError(f"no resolver for {spec.kind!r} inputs (cell {cell_id})")
367
398
  result = resolvers[spec.kind](spec)
@@ -391,6 +422,8 @@ def execution_key(
391
422
  "upstream": sorted([address, value] for address, value in assembly.components),
392
423
  "inputs": sorted([kind, name, component] for kind, name, component in inputs),
393
424
  }
425
+ if not owns_key:
426
+ return HostKeyed(parts=parts)
394
427
  if assembly.unresolved:
395
428
  return Unavailable(
396
429
  reason="unresolved: " + ", ".join(assembly.unresolved),
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
 
8
8
  import ast
9
9
  import builtins
10
+ import functools
10
11
  import symtable
11
12
  from dataclasses import dataclass, field
12
13
  from typing import TYPE_CHECKING
@@ -641,11 +642,26 @@ def analyze_cell(source: str) -> CellAnalysis:
641
642
  )
642
643
 
643
644
 
645
+ # A notebook is analyzed again on every edit with most cells unchanged, so the
646
+ # per-source work is kept for the sources seen last.
647
+ _MEMO_SIZE = 4096
648
+
649
+
650
+ @functools.lru_cache(maxsize=_MEMO_SIZE)
651
+ def _analyzed(source: str) -> CellAnalysis:
652
+ return analyze_cell(source)
653
+
654
+
644
655
  def normalize(source: str) -> str:
645
656
  """Canonical text for *source*: its AST round trip, so comments and layout drop out.
646
657
 
647
658
  Source that does not parse falls back to the line strip, which keeps comments.
648
659
  """
660
+ return _normalized(source)
661
+
662
+
663
+ @functools.lru_cache(maxsize=_MEMO_SIZE)
664
+ def _normalized(source: str) -> str:
649
665
  try:
650
666
  return ast.unparse(ast.parse(source))
651
667
  except SyntaxError:
@@ -659,7 +675,8 @@ class PythonAnalyzer:
659
675
  owns_key = True
660
676
 
661
677
  def analyze(self, cell: Cell) -> Analyzed:
662
- result = analyze_cell(cell.source)
678
+ # Memoized: the lists are copied, so the caller may change its own.
679
+ result = _analyzed(cell.source)
663
680
  return Analyzed(
664
681
  defines=list(result.defines),
665
682
  references=list(result.references),
File without changes
File without changes