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/__init__.py +59 -0
- mathspec/__main__.py +87 -0
- mathspec/_expression_parser.py +436 -0
- mathspec/_expression_resolver.py +739 -0
- mathspec/_sealed.py +62 -0
- mathspec/_where_parser.py +288 -0
- mathspec/_where_resolver.py +745 -0
- mathspec/_yaml.py +141 -0
- mathspec/advice.py +79 -0
- mathspec/boundedness.py +168 -0
- mathspec/degree.py +209 -0
- mathspec/dimensions.py +377 -0
- mathspec/errors.py +105 -0
- mathspec/exclusivity.py +587 -0
- mathspec/expansion.py +124 -0
- mathspec/lowering.py +302 -0
- mathspec/model.py +905 -0
- mathspec/operators.py +235 -0
- mathspec/piecewise.py +505 -0
- mathspec/program.py +1514 -0
- mathspec/py.typed +0 -0
- mathspec/resolution.py +475 -0
- mathspec/separability.py +185 -0
- mathspec/sos.py +159 -0
- mathspec/typesetting/README.md +110 -0
- mathspec/typesetting/__init__.py +223 -0
- mathspec/typesetting/format.py +273 -0
- mathspec/typesetting/latex.py +139 -0
- mathspec/typesetting/legend.py +286 -0
- mathspec/typesetting/markdown.py +105 -0
- mathspec/typesetting/symbols.py +310 -0
- mathspec/typesetting/typst.py +147 -0
- mathspec/typesetting/walk.py +941 -0
- mathspec/validation.py +338 -0
- mathspec-0.1.0.dist-info/METADATA +247 -0
- mathspec-0.1.0.dist-info/RECORD +38 -0
- mathspec-0.1.0.dist-info/WHEEL +4 -0
- mathspec-0.1.0.dist-info/licenses/LICENSE +21 -0
mathspec/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: mathspec Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""The language: what a YAML file may say, and what it means.
|
|
6
|
+
|
|
7
|
+
Two public states — a :class:`~mathspec.model.Spec` is what the file *says*,
|
|
8
|
+
and its :attr:`~mathspec.model.Spec.program` is what it *means* — and
|
|
9
|
+
:func:`to_spec`, the one door to both. Everything between them — both grammars
|
|
10
|
+
and the tree they build — is package-private, because a consumer reads a
|
|
11
|
+
program instead.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from mathspec import program
|
|
15
|
+
from mathspec.advice import advice
|
|
16
|
+
from mathspec.errors import Advice, AdviceKind, DimensionError, LanguageError, MathSpecError, SchemaError, did_you_mean
|
|
17
|
+
from mathspec.model import Spec
|
|
18
|
+
from mathspec.operators import BUILTIN_NAMES
|
|
19
|
+
from mathspec.typesetting import (
|
|
20
|
+
FORMATS,
|
|
21
|
+
SymbolTable,
|
|
22
|
+
to_latex,
|
|
23
|
+
to_markdown,
|
|
24
|
+
to_typst,
|
|
25
|
+
typeset,
|
|
26
|
+
typeset_declaration,
|
|
27
|
+
)
|
|
28
|
+
from mathspec.validation import to_spec
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
'BUILTIN_NAMES',
|
|
32
|
+
'FORMATS',
|
|
33
|
+
'Advice',
|
|
34
|
+
'AdviceKind',
|
|
35
|
+
'DimensionError',
|
|
36
|
+
'LanguageError',
|
|
37
|
+
'MathSpecError',
|
|
38
|
+
'SchemaError',
|
|
39
|
+
'Spec',
|
|
40
|
+
'SymbolTable',
|
|
41
|
+
'advice',
|
|
42
|
+
'did_you_mean',
|
|
43
|
+
'program',
|
|
44
|
+
'to_latex',
|
|
45
|
+
'to_markdown',
|
|
46
|
+
'to_spec',
|
|
47
|
+
'to_typst',
|
|
48
|
+
'typeset',
|
|
49
|
+
'typeset_declaration',
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
import warnings as _warnings
|
|
53
|
+
from importlib import metadata as _metadata
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
__version__ = _metadata.version(__name__)
|
|
57
|
+
except _metadata.PackageNotFoundError as e: # pragma: no cover
|
|
58
|
+
_warnings.warn(f'Could not determine version of {__name__}\n{e!s}', stacklevel=2)
|
|
59
|
+
__version__ = 'unknown'
|
mathspec/__main__.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: mathspec Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""``python -m mathspec <verb> model.yaml`` — the shell front.
|
|
6
|
+
|
|
7
|
+
``check`` loads the file and prints the language's advice; one further verb
|
|
8
|
+
per typeset format, read off :data:`mathspec.typesetting.FORMATS`. Every verb
|
|
9
|
+
reads the file as written, and nothing here writes a formulation out unasked.
|
|
10
|
+
The typeset verbs take ``--expand``, because a shell cannot compose
|
|
11
|
+
:meth:`~mathspec.model.Spec.expand` the way a caller does and the rows are a
|
|
12
|
+
different document; ``check`` has no such flag, because advice reads a block
|
|
13
|
+
as the rows it states.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from mathspec.advice import advice
|
|
23
|
+
from mathspec.errors import MathSpecError
|
|
24
|
+
from mathspec.typesetting import FORMATS, typeset
|
|
25
|
+
from mathspec.validation import to_spec
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parser() -> argparse.ArgumentParser:
|
|
29
|
+
"""The verbs, built from ``FORMATS``."""
|
|
30
|
+
front = argparse.ArgumentParser(prog='python -m mathspec')
|
|
31
|
+
verbs = front.add_subparsers(dest='verb', required=True)
|
|
32
|
+
|
|
33
|
+
check = verbs.add_parser('check', help='load a model, and print what the language advises')
|
|
34
|
+
check.add_argument('model', help='path to a mathspec YAML model')
|
|
35
|
+
|
|
36
|
+
for name in FORMATS:
|
|
37
|
+
verb = verbs.add_parser(name, help=f'render a model as {name}')
|
|
38
|
+
verb.add_argument('model', help='path to a mathspec YAML model')
|
|
39
|
+
verb.add_argument('-o', '--out', help='write here instead of stdout')
|
|
40
|
+
verb.add_argument('--symbols', help='sidecar YAML saying how names should print')
|
|
41
|
+
verb.add_argument('--standalone', action='store_true', help='emit a compilable document')
|
|
42
|
+
verb.add_argument('--no-legend', action='store_true', help='omit the sets/parameters/variables table')
|
|
43
|
+
verb.add_argument('--no-numbers', action='store_true', help='leave the equations unnumbered')
|
|
44
|
+
verb.add_argument(
|
|
45
|
+
'--inline-expressions', action='store_true', help='substitute each named expression where it is used'
|
|
46
|
+
)
|
|
47
|
+
verb.add_argument(
|
|
48
|
+
'--expand',
|
|
49
|
+
action='store_true',
|
|
50
|
+
help='print the variables and constraints the piecewise: and sos: blocks state, not the blocks',
|
|
51
|
+
)
|
|
52
|
+
return front
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv: list[str] | None = None) -> int:
|
|
56
|
+
"""Run one verb; a refused file is its message on stderr and exit status 1.
|
|
57
|
+
|
|
58
|
+
Advice is not a refusal: ``check`` prints it and exits 0.
|
|
59
|
+
"""
|
|
60
|
+
args = parser().parse_args(argv)
|
|
61
|
+
if args.verb == 'check':
|
|
62
|
+
try:
|
|
63
|
+
notes = advice(args.model)
|
|
64
|
+
except MathSpecError as e:
|
|
65
|
+
sys.stderr.write(f'{e}\n')
|
|
66
|
+
return 1
|
|
67
|
+
sys.stdout.write(''.join(f'{note}\n' for note in notes))
|
|
68
|
+
return 0
|
|
69
|
+
model = to_spec(args.model).expand() if args.expand else args.model
|
|
70
|
+
text = typeset(
|
|
71
|
+
model,
|
|
72
|
+
args.verb,
|
|
73
|
+
symbols=args.symbols,
|
|
74
|
+
standalone=args.standalone,
|
|
75
|
+
legend=not args.no_legend,
|
|
76
|
+
numbered=not args.no_numbers,
|
|
77
|
+
inline_expressions=args.inline_expressions,
|
|
78
|
+
)
|
|
79
|
+
if args.out:
|
|
80
|
+
Path(args.out).write_text(text, encoding='utf-8')
|
|
81
|
+
else:
|
|
82
|
+
sys.stdout.write(text)
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == '__main__':
|
|
87
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: mathspec Contributors
|
|
2
|
+
#
|
|
3
|
+
# SPDX-License-Identifier: MIT
|
|
4
|
+
|
|
5
|
+
"""The syntax tree the expression grammar builds, and the grammar — package-private.
|
|
6
|
+
|
|
7
|
+
Only expansion and resolution read it: resolution rewrites it into the
|
|
8
|
+
:mod:`mathspec.program` vocabulary, which every pass after reads. Arithmetic
|
|
9
|
+
nests anywhere; a comparison appears only at the top of a parsed expression.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
from typing import TYPE_CHECKING, Literal, assert_never, cast, get_args
|
|
17
|
+
|
|
18
|
+
import pyparsing as pp
|
|
19
|
+
|
|
20
|
+
from mathspec._sealed import Sealed
|
|
21
|
+
from mathspec.errors import SchemaError
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Callable, Iterable, Iterator, Mapping
|
|
25
|
+
|
|
26
|
+
#: The relation a comparison may carry — the three an expression may be
|
|
27
|
+
#: written with, which is what a constraint's sense is read off.
|
|
28
|
+
ComparisonOperator = Literal['<=', '>=', '==']
|
|
29
|
+
|
|
30
|
+
#: The sign a unary operator applies to its operand.
|
|
31
|
+
UnaryOperator = Literal['+', '-']
|
|
32
|
+
|
|
33
|
+
#: The arithmetic a binary operator may spell. Closed by the grammar, and the
|
|
34
|
+
#: vocabulary a renderer dispatching on :attr:`BinaryOperatorNode.op`
|
|
35
|
+
#: switches over — it keeps no list of its own.
|
|
36
|
+
BinaryOperator = Literal['+', '-', '*', '/', '**']
|
|
37
|
+
|
|
38
|
+
#: What an expression writes to refer to a declaration, and so what a
|
|
39
|
+
#: declaration may be named.
|
|
40
|
+
NAME = r'[a-zA-Z_][a-zA-Z0-9_]*'
|
|
41
|
+
|
|
42
|
+
#: A float — a fractional part or an exponent. A sign is the unary operator's.
|
|
43
|
+
REAL = r'\d+\.\d*([eE][+-]?\d+)?|\d+[eE][+-]?\d+'
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# AST nodes
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class NumberNode:
|
|
52
|
+
value: float
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
"""A whole number without its fraction, which is how a file writes one and how it parses back."""
|
|
56
|
+
return str(int(self.value)) if self.value.is_integer() else str(self.value)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class NameNode:
|
|
61
|
+
"""A bare name whose kind only the schema knows; resolution rewrites every one into a program node."""
|
|
62
|
+
|
|
63
|
+
name: str
|
|
64
|
+
|
|
65
|
+
def __str__(self) -> str:
|
|
66
|
+
return self.name
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class NameListNode:
|
|
71
|
+
"""A bracketed list of names in a kwarg value — ``sum(x, by=[a, b])``.
|
|
72
|
+
|
|
73
|
+
Unresolved: which kind of name the kwarg admits is the operator's business.
|
|
74
|
+
"""
|
|
75
|
+
|
|
76
|
+
names: tuple[str, ...]
|
|
77
|
+
|
|
78
|
+
def __str__(self) -> str:
|
|
79
|
+
return shown(self.names)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class KeywordNode:
|
|
84
|
+
"""A quoted closed keyword in a kwarg value — ``shift(..., edge='wrap')``.
|
|
85
|
+
|
|
86
|
+
Unresolved: which keywords the kwarg accepts is the operator's business.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
value: str
|
|
90
|
+
|
|
91
|
+
def __str__(self) -> str:
|
|
92
|
+
return f"'{self.value}'"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class UnaryOperatorNode:
|
|
97
|
+
op: UnaryOperator
|
|
98
|
+
operand: ArithmeticNode
|
|
99
|
+
|
|
100
|
+
def __str__(self) -> str:
|
|
101
|
+
return f'{self.op}{operand(self.operand)}'
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class BinaryOperatorNode:
|
|
106
|
+
op: BinaryOperator
|
|
107
|
+
left: ArithmeticNode
|
|
108
|
+
right: ArithmeticNode
|
|
109
|
+
|
|
110
|
+
def __str__(self) -> str:
|
|
111
|
+
return f'{operand(self.left)} {self.op} {operand(self.right)}'
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class FunctionCallNode:
|
|
116
|
+
"""An operator or macro call.
|
|
117
|
+
|
|
118
|
+
``kwargs`` is held behind a read-only view and excluded from the hash;
|
|
119
|
+
equal nodes still hash equal on ``name`` and ``args``.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
name: str
|
|
123
|
+
args: tuple[ArithmeticNode, ...] = ()
|
|
124
|
+
kwargs: Mapping[str, ArithmeticNode] = field(default_factory=dict, hash=False)
|
|
125
|
+
|
|
126
|
+
def __post_init__(self) -> None:
|
|
127
|
+
object.__setattr__(self, 'kwargs', Sealed(self.kwargs))
|
|
128
|
+
|
|
129
|
+
def __str__(self) -> str:
|
|
130
|
+
passed = [*(str(arg) for arg in self.args), *(f'{key}={value}' for key, value in self.kwargs.items())]
|
|
131
|
+
return f'{self.name}({", ".join(passed)})'
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
#: Every arithmetic node the grammar builds. A name, a name list and a quoted
|
|
135
|
+
#: keyword are what resolution reads for their kind; the rest is structure.
|
|
136
|
+
ArithmeticNode = (
|
|
137
|
+
NumberNode | NameNode | NameListNode | KeywordNode | UnaryOperatorNode | BinaryOperatorNode | FunctionCallNode
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True)
|
|
142
|
+
class ComparisonNode:
|
|
143
|
+
op: ComparisonOperator
|
|
144
|
+
left: ArithmeticNode
|
|
145
|
+
right: ArithmeticNode
|
|
146
|
+
|
|
147
|
+
def __str__(self) -> str:
|
|
148
|
+
"""Both sides bare: a comparison is not an :data:`ArithmeticNode`, so nothing can take one as an operand."""
|
|
149
|
+
return f'{self.left} {self.op} {self.right}'
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
#: A whole parsed expression: arithmetic, or one comparison over it.
|
|
153
|
+
ParsedNode = ArithmeticNode | ComparisonNode
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def shown(names: tuple[str, ...]) -> str:
|
|
157
|
+
"""Names as a kwarg value is written: bare when one, bracketed when several."""
|
|
158
|
+
return names[0] if len(names) == 1 else f'[{", ".join(names)}]'
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def operand(node: ArithmeticNode) -> str:
|
|
162
|
+
"""One operand of an operator, bracketed where reading the text back would regroup the tree.
|
|
163
|
+
|
|
164
|
+
Whoever writes a node into a larger text — an operator, a line of a dumped
|
|
165
|
+
sum — asks this rather than restating when brackets are needed.
|
|
166
|
+
|
|
167
|
+
A leaf and a call are self-delimiting, and an operator node is not. The brackets go on every operator operand rather than only the
|
|
168
|
+
ones precedence would regroup, because a node prints without knowing its
|
|
169
|
+
parent: ``a + (b * c)`` keeps the tree where ``a + b * c`` would rely on the
|
|
170
|
+
reader knowing which binds tighter.
|
|
171
|
+
"""
|
|
172
|
+
return f'({node})' if isinstance(node, (UnaryOperatorNode, BinaryOperatorNode)) else str(node)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def children(node: ParsedNode) -> tuple[ArithmeticNode, ...]:
|
|
176
|
+
"""The sub-expressions of *node* — the structural half of any walk.
|
|
177
|
+
|
|
178
|
+
An operator's kwargs are children too — a dimension or coordinate is an
|
|
179
|
+
ordinary node in a kwarg value, which is what lets a macro bind a formal.
|
|
180
|
+
"""
|
|
181
|
+
if isinstance(node, UnaryOperatorNode):
|
|
182
|
+
return (node.operand,)
|
|
183
|
+
if isinstance(node, (BinaryOperatorNode, ComparisonNode)):
|
|
184
|
+
return (node.left, node.right)
|
|
185
|
+
if isinstance(node, FunctionCallNode):
|
|
186
|
+
return (*node.args, *node.kwargs.values())
|
|
187
|
+
return ()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def nodes(*roots: ParsedNode) -> Iterator[ParsedNode]:
|
|
191
|
+
"""Every node under *roots*, each root itself included, parents first.
|
|
192
|
+
|
|
193
|
+
The traversal a question about a tree is a filter of, the way
|
|
194
|
+
:func:`mathspec.program.walk` is for a program.
|
|
195
|
+
"""
|
|
196
|
+
for root in roots:
|
|
197
|
+
yield root
|
|
198
|
+
yield from nodes(*children(root))
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def with_children(node: ArithmeticNode, recurse: Callable[[ArithmeticNode], ArithmeticNode]) -> ArithmeticNode:
|
|
202
|
+
"""*node* rebuilt with *recurse* applied to each of its :func:`children`; a leaf comes back as is."""
|
|
203
|
+
if isinstance(node, NumberNode | NameNode | NameListNode | KeywordNode):
|
|
204
|
+
return node
|
|
205
|
+
if isinstance(node, UnaryOperatorNode):
|
|
206
|
+
return UnaryOperatorNode(node.op, recurse(node.operand))
|
|
207
|
+
if isinstance(node, BinaryOperatorNode):
|
|
208
|
+
return BinaryOperatorNode(node.op, recurse(node.left), recurse(node.right))
|
|
209
|
+
if isinstance(node, FunctionCallNode):
|
|
210
|
+
return FunctionCallNode(
|
|
211
|
+
node.name,
|
|
212
|
+
tuple(recurse(a) for a in node.args),
|
|
213
|
+
{k: recurse(v) for k, v in node.kwargs.items()},
|
|
214
|
+
)
|
|
215
|
+
assert_never(node)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# Grammar
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement]:
|
|
224
|
+
"""The arithmetic grammar, and the expression grammar that puts one comparison over it.
|
|
225
|
+
|
|
226
|
+
``inf`` is a ``pp.Keyword`` rather than a ``pp.Literal``, which would
|
|
227
|
+
match the prefix of ``inflow``.
|
|
228
|
+
"""
|
|
229
|
+
arith = pp.Forward()
|
|
230
|
+
|
|
231
|
+
inf_literal = (pp.Keyword('.inf') | pp.Keyword('inf')).set_parse_action(lambda: NumberNode(float('inf')))
|
|
232
|
+
# pyrefly: ignore[implicit-any-lambda]
|
|
233
|
+
number = inf_literal | pp.Regex(rf'{REAL}|\d+').set_parse_action(lambda t: NumberNode(float(t[0])))
|
|
234
|
+
|
|
235
|
+
name = pp.Regex(NAME)
|
|
236
|
+
|
|
237
|
+
quoted = (pp.QuotedString("'") | pp.QuotedString('"')).set_parse_action(lambda t: KeywordNode(str(t[0])))
|
|
238
|
+
name_list = (pp.Suppress('[') + pp.DelimitedList(name) + pp.Suppress(']')).set_parse_action(
|
|
239
|
+
lambda t: NameListNode(tuple(str(x) for x in t))
|
|
240
|
+
)
|
|
241
|
+
kwarg = (name + pp.Suppress('=') + (quoted | name_list | arith)).set_parse_action(lambda t: (t[0], t[1]))
|
|
242
|
+
pos_arg = arith
|
|
243
|
+
arg_list = pp.Optional(pp.DelimitedList(kwarg | pos_arg))
|
|
244
|
+
func_call = (name + pp.Suppress('(') + arg_list + pp.Suppress(')')).set_parse_action(_make_func_call)
|
|
245
|
+
|
|
246
|
+
# pyrefly: ignore[implicit-any-lambda]
|
|
247
|
+
name_node = name.copy().set_parse_action(lambda t: NameNode(t[0]))
|
|
248
|
+
atom = func_call | number | name_node | (pp.Suppress('(') + arith + pp.Suppress(')'))
|
|
249
|
+
|
|
250
|
+
unary = pp.Forward()
|
|
251
|
+
power = (atom + pp.Optional(pp.Literal('**') + unary)).set_parse_action(_make_power)
|
|
252
|
+
# pyrefly: ignore[implicit-any-lambda]
|
|
253
|
+
unary <<= (pp.one_of('+ -') + unary).set_parse_action(lambda t: UnaryOperatorNode(t[0], t[1])) | power
|
|
254
|
+
|
|
255
|
+
mul_div = unary + pp.ZeroOrMore(pp.one_of('* /') + unary)
|
|
256
|
+
mul_div.set_parse_action(_make_left_assoc)
|
|
257
|
+
|
|
258
|
+
add_sub = mul_div + pp.ZeroOrMore(pp.one_of('+ -') + mul_div)
|
|
259
|
+
add_sub.set_parse_action(_make_left_assoc)
|
|
260
|
+
|
|
261
|
+
arith <<= add_sub
|
|
262
|
+
|
|
263
|
+
comparator = pp.one_of(list(get_args(ComparisonOperator)))
|
|
264
|
+
expression = (arith + pp.Optional(comparator + arith)).set_parse_action(
|
|
265
|
+
lambda t: ComparisonNode(t[1], t[0], t[2]) if len(t) == 3 else t[0]
|
|
266
|
+
)
|
|
267
|
+
return arith, expression
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _make_func_call(tokens: pp.ParseResults) -> FunctionCallNode:
|
|
271
|
+
"""The callee is cast: a ParseResults element is untyped, and the grammar guarantees an identifier in position 0."""
|
|
272
|
+
name = cast('str', tokens[0])
|
|
273
|
+
args: list[ArithmeticNode] = []
|
|
274
|
+
pairs: list[tuple[str, ArithmeticNode]] = []
|
|
275
|
+
for item in tokens[1:]:
|
|
276
|
+
(pairs if isinstance(item, tuple) and len(item) == 2 else args).append(item)
|
|
277
|
+
return FunctionCallNode(name=name, args=tuple(args), kwargs=keywords(name, pairs))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def keywords[V](name: str, pairs: Iterable[tuple[str, V]]) -> dict[str, V]:
|
|
281
|
+
"""A call's keywords, in the order written; a keyword given twice is refused, in both grammars."""
|
|
282
|
+
kwargs: dict[str, V] = {}
|
|
283
|
+
for key, value in pairs:
|
|
284
|
+
if key in kwargs:
|
|
285
|
+
msg = f'{name}({key}=) is given twice. A keyword names one value; drop one of them.'
|
|
286
|
+
raise SchemaError(msg)
|
|
287
|
+
kwargs[key] = value
|
|
288
|
+
return kwargs
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _make_left_assoc(tokens: pp.ParseResults) -> ArithmeticNode:
|
|
292
|
+
result: ArithmeticNode
|
|
293
|
+
result, *rest = tokens
|
|
294
|
+
for op, right in zip(rest[::2], rest[1::2], strict=True):
|
|
295
|
+
result = BinaryOperatorNode(op, result, right)
|
|
296
|
+
return result
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _make_power(tokens: pp.ParseResults) -> ArithmeticNode:
|
|
300
|
+
"""A base and at most one exponent — right-associative, since the exponent is itself a ``unary``."""
|
|
301
|
+
items: list[ArithmeticNode] = list(tokens)
|
|
302
|
+
return items[0] if len(items) == 1 else BinaryOperatorNode('**', items[0], items[2])
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
#: The arithmetic half on its own, for the where grammar to put a predicate's
|
|
306
|
+
#: comparator over: one grammar for what a side may say, wherever it stands.
|
|
307
|
+
ARITHMETIC, _GRAMMAR = _build_grammar()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
#: How deep a tree the language admits. Every pass over an expression recurses,
|
|
311
|
+
#: so a deeper one exhausts the interpreter's stack instead of failing as a
|
|
312
|
+
#: ``LanguageError``; the whole pipeline survives 300 on a default stack (#359).
|
|
313
|
+
MAX_DEPTH = 100
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def depth[T](node: T, child_of: Callable[[T], tuple[T, ...]]) -> int:
|
|
317
|
+
"""How many nodes deep *node* is, walked with an explicit stack.
|
|
318
|
+
|
|
319
|
+
Iterative deliberately: this measurement is what refuses a tree too deep to
|
|
320
|
+
recurse over, so recursing to take it would be the crash it prevents.
|
|
321
|
+
"""
|
|
322
|
+
deepest, pending = 0, [(node, 1)]
|
|
323
|
+
while pending:
|
|
324
|
+
current, reached = pending.pop()
|
|
325
|
+
deepest = max(deepest, reached)
|
|
326
|
+
pending.extend((child, reached + 1) for child in child_of(current))
|
|
327
|
+
return deepest
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _too_deep(what: str, text: str, found: int | None, rewrite: str) -> str:
|
|
331
|
+
"""The refusal both grammars raise, so the two say the same thing about the same limit.
|
|
332
|
+
|
|
333
|
+
*found* is ``None`` where the parser ran out of stack before a tree existed
|
|
334
|
+
to measure.
|
|
335
|
+
"""
|
|
336
|
+
shown = text if len(text) <= 60 else f'{text[:60]}…'
|
|
337
|
+
measured = f'nests {found} deep' if found is not None else 'nests deeper'
|
|
338
|
+
return f'The {what} {measured}, past the {MAX_DEPTH} levels the language admits: {shown!r}\n{rewrite}'
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def parse_text[T](
|
|
342
|
+
grammar: pp.ParserElement,
|
|
343
|
+
text: str,
|
|
344
|
+
what: str,
|
|
345
|
+
rewrite: Callable[[str, int], str | None],
|
|
346
|
+
child_of: Callable[[T], tuple[T, ...]],
|
|
347
|
+
deep_rewrite: str,
|
|
348
|
+
) -> T:
|
|
349
|
+
"""Parse the whole of *text* with *grammar*, or raise :class:`SchemaError` naming *what* failed to parse.
|
|
350
|
+
|
|
351
|
+
*rewrite* is asked for the predictable mistake at the failure position; its
|
|
352
|
+
sentence, if any, precedes the grammar's own complaint. A tree nesting past
|
|
353
|
+
:data:`MAX_DEPTH`, measured through *child_of*, is refused with
|
|
354
|
+
*deep_rewrite* — and so is one the parser itself ran out of stack on. The
|
|
355
|
+
node comes back as the type *child_of* walks, which is the grammar's word
|
|
356
|
+
for what it builds.
|
|
357
|
+
"""
|
|
358
|
+
try:
|
|
359
|
+
result = grammar.parse_string(text, parse_all=True)
|
|
360
|
+
except pp.ParseException as e:
|
|
361
|
+
hint = rewrite(text, e.loc)
|
|
362
|
+
msg = f'Failed to parse {what}: {text!r}\n{f"{hint}\n" if hint is not None else ""}{e}'
|
|
363
|
+
raise SchemaError(msg) from e
|
|
364
|
+
except RecursionError:
|
|
365
|
+
raise SchemaError(_too_deep(what, text, None, deep_rewrite)) from None
|
|
366
|
+
node = cast('T', result[0])
|
|
367
|
+
found = depth(node, child_of)
|
|
368
|
+
if found > MAX_DEPTH:
|
|
369
|
+
raise SchemaError(_too_deep(what, text, found, deep_rewrite))
|
|
370
|
+
return node
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _named_rewrite(text: str, loc: int) -> str | None:
|
|
374
|
+
"""The rewrite for a predictable mistake at the token where the grammar gave up, or ``None``.
|
|
375
|
+
|
|
376
|
+
A two-character token is tested before its one-character prefix.
|
|
377
|
+
"""
|
|
378
|
+
rest = text[loc:].lstrip()
|
|
379
|
+
if rest.startswith(get_args(ComparisonOperator)):
|
|
380
|
+
return (
|
|
381
|
+
f"'{rest[:2]}' follows a complete comparison, and an expression carries "
|
|
382
|
+
f'one comparison, at the top. Split the chain into two constraints.'
|
|
383
|
+
)
|
|
384
|
+
if rest.startswith('!='):
|
|
385
|
+
return (
|
|
386
|
+
"'!=' is not a constraint sense — the senses are <=, >= and ==. "
|
|
387
|
+
'Holding rows apart is a where matter: write the test in where:, where != is legal.'
|
|
388
|
+
)
|
|
389
|
+
if rest.startswith(('<', '>')):
|
|
390
|
+
return f"'{rest[0]}' is not a constraint sense — the senses are <=, >= and ==. Write the bound inclusive."
|
|
391
|
+
if rest.startswith('='):
|
|
392
|
+
return (
|
|
393
|
+
"'=' on its own is how a kwarg is written inside a call, like sum(x, over=d). "
|
|
394
|
+
'Equality between two sides is written ==.'
|
|
395
|
+
)
|
|
396
|
+
if rest.startswith('^'):
|
|
397
|
+
return "power is written '**', not '^'."
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
#: The rewrite an over-deep expression is given. Writing the terms out is
|
|
402
|
+
#: what ``sum`` exists to replace, so the refusal points at the language's own
|
|
403
|
+
#: answer before it offers the general one.
|
|
404
|
+
_DEEP_REWRITE = (
|
|
405
|
+
'Reduce over a dimension with sum() rather than writing the terms out, or name an intermediate '
|
|
406
|
+
'quantity under expressions: and refer to it by name.'
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
@lru_cache(maxsize=4096)
|
|
411
|
+
def parse_expression(text: str) -> ParsedNode:
|
|
412
|
+
"""Parse a math expression string into an AST.
|
|
413
|
+
|
|
414
|
+
Raises:
|
|
415
|
+
SchemaError: If *text* is not an expression of the language. A
|
|
416
|
+
predictable mistake — a strict or chained comparison, ``!=``, a
|
|
417
|
+
lone ``=``, ``^`` for power — is named with its rewrite before the
|
|
418
|
+
grammar's own complaint.
|
|
419
|
+
"""
|
|
420
|
+
return parse_text(_GRAMMAR, text, 'expression', _named_rewrite, children, _DEEP_REWRITE)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def names_in(value: ArithmeticNode) -> tuple[str, ...]:
|
|
424
|
+
"""The names a relation kwarg carries: one bare, several bracketed, none otherwise."""
|
|
425
|
+
if isinstance(value, NameNode):
|
|
426
|
+
return (value.name,)
|
|
427
|
+
return value.names if isinstance(value, NameListNode) else ()
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def literal_number(value: ArithmeticNode) -> NumberNode | None:
|
|
431
|
+
"""The number a literal names, its sign folded in — ``None`` where *value* is not one."""
|
|
432
|
+
if isinstance(value, NumberNode):
|
|
433
|
+
return value
|
|
434
|
+
if isinstance(value, UnaryOperatorNode) and isinstance(value.operand, NumberNode):
|
|
435
|
+
return NumberNode(-value.operand.value if value.op == '-' else value.operand.value)
|
|
436
|
+
return None
|