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.
- {cellgraph-0.1.0 → cellgraph-0.3.0}/PKG-INFO +1 -1
- {cellgraph-0.1.0 → cellgraph-0.3.0}/pyproject.toml +1 -1
- {cellgraph-0.1.0 → cellgraph-0.3.0}/pyproject.toml.orig +1 -1
- {cellgraph-0.1.0 → cellgraph-0.3.0}/src/cellgraph/__init__.py +6 -0
- {cellgraph-0.1.0 → cellgraph-0.3.0}/src/cellgraph/analysis.py +182 -27
- {cellgraph-0.1.0 → cellgraph-0.3.0}/src/cellgraph/core.py +39 -2
- {cellgraph-0.1.0 → cellgraph-0.3.0}/src/cellgraph/diagnostics.py +9 -2
- {cellgraph-0.1.0 → cellgraph-0.3.0}/src/cellgraph/effects.py +6 -1
- {cellgraph-0.1.0 → cellgraph-0.3.0}/src/cellgraph/keys.py +104 -38
- {cellgraph-0.1.0 → cellgraph-0.3.0}/src/cellgraph/python.py +18 -1
- {cellgraph-0.1.0 → cellgraph-0.3.0}/LICENSE +0 -0
- {cellgraph-0.1.0 → cellgraph-0.3.0}/README.md +0 -0
- {cellgraph-0.1.0 → cellgraph-0.3.0}/src/cellgraph/grammar.py +0 -0
- {cellgraph-0.1.0 → cellgraph-0.3.0}/src/cellgraph/languages.py +0 -0
- {cellgraph-0.1.0 → cellgraph-0.3.0}/src/cellgraph/plan.py +0 -0
- {cellgraph-0.1.0 → cellgraph-0.3.0}/src/cellgraph/py.typed +0 -0
- {cellgraph-0.1.0 → cellgraph-0.3.0}/src/cellgraph/vocabulary.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: cellgraph
|
|
3
|
-
Version: 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.
|
|
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.
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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",
|
|
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(
|
|
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
|
-
|
|
779
|
+
reason, message = "unknown", f"`{name}` is seeded from {seed_id!r}, no cell"
|
|
688
780
|
elif seed_id == cell.id:
|
|
689
|
-
|
|
781
|
+
reason, message = "self", f"`{name}` is seeded from the cell itself"
|
|
690
782
|
elif seed_id in inactive:
|
|
691
|
-
|
|
692
|
-
"
|
|
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
|
-
|
|
696
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
184
|
-
analysis: Analysis, cell_id: str, upstream_id: str, var: str, instance: str | None
|
|
185
|
-
) ->
|
|
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
|
|
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
|
|
230
|
+
return VariantRead(
|
|
207
231
|
variants=(member,) if member == instance else (), scalar=True, fanout=False
|
|
208
232
|
)
|
|
209
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
if not
|
|
279
|
-
|
|
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
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|