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/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
+ ]
@@ -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()