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.
Files changed (48) hide show
  1. telemetry_cli/__init__.py +8 -0
  2. telemetry_cli/_cli.py +18 -0
  3. telemetry_cli/_getopt.py +114 -0
  4. telemetry_cli/_msg.py +39 -0
  5. telemetry_cli/_pack.py +183 -0
  6. telemetry_cli/_perl.py +183 -0
  7. telemetry_cli/pick/__init__.py +1 -0
  8. telemetry_cli/pick/__main__.py +7 -0
  9. telemetry_cli/pick/cli.py +157 -0
  10. telemetry_cli/pick/commandline.py +219 -0
  11. telemetry_cli/pick/fields.py +166 -0
  12. telemetry_cli/pick/formats.py +191 -0
  13. telemetry_cli/pick/help.py +536 -0
  14. telemetry_cli/pick/symbols/airmoc +24 -0
  15. telemetry_cli/pick/symbols/aux +28 -0
  16. telemetry_cli/pick/symbols/corr +31 -0
  17. telemetry_cli/pick/symbols/corrOld +29 -0
  18. telemetry_cli/pick/symbols/hw +16 -0
  19. telemetry_cli/pick/symbols/moc +42 -0
  20. telemetry_cli/pick/symbols/subcom +83 -0
  21. telemetry_cli/pick/symbols/subcom_2002 +111 -0
  22. telemetry_cli/recl/__init__.py +1 -0
  23. telemetry_cli/recl/__main__.py +7 -0
  24. telemetry_cli/recl/cli.py +214 -0
  25. telemetry_cli/recl/corr.py +83 -0
  26. telemetry_cli/recs/__init__.py +1 -0
  27. telemetry_cli/recs/__main__.py +7 -0
  28. telemetry_cli/recs/cli.py +182 -0
  29. telemetry_cli/recs/extract.py +399 -0
  30. telemetry_cli/tgen/__init__.py +1 -0
  31. telemetry_cli/tgen/__main__.py +7 -0
  32. telemetry_cli/tgen/cli.py +128 -0
  33. telemetry_cli/tgen/cmdfile.py +68 -0
  34. telemetry_cli/tgen/convert.py +278 -0
  35. telemetry_cli/tgen/examples/arb_data.tgen +15 -0
  36. telemetry_cli/tgen/examples/calsweep.tgen +32 -0
  37. telemetry_cli/tgen/examples/cond_fill.tgen +17 -0
  38. telemetry_cli/tgen/examples/counter.tgen +11 -0
  39. telemetry_cli/tgen/examples/null_data.tgen +11 -0
  40. telemetry_cli/tgen/examples/random_size.tgen +20 -0
  41. telemetry_cli/tgen/examples/subcom.tgen +14 -0
  42. telemetry_cli/tgen/examples/valueV_value.tgen +20 -0
  43. telemetry_cli/tgen/runtime.py +390 -0
  44. telemetry_cli-1.0.0.dist-info/METADATA +106 -0
  45. telemetry_cli-1.0.0.dist-info/RECORD +48 -0
  46. telemetry_cli-1.0.0.dist-info/WHEEL +4 -0
  47. telemetry_cli-1.0.0.dist-info/entry_points.txt +5 -0
  48. 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