telemetry-cli 1.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.
- telemetry_cli/__init__.py +8 -0
- telemetry_cli/_cli.py +18 -0
- telemetry_cli/_getopt.py +114 -0
- telemetry_cli/_msg.py +39 -0
- telemetry_cli/_pack.py +183 -0
- telemetry_cli/_perl.py +183 -0
- telemetry_cli/pick/__init__.py +1 -0
- telemetry_cli/pick/__main__.py +7 -0
- telemetry_cli/pick/cli.py +157 -0
- telemetry_cli/pick/commandline.py +219 -0
- telemetry_cli/pick/fields.py +166 -0
- telemetry_cli/pick/formats.py +191 -0
- telemetry_cli/pick/help.py +536 -0
- telemetry_cli/pick/symbols/airmoc +24 -0
- telemetry_cli/pick/symbols/aux +28 -0
- telemetry_cli/pick/symbols/corr +31 -0
- telemetry_cli/pick/symbols/corrOld +29 -0
- telemetry_cli/pick/symbols/hw +16 -0
- telemetry_cli/pick/symbols/moc +42 -0
- telemetry_cli/pick/symbols/subcom +83 -0
- telemetry_cli/pick/symbols/subcom_2002 +111 -0
- telemetry_cli/recl/__init__.py +1 -0
- telemetry_cli/recl/__main__.py +7 -0
- telemetry_cli/recl/cli.py +214 -0
- telemetry_cli/recl/corr.py +83 -0
- telemetry_cli/recs/__init__.py +1 -0
- telemetry_cli/recs/__main__.py +7 -0
- telemetry_cli/recs/cli.py +182 -0
- telemetry_cli/recs/extract.py +399 -0
- telemetry_cli/tgen/__init__.py +1 -0
- telemetry_cli/tgen/__main__.py +7 -0
- telemetry_cli/tgen/cli.py +128 -0
- telemetry_cli/tgen/cmdfile.py +68 -0
- telemetry_cli/tgen/convert.py +278 -0
- telemetry_cli/tgen/examples/arb_data.tgen +15 -0
- telemetry_cli/tgen/examples/calsweep.tgen +32 -0
- telemetry_cli/tgen/examples/cond_fill.tgen +17 -0
- telemetry_cli/tgen/examples/counter.tgen +11 -0
- telemetry_cli/tgen/examples/null_data.tgen +11 -0
- telemetry_cli/tgen/examples/random_size.tgen +20 -0
- telemetry_cli/tgen/examples/subcom.tgen +14 -0
- telemetry_cli/tgen/examples/valueV_value.tgen +20 -0
- telemetry_cli/tgen/runtime.py +390 -0
- telemetry_cli-1.0.0.dist-info/METADATA +106 -0
- telemetry_cli-1.0.0.dist-info/RECORD +48 -0
- telemetry_cli-1.0.0.dist-info/WHEEL +4 -0
- telemetry_cli-1.0.0.dist-info/entry_points.txt +5 -0
- telemetry_cli-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""The pick command: extract and print fields from fixed-length binary records.
|
|
2
|
+
|
|
3
|
+
pick file.dat reclen [ fieldRequests | options | parameters ]
|
|
4
|
+
cat file.dat | pick reclen [ fieldRequests | options | parameters ]
|
|
5
|
+
|
|
6
|
+
Run ``pick ?`` for help.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from typing import BinaryIO, TextIO
|
|
13
|
+
|
|
14
|
+
from .._cli import broken_pipe
|
|
15
|
+
from .commandline import UsageError, parse, resolve, wants_help
|
|
16
|
+
from .fields import FieldError, parse_fields
|
|
17
|
+
from .formats import FormatError, render
|
|
18
|
+
from .help import help_text
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Input:
|
|
22
|
+
"""Record input with the Perl original's seek rules.
|
|
23
|
+
|
|
24
|
+
Files can seek anywhere; stdin can only skip forward (a request to go
|
|
25
|
+
back is ignored, as in the Perl).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(self, stream: BinaryIO, seekable: bool):
|
|
29
|
+
self.stream = stream
|
|
30
|
+
self.seekable = seekable
|
|
31
|
+
self.pos = 0
|
|
32
|
+
|
|
33
|
+
def seek(self, target: int) -> bool:
|
|
34
|
+
if not target:
|
|
35
|
+
return True
|
|
36
|
+
n = target - self.pos
|
|
37
|
+
if not n:
|
|
38
|
+
return True
|
|
39
|
+
if self.seekable:
|
|
40
|
+
if target < 0:
|
|
41
|
+
return False
|
|
42
|
+
self.pos = target
|
|
43
|
+
self.stream.seek(n, 1)
|
|
44
|
+
return True
|
|
45
|
+
if n < 1:
|
|
46
|
+
return True
|
|
47
|
+
self.pos = target
|
|
48
|
+
return len(self.stream.read(n)) > 0
|
|
49
|
+
|
|
50
|
+
def read(self, n: int) -> bytes:
|
|
51
|
+
return self.stream.read(n)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def main(
|
|
55
|
+
argv: list[str] | None = None,
|
|
56
|
+
*,
|
|
57
|
+
stdin: BinaryIO | None = None,
|
|
58
|
+
stdout: BinaryIO | None = None,
|
|
59
|
+
stderr: TextIO | None = None,
|
|
60
|
+
host_order: str | None = None,
|
|
61
|
+
) -> int:
|
|
62
|
+
"""Run pick. Returns the exit status.
|
|
63
|
+
|
|
64
|
+
host_order ("big" or "little") overrides the machine's byte order, which
|
|
65
|
+
is used when the data's order isn't given; the tests use it to check
|
|
66
|
+
pick's big-endian behavior on any machine.
|
|
67
|
+
"""
|
|
68
|
+
argv = sys.argv[1:] if argv is None else list(argv)
|
|
69
|
+
stdin = stdin if stdin is not None else sys.stdin.buffer
|
|
70
|
+
stdout = stdout if stdout is not None else sys.stdout.buffer
|
|
71
|
+
stderr = stderr if stderr is not None else sys.stderr
|
|
72
|
+
host = host_order or sys.byteorder
|
|
73
|
+
try:
|
|
74
|
+
return _run(argv, stdin, stdout, stderr, host)
|
|
75
|
+
except (UsageError, FieldError, FormatError) as e:
|
|
76
|
+
stdout.flush()
|
|
77
|
+
print(f"pick: {e}", file=stderr)
|
|
78
|
+
topics = getattr(e, "topics", "")
|
|
79
|
+
if isinstance(e, FormatError):
|
|
80
|
+
topics = "f"
|
|
81
|
+
if topics:
|
|
82
|
+
print(f"(see 'pick --help {topics}')", file=stderr)
|
|
83
|
+
return 1
|
|
84
|
+
except BrokenPipeError:
|
|
85
|
+
return broken_pipe(stdout)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _help_argv(argv: list[str]) -> list[str]:
|
|
89
|
+
"""Turn ``--help [topic]`` / ``-h [topic]`` into pick's ``?topic`` form."""
|
|
90
|
+
if argv and argv[0] in ("--help", "-h"):
|
|
91
|
+
topic = argv[1] if len(argv) > 1 else ""
|
|
92
|
+
return ["??" if topic == "all" else "?" + topic.lstrip("?") if topic else "help"]
|
|
93
|
+
return argv
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _run(argv, stdin, stdout, stderr, host) -> int:
|
|
97
|
+
cmd = parse(_help_argv(argv))
|
|
98
|
+
if wants_help(cmd.text):
|
|
99
|
+
stdout.write(
|
|
100
|
+
help_text(
|
|
101
|
+
cmd.text,
|
|
102
|
+
cmd.symbols if cmd.symbol_file else None,
|
|
103
|
+
cmd.symbol_file or "",
|
|
104
|
+
).encode("latin-1")
|
|
105
|
+
)
|
|
106
|
+
return 0
|
|
107
|
+
plan = resolve(cmd)
|
|
108
|
+
opts = cmd.opts
|
|
109
|
+
order = host if cmd.order in (None, "native") else cmd.order
|
|
110
|
+
if opts["r"]:
|
|
111
|
+
order = "big" if order == "little" else "little"
|
|
112
|
+
fields = parse_fields(plan.fields_text, plan.size, binary=opts["u"])
|
|
113
|
+
if plan.size <= 0: # the Perl printed empty records forever
|
|
114
|
+
raise UsageError("the record size must be at least 1 byte", "p")
|
|
115
|
+
|
|
116
|
+
if plan.file == "-":
|
|
117
|
+
if plan.from_stdin_default and not opts["q"]:
|
|
118
|
+
print("Reading from stdin", file=stderr)
|
|
119
|
+
source = _Input(stdin, seekable=False)
|
|
120
|
+
return _loop(source, plan, fields, opts, order, host, stdout)
|
|
121
|
+
try:
|
|
122
|
+
f = open(plan.file, "rb") # noqa: SIM115
|
|
123
|
+
except OSError as e:
|
|
124
|
+
raise UsageError(f"can't read {plan.file}: {e.strerror}") from None
|
|
125
|
+
with f:
|
|
126
|
+
return _loop(_Input(f, seekable=True), plan, fields, opts, order, host, stdout)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _loop(source: _Input, plan, fields, opts, order, host, stdout) -> int:
|
|
130
|
+
if plan.head and not source.seek(plan.head):
|
|
131
|
+
raise UsageError("End of file while seeking past header")
|
|
132
|
+
newline = not (opts["u"] or opts["b"] or opts["s"])
|
|
133
|
+
record, lines = plan.start, 0
|
|
134
|
+
while True:
|
|
135
|
+
if not source.seek(record * plan.size + plan.head):
|
|
136
|
+
break
|
|
137
|
+
data = source.read(plan.size)
|
|
138
|
+
if len(data) != plan.size:
|
|
139
|
+
break
|
|
140
|
+
out = bytearray()
|
|
141
|
+
if opts["l"]:
|
|
142
|
+
out += f"{lines} ".encode()
|
|
143
|
+
if opts["n"]:
|
|
144
|
+
out += f"{record} ".encode()
|
|
145
|
+
if opts["m"]:
|
|
146
|
+
out += f"{source.pos} ".encode()
|
|
147
|
+
out += render(data, fields, order=order, host=host, fast_binary=opts["b"])
|
|
148
|
+
if newline:
|
|
149
|
+
out += b"\n"
|
|
150
|
+
stdout.write(out)
|
|
151
|
+
source.pos += plan.size
|
|
152
|
+
record += plan.skip + 1
|
|
153
|
+
lines += 1
|
|
154
|
+
if plan.stop >= 0 and record > plan.stop:
|
|
155
|
+
break
|
|
156
|
+
stdout.flush()
|
|
157
|
+
return 0
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Reading a pick command line.
|
|
2
|
+
|
|
3
|
+
pick doesn't parse its arguments one by one. Like the Perl original, it joins
|
|
4
|
+
them into one string and removes pieces in this order: options (``-nlr``),
|
|
5
|
+
numeric parameters (``start=5``, ``head=4z``), word parameters (``file=...``,
|
|
6
|
+
``sym=...``), then expands symbols from a symbol file. What is left is an
|
|
7
|
+
optional file name, the record size, and the field requests. This is why
|
|
8
|
+
parameters can go anywhere on the command line.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from importlib import resources
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from .._perl import truthy
|
|
20
|
+
from .fields import DTYPES, NBYTES, parse_size
|
|
21
|
+
|
|
22
|
+
OPTIONS = "mnblqrsu"
|
|
23
|
+
NUM_PARAMS = "start|head|stop|skip|nrecs|every|length|size|header|rec"
|
|
24
|
+
WORD_PARAMS = "file|filename|sym|symFile|symTable"
|
|
25
|
+
MAX_SYMBOL_EXPANSIONS = 100_000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UsageError(ValueError):
|
|
29
|
+
"""A problem with the command line. ``topics`` names help to suggest."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str, topics: str = ""):
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.topics = topics
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class Command:
|
|
38
|
+
opts: dict[str, bool]
|
|
39
|
+
params: dict[str, int | str | None]
|
|
40
|
+
text: str # what's left after options, parameters and symbols are removed
|
|
41
|
+
symbols: dict[str, str] = field(default_factory=dict)
|
|
42
|
+
symbol_file: str | None = None
|
|
43
|
+
order: str | None = None # order=big|little, if given
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_options(s: str) -> tuple[str, dict[str, bool]]:
|
|
47
|
+
opts = dict.fromkeys(OPTIONS, False)
|
|
48
|
+
while m := re.search(r"\s([-]([a-zA-Z]+))", s):
|
|
49
|
+
tag, name = m.group(1), m.group(2)
|
|
50
|
+
for k in OPTIONS:
|
|
51
|
+
if k in name:
|
|
52
|
+
opts[k] = True
|
|
53
|
+
name = name.replace(k, "")
|
|
54
|
+
if name:
|
|
55
|
+
raise UsageError(f"Unrecognized option-->{name}<--", "o")
|
|
56
|
+
s = s.replace(tag, "", 1)
|
|
57
|
+
return s, opts
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_params(s: str) -> tuple[str, dict]:
|
|
61
|
+
params: dict[str, int | str | None] = {}
|
|
62
|
+
num = re.compile(rf"(({NUM_PARAMS})\s*=\s*(\d+)([{DTYPES}]?))", re.IGNORECASE)
|
|
63
|
+
while m := num.search(s):
|
|
64
|
+
whole, key, value, unit = m.groups()
|
|
65
|
+
s = s[: m.start()] + s[m.end() :]
|
|
66
|
+
params[key] = int(value) * (NBYTES[unit] if unit else 1)
|
|
67
|
+
word = re.compile(rf"(({WORD_PARAMS})\s*=\s*(\S+))", re.IGNORECASE)
|
|
68
|
+
while m := word.search(s):
|
|
69
|
+
s = s[: m.start()] + s[m.end() :]
|
|
70
|
+
params[m.group(2)] = m.group(3)
|
|
71
|
+
return s, params
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def parse_order(s: str) -> tuple[str, str | None]:
|
|
75
|
+
"""The order=big|little parameter (new in the Python version)."""
|
|
76
|
+
m = re.search(r"(?<!\S)order\s*=\s*(\S+)", s)
|
|
77
|
+
if not m:
|
|
78
|
+
return s, None
|
|
79
|
+
value = m.group(1).lower()
|
|
80
|
+
if value not in ("big", "little", "native"):
|
|
81
|
+
raise UsageError(f"order must be big, little or native, not {m.group(1)!r}", "p")
|
|
82
|
+
return s[: m.start()] + s[m.end() :], value
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def find_symbol_file(name: str) -> Path:
|
|
86
|
+
if os.path.exists(name):
|
|
87
|
+
return Path(name)
|
|
88
|
+
path = os.environ.get("PICKPATH", "") + "/" + name
|
|
89
|
+
if os.path.exists(path):
|
|
90
|
+
return Path(path)
|
|
91
|
+
bundled = resources.files("telemetry_cli.pick") / "symbols" / name
|
|
92
|
+
if "/" not in name and bundled.is_file():
|
|
93
|
+
return Path(str(bundled))
|
|
94
|
+
raise UsageError(f"Couldn't find symbol table-->{path}<--", "q")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def read_symbols(path: Path) -> dict[str, str]:
|
|
98
|
+
symbols: dict[str, str] = {}
|
|
99
|
+
for line in path.read_text(encoding="latin-1").split("\n"):
|
|
100
|
+
line = re.sub(r";.*", "", line)
|
|
101
|
+
m = re.search(r"(\S+)\s*=\s*(.+)", line)
|
|
102
|
+
if m and truthy(m.group(1)) and truthy(m.group(2)):
|
|
103
|
+
symbols[m.group(1)] = m.group(2)
|
|
104
|
+
return symbols
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def expand_symbols(s: str, symbols: dict[str, str]) -> str:
|
|
108
|
+
"""Replace symbol names with their definitions, repeatedly, as whole words.
|
|
109
|
+
|
|
110
|
+
Names match without regard to case, but the replacement is looked up by
|
|
111
|
+
the text as written, so a name typed in the wrong case expands to nothing
|
|
112
|
+
(as in the Perl).
|
|
113
|
+
"""
|
|
114
|
+
if not symbols:
|
|
115
|
+
return s
|
|
116
|
+
try:
|
|
117
|
+
pattern = re.compile(r"\b(" + "|".join(symbols) + r")\b", re.IGNORECASE)
|
|
118
|
+
except re.error as e:
|
|
119
|
+
raise UsageError(f"can't use the symbol names in this table: {e}", "q") from None
|
|
120
|
+
for _ in range(MAX_SYMBOL_EXPANSIONS):
|
|
121
|
+
m = pattern.search(s)
|
|
122
|
+
if not m:
|
|
123
|
+
return s
|
|
124
|
+
s = s[: m.start()] + symbols.get(m.group(1), "") + s[m.end() :]
|
|
125
|
+
raise UsageError("symbol definitions refer to themselves without end", "q")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse(argv: list[str]) -> Command:
|
|
129
|
+
s = " ".join(argv)
|
|
130
|
+
s, opts = parse_options(s)
|
|
131
|
+
s, params = parse_params(s)
|
|
132
|
+
s, order = parse_order(s)
|
|
133
|
+
if "=" in s:
|
|
134
|
+
raise UsageError(f"Unexpected '=' in command line after parsing parms:\n {s}", "p")
|
|
135
|
+
cmd = Command(opts=opts, params=params, text=s, order=order)
|
|
136
|
+
for key in ("symTable", "symFile"):
|
|
137
|
+
if truthy(params.get(key)) and not truthy(params.get("sym")):
|
|
138
|
+
params["sym"] = params[key]
|
|
139
|
+
break
|
|
140
|
+
if truthy(params.get("sym")):
|
|
141
|
+
path = find_symbol_file(str(params["sym"]))
|
|
142
|
+
cmd.symbol_file = str(params["sym"]) if os.path.exists(str(params["sym"])) else str(path)
|
|
143
|
+
cmd.symbols = read_symbols(path)
|
|
144
|
+
cmd.text = expand_symbols(cmd.text, cmd.symbols)
|
|
145
|
+
for key in ("head", "size", "length"):
|
|
146
|
+
if key in cmd.symbols:
|
|
147
|
+
m = re.search(rf"(\d+)([{DTYPES}]?)", cmd.symbols[key])
|
|
148
|
+
params[key] = int(m.group(1)) * NBYTES.get(m.group(2) or "b", 1) if m else None
|
|
149
|
+
return cmd
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def wants_help(text: str) -> bool:
|
|
153
|
+
return "?" in text or not truthy(text) or "help" in text
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class Plan:
|
|
158
|
+
"""Where to read and which records, after the file name and size are found."""
|
|
159
|
+
|
|
160
|
+
file: str
|
|
161
|
+
size: int
|
|
162
|
+
head: int
|
|
163
|
+
start: int
|
|
164
|
+
stop: int
|
|
165
|
+
skip: int
|
|
166
|
+
fields_text: str
|
|
167
|
+
from_stdin_default: bool
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def resolve(cmd: Command) -> Plan:
|
|
171
|
+
params = cmd.params
|
|
172
|
+
s = cmd.text.lstrip()
|
|
173
|
+
args = re.split(r"\s", s) if s else []
|
|
174
|
+
while args and args[-1] == "": # Perl's split drops trailing empty fields
|
|
175
|
+
args.pop()
|
|
176
|
+
file = params.get("file")
|
|
177
|
+
default_stdin = False
|
|
178
|
+
if not truthy(file):
|
|
179
|
+
if args and args[0] and os.path.exists(args[0]):
|
|
180
|
+
file = args.pop(0)
|
|
181
|
+
s = s.replace(file, "", 1)
|
|
182
|
+
else:
|
|
183
|
+
file = "-"
|
|
184
|
+
default_stdin = True
|
|
185
|
+
if "rec" in params:
|
|
186
|
+
params["start"] = params["stop"] = params["rec"]
|
|
187
|
+
head = params.get("head") or params.get("header") or 0
|
|
188
|
+
size = params.get("size") or params.get("length")
|
|
189
|
+
if not size:
|
|
190
|
+
first = args[0] if args else ""
|
|
191
|
+
size = parse_size(first)
|
|
192
|
+
if size is None:
|
|
193
|
+
raise UsageError(f"Can't find record size; looking at {first}")
|
|
194
|
+
s = s.replace(first, "", 1)
|
|
195
|
+
skip = params.get("skip")
|
|
196
|
+
if skip is None:
|
|
197
|
+
skip = params["every"] - 1 if "every" in params else 0
|
|
198
|
+
if skip < 0: # the Perl reread the same record forever
|
|
199
|
+
raise UsageError("every must be at least 1", "p")
|
|
200
|
+
start, stop, nrecs = params.get("start"), params.get("stop"), params.get("nrecs")
|
|
201
|
+
if start is None or stop is None:
|
|
202
|
+
if start is not None:
|
|
203
|
+
stop = start + nrecs - 1 if nrecs is not None else -1
|
|
204
|
+
elif stop is not None:
|
|
205
|
+
start = stop - nrecs + 1 if nrecs is not None else 0
|
|
206
|
+
elif nrecs is not None:
|
|
207
|
+
start, stop = 0, nrecs - 1
|
|
208
|
+
else:
|
|
209
|
+
start, stop = 0, -1
|
|
210
|
+
return Plan(
|
|
211
|
+
file=str(file),
|
|
212
|
+
size=int(size),
|
|
213
|
+
head=int(head),
|
|
214
|
+
start=int(start),
|
|
215
|
+
stop=int(stop),
|
|
216
|
+
skip=int(skip),
|
|
217
|
+
fields_text=s,
|
|
218
|
+
from_stdin_default=default_stdin,
|
|
219
|
+
)
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""pick's field-request language: data types, print requests, moves and groups.
|
|
2
|
+
|
|
3
|
+
A print request is ``N type Offset :bitOffset:nBits formats`` (e.g. ``4d0+30``,
|
|
4
|
+
``w5:1:7bdx``); a move is ``(+|-)N type`` (e.g. ``+5z``); and requests can be
|
|
5
|
+
grouped with brackets and repeated (``20[ f +99f i -100f ]``). See
|
|
6
|
+
``pick ?r``, ``pick ?m`` and ``pick ?g``.
|
|
7
|
+
|
|
8
|
+
This follows the Perl original closely, including how groups are rewritten
|
|
9
|
+
and how the current position may run past either end of the record.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from .._perl import truthy
|
|
18
|
+
|
|
19
|
+
DTYPES = "SAbcswuidfzZ"
|
|
20
|
+
PFORMS = "udxnsgGoMPRIDbSAcU"
|
|
21
|
+
|
|
22
|
+
SIZE_MATCH = re.compile(rf"^((\d+)([{DTYPES}])?)$")
|
|
23
|
+
DATA_MATCH = re.compile(
|
|
24
|
+
rf"(^(\d+)?([{DTYPES}])((\d+)([+-]\d+)?)?(:(\d+)(?::(\d+))?)?([{PFORMS}]+)?$)"
|
|
25
|
+
)
|
|
26
|
+
MOVE_MATCH = re.compile(rf"((^[+-]\d+)([{DTYPES}])?$)")
|
|
27
|
+
|
|
28
|
+
# bytes per item of each data type
|
|
29
|
+
NBYTES = {"S": 1, "A": 1, "b": 1, "c": 1, "s": 2, "w": 2}
|
|
30
|
+
NBYTES |= {"u": 4, "i": 4, "f": 4, "d": 8, "z": 8, "Z": 16}
|
|
31
|
+
|
|
32
|
+
# default print formats
|
|
33
|
+
DEFAULT_FORMAT = {"b": "x", "c": "c", "s": "d", "w": "u", "S": "S", "A": "A"}
|
|
34
|
+
DEFAULT_FORMAT |= {"u": "u", "i": "d", "f": "g", "d": "g", "z": "RI", "Z": "RI"}
|
|
35
|
+
|
|
36
|
+
# struct codes used to unpack each type (for complex types: one component)
|
|
37
|
+
STRUCT = {"b": "B", "s": "h", "w": "H", "u": "I", "i": "i", "f": "f", "d": "d"}
|
|
38
|
+
STRUCT |= {"z": "f", "Z": "d"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FieldError(ValueError):
|
|
42
|
+
"""A field request pick can't use."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Field:
|
|
47
|
+
"""One print request, with its position resolved."""
|
|
48
|
+
|
|
49
|
+
src: str # the request as written
|
|
50
|
+
pos: int # byte position of the first item in the record (may be negative)
|
|
51
|
+
t: str # data type letter
|
|
52
|
+
m: int # number of items
|
|
53
|
+
formats: str # print formats
|
|
54
|
+
nb: int # bytes per item
|
|
55
|
+
first_bit: int # bit offset (0 when none given)
|
|
56
|
+
nbits: int # number of bits used
|
|
57
|
+
mask: int
|
|
58
|
+
bits: bool # was a bit offset given?
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def out_bytes(self) -> int:
|
|
62
|
+
"""Bytes of output per item for bit-limited formats (Perl's $nb)."""
|
|
63
|
+
return (7 + self.nbits) // 8
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_size(token: str) -> int | None:
|
|
67
|
+
"""A record size like ``256``, ``21d`` or ``4z``, in bytes; None if it isn't one."""
|
|
68
|
+
m = SIZE_MATCH.search(token)
|
|
69
|
+
if not m:
|
|
70
|
+
return None
|
|
71
|
+
return int(m.group(2)) * NBYTES[m.group(3) or "b"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class Parser:
|
|
75
|
+
"""Parse field requests, tracking the current position as the Perl did."""
|
|
76
|
+
|
|
77
|
+
def __init__(self, record_size: int, *, binary: bool = False):
|
|
78
|
+
self.size = record_size
|
|
79
|
+
self.binary = binary # -u: print everything as 'U'
|
|
80
|
+
self.pos = 0
|
|
81
|
+
self.fields: list[Field] = []
|
|
82
|
+
|
|
83
|
+
def field(self, f: str) -> None:
|
|
84
|
+
m = MOVE_MATCH.search(f)
|
|
85
|
+
if m:
|
|
86
|
+
t = m.group(3) or "b"
|
|
87
|
+
self.pos += int(m.group(2)) * NBYTES[t]
|
|
88
|
+
return
|
|
89
|
+
m = DATA_MATCH.search(f)
|
|
90
|
+
if not m:
|
|
91
|
+
raise FieldError(f"{f}: Matched neither a move nor a data request")
|
|
92
|
+
_, mult, t, offset, n, byte_off, bitspec, first_bit, nbits, formats = m.groups()
|
|
93
|
+
mult = int(mult) if truthy(mult) else 1 # Perl: $m ||= 1, so "0i" is "i" ("00i" is not)
|
|
94
|
+
formats = formats or DEFAULT_FORMAT[t]
|
|
95
|
+
nb = NBYTES[t]
|
|
96
|
+
if offset:
|
|
97
|
+
o = int(byte_off) if byte_off else 0
|
|
98
|
+
cpos = nb * int(n) + o
|
|
99
|
+
self.pos = nb * (int(n) + mult) + o
|
|
100
|
+
else:
|
|
101
|
+
cpos = self.pos
|
|
102
|
+
self.pos += nb * mult
|
|
103
|
+
if self.pos > self.size:
|
|
104
|
+
raise FieldError("data requested beyond record length")
|
|
105
|
+
nbits = int(nbits) if nbits is not None else 8 * nb
|
|
106
|
+
# Perl built the mask one bit at a time in a 64-bit integer; 0 bits gave 1.
|
|
107
|
+
mask = (1 << min(max(nbits, 1), 64)) - 1
|
|
108
|
+
if self.binary:
|
|
109
|
+
formats = "U"
|
|
110
|
+
self.fields.append(
|
|
111
|
+
Field(
|
|
112
|
+
src=f,
|
|
113
|
+
pos=cpos,
|
|
114
|
+
t=t,
|
|
115
|
+
m=mult,
|
|
116
|
+
formats=formats,
|
|
117
|
+
nb=nb,
|
|
118
|
+
first_bit=int(first_bit) if first_bit else 0,
|
|
119
|
+
nbits=nbits,
|
|
120
|
+
mask=mask,
|
|
121
|
+
bits=bitspec is not None,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def token(self, t: str, groups: list[str]) -> None:
|
|
126
|
+
t = t.replace("\\", "")
|
|
127
|
+
m = re.search(r"(\d*)(Y)(\d+)", t)
|
|
128
|
+
if not m:
|
|
129
|
+
self.field(t)
|
|
130
|
+
return
|
|
131
|
+
repeat = int(m.group(1)) if truthy(m.group(1)) else 1
|
|
132
|
+
k = int(m.group(3))
|
|
133
|
+
body = groups[k] if k < len(groups) else ""
|
|
134
|
+
for _ in range(repeat):
|
|
135
|
+
for sub in body.split():
|
|
136
|
+
self.token(sub, groups)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def extract_groups(text: str) -> tuple[str, list[str]]:
|
|
140
|
+
"""Replace each innermost ``[ ... ]`` with ``Y<n>`` until none are left.
|
|
141
|
+
|
|
142
|
+
Returns the rewritten text and the group bodies. Like the Perl, the body is
|
|
143
|
+
used as a regular expression (with + and - escaped) to find the group again.
|
|
144
|
+
"""
|
|
145
|
+
groups: list[str] = []
|
|
146
|
+
while m := re.search(r"\[([^\[\]]*)\]", text):
|
|
147
|
+
body = re.sub(r"([+-])", r"\\\1", m.group(1))
|
|
148
|
+
try:
|
|
149
|
+
pattern = re.compile(r"\[" + body + r"\]")
|
|
150
|
+
except re.error as e:
|
|
151
|
+
raise FieldError(f"can't parse group [{m.group(1)}]: {e}") from None
|
|
152
|
+
new = pattern.sub(f"Y{len(groups)} ", text, count=1)
|
|
153
|
+
if new == text: # the Perl looped forever here
|
|
154
|
+
raise FieldError(f"can't parse group [{m.group(1)}]")
|
|
155
|
+
text = new
|
|
156
|
+
groups.append(body)
|
|
157
|
+
return text, groups
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def parse_fields(text: str, record_size: int, *, binary: bool = False) -> list[Field]:
|
|
161
|
+
"""Parse the field-request part of a pick command line."""
|
|
162
|
+
text, groups = extract_groups(text)
|
|
163
|
+
p = Parser(record_size, binary=binary)
|
|
164
|
+
for tok in text.split():
|
|
165
|
+
p.token(tok, groups)
|
|
166
|
+
return p.fields
|