mathspec 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.
mathspec/_sealed.py ADDED
@@ -0,0 +1,62 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """The seal on what the language hands out — a mapping nothing can write to, which pickles."""
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping
10
+ from typing import TYPE_CHECKING, override
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Iterator
14
+
15
+
16
+ class Sealed[K, V](Mapping[K, V]):
17
+ """A mapping nothing can write to, which pickles.
18
+
19
+ What every group a program hands out is held behind, and a node's
20
+ keyword arguments with them: a consumer cannot rewrite what another
21
+ consumer reads, and the whole crosses a process — which is the one thing
22
+ :class:`types.MappingProxyType` cannot do. Equal to any mapping with the
23
+ same items, and hashable over them where they are, so a dataclass may
24
+ hold one as a default.
25
+ """
26
+
27
+ __slots__ = ('_items',)
28
+
29
+ def __init__(self, items: Mapping[K, V]) -> None:
30
+ self._items = dict(items)
31
+
32
+ @override
33
+ def __getitem__(self, key: K) -> V:
34
+ return self._items[key]
35
+
36
+ @override
37
+ def __iter__(self) -> Iterator[K]:
38
+ return iter(self._items)
39
+
40
+ @override
41
+ def __len__(self) -> int:
42
+ return len(self._items)
43
+
44
+ @override
45
+ def __hash__(self) -> int:
46
+ return hash(frozenset(self._items.items()))
47
+
48
+ def __or__(self, other: Mapping[K, V]) -> dict[K, V]:
49
+ """A plain dict of both, the other's entries winning — what the proxy answered ``|`` with."""
50
+ return {**self._items, **other}
51
+
52
+ def __ror__(self, other: Mapping[K, V]) -> dict[K, V]:
53
+ return {**other, **self._items}
54
+
55
+ def __repr__(self) -> str:
56
+ return f'{type(self).__name__}({self._items!r})'
57
+
58
+ def __getstate__(self) -> dict[K, V]:
59
+ return self._items
60
+
61
+ def __setstate__(self, items: dict[K, V]) -> None:
62
+ self._items = items
@@ -0,0 +1,288 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """The where-string grammar and the ``Unresolved*`` nodes it emits, package-private.
6
+
7
+ A where string is a boolean algebra over comparisons, and a comparison's
8
+ sides are the expression grammar's own arithmetic. What a side *is* — a
9
+ parameter, a dimension, a relation column, a ``position()`` — only the schema
10
+ knows, so the grammar hands both sides over bare and
11
+ :mod:`mathspec.resolution` reads them. The resolved vocabulary lives in
12
+ :mod:`mathspec.program`.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from functools import lru_cache
19
+ from typing import TYPE_CHECKING, cast, get_args
20
+
21
+ import pyparsing as pp
22
+
23
+ from mathspec._expression_parser import (
24
+ ARITHMETIC,
25
+ NAME,
26
+ ArithmeticNode,
27
+ KeywordNode,
28
+ NameNode,
29
+ children,
30
+ keywords,
31
+ parse_text,
32
+ )
33
+ from mathspec._sealed import Sealed
34
+ from mathspec.program import (
35
+ And,
36
+ BooleanLiteral,
37
+ Connective,
38
+ Not,
39
+ Or,
40
+ Predicate,
41
+ PredicateOperator,
42
+ where_children,
43
+ )
44
+
45
+ if TYPE_CHECKING:
46
+ from collections.abc import Callable, Mapping
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # AST nodes
50
+ # ---------------------------------------------------------------------------
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class ColumnNode:
55
+ """``relation.column`` on a side of a comparison — the one place the language names a column."""
56
+
57
+ relation: str
58
+ column: str
59
+
60
+ @property
61
+ def shown(self) -> str:
62
+ """The column as the file wrote it, for an error message."""
63
+ return f'{self.relation}.{self.column}'
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class UnresolvedPredicateCallNode:
68
+ """``<name>(<predicate>[, <kwarg>…])`` — an operator reading a predicate rather than arithmetic.
69
+
70
+ The expression grammar cannot carry this shape: its call rule takes
71
+ arithmetic arguments, and a predicate is not arithmetic. So the where
72
+ grammar reads it, and resolution decides which operator the name is and
73
+ what the kwargs mean. ``kwargs`` is held and hashed as
74
+ :class:`~mathspec._expression_parser.FunctionCallNode` holds its own.
75
+ """
76
+
77
+ name: str
78
+ operand: Predicate | UnresolvedWhereNode
79
+ kwargs: Mapping[str, ArithmeticNode] = field(default_factory=dict, hash=False)
80
+
81
+ def __post_init__(self) -> None:
82
+ object.__setattr__(self, 'kwargs', Sealed(self.kwargs))
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class UnresolvedCountNode:
87
+ """``count(<predicate>, over=<dim>) <op> <number>`` — a count against a literal.
88
+
89
+ Its own node rather than an :class:`UnresolvedComparisonNode` with a call
90
+ on the left: every other comparison has arithmetic on both sides, and
91
+ widening that one to carry a predicate would widen every reader of a side
92
+ with it.
93
+ """
94
+
95
+ call: UnresolvedPredicateCallNode
96
+ op: PredicateOperator
97
+ value: ArithmeticNode
98
+
99
+
100
+ @dataclass(frozen=True)
101
+ class UnresolvedComparisonNode:
102
+ """``side <op> side`` before the sides are read; ``_where_resolver.py`` decides what each is.
103
+
104
+ A side is the expression grammar's arithmetic, so a name, a number and a
105
+ ``position(...)`` call all arrive as the nodes an expression would carry
106
+ them in, a quoted label as the :class:`KeywordNode` a quoted kwarg is, and a
107
+ relation column in a node of its own.
108
+ """
109
+
110
+ left: ArithmeticNode | ColumnNode
111
+ op: PredicateOperator
112
+ right: ArithmeticNode | ColumnNode | KeywordNode
113
+
114
+
115
+ #: What resolution rewrites away on the where side — the nodes whose leaves
116
+ #: are still names the schema has not been asked about.
117
+ UnresolvedWhereNode = NameNode | UnresolvedComparisonNode | UnresolvedPredicateCallNode | UnresolvedCountNode
118
+
119
+ #: Every node a parsed where string is built of: the connectives and literals,
120
+ #: the unresolved leaves, and the arithmetic and the two side nodes under a
121
+ #: comparison. What the depth measurement walks.
122
+ ParsedWhere = Predicate | UnresolvedWhereNode | ArithmeticNode | ColumnNode | KeywordNode
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Grammar
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ def _predicate_call(tokens: pp.ParseResults) -> UnresolvedPredicateCallNode:
131
+ """The call node, with a keyword given twice refused as the arithmetic grammar refuses it."""
132
+ name, operand, *pairs = tokens
133
+ return UnresolvedPredicateCallNode(name, operand, keywords(name, pairs))
134
+
135
+
136
+ def _build_where_grammar() -> pp.ParserElement:
137
+ """Build the pyparsing grammar for where strings.
138
+
139
+ Both quote characters are accepted because YAML already owns one of them.
140
+ ``NOT`` binds tightest, then ``AND``, then ``OR``. A comparison is tried
141
+ before a bare name, since its left side begins with one.
142
+ """
143
+ where_expr = pp.Forward()
144
+
145
+ true_lit = pp.CaselessKeyword('True').set_parse_action(lambda: BooleanLiteral(True))
146
+ false_lit = pp.CaselessKeyword('False').set_parse_action(lambda: BooleanLiteral(False))
147
+
148
+ name = pp.Regex(NAME)
149
+ # pyrefly: ignore[implicit-any-lambda]
150
+ column = pp.Regex(rf'({NAME})\.({NAME})').set_parse_action(lambda t: ColumnNode(*t[0].split('.')))
151
+ quoted = (pp.QuotedString("'", esc_char='\\') | pp.QuotedString('"', esc_char='\\')).set_parse_action(
152
+ # pyrefly: ignore[implicit-any-lambda]
153
+ lambda t: KeywordNode(t[0])
154
+ )
155
+ comparator = pp.one_of(list(get_args(PredicateOperator)))
156
+
157
+ kwarg = (name + pp.Suppress('=') + (quoted | ARITHMETIC)).set_parse_action(lambda t: (t[0], t[1]))
158
+
159
+ def _call(head: pp.ParserElement) -> pp.ParserElement:
160
+ """``<head>(<predicate>[, <kwarg>…])`` — the one shape whose operand is a predicate.
161
+
162
+ ``count`` is spelled in the grammar rather than left to resolution, as
163
+ ``position`` is, because only the grammar can decide to read its
164
+ argument as a predicate. Every other predicate-taking call stands where
165
+ arithmetic cannot, so the comparison above it has already been tried
166
+ and the name is free.
167
+ """
168
+ return (
169
+ head + pp.Suppress('(') + where_expr + pp.ZeroOrMore(pp.Suppress(',') + kwarg) + pp.Suppress(')')
170
+ ).set_parse_action(_predicate_call)
171
+
172
+ count_call = _call(pp.CaselessKeyword('count'))
173
+ predicate_call = _call(name.copy())
174
+
175
+ count_comparison = (count_call + comparator + ARITHMETIC).set_parse_action(
176
+ # pyrefly: ignore[implicit-any-lambda]
177
+ lambda t: UnresolvedCountNode(t[0], t[1], t[2])
178
+ )
179
+ comparison = (
180
+ (column | ARITHMETIC) + comparator + (quoted | column | ARITHMETIC)
181
+ # pyrefly: ignore[implicit-any-lambda]
182
+ ).set_parse_action(lambda t: UnresolvedComparisonNode(t[0], t[1], t[2]))
183
+ # pyrefly: ignore[implicit-any-lambda]
184
+ existence = name.copy().set_parse_action(lambda t: NameNode(t[0]))
185
+
186
+ atom = (
187
+ true_lit
188
+ | false_lit
189
+ | count_comparison
190
+ | comparison
191
+ | predicate_call
192
+ | existence
193
+ | (pp.Suppress('(') + where_expr + pp.Suppress(')'))
194
+ )
195
+
196
+ NOT = pp.CaselessKeyword('NOT').suppress()
197
+ # pyrefly: ignore[implicit-any-lambda]
198
+ not_expr = (NOT + atom).set_parse_action(lambda t: Not(t[0])) | atom
199
+
200
+ AND = pp.CaselessKeyword('AND').suppress()
201
+ and_expr = not_expr + pp.ZeroOrMore(AND + not_expr)
202
+ and_expr.set_parse_action(_folder(And))
203
+
204
+ OR = pp.CaselessKeyword('OR').suppress()
205
+ or_expr = and_expr + pp.ZeroOrMore(OR + and_expr)
206
+ or_expr.set_parse_action(_folder(Or))
207
+
208
+ where_expr <<= or_expr
209
+ return where_expr
210
+
211
+
212
+ def _folder(node_type: type[And] | type[Or]) -> Callable[[pp.ParseResults], Predicate | UnresolvedWhereNode]:
213
+ """A parse action left-folding a flat operator chain into *node_type*."""
214
+
215
+ def fold(tokens: pp.ParseResults) -> Predicate | UnresolvedWhereNode:
216
+ items: list[Predicate | UnresolvedWhereNode] = list(tokens)
217
+ result = items[0]
218
+ for item in items[1:]:
219
+ result = node_type(cast('Predicate', result), cast('Predicate', item))
220
+ return result
221
+
222
+ return fold
223
+
224
+
225
+ _WHERE_GRAMMAR = _build_where_grammar()
226
+
227
+
228
+ def _named_rewrite(text: str, loc: int) -> str | None:
229
+ """The rewrite for a connective habit of pandas or C at the token where the grammar gave up, or ``None``.
230
+
231
+ ``!=``, ``<`` and ``>`` are legal here, so only the tokens no predicate
232
+ admits are diagnosed.
233
+ """
234
+ rest = text[loc:].lstrip()
235
+ if rest.startswith('&'):
236
+ return "'&' is not the conjunction — both predicates at once is written AND."
237
+ if rest.startswith('|'):
238
+ return "'|' is not the disjunction — either predicate is written OR."
239
+ if rest.startswith(('~', '!')) and not rest.startswith('!='):
240
+ return f"'{rest[0]}' is not the negation — it is written NOT, before the predicate."
241
+ if rest.startswith('=') and not rest.startswith('=='):
242
+ return "'=' compares nothing — equality is written ==."
243
+ return None
244
+
245
+
246
+ #: The rewrite an over-deep where string is given. A long chain of predicates
247
+ #: is a test the file could carry as data instead, which is the language's own
248
+ #: answer before the general one.
249
+ _DEEP_REWRITE = (
250
+ 'Declare a parameter or relation carrying part of the test and name that here, or split the '
251
+ 'declaration into two, each masked by one half.'
252
+ )
253
+
254
+
255
+ def nested(node: ParsedWhere) -> tuple[ParsedWhere, ...]:
256
+ """What a where string nests through: a connective's operands, a comparison's sides, and a call's predicate."""
257
+ if isinstance(node, UnresolvedCountNode):
258
+ return (node.call, node.value)
259
+ if isinstance(node, UnresolvedPredicateCallNode):
260
+ return (node.operand, *node.kwargs.values())
261
+ if isinstance(node, UnresolvedComparisonNode):
262
+ return (node.left, node.right)
263
+ if isinstance(node, ArithmeticNode):
264
+ return children(node)
265
+ if isinstance(node, Connective):
266
+ return where_children(node)
267
+ return ()
268
+
269
+
270
+ @lru_cache(maxsize=4096)
271
+ def parse_where(text: str) -> Predicate | UnresolvedWhereNode:
272
+ """Parse a where string into an AST, its leaves still unresolved.
273
+
274
+ The connectives and literals are the resolved vocabulary's own; the leaves
275
+ naming declarations are a bare :class:`NameNode` or an ``Unresolved*``
276
+ node, which only :func:`~mathspec.resolution.resolve_where` takes.
277
+
278
+ Raises:
279
+ SchemaError: If *text* is not a where string of the language. A
280
+ predictable mistake — ``&``/``|``/``~``/``!`` for a connective, a
281
+ lone ``=`` — is named with its rewrite beside the grammar's own
282
+ complaint. A side nesting past what an expression may is refused
283
+ as an expression is.
284
+ """
285
+ return cast(
286
+ 'Predicate | UnresolvedWhereNode',
287
+ parse_text(_WHERE_GRAMMAR, text, 'where string', _named_rewrite, nested, _DEEP_REWRITE),
288
+ )