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/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