mathspec 0.2.1__tar.gz → 0.3.0__tar.gz

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.
Files changed (46) hide show
  1. {mathspec-0.2.1 → mathspec-0.3.0}/PKG-INFO +28 -29
  2. {mathspec-0.2.1 → mathspec-0.3.0}/README.md +27 -28
  3. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/__init__.py +18 -20
  4. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/_expression_parser.py +50 -30
  5. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/_expression_resolver.py +315 -264
  6. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/_where_parser.py +24 -17
  7. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/_where_resolver.py +126 -122
  8. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/_yaml.py +5 -10
  9. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/advising.py +16 -16
  10. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/boundedness.py +7 -9
  11. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/canonical.py +1 -1
  12. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/composition.py +134 -69
  13. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/degree.py +16 -25
  14. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/dimensions.py +86 -116
  15. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/errors.py +13 -28
  16. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/exclusivity.py +78 -49
  17. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/expansion.py +35 -8
  18. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/lowering.py +40 -28
  19. mathspec-0.3.0/src/mathspec/operators.py +178 -0
  20. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/piecewise.py +43 -24
  21. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/program.py +275 -102
  22. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/resolution.py +122 -30
  23. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/separability.py +20 -20
  24. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/sos.py +1 -1
  25. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/spec.py +303 -95
  26. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/typesetting/__init__.py +11 -11
  27. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/typesetting/format.py +20 -2
  28. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/typesetting/legend.py +55 -10
  29. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/typesetting/markdown.py +1 -1
  30. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/typesetting/symbols.py +12 -7
  31. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/typesetting/walk.py +99 -99
  32. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/validation.py +44 -49
  33. mathspec-0.2.1/src/mathspec/operators.py +0 -235
  34. {mathspec-0.2.1 → mathspec-0.3.0}/.gitignore +0 -0
  35. {mathspec-0.2.1 → mathspec-0.3.0}/LICENSE +0 -0
  36. {mathspec-0.2.1 → mathspec-0.3.0}/LICENSES/Apache-2.0.txt +0 -0
  37. {mathspec-0.2.1 → mathspec-0.3.0}/LICENSES/CC-BY-4.0.txt +0 -0
  38. {mathspec-0.2.1 → mathspec-0.3.0}/LICENSES/CC0-1.0.txt +0 -0
  39. {mathspec-0.2.1 → mathspec-0.3.0}/LICENSES/MIT.txt +0 -0
  40. {mathspec-0.2.1 → mathspec-0.3.0}/pyproject.toml +0 -0
  41. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/__main__.py +0 -0
  42. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/_sealed.py +0 -0
  43. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/py.typed +0 -0
  44. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/typesetting/README.md +0 -0
  45. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/typesetting/latex.py +0 -0
  46. {mathspec-0.2.1 → mathspec-0.3.0}/src/mathspec/typesetting/typst.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: mathspec
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Write the specification (spec) of an optimisation model as a YAML file. Check it and print it as math, with no data and no solver.
5
5
  Project-URL: Documentation, https://mathspec.readthedocs.io
6
6
  Project-URL: Repository, https://github.com/energy-models/mathspec
@@ -43,11 +43,11 @@ SPDX-License-Identifier: CC-BY-4.0
43
43
  **Write the specification (spec) of an optimisation model as a YAML file. Check it and print it
44
44
  as math, with no data and no solver.**
45
45
 
46
- A mathspec file states a specification, or spec. A spec declares four things: the axes it runs
47
- over, such as `snapshot` and `generator`; the data it expects, such as `load`
48
- and `cost`; the decisions the solver makes, such as `dispatch`; and the rules
49
- those decisions obey, such as `sum(dispatch, over=generator) == load`. The file
50
- [below](#example) is a complete spec.
46
+ A spec declares four things: the dimensions it runs over, such as `snapshot`
47
+ and `generator`; the data it expects, such as `load` and `cost`; the decisions
48
+ the solver makes, such as `dispatch`; and the rules those decisions obey, such
49
+ as `sum(dispatch, over=generator) == load`. The file [below](#example) is a
50
+ complete spec.
51
51
 
52
52
  <!--- --8<-- [start:benefits] -->
53
53
 
@@ -114,6 +114,14 @@ Markdown is one of three formats, and GitHub renders it here.
114
114
 
115
115
  Least-cost dispatch of a generator fleet against an hourly load.
116
116
 
117
+ #### Variable domains
118
+
119
+ **`dispatch`**
120
+
121
+ ```math
122
+ 0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{capacity}_{g} > 0
123
+ ```
124
+
117
125
  #### Objective
118
126
 
119
127
  ```math
@@ -128,14 +136,6 @@ Least-cost dispatch of a generator fleet against an hourly load.
128
136
  \sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T}
129
137
  ```
130
138
 
131
- #### Variable domains
132
-
133
- **`dispatch`**
134
-
135
- ```math
136
- 0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{capacity}_{g} > 0
137
- ```
138
-
139
139
  <details>
140
140
  <summary>The whole document: a symbol table, and the legend it prints</summary>
141
141
 
@@ -162,6 +162,12 @@ Least-cost dispatch of a generator fleet against an hourly load.
162
162
  |---|---|
163
163
  | $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{S} \times \mathcal{G}`$ — output of a generator in a snapshot |
164
164
 
165
+ #### Variable domains
166
+
167
+ **`dispatch`**
168
+
169
+ $`0 \le \mathit{dispatch}_{s,g} \le \bar p_{g} \qquad \forall\, s \in \mathcal{S},\ g \in \mathcal{G} \,:\, \bar p_{g} > 0`$
170
+
165
171
  #### Objective
166
172
 
167
173
  $`\min \sum_{s \in \mathcal{S},\ g \in \mathcal{G}} \mathit{dispatch}_{s,g} \cdot c_{g}`$
@@ -172,12 +178,6 @@ $`\min \sum_{s \in \mathcal{S},\ g \in \mathcal{G}} \mathit{dispatch}_{s,g} \cdo
172
178
 
173
179
  $`\sum_{g \in \mathcal{G}} \mathit{dispatch}_{s,g} = \ell_{s} \qquad \forall\, s \in \mathcal{S}`$
174
180
 
175
- #### Variable domains
176
-
177
- **`dispatch`**
178
-
179
- $`0 \le \mathit{dispatch}_{s,g} \le \bar p_{g} \qquad \forall\, s \in \mathcal{S},\ g \in \mathcal{G} \,:\, \bar p_{g} > 0`$
180
-
181
181
  </details>
182
182
 
183
183
  <!-- readme-math:end -->
@@ -203,7 +203,7 @@ conventional spelling, as in the folded block.
203
203
 
204
204
  <!--- --8<-- [start:engines] -->
205
205
 
206
- mathspec builds nothing and solves nothing itself.
206
+ mathspec builds nothing and solves nothing itself. Instead,
207
207
  [specsolve](https://github.com/fluxopt/specsolve) and
208
208
  [linopy](https://github.com/PyPSA/linopy) build a model from a spec and its
209
209
  data, and solve it. Support in both is work in progress. Any other tool can
@@ -240,11 +240,10 @@ See [installation](https://mathspec.readthedocs.io/en/latest/howto/installation/
240
240
  ## Prior art
241
241
 
242
242
  Every file under `src/` was written in [specsolve](https://github.com/fluxopt/specsolve)
243
- and extracted here. The keys themselves,
244
- which are YAML math, a block per component, `dims:` and a `where:` string,
245
- come from [Calliope](https://github.com/calliope-project/calliope).
246
- [linopy](https://github.com/PyPSA/linopy) supplies the vocabulary that
247
- `sum(over=)` and the dimension rules are named against.
243
+ and extracted here. The keys come from
244
+ [Calliope](https://github.com/calliope-project/calliope): math in YAML, one
245
+ block per component, `dims:` and a `where:` string. `sum(over=)` and the
246
+ dimension rules take their names from [linopy](https://github.com/PyPSA/linopy).
248
247
 
249
248
  ## Status
250
249
 
@@ -255,9 +254,9 @@ Alpha, pre-1.0.
255
254
  **Breaking changes land without a deprecation cycle.** Pin an exact version if
256
255
  you depend on this, and read the
257
256
  [changelog](https://github.com/energy-models/mathspec/blob/main/CHANGELOG.md)
258
- before upgrading. Every construct round-trips through the schema, the parsers
259
- and all three typeset formats, and the LaTeX is compiled. The accepted YAML is
260
- not yet frozen.
257
+ before upgrading. The test suite passes every construct through the schema,
258
+ the parsers and all three typeset formats, and compiles the LaTeX. The YAML that
259
+ mathspec accepts can still change.
261
260
 
262
261
  <!--- --8<-- [end:status] -->
263
262
 
@@ -18,11 +18,11 @@ SPDX-License-Identifier: CC-BY-4.0
18
18
  **Write the specification (spec) of an optimisation model as a YAML file. Check it and print it
19
19
  as math, with no data and no solver.**
20
20
 
21
- A mathspec file states a specification, or spec. A spec declares four things: the axes it runs
22
- over, such as `snapshot` and `generator`; the data it expects, such as `load`
23
- and `cost`; the decisions the solver makes, such as `dispatch`; and the rules
24
- those decisions obey, such as `sum(dispatch, over=generator) == load`. The file
25
- [below](#example) is a complete spec.
21
+ A spec declares four things: the dimensions it runs over, such as `snapshot`
22
+ and `generator`; the data it expects, such as `load` and `cost`; the decisions
23
+ the solver makes, such as `dispatch`; and the rules those decisions obey, such
24
+ as `sum(dispatch, over=generator) == load`. The file [below](#example) is a
25
+ complete spec.
26
26
 
27
27
  <!--- --8<-- [start:benefits] -->
28
28
 
@@ -89,6 +89,14 @@ Markdown is one of three formats, and GitHub renders it here.
89
89
 
90
90
  Least-cost dispatch of a generator fleet against an hourly load.
91
91
 
92
+ #### Variable domains
93
+
94
+ **`dispatch`**
95
+
96
+ ```math
97
+ 0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{capacity}_{g} > 0
98
+ ```
99
+
92
100
  #### Objective
93
101
 
94
102
  ```math
@@ -103,14 +111,6 @@ Least-cost dispatch of a generator fleet against an hourly load.
103
111
  \sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T}
104
112
  ```
105
113
 
106
- #### Variable domains
107
-
108
- **`dispatch`**
109
-
110
- ```math
111
- 0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{capacity}_{g} > 0
112
- ```
113
-
114
114
  <details>
115
115
  <summary>The whole document: a symbol table, and the legend it prints</summary>
116
116
 
@@ -137,6 +137,12 @@ Least-cost dispatch of a generator fleet against an hourly load.
137
137
  |---|---|
138
138
  | $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{S} \times \mathcal{G}`$ — output of a generator in a snapshot |
139
139
 
140
+ #### Variable domains
141
+
142
+ **`dispatch`**
143
+
144
+ $`0 \le \mathit{dispatch}_{s,g} \le \bar p_{g} \qquad \forall\, s \in \mathcal{S},\ g \in \mathcal{G} \,:\, \bar p_{g} > 0`$
145
+
140
146
  #### Objective
141
147
 
142
148
  $`\min \sum_{s \in \mathcal{S},\ g \in \mathcal{G}} \mathit{dispatch}_{s,g} \cdot c_{g}`$
@@ -147,12 +153,6 @@ $`\min \sum_{s \in \mathcal{S},\ g \in \mathcal{G}} \mathit{dispatch}_{s,g} \cdo
147
153
 
148
154
  $`\sum_{g \in \mathcal{G}} \mathit{dispatch}_{s,g} = \ell_{s} \qquad \forall\, s \in \mathcal{S}`$
149
155
 
150
- #### Variable domains
151
-
152
- **`dispatch`**
153
-
154
- $`0 \le \mathit{dispatch}_{s,g} \le \bar p_{g} \qquad \forall\, s \in \mathcal{S},\ g \in \mathcal{G} \,:\, \bar p_{g} > 0`$
155
-
156
156
  </details>
157
157
 
158
158
  <!-- readme-math:end -->
@@ -178,7 +178,7 @@ conventional spelling, as in the folded block.
178
178
 
179
179
  <!--- --8<-- [start:engines] -->
180
180
 
181
- mathspec builds nothing and solves nothing itself.
181
+ mathspec builds nothing and solves nothing itself. Instead,
182
182
  [specsolve](https://github.com/fluxopt/specsolve) and
183
183
  [linopy](https://github.com/PyPSA/linopy) build a model from a spec and its
184
184
  data, and solve it. Support in both is work in progress. Any other tool can
@@ -215,11 +215,10 @@ See [installation](https://mathspec.readthedocs.io/en/latest/howto/installation/
215
215
  ## Prior art
216
216
 
217
217
  Every file under `src/` was written in [specsolve](https://github.com/fluxopt/specsolve)
218
- and extracted here. The keys themselves,
219
- which are YAML math, a block per component, `dims:` and a `where:` string,
220
- come from [Calliope](https://github.com/calliope-project/calliope).
221
- [linopy](https://github.com/PyPSA/linopy) supplies the vocabulary that
222
- `sum(over=)` and the dimension rules are named against.
218
+ and extracted here. The keys come from
219
+ [Calliope](https://github.com/calliope-project/calliope): math in YAML, one
220
+ block per component, `dims:` and a `where:` string. `sum(over=)` and the
221
+ dimension rules take their names from [linopy](https://github.com/PyPSA/linopy).
223
222
 
224
223
  ## Status
225
224
 
@@ -230,9 +229,9 @@ Alpha, pre-1.0.
230
229
  **Breaking changes land without a deprecation cycle.** Pin an exact version if
231
230
  you depend on this, and read the
232
231
  [changelog](https://github.com/energy-models/mathspec/blob/main/CHANGELOG.md)
233
- before upgrading. Every construct round-trips through the schema, the parsers
234
- and all three typeset formats, and the LaTeX is compiled. The accepted YAML is
235
- not yet frozen.
232
+ before upgrading. The test suite passes every construct through the schema,
233
+ the parsers and all three typeset formats, and compiles the LaTeX. The YAML that
234
+ mathspec accepts can still change.
236
235
 
237
236
  <!--- --8<-- [end:status] -->
238
237
 
@@ -4,21 +4,26 @@
4
4
 
5
5
  """The language: what a YAML file may say, and what it means.
6
6
 
7
- Two public states — a [`Spec`][] is what the file *says*,
8
- and its [`program`][mathspec.spec.Spec.program] is what it *means* — and
9
- [`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.
7
+ The top level is what a consumer calls: [`to_spec`][], the one door to a
8
+ file, and the verbs over what it returns. The rest of the surface is three
9
+ modules, each with one rule:
10
+
11
+ - [`mathspec.spec`][] is what the file *says*: [`Spec`][mathspec.spec.Spec]
12
+ and the blocks it holds.
13
+ - [`mathspec.program`][] is what the file *means*: the
14
+ [`Program`][mathspec.program.Program] a spec lowers to, its nodes, and the
15
+ [`Advice`][mathspec.program.Advice] the language gives about it.
16
+ - [`mathspec.errors`][] is what a consumer catches.
17
+
18
+ Everything between them — both grammars and the tree they build — is
19
+ package-private, because a consumer reads a program instead.
12
20
  """
13
21
 
14
- from mathspec import program
22
+ from mathspec import errors, program, spec
15
23
  from mathspec.advising import advice
16
24
  from mathspec.composition import merge, override
17
- from mathspec.errors import Advice, AdviceKind, DimensionError, LanguageError, MathSpecError, SchemaError, did_you_mean
18
- from mathspec.operators import BUILTIN_NAMES
19
- from mathspec.spec import Spec
20
25
  from mathspec.typesetting import (
21
- FORMATS,
26
+ FormatName,
22
27
  SymbolTable,
23
28
  to_latex,
24
29
  to_markdown,
@@ -29,21 +34,14 @@ from mathspec.typesetting import (
29
34
  from mathspec.validation import to_spec
30
35
 
31
36
  __all__ = [
32
- 'BUILTIN_NAMES',
33
- 'FORMATS',
34
- 'Advice',
35
- 'AdviceKind',
36
- 'DimensionError',
37
- 'LanguageError',
38
- 'MathSpecError',
39
- 'SchemaError',
40
- 'Spec',
37
+ 'FormatName',
41
38
  'SymbolTable',
42
39
  'advice',
43
- 'did_you_mean',
40
+ 'errors',
44
41
  'merge',
45
42
  'override',
46
43
  'program',
44
+ 'spec',
47
45
  'to_latex',
48
46
  'to_markdown',
49
47
  'to_spec',
@@ -23,23 +23,23 @@ from mathspec.errors import SchemaError
23
23
  if TYPE_CHECKING:
24
24
  from collections.abc import Callable, Iterable, Iterator, Mapping
25
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.
26
+ #: The three comparison operators an expression may use. A constraint reads
27
+ #: its sense from this operator.
28
28
  ComparisonOperator = Literal['<=', '>=', '==']
29
29
 
30
30
  #: The sign a unary operator applies to its operand.
31
31
  UnaryOperator = Literal['+', '-']
32
32
 
33
- #: The arithmetic a binary operator may spell. Closed by the grammar, and the
34
- #: vocabulary a renderer dispatching on [`BinaryOperatorNode.op`][]
35
- #: switches over — it keeps no list of its own.
33
+ #: The arithmetic a binary operator may spell. The grammar closes the set, and
34
+ #: a renderer that dispatches on [`BinaryOperatorNode.op`][] switches over it
35
+ #: and keeps no list of its own.
36
36
  BinaryOperator = Literal['+', '-', '*', '/', '**']
37
37
 
38
38
  #: What an expression writes to refer to a declaration, and so what a
39
39
  #: declaration may be named.
40
40
  NAME = r'[a-zA-Z_][a-zA-Z0-9_]*'
41
41
 
42
- #: A float — a fractional part or an exponent. A sign is the unary operator's.
42
+ #: A float has a fractional part or an exponent. The unary operator carries the sign.
43
43
  REAL = r'\d+\.\d*([eE][+-]?\d+)?|\d+[eE][+-]?\d+'
44
44
 
45
45
  # ---------------------------------------------------------------------------
@@ -79,6 +79,22 @@ class NameListNode:
79
79
  return shown(self.names)
80
80
 
81
81
 
82
+ @dataclass(frozen=True)
83
+ class ColumnsNode:
84
+ """Columns of one relation in a kwarg value — ``sum(x, over=g, by=gen_bus[bus])``.
85
+
86
+ The relation is written once and its columns after it, so one call cannot
87
+ name columns of two tables. Unresolved: which columns the kwarg admits is
88
+ the operator's business.
89
+ """
90
+
91
+ relation: str
92
+ columns: tuple[str, ...]
93
+
94
+ def __str__(self) -> str:
95
+ return f'{self.relation}[{", ".join(self.columns)}]'
96
+
97
+
82
98
  @dataclass(frozen=True)
83
99
  class KeywordNode:
84
100
  """A quoted closed keyword in a kwarg value — ``shift(..., edge='wrap')``.
@@ -131,10 +147,18 @@ class FunctionCallNode:
131
147
  return f'{self.name}({", ".join(passed)})'
132
148
 
133
149
 
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.
150
+ #: Every arithmetic node the grammar builds. A name, a name list, a column
151
+ #: selection and a quoted keyword are what resolution reads for their kind; the
152
+ #: rest is structure.
136
153
  ArithmeticNode = (
137
- NumberNode | NameNode | NameListNode | KeywordNode | UnaryOperatorNode | BinaryOperatorNode | FunctionCallNode
154
+ NumberNode
155
+ | NameNode
156
+ | NameListNode
157
+ | ColumnsNode
158
+ | KeywordNode
159
+ | UnaryOperatorNode
160
+ | BinaryOperatorNode
161
+ | FunctionCallNode
138
162
  )
139
163
 
140
164
 
@@ -200,7 +224,7 @@ def nodes(*roots: ParsedNode) -> Iterator[ParsedNode]:
200
224
 
201
225
  def with_children(node: ArithmeticNode, recurse: Callable[[ArithmeticNode], ArithmeticNode]) -> ArithmeticNode:
202
226
  """*node* rebuilt with *recurse* applied to each of its [`children`][]; a leaf comes back as is."""
203
- if isinstance(node, NumberNode | NameNode | NameListNode | KeywordNode):
227
+ if isinstance(node, NumberNode | NameNode | NameListNode | ColumnsNode | KeywordNode):
204
228
  return node
205
229
  if isinstance(node, UnaryOperatorNode):
206
230
  return UnaryOperatorNode(node.op, recurse(node.operand))
@@ -227,8 +251,8 @@ NAME_LIST = (pp.Suppress('[') + pp.DelimitedList(pp.Regex(NAME)) + pp.Suppress('
227
251
  )
228
252
 
229
253
 
230
- def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement]:
231
- """The arithmetic grammar, and the expression grammar that puts one comparison over it.
254
+ def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement, pp.ParserElement]:
255
+ """The arithmetic grammar, the expression grammar that puts one comparison over it, and a column selection.
232
256
 
233
257
  ``inf`` is a ``pp.Keyword`` rather than a ``pp.Literal``, which would
234
258
  match the prefix of ``inflow``.
@@ -242,7 +266,10 @@ def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement]:
242
266
  name = pp.Regex(NAME)
243
267
 
244
268
  quoted = (pp.QuotedString("'") | pp.QuotedString('"')).set_parse_action(lambda t: KeywordNode(str(t[0])))
245
- kwarg = (name + pp.Suppress('=') + (quoted | NAME_LIST | arith)).set_parse_action(lambda t: (t[0], t[1]))
269
+ columns = (name + pp.Suppress('[') + pp.DelimitedList(name) + pp.Suppress(']')).set_parse_action(
270
+ lambda t: ColumnsNode(str(t[0]), tuple(str(x) for x in t[1:]))
271
+ )
272
+ kwarg = (name + pp.Suppress('=') + (quoted | columns | NAME_LIST | arith)).set_parse_action(lambda t: (t[0], t[1]))
246
273
  pos_arg = arith
247
274
  arg_list = pp.Optional(pp.DelimitedList(kwarg | pos_arg))
248
275
  func_call = (name + pp.Suppress('(') + arg_list + pp.Suppress(')')).set_parse_action(_make_func_call)
@@ -268,7 +295,7 @@ def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement]:
268
295
  expression = (arith + pp.Optional(comparator + arith)).set_parse_action(
269
296
  lambda t: ComparisonNode(t[1], t[0], t[2]) if len(t) == 3 else t[0]
270
297
  )
271
- return arith, expression
298
+ return arith, expression, columns
272
299
 
273
300
 
274
301
  def _make_func_call(tokens: pp.ParseResults) -> FunctionCallNode:
@@ -286,7 +313,7 @@ def keywords[V](name: str, pairs: Iterable[tuple[str, V]]) -> dict[str, V]:
286
313
  kwargs: dict[str, V] = {}
287
314
  for key, value in pairs:
288
315
  if key in kwargs:
289
- msg = f'{name}({key}=) is given twice. A keyword names one value; drop one of them.'
316
+ msg = f'{name}({key}=) is given twice. Delete one of them.'
290
317
  raise SchemaError(msg)
291
318
  kwargs[key] = value
292
319
  return kwargs
@@ -308,7 +335,9 @@ def _make_power(tokens: pp.ParseResults) -> ArithmeticNode:
308
335
 
309
336
  #: The arithmetic half on its own, for the where grammar to put a predicate's
310
337
  #: comparator over: one grammar for what a side may say, wherever it stands.
311
- ARITHMETIC, _GRAMMAR = _build_grammar()
338
+ #: The column selection is shared too, so a kwarg reads one the same way in
339
+ #: both grammars.
340
+ ARITHMETIC, _GRAMMAR, COLUMNS = _build_grammar()
312
341
 
313
342
 
314
343
  #: How deep a tree the language admits. Every pass over an expression recurses,
@@ -363,7 +392,7 @@ def parse_text[T](
363
392
  result = grammar.parse_string(text, parse_all=True)
364
393
  except pp.ParseException as e:
365
394
  hint = rewrite(text, e.loc)
366
- msg = f'Failed to parse {what}: {text!r}\n{f"{hint}\n" if hint is not None else ""}{e}'
395
+ msg = f'Cannot parse the {what} {text!r}.\n{f"{hint}\n" if hint is not None else ""}{e}'
367
396
  raise SchemaError(msg) from e
368
397
  except RecursionError:
369
398
  raise SchemaError(_too_deep(what, text, None, deep_rewrite)) from None
@@ -381,22 +410,13 @@ def _named_rewrite(text: str, loc: int) -> str | None:
381
410
  """
382
411
  rest = text[loc:].lstrip()
383
412
  if rest.startswith(get_args(ComparisonOperator)):
384
- return (
385
- f"'{rest[:2]}' follows a complete comparison, and an expression carries "
386
- f'one comparison, at the top. Split the chain into two constraints.'
387
- )
413
+ return f"'{rest[:2]}' follows a complete comparison. Split the chain into two constraints."
388
414
  if rest.startswith('!='):
389
- return (
390
- "'!=' is not a constraint sense — the senses are <=, >= and ==. "
391
- 'Holding rows apart is a where matter: write the test in where:, where != is legal.'
392
- )
415
+ return "'!=' is not a constraint sense. Write <=, >= or ==, or move the != test to where:."
393
416
  if rest.startswith(('<', '>')):
394
- return f"'{rest[0]}' is not a constraint sense — the senses are <=, >= and ==. Write the bound inclusive."
417
+ return f"'{rest[0]}' is not a constraint sense. Write '{rest[0]}=' instead."
395
418
  if rest.startswith('='):
396
- return (
397
- "'=' on its own is how a kwarg is written inside a call, like sum(x, over=d). "
398
- 'Equality between two sides is written ==.'
399
- )
419
+ return "A single '=' passes a keyword argument, as in sum(x, over=d). Write == for equality."
400
420
  if rest.startswith('^'):
401
421
  return "power is written '**', not '^'."
402
422
  return None