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