pyvexfile 2.0.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.
- pyvexfile/__init__.py +35 -0
- pyvexfile/__main__.py +6 -0
- pyvexfile/cli.py +282 -0
- pyvexfile/lexer.py +228 -0
- pyvexfile/nodes.py +633 -0
- pyvexfile/observation.py +213 -0
- pyvexfile/parser.py +318 -0
- pyvexfile/units.py +69 -0
- pyvexfile/verify.py +556 -0
- pyvexfile-2.0.0.dist-info/METADATA +635 -0
- pyvexfile-2.0.0.dist-info/RECORD +15 -0
- pyvexfile-2.0.0.dist-info/WHEEL +5 -0
- pyvexfile-2.0.0.dist-info/entry_points.txt +2 -0
- pyvexfile-2.0.0.dist-info/licenses/LICENSE +675 -0
- pyvexfile-2.0.0.dist-info/top_level.txt +1 -0
pyvexfile/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""pyvexfile: parser for VEX files that keeps the comments and layout of the original file.
|
|
2
|
+
|
|
3
|
+
Usage
|
|
4
|
+
-----
|
|
5
|
+
|
|
6
|
+
from pyvexfile import Vex
|
|
7
|
+
|
|
8
|
+
vex = Vex.from_file("experiment.vex")
|
|
9
|
+
vex["STATION"]["Jb"]["$SITE"] # value of 'ref $SITE = ...;'
|
|
10
|
+
vex["FREQ"]["4632MHz32x32MHz"].get_all("chan_def")
|
|
11
|
+
vex["EXPER"]["N26C3"]["exper_name"] = "N26C4"
|
|
12
|
+
problems = vex.verify()
|
|
13
|
+
vex.to_file("new.vex")
|
|
14
|
+
|
|
15
|
+
Unmodified parts of the file are written exactly as they were read, comments included.
|
|
16
|
+
"""
|
|
17
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
18
|
+
|
|
19
|
+
from .lexer import VexSyntaxError
|
|
20
|
+
from .nodes import BlankLine, Block, Comment, Def, Literal, Node, Scan, Statement, Value, Vex
|
|
21
|
+
from .observation import IF, Channel, Station
|
|
22
|
+
from .parser import parse
|
|
23
|
+
from .verify import Issue, Severity
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__: list[str] = [
|
|
27
|
+
"Vex", "Block", "Def", "Scan", "Statement", "Comment", "BlankLine", "Literal", "Node",
|
|
28
|
+
"Value", "Station", "Channel", "IF", "Issue", "Severity", "VexSyntaxError", "parse",
|
|
29
|
+
"__version__",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
__version__ = version("pyvexfile")
|
|
34
|
+
except PackageNotFoundError: # running from the source tree without installing it
|
|
35
|
+
__version__ = "unknown"
|
pyvexfile/__main__.py
ADDED
pyvexfile/cli.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Command line interface to inspect VEX files.
|
|
2
|
+
|
|
3
|
+
vex experiment.vex summary of the observation
|
|
4
|
+
vex experiment.vex --stations list of stations
|
|
5
|
+
vex experiment.vex --if IF setups of each mode
|
|
6
|
+
vex experiment.vex --scans list of scans
|
|
7
|
+
vex experiment.vex --freq frequencies observed by each station, with a plot
|
|
8
|
+
vex experiment.vex --verify check the file (exit status 1 if it has errors)
|
|
9
|
+
vex experiment.vex --version VEX version of the file
|
|
10
|
+
|
|
11
|
+
Several options can be combined.
|
|
12
|
+
"""
|
|
13
|
+
import argparse
|
|
14
|
+
import math
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
from collections import Counter
|
|
18
|
+
from collections.abc import Callable, Sequence
|
|
19
|
+
from datetime import timedelta
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .lexer import VexSyntaxError
|
|
23
|
+
from .nodes import Vex
|
|
24
|
+
from .observation import IF, covered_ranges
|
|
25
|
+
from .verify import Severity
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
__all__: list[str] = ["main"]
|
|
29
|
+
|
|
30
|
+
# Characters used in the frequency plot, depending on how much of each cell is covered
|
|
31
|
+
_FULL, _PARTIAL, _EMPTY = "█", "▌", " "
|
|
32
|
+
_MIN_PLOT_WIDTH = 20
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _table(headers: Sequence[str], rows: Sequence[Sequence[object]], indent: str = "") -> str:
|
|
36
|
+
"""Returns the rows as a text table with aligned columns."""
|
|
37
|
+
cells = [[str(cell) for cell in row] for row in rows]
|
|
38
|
+
widths = [max([len(header)] + [len(row[i]) for row in cells]) for i, header in enumerate(headers)]
|
|
39
|
+
lines = [headers, ["-" * width for width in widths]] + cells
|
|
40
|
+
return "\n".join(indent + " ".join(cell.ljust(width) for cell, width in zip(line, widths)).rstrip()
|
|
41
|
+
for line in lines)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _format_frequency(value: float) -> str:
|
|
45
|
+
return f"{value:.2f}".rstrip("0").rstrip(".")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _section(title: str, body: str) -> str:
|
|
49
|
+
return f"{title}\n{body}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def show_summary(vex: Vex) -> str:
|
|
53
|
+
scans = vex.scans
|
|
54
|
+
starts = [scan.start for scan in scans if scan.start is not None]
|
|
55
|
+
rows = [
|
|
56
|
+
("Experiment", vex.experiment or "-"),
|
|
57
|
+
("VEX version", vex.version or "-"),
|
|
58
|
+
("Stations", f"{len(vex.stations)}: {' '.join(s.code for s in vex.stations)}"),
|
|
59
|
+
("Modes", ", ".join(vex.modes) or "-"),
|
|
60
|
+
("Sources", len(vex["SOURCE"]) if "SOURCE" in vex else 0),
|
|
61
|
+
("Scans", len(scans)),
|
|
62
|
+
]
|
|
63
|
+
if starts:
|
|
64
|
+
end = max(scan.start + timedelta(seconds=scan.duration or 0)
|
|
65
|
+
for scan in scans if scan.start is not None)
|
|
66
|
+
rows.append(("Start (UTC)", f"{min(starts):%Y-%m-%d %H:%M:%S}"))
|
|
67
|
+
rows.append(("End (UTC)", f"{end:%Y-%m-%d %H:%M:%S}"))
|
|
68
|
+
|
|
69
|
+
width = max(len(name) for name, _ in rows)
|
|
70
|
+
return "\n".join(f"{name.ljust(width)} {value}" for name, value in rows)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def show_version(vex: Vex) -> str:
|
|
74
|
+
return f"VEX version: {vex.version or 'unknown (no VEX_rev statement)'}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def show_stations(vex: Vex) -> str:
|
|
78
|
+
rows = [(s.code, s.name, s.antenna or "-", s.das or "-") for s in vex.stations]
|
|
79
|
+
return _section(f"Stations ({len(rows)})", _table(["Code", "Name", "Antenna", "DAS"], rows))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def show_scans(vex: Vex) -> str:
|
|
83
|
+
rows = []
|
|
84
|
+
for scan in vex.scans:
|
|
85
|
+
start = f"{scan.start:%Y-%m-%d %H:%M:%S}" if scan.start else "-"
|
|
86
|
+
duration = f"{scan.duration:.0f} s" if scan.duration is not None else "-"
|
|
87
|
+
rows.append((scan.name, start, duration, " ".join(scan.sources), scan.mode or "-",
|
|
88
|
+
f"{len(scan.stations)}: {' '.join(scan.stations)}"))
|
|
89
|
+
|
|
90
|
+
headers = ["Scan", "Start (UTC)", "Duration", "Source", "Mode", "Stations"]
|
|
91
|
+
return _section(f"Scans ({len(rows)})", _table(headers, rows))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def show_ifs(vex: Vex) -> str:
|
|
95
|
+
sections = []
|
|
96
|
+
for mode in vex.modes:
|
|
97
|
+
rows = []
|
|
98
|
+
for name, stations in vex.setups("IF", mode).items():
|
|
99
|
+
definition = vex["IF"].get(name) if "IF" in vex else None
|
|
100
|
+
ifs = IF.from_def(definition) if definition is not None else []
|
|
101
|
+
for i, an_if in enumerate(ifs or [None]):
|
|
102
|
+
setup = (name, " ".join(stations)) if i == 0 else ("", "")
|
|
103
|
+
if an_if is None:
|
|
104
|
+
rows.append(setup + ("(missing in $IF)", "", "", "", ""))
|
|
105
|
+
else:
|
|
106
|
+
rows.append(setup + (an_if.name, an_if.physical_name, an_if.polarization,
|
|
107
|
+
_format_frequency(an_if.lo), an_if.sideband))
|
|
108
|
+
|
|
109
|
+
headers = ["$IF setup", "Stations", "IF", "Physical", "Pol", "LO (MHz)", "Sideband"]
|
|
110
|
+
sections.append(_section(f"Mode {mode}", _table(headers, rows, indent=" ")))
|
|
111
|
+
|
|
112
|
+
return "\n\n".join(sections) if sections else "No modes defined in $MODE"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _tick_step(span: float, max_ticks: int) -> float:
|
|
116
|
+
"""Returns a round step (1, 2 or 5 times a power of 10) giving at most max_ticks ticks in span."""
|
|
117
|
+
step = 10 ** math.floor(math.log10(span / max(max_ticks, 1))) if span > 0 else 1.0
|
|
118
|
+
while span / step > max_ticks:
|
|
119
|
+
for factor in (2, 2.5, 2): # 1 -> 2 -> 5 -> 10
|
|
120
|
+
step *= factor
|
|
121
|
+
if span / step <= max_ticks:
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
return step
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _ruler(low: float, high: float, width: int) -> tuple[str, str]:
|
|
128
|
+
"""Returns the lines with the frequency labels and the ticks for a plot of the given width,
|
|
129
|
+
with ticks at round frequencies.
|
|
130
|
+
"""
|
|
131
|
+
span = high - low
|
|
132
|
+
step = _tick_step(span, max(2, width // 12))
|
|
133
|
+
ticks = ["─"] * width
|
|
134
|
+
ticks[0], ticks[-1] = "├", "┤"
|
|
135
|
+
labels = [" "] * width
|
|
136
|
+
free_from = 0 # first column where a new label can start
|
|
137
|
+
value = math.ceil(low / step) * step
|
|
138
|
+
while value <= high + 1e-9:
|
|
139
|
+
position = round((value - low) / span * (width - 1)) if span > 0 else 0
|
|
140
|
+
if 0 < position < width - 1:
|
|
141
|
+
ticks[position] = "┼"
|
|
142
|
+
|
|
143
|
+
text = _format_frequency(value)
|
|
144
|
+
start = min(max(position - len(text) // 2, 0), width - len(text))
|
|
145
|
+
if start >= free_from:
|
|
146
|
+
labels[start:start + len(text)] = text
|
|
147
|
+
free_from = start + len(text) + 1
|
|
148
|
+
|
|
149
|
+
value += step
|
|
150
|
+
|
|
151
|
+
return "".join(labels).rstrip(), "".join(ticks)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _plot_row(ranges: list[tuple[float, float]], low: float, high: float, width: int) -> str:
|
|
155
|
+
cell = (high - low) / width
|
|
156
|
+
row = []
|
|
157
|
+
for i in range(width):
|
|
158
|
+
cell_low, cell_high = low + i * cell, low + (i + 1) * cell
|
|
159
|
+
covered = sum(max(0.0, min(high_, cell_high) - max(low_, cell_low)) for low_, high_ in ranges)
|
|
160
|
+
row.append(_FULL if covered >= 0.99 * cell else _PARTIAL if covered > 0 else _EMPTY)
|
|
161
|
+
|
|
162
|
+
return "".join(row)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def show_frequencies(vex: Vex, width: int | None = None) -> str:
|
|
166
|
+
width = width or shutil.get_terminal_size((100, 24)).columns
|
|
167
|
+
sections = []
|
|
168
|
+
for mode in vex.modes:
|
|
169
|
+
stations = [code for codes in vex.setups("FREQ", mode).values() for code in codes]
|
|
170
|
+
stations = list(dict.fromkeys(stations))
|
|
171
|
+
channels = {code: vex.channels(mode, code) for code in stations}
|
|
172
|
+
rows = []
|
|
173
|
+
for code in stations:
|
|
174
|
+
setup = vex.setup("FREQ", mode, code)
|
|
175
|
+
chans = channels[code]
|
|
176
|
+
bandwidths = Counter(channel.bandwidth for channel in chans)
|
|
177
|
+
polarizations = sorted({c.polarization for c in chans if c.polarization}) or ["?"]
|
|
178
|
+
rows.append((
|
|
179
|
+
code,
|
|
180
|
+
setup.name if setup is not None else "(missing in $FREQ)",
|
|
181
|
+
", ".join(f"{n}x{_format_frequency(bw)}" for bw, n in sorted(bandwidths.items())),
|
|
182
|
+
", ".join(f"{_format_frequency(lo)}-{_format_frequency(hi)}"
|
|
183
|
+
for lo, hi in covered_ranges(chans)),
|
|
184
|
+
" ".join(polarizations),
|
|
185
|
+
))
|
|
186
|
+
|
|
187
|
+
headers = ["Station", "$FREQ setup", "Channels (MHz)", "Covered (MHz)", "Pol"]
|
|
188
|
+
body = [_table(headers, rows, indent=" ")]
|
|
189
|
+
all_channels = [channel for chans in channels.values() for channel in chans]
|
|
190
|
+
if all_channels:
|
|
191
|
+
low = min(channel.low for channel in all_channels)
|
|
192
|
+
high = max(channel.high for channel in all_channels)
|
|
193
|
+
label_width = max(len(code) for code in stations)
|
|
194
|
+
plot_width = max(_MIN_PLOT_WIDTH, width - label_width - 6)
|
|
195
|
+
labels, ticks = _ruler(low, high, plot_width)
|
|
196
|
+
pad = " " + " " * label_width + " "
|
|
197
|
+
body.append("")
|
|
198
|
+
body.append(f"{pad} Frequency coverage (MHz)")
|
|
199
|
+
body.append(f"{pad} {labels}")
|
|
200
|
+
body.append(f"{pad} {ticks}")
|
|
201
|
+
for code in stations:
|
|
202
|
+
row = _plot_row(covered_ranges(channels[code]), low, high, plot_width)
|
|
203
|
+
body.append(f" {code.ljust(label_width)} │{row}│")
|
|
204
|
+
|
|
205
|
+
body.append(f"{pad} {ticks}")
|
|
206
|
+
|
|
207
|
+
sections.append(_section(f"Mode {mode}", "\n".join(body)))
|
|
208
|
+
|
|
209
|
+
return "\n\n".join(sections) if sections else "No modes defined in $MODE"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def show_verify(vex: Vex, path: Path, freq_overlap: bool = True) -> tuple[str, bool]:
|
|
213
|
+
"""Returns the problems found in the file, and whether any of them is an error."""
|
|
214
|
+
issues = vex.verify(freq_overlap=freq_overlap)
|
|
215
|
+
if not issues:
|
|
216
|
+
return f"{path}: no problems found", False
|
|
217
|
+
|
|
218
|
+
lines = [f"{path}:{'' if issue.line is None else f'{issue.line}:'} {issue.severity}: {issue.message}"
|
|
219
|
+
for issue in issues]
|
|
220
|
+
n_errors = sum(issue.severity is Severity.ERROR for issue in issues)
|
|
221
|
+
n_warnings = len(issues) - n_errors
|
|
222
|
+
lines.append(f"{n_errors} error{'s' if n_errors != 1 else ''}, "
|
|
223
|
+
f"{n_warnings} warning{'s' if n_warnings != 1 else ''}")
|
|
224
|
+
return "\n".join(lines), n_errors > 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _parser() -> argparse.ArgumentParser:
|
|
228
|
+
parser = argparse.ArgumentParser(
|
|
229
|
+
prog="vex",
|
|
230
|
+
description="Inspect a VEX file. Without options, shows a summary of the observation.",
|
|
231
|
+
)
|
|
232
|
+
parser.add_argument("vexfile", type=Path, help="the VEX file")
|
|
233
|
+
parser.add_argument("--version", action="store_true", help="show the VEX version of the file")
|
|
234
|
+
parser.add_argument("--stations", action="store_true", help="list the stations")
|
|
235
|
+
parser.add_argument("--if", dest="ifs", action="store_true", help="list the IF setups of each mode")
|
|
236
|
+
parser.add_argument("--scans", action="store_true", help="list the scans")
|
|
237
|
+
parser.add_argument("--freq", action="store_true",
|
|
238
|
+
help="show the frequencies observed by each station, with a plot")
|
|
239
|
+
parser.add_argument("--verify", action="store_true", help="check that the VEX file is correct")
|
|
240
|
+
parser.add_argument("--no-freq-overlap", dest="freq_overlap", action="store_false",
|
|
241
|
+
help="with --verify, do not check that each station shares frequencies "
|
|
242
|
+
"with the other stations in its scans")
|
|
243
|
+
return parser
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
247
|
+
args = _parser().parse_args(argv)
|
|
248
|
+
try:
|
|
249
|
+
vex = Vex.from_file(args.vexfile)
|
|
250
|
+
except OSError as error:
|
|
251
|
+
print(f"vex: cannot read {args.vexfile}: {error.strerror or error}", file=sys.stderr)
|
|
252
|
+
return 1
|
|
253
|
+
except VexSyntaxError as error:
|
|
254
|
+
print(f"{args.vexfile}:{error.line}:{error.col}: syntax error: {error.message}", file=sys.stderr)
|
|
255
|
+
return 1
|
|
256
|
+
|
|
257
|
+
has_errors = False
|
|
258
|
+
|
|
259
|
+
def verification() -> str:
|
|
260
|
+
nonlocal has_errors
|
|
261
|
+
text, has_errors = show_verify(vex, args.vexfile, args.freq_overlap)
|
|
262
|
+
return text
|
|
263
|
+
|
|
264
|
+
views: list[tuple[bool, Callable[[], str]]] = [
|
|
265
|
+
(args.version, lambda: show_version(vex)),
|
|
266
|
+
(args.verify, verification),
|
|
267
|
+
(args.stations, lambda: show_stations(vex)),
|
|
268
|
+
(args.ifs, lambda: show_ifs(vex)),
|
|
269
|
+
(args.freq, lambda: show_frequencies(vex)),
|
|
270
|
+
(args.scans, lambda: show_scans(vex)),
|
|
271
|
+
]
|
|
272
|
+
outputs = [view() for selected, view in views if selected] or [show_summary(vex)]
|
|
273
|
+
try:
|
|
274
|
+
print("\n\n".join(outputs))
|
|
275
|
+
except BrokenPipeError: # e.g. when piped to 'head'
|
|
276
|
+
sys.stderr.close()
|
|
277
|
+
|
|
278
|
+
return 1 if has_errors else 0
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
if __name__ == "__main__":
|
|
282
|
+
sys.exit(main())
|
pyvexfile/lexer.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Lexical analysis of VEX files.
|
|
2
|
+
|
|
3
|
+
The tokenizer is lossless: joining the values of all the tokens produced by
|
|
4
|
+
tokenize() gives back the input text exactly, including comments, whitespace
|
|
5
|
+
and line breaks. This allows the parser to keep the comments and the original
|
|
6
|
+
layout of a VEX file, so it can be written again without changes.
|
|
7
|
+
|
|
8
|
+
Usage
|
|
9
|
+
-----
|
|
10
|
+
|
|
11
|
+
from pyvexfile.lexer import tokenize
|
|
12
|
+
|
|
13
|
+
for token in tokenize(text):
|
|
14
|
+
print(token.type, repr(token.value), token.line, token.col)
|
|
15
|
+
"""
|
|
16
|
+
from collections.abc import Iterator
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from enum import Enum, auto
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__all__: list[str] = ["TokenType", "Token", "VexSyntaxError", "tokenize", "TRIVIA"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TokenType(Enum):
|
|
25
|
+
"""Types of the tokens that can be found in a VEX file:
|
|
26
|
+
- WORD. Any sequence of characters not containing whitespace, ';', ':', '=', '$', '&' or '*'.
|
|
27
|
+
- STRING. A quoted string, including the quotes ("...").
|
|
28
|
+
- SEMICOLON, COLON, EQUALS, DOLLAR, AMPERSAND. The single-character punctuation.
|
|
29
|
+
- COMMENT. Everything from a '*' until the end of the line (the line break not included).
|
|
30
|
+
- WHITESPACE. A sequence of spaces and/or tabs.
|
|
31
|
+
- NEWLINE. A line break ('\\n' or '\\r\\n').
|
|
32
|
+
- LITERAL. A full literal block, from 'start_literal(...)' until 'end_literal(...)',
|
|
33
|
+
both included. The final ';' after 'end_literal(...)' is a separated SEMICOLON token.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
WORD = auto()
|
|
37
|
+
STRING = auto()
|
|
38
|
+
SEMICOLON = auto()
|
|
39
|
+
COLON = auto()
|
|
40
|
+
EQUALS = auto()
|
|
41
|
+
DOLLAR = auto()
|
|
42
|
+
AMPERSAND = auto()
|
|
43
|
+
COMMENT = auto()
|
|
44
|
+
WHITESPACE = auto()
|
|
45
|
+
NEWLINE = auto()
|
|
46
|
+
LITERAL = auto()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Tokens that do not carry syntactic meaning, but must be kept to reproduce the file
|
|
50
|
+
TRIVIA: frozenset[TokenType] = frozenset(
|
|
51
|
+
{TokenType.COMMENT, TokenType.WHITESPACE, TokenType.NEWLINE}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
_PUNCTUATION: dict[str, TokenType] = {
|
|
55
|
+
";": TokenType.SEMICOLON,
|
|
56
|
+
":": TokenType.COLON,
|
|
57
|
+
"=": TokenType.EQUALS,
|
|
58
|
+
"$": TokenType.DOLLAR,
|
|
59
|
+
"&": TokenType.AMPERSAND,
|
|
60
|
+
}
|
|
61
|
+
_WHITESPACE = " \t\f\v"
|
|
62
|
+
# Note that '"' is not a delimiter: it only opens a string at the start of a token,
|
|
63
|
+
# so values like 28d48'04.938910" are read as a single word.
|
|
64
|
+
_WORD_DELIMITERS = frozenset(_WHITESPACE + "\r\n;:=$&*")
|
|
65
|
+
_LITERAL_START = "start_literal"
|
|
66
|
+
_LITERAL_END = "end_literal"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class VexSyntaxError(ValueError):
|
|
70
|
+
"""Error in the syntax of a VEX file, at the given line and column (both starting at 1)."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, message: str, line: int, col: int):
|
|
73
|
+
super().__init__(f"line {line}, column {col}: {message}")
|
|
74
|
+
self.message = message
|
|
75
|
+
self.line = line
|
|
76
|
+
self.col = col
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class Token:
|
|
81
|
+
"""A token from a VEX file. 'offset' is the position of its first character in the
|
|
82
|
+
input text, and 'line' and 'col' its line and column numbers (starting at 1).
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
type: TokenType
|
|
86
|
+
value: str
|
|
87
|
+
line: int
|
|
88
|
+
col: int
|
|
89
|
+
offset: int
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def tokenize(text: str) -> Iterator[Token]:
|
|
93
|
+
"""Splits the text of a VEX file into Tokens. Raises VexSyntaxError if a string or
|
|
94
|
+
a literal block is not terminated.
|
|
95
|
+
"""
|
|
96
|
+
pos, line, col = 0, 1, 1
|
|
97
|
+
while pos < len(text):
|
|
98
|
+
token_type, end = _scan_token(text, pos, line, col)
|
|
99
|
+
value = text[pos:end]
|
|
100
|
+
yield Token(token_type, value, line, col, pos)
|
|
101
|
+
if (n_lines := value.count("\n")) > 0:
|
|
102
|
+
line += n_lines
|
|
103
|
+
col = len(value) - value.rfind("\n")
|
|
104
|
+
else:
|
|
105
|
+
col += len(value)
|
|
106
|
+
|
|
107
|
+
pos = end
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _scan_token(text: str, pos: int, line: int, col: int) -> tuple[TokenType, int]:
|
|
111
|
+
"""Identifies the token starting at text[pos]. Returns its type and the position
|
|
112
|
+
right after its last character.
|
|
113
|
+
"""
|
|
114
|
+
char = text[pos]
|
|
115
|
+
if char == "\n":
|
|
116
|
+
return TokenType.NEWLINE, pos + 1
|
|
117
|
+
|
|
118
|
+
if char == "\r":
|
|
119
|
+
if text.startswith("\r\n", pos):
|
|
120
|
+
return TokenType.NEWLINE, pos + 2
|
|
121
|
+
|
|
122
|
+
return TokenType.WHITESPACE, pos + 1
|
|
123
|
+
|
|
124
|
+
if char in _WHITESPACE:
|
|
125
|
+
end = pos + 1
|
|
126
|
+
while end < len(text) and text[end] in _WHITESPACE:
|
|
127
|
+
end += 1
|
|
128
|
+
|
|
129
|
+
return TokenType.WHITESPACE, end
|
|
130
|
+
|
|
131
|
+
if char == "*":
|
|
132
|
+
end = text.find("\n", pos)
|
|
133
|
+
if end == -1:
|
|
134
|
+
end = len(text)
|
|
135
|
+
elif text[end - 1] == "\r":
|
|
136
|
+
end -= 1
|
|
137
|
+
|
|
138
|
+
return TokenType.COMMENT, end
|
|
139
|
+
|
|
140
|
+
if char == '"':
|
|
141
|
+
return TokenType.STRING, _scan_string(text, pos, line, col)
|
|
142
|
+
|
|
143
|
+
if char in _PUNCTUATION:
|
|
144
|
+
return _PUNCTUATION[char], pos + 1
|
|
145
|
+
|
|
146
|
+
if (literal_end := _scan_literal(text, pos, line, col)) is not None:
|
|
147
|
+
return TokenType.LITERAL, literal_end
|
|
148
|
+
|
|
149
|
+
end = pos + 1
|
|
150
|
+
while end < len(text) and text[end] not in _WORD_DELIMITERS:
|
|
151
|
+
end += 1
|
|
152
|
+
|
|
153
|
+
return TokenType.WORD, end
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _scan_string(text: str, pos: int, line: int, col: int) -> int:
|
|
157
|
+
"""Returns the position right after the closing quote of the string opened at text[pos].
|
|
158
|
+
A backslash escapes the following character. Strings cannot span multiple lines.
|
|
159
|
+
"""
|
|
160
|
+
end = pos + 1
|
|
161
|
+
while end < len(text):
|
|
162
|
+
char = text[end]
|
|
163
|
+
if char == "\\":
|
|
164
|
+
end += 2
|
|
165
|
+
elif char == '"':
|
|
166
|
+
return end + 1
|
|
167
|
+
elif char in "\r\n":
|
|
168
|
+
break
|
|
169
|
+
else:
|
|
170
|
+
end += 1
|
|
171
|
+
|
|
172
|
+
raise VexSyntaxError("unterminated string", line, col)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _scan_literal(text: str, pos: int, line: int, col: int) -> int | None:
|
|
176
|
+
"""If a literal block starts at text[pos], returns the position right after the
|
|
177
|
+
'end_literal(label)' that closes it. Otherwise returns None.
|
|
178
|
+
|
|
179
|
+
A literal block has the form:
|
|
180
|
+
start_literal(label);
|
|
181
|
+
...any text, kept verbatim...
|
|
182
|
+
end_literal(label);
|
|
183
|
+
where the label is optional, but must be the same in both lines.
|
|
184
|
+
"""
|
|
185
|
+
label_start = _open_parenthesis(text, pos, _LITERAL_START)
|
|
186
|
+
if label_start is None:
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
label_end = text.find(")", label_start)
|
|
190
|
+
if label_end == -1:
|
|
191
|
+
raise VexSyntaxError(f"'{_LITERAL_START}(' without closing ')'", line, col)
|
|
192
|
+
|
|
193
|
+
label = text[label_start:label_end].strip()
|
|
194
|
+
search_from = label_end + 1
|
|
195
|
+
while (end_pos := text.find(_LITERAL_END, search_from)) != -1:
|
|
196
|
+
end_label_start = _open_parenthesis(text, end_pos, _LITERAL_END)
|
|
197
|
+
if end_label_start is not None:
|
|
198
|
+
end_label_end = text.find(")", end_label_start)
|
|
199
|
+
if end_label_end == -1:
|
|
200
|
+
break
|
|
201
|
+
|
|
202
|
+
if text[end_label_start:end_label_end].strip() == label:
|
|
203
|
+
return end_label_end + 1
|
|
204
|
+
|
|
205
|
+
search_from = end_pos + len(_LITERAL_END)
|
|
206
|
+
|
|
207
|
+
raise VexSyntaxError(
|
|
208
|
+
f"literal block '{_LITERAL_START}({label})' without matching '{_LITERAL_END}({label})'",
|
|
209
|
+
line,
|
|
210
|
+
col,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _open_parenthesis(text: str, pos: int, keyword: str) -> int | None:
|
|
215
|
+
"""If text[pos:] starts with keyword followed by '(' (optionally with whitespace in between),
|
|
216
|
+
returns the position right after the '('. Otherwise returns None.
|
|
217
|
+
"""
|
|
218
|
+
if not text.startswith(keyword, pos):
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
after = pos + len(keyword)
|
|
222
|
+
while after < len(text) and text[after] in _WHITESPACE:
|
|
223
|
+
after += 1
|
|
224
|
+
|
|
225
|
+
if after < len(text) and text[after] == "(":
|
|
226
|
+
return after + 1
|
|
227
|
+
|
|
228
|
+
return None
|