mathspec 0.2.0__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 (48) hide show
  1. {mathspec-0.2.0 → mathspec-0.3.0}/PKG-INFO +56 -37
  2. {mathspec-0.2.0 → mathspec-0.3.0}/README.md +55 -36
  3. {mathspec-0.2.0 → mathspec-0.3.0}/pyproject.toml +3 -2
  4. mathspec-0.3.0/src/mathspec/__init__.py +60 -0
  5. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/__main__.py +51 -14
  6. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/_expression_parser.py +56 -32
  7. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/_expression_resolver.py +316 -249
  8. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/_where_parser.py +24 -16
  9. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/_where_resolver.py +151 -125
  10. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/_yaml.py +5 -10
  11. mathspec-0.3.0/src/mathspec/advising.py +137 -0
  12. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/boundedness.py +11 -11
  13. mathspec-0.3.0/src/mathspec/canonical.py +268 -0
  14. mathspec-0.3.0/src/mathspec/composition.py +965 -0
  15. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/degree.py +17 -52
  16. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/dimensions.py +116 -120
  17. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/errors.py +16 -31
  18. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/exclusivity.py +78 -71
  19. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/expansion.py +56 -10
  20. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/lowering.py +135 -21
  21. mathspec-0.3.0/src/mathspec/operators.py +178 -0
  22. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/piecewise.py +43 -24
  23. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/program.py +333 -110
  24. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/resolution.py +134 -37
  25. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/separability.py +20 -20
  26. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/sos.py +1 -1
  27. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/spec.py +428 -99
  28. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/typesetting/__init__.py +26 -9
  29. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/typesetting/format.py +20 -2
  30. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/typesetting/legend.py +96 -10
  31. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/typesetting/markdown.py +1 -1
  32. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/typesetting/symbols.py +46 -20
  33. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/typesetting/walk.py +133 -109
  34. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/validation.py +67 -49
  35. mathspec-0.2.0/src/mathspec/__init__.py +0 -59
  36. mathspec-0.2.0/src/mathspec/advising.py +0 -85
  37. mathspec-0.2.0/src/mathspec/operators.py +0 -235
  38. {mathspec-0.2.0 → mathspec-0.3.0}/.gitignore +0 -0
  39. {mathspec-0.2.0 → mathspec-0.3.0}/LICENSE +0 -0
  40. {mathspec-0.2.0 → mathspec-0.3.0}/LICENSES/Apache-2.0.txt +0 -0
  41. {mathspec-0.2.0 → mathspec-0.3.0}/LICENSES/CC-BY-4.0.txt +0 -0
  42. {mathspec-0.2.0 → mathspec-0.3.0}/LICENSES/CC0-1.0.txt +0 -0
  43. {mathspec-0.2.0 → mathspec-0.3.0}/LICENSES/MIT.txt +0 -0
  44. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/_sealed.py +0 -0
  45. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/py.typed +0 -0
  46. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/typesetting/README.md +0 -0
  47. {mathspec-0.2.0 → mathspec-0.3.0}/src/mathspec/typesetting/latex.py +0 -0
  48. {mathspec-0.2.0 → 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.0
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,20 +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.
51
-
52
- <!--- --8<-- [start:engines] -->
53
-
54
- mathspec builds nothing and solves nothing itself.
55
- [specsolve](https://github.com/fluxopt/specsolve) and
56
- [linopy](https://github.com/PyPSA/linopy) build a model from a spec and its
57
- data, and solve it. Support in both is work in progress.
58
-
59
- <!--- --8<-- [end:engines] -->
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.
60
51
 
61
52
  <!--- --8<-- [start:benefits] -->
62
53
 
@@ -123,6 +114,14 @@ Markdown is one of three formats, and GitHub renders it here.
123
114
 
124
115
  Least-cost dispatch of a generator fleet against an hourly load.
125
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
+
126
125
  #### Objective
127
126
 
128
127
  ```math
@@ -137,14 +136,6 @@ Least-cost dispatch of a generator fleet against an hourly load.
137
136
  \sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T}
138
137
  ```
139
138
 
140
- #### Variable domains
141
-
142
- **`dispatch`**
143
-
144
- ```math
145
- 0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{capacity}_{g} > 0
146
- ```
147
-
148
139
  <details>
149
140
  <summary>The whole document: a symbol table, and the legend it prints</summary>
150
141
 
@@ -171,6 +162,12 @@ Least-cost dispatch of a generator fleet against an hourly load.
171
162
  |---|---|
172
163
  | $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{S} \times \mathcal{G}`$ — output of a generator in a snapshot |
173
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
+
174
171
  #### Objective
175
172
 
176
173
  $`\min \sum_{s \in \mathcal{S},\ g \in \mathcal{G}} \mathit{dispatch}_{s,g} \cdot c_{g}`$
@@ -181,12 +178,6 @@ $`\min \sum_{s \in \mathcal{S},\ g \in \mathcal{G}} \mathit{dispatch}_{s,g} \cdo
181
178
 
182
179
  $`\sum_{g \in \mathcal{G}} \mathit{dispatch}_{s,g} = \ell_{s} \qquad \forall\, s \in \mathcal{S}`$
183
180
 
184
- #### Variable domains
185
-
186
- **`dispatch`**
187
-
188
- $`0 \le \mathit{dispatch}_{s,g} \le \bar p_{g} \qquad \forall\, s \in \mathcal{S},\ g \in \mathcal{G} \,:\, \bar p_{g} > 0`$
189
-
190
181
  </details>
191
182
 
192
183
  <!-- readme-math:end -->
@@ -208,6 +199,35 @@ A [symbol table](https://mathspec.readthedocs.io/en/latest/reference/typeset/#sy
208
199
  conventional spelling, as in the folded block.
209
200
  [Print a spec as math](https://mathspec.readthedocs.io/en/latest/howto/print/) does the same from a shell.
210
201
 
202
+ ## Engines and other tools
203
+
204
+ <!--- --8<-- [start:engines] -->
205
+
206
+ mathspec builds nothing and solves nothing itself. Instead,
207
+ [specsolve](https://github.com/fluxopt/specsolve) and
208
+ [linopy](https://github.com/PyPSA/linopy) build a model from a spec and its
209
+ data, and solve it. Support in both is work in progress. Any other tool can
210
+ read the same spec through the
211
+ [Program API](https://mathspec.readthedocs.io/en/latest/reference/program/).
212
+ The solid boxes are mathspec; the dashed boxes are outside it.
213
+
214
+ ```mermaid
215
+ flowchart LR
216
+ accTitle: What mathspec does, and what other tools do with a spec
217
+ accDescr: A YAML file loads into a Spec and the Program it lowers to. mathspec checks the spec and prints it as math, with no data. Outside mathspec, drawn dashed, an engine such as specsolve or linopy reads the same spec, takes your data and returns your answers, and any other tool, such as a renderer or an analyser, reads the same spec through the Program API.
218
+ Y(["your spec<br/>one YAML file"]) --> SPEC["<b>Spec</b> and the <b>Program</b> it lowers to<br/><i>checked before any data exists</i>"]
219
+ SPEC --> CHECK["<b>check it</b><br/>python -m mathspec check"]
220
+ SPEC --> SHOW["<b>print it as math</b><br/>LaTeX · Typst · Markdown"]
221
+ SPEC -.-> OTHER["<b>your own tool</b><br/>a renderer · an analyser · …<br/>reads the Program API"]
222
+ SPEC -.-> ENGINE["<b>an engine</b><br/>specsolve · linopy<br/>builds and solves the model"]
223
+ DATA[("your data")] -.-> ENGINE
224
+ ENGINE -.-> ANS(["your answers"])
225
+ classDef outside stroke-dasharray:5 4
226
+ class ENGINE,DATA,ANS,OTHER outside
227
+ ```
228
+
229
+ <!--- --8<-- [end:engines] -->
230
+
211
231
  ## Documentation
212
232
 
213
233
  The documentation is at <https://mathspec.readthedocs.io>.
@@ -220,11 +240,10 @@ See [installation](https://mathspec.readthedocs.io/en/latest/howto/installation/
220
240
  ## Prior art
221
241
 
222
242
  Every file under `src/` was written in [specsolve](https://github.com/fluxopt/specsolve)
223
- and extracted here. The keys themselves,
224
- which are YAML math, a block per component, `dims:` and a `where:` string,
225
- come from [Calliope](https://github.com/calliope-project/calliope).
226
- [linopy](https://github.com/PyPSA/linopy) supplies the vocabulary that
227
- `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).
228
247
 
229
248
  ## Status
230
249
 
@@ -235,9 +254,9 @@ Alpha, pre-1.0.
235
254
  **Breaking changes land without a deprecation cycle.** Pin an exact version if
236
255
  you depend on this, and read the
237
256
  [changelog](https://github.com/energy-models/mathspec/blob/main/CHANGELOG.md)
238
- before upgrading. Every construct round-trips through the schema, the parsers
239
- and all three typeset formats, and the LaTeX is compiled. The accepted YAML is
240
- 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.
241
260
 
242
261
  <!--- --8<-- [end:status] -->
243
262
 
@@ -18,20 +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.
26
-
27
- <!--- --8<-- [start:engines] -->
28
-
29
- mathspec builds nothing and solves nothing itself.
30
- [specsolve](https://github.com/fluxopt/specsolve) and
31
- [linopy](https://github.com/PyPSA/linopy) build a model from a spec and its
32
- data, and solve it. Support in both is work in progress.
33
-
34
- <!--- --8<-- [end:engines] -->
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.
35
26
 
36
27
  <!--- --8<-- [start:benefits] -->
37
28
 
@@ -98,6 +89,14 @@ Markdown is one of three formats, and GitHub renders it here.
98
89
 
99
90
  Least-cost dispatch of a generator fleet against an hourly load.
100
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
+
101
100
  #### Objective
102
101
 
103
102
  ```math
@@ -112,14 +111,6 @@ Least-cost dispatch of a generator fleet against an hourly load.
112
111
  \sum_{g \in \mathcal{G}} \mathit{dispatch}_{t,g} = \mathrm{load}_{t} \qquad \forall\, t \in \mathcal{T}
113
112
  ```
114
113
 
115
- #### Variable domains
116
-
117
- **`dispatch`**
118
-
119
- ```math
120
- 0 \le \mathit{dispatch}_{t,g} \le \mathrm{capacity}_{g} \qquad \forall\, t \in \mathcal{T},\ g \in \mathcal{G} \,:\, \mathrm{capacity}_{g} > 0
121
- ```
122
-
123
114
  <details>
124
115
  <summary>The whole document: a symbol table, and the legend it prints</summary>
125
116
 
@@ -146,6 +137,12 @@ Least-cost dispatch of a generator fleet against an hourly load.
146
137
  |---|---|
147
138
  | $`\mathit{dispatch}`$ | `dispatch` over $`\mathcal{S} \times \mathcal{G}`$ — output of a generator in a snapshot |
148
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
+
149
146
  #### Objective
150
147
 
151
148
  $`\min \sum_{s \in \mathcal{S},\ g \in \mathcal{G}} \mathit{dispatch}_{s,g} \cdot c_{g}`$
@@ -156,12 +153,6 @@ $`\min \sum_{s \in \mathcal{S},\ g \in \mathcal{G}} \mathit{dispatch}_{s,g} \cdo
156
153
 
157
154
  $`\sum_{g \in \mathcal{G}} \mathit{dispatch}_{s,g} = \ell_{s} \qquad \forall\, s \in \mathcal{S}`$
158
155
 
159
- #### Variable domains
160
-
161
- **`dispatch`**
162
-
163
- $`0 \le \mathit{dispatch}_{s,g} \le \bar p_{g} \qquad \forall\, s \in \mathcal{S},\ g \in \mathcal{G} \,:\, \bar p_{g} > 0`$
164
-
165
156
  </details>
166
157
 
167
158
  <!-- readme-math:end -->
@@ -183,6 +174,35 @@ A [symbol table](https://mathspec.readthedocs.io/en/latest/reference/typeset/#sy
183
174
  conventional spelling, as in the folded block.
184
175
  [Print a spec as math](https://mathspec.readthedocs.io/en/latest/howto/print/) does the same from a shell.
185
176
 
177
+ ## Engines and other tools
178
+
179
+ <!--- --8<-- [start:engines] -->
180
+
181
+ mathspec builds nothing and solves nothing itself. Instead,
182
+ [specsolve](https://github.com/fluxopt/specsolve) and
183
+ [linopy](https://github.com/PyPSA/linopy) build a model from a spec and its
184
+ data, and solve it. Support in both is work in progress. Any other tool can
185
+ read the same spec through the
186
+ [Program API](https://mathspec.readthedocs.io/en/latest/reference/program/).
187
+ The solid boxes are mathspec; the dashed boxes are outside it.
188
+
189
+ ```mermaid
190
+ flowchart LR
191
+ accTitle: What mathspec does, and what other tools do with a spec
192
+ accDescr: A YAML file loads into a Spec and the Program it lowers to. mathspec checks the spec and prints it as math, with no data. Outside mathspec, drawn dashed, an engine such as specsolve or linopy reads the same spec, takes your data and returns your answers, and any other tool, such as a renderer or an analyser, reads the same spec through the Program API.
193
+ Y(["your spec<br/>one YAML file"]) --> SPEC["<b>Spec</b> and the <b>Program</b> it lowers to<br/><i>checked before any data exists</i>"]
194
+ SPEC --> CHECK["<b>check it</b><br/>python -m mathspec check"]
195
+ SPEC --> SHOW["<b>print it as math</b><br/>LaTeX · Typst · Markdown"]
196
+ SPEC -.-> OTHER["<b>your own tool</b><br/>a renderer · an analyser · …<br/>reads the Program API"]
197
+ SPEC -.-> ENGINE["<b>an engine</b><br/>specsolve · linopy<br/>builds and solves the model"]
198
+ DATA[("your data")] -.-> ENGINE
199
+ ENGINE -.-> ANS(["your answers"])
200
+ classDef outside stroke-dasharray:5 4
201
+ class ENGINE,DATA,ANS,OTHER outside
202
+ ```
203
+
204
+ <!--- --8<-- [end:engines] -->
205
+
186
206
  ## Documentation
187
207
 
188
208
  The documentation is at <https://mathspec.readthedocs.io>.
@@ -195,11 +215,10 @@ See [installation](https://mathspec.readthedocs.io/en/latest/howto/installation/
195
215
  ## Prior art
196
216
 
197
217
  Every file under `src/` was written in [specsolve](https://github.com/fluxopt/specsolve)
198
- and extracted here. The keys themselves,
199
- which are YAML math, a block per component, `dims:` and a `where:` string,
200
- come from [Calliope](https://github.com/calliope-project/calliope).
201
- [linopy](https://github.com/PyPSA/linopy) supplies the vocabulary that
202
- `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).
203
222
 
204
223
  ## Status
205
224
 
@@ -210,9 +229,9 @@ Alpha, pre-1.0.
210
229
  **Breaking changes land without a deprecation cycle.** Pin an exact version if
211
230
  you depend on this, and read the
212
231
  [changelog](https://github.com/energy-models/mathspec/blob/main/CHANGELOG.md)
213
- before upgrading. Every construct round-trips through the schema, the parsers
214
- and all three typeset formats, and the LaTeX is compiled. The accepted YAML is
215
- 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.
216
235
 
217
236
  <!--- --8<-- [end:status] -->
218
237
 
@@ -211,12 +211,13 @@ extend-exclude = ["CHANGELOG.md"]
211
211
  [tool.typos.default.extend-words]
212
212
  # Each of these is deliberate, and none is reachable by fixing a spelling.
213
213
  #
214
- # `wher` and `generatr` are misspellings *on purpose*: they are the input to the
215
- # did-you-mean suggester, so correcting them deletes the case. `missable` is the
214
+ # `wher`, `generatr` and `balnce` are misspellings *on purpose*: they are the
215
+ # input to the did-you-mean suggester, so correcting them deletes the case. `missable` is the
216
216
  # word the sentence wants — a coordinate that can go missing — and is not
217
217
  # "miscible". `unparseable` is the accepted variant and is the name of a test.
218
218
  wher = "wher"
219
219
  generatr = "generatr"
220
+ balnce = "balnce"
220
221
  missable = "missable"
221
222
  unparseable = "unparseable"
222
223
  # `fom` is PyPSA's `fom_cost` — fixed operation and maintenance — quoted by name
@@ -0,0 +1,60 @@
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
+ 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.
20
+ """
21
+
22
+ from mathspec import errors, program, spec
23
+ from mathspec.advising import advice
24
+ from mathspec.composition import merge, override
25
+ from mathspec.typesetting import (
26
+ FormatName,
27
+ SymbolTable,
28
+ to_latex,
29
+ to_markdown,
30
+ to_typst,
31
+ typeset,
32
+ typeset_declaration,
33
+ )
34
+ from mathspec.validation import to_spec
35
+
36
+ __all__ = [
37
+ 'FormatName',
38
+ 'SymbolTable',
39
+ 'advice',
40
+ 'errors',
41
+ 'merge',
42
+ 'override',
43
+ 'program',
44
+ 'spec',
45
+ 'to_latex',
46
+ 'to_markdown',
47
+ 'to_spec',
48
+ 'to_typst',
49
+ 'typeset',
50
+ 'typeset_declaration',
51
+ ]
52
+
53
+ import warnings as _warnings
54
+ from importlib import metadata as _metadata
55
+
56
+ try:
57
+ __version__ = _metadata.version(__name__)
58
+ except _metadata.PackageNotFoundError as e: # pragma: no cover
59
+ _warnings.warn(f'Could not determine version of {__name__}\n{e!s}', stacklevel=2)
60
+ __version__ = 'unknown'
@@ -4,9 +4,11 @@
4
4
 
5
5
  """``python -m mathspec <verb> spec.yaml`` — the shell front.
6
6
 
7
- ``check`` loads the file and prints the language's advice; one further verb
8
- per typeset format, read off [`mathspec.typesetting.FORMATS`][]. Every verb
9
- reads the file as written, and nothing here writes a formulation out unasked.
7
+ ``check`` loads the file and prints the language's advice; ``canonical``
8
+ writes the spec in the form two files that state the same spec share, or with
9
+ ``--check`` asks whether the file is already in that form; one further verb per
10
+ typeset format, read off [`mathspec.typesetting.FORMATS`][]. Every verb reads
11
+ the file as written, and nothing here writes a formulation out unasked.
10
12
  The typeset verbs take ``--expand``, because a shell cannot compose
11
13
  [`expand`][mathspec.spec.Spec.expand] the way a caller does and the rows are a
12
14
  different document; ``check`` has no such flag, because advice reads a block
@@ -33,6 +35,13 @@ def parser() -> argparse.ArgumentParser:
33
35
  check = verbs.add_parser('check', help='load a spec, and print what the language advises')
34
36
  check.add_argument('spec', help='path to a mathspec YAML file')
35
37
 
38
+ canonical = verbs.add_parser('canonical', help='write a spec in the form two files that state it share')
39
+ canonical.add_argument('spec', help='path to a mathspec YAML file')
40
+ target = canonical.add_mutually_exclusive_group()
41
+ target.add_argument('-o', '--out', help='write here instead of stdout')
42
+ target.add_argument('--write', action='store_true', help='rewrite the file in the form, dropping its comments')
43
+ target.add_argument('--check', action='store_true', help='exit 1 if the file is not in the form, writing nothing')
44
+
36
45
  for name in FORMATS:
37
46
  verb = verbs.add_parser(name, help=f'render a spec as {name}')
38
47
  verb.add_argument('spec', help='path to a mathspec YAML file')
@@ -52,10 +61,26 @@ def parser() -> argparse.ArgumentParser:
52
61
  return front
53
62
 
54
63
 
64
+ def _checked(path: str, text: str) -> int:
65
+ """Exit status 0 if the file at *path* already holds *text*, the form, and 1 with the rewrite on stderr if not.
66
+
67
+ A CI job runs this, so it compares bytes and writes nothing: the job fails,
68
+ and the author runs the rewrite the message names.
69
+ """
70
+ if Path(path).read_text(encoding='utf-8') == text:
71
+ return 0
72
+ sys.stderr.write(
73
+ f'{path} is not in the canonical form. Run `python -m mathspec canonical --write {path}` to rewrite it.\n'
74
+ )
75
+ return 1
76
+
77
+
55
78
  def main(argv: list[str] | None = None) -> int:
56
79
  """Run one verb; a refused file is its message on stderr and exit status 1.
57
80
 
58
- Advice is not a refusal: ``check`` prints it and exits 0.
81
+ Advice is not a refusal: ``check`` prints it and exits 0. A file that
82
+ ``canonical --check`` finds out of the form exits 1, with the rewrite on
83
+ stderr.
59
84
  """
60
85
  args = parser().parse_args(argv)
61
86
  if args.verb == 'check':
@@ -66,16 +91,28 @@ def main(argv: list[str] | None = None) -> int:
66
91
  return 1
67
92
  sys.stdout.write(''.join(f'{note}\n' for note in notes))
68
93
  return 0
69
- spec = to_spec(args.spec).expand() if args.expand else args.spec
70
- text = typeset(
71
- spec,
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
- )
94
+ if args.verb == 'canonical':
95
+ try:
96
+ text = to_spec(args.spec).to_yaml(canonical=True)
97
+ except MathSpecError as e:
98
+ sys.stderr.write(f'{e}\n')
99
+ return 1
100
+ if args.check:
101
+ return _checked(args.spec, text)
102
+ if args.write:
103
+ Path(args.spec).write_text(text, encoding='utf-8')
104
+ return 0
105
+ else:
106
+ spec = to_spec(args.spec).expand() if args.expand else args.spec
107
+ text = typeset(
108
+ spec,
109
+ args.verb,
110
+ symbols=args.symbols,
111
+ standalone=args.standalone,
112
+ legend=not args.no_legend,
113
+ numbered=not args.no_numbers,
114
+ inline_expressions=args.inline_expressions,
115
+ )
79
116
  if args.out:
80
117
  Path(args.out).write_text(text, encoding='utf-8')
81
118
  else:
@@ -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))
@@ -220,8 +244,15 @@ def with_children(node: ArithmeticNode, recurse: Callable[[ArithmeticNode], Arit
220
244
  # ---------------------------------------------------------------------------
221
245
 
222
246
 
223
- def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement]:
224
- """The arithmetic grammar, and the expression grammar that puts one comparison over it.
247
+ #: A bracketed list of names as a kwarg value, in an expression and in a where
248
+ #: string alike.
249
+ NAME_LIST = (pp.Suppress('[') + pp.DelimitedList(pp.Regex(NAME)) + pp.Suppress(']')).set_parse_action(
250
+ lambda t: NameListNode(tuple(str(x) for x in t))
251
+ )
252
+
253
+
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.
225
256
 
226
257
  ``inf`` is a ``pp.Keyword`` rather than a ``pp.Literal``, which would
227
258
  match the prefix of ``inflow``.
@@ -235,10 +266,10 @@ def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement]:
235
266
  name = pp.Regex(NAME)
236
267
 
237
268
  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))
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:]))
240
271
  )
241
- kwarg = (name + pp.Suppress('=') + (quoted | name_list | arith)).set_parse_action(lambda t: (t[0], t[1]))
272
+ kwarg = (name + pp.Suppress('=') + (quoted | columns | NAME_LIST | arith)).set_parse_action(lambda t: (t[0], t[1]))
242
273
  pos_arg = arith
243
274
  arg_list = pp.Optional(pp.DelimitedList(kwarg | pos_arg))
244
275
  func_call = (name + pp.Suppress('(') + arg_list + pp.Suppress(')')).set_parse_action(_make_func_call)
@@ -264,7 +295,7 @@ def _build_grammar() -> tuple[pp.ParserElement, pp.ParserElement]:
264
295
  expression = (arith + pp.Optional(comparator + arith)).set_parse_action(
265
296
  lambda t: ComparisonNode(t[1], t[0], t[2]) if len(t) == 3 else t[0]
266
297
  )
267
- return arith, expression
298
+ return arith, expression, columns
268
299
 
269
300
 
270
301
  def _make_func_call(tokens: pp.ParseResults) -> FunctionCallNode:
@@ -282,7 +313,7 @@ def keywords[V](name: str, pairs: Iterable[tuple[str, V]]) -> dict[str, V]:
282
313
  kwargs: dict[str, V] = {}
283
314
  for key, value in pairs:
284
315
  if key in kwargs:
285
- 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.'
286
317
  raise SchemaError(msg)
287
318
  kwargs[key] = value
288
319
  return kwargs
@@ -304,7 +335,9 @@ def _make_power(tokens: pp.ParseResults) -> ArithmeticNode:
304
335
 
305
336
  #: The arithmetic half on its own, for the where grammar to put a predicate's
306
337
  #: comparator over: one grammar for what a side may say, wherever it stands.
307
- 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()
308
341
 
309
342
 
310
343
  #: How deep a tree the language admits. Every pass over an expression recurses,
@@ -359,7 +392,7 @@ def parse_text[T](
359
392
  result = grammar.parse_string(text, parse_all=True)
360
393
  except pp.ParseException as e:
361
394
  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}'
395
+ msg = f'Cannot parse the {what} {text!r}.\n{f"{hint}\n" if hint is not None else ""}{e}'
363
396
  raise SchemaError(msg) from e
364
397
  except RecursionError:
365
398
  raise SchemaError(_too_deep(what, text, None, deep_rewrite)) from None
@@ -377,22 +410,13 @@ def _named_rewrite(text: str, loc: int) -> str | None:
377
410
  """
378
411
  rest = text[loc:].lstrip()
379
412
  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
- )
413
+ return f"'{rest[:2]}' follows a complete comparison. Split the chain into two constraints."
384
414
  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
- )
415
+ return "'!=' is not a constraint sense. Write <=, >= or ==, or move the != test to where:."
389
416
  if rest.startswith(('<', '>')):
390
- 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."
391
418
  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
- )
419
+ return "A single '=' passes a keyword argument, as in sum(x, over=d). Write == for equality."
396
420
  if rest.startswith('^'):
397
421
  return "power is written '**', not '^'."
398
422
  return None