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 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
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+
6
+ sys.exit(main())
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