cellgraph 0.2.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.2.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.2.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.2.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,6 +40,7 @@ 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,
@@ -105,6 +106,7 @@ __all__ = [
105
106
  "GroupConfig",
106
107
  "HostKeyed",
107
108
  "Input",
109
+ "Item",
108
110
  "Keeps",
109
111
  "Keyed",
110
112
  "LanguageAnalyzer",
@@ -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]:
@@ -511,6 +513,20 @@ def _names_phase(ca: CellAnalysis, analyzed: Analyzed) -> None:
511
513
  args={"name": name},
512
514
  )
513
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)
529
+
514
530
  ca.defines = defines
515
531
  ca.references = [name for name in references if name not in bound]
516
532
  ca.builtin_references = [name for name in analyzed.builtin_references if name not in bound]
@@ -555,7 +571,7 @@ def _names_phase(ca: CellAnalysis, analyzed: Analyzed) -> None:
555
571
  if ca.fanout is not None and ca.fanout != effect:
556
572
  ca.error(
557
573
  "fanout_conflict",
558
- "the cell fans out over two groups",
574
+ "the cell fans out two ways",
559
575
  annotation=origin,
560
576
  line=origin_line,
561
577
  )
@@ -823,7 +839,10 @@ def _wiring_phase(cells: list[CellAnalysis], groups: GroupConfig) -> Dag:
823
839
 
824
840
  is_sweep_member = cell.id in sweep_member_group
825
841
  fanout_group: str | None = None
826
- 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:
827
846
  read_groups = dag.sweep_groups_read(cell.id)
828
847
  named = cell.fanout.group
829
848
  if named is not None:
@@ -872,6 +891,39 @@ def _wiring_phase(cells: list[CellAnalysis], groups: GroupConfig) -> Dag:
872
891
  return dag
873
892
 
874
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
+
875
927
  def _fanout_notes(
876
928
  cell: CellAnalysis, read_groups: set[str], sizes: Mapping[str, tuple[str, ...]]
877
929
  ) -> None:
@@ -950,6 +1002,7 @@ def _restrict(graph: Dag, keep: set[str]) -> Dag:
950
1002
  if c in keep
951
1003
  },
952
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},
953
1006
  read_producers={c: dict(r) for c, r in graph.read_producers.items() if c in keep},
954
1007
  )
955
1008
  active = [cid for cid in ids if cid not in graph.inactive_cells]
@@ -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
  ]
@@ -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.
@@ -277,6 +289,7 @@ def _upstream(
277
289
  cell_id: str,
278
290
  instance: str | None,
279
291
  published: Callable[[str], Published],
292
+ without: str | None = None,
280
293
  ) -> _Assembly:
281
294
  graph = analysis.graph
282
295
  cell = analysis.cells[cell_id]
@@ -307,6 +320,8 @@ def _upstream(
307
320
  for var in names:
308
321
  if by_content and var not in reads:
309
322
  continue
323
+ if var == without:
324
+ continue
310
325
  read = variant_read(analysis, cell_id, upstream_id, var, instance)
311
326
  if read is not None and read.fanout:
312
327
  parts = []
@@ -356,7 +371,7 @@ def execution_key(
356
371
  analysis: Analysis,
357
372
  cell_id: str,
358
373
  *,
359
- instance: str | None = None,
374
+ instance: str | Item | None = None,
360
375
  published: Callable[[str], Published],
361
376
  resolve: Mapping[str, Resolver] | None = None,
362
377
  env_hash: str,
@@ -364,7 +379,9 @@ def execution_key(
364
379
  """The key that says whether *cell_id* (or one fan-out *instance*) must run.
365
380
 
366
381
  ``published(cell_id)`` is what an upstream published; ``resolve`` has one resolver
367
- 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.
368
385
  """
369
386
  from cellgraph.analysis import STRUCTURAL_CODES, NotPlannableError
370
387
 
@@ -381,18 +398,32 @@ def execution_key(
381
398
  raise NotPlannableError(cell_id, structural)
382
399
  owns_key = analysis.analyzers[cell.language].owns_key
383
400
  fanout = analysis.graph.fanout_cells.get(cell_id)
384
- 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")
385
410
  if fanout is None:
386
411
  raise ValueError(f"cell {cell_id} does not fan out, so it has no instance {instance!r}")
387
412
  if instance not in {name for name, _ in fanout.variants}:
388
413
  raise ValueError(f"cell {cell_id} has no instance {instance!r}")
389
414
 
390
415
  legacy = analysis.digest == "legacy-1"
391
- 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)
392
421
  inputs: list[tuple[str, str, str]] = []
393
422
  resolvers = resolve or {}
394
- # A host-keyed cell's inputs are the host's to key.
423
+ # A host-keyed cell's inputs are the host's to key. An item stands in for its source.
395
424
  for spec in cell.inputs if owns_key else []:
425
+ if item is not None and spec.name == source:
426
+ continue
396
427
  if spec.kind not in resolvers:
397
428
  raise ValueError(f"no resolver for {spec.kind!r} inputs (cell {cell_id})")
398
429
  result = resolvers[spec.kind](spec)
@@ -434,7 +465,9 @@ def execution_key(
434
465
  components = [value for _, value in assembly.components] + [c for _, _, c in inputs]
435
466
  if legacy:
436
467
  digest = compute_provenance_hash(components, cell.definition_key, env_hash)
437
- 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:
438
471
  digest = derive_subkey(digest, f"variant={instance}")
439
472
  else:
440
473
  digest = _sha256(
File without changes
File without changes