cellgraph 0.1.0__py3-none-any.whl
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/__init__.py +142 -0
- cellgraph/analysis.py +1013 -0
- cellgraph/core.py +87 -0
- cellgraph/diagnostics.py +26 -0
- cellgraph/effects.py +127 -0
- cellgraph/grammar.py +144 -0
- cellgraph/keys.py +420 -0
- cellgraph/languages.py +96 -0
- cellgraph/plan.py +34 -0
- cellgraph/py.typed +0 -0
- cellgraph/python.py +672 -0
- cellgraph/vocabulary.py +263 -0
- cellgraph-0.1.0.dist-info/METADATA +63 -0
- cellgraph-0.1.0.dist-info/RECORD +16 -0
- cellgraph-0.1.0.dist-info/WHEEL +4 -0
- cellgraph-0.1.0.dist-info/licenses/LICENSE +201 -0
cellgraph/analysis.py
ADDED
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
"""Cell analysis and the DAG (design 4.4 and 4.5).
|
|
2
|
+
|
|
3
|
+
``analyze`` runs the phases in a fixed order: parse, names, groups, wiring,
|
|
4
|
+
outputs, keys. It always returns an analysis an editor can show. A cell carries
|
|
5
|
+
``plannable``, which is false for its own errors, for a structural error it is
|
|
6
|
+
part of (a cycle, a variant collision) and for any cell it depends on that is not
|
|
7
|
+
plannable. ``Analysis.graph`` holds every candidate edge; ``Analysis.dag`` keeps
|
|
8
|
+
only plannable cells.
|
|
9
|
+
|
|
10
|
+
The wiring is Strata's ``NotebookDag.from_cells``, ported as is.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import difflib
|
|
17
|
+
import math
|
|
18
|
+
from collections import deque
|
|
19
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
20
|
+
from dataclasses import dataclass, field, replace
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from cellgraph.diagnostics import Diagnostic, Severity
|
|
24
|
+
from cellgraph.effects import (
|
|
25
|
+
EFFECT_TYPES,
|
|
26
|
+
After,
|
|
27
|
+
Binds,
|
|
28
|
+
Defines,
|
|
29
|
+
Effect,
|
|
30
|
+
FanOut,
|
|
31
|
+
Input,
|
|
32
|
+
Keeps,
|
|
33
|
+
Member,
|
|
34
|
+
NoCache,
|
|
35
|
+
Reads,
|
|
36
|
+
Seeds,
|
|
37
|
+
)
|
|
38
|
+
from cellgraph.grammar import Directive
|
|
39
|
+
from cellgraph.languages import (
|
|
40
|
+
Analyzed,
|
|
41
|
+
LanguageAnalyzer,
|
|
42
|
+
directive_keys,
|
|
43
|
+
registered_analyzers,
|
|
44
|
+
)
|
|
45
|
+
from cellgraph.vocabulary import AnnotationError, Partial, ValidationContext, Vocabulary
|
|
46
|
+
|
|
47
|
+
NORMALIZE_LEGACY_1 = "legacy-1"
|
|
48
|
+
NORMALIZE_CELLGRAPH_1 = "cellgraph-1"
|
|
49
|
+
DIGEST_LEGACY_1 = "legacy-1"
|
|
50
|
+
DIGEST_CELLGRAPH_1 = "cellgraph-1"
|
|
51
|
+
|
|
52
|
+
# Diagnostics that make a structural error: the cell is on a cycle or in a collision.
|
|
53
|
+
STRUCTURAL_CODES = frozenset({"cycle", "variant_collision"})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Cell:
|
|
58
|
+
"""A cell as the host gives it. ``meta`` is the host's, passed to ``cell_effects``."""
|
|
59
|
+
|
|
60
|
+
id: str
|
|
61
|
+
source: str
|
|
62
|
+
language: str = "python"
|
|
63
|
+
meta: Mapping[str, Any] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class GroupConfig:
|
|
68
|
+
"""Per variant group: its mode (``switch`` or ``sweep``) and its active member's name."""
|
|
69
|
+
|
|
70
|
+
modes: Mapping[str, str] = field(default_factory=dict)
|
|
71
|
+
active: Mapping[str, str] = field(default_factory=dict)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class NotPlannableError(ValueError):
|
|
75
|
+
"""A key or a plan was asked of a cell that is not plannable."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, cell_id: str, diagnostics: Sequence[Diagnostic]):
|
|
78
|
+
reasons = "; ".join(f"{d.cell}: {d.code}" for d in diagnostics) or "no diagnostic"
|
|
79
|
+
super().__init__(f"cell {cell_id} is not plannable ({reasons})")
|
|
80
|
+
self.cell_id = cell_id
|
|
81
|
+
self.diagnostics = list(diagnostics)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# --- the DAG ----------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class DagEdge:
|
|
89
|
+
"""``variable`` flows from ``from_cell_id`` to ``to_cell_id``; empty for an ordering edge."""
|
|
90
|
+
|
|
91
|
+
from_cell_id: str
|
|
92
|
+
to_cell_id: str
|
|
93
|
+
variable: str
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class VariantGroupResolution:
|
|
98
|
+
"""One variant group: its members in source order, inactive ones included."""
|
|
99
|
+
|
|
100
|
+
group: str
|
|
101
|
+
active_name: str
|
|
102
|
+
active_cell_id: str
|
|
103
|
+
members: list[tuple[str, str]] = field(default_factory=list)
|
|
104
|
+
mode: str = "switch"
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class SweepProducer:
|
|
109
|
+
"""A variable produced across a set of variants.
|
|
110
|
+
|
|
111
|
+
- **Sweep group** (``fanout_cell is None``): ``variants`` maps
|
|
112
|
+
``variant_name -> member_cell_id``; a reader takes one edge per member.
|
|
113
|
+
- **Fan-out cell** (``fanout_cell`` set): every entry points at ``fanout_cell``,
|
|
114
|
+
which runs once per variant.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
group: str
|
|
118
|
+
variants: tuple[tuple[str, str], ...]
|
|
119
|
+
fanout_cell: str | None = None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class Dag:
|
|
124
|
+
"""The wired graph over active cells."""
|
|
125
|
+
|
|
126
|
+
edges: list[DagEdge] = field(default_factory=list)
|
|
127
|
+
cell_upstream: dict[str, list[str]] = field(default_factory=dict)
|
|
128
|
+
cell_downstream: dict[str, list[str]] = field(default_factory=dict)
|
|
129
|
+
leaves: set[str] = field(default_factory=set)
|
|
130
|
+
roots: set[str] = field(default_factory=set)
|
|
131
|
+
topological_order: list[str] = field(default_factory=list)
|
|
132
|
+
variable_producer: dict[str, str | SweepProducer] = field(default_factory=dict)
|
|
133
|
+
consumed_variables: dict[str, set[str]] = field(default_factory=dict)
|
|
134
|
+
shadow_warnings: dict[str, list[str]] = field(default_factory=dict)
|
|
135
|
+
variant_groups: list[VariantGroupResolution] = field(default_factory=list)
|
|
136
|
+
inactive_cells: set[str] = field(default_factory=set)
|
|
137
|
+
wired_inputs: dict[str, dict[str, set[str]]] = field(default_factory=dict)
|
|
138
|
+
fanout_cells: dict[str, SweepProducer] = field(default_factory=dict)
|
|
139
|
+
read_producers: dict[str, dict[str, str | SweepProducer]] = field(default_factory=dict)
|
|
140
|
+
|
|
141
|
+
def wired_variables(self, consumer_id: str, producer_id: str) -> set[str]:
|
|
142
|
+
"""The variables ``consumer_id`` reads from ``producer_id``."""
|
|
143
|
+
return self.wired_inputs.get(consumer_id, {}).get(producer_id, set())
|
|
144
|
+
|
|
145
|
+
def read_producer(self, consumer_id: str, var: str) -> str | SweepProducer | None:
|
|
146
|
+
"""What ``consumer_id`` reads ``var`` from; its last producer for a name it never reads."""
|
|
147
|
+
reads = self.read_producers.get(consumer_id, {})
|
|
148
|
+
return reads[var] if var in reads else self.variable_producer.get(var)
|
|
149
|
+
|
|
150
|
+
def sweep_groups_read(self, consumer_id: str) -> set[str]:
|
|
151
|
+
"""The groups whose sweep (or fan-out over it) ``consumer_id`` reads."""
|
|
152
|
+
return {
|
|
153
|
+
producer.group
|
|
154
|
+
for producer in self.read_producers.get(consumer_id, {}).values()
|
|
155
|
+
if isinstance(producer, SweepProducer)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
def upstream_reachable(self, start: str) -> set[str]:
|
|
159
|
+
"""The cells reachable upstream from ``start``, ``start`` included."""
|
|
160
|
+
visited: set[str] = set()
|
|
161
|
+
queue: deque[str] = deque([start])
|
|
162
|
+
while queue:
|
|
163
|
+
current = queue.popleft()
|
|
164
|
+
if current in visited:
|
|
165
|
+
continue
|
|
166
|
+
visited.add(current)
|
|
167
|
+
for neighbor in self.cell_upstream.get(current, []):
|
|
168
|
+
if neighbor not in visited:
|
|
169
|
+
queue.append(neighbor)
|
|
170
|
+
return visited
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _kahn(dag: Dag, cell_ids: list[str]) -> list[str]:
|
|
174
|
+
"""Topological order of *cell_ids*; cells on or after a cycle are left out."""
|
|
175
|
+
members = set(cell_ids)
|
|
176
|
+
degree = {cid: sum(1 for u in dag.cell_upstream[cid] if u in members) for cid in cell_ids}
|
|
177
|
+
queue: deque[str] = deque(cid for cid in cell_ids if degree[cid] == 0)
|
|
178
|
+
order: list[str] = []
|
|
179
|
+
while queue:
|
|
180
|
+
current = queue.popleft()
|
|
181
|
+
order.append(current)
|
|
182
|
+
for downstream in dag.cell_downstream[current]:
|
|
183
|
+
if downstream not in members:
|
|
184
|
+
continue
|
|
185
|
+
degree[downstream] -= 1
|
|
186
|
+
if degree[downstream] == 0:
|
|
187
|
+
queue.append(downstream)
|
|
188
|
+
return order
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _cycles(dag: Dag, cell_ids: list[str]) -> list[list[str]]:
|
|
192
|
+
"""Every cycle the depth-first walk finds, each as the ids along it (Strata's order)."""
|
|
193
|
+
color = {cid: 0 for cid in cell_ids}
|
|
194
|
+
cycles: list[list[str]] = []
|
|
195
|
+
|
|
196
|
+
def dfs(node: str, path: list[str]) -> None:
|
|
197
|
+
color[node] = 1
|
|
198
|
+
path.append(node)
|
|
199
|
+
for downstream in dag.cell_downstream[node]:
|
|
200
|
+
if color.get(downstream) == 1:
|
|
201
|
+
start = path.index(downstream)
|
|
202
|
+
cycles.append(path[start:] + [downstream])
|
|
203
|
+
elif color.get(downstream) == 0:
|
|
204
|
+
dfs(downstream, path)
|
|
205
|
+
path.pop()
|
|
206
|
+
color[node] = 2
|
|
207
|
+
|
|
208
|
+
for cid in cell_ids:
|
|
209
|
+
if color[cid] == 0:
|
|
210
|
+
dfs(cid, [])
|
|
211
|
+
return cycles
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _on_cycles(dag: Dag, cell_ids: list[str]) -> set[str]:
|
|
215
|
+
"""The cells in a strongly connected component of more than one cell."""
|
|
216
|
+
index: dict[str, int] = {}
|
|
217
|
+
low: dict[str, int] = {}
|
|
218
|
+
stack: list[str] = []
|
|
219
|
+
on_stack: set[str] = set()
|
|
220
|
+
found: set[str] = set()
|
|
221
|
+
counter = [0]
|
|
222
|
+
members = set(cell_ids)
|
|
223
|
+
|
|
224
|
+
def strong(v: str) -> None:
|
|
225
|
+
index[v] = low[v] = counter[0]
|
|
226
|
+
counter[0] += 1
|
|
227
|
+
stack.append(v)
|
|
228
|
+
on_stack.add(v)
|
|
229
|
+
for w in dag.cell_downstream[v]:
|
|
230
|
+
if w not in members:
|
|
231
|
+
continue
|
|
232
|
+
if w not in index:
|
|
233
|
+
strong(w)
|
|
234
|
+
low[v] = min(low[v], low[w])
|
|
235
|
+
elif w in on_stack:
|
|
236
|
+
low[v] = min(low[v], index[w])
|
|
237
|
+
if low[v] == index[v]:
|
|
238
|
+
component = []
|
|
239
|
+
while True:
|
|
240
|
+
w = stack.pop()
|
|
241
|
+
on_stack.discard(w)
|
|
242
|
+
component.append(w)
|
|
243
|
+
if w == v:
|
|
244
|
+
break
|
|
245
|
+
if len(component) > 1:
|
|
246
|
+
found.update(component)
|
|
247
|
+
|
|
248
|
+
for cid in cell_ids:
|
|
249
|
+
if cid not in index:
|
|
250
|
+
strong(cid)
|
|
251
|
+
return found
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _wire(dag: Dag, from_id: str, to_id: str, var: str) -> None:
|
|
255
|
+
dag.edges.append(DagEdge(from_cell_id=from_id, to_cell_id=to_id, variable=var))
|
|
256
|
+
if from_id not in dag.cell_upstream[to_id]:
|
|
257
|
+
dag.cell_upstream[to_id].append(from_id)
|
|
258
|
+
if to_id not in dag.cell_downstream[from_id]:
|
|
259
|
+
dag.cell_downstream[from_id].append(to_id)
|
|
260
|
+
dag.consumed_variables[from_id].add(var)
|
|
261
|
+
dag.wired_inputs.setdefault(to_id, {}).setdefault(from_id, set()).add(var)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# --- per-cell analysis --------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@dataclass
|
|
268
|
+
class CellAnalysis:
|
|
269
|
+
"""One cell after every phase."""
|
|
270
|
+
|
|
271
|
+
id: str
|
|
272
|
+
language: str
|
|
273
|
+
source: str
|
|
274
|
+
# Parsed value of each annotation present on the cell, by key.
|
|
275
|
+
annotations: dict[str, Any] = field(default_factory=dict)
|
|
276
|
+
directives: list[Directive] = field(default_factory=list)
|
|
277
|
+
effects: list[Effect] = field(default_factory=list)
|
|
278
|
+
defines: list[str] = field(default_factory=list)
|
|
279
|
+
references: list[str] = field(default_factory=list)
|
|
280
|
+
builtin_references: list[str] = field(default_factory=list)
|
|
281
|
+
mutation_defines: list[str] = field(default_factory=list)
|
|
282
|
+
bound: list[str] = field(default_factory=list)
|
|
283
|
+
after: list[str] = field(default_factory=list)
|
|
284
|
+
seeds: dict[str, str] = field(default_factory=dict)
|
|
285
|
+
member: Member | None = None
|
|
286
|
+
fanout: FanOut | None = None
|
|
287
|
+
kept: list[str] = field(default_factory=list)
|
|
288
|
+
inputs: list[Input] = field(default_factory=list)
|
|
289
|
+
nocache: bool = False
|
|
290
|
+
key_fields: dict[str, Any] = field(default_factory=dict)
|
|
291
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
292
|
+
plannable: bool = True
|
|
293
|
+
# The cells whose own errors keep this one from planning (itself included).
|
|
294
|
+
blocked_by: list[str] = field(default_factory=list)
|
|
295
|
+
definition_key: str | None = None
|
|
296
|
+
normalized: str | None = None
|
|
297
|
+
|
|
298
|
+
def error(self, code: str, message: str, *, annotation: str | None = None, line=None):
|
|
299
|
+
self.diagnostics.append(
|
|
300
|
+
Diagnostic(code, Severity.ERROR, message, self.id, annotation, line)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def note(
|
|
304
|
+
self,
|
|
305
|
+
code: str,
|
|
306
|
+
severity: Severity,
|
|
307
|
+
message: str,
|
|
308
|
+
*,
|
|
309
|
+
annotation: str | None = None,
|
|
310
|
+
line=None,
|
|
311
|
+
):
|
|
312
|
+
self.diagnostics.append(Diagnostic(code, severity, message, self.id, annotation, line))
|
|
313
|
+
|
|
314
|
+
@property
|
|
315
|
+
def has_error(self) -> bool:
|
|
316
|
+
return any(d.severity == Severity.ERROR for d in self.diagnostics)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _is_canonical(value: Any) -> bool:
|
|
320
|
+
"""None, bool, int, finite float, str, and lists and str-keyed dicts of these."""
|
|
321
|
+
if value is None or isinstance(value, bool | int | str):
|
|
322
|
+
return True
|
|
323
|
+
if isinstance(value, float):
|
|
324
|
+
return math.isfinite(value)
|
|
325
|
+
if isinstance(value, list):
|
|
326
|
+
return all(_is_canonical(item) for item in value)
|
|
327
|
+
if isinstance(value, dict):
|
|
328
|
+
return all(isinstance(k, str) and _is_canonical(v) for k, v in value.items())
|
|
329
|
+
return False
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _annotation_errors(exc: AnnotationError | ExceptionGroup) -> list[AnnotationError]:
|
|
333
|
+
"""The errors a parser or validator raised: one, or an ``ExceptionGroup`` of them."""
|
|
334
|
+
if isinstance(exc, AnnotationError):
|
|
335
|
+
return [exc]
|
|
336
|
+
matched, rest = exc.split(AnnotationError)
|
|
337
|
+
if rest is not None:
|
|
338
|
+
raise rest
|
|
339
|
+
assert matched is not None
|
|
340
|
+
return _leaves(matched)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _leaves(group: BaseExceptionGroup) -> list[AnnotationError]:
|
|
344
|
+
out: list[AnnotationError] = []
|
|
345
|
+
for exc in group.exceptions:
|
|
346
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
347
|
+
out.extend(_leaves(exc))
|
|
348
|
+
elif isinstance(exc, AnnotationError):
|
|
349
|
+
out.append(exc)
|
|
350
|
+
return out
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _first_line(directives: list[Directive], key: str) -> int | None:
|
|
354
|
+
return next((d.line for d in directives if d.key == key), None)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _top_level_imports(source: str) -> set[str]:
|
|
358
|
+
try:
|
|
359
|
+
tree = ast.parse(source)
|
|
360
|
+
except SyntaxError:
|
|
361
|
+
return set()
|
|
362
|
+
names: set[str] = set()
|
|
363
|
+
for node in tree.body:
|
|
364
|
+
if isinstance(node, ast.Import):
|
|
365
|
+
for alias in node.names:
|
|
366
|
+
names.add((alias.asname or alias.name).split(".")[0])
|
|
367
|
+
elif isinstance(node, ast.ImportFrom):
|
|
368
|
+
for alias in node.names:
|
|
369
|
+
names.add(alias.asname or alias.name)
|
|
370
|
+
return names
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _parse_phase(
|
|
374
|
+
cell: Cell, vocabulary: Vocabulary, analyzer: LanguageAnalyzer, strict: bool
|
|
375
|
+
) -> tuple[CellAnalysis, Analyzed]:
|
|
376
|
+
"""Phase 1: the body's analysis, and every annotation's parser."""
|
|
377
|
+
ca = CellAnalysis(id=cell.id, language=cell.language, source=cell.source)
|
|
378
|
+
ca.directives = vocabulary.grammar.directives(cell.source, keys=directive_keys(analyzer))
|
|
379
|
+
|
|
380
|
+
by_owner: dict[str, list[Directive]] = {}
|
|
381
|
+
for directive in ca.directives:
|
|
382
|
+
owner = vocabulary.owner(directive.key)
|
|
383
|
+
if owner is None:
|
|
384
|
+
close = difflib.get_close_matches(directive.key, vocabulary.keys, n=1, cutoff=0.75)
|
|
385
|
+
hint = f" Did you mean `@{close[0]}`?" if close else ""
|
|
386
|
+
ca.note(
|
|
387
|
+
"annotation_unknown",
|
|
388
|
+
Severity.ERROR if strict else Severity.WARN,
|
|
389
|
+
f"`@{directive.key}` is not an annotation this vocabulary knows.{hint}",
|
|
390
|
+
annotation=directive.key,
|
|
391
|
+
line=directive.line,
|
|
392
|
+
)
|
|
393
|
+
continue
|
|
394
|
+
by_owner.setdefault(owner.key, []).append(directive)
|
|
395
|
+
|
|
396
|
+
for annotation in vocabulary:
|
|
397
|
+
directives = by_owner.get(annotation.key)
|
|
398
|
+
if not directives:
|
|
399
|
+
continue
|
|
400
|
+
try:
|
|
401
|
+
value = annotation.parse(directives)
|
|
402
|
+
except (AnnotationError, ExceptionGroup) as raised:
|
|
403
|
+
for exc in _annotation_errors(raised):
|
|
404
|
+
ca.error(
|
|
405
|
+
exc.code or f"{directives[0].key}_unreadable",
|
|
406
|
+
str(exc),
|
|
407
|
+
annotation=annotation.key,
|
|
408
|
+
line=exc.line if exc.line is not None else directives[0].line,
|
|
409
|
+
)
|
|
410
|
+
continue
|
|
411
|
+
if isinstance(value, Partial):
|
|
412
|
+
for exc in value.errors:
|
|
413
|
+
ca.error(
|
|
414
|
+
exc.code or f"{directives[0].key}_unreadable",
|
|
415
|
+
str(exc),
|
|
416
|
+
annotation=annotation.key,
|
|
417
|
+
line=exc.line if exc.line is not None else directives[0].line,
|
|
418
|
+
)
|
|
419
|
+
value = value.value
|
|
420
|
+
ca.annotations[annotation.key] = value
|
|
421
|
+
if annotation.in_key:
|
|
422
|
+
encoded = annotation.encode(value) if annotation.encode is not None else value
|
|
423
|
+
if not _is_canonical(encoded):
|
|
424
|
+
ca.error(
|
|
425
|
+
f"{annotation.key}_unencodable",
|
|
426
|
+
f"`@{annotation.key}` has a value the key cannot encode "
|
|
427
|
+
f"({type(encoded).__name__}); the annotation needs an encoder",
|
|
428
|
+
annotation=annotation.key,
|
|
429
|
+
line=directives[0].line,
|
|
430
|
+
)
|
|
431
|
+
else:
|
|
432
|
+
ca.key_fields[annotation.key] = encoded
|
|
433
|
+
if annotation.effects is not None:
|
|
434
|
+
ca.effects.extend(annotation.effects(value))
|
|
435
|
+
|
|
436
|
+
if vocabulary.cell_effects is not None:
|
|
437
|
+
ca.effects.extend(vocabulary.cell_effects(cell.meta, dict(ca.annotations)))
|
|
438
|
+
for effect in ca.effects:
|
|
439
|
+
if not isinstance(effect, EFFECT_TYPES):
|
|
440
|
+
raise TypeError(f"{effect!r} is not one of the ten effects")
|
|
441
|
+
|
|
442
|
+
analyzed = analyzer.analyze(cell)
|
|
443
|
+
if analyzed.error is not None:
|
|
444
|
+
ca.error("syntax_error", analyzed.error)
|
|
445
|
+
ca.diagnostics.extend(replace(d, cell=ca.id) for d in analyzed.diagnostics)
|
|
446
|
+
return ca, analyzed
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _names_phase(ca: CellAnalysis, analyzed: Analyzed) -> None:
|
|
450
|
+
"""Phase 2: defines, references and bound names after the effects."""
|
|
451
|
+
defines = list(analyzed.defines)
|
|
452
|
+
for effect in ca.effects:
|
|
453
|
+
if isinstance(effect, Defines):
|
|
454
|
+
for name in effect.names:
|
|
455
|
+
if name not in defines:
|
|
456
|
+
defines.append(name)
|
|
457
|
+
|
|
458
|
+
references = list(analyzed.references)
|
|
459
|
+
read: list[str] = []
|
|
460
|
+
for effect in ca.effects:
|
|
461
|
+
if isinstance(effect, Reads):
|
|
462
|
+
for name in effect.names:
|
|
463
|
+
read.append(name)
|
|
464
|
+
if name in references:
|
|
465
|
+
continue
|
|
466
|
+
if not effect.even_if_defined and name in defines:
|
|
467
|
+
continue
|
|
468
|
+
references.append(name)
|
|
469
|
+
|
|
470
|
+
bound: list[str] = []
|
|
471
|
+
for effect in ca.effects:
|
|
472
|
+
if isinstance(effect, Binds):
|
|
473
|
+
for name in effect.names:
|
|
474
|
+
if name in bound:
|
|
475
|
+
ca.error("bind_conflict", f"`{name}` is bound twice")
|
|
476
|
+
else:
|
|
477
|
+
bound.append(name)
|
|
478
|
+
for name in sorted(set(bound) & set(read)):
|
|
479
|
+
ca.error("bind_read_conflict", f"`{name}` is both bound and read from upstream")
|
|
480
|
+
|
|
481
|
+
ca.defines = defines
|
|
482
|
+
ca.references = [name for name in references if name not in bound]
|
|
483
|
+
ca.builtin_references = [name for name in analyzed.builtin_references if name not in bound]
|
|
484
|
+
ca.mutation_defines = list(analyzed.mutation_defines)
|
|
485
|
+
ca.bound = bound
|
|
486
|
+
|
|
487
|
+
for effect in ca.effects:
|
|
488
|
+
if isinstance(effect, After):
|
|
489
|
+
for cell_id in effect.cell_ids:
|
|
490
|
+
if cell_id not in ca.after:
|
|
491
|
+
ca.after.append(cell_id)
|
|
492
|
+
elif isinstance(effect, Seeds):
|
|
493
|
+
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")
|
|
495
|
+
else:
|
|
496
|
+
ca.seeds[effect.name] = effect.cell_id
|
|
497
|
+
if effect.name in bound:
|
|
498
|
+
ca.error("bind_read_conflict", f"`{effect.name}` is both bound and seeded")
|
|
499
|
+
elif isinstance(effect, Member):
|
|
500
|
+
if ca.member is not None and ca.member != effect:
|
|
501
|
+
ca.error("member_conflict", "the cell is a member of two variants")
|
|
502
|
+
ca.member = effect
|
|
503
|
+
elif isinstance(effect, FanOut):
|
|
504
|
+
if ca.fanout is not None and ca.fanout != effect:
|
|
505
|
+
ca.error("fanout_conflict", "the cell fans out over two groups")
|
|
506
|
+
ca.fanout = effect
|
|
507
|
+
elif isinstance(effect, Keeps):
|
|
508
|
+
ca.kept.extend(name for name in effect.names if name not in ca.kept)
|
|
509
|
+
elif isinstance(effect, Input):
|
|
510
|
+
if any((i.kind, i.name) == (effect.kind, effect.name) for i in ca.inputs):
|
|
511
|
+
ca.error(
|
|
512
|
+
"input_conflict", f"two {effect.kind} inputs have the address {effect.name!r}"
|
|
513
|
+
)
|
|
514
|
+
else:
|
|
515
|
+
ca.inputs.append(effect)
|
|
516
|
+
elif isinstance(effect, NoCache):
|
|
517
|
+
ca.nocache = True
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _resolve_groups(
|
|
521
|
+
cells: list[CellAnalysis], groups: GroupConfig
|
|
522
|
+
) -> tuple[list[VariantGroupResolution], set[str]]:
|
|
523
|
+
"""Phase 3: each variant group's members and its active one; collisions are errors."""
|
|
524
|
+
sweep = {g for g, m in groups.modes.items() if m == "sweep"}
|
|
525
|
+
grouped: dict[str, list[tuple[str, str]]] = {}
|
|
526
|
+
group_order: list[str] = []
|
|
527
|
+
by_id = {c.id: c for c in cells}
|
|
528
|
+
for cell in cells:
|
|
529
|
+
if cell.member is None:
|
|
530
|
+
continue
|
|
531
|
+
group, name = cell.member.group, cell.member.name
|
|
532
|
+
members = grouped.setdefault(group, [])
|
|
533
|
+
clash = next((cid for cid, n in members if n == name), None)
|
|
534
|
+
if clash is not None:
|
|
535
|
+
message = (
|
|
536
|
+
f"Variant name '{name}' is used by both cell {clash[:8]} and cell "
|
|
537
|
+
f"{cell.id[:8]} in group '{group}'"
|
|
538
|
+
)
|
|
539
|
+
for cid in (clash, cell.id):
|
|
540
|
+
by_id[cid].error("variant_collision", message, annotation="variant")
|
|
541
|
+
continue
|
|
542
|
+
if not members:
|
|
543
|
+
group_order.append(group)
|
|
544
|
+
members.append((cell.id, name))
|
|
545
|
+
|
|
546
|
+
resolutions: list[VariantGroupResolution] = []
|
|
547
|
+
inactive: set[str] = set()
|
|
548
|
+
for group in group_order:
|
|
549
|
+
members = grouped[group]
|
|
550
|
+
is_sweep = group in sweep
|
|
551
|
+
wanted = groups.active.get(group)
|
|
552
|
+
active_cell_id, active_name = members[0]
|
|
553
|
+
if wanted is not None and not is_sweep:
|
|
554
|
+
for cid, name in members:
|
|
555
|
+
if name == wanted:
|
|
556
|
+
active_cell_id, active_name = cid, name
|
|
557
|
+
break
|
|
558
|
+
resolutions.append(
|
|
559
|
+
VariantGroupResolution(
|
|
560
|
+
group=group,
|
|
561
|
+
active_name=active_name,
|
|
562
|
+
active_cell_id=active_cell_id,
|
|
563
|
+
members=list(members),
|
|
564
|
+
mode="sweep" if is_sweep else "switch",
|
|
565
|
+
)
|
|
566
|
+
)
|
|
567
|
+
if not is_sweep:
|
|
568
|
+
inactive.update(cid for cid, _ in members if cid != active_cell_id)
|
|
569
|
+
return resolutions, inactive
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _variant_notes(cells: list[CellAnalysis], dag: Dag, groups: GroupConfig) -> None:
|
|
573
|
+
"""Strata's variant warnings: diverging defines, an unknown mode or active name."""
|
|
574
|
+
members = {res.group: res for res in dag.variant_groups}
|
|
575
|
+
by_id = {c.id: c for c in cells}
|
|
576
|
+
for cell in cells:
|
|
577
|
+
if cell.member is None or cell.member.group not in members:
|
|
578
|
+
continue
|
|
579
|
+
group = cell.member.group
|
|
580
|
+
line = _first_line(cell.directives, "variant")
|
|
581
|
+
siblings = [by_id[cid] for cid, _ in members[group].members if cid != cell.id]
|
|
582
|
+
own = set(cell.defines) - _top_level_imports(cell.source)
|
|
583
|
+
for sibling in siblings:
|
|
584
|
+
theirs = set(sibling.defines) - _top_level_imports(sibling.source)
|
|
585
|
+
if own != theirs:
|
|
586
|
+
cell.note(
|
|
587
|
+
"variant_contract_mismatch",
|
|
588
|
+
Severity.WARN,
|
|
589
|
+
f"variant `{cell.member.name}` of `{group}` defines {sorted(own)}, "
|
|
590
|
+
f"its sibling `{sibling.member.name if sibling.member else '?'}` "
|
|
591
|
+
f"defines {sorted(theirs)}",
|
|
592
|
+
annotation="variant",
|
|
593
|
+
line=line,
|
|
594
|
+
)
|
|
595
|
+
break
|
|
596
|
+
mode = groups.modes.get(group, "switch")
|
|
597
|
+
if mode not in ("switch", "sweep"):
|
|
598
|
+
cell.note(
|
|
599
|
+
"variant_mode_invalid",
|
|
600
|
+
Severity.WARN,
|
|
601
|
+
f"group `{group}` has mode {mode!r}; it runs as a switch",
|
|
602
|
+
annotation="variant",
|
|
603
|
+
line=line,
|
|
604
|
+
)
|
|
605
|
+
active = groups.active.get(group)
|
|
606
|
+
if mode == "sweep" and active:
|
|
607
|
+
cell.note(
|
|
608
|
+
"variant_active_redundant",
|
|
609
|
+
Severity.INFO,
|
|
610
|
+
f"group `{group}` is a sweep, so its active selection is ignored",
|
|
611
|
+
annotation="variant",
|
|
612
|
+
line=line,
|
|
613
|
+
)
|
|
614
|
+
if mode != "sweep" and active:
|
|
615
|
+
names = [name for _, name in members[group].members]
|
|
616
|
+
if active not in names:
|
|
617
|
+
cell.note(
|
|
618
|
+
"variant_active_unknown",
|
|
619
|
+
Severity.WARN,
|
|
620
|
+
f"group `{group}` selects `{active}`, which no cell provides",
|
|
621
|
+
annotation="variant",
|
|
622
|
+
line=line,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _wiring_phase(cells: list[CellAnalysis], groups: GroupConfig) -> Dag:
|
|
627
|
+
"""Phase 4: every reference to its latest earlier producer (``NotebookDag.from_cells``)."""
|
|
628
|
+
dag = Dag()
|
|
629
|
+
cell_ids = [c.id for c in cells]
|
|
630
|
+
dag.variant_groups, dag.inactive_cells = _resolve_groups(cells, groups)
|
|
631
|
+
inactive = dag.inactive_cells
|
|
632
|
+
|
|
633
|
+
cell_by_id = {c.id: c for c in cells}
|
|
634
|
+
sweep_producer_for_var: dict[str, SweepProducer] = {}
|
|
635
|
+
sweep_member_group: dict[str, str] = {}
|
|
636
|
+
for res in dag.variant_groups:
|
|
637
|
+
if res.mode != "sweep":
|
|
638
|
+
continue
|
|
639
|
+
for cid, _ in res.members:
|
|
640
|
+
sweep_member_group[cid] = res.group
|
|
641
|
+
var_members: dict[str, list[tuple[str, str]]] = {}
|
|
642
|
+
for cid, name in res.members:
|
|
643
|
+
for var in cell_by_id[cid].defines:
|
|
644
|
+
var_members.setdefault(var, []).append((name, cid))
|
|
645
|
+
for var, members in var_members.items():
|
|
646
|
+
sweep_producer_for_var[var] = SweepProducer(
|
|
647
|
+
group=res.group, variants=tuple(sorted(members))
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
sweep_group_variant_names: dict[str, tuple[str, ...]] = {
|
|
651
|
+
res.group: tuple(sorted(name for _, name in res.members))
|
|
652
|
+
for res in dag.variant_groups
|
|
653
|
+
if res.mode == "sweep"
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
for cell_id in cell_ids:
|
|
657
|
+
dag.cell_upstream[cell_id] = []
|
|
658
|
+
dag.cell_downstream[cell_id] = []
|
|
659
|
+
dag.consumed_variables[cell_id] = set()
|
|
660
|
+
|
|
661
|
+
# Wire each reference to the producer before this cell, then register this cell's
|
|
662
|
+
# defines: a mutating cell reads the prior value and produces the new one.
|
|
663
|
+
active_ids = set(cell_ids) - inactive
|
|
664
|
+
for cell in cells:
|
|
665
|
+
if cell.id in inactive:
|
|
666
|
+
continue
|
|
667
|
+
for var in (*cell.references, *cell.builtin_references):
|
|
668
|
+
if var in cell.seeds:
|
|
669
|
+
continue
|
|
670
|
+
producer = dag.variable_producer.get(var)
|
|
671
|
+
if producer is None or producer == cell.id:
|
|
672
|
+
continue
|
|
673
|
+
if isinstance(producer, SweepProducer):
|
|
674
|
+
if cell.id in {cid for _, cid in producer.variants}:
|
|
675
|
+
# A member refining its own group's variable does not read its siblings.
|
|
676
|
+
continue
|
|
677
|
+
dag.read_producers.setdefault(cell.id, {})[var] = producer
|
|
678
|
+
if isinstance(producer, SweepProducer):
|
|
679
|
+
for _name, member_id in producer.variants:
|
|
680
|
+
_wire(dag, member_id, cell.id, var)
|
|
681
|
+
else:
|
|
682
|
+
_wire(dag, producer, cell.id, var)
|
|
683
|
+
|
|
684
|
+
for name, seed_id in cell.seeds.items():
|
|
685
|
+
seed = cell_by_id.get(seed_id)
|
|
686
|
+
if seed is None:
|
|
687
|
+
cell.error("seed_unknown_cell", f"`{name}` is seeded from {seed_id!r}, no cell")
|
|
688
|
+
elif seed_id == cell.id:
|
|
689
|
+
cell.error("seed_unknown_cell", f"`{name}` is seeded from the cell itself")
|
|
690
|
+
elif seed_id in inactive:
|
|
691
|
+
cell.error(
|
|
692
|
+
"seed_unknown_cell", f"`{name}` is seeded from {seed_id}, an inactive variant"
|
|
693
|
+
)
|
|
694
|
+
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
|
+
)
|
|
699
|
+
else:
|
|
700
|
+
dag.read_producers.setdefault(cell.id, {})[name] = seed_id
|
|
701
|
+
_wire(dag, seed_id, cell.id, name)
|
|
702
|
+
|
|
703
|
+
dag.consumed_variables[cell.id].update(v for v in cell.kept if v in cell.defines)
|
|
704
|
+
|
|
705
|
+
for upstream_id in cell.after:
|
|
706
|
+
line = _first_line(cell.directives, "after")
|
|
707
|
+
if upstream_id == cell.id:
|
|
708
|
+
cell.note(
|
|
709
|
+
"after_self",
|
|
710
|
+
Severity.WARN,
|
|
711
|
+
"`@after` names this cell",
|
|
712
|
+
annotation="after",
|
|
713
|
+
line=line,
|
|
714
|
+
)
|
|
715
|
+
continue
|
|
716
|
+
if upstream_id not in cell_by_id:
|
|
717
|
+
cell.error(
|
|
718
|
+
"after_unknown_cell",
|
|
719
|
+
f"`@after {upstream_id}` names no cell",
|
|
720
|
+
annotation="after",
|
|
721
|
+
line=line,
|
|
722
|
+
)
|
|
723
|
+
continue
|
|
724
|
+
if upstream_id not in active_ids:
|
|
725
|
+
cell.error(
|
|
726
|
+
"after_inactive_variant",
|
|
727
|
+
f"`@after {upstream_id}` names a variant that is not active",
|
|
728
|
+
annotation="after",
|
|
729
|
+
line=line,
|
|
730
|
+
)
|
|
731
|
+
continue
|
|
732
|
+
dag.edges.append(DagEdge(from_cell_id=upstream_id, to_cell_id=cell.id, variable=""))
|
|
733
|
+
if upstream_id not in dag.cell_upstream[cell.id]:
|
|
734
|
+
dag.cell_upstream[cell.id].append(upstream_id)
|
|
735
|
+
if cell.id not in dag.cell_downstream[upstream_id]:
|
|
736
|
+
dag.cell_downstream[upstream_id].append(cell.id)
|
|
737
|
+
|
|
738
|
+
is_sweep_member = cell.id in sweep_member_group
|
|
739
|
+
fanout_group: str | None = None
|
|
740
|
+
if cell.fanout is not None:
|
|
741
|
+
read_groups = dag.sweep_groups_read(cell.id)
|
|
742
|
+
named = cell.fanout.group
|
|
743
|
+
if named is not None:
|
|
744
|
+
fanout_group = named if named in read_groups else None
|
|
745
|
+
else:
|
|
746
|
+
fanout_group = next(iter(read_groups)) if len(read_groups) == 1 else None
|
|
747
|
+
_fanout_notes(cell, read_groups, sweep_group_variant_names)
|
|
748
|
+
if fanout_group is not None and not is_sweep_member:
|
|
749
|
+
dag.fanout_cells[cell.id] = SweepProducer(
|
|
750
|
+
group=fanout_group,
|
|
751
|
+
variants=tuple(
|
|
752
|
+
sorted((name, cell.id) for name in sweep_group_variant_names[fanout_group])
|
|
753
|
+
),
|
|
754
|
+
fanout_cell=cell.id,
|
|
755
|
+
)
|
|
756
|
+
for var in cell.defines:
|
|
757
|
+
if is_sweep_member and var in sweep_producer_for_var:
|
|
758
|
+
new_producer: str | SweepProducer = sweep_producer_for_var[var]
|
|
759
|
+
elif cell.id in dag.fanout_cells:
|
|
760
|
+
new_producer = dag.fanout_cells[cell.id]
|
|
761
|
+
else:
|
|
762
|
+
new_producer = cell.id
|
|
763
|
+
previous = dag.variable_producer.get(var)
|
|
764
|
+
if previous is not None and previous != new_producer:
|
|
765
|
+
short = (previous.group if isinstance(previous, SweepProducer) else previous)[:8]
|
|
766
|
+
dag.shadow_warnings.setdefault(cell.id, []).append(
|
|
767
|
+
f"Variable '{var}' shadows definition from cell {short}"
|
|
768
|
+
)
|
|
769
|
+
dag.variable_producer[var] = new_producer
|
|
770
|
+
|
|
771
|
+
active_cell_ids = [cid for cid in cell_ids if cid not in inactive]
|
|
772
|
+
for cell_id in active_cell_ids:
|
|
773
|
+
if not dag.cell_downstream[cell_id]:
|
|
774
|
+
dag.leaves.add(cell_id)
|
|
775
|
+
if not dag.cell_upstream[cell_id]:
|
|
776
|
+
dag.roots.add(cell_id)
|
|
777
|
+
|
|
778
|
+
on_cycle = _on_cycles(dag, active_cell_ids)
|
|
779
|
+
if on_cycle:
|
|
780
|
+
cycles = _cycles(dag, active_cell_ids)
|
|
781
|
+
message = "Cycle detected in DAG: " + (" → ".join(cycles[0]) if cycles else "unknown")
|
|
782
|
+
for cid in active_cell_ids:
|
|
783
|
+
if cid in on_cycle:
|
|
784
|
+
cell_by_id[cid].error("cycle", message)
|
|
785
|
+
dag.topological_order = _kahn(dag, active_cell_ids)
|
|
786
|
+
return dag
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def _fanout_notes(
|
|
790
|
+
cell: CellAnalysis, read_groups: set[str], sizes: Mapping[str, tuple[str, ...]]
|
|
791
|
+
) -> None:
|
|
792
|
+
"""Strata's ``@per_variant`` warnings, against the sweep groups the cell reads."""
|
|
793
|
+
assert cell.fanout is not None
|
|
794
|
+
line = _first_line(cell.directives, "per_variant")
|
|
795
|
+
if cell.member is not None:
|
|
796
|
+
cell.note(
|
|
797
|
+
"per_variant_on_variant_member",
|
|
798
|
+
Severity.WARN,
|
|
799
|
+
"a variant member cannot also fan out over a group",
|
|
800
|
+
annotation="per_variant",
|
|
801
|
+
line=line,
|
|
802
|
+
)
|
|
803
|
+
return
|
|
804
|
+
named = cell.fanout.group
|
|
805
|
+
if named is not None:
|
|
806
|
+
if named not in read_groups:
|
|
807
|
+
cell.note(
|
|
808
|
+
"per_variant_unknown_group",
|
|
809
|
+
Severity.WARN,
|
|
810
|
+
f"`@per_variant {named}` names no sweep group the cell reads",
|
|
811
|
+
annotation="per_variant",
|
|
812
|
+
line=line,
|
|
813
|
+
)
|
|
814
|
+
return
|
|
815
|
+
effective = named
|
|
816
|
+
else:
|
|
817
|
+
if not read_groups:
|
|
818
|
+
cell.note(
|
|
819
|
+
"per_variant_no_sweep_source",
|
|
820
|
+
Severity.WARN,
|
|
821
|
+
"`@per_variant` reads no sweep-produced variable",
|
|
822
|
+
annotation="per_variant",
|
|
823
|
+
line=line,
|
|
824
|
+
)
|
|
825
|
+
return
|
|
826
|
+
if len(read_groups) > 1:
|
|
827
|
+
cell.note(
|
|
828
|
+
"per_variant_ambiguous_group",
|
|
829
|
+
Severity.WARN,
|
|
830
|
+
f"`@per_variant` reads {', '.join(sorted(read_groups))}; name one",
|
|
831
|
+
annotation="per_variant",
|
|
832
|
+
line=line,
|
|
833
|
+
)
|
|
834
|
+
return
|
|
835
|
+
effective = next(iter(read_groups))
|
|
836
|
+
if len(sizes.get(effective, ())) == 1:
|
|
837
|
+
cell.note(
|
|
838
|
+
"per_variant_group_of_one",
|
|
839
|
+
Severity.INFO,
|
|
840
|
+
f"group `{effective}` has one variant",
|
|
841
|
+
annotation="per_variant",
|
|
842
|
+
line=line,
|
|
843
|
+
)
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _restrict(graph: Dag, keep: set[str]) -> Dag:
|
|
847
|
+
"""*graph* over the cells in *keep* only."""
|
|
848
|
+
ids = [cid for cid in graph.cell_upstream if cid in keep]
|
|
849
|
+
dag = Dag(
|
|
850
|
+
edges=[e for e in graph.edges if e.from_cell_id in keep and e.to_cell_id in keep],
|
|
851
|
+
cell_upstream={cid: [u for u in graph.cell_upstream[cid] if u in keep] for cid in ids},
|
|
852
|
+
cell_downstream={cid: [d for d in graph.cell_downstream[cid] if d in keep] for cid in ids},
|
|
853
|
+
variable_producer=dict(graph.variable_producer),
|
|
854
|
+
consumed_variables={cid: set(graph.consumed_variables[cid]) for cid in ids},
|
|
855
|
+
shadow_warnings={c: w for c, w in graph.shadow_warnings.items() if c in keep},
|
|
856
|
+
variant_groups=list(graph.variant_groups),
|
|
857
|
+
inactive_cells=set(graph.inactive_cells),
|
|
858
|
+
wired_inputs={
|
|
859
|
+
c: {p: set(v) for p, v in ins.items() if p in keep}
|
|
860
|
+
for c, ins in graph.wired_inputs.items()
|
|
861
|
+
if c in keep
|
|
862
|
+
},
|
|
863
|
+
fanout_cells={c: p for c, p in graph.fanout_cells.items() if c in keep},
|
|
864
|
+
read_producers={c: dict(r) for c, r in graph.read_producers.items() if c in keep},
|
|
865
|
+
)
|
|
866
|
+
active = [cid for cid in ids if cid not in graph.inactive_cells]
|
|
867
|
+
dag.leaves = {cid for cid in active if not dag.cell_downstream[cid]}
|
|
868
|
+
dag.roots = {cid for cid in active if not dag.cell_upstream[cid]}
|
|
869
|
+
dag.topological_order = _kahn(dag, active)
|
|
870
|
+
return dag
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
# --- the result -------------------------------------------------------------------
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
@dataclass
|
|
877
|
+
class Analysis:
|
|
878
|
+
"""What ``analyze`` returns."""
|
|
879
|
+
|
|
880
|
+
cells: dict[str, CellAnalysis]
|
|
881
|
+
graph: Dag
|
|
882
|
+
dag: Dag
|
|
883
|
+
vocabulary: Vocabulary
|
|
884
|
+
analyzers: dict[str, LanguageAnalyzer]
|
|
885
|
+
groups: GroupConfig
|
|
886
|
+
normalization: str
|
|
887
|
+
digest: str
|
|
888
|
+
|
|
889
|
+
@property
|
|
890
|
+
def diagnostics(self) -> list[Diagnostic]:
|
|
891
|
+
return [d for cell in self.cells.values() for d in cell.diagnostics]
|
|
892
|
+
|
|
893
|
+
@property
|
|
894
|
+
def structural_errors(self) -> list[Diagnostic]:
|
|
895
|
+
return [d for d in self.diagnostics if d.code in STRUCTURAL_CODES]
|
|
896
|
+
|
|
897
|
+
def blocking(self, cell_id: str) -> list[Diagnostic]:
|
|
898
|
+
"""The error diagnostics that keep *cell_id* from planning."""
|
|
899
|
+
return [
|
|
900
|
+
d
|
|
901
|
+
for blocker in self.cells[cell_id].blocked_by
|
|
902
|
+
for d in self.cells[blocker].diagnostics
|
|
903
|
+
if d.severity == Severity.ERROR
|
|
904
|
+
]
|
|
905
|
+
|
|
906
|
+
def execution_key(self, cell_id: str, **kwargs: Any) -> Any:
|
|
907
|
+
"""See ``cellgraph.keys.execution_key``."""
|
|
908
|
+
from cellgraph.keys import execution_key
|
|
909
|
+
|
|
910
|
+
return execution_key(self, cell_id, **kwargs)
|
|
911
|
+
|
|
912
|
+
def plan(self, target: str, ready: Callable[[str], bool]) -> list[str]:
|
|
913
|
+
"""See ``cellgraph.plan.plan``."""
|
|
914
|
+
from cellgraph.plan import plan
|
|
915
|
+
|
|
916
|
+
return plan(self, target, ready)
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def analyze(
|
|
920
|
+
cells: Iterable[Cell],
|
|
921
|
+
vocabulary: Vocabulary,
|
|
922
|
+
groups: GroupConfig | None = None,
|
|
923
|
+
normalization: str = NORMALIZE_LEGACY_1,
|
|
924
|
+
digest: str = DIGEST_LEGACY_1,
|
|
925
|
+
strict: bool = False,
|
|
926
|
+
*,
|
|
927
|
+
analyzers: Mapping[str, LanguageAnalyzer] | None = None,
|
|
928
|
+
context: Any = None,
|
|
929
|
+
) -> Analysis:
|
|
930
|
+
"""Analyze *cells*, given in source order.
|
|
931
|
+
|
|
932
|
+
``analyzers`` maps a language to its analyzer; the registry when omitted.
|
|
933
|
+
``context`` is passed to every validator as ``ValidationContext.host``. With
|
|
934
|
+
``strict=True`` an unknown directive key is an error rather than a warning.
|
|
935
|
+
"""
|
|
936
|
+
from cellgraph.keys import definition_key
|
|
937
|
+
|
|
938
|
+
if normalization not in (NORMALIZE_LEGACY_1, NORMALIZE_CELLGRAPH_1):
|
|
939
|
+
raise ValueError(f"unknown normalization {normalization!r}")
|
|
940
|
+
if digest not in (DIGEST_LEGACY_1, DIGEST_CELLGRAPH_1):
|
|
941
|
+
raise ValueError(f"unknown digest {digest!r}")
|
|
942
|
+
table = dict(analyzers) if analyzers is not None else registered_analyzers()
|
|
943
|
+
groups = groups or GroupConfig()
|
|
944
|
+
cell_list = list(cells)
|
|
945
|
+
|
|
946
|
+
analyses: list[CellAnalysis] = []
|
|
947
|
+
for cell in cell_list:
|
|
948
|
+
if cell.language not in table:
|
|
949
|
+
from cellgraph.languages import UnknownLanguageError
|
|
950
|
+
|
|
951
|
+
raise UnknownLanguageError(f"no language analyzer for {cell.language!r}")
|
|
952
|
+
ca, analyzed = _parse_phase(cell, vocabulary, table[cell.language], strict)
|
|
953
|
+
_names_phase(ca, analyzed)
|
|
954
|
+
analyses.append(ca)
|
|
955
|
+
|
|
956
|
+
for cell, ca in zip(cell_list, analyses, strict=True):
|
|
957
|
+
for annotation in vocabulary:
|
|
958
|
+
if annotation.validate is None or annotation.key not in ca.annotations:
|
|
959
|
+
continue
|
|
960
|
+
context_view = ValidationContext(
|
|
961
|
+
cell=cell, defines=tuple(ca.defines), references=tuple(ca.references), host=context
|
|
962
|
+
)
|
|
963
|
+
try:
|
|
964
|
+
ca.diagnostics.extend(
|
|
965
|
+
annotation.validate(ca.annotations[annotation.key], context_view)
|
|
966
|
+
)
|
|
967
|
+
except (AnnotationError, ExceptionGroup) as raised:
|
|
968
|
+
first = next((d.line for d in ca.directives if d.key in annotation.keys), None)
|
|
969
|
+
for exc in _annotation_errors(raised):
|
|
970
|
+
ca.error(
|
|
971
|
+
exc.code or f"{annotation.key}_invalid",
|
|
972
|
+
str(exc),
|
|
973
|
+
annotation=annotation.key,
|
|
974
|
+
line=exc.line if exc.line is not None else first,
|
|
975
|
+
)
|
|
976
|
+
|
|
977
|
+
graph = _wiring_phase(analyses, groups)
|
|
978
|
+
_variant_notes(analyses, graph, groups)
|
|
979
|
+
|
|
980
|
+
by_id = {ca.id: ca for ca in analyses}
|
|
981
|
+
for ca in analyses:
|
|
982
|
+
if ca.has_error:
|
|
983
|
+
ca.plannable = False
|
|
984
|
+
ca.blocked_by = [ca.id]
|
|
985
|
+
for blocker in [ca.id for ca in analyses if not ca.plannable]:
|
|
986
|
+
queue: deque[str] = deque(graph.cell_downstream.get(blocker, []))
|
|
987
|
+
seen: set[str] = set()
|
|
988
|
+
while queue:
|
|
989
|
+
current = queue.popleft()
|
|
990
|
+
if current in seen:
|
|
991
|
+
continue
|
|
992
|
+
seen.add(current)
|
|
993
|
+
target = by_id[current]
|
|
994
|
+
target.plannable = False
|
|
995
|
+
if blocker not in target.blocked_by:
|
|
996
|
+
target.blocked_by.append(blocker)
|
|
997
|
+
queue.extend(graph.cell_downstream.get(current, []))
|
|
998
|
+
|
|
999
|
+
plannable = {ca.id for ca in analyses if ca.plannable}
|
|
1000
|
+
analysis = Analysis(
|
|
1001
|
+
cells=by_id,
|
|
1002
|
+
graph=graph,
|
|
1003
|
+
dag=_restrict(graph, plannable),
|
|
1004
|
+
vocabulary=vocabulary,
|
|
1005
|
+
analyzers=table,
|
|
1006
|
+
groups=groups,
|
|
1007
|
+
normalization=normalization,
|
|
1008
|
+
digest=digest,
|
|
1009
|
+
)
|
|
1010
|
+
for ca in analyses:
|
|
1011
|
+
if ca.plannable:
|
|
1012
|
+
ca.normalized, ca.definition_key = definition_key(analysis, ca)
|
|
1013
|
+
return analysis
|