cellgraph 0.1.0__tar.gz → 0.3.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.3.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.3.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.3.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"
@@ -40,13 +40,16 @@ from cellgraph.grammar import GRAMMAR, Directive, Grammar
40
40
  from cellgraph.keys import (
41
41
  PARTS_SCHEMA,
42
42
  HostKeyed,
43
+ Item,
43
44
  Keyed,
44
45
  OutputRef,
45
46
  Provenance,
46
47
  Published,
47
48
  Resolved,
49
+ Resolver,
48
50
  Run,
49
51
  Unavailable,
52
+ VariantRead,
50
53
  canonical_json,
51
54
  compute_provenance_hash,
52
55
  derive_subkey,
@@ -103,6 +106,7 @@ __all__ = [
103
106
  "GroupConfig",
104
107
  "HostKeyed",
105
108
  "Input",
109
+ "Item",
106
110
  "Keeps",
107
111
  "Keyed",
108
112
  "LanguageAnalyzer",
@@ -117,11 +121,13 @@ __all__ = [
117
121
  "PythonAnalyzer",
118
122
  "Reads",
119
123
  "Resolved",
124
+ "Resolver",
120
125
  "Run",
121
126
  "Seeds",
122
127
  "Severity",
123
128
  "SweepProducer",
124
129
  "Unavailable",
130
+ "VariantRead",
125
131
  "UnknownLanguageError",
126
132
  "ValidationContext",
127
133
  "VariantGroupResolution",
@@ -136,6 +136,8 @@ class Dag:
136
136
  inactive_cells: set[str] = field(default_factory=set)
137
137
  wired_inputs: dict[str, dict[str, set[str]]] = field(default_factory=dict)
138
138
  fanout_cells: dict[str, SweepProducer] = field(default_factory=dict)
139
+ # An ``@each`` cell: the name its items come from. Not a sweep: a reader sees one output.
140
+ each_cells: dict[str, str] = field(default_factory=dict)
139
141
  read_producers: dict[str, dict[str, str | SweepProducer]] = field(default_factory=dict)
140
142
 
141
143
  def wired_variables(self, consumer_id: str, producer_id: str) -> set[str]:
@@ -275,6 +277,8 @@ class CellAnalysis:
275
277
  annotations: dict[str, Any] = field(default_factory=dict)
276
278
  directives: list[Directive] = field(default_factory=list)
277
279
  effects: list[Effect] = field(default_factory=list)
280
+ # Per effect: the annotation key and line it came from; None for the host's hook.
281
+ origins: list[tuple[str | None, int | None]] = field(default_factory=list)
278
282
  defines: list[str] = field(default_factory=list)
279
283
  references: list[str] = field(default_factory=list)
280
284
  builtin_references: list[str] = field(default_factory=list)
@@ -282,6 +286,7 @@ class CellAnalysis:
282
286
  bound: list[str] = field(default_factory=list)
283
287
  after: list[str] = field(default_factory=list)
284
288
  seeds: dict[str, str] = field(default_factory=dict)
289
+ seed_origins: dict[str, tuple[str | None, int | None]] = field(default_factory=dict)
285
290
  member: Member | None = None
286
291
  fanout: FanOut | None = None
287
292
  kept: list[str] = field(default_factory=list)
@@ -292,12 +297,21 @@ class CellAnalysis:
292
297
  plannable: bool = True
293
298
  # The cells whose own errors keep this one from planning (itself included).
294
299
  blocked_by: list[str] = field(default_factory=list)
300
+ # Set by ``analyze`` for every cell, plannable or not.
295
301
  definition_key: str | None = None
296
302
  normalized: str | None = None
297
303
 
298
- def error(self, code: str, message: str, *, annotation: str | None = None, line=None):
304
+ def error(
305
+ self,
306
+ code: str,
307
+ message: str,
308
+ *,
309
+ annotation: str | None = None,
310
+ line=None,
311
+ args: Mapping[str, Any] | None = None,
312
+ ):
299
313
  self.diagnostics.append(
300
- Diagnostic(code, Severity.ERROR, message, self.id, annotation, line)
314
+ Diagnostic(code, Severity.ERROR, message, self.id, annotation, line, dict(args or {}))
301
315
  )
302
316
 
303
317
  def note(
@@ -308,8 +322,11 @@ class CellAnalysis:
308
322
  *,
309
323
  annotation: str | None = None,
310
324
  line=None,
325
+ args: Mapping[str, Any] | None = None,
311
326
  ):
312
- self.diagnostics.append(Diagnostic(code, severity, message, self.id, annotation, line))
327
+ self.diagnostics.append(
328
+ Diagnostic(code, severity, message, self.id, annotation, line, dict(args or {}))
329
+ )
313
330
 
314
331
  @property
315
332
  def has_error(self) -> bool:
@@ -354,6 +371,14 @@ def _first_line(directives: list[Directive], key: str) -> int | None:
354
371
  return next((d.line for d in directives if d.key == key), None)
355
372
 
356
373
 
374
+ def _naming_line(directives: list[Directive], key: str, token: str) -> int | None:
375
+ """The first *key* line with *token* among its values; else the first *key* line."""
376
+ for d in directives:
377
+ if d.key == key and token in (t.rstrip(",") for t in d.value.split()):
378
+ return d.line
379
+ return _first_line(directives, key)
380
+
381
+
357
382
  def _top_level_imports(source: str) -> set[str]:
358
383
  try:
359
384
  tree = ast.parse(source)
@@ -389,6 +414,7 @@ def _parse_phase(
389
414
  f"`@{directive.key}` is not an annotation this vocabulary knows.{hint}",
390
415
  annotation=directive.key,
391
416
  line=directive.line,
417
+ args={"key": directive.key, "suggestion": close[0] if close else None},
392
418
  )
393
419
  continue
394
420
  by_owner.setdefault(owner.key, []).append(directive)
@@ -427,14 +453,19 @@ def _parse_phase(
427
453
  f"({type(encoded).__name__}); the annotation needs an encoder",
428
454
  annotation=annotation.key,
429
455
  line=directives[0].line,
456
+ args={"type": type(encoded).__name__},
430
457
  )
431
458
  else:
432
459
  ca.key_fields[annotation.key] = encoded
433
460
  if annotation.effects is not None:
434
- ca.effects.extend(annotation.effects(value))
461
+ produced = list(annotation.effects(value))
462
+ ca.effects.extend(produced)
463
+ ca.origins.extend([(annotation.key, directives[0].line)] * len(produced))
435
464
 
436
465
  if vocabulary.cell_effects is not None:
437
- ca.effects.extend(vocabulary.cell_effects(cell.meta, dict(ca.annotations)))
466
+ hooked = list(vocabulary.cell_effects(cell.meta, dict(ca.annotations)))
467
+ ca.effects.extend(hooked)
468
+ ca.origins.extend([(None, None)] * len(hooked))
438
469
  for effect in ca.effects:
439
470
  if not isinstance(effect, EFFECT_TYPES):
440
471
  raise TypeError(f"{effect!r} is not one of the ten effects")
@@ -472,11 +503,29 @@ def _names_phase(ca: CellAnalysis, analyzed: Analyzed) -> None:
472
503
  if isinstance(effect, Binds):
473
504
  for name in effect.names:
474
505
  if name in bound:
475
- ca.error("bind_conflict", f"`{name}` is bound twice")
506
+ ca.error("bind_conflict", f"`{name}` is bound twice", args={"name": name})
476
507
  else:
477
508
  bound.append(name)
478
509
  for name in sorted(set(bound) & set(read)):
479
- ca.error("bind_read_conflict", f"`{name}` is both bound and read from upstream")
510
+ ca.error(
511
+ "bind_read_conflict",
512
+ f"`{name}` is both bound and read from upstream",
513
+ args={"name": name},
514
+ )
515
+
516
+ for effect in ca.effects:
517
+ if isinstance(effect, FanOut) and effect.over is not None:
518
+ # An instance's key leaves the source out, so the body must not read it whole.
519
+ if effect.over in analyzed.references:
520
+ ca.error(
521
+ "each_reads_source",
522
+ f"the cell reads `{effect.over}` itself, which every item's result would "
523
+ "then depend on; read each item, or compute it in a cell before this one",
524
+ annotation="each",
525
+ args={"source": effect.over},
526
+ )
527
+ elif effect.over not in bound and effect.over not in references:
528
+ references.append(effect.over)
480
529
 
481
530
  ca.defines = defines
482
531
  ca.references = [name for name in references if name not in bound]
@@ -484,32 +533,59 @@ def _names_phase(ca: CellAnalysis, analyzed: Analyzed) -> None:
484
533
  ca.mutation_defines = list(analyzed.mutation_defines)
485
534
  ca.bound = bound
486
535
 
487
- for effect in ca.effects:
536
+ for effect, (origin, origin_line) in zip(ca.effects, ca.origins, strict=True):
488
537
  if isinstance(effect, After):
489
538
  for cell_id in effect.cell_ids:
490
539
  if cell_id not in ca.after:
491
540
  ca.after.append(cell_id)
492
541
  elif isinstance(effect, Seeds):
493
542
  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")
543
+ ca.error(
544
+ "seed_conflict",
545
+ f"`{effect.name}` is seeded from two cells",
546
+ annotation=origin,
547
+ line=origin_line,
548
+ args={"name": effect.name},
549
+ )
495
550
  else:
496
551
  ca.seeds[effect.name] = effect.cell_id
552
+ ca.seed_origins[effect.name] = (origin, origin_line)
497
553
  if effect.name in bound:
498
- ca.error("bind_read_conflict", f"`{effect.name}` is both bound and seeded")
554
+ ca.error(
555
+ "bind_read_conflict",
556
+ f"`{effect.name}` is both bound and seeded",
557
+ annotation=origin,
558
+ line=origin_line,
559
+ args={"name": effect.name},
560
+ )
499
561
  elif isinstance(effect, Member):
500
562
  if ca.member is not None and ca.member != effect:
501
- ca.error("member_conflict", "the cell is a member of two variants")
563
+ ca.error(
564
+ "member_conflict",
565
+ "the cell is a member of two variants",
566
+ annotation=origin,
567
+ line=origin_line,
568
+ )
502
569
  ca.member = effect
503
570
  elif isinstance(effect, FanOut):
504
571
  if ca.fanout is not None and ca.fanout != effect:
505
- ca.error("fanout_conflict", "the cell fans out over two groups")
572
+ ca.error(
573
+ "fanout_conflict",
574
+ "the cell fans out two ways",
575
+ annotation=origin,
576
+ line=origin_line,
577
+ )
506
578
  ca.fanout = effect
507
579
  elif isinstance(effect, Keeps):
508
580
  ca.kept.extend(name for name in effect.names if name not in ca.kept)
509
581
  elif isinstance(effect, Input):
510
582
  if any((i.kind, i.name) == (effect.kind, effect.name) for i in ca.inputs):
511
583
  ca.error(
512
- "input_conflict", f"two {effect.kind} inputs have the address {effect.name!r}"
584
+ "input_conflict",
585
+ f"two {effect.kind} inputs have the address {effect.name!r}",
586
+ annotation=origin,
587
+ line=origin_line,
588
+ args={"kind": effect.kind, "name": effect.name},
513
589
  )
514
590
  else:
515
591
  ca.inputs.append(effect)
@@ -537,7 +613,12 @@ def _resolve_groups(
537
613
  f"{cell.id[:8]} in group '{group}'"
538
614
  )
539
615
  for cid in (clash, cell.id):
540
- by_id[cid].error("variant_collision", message, annotation="variant")
616
+ by_id[cid].error(
617
+ "variant_collision",
618
+ message,
619
+ annotation="variant",
620
+ args={"group": group, "name": name, "cells": [clash, cell.id]},
621
+ )
541
622
  continue
542
623
  if not members:
543
624
  group_order.append(group)
@@ -591,6 +672,13 @@ def _variant_notes(cells: list[CellAnalysis], dag: Dag, groups: GroupConfig) ->
591
672
  f"defines {sorted(theirs)}",
592
673
  annotation="variant",
593
674
  line=line,
675
+ args={
676
+ "group": group,
677
+ "name": cell.member.name,
678
+ "defines": sorted(own),
679
+ "sibling": sibling.member.name if sibling.member else None,
680
+ "sibling_defines": sorted(theirs),
681
+ },
594
682
  )
595
683
  break
596
684
  mode = groups.modes.get(group, "switch")
@@ -601,6 +689,7 @@ def _variant_notes(cells: list[CellAnalysis], dag: Dag, groups: GroupConfig) ->
601
689
  f"group `{group}` has mode {mode!r}; it runs as a switch",
602
690
  annotation="variant",
603
691
  line=line,
692
+ args={"group": group, "mode": mode},
604
693
  )
605
694
  active = groups.active.get(group)
606
695
  if mode == "sweep" and active:
@@ -610,6 +699,7 @@ def _variant_notes(cells: list[CellAnalysis], dag: Dag, groups: GroupConfig) ->
610
699
  f"group `{group}` is a sweep, so its active selection is ignored",
611
700
  annotation="variant",
612
701
  line=line,
702
+ args={"group": group, "active": active},
613
703
  )
614
704
  if mode != "sweep" and active:
615
705
  names = [name for _, name in members[group].members]
@@ -620,6 +710,7 @@ def _variant_notes(cells: list[CellAnalysis], dag: Dag, groups: GroupConfig) ->
620
710
  f"group `{group}` selects `{active}`, which no cell provides",
621
711
  annotation="variant",
622
712
  line=line,
713
+ args={"group": group, "active": active},
623
714
  )
624
715
 
625
716
 
@@ -683,27 +774,35 @@ def _wiring_phase(cells: list[CellAnalysis], groups: GroupConfig) -> Dag:
683
774
 
684
775
  for name, seed_id in cell.seeds.items():
685
776
  seed = cell_by_id.get(seed_id)
777
+ origin, origin_line = cell.seed_origins.get(name, (None, None))
686
778
  if seed is None:
687
- cell.error("seed_unknown_cell", f"`{name}` is seeded from {seed_id!r}, no cell")
779
+ reason, message = "unknown", f"`{name}` is seeded from {seed_id!r}, no cell"
688
780
  elif seed_id == cell.id:
689
- cell.error("seed_unknown_cell", f"`{name}` is seeded from the cell itself")
781
+ reason, message = "self", f"`{name}` is seeded from the cell itself"
690
782
  elif seed_id in inactive:
691
- cell.error(
692
- "seed_unknown_cell", f"`{name}` is seeded from {seed_id}, an inactive variant"
783
+ reason, message = (
784
+ "inactive",
785
+ f"`{name}` is seeded from {seed_id}, an inactive variant",
693
786
  )
694
787
  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
- )
788
+ reason = "undefined"
789
+ message = f"`{name}` is seeded from {seed_id}, which does not define it"
699
790
  else:
700
791
  dag.read_producers.setdefault(cell.id, {})[name] = seed_id
701
792
  _wire(dag, seed_id, cell.id, name)
793
+ continue
794
+ cell.error(
795
+ "seed_unknown_cell",
796
+ message,
797
+ annotation=origin,
798
+ line=origin_line,
799
+ args={"name": name, "cell_id": seed_id, "reason": reason},
800
+ )
702
801
 
703
802
  dag.consumed_variables[cell.id].update(v for v in cell.kept if v in cell.defines)
704
803
 
705
804
  for upstream_id in cell.after:
706
- line = _first_line(cell.directives, "after")
805
+ line = _naming_line(cell.directives, "after", upstream_id)
707
806
  if upstream_id == cell.id:
708
807
  cell.note(
709
808
  "after_self",
@@ -711,6 +810,7 @@ def _wiring_phase(cells: list[CellAnalysis], groups: GroupConfig) -> Dag:
711
810
  "`@after` names this cell",
712
811
  annotation="after",
713
812
  line=line,
813
+ args={"cell_id": upstream_id},
714
814
  )
715
815
  continue
716
816
  if upstream_id not in cell_by_id:
@@ -719,6 +819,7 @@ def _wiring_phase(cells: list[CellAnalysis], groups: GroupConfig) -> Dag:
719
819
  f"`@after {upstream_id}` names no cell",
720
820
  annotation="after",
721
821
  line=line,
822
+ args={"cell_id": upstream_id},
722
823
  )
723
824
  continue
724
825
  if upstream_id not in active_ids:
@@ -727,6 +828,7 @@ def _wiring_phase(cells: list[CellAnalysis], groups: GroupConfig) -> Dag:
727
828
  f"`@after {upstream_id}` names a variant that is not active",
728
829
  annotation="after",
729
830
  line=line,
831
+ args={"cell_id": upstream_id},
730
832
  )
731
833
  continue
732
834
  dag.edges.append(DagEdge(from_cell_id=upstream_id, to_cell_id=cell.id, variable=""))
@@ -737,7 +839,10 @@ def _wiring_phase(cells: list[CellAnalysis], groups: GroupConfig) -> Dag:
737
839
 
738
840
  is_sweep_member = cell.id in sweep_member_group
739
841
  fanout_group: str | None = None
740
- if cell.fanout is not None:
842
+ if cell.fanout is not None and cell.fanout.over is not None:
843
+ dag.each_cells[cell.id] = cell.fanout.over
844
+ _each_notes(cell, dag)
845
+ elif cell.fanout is not None:
741
846
  read_groups = dag.sweep_groups_read(cell.id)
742
847
  named = cell.fanout.group
743
848
  if named is not None:
@@ -781,11 +886,44 @@ def _wiring_phase(cells: list[CellAnalysis], groups: GroupConfig) -> Dag:
781
886
  message = "Cycle detected in DAG: " + (" → ".join(cycles[0]) if cycles else "unknown")
782
887
  for cid in active_cell_ids:
783
888
  if cid in on_cycle:
784
- cell_by_id[cid].error("cycle", message)
889
+ cell_by_id[cid].error("cycle", message, args={"cycle": cycles[0] if cycles else []})
785
890
  dag.topological_order = _kahn(dag, active_cell_ids)
786
891
  return dag
787
892
 
788
893
 
894
+ def _each_notes(cell: CellAnalysis, dag: Dag) -> None:
895
+ """What an ``@each`` cell cannot iterate, or be."""
896
+ assert cell.fanout is not None and cell.fanout.over is not None
897
+ source = cell.fanout.over
898
+ line = _first_line(cell.directives, "each")
899
+ if cell.member is not None:
900
+ cell.error(
901
+ "each_on_variant_member",
902
+ "a variant member cannot also run once per item",
903
+ annotation="each",
904
+ line=line,
905
+ )
906
+ if source in cell.bound:
907
+ return
908
+ producer = dag.read_producer(cell.id, source)
909
+ if isinstance(producer, SweepProducer):
910
+ cell.error(
911
+ "each_over_sweep",
912
+ f"`{source}` comes from a sweep, which has no one collection to iterate",
913
+ annotation="each",
914
+ line=line,
915
+ args={"source": source},
916
+ )
917
+ elif producer is None:
918
+ cell.error(
919
+ "each_unknown_source",
920
+ f"no cell before this one defines `{source}`, and the cell does not bind it",
921
+ annotation="each",
922
+ line=line,
923
+ args={"source": source},
924
+ )
925
+
926
+
789
927
  def _fanout_notes(
790
928
  cell: CellAnalysis, read_groups: set[str], sizes: Mapping[str, tuple[str, ...]]
791
929
  ) -> None:
@@ -810,6 +948,7 @@ def _fanout_notes(
810
948
  f"`@per_variant {named}` names no sweep group the cell reads",
811
949
  annotation="per_variant",
812
950
  line=line,
951
+ args={"group": named},
813
952
  )
814
953
  return
815
954
  effective = named
@@ -830,6 +969,7 @@ def _fanout_notes(
830
969
  f"`@per_variant` reads {', '.join(sorted(read_groups))}; name one",
831
970
  annotation="per_variant",
832
971
  line=line,
972
+ args={"groups": sorted(read_groups)},
833
973
  )
834
974
  return
835
975
  effective = next(iter(read_groups))
@@ -840,6 +980,7 @@ def _fanout_notes(
840
980
  f"group `{effective}` has one variant",
841
981
  annotation="per_variant",
842
982
  line=line,
983
+ args={"group": effective},
843
984
  )
844
985
 
845
986
 
@@ -861,6 +1002,7 @@ def _restrict(graph: Dag, keep: set[str]) -> Dag:
861
1002
  if c in keep
862
1003
  },
863
1004
  fanout_cells={c: p for c, p in graph.fanout_cells.items() if c in keep},
1005
+ each_cells={c: s for c, s in graph.each_cells.items() if c in keep},
864
1006
  read_producers={c: dict(r) for c, r in graph.read_producers.items() if c in keep},
865
1007
  )
866
1008
  active = [cid for cid in ids if cid not in graph.inactive_cells]
@@ -909,6 +1051,20 @@ class Analysis:
909
1051
 
910
1052
  return execution_key(self, cell_id, **kwargs)
911
1053
 
1054
+ def variant_read(
1055
+ self, cell_id: str, upstream_id: str, var: str, instance: str | None = None
1056
+ ) -> Any:
1057
+ """See ``cellgraph.keys.variant_read``."""
1058
+ from cellgraph.keys import variant_read
1059
+
1060
+ return variant_read(self, cell_id, upstream_id, var, instance)
1061
+
1062
+ def shadowed_reads(self, cell_id: str) -> set[str]:
1063
+ """See ``cellgraph.keys.shadowed_reads``."""
1064
+ from cellgraph.keys import shadowed_reads
1065
+
1066
+ return shadowed_reads(self, cell_id)
1067
+
912
1068
  def plan(self, target: str, ready: Callable[[str], bool]) -> list[str]:
913
1069
  """See ``cellgraph.plan.plan``."""
914
1070
  from cellgraph.plan import plan
@@ -1008,6 +1164,5 @@ def analyze(
1008
1164
  digest=digest,
1009
1165
  )
1010
1166
  for ca in analyses:
1011
- if ca.plannable:
1012
- ca.normalized, ca.definition_key = definition_key(analysis, ca)
1167
+ ca.normalized, ca.definition_key = definition_key(analysis, ca)
1013
1168
  return analysis
@@ -1,4 +1,5 @@
1
- """The core vocabulary: ``@name``, ``@after``, ``@nocache``, ``@variant``, ``@per_variant``.
1
+ """The core vocabulary: ``@name``, ``@after``, ``@nocache``, ``@variant``, ``@per_variant``,
2
+ ``@each``.
2
3
 
3
4
  The parsing rules and diagnostic codes are Strata's (``annotations.py`` and
4
5
  ``annotation_validation.py``), which these were ported from.
@@ -6,7 +7,7 @@ The parsing rules and diagnostic codes are Strata's (``annotations.py`` and
6
7
 
7
8
  from __future__ import annotations
8
9
 
9
- from cellgraph.effects import After, FanOut, Member, NoCache
10
+ from cellgraph.effects import After, Binds, FanOut, Member, NoCache
10
11
  from cellgraph.grammar import Directive
11
12
  from cellgraph.vocabulary import Annotation, AnnotationError, flag, mark_stacked, scalar
12
13
 
@@ -56,6 +57,36 @@ def _parse_per_variant(directives: list[Directive]) -> str | None:
56
57
  return tokens[0] if tokens else None
57
58
 
58
59
 
60
+ def _parse_each(directives: list[Directive]) -> dict[str, str]:
61
+ """``# @each <name> in <source> [key=<column>] [glob=<pattern>]``; the last line wins."""
62
+ value: dict[str, str] = {}
63
+ for directive in directives:
64
+ tokens = directive.value.split()
65
+ value = {}
66
+ if len(tokens) >= 3 and tokens[1] == "in":
67
+ value = {"name": tokens[0], "source": tokens[2]}
68
+ for token in tokens[3:]:
69
+ option, _, setting = token.partition("=")
70
+ if option not in ("key", "glob") or not setting or option in value:
71
+ value = {}
72
+ break
73
+ value[option] = setting
74
+ if not value or not (value["name"].isidentifier() and value["source"].isidentifier()):
75
+ raise AnnotationError(
76
+ f"`@each {directive.value}`: expected `@each <name> in <source> "
77
+ "[key=<column>] [glob=<pattern>]`, with the name and source Python identifiers",
78
+ code="each_malformed",
79
+ line=directive.line,
80
+ )
81
+ if value["name"] == value["source"]:
82
+ raise AnnotationError(
83
+ f"`@each {directive.value}`: an item cannot have its source's name",
84
+ code="each_malformed",
85
+ line=directive.line,
86
+ )
87
+ return value
88
+
89
+
59
90
  def core_annotations() -> list[Annotation]:
60
91
  return [
61
92
  Annotation("name", parse=scalar(str), in_key=False),
@@ -84,4 +115,10 @@ def core_annotations() -> list[Annotation]:
84
115
  parse=_parse_per_variant,
85
116
  effects=lambda group: [FanOut(group)],
86
117
  ),
118
+ # What it iterates, and how an item is named, decide what each instance is.
119
+ Annotation(
120
+ "each",
121
+ parse=_parse_each,
122
+ effects=lambda each: [Binds(each["name"]), FanOut(over=each["source"])],
123
+ ),
87
124
  ]
@@ -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)
@@ -88,9 +88,14 @@ class Member:
88
88
 
89
89
  @dataclass(frozen=True)
90
90
  class FanOut:
91
- """Run once per member of ``group``, or of the single sweep group the cell reads."""
91
+ """Run once per member of ``group``, or of the single sweep group the cell reads.
92
+
93
+ With ``over``, once per item the host lists from that name instead: an upstream
94
+ variable, or a name the cell binds. The cell reads it through its items alone.
95
+ """
92
96
 
93
97
  group: str | None = None
98
+ over: str | None = None
94
99
 
95
100
 
96
101
  @dataclass(frozen=True)
@@ -104,6 +104,18 @@ class Published:
104
104
  cell_ref: OutputRef | Unavailable | None = None
105
105
 
106
106
 
107
+ @dataclass(frozen=True)
108
+ class Item:
109
+ """One item of an ``@each`` cell: what the host calls it, and its current content.
110
+
111
+ The key is the instance's name, so it must be stable across runs; the digest is
112
+ what makes the instance run again.
113
+ """
114
+
115
+ key: str
116
+ digest: str
117
+
118
+
107
119
  @dataclass(frozen=True)
108
120
  class Resolved:
109
121
  """A resolver's answer for one input.
@@ -142,7 +154,11 @@ class Unavailable:
142
154
 
143
155
  @dataclass(frozen=True)
144
156
  class HostKeyed:
145
- """The host computes this cell's key (``owns_key=False``)."""
157
+ """The host computes this cell's key (``owns_key=False``).
158
+
159
+ ``parts`` holds the upstream components the cell reads, as a key would, and no
160
+ inputs: those are the host's to key.
161
+ """
146
162
 
147
163
  parts: dict[str, Any] | None = None
148
164
 
@@ -174,15 +190,23 @@ def definition_key(analysis: Analysis, cell: CellAnalysis) -> tuple[str, str]:
174
190
 
175
191
 
176
192
  @dataclass(frozen=True)
177
- class _VariantRead:
193
+ class VariantRead:
194
+ """What a cell reads of one variable a sweep makes: the variants, and how.
195
+
196
+ ``scalar``: a fan-out instance's own group, read as that variant's value; otherwise
197
+ the ``{variant: value}`` dict. ``fanout``: the variants are one fan-out cell's
198
+ instances rather than the members of a group. No variants: another variant's
199
+ member, which this cell does not read.
200
+ """
201
+
178
202
  variants: tuple[str, ...]
179
203
  scalar: bool
180
204
  fanout: bool
181
205
 
182
206
 
183
- def _variant_read(
184
- analysis: Analysis, cell_id: str, upstream_id: str, var: str, instance: str | None
185
- ) -> _VariantRead | None:
207
+ def variant_read(
208
+ analysis: Analysis, cell_id: str, upstream_id: str, var: str, instance: str | None = None
209
+ ) -> VariantRead | None:
186
210
  """What *cell_id* (or its *instance*) reads of *var* from *upstream_id*, if a sweep makes it."""
187
211
  from cellgraph.analysis import SweepProducer
188
212
 
@@ -198,15 +222,33 @@ def _variant_read(
198
222
  names = (
199
223
  (instance,) if own and instance is not None else tuple(n for n, _ in producer.variants)
200
224
  )
201
- return _VariantRead(variants=names, scalar=own, fanout=True)
225
+ return VariantRead(variants=names, scalar=own, fanout=True)
202
226
  member = next((name for name, cid in producer.variants if cid == upstream_id), None)
203
227
  if member is None:
204
228
  return None
205
229
  if own:
206
- return _VariantRead(
230
+ return VariantRead(
207
231
  variants=(member,) if member == instance else (), scalar=True, fanout=False
208
232
  )
209
- return _VariantRead(variants=(member,), scalar=False, fanout=False)
233
+ return VariantRead(variants=(member,), scalar=False, fanout=False)
234
+
235
+
236
+ def shadowed_reads(analysis: Analysis, cell_id: str) -> set[str]:
237
+ """Names *cell_id* reads that an upstream defines but is not wired to give it.
238
+
239
+ Such a name is read from its wired producer only.
240
+ """
241
+ graph = analysis.graph
242
+ cell = analysis.cells[cell_id]
243
+ reads = set(cell.references) | set(cell.builtin_references)
244
+ shadowed: set[str] = set()
245
+ for upstream_id in graph.cell_upstream.get(cell_id, []):
246
+ upstream = analysis.cells.get(upstream_id)
247
+ if upstream is not None:
248
+ shadowed |= (reads & set(upstream.defines)) - graph.wired_variables(
249
+ cell_id, upstream_id
250
+ )
251
+ return shadowed
210
252
 
211
253
 
212
254
  class _Assembly:
@@ -247,6 +289,7 @@ def _upstream(
247
289
  cell_id: str,
248
290
  instance: str | None,
249
291
  published: Callable[[str], Published],
292
+ without: str | None = None,
250
293
  ) -> _Assembly:
251
294
  graph = analysis.graph
252
295
  cell = analysis.cells[cell_id]
@@ -256,14 +299,7 @@ def _upstream(
256
299
  return assembly
257
300
 
258
301
  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
- )
302
+ shadowed = shadowed_reads(analysis, cell_id)
267
303
 
268
304
  sweep_buckets: dict[str, list[tuple[str, str]]] = {}
269
305
  for upstream_id in upstream_ids:
@@ -274,19 +310,19 @@ def _upstream(
274
310
  wired = graph.wired_variables(cell_id, upstream_id) if shadowed else set()
275
311
  snapshot = published(upstream_id)
276
312
 
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)]
313
+ names = list(snapshot.outputs)
314
+ if not names and snapshot.cell_ref is not None:
315
+ value = assembly.component(snapshot.cell_ref, by_content, f"cell:{upstream_id}")
316
+ if value is not None:
317
+ assembly.add(f"cell:{upstream_id}", value)
280
318
 
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
319
+ # An output is looked up only once it counts, so a host's mapping can be lazy.
320
+ for var in names:
287
321
  if by_content and var not in reads:
288
322
  continue
289
- read = _variant_read(analysis, cell_id, upstream_id, var, instance)
323
+ if var == without:
324
+ continue
325
+ read = variant_read(analysis, cell_id, upstream_id, var, instance)
290
326
  if read is not None and read.fanout:
291
327
  parts = []
292
328
  for name in read.variants:
@@ -304,7 +340,9 @@ def _upstream(
304
340
  continue
305
341
  if read is not None and not read.variants:
306
342
  continue # another variant's member
307
- value = assembly.component(ref, by_content, f"output:{upstream_id}.{var}")
343
+ value = assembly.component(
344
+ snapshot.outputs[var], by_content, f"output:{upstream_id}.{var}"
345
+ )
308
346
  if value is None:
309
347
  continue
310
348
  if read is not None:
@@ -333,7 +371,7 @@ def execution_key(
333
371
  analysis: Analysis,
334
372
  cell_id: str,
335
373
  *,
336
- instance: str | None = None,
374
+ instance: str | Item | None = None,
337
375
  published: Callable[[str], Published],
338
376
  resolve: Mapping[str, Resolver] | None = None,
339
377
  env_hash: str,
@@ -341,27 +379,51 @@ def execution_key(
341
379
  """The key that says whether *cell_id* (or one fan-out *instance*) must run.
342
380
 
343
381
  ``published(cell_id)`` is what an upstream published; ``resolve`` has one resolver
344
- per input kind. ``instance=None`` on a fan-out cell is its cell-level key.
382
+ per input kind. ``instance=None`` on a fan-out cell is its cell-level key. An ``@each``
383
+ cell's instance is an :class:`Item`, keyed on that item in place of its source, so
384
+ changing one item leaves every other item's key alone.
345
385
  """
346
- from cellgraph.analysis import NotPlannableError
386
+ from cellgraph.analysis import STRUCTURAL_CODES, NotPlannableError
347
387
 
348
388
  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()
389
+ # A blocked cell is keyed too: a result cached under its key is still its result.
390
+ # One a cycle or a variant collision reaches has no sound upstreams to key it on.
391
+ structural = [
392
+ d
393
+ for blocker in cell.blocked_by
394
+ for d in analysis.cells[blocker].diagnostics
395
+ if d.code in STRUCTURAL_CODES
396
+ ]
397
+ if structural:
398
+ raise NotPlannableError(cell_id, structural)
399
+ owns_key = analysis.analyzers[cell.language].owns_key
353
400
  fanout = analysis.graph.fanout_cells.get(cell_id)
354
- if instance is not None:
401
+ source = analysis.graph.each_cells.get(cell_id)
402
+ item = instance if isinstance(instance, Item) else None
403
+ if item is not None:
404
+ if source is None:
405
+ raise ValueError(f"cell {cell_id} has no items, so it has no item {item.key!r}")
406
+ instance = item.key
407
+ elif instance is not None:
408
+ if source is not None:
409
+ raise ValueError(f"cell {cell_id} runs per item; {instance!r} is not an Item")
355
410
  if fanout is None:
356
411
  raise ValueError(f"cell {cell_id} does not fan out, so it has no instance {instance!r}")
357
412
  if instance not in {name for name, _ in fanout.variants}:
358
413
  raise ValueError(f"cell {cell_id} has no instance {instance!r}")
359
414
 
360
415
  legacy = analysis.digest == "legacy-1"
361
- assembly = _upstream(analysis, cell_id, instance, published)
416
+ if item is not None:
417
+ assembly = _upstream(analysis, cell_id, None, published, without=source)
418
+ assembly.add(f"each:{source}", f"each:{item.key}={item.digest}")
419
+ else:
420
+ assembly = _upstream(analysis, cell_id, instance, published)
362
421
  inputs: list[tuple[str, str, str]] = []
363
422
  resolvers = resolve or {}
364
- for spec in cell.inputs:
423
+ # A host-keyed cell's inputs are the host's to key. An item stands in for its source.
424
+ for spec in cell.inputs if owns_key else []:
425
+ if item is not None and spec.name == source:
426
+ continue
365
427
  if spec.kind not in resolvers:
366
428
  raise ValueError(f"no resolver for {spec.kind!r} inputs (cell {cell_id})")
367
429
  result = resolvers[spec.kind](spec)
@@ -391,6 +453,8 @@ def execution_key(
391
453
  "upstream": sorted([address, value] for address, value in assembly.components),
392
454
  "inputs": sorted([kind, name, component] for kind, name, component in inputs),
393
455
  }
456
+ if not owns_key:
457
+ return HostKeyed(parts=parts)
394
458
  if assembly.unresolved:
395
459
  return Unavailable(
396
460
  reason="unresolved: " + ", ".join(assembly.unresolved),
@@ -401,7 +465,9 @@ def execution_key(
401
465
  components = [value for _, value in assembly.components] + [c for _, _, c in inputs]
402
466
  if legacy:
403
467
  digest = compute_provenance_hash(components, cell.definition_key, env_hash)
404
- if instance is not None:
468
+ if item is not None:
469
+ digest = derive_subkey(digest, f"each={item.key}")
470
+ elif instance is not None:
405
471
  digest = derive_subkey(digest, f"variant={instance}")
406
472
  else:
407
473
  digest = _sha256(
@@ -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