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/keys.py
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""The definition key and the execution key (design 4.2, 4.3, 7).
|
|
2
|
+
|
|
3
|
+
Two independent axes: the **normalization** decides the text a definition key is
|
|
4
|
+
built from, the **digest** decides the bytes hashed. ``legacy-1`` on both is
|
|
5
|
+
Strata's format, byte for byte; ``cellgraph-1`` folds the format name into
|
|
6
|
+
canonical JSON.
|
|
7
|
+
|
|
8
|
+
``execution_key`` returns ``Keyed``, ``Unavailable`` or ``HostKeyed``, each with
|
|
9
|
+
``parts``: the key's components by stable address, versioned by
|
|
10
|
+
``PARTS_SCHEMA`` apart from the digest format (13.1).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
from collections.abc import Callable, Mapping
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import TYPE_CHECKING, Any
|
|
20
|
+
|
|
21
|
+
from cellgraph import python
|
|
22
|
+
from cellgraph.effects import Input
|
|
23
|
+
from cellgraph.languages import directive_keys
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from cellgraph.analysis import Analysis, CellAnalysis
|
|
27
|
+
|
|
28
|
+
PARTS_SCHEMA = 1
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def derive_subkey(parent_hash: str, *labels: str) -> str:
|
|
32
|
+
"""``sha256("parent:label1:label2:...")``, byte-identical to vestigo's.
|
|
33
|
+
|
|
34
|
+
Labels are joined by ``:`` with no escaping, so a label must not contain one.
|
|
35
|
+
"""
|
|
36
|
+
for label in labels:
|
|
37
|
+
if ":" in label:
|
|
38
|
+
raise ValueError(f"label {label!r} contains ':', the label separator")
|
|
39
|
+
return hashlib.sha256(":".join([parent_hash, *labels]).encode()).hexdigest()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compute_provenance_hash(inputs: list[str], source_hash: str, env_hash: str) -> str:
|
|
43
|
+
"""The legacy execution digest: sorted inputs, the source hash and the env hash."""
|
|
44
|
+
hasher = hashlib.sha256()
|
|
45
|
+
for component in sorted(inputs):
|
|
46
|
+
hasher.update(component.encode("utf-8"))
|
|
47
|
+
hasher.update(b"\x00")
|
|
48
|
+
hasher.update(source_hash.encode("utf-8"))
|
|
49
|
+
hasher.update(b"\x00")
|
|
50
|
+
hasher.update(env_hash.encode("utf-8"))
|
|
51
|
+
return hasher.hexdigest()
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def canonical_json(value: Any) -> str:
|
|
55
|
+
"""Sorted keys, no whitespace, UTF-8 as is, no NaN."""
|
|
56
|
+
return json.dumps(
|
|
57
|
+
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _sha256(text: str) -> str:
|
|
62
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --- what the host supplies -------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class Provenance:
|
|
70
|
+
"""An output filed under a provenance hash."""
|
|
71
|
+
|
|
72
|
+
hash: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class Run:
|
|
77
|
+
"""An output of a run that had no reusable key (4.3), named by the run."""
|
|
78
|
+
|
|
79
|
+
id: str
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class OutputRef:
|
|
84
|
+
"""One published output: its identity, and its content digest when known."""
|
|
85
|
+
|
|
86
|
+
identity: Provenance | Run
|
|
87
|
+
content_digest: str | None = None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class Published:
|
|
92
|
+
"""What an upstream cell published (7.3).
|
|
93
|
+
|
|
94
|
+
- not published: the variable is absent from ``outputs``;
|
|
95
|
+
- published but unreadable: present as ``Unavailable``;
|
|
96
|
+
- the older cell-level representation: ``outputs`` empty and ``cell_ref`` set;
|
|
97
|
+
- a fan-out cell: each variable in ``outputs`` (any instance's ref, as Strata keeps
|
|
98
|
+
the last one stored) and each ``(variable, variant)`` in ``instances``. A
|
|
99
|
+
variable missing from ``outputs`` is not read, whatever ``instances`` holds.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
outputs: Mapping[str, OutputRef | Unavailable] = field(default_factory=dict)
|
|
103
|
+
instances: Mapping[tuple[str, str], OutputRef | Unavailable] = field(default_factory=dict)
|
|
104
|
+
cell_ref: OutputRef | Unavailable | None = None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True)
|
|
108
|
+
class Resolved:
|
|
109
|
+
"""A resolver's answer for one input.
|
|
110
|
+
|
|
111
|
+
``identity`` names what is read and ``fingerprint`` its current content; under
|
|
112
|
+
the ``legacy-1`` digest the component is ``legacy``, the host's own string.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
identity: Any
|
|
116
|
+
fingerprint: Any
|
|
117
|
+
legacy: str | None = None
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# --- what execution_key returns ---------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class Keyed:
|
|
125
|
+
digest: str
|
|
126
|
+
parts: dict[str, Any]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class Unavailable:
|
|
131
|
+
"""No reusable key: an input or an upstream output could not be resolved.
|
|
132
|
+
|
|
133
|
+
Also what a resolver or a ``Published`` entry returns for one it cannot read.
|
|
134
|
+
As a result, ``parts`` holds what could be built and ``unresolved`` the addresses
|
|
135
|
+
that could not.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
reason: str = ""
|
|
139
|
+
parts: dict[str, Any] | None = None
|
|
140
|
+
unresolved: tuple[str, ...] = ()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(frozen=True)
|
|
144
|
+
class HostKeyed:
|
|
145
|
+
"""The host computes this cell's key (``owns_key=False``)."""
|
|
146
|
+
|
|
147
|
+
parts: dict[str, Any] | None = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --- the definition key -----------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def definition_key(analysis: Analysis, cell: CellAnalysis) -> tuple[str, str]:
|
|
154
|
+
"""``(normalized text, definition key)`` for *cell*."""
|
|
155
|
+
if analysis.normalization == "legacy-1":
|
|
156
|
+
# Strata's compute_source_hash body: the whole source, whatever the language.
|
|
157
|
+
normalized = python.normalize(cell.source)
|
|
158
|
+
else:
|
|
159
|
+
analyzer = analysis.analyzers[cell.language]
|
|
160
|
+
body = analysis.vocabulary.grammar.strip_directives(
|
|
161
|
+
cell.source, keys=directive_keys(analyzer)
|
|
162
|
+
)
|
|
163
|
+
normalized = analyzer.normalize(body)
|
|
164
|
+
fields = cell.key_fields
|
|
165
|
+
if analysis.digest == "legacy-1":
|
|
166
|
+
text = normalized
|
|
167
|
+
if fields:
|
|
168
|
+
text += "\x00" + json.dumps(fields, sort_keys=True, separators=(",", ":"))
|
|
169
|
+
return normalized, _sha256(text)
|
|
170
|
+
return normalized, _sha256(canonical_json(["cellgraph-1", "definition", normalized, fields]))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --- the execution key ------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True)
|
|
177
|
+
class _VariantRead:
|
|
178
|
+
variants: tuple[str, ...]
|
|
179
|
+
scalar: bool
|
|
180
|
+
fanout: bool
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _variant_read(
|
|
184
|
+
analysis: Analysis, cell_id: str, upstream_id: str, var: str, instance: str | None
|
|
185
|
+
) -> _VariantRead | None:
|
|
186
|
+
"""What *cell_id* (or its *instance*) reads of *var* from *upstream_id*, if a sweep makes it."""
|
|
187
|
+
from cellgraph.analysis import SweepProducer
|
|
188
|
+
|
|
189
|
+
graph = analysis.graph
|
|
190
|
+
producer = graph.read_producer(cell_id, var)
|
|
191
|
+
if not isinstance(producer, SweepProducer):
|
|
192
|
+
return None
|
|
193
|
+
own_fanout = graph.fanout_cells.get(cell_id)
|
|
194
|
+
own = instance is not None and own_fanout is not None and producer.group == own_fanout.group
|
|
195
|
+
if producer.fanout_cell is not None:
|
|
196
|
+
if producer.fanout_cell != upstream_id:
|
|
197
|
+
return None
|
|
198
|
+
names = (
|
|
199
|
+
(instance,) if own and instance is not None else tuple(n for n, _ in producer.variants)
|
|
200
|
+
)
|
|
201
|
+
return _VariantRead(variants=names, scalar=own, fanout=True)
|
|
202
|
+
member = next((name for name, cid in producer.variants if cid == upstream_id), None)
|
|
203
|
+
if member is None:
|
|
204
|
+
return None
|
|
205
|
+
if own:
|
|
206
|
+
return _VariantRead(
|
|
207
|
+
variants=(member,) if member == instance else (), scalar=True, fanout=False
|
|
208
|
+
)
|
|
209
|
+
return _VariantRead(variants=(member,), scalar=False, fanout=False)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class _Assembly:
|
|
213
|
+
"""Upstream components, by address, as Strata's ``_collect_input_hashes`` builds them."""
|
|
214
|
+
|
|
215
|
+
def __init__(self, legacy: bool):
|
|
216
|
+
self.legacy = legacy
|
|
217
|
+
self.components: list[tuple[str, str]] = []
|
|
218
|
+
self.unresolved: list[str] = []
|
|
219
|
+
|
|
220
|
+
def component(self, ref: Any, by_content: bool, address: str) -> str | None:
|
|
221
|
+
"""One output's component, or None when the legacy format skips it."""
|
|
222
|
+
if isinstance(ref, Unavailable) or ref is None:
|
|
223
|
+
if self.legacy:
|
|
224
|
+
return None
|
|
225
|
+
self.unresolved.append(address)
|
|
226
|
+
return None
|
|
227
|
+
if isinstance(ref.identity, Run):
|
|
228
|
+
if ref.content_digest is not None:
|
|
229
|
+
return f"content:{ref.content_digest}"
|
|
230
|
+
return f"run:{ref.identity.id}"
|
|
231
|
+
provenance = ref.identity.hash
|
|
232
|
+
if not by_content:
|
|
233
|
+
return provenance
|
|
234
|
+
if ref.content_digest is None:
|
|
235
|
+
if self.legacy:
|
|
236
|
+
return provenance
|
|
237
|
+
self.unresolved.append(address)
|
|
238
|
+
return None
|
|
239
|
+
return derive_subkey(provenance, f"content={ref.content_digest}")
|
|
240
|
+
|
|
241
|
+
def add(self, address: str, value: str) -> None:
|
|
242
|
+
self.components.append((address, value))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _upstream(
|
|
246
|
+
analysis: Analysis,
|
|
247
|
+
cell_id: str,
|
|
248
|
+
instance: str | None,
|
|
249
|
+
published: Callable[[str], Published],
|
|
250
|
+
) -> _Assembly:
|
|
251
|
+
graph = analysis.graph
|
|
252
|
+
cell = analysis.cells[cell_id]
|
|
253
|
+
assembly = _Assembly(legacy=analysis.digest == "legacy-1")
|
|
254
|
+
upstream_ids = graph.cell_upstream.get(cell_id, [])
|
|
255
|
+
if not upstream_ids:
|
|
256
|
+
return assembly
|
|
257
|
+
|
|
258
|
+
reads = set(cell.references) | set(cell.builtin_references)
|
|
259
|
+
# A name several upstreams define is read from its wired producer only.
|
|
260
|
+
shadowed: set[str] = set()
|
|
261
|
+
for upstream_id in upstream_ids:
|
|
262
|
+
upstream = analysis.cells.get(upstream_id)
|
|
263
|
+
if upstream is not None:
|
|
264
|
+
shadowed |= (reads & set(upstream.defines)) - graph.wired_variables(
|
|
265
|
+
cell_id, upstream_id
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
sweep_buckets: dict[str, list[tuple[str, str]]] = {}
|
|
269
|
+
for upstream_id in upstream_ids:
|
|
270
|
+
upstream = analysis.cells.get(upstream_id)
|
|
271
|
+
if upstream is None:
|
|
272
|
+
continue
|
|
273
|
+
by_content = upstream.nocache
|
|
274
|
+
wired = graph.wired_variables(cell_id, upstream_id) if shadowed else set()
|
|
275
|
+
snapshot = published(upstream_id)
|
|
276
|
+
|
|
277
|
+
items: list[tuple[str | None, Any]] = list(snapshot.outputs.items())
|
|
278
|
+
if not items and snapshot.cell_ref is not None:
|
|
279
|
+
items = [(None, snapshot.cell_ref)]
|
|
280
|
+
|
|
281
|
+
for var, ref in items:
|
|
282
|
+
if var is None:
|
|
283
|
+
value = assembly.component(ref, by_content, f"cell:{upstream_id}")
|
|
284
|
+
if value is not None:
|
|
285
|
+
assembly.add(f"cell:{upstream_id}", value)
|
|
286
|
+
continue
|
|
287
|
+
if by_content and var not in reads:
|
|
288
|
+
continue
|
|
289
|
+
read = _variant_read(analysis, cell_id, upstream_id, var, instance)
|
|
290
|
+
if read is not None and read.fanout:
|
|
291
|
+
parts = []
|
|
292
|
+
for name in read.variants:
|
|
293
|
+
instance_ref = snapshot.instances.get((var, name))
|
|
294
|
+
if instance_ref is None:
|
|
295
|
+
continue # not run yet
|
|
296
|
+
value = assembly.component(
|
|
297
|
+
instance_ref, by_content, f"output:{upstream_id}.{var}@{name}"
|
|
298
|
+
)
|
|
299
|
+
parts.append(f"{name}={value}")
|
|
300
|
+
prefix = "variant" if read.scalar else "fanout"
|
|
301
|
+
assembly.add(
|
|
302
|
+
f"instances:{upstream_id}.{var}", f"{prefix}:{var}:{';'.join(sorted(parts))}"
|
|
303
|
+
)
|
|
304
|
+
continue
|
|
305
|
+
if read is not None and not read.variants:
|
|
306
|
+
continue # another variant's member
|
|
307
|
+
value = assembly.component(ref, by_content, f"output:{upstream_id}.{var}")
|
|
308
|
+
if value is None:
|
|
309
|
+
continue
|
|
310
|
+
if read is not None:
|
|
311
|
+
for name in read.variants:
|
|
312
|
+
if read.scalar:
|
|
313
|
+
assembly.add(
|
|
314
|
+
f"variant:{upstream_id}.{var}@{name}", f"variant:{var}:{name}={value}"
|
|
315
|
+
)
|
|
316
|
+
else:
|
|
317
|
+
sweep_buckets.setdefault(var, []).append((name, value))
|
|
318
|
+
continue
|
|
319
|
+
assembly.add(f"output:{upstream_id}.{var}", value)
|
|
320
|
+
if var in shadowed and var in wired:
|
|
321
|
+
assembly.add(f"wired:{upstream_id}.{var}", f"wired:{var}={value}")
|
|
322
|
+
|
|
323
|
+
for var, pairs in sweep_buckets.items():
|
|
324
|
+
joined = ";".join(f"{name}={value}" for name, value in sorted(pairs))
|
|
325
|
+
assembly.add(f"sweep:{var}", f"sweep:{var}:{joined}")
|
|
326
|
+
return assembly
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
Resolver = Callable[[Input], "Resolved | Unavailable"]
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def execution_key(
|
|
333
|
+
analysis: Analysis,
|
|
334
|
+
cell_id: str,
|
|
335
|
+
*,
|
|
336
|
+
instance: str | None = None,
|
|
337
|
+
published: Callable[[str], Published],
|
|
338
|
+
resolve: Mapping[str, Resolver] | None = None,
|
|
339
|
+
env_hash: str,
|
|
340
|
+
) -> Keyed | Unavailable | HostKeyed:
|
|
341
|
+
"""The key that says whether *cell_id* (or one fan-out *instance*) must run.
|
|
342
|
+
|
|
343
|
+
``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.
|
|
345
|
+
"""
|
|
346
|
+
from cellgraph.analysis import NotPlannableError
|
|
347
|
+
|
|
348
|
+
cell = analysis.cells[cell_id]
|
|
349
|
+
if not cell.plannable:
|
|
350
|
+
raise NotPlannableError(cell_id, analysis.blocking(cell_id))
|
|
351
|
+
if not analysis.analyzers[cell.language].owns_key:
|
|
352
|
+
return HostKeyed()
|
|
353
|
+
fanout = analysis.graph.fanout_cells.get(cell_id)
|
|
354
|
+
if instance is not None:
|
|
355
|
+
if fanout is None:
|
|
356
|
+
raise ValueError(f"cell {cell_id} does not fan out, so it has no instance {instance!r}")
|
|
357
|
+
if instance not in {name for name, _ in fanout.variants}:
|
|
358
|
+
raise ValueError(f"cell {cell_id} has no instance {instance!r}")
|
|
359
|
+
|
|
360
|
+
legacy = analysis.digest == "legacy-1"
|
|
361
|
+
assembly = _upstream(analysis, cell_id, instance, published)
|
|
362
|
+
inputs: list[tuple[str, str, str]] = []
|
|
363
|
+
resolvers = resolve or {}
|
|
364
|
+
for spec in cell.inputs:
|
|
365
|
+
if spec.kind not in resolvers:
|
|
366
|
+
raise ValueError(f"no resolver for {spec.kind!r} inputs (cell {cell_id})")
|
|
367
|
+
result = resolvers[spec.kind](spec)
|
|
368
|
+
address = f"input:{spec.kind}:{spec.name}"
|
|
369
|
+
if isinstance(result, Unavailable):
|
|
370
|
+
assembly.unresolved.append(address)
|
|
371
|
+
continue
|
|
372
|
+
if legacy:
|
|
373
|
+
if result.legacy is None:
|
|
374
|
+
raise ValueError(
|
|
375
|
+
f"the {spec.kind} resolver gave no legacy string for {spec.name!r}"
|
|
376
|
+
)
|
|
377
|
+
component = result.legacy
|
|
378
|
+
else:
|
|
379
|
+
component = canonical_json([spec.kind, spec.name, result.identity, result.fingerprint])
|
|
380
|
+
inputs.append((spec.kind, spec.name, component))
|
|
381
|
+
|
|
382
|
+
assert cell.definition_key is not None
|
|
383
|
+
parts: dict[str, Any] = {
|
|
384
|
+
"parts_schema": PARTS_SCHEMA,
|
|
385
|
+
"digest": analysis.digest,
|
|
386
|
+
"scope": "instance" if instance is not None else "cell",
|
|
387
|
+
"instance": instance,
|
|
388
|
+
"definition_key": cell.definition_key,
|
|
389
|
+
"fields": dict(cell.key_fields),
|
|
390
|
+
"env_hash": env_hash,
|
|
391
|
+
"upstream": sorted([address, value] for address, value in assembly.components),
|
|
392
|
+
"inputs": sorted([kind, name, component] for kind, name, component in inputs),
|
|
393
|
+
}
|
|
394
|
+
if assembly.unresolved:
|
|
395
|
+
return Unavailable(
|
|
396
|
+
reason="unresolved: " + ", ".join(assembly.unresolved),
|
|
397
|
+
parts=parts,
|
|
398
|
+
unresolved=tuple(assembly.unresolved),
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
components = [value for _, value in assembly.components] + [c for _, _, c in inputs]
|
|
402
|
+
if legacy:
|
|
403
|
+
digest = compute_provenance_hash(components, cell.definition_key, env_hash)
|
|
404
|
+
if instance is not None:
|
|
405
|
+
digest = derive_subkey(digest, f"variant={instance}")
|
|
406
|
+
else:
|
|
407
|
+
digest = _sha256(
|
|
408
|
+
canonical_json(
|
|
409
|
+
[
|
|
410
|
+
"cellgraph-1",
|
|
411
|
+
"execution",
|
|
412
|
+
cell.definition_key,
|
|
413
|
+
env_hash,
|
|
414
|
+
sorted(components),
|
|
415
|
+
parts["scope"],
|
|
416
|
+
instance,
|
|
417
|
+
]
|
|
418
|
+
)
|
|
419
|
+
)
|
|
420
|
+
return Keyed(digest=digest, parts=parts)
|
cellgraph/languages.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The language analyzer protocol and its registry (design 7.1).
|
|
2
|
+
|
|
3
|
+
An analyzer gives a cell's defines and references, and the normalized text its
|
|
4
|
+
definition key is built from. ``owns_key`` is False when the host computes the
|
|
5
|
+
cell's execution key itself (Strata's SQL and prompt cells); such a cell still
|
|
6
|
+
gets names, edges, plannability and plan steps.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import TYPE_CHECKING, Protocol
|
|
13
|
+
|
|
14
|
+
from cellgraph.diagnostics import Diagnostic
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from cellgraph.analysis import Cell
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Analyzed:
|
|
22
|
+
"""One cell's names. ``error`` is a body that could not be read (a syntax error)."""
|
|
23
|
+
|
|
24
|
+
defines: list[str] = field(default_factory=list)
|
|
25
|
+
references: list[str] = field(default_factory=list)
|
|
26
|
+
# Free names that shadow a builtin (``input``, ``type``): kept apart from
|
|
27
|
+
# ``references`` but resolved against upstream producers the same way.
|
|
28
|
+
builtin_references: list[str] = field(default_factory=list)
|
|
29
|
+
# Names the body mutates in place (``df["c"] = ...``): read and defined.
|
|
30
|
+
mutation_defines: list[str] = field(default_factory=list)
|
|
31
|
+
# A body that cannot be read becomes the error ``syntax_error``.
|
|
32
|
+
error: str | None = None
|
|
33
|
+
# Anything else the analyzer finds; an ERROR blocks the cell like a syntax error.
|
|
34
|
+
diagnostics: list[Diagnostic] = field(default_factory=list)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LanguageAnalyzer(Protocol):
|
|
38
|
+
"""What ``analyze`` needs of a language.
|
|
39
|
+
|
|
40
|
+
An analyzer may also set ``directives``: ``True`` (the default) reads every
|
|
41
|
+
directive, ``False`` none (markdown headings are ``#`` lines), and a frozenset
|
|
42
|
+
of keys only those. A ``# @`` line whose key is not read is plain text: no
|
|
43
|
+
annotation and no diagnostic.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
owns_key: bool
|
|
47
|
+
|
|
48
|
+
def analyze(self, cell: Cell) -> Analyzed:
|
|
49
|
+
"""The cell's names; the body only, its directives are the vocabulary's."""
|
|
50
|
+
...
|
|
51
|
+
|
|
52
|
+
def normalize(self, body: str) -> str:
|
|
53
|
+
"""The canonical text of a body whose directive lines are already removed."""
|
|
54
|
+
...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def directive_keys(analyzer: LanguageAnalyzer) -> frozenset[str] | None:
|
|
58
|
+
"""The directive keys *analyzer*'s language reads; None for every key."""
|
|
59
|
+
directives = getattr(analyzer, "directives", True)
|
|
60
|
+
if directives is True:
|
|
61
|
+
return None
|
|
62
|
+
if directives is False:
|
|
63
|
+
return frozenset()
|
|
64
|
+
if isinstance(directives, frozenset) and all(isinstance(k, str) for k in directives):
|
|
65
|
+
return frozenset(key.lower() for key in directives)
|
|
66
|
+
raise TypeError(f"`directives` must be a bool or a frozenset of keys, not {directives!r}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def strip_lines(source: str) -> str:
|
|
70
|
+
"""The default normalizer: trailing whitespace per line, and outer blank lines, dropped."""
|
|
71
|
+
lines = [line.rstrip() for line in source.splitlines()]
|
|
72
|
+
return "\n".join(lines).strip()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class UnknownLanguageError(LookupError):
|
|
76
|
+
"""A cell's language has no analyzer, which would drop every reference silently."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_REGISTRY: dict[str, LanguageAnalyzer] = {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def register_language_analyzer(language: str, analyzer: LanguageAnalyzer) -> None:
|
|
83
|
+
"""Bind *analyzer* to *language*; a later registration replaces an earlier one."""
|
|
84
|
+
_REGISTRY[language] = analyzer
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_language_analyzer(language: str) -> LanguageAnalyzer:
|
|
88
|
+
try:
|
|
89
|
+
return _REGISTRY[language]
|
|
90
|
+
except KeyError as exc:
|
|
91
|
+
raise UnknownLanguageError(f"no language analyzer registered for {language!r}") from exc
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def registered_analyzers() -> dict[str, LanguageAnalyzer]:
|
|
95
|
+
"""A copy of the registry."""
|
|
96
|
+
return dict(_REGISTRY)
|
cellgraph/plan.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""What to run before a cell (design 8): readiness, not keys.
|
|
2
|
+
|
|
3
|
+
A downstream key can depend on an upstream's bytes and on inputs resolved at run
|
|
4
|
+
time, so it does not exist before the upstream runs. The plan therefore asks the
|
|
5
|
+
host which cells are ready, as Strata's cascade planner does.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from cellgraph.analysis import Analysis
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def plan(analysis: Analysis, target: str, ready: Callable[[str], bool]) -> list[str]:
|
|
18
|
+
"""The upstream-reachable cells that are not ready, in topological order, then *target*.
|
|
19
|
+
|
|
20
|
+
Ordering edges count as dependencies; a fan-out cell is one step. A target that
|
|
21
|
+
is not plannable raises ``NotPlannableError`` with the diagnostics that block it.
|
|
22
|
+
"""
|
|
23
|
+
from cellgraph.analysis import NotPlannableError
|
|
24
|
+
|
|
25
|
+
if not analysis.cells[target].plannable:
|
|
26
|
+
raise NotPlannableError(target, analysis.blocking(target))
|
|
27
|
+
dag = analysis.dag
|
|
28
|
+
reachable = dag.upstream_reachable(target)
|
|
29
|
+
steps = [
|
|
30
|
+
cell_id
|
|
31
|
+
for cell_id in dag.topological_order
|
|
32
|
+
if cell_id in reachable and cell_id != target and not ready(cell_id)
|
|
33
|
+
]
|
|
34
|
+
return [*steps, target]
|
cellgraph/py.typed
ADDED
|
File without changes
|