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/core.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""The core vocabulary: ``@name``, ``@after``, ``@nocache``, ``@variant``, ``@per_variant``.
|
|
2
|
+
|
|
3
|
+
The parsing rules and diagnostic codes are Strata's (``annotations.py`` and
|
|
4
|
+
``annotation_validation.py``), which these were ported from.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from cellgraph.effects import After, FanOut, Member, NoCache
|
|
10
|
+
from cellgraph.grammar import Directive
|
|
11
|
+
from cellgraph.vocabulary import Annotation, AnnotationError, flag, mark_stacked, scalar
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_after(directives: list[Directive]) -> list[str]:
|
|
15
|
+
"""One edge per whitespace-separated id; lines stack, and a repeated id counts once."""
|
|
16
|
+
ids: list[str] = []
|
|
17
|
+
for directive in directives:
|
|
18
|
+
if not directive.value:
|
|
19
|
+
raise AnnotationError("`@after` needs a cell id", line=directive.line)
|
|
20
|
+
for token in directive.value.split():
|
|
21
|
+
token = token.strip().rstrip(",")
|
|
22
|
+
if token and token not in ids:
|
|
23
|
+
ids.append(token)
|
|
24
|
+
return ids
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
mark_stacked(_parse_after)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parse_variant(directives: list[Directive]) -> tuple[str, str]:
|
|
31
|
+
"""``# @variant <group> <name>``, both identifiers; the last line wins."""
|
|
32
|
+
for directive in directives:
|
|
33
|
+
parts = directive.value.split()
|
|
34
|
+
if len(parts) != 2 or not all(part.isidentifier() for part in parts):
|
|
35
|
+
raise AnnotationError(
|
|
36
|
+
f"`@variant {directive.value}`: expected `@variant <group> <name>`, "
|
|
37
|
+
"both Python identifiers",
|
|
38
|
+
code="variant_malformed",
|
|
39
|
+
line=directive.line,
|
|
40
|
+
)
|
|
41
|
+
group, name = directives[-1].value.split()
|
|
42
|
+
return group, name
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _parse_per_variant(directives: list[Directive]) -> str | None:
|
|
46
|
+
"""``# @per_variant`` (the single sweep group read) or ``# @per_variant <group>``."""
|
|
47
|
+
for directive in directives:
|
|
48
|
+
tokens = directive.value.split()
|
|
49
|
+
if len(tokens) > 1 or (tokens and not tokens[0].isidentifier()):
|
|
50
|
+
raise AnnotationError(
|
|
51
|
+
f"`@per_variant {directive.value}`: expected at most one group name, "
|
|
52
|
+
"a Python identifier",
|
|
53
|
+
line=directive.line,
|
|
54
|
+
)
|
|
55
|
+
tokens = directives[-1].value.split()
|
|
56
|
+
return tokens[0] if tokens else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def core_annotations() -> list[Annotation]:
|
|
60
|
+
return [
|
|
61
|
+
Annotation("name", parse=scalar(str), in_key=False),
|
|
62
|
+
Annotation(
|
|
63
|
+
"after",
|
|
64
|
+
parse=_parse_after,
|
|
65
|
+
in_key=False,
|
|
66
|
+
effects=lambda ids: [After(ids)],
|
|
67
|
+
),
|
|
68
|
+
Annotation(
|
|
69
|
+
"nocache",
|
|
70
|
+
parse=flag(),
|
|
71
|
+
in_key=False,
|
|
72
|
+
effects=lambda on: [NoCache()] if on else [],
|
|
73
|
+
),
|
|
74
|
+
Annotation(
|
|
75
|
+
"variant",
|
|
76
|
+
parse=_parse_variant,
|
|
77
|
+
in_key=False,
|
|
78
|
+
effects=lambda value: [Member(group=value[0], name=value[1])],
|
|
79
|
+
),
|
|
80
|
+
# The group decides which upstream binds a scalar, so it is in the key; a
|
|
81
|
+
# bare ``@per_variant`` is present with the value None.
|
|
82
|
+
Annotation(
|
|
83
|
+
"per_variant",
|
|
84
|
+
parse=_parse_per_variant,
|
|
85
|
+
effects=lambda group: [FanOut(group)],
|
|
86
|
+
),
|
|
87
|
+
]
|
cellgraph/diagnostics.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""What analysis says about a cell: a code, a severity and where."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Severity(StrEnum):
|
|
10
|
+
"""An ``ERROR`` keeps the cell, and every cell that depends on it, from planning."""
|
|
11
|
+
|
|
12
|
+
ERROR = "error"
|
|
13
|
+
WARN = "warn"
|
|
14
|
+
INFO = "info"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Diagnostic:
|
|
19
|
+
"""One finding. ``annotation`` is the directive key it is about, if any."""
|
|
20
|
+
|
|
21
|
+
code: str
|
|
22
|
+
severity: Severity
|
|
23
|
+
message: str
|
|
24
|
+
cell: str | None = None
|
|
25
|
+
annotation: str | None = None
|
|
26
|
+
line: int | None = None
|
cellgraph/effects.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""The ten effects an annotation can have on the graph (design 4.1).
|
|
2
|
+
|
|
3
|
+
The set is closed: ``analyze`` applies these and never interprets a host's
|
|
4
|
+
annotation beyond them. Names are tuples, so an effect is hashable and compares
|
|
5
|
+
by value.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _names(names: Any) -> tuple[str, ...]:
|
|
15
|
+
return (names,) if isinstance(names, str) else tuple(names)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, init=False)
|
|
19
|
+
class Binds:
|
|
20
|
+
"""Names put in the namespace before the body runs; never resolved to an upstream."""
|
|
21
|
+
|
|
22
|
+
names: tuple[str, ...]
|
|
23
|
+
|
|
24
|
+
def __init__(self, names: Any) -> None:
|
|
25
|
+
object.__setattr__(self, "names", _names(names))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, init=False)
|
|
29
|
+
class Defines:
|
|
30
|
+
"""Outputs the body does not assign."""
|
|
31
|
+
|
|
32
|
+
names: tuple[str, ...]
|
|
33
|
+
|
|
34
|
+
def __init__(self, names: Any) -> None:
|
|
35
|
+
object.__setattr__(self, "names", _names(names))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True, init=False)
|
|
39
|
+
class Reads:
|
|
40
|
+
"""References the body's analysis cannot see.
|
|
41
|
+
|
|
42
|
+
With ``even_if_defined=False`` a name the cell defines is not added.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
names: tuple[str, ...]
|
|
46
|
+
even_if_defined: bool
|
|
47
|
+
|
|
48
|
+
def __init__(self, names: Any, even_if_defined: bool = False) -> None:
|
|
49
|
+
object.__setattr__(self, "names", _names(names))
|
|
50
|
+
object.__setattr__(self, "even_if_defined", even_if_defined)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, init=False)
|
|
54
|
+
class Keeps:
|
|
55
|
+
"""Outputs kept with no downstream reader."""
|
|
56
|
+
|
|
57
|
+
names: tuple[str, ...]
|
|
58
|
+
|
|
59
|
+
def __init__(self, names: Any) -> None:
|
|
60
|
+
object.__setattr__(self, "names", _names(names))
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, init=False)
|
|
64
|
+
class After:
|
|
65
|
+
"""Ordering edges from these cells, with no variable."""
|
|
66
|
+
|
|
67
|
+
cell_ids: tuple[str, ...]
|
|
68
|
+
|
|
69
|
+
def __init__(self, cell_ids: Any) -> None:
|
|
70
|
+
object.__setattr__(self, "cell_ids", _names(cell_ids))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class Seeds:
|
|
75
|
+
"""Resolve ``name`` from ``cell_id``, in place of its latest earlier producer."""
|
|
76
|
+
|
|
77
|
+
cell_id: str
|
|
78
|
+
name: str
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True)
|
|
82
|
+
class Member:
|
|
83
|
+
"""Member ``name`` of variant group ``group``."""
|
|
84
|
+
|
|
85
|
+
group: str
|
|
86
|
+
name: str
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class FanOut:
|
|
91
|
+
"""Run once per member of ``group``, or of the single sweep group the cell reads."""
|
|
92
|
+
|
|
93
|
+
group: str | None = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass(frozen=True)
|
|
97
|
+
class Input:
|
|
98
|
+
"""An external input the host resolves.
|
|
99
|
+
|
|
100
|
+
``name`` is the binding name (or another stable name); ``spec`` is whatever the
|
|
101
|
+
host's resolver for ``kind`` needs. One input per ``(kind, name)`` in a cell.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
kind: str
|
|
105
|
+
name: str
|
|
106
|
+
spec: Any = field(default=None, compare=False)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True)
|
|
110
|
+
class NoCache:
|
|
111
|
+
"""Never served from cache; a downstream key follows this cell's bytes."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
Effect = Binds | Defines | Reads | Keeps | After | Seeds | Member | FanOut | Input | NoCache
|
|
115
|
+
|
|
116
|
+
EFFECT_TYPES: tuple[type, ...] = (
|
|
117
|
+
Binds,
|
|
118
|
+
Defines,
|
|
119
|
+
Reads,
|
|
120
|
+
Keeps,
|
|
121
|
+
After,
|
|
122
|
+
Seeds,
|
|
123
|
+
Member,
|
|
124
|
+
FanOut,
|
|
125
|
+
Input,
|
|
126
|
+
NoCache,
|
|
127
|
+
)
|
cellgraph/grammar.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""The ``# @key value`` directive block at the top of a cell.
|
|
2
|
+
|
|
3
|
+
One object holds the whole syntax: finding the leading block, reading a directive,
|
|
4
|
+
stripping the block, and setting or removing a directive, so editing round-trips
|
|
5
|
+
with parsing. A second syntax (Quarto's ``#| key: value``) would be another
|
|
6
|
+
``Grammar``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Container, Iterator
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Directive:
|
|
18
|
+
"""One ``# @key value`` line: its lower-cased key, its value, and its 1-based line."""
|
|
19
|
+
|
|
20
|
+
key: str
|
|
21
|
+
value: str
|
|
22
|
+
line: int
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Grammar:
|
|
26
|
+
"""The ``# @key value`` syntax of a cell's leading comment block."""
|
|
27
|
+
|
|
28
|
+
directive_re = re.compile(r"^#\s*@(\w+)\s*(.*?)\s*$")
|
|
29
|
+
|
|
30
|
+
def block(self, source: str) -> Iterator[tuple[int, str]]:
|
|
31
|
+
"""Yield ``(1-based lineno, raw_line)`` for the leading comment block.
|
|
32
|
+
|
|
33
|
+
The block is the first contiguous run of blank and ``#`` lines; blank lines
|
|
34
|
+
are not yielded.
|
|
35
|
+
"""
|
|
36
|
+
for lineno, line in enumerate(source.splitlines(), start=1):
|
|
37
|
+
stripped = line.strip()
|
|
38
|
+
if not stripped:
|
|
39
|
+
continue
|
|
40
|
+
if not stripped.startswith("#"):
|
|
41
|
+
break
|
|
42
|
+
yield lineno, line
|
|
43
|
+
|
|
44
|
+
def parse_directive(self, line: str) -> tuple[str, str] | None:
|
|
45
|
+
"""Match one ``# @key value`` line and return ``(key.lower(), value)``, or ``None``."""
|
|
46
|
+
match = self.directive_re.match(line.strip())
|
|
47
|
+
if not match:
|
|
48
|
+
return None
|
|
49
|
+
return match.group(1).lower(), match.group(2).strip()
|
|
50
|
+
|
|
51
|
+
def directives(self, source: str, keys: Container[str] | None = None) -> list[Directive]:
|
|
52
|
+
"""Every directive of the leading block, in source order; only *keys* when given."""
|
|
53
|
+
found = []
|
|
54
|
+
for lineno, line in self.block(source):
|
|
55
|
+
parsed = self.parse_directive(line)
|
|
56
|
+
if parsed is not None and (keys is None or parsed[0] in keys):
|
|
57
|
+
found.append(Directive(key=parsed[0], value=parsed[1], line=lineno))
|
|
58
|
+
return found
|
|
59
|
+
|
|
60
|
+
def strip(self, source: str) -> str:
|
|
61
|
+
"""Return *source* with the leading comment block removed."""
|
|
62
|
+
lines = source.splitlines()
|
|
63
|
+
for i, line in enumerate(lines):
|
|
64
|
+
stripped = line.strip()
|
|
65
|
+
if not stripped or stripped.startswith("#"):
|
|
66
|
+
continue
|
|
67
|
+
return "\n".join(lines[i:])
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
def strip_directives(self, source: str, keys: Container[str] | None = None) -> str:
|
|
71
|
+
"""Return *source* without the directive lines of its leading block.
|
|
72
|
+
|
|
73
|
+
With *keys*, only those keys' lines go. Other comments stay: they are the
|
|
74
|
+
body's, for its normalizer to judge.
|
|
75
|
+
"""
|
|
76
|
+
lines = source.splitlines()
|
|
77
|
+
end = self._block_end(lines)
|
|
78
|
+
|
|
79
|
+
def is_directive(line: str) -> bool:
|
|
80
|
+
parsed = self.parse_directive(line)
|
|
81
|
+
return parsed is not None and (keys is None or parsed[0] in keys)
|
|
82
|
+
|
|
83
|
+
kept = [line for i, line in enumerate(lines) if i >= end or not is_directive(line)]
|
|
84
|
+
return "\n".join(kept)
|
|
85
|
+
|
|
86
|
+
def set_directive(self, source: str, key: str, value: str) -> str:
|
|
87
|
+
"""Return *source* with a single ``# @key value`` directive set.
|
|
88
|
+
|
|
89
|
+
Replaces the first ``# @key`` in the leading block and drops duplicates; when
|
|
90
|
+
absent, inserts after the last directive (or at the top).
|
|
91
|
+
"""
|
|
92
|
+
key = key.lower()
|
|
93
|
+
new_line = self._format(key, value)
|
|
94
|
+
sep = self._line_sep(source)
|
|
95
|
+
lines = source.splitlines()
|
|
96
|
+
end = self._block_end(lines)
|
|
97
|
+
|
|
98
|
+
matches = [i for i in range(end) if (d := self.parse_directive(lines[i])) and d[0] == key]
|
|
99
|
+
if matches:
|
|
100
|
+
lines[matches[0]] = new_line
|
|
101
|
+
for i in reversed(matches[1:]):
|
|
102
|
+
del lines[i]
|
|
103
|
+
else:
|
|
104
|
+
directives = [i for i in range(end) if self.parse_directive(lines[i])]
|
|
105
|
+
lines.insert(directives[-1] + 1 if directives else 0, new_line)
|
|
106
|
+
|
|
107
|
+
result = sep.join(lines)
|
|
108
|
+
return result + sep if source.endswith("\n") else result
|
|
109
|
+
|
|
110
|
+
def remove_directive(self, source: str, key: str) -> str:
|
|
111
|
+
"""Return *source* with every ``# @key`` directive removed from the block."""
|
|
112
|
+
key = key.lower()
|
|
113
|
+
sep = self._line_sep(source)
|
|
114
|
+
lines = source.splitlines()
|
|
115
|
+
end = self._block_end(lines)
|
|
116
|
+
kept = [
|
|
117
|
+
line
|
|
118
|
+
for i, line in enumerate(lines)
|
|
119
|
+
if not (i < end and (d := self.parse_directive(line)) is not None and d[0] == key)
|
|
120
|
+
]
|
|
121
|
+
result = sep.join(kept)
|
|
122
|
+
return result + sep if source.endswith("\n") else result
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _block_end(lines: list[str]) -> int:
|
|
126
|
+
"""Index of the first body line: the end of the leading comment block."""
|
|
127
|
+
for i, line in enumerate(lines):
|
|
128
|
+
stripped = line.strip()
|
|
129
|
+
if stripped and not stripped.startswith("#"):
|
|
130
|
+
return i
|
|
131
|
+
return len(lines)
|
|
132
|
+
|
|
133
|
+
@staticmethod
|
|
134
|
+
def _format(key: str, value: str) -> str:
|
|
135
|
+
value = value.strip()
|
|
136
|
+
return f"# @{key} {value}" if value else f"# @{key}"
|
|
137
|
+
|
|
138
|
+
@staticmethod
|
|
139
|
+
def _line_sep(source: str) -> str:
|
|
140
|
+
"""The dominant line ending, so a splice preserves CRLF vs LF."""
|
|
141
|
+
return "\r\n" if "\r\n" in source else "\n"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
GRAMMAR = Grammar()
|