oep-client-python 0.0.1__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.
@@ -0,0 +1,299 @@
1
+ """Serve a fake probe (`endpoint.Endpoint`) on a pty or a TCP port, for other programs' tests.
2
+
3
+ python -m oep_client.v1.fake_serve [--pty | --tcp PORT] [options]
4
+
5
+ --pty (the default) opens a pseudo terminal that is the probe's serial port (oep-core §3.4): COBS frames
6
+ 0x00 <COBS> 0x00 and the raw bytes of the port's bind on one line. The host opens the printed path itself (and
7
+ should set TIOCEXCL on it, as on a real port; this program never opens the slave side). --tcp PORT (0 = any free
8
+ one) serves one connection at a time: --framing cobs is the serial port again, --framing length is
9
+ length(u16) message as on vendor bulk / TCP (no raw bytes).
10
+
11
+ The first line on stdout says where to open: `PTY /dev/pts/N` or `PORT n`. The program ends when stdin closes
12
+ (so a test's child never stays behind), or with --once when the first TCP connection closes.
13
+
14
+ Options:
15
+ --profile NAME p4-x035 (default), esp32-v003 or p4-bench (p4_x035 style names work too)
16
+ --port-index N which serial port of the profile the pty / cobs TCP is (default: the first one)
17
+ --noise TEXT raw bytes written in front of every answer (the host must skip them)
18
+ --drop N the N-th answer (1-based) is not sent, once (the request did run: a resend gets the
19
+ remembered result)
20
+ --corrupt N the N-th answer goes out once with a broken CRC
21
+ --console FMT what the targets write to their consoles, %d = a counter, {t} = the target's index
22
+ --every MS how often (default 100; "100ms" works too)
23
+ --slot NAME register a slot at boot (repeatable; the n-th on the n-th pin pair of the first wire, at boot,
24
+ retry 1 s, mechanism dmseq), as if saved
25
+ --bind MODE bind the serial port to every --slot: last-reset, manual or mixed
26
+ --target-id HEX the target_id every target's attach reports (wch_dmi_7f)
27
+ --absent N the N-th pin pair of the first wire has no target (repeatable)
28
+ --run-hook SPEC what riscv-dm run does on every target: SPEC is module:function or path/file.py:function,
29
+ called as function(target, pc, regs) -> (stopped, dpc, elapsed_us). `target` is the
30
+ endpoint.FakeTarget (mem = word address -> value, regs = regno -> value, halted, dpc), so a
31
+ host's loader can be played by the host's own test code (the fake knows no loader).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import argparse
37
+ import importlib
38
+ import importlib.util
39
+ import os
40
+ import re
41
+ import select
42
+ import socket
43
+ import struct
44
+ import sys
45
+ import time
46
+ import tty
47
+
48
+ from . import endpoint, fake, fake_serial, registry as reg
49
+
50
+ _ITEM = reg.PROBE_CONFIG.tlv["item"]
51
+ _MODES = reg.PROBE_CONFIG.enum["bind_mode"]
52
+
53
+
54
+ def _ms(text: str) -> int:
55
+ return int(re.fullmatch(r"(\d+)\s*(ms)?", text.strip()).group(1))
56
+
57
+
58
+ def build(a: argparse.Namespace) -> endpoint.Endpoint:
59
+ profile = fake.PROFILES.get(a.profile) or fake.PROFILES[a.profile.replace("_", "-")]
60
+ start = time.monotonic()
61
+ ep = endpoint.Endpoint(profile(), lambda: int((time.monotonic() - start) * 1000))
62
+ wire = min(ep.pairs) if ep.pairs else None
63
+ if a.target_id is not None:
64
+ for tg in ep.targets.values():
65
+ tg.target_id = int(a.target_id, 16)
66
+ if a.run_hook:
67
+ hook = _load_hook(a.run_hook)
68
+ for tg in ep.targets.values():
69
+ tg.run_hook = (lambda t: lambda pc, regs: hook(t, pc, regs))(tg)
70
+ for n in a.absent:
71
+ ep.targets[(wire, ep.pairs[wire][n])].present = False
72
+ items = []
73
+ for n, name in enumerate(a.slot):
74
+ swdio, swclk = ep.pairs[wire][n]
75
+ mech = 2 if 2 in ep.mechanisms else min(ep.mechanisms)
76
+ raw = name.encode()
77
+ value = struct.pack("<BHHHBHBB", n, wire, swdio, swclk, reg.PROBE_CONFIG.enum["slot_attach"]["at_boot"], 1,
78
+ mech, len(raw)) + raw + b"\x00" # no lock
79
+ items.append(bytes([_ITEM["slot"], len(value)]) + value)
80
+ if a.bind:
81
+ streams = b"".join(struct.pack("<BH", reg.PROBE_CONFIG.enum["bind_stream"]["slot_console"], n)
82
+ for n in range(len(a.slot)))
83
+ value = struct.pack("<BBBB", a.port_index, _MODES[a.bind.replace("-", "_")], 0, len(a.slot)) + streams
84
+ items.append(bytes([_ITEM["bind"], len(value)]) + value)
85
+ if items:
86
+ ep.load_config(items, saved=True)
87
+ return ep
88
+
89
+
90
+ def _load_hook(spec: str):
91
+ where, _, name = spec.rpartition(":")
92
+ if not where or not name:
93
+ raise SystemExit(f"--run-hook {spec}: want module:function or file.py:function")
94
+ if where.endswith(".py") or os.sep in where:
95
+ loader = importlib.util.spec_from_file_location("fake_run_hook", where)
96
+ module = importlib.util.module_from_spec(loader)
97
+ loader.loader.exec_module(module)
98
+ else:
99
+ module = importlib.import_module(where)
100
+ return getattr(module, name)
101
+
102
+
103
+ def _filter(a: argparse.Namespace):
104
+ done = set()
105
+
106
+ def answer(n: int, result: bytes) -> bytes | None:
107
+ from . import cobs
108
+ if n == a.drop and "drop" not in done:
109
+ done.add("drop")
110
+ return None
111
+ wire = cobs.frame(result)
112
+ if n == a.corrupt and "corrupt" not in done:
113
+ done.add("corrupt")
114
+ body = bytearray(cobs.decode(wire[1:-1]))
115
+ body[-1] ^= 0xFF # the CRC's high byte
116
+ wire = b"\x00" + cobs.encode(bytes(body)) + b"\x00"
117
+ return a.noise.encode() + wire if a.noise else wire
118
+ return answer
119
+
120
+
121
+ class Console:
122
+ def __init__(self, ep: endpoint.Endpoint, fmt: str | None, every_ms: int):
123
+ self.ep, self.fmt, self.every = ep, fmt, every_ms
124
+ self.next_ms, self.count = 0, 0
125
+
126
+ def tick(self) -> None:
127
+ if not self.fmt or self.ep.now() < self.next_ms:
128
+ return
129
+ self.next_ms = self.ep.now() + self.every
130
+ for i, tg in enumerate(self.ep.targets.values()):
131
+ text = self.fmt.replace("{t}", str(i))
132
+ if "%d" in text:
133
+ text = text.replace("%d", str(self.count))
134
+ self.ep.target_says(text.encode().decode("unicode_escape").encode("latin-1"), tg)
135
+ self.count += 1
136
+
137
+
138
+ def _stdin_closed(readable) -> bool:
139
+ if sys.stdin in readable:
140
+ try:
141
+ return not os.read(sys.stdin.fileno(), 4096)
142
+ except OSError:
143
+ return True
144
+ return False
145
+
146
+
147
+ def serve_pty(a, ep, console) -> None:
148
+ master, slave = os.openpty()
149
+ tty.setraw(slave) # no echo, no line discipline between host and probe
150
+ print(f"PTY {os.ttyname(slave)}", flush=True)
151
+ os.close(slave)
152
+ os.set_blocking(master, False)
153
+ port = fake_serial.FakeSerialPort(ep, a.port_index, _filter(a))
154
+ pending = bytearray()
155
+ watch = [master] + ([sys.stdin] if not sys.stdin.closed else [])
156
+ while True:
157
+ readable, _, _ = select.select(watch, [], [], 0.005)
158
+ if _stdin_closed(readable):
159
+ return
160
+ if master in readable:
161
+ try:
162
+ data = os.read(master, 65536)
163
+ except OSError: # nobody has the port open (EIO)
164
+ data = b""
165
+ if data:
166
+ port.feed(data)
167
+ console.tick()
168
+ port.tick()
169
+ if len(pending) < 1024:
170
+ pending += port.output()
171
+ if pending:
172
+ try:
173
+ pending = pending[os.write(master, pending):]
174
+ except (BlockingIOError, OSError):
175
+ pass # full or not open: keep it (the stream positions hold)
176
+
177
+
178
+ def serve_tcp(a, ep, console) -> None:
179
+ srv = socket.socket()
180
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
181
+ srv.bind(("127.0.0.1", a.tcp))
182
+ srv.listen(1)
183
+ print(f"PORT {srv.getsockname()[1]}", flush=True)
184
+ watch_stdin = [sys.stdin] if not sys.stdin.closed else []
185
+ while True:
186
+ readable, _, _ = select.select([srv] + watch_stdin, [], [], 0.05)
187
+ if _stdin_closed(readable):
188
+ return
189
+ console.tick()
190
+ ep.tick()
191
+ if srv not in readable:
192
+ continue
193
+ conn, _ = srv.accept()
194
+ conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
195
+ if _serve_conn(a, ep, console, conn, watch_stdin):
196
+ return
197
+ conn.close()
198
+ if a.once:
199
+ return
200
+
201
+
202
+ def _serve_conn(a, ep, console, conn, watch_stdin) -> bool:
203
+ """One TCP connection; True = stdin closed (end the program)."""
204
+ conn.setblocking(False)
205
+ if a.framing == "cobs":
206
+ port = fake_serial.FakeSerialPort(ep, a.port_index, _filter(a))
207
+ else:
208
+ index = next((i for i, k in enumerate(ep.transports) if i not in ep.serial_ports), 0)
209
+ buf, answers, filt = bytearray(), 0, _filter(a)
210
+ while True:
211
+ readable, _, _ = select.select([conn] + watch_stdin, [], [], 0.005)
212
+ if _stdin_closed(readable):
213
+ return True
214
+ if conn in readable:
215
+ try:
216
+ data = conn.recv(65536)
217
+ except BlockingIOError:
218
+ data = None
219
+ except ConnectionResetError:
220
+ data = b""
221
+ if data == b"":
222
+ return False
223
+ if data:
224
+ if a.framing == "cobs":
225
+ port.feed(data)
226
+ else:
227
+ buf += data
228
+ while len(buf) >= 2:
229
+ n = struct.unpack_from("<H", buf)[0]
230
+ if n == 0:
231
+ del buf[:2]
232
+ continue
233
+ if len(buf) < 2 + n:
234
+ break
235
+ msg = bytes(buf[2:2 + n])
236
+ del buf[:2 + n]
237
+ try:
238
+ result = ep.handle(msg, index)
239
+ except ValueError:
240
+ continue
241
+ if result is None:
242
+ continue
243
+ answers += 1
244
+ if a.drop == answers and filt(answers, result) is None:
245
+ continue
246
+ conn.sendall(a.noise.encode() + struct.pack("<H", len(result)) + result)
247
+ console.tick()
248
+ if a.framing == "cobs":
249
+ port.tick()
250
+ out = port.output()
251
+ if out:
252
+ conn.setblocking(True)
253
+ conn.sendall(out)
254
+ conn.setblocking(False)
255
+ else:
256
+ ep.tick()
257
+
258
+
259
+ def main(argv: list[str] | None = None) -> None:
260
+ ap = argparse.ArgumentParser(prog="python -m oep_client.v1.fake_serve", description=__doc__.split("\n\n")[0])
261
+ where = ap.add_mutually_exclusive_group()
262
+ where.add_argument("--pty", action="store_true")
263
+ where.add_argument("--tcp", type=int, metavar="PORT")
264
+ ap.add_argument("--framing", choices=["cobs", "length"], default="cobs")
265
+ ap.add_argument("--profile", default="p4-x035")
266
+ ap.add_argument("--port-index", type=int)
267
+ ap.add_argument("--noise", default="")
268
+ ap.add_argument("--drop", type=int, default=0)
269
+ ap.add_argument("--corrupt", type=int, default=0)
270
+ ap.add_argument("--console")
271
+ ap.add_argument("--every", default="100")
272
+ ap.add_argument("--slot", action="append", default=[])
273
+ ap.add_argument("--bind", choices=["last-reset", "manual", "mixed"])
274
+ ap.add_argument("--target-id")
275
+ ap.add_argument("--absent", type=int, action="append", default=[])
276
+ ap.add_argument("--run-hook")
277
+ ap.add_argument("--once", action="store_true")
278
+ a = ap.parse_args(argv)
279
+ if a.tcp is None and a.framing == "length":
280
+ ap.error("--framing length is for --tcp")
281
+ profile = fake.PROFILES.get(a.profile) or fake.PROFILES.get(a.profile.replace("_", "-"))
282
+ if profile is None:
283
+ ap.error(f"unknown profile {a.profile}; one of {', '.join(sorted(fake.PROFILES))}")
284
+ if a.port_index is None:
285
+ probe_ep = endpoint.Endpoint(profile(), lambda: 0)
286
+ a.port_index = min(probe_ep.serial_ports) if probe_ep.serial_ports else 0
287
+ ep = build(a)
288
+ console = Console(ep, a.console, _ms(a.every))
289
+ try:
290
+ if a.tcp is None:
291
+ serve_pty(a, ep, console)
292
+ else:
293
+ serve_tcp(a, ep, console)
294
+ except KeyboardInterrupt:
295
+ pass
296
+
297
+
298
+ if __name__ == "__main__":
299
+ main()
@@ -0,0 +1,121 @@
1
+ """oep.fixture.gpio and oep.fixture.uart, revision 1 (oep-spec oep-if-fixture). The capture is in `capture`
2
+ (oep.fixture.capture revision 1 = logic-capture's basic set).
3
+
4
+ Plan roles stay as they were: gpio 1 = line; uart 1 = RX, 2 = TX. Only channels the plan assigned can be used.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import struct
10
+ import time
11
+
12
+ from . import host as h, message as m, registry as reg
13
+ from .console import PositionStream, StreamIO
14
+ from .core import Interface
15
+
16
+ _GPIO, _UART = reg.FIXTURE_GPIO, reg.FIXTURE_UART
17
+ _MODE = _GPIO.enum["mode"]
18
+
19
+
20
+ class Gpio(Interface):
21
+ """oep.fixture.gpio. `set` applies (channel, mode) pairs in order in one request (pull NRST, then release it); a
22
+ channel the plan did not assign or a mode the probe lacks rejects the whole list as unavailable (payload: its
23
+ index). The open-drain modes never drive a line high: the way to move a target's reset line."""
24
+ NAME = "oep.fixture.gpio"
25
+ REVISION = 1
26
+ SET, READ = _GPIO.op["set"], _GPIO.op["read"]
27
+ INPUT, INPUT_PULLUP, INPUT_PULLDOWN = _MODE["input"], _MODE["input_pullup"], _MODE["input_pulldown"]
28
+ OUTPUT_LOW, OUTPUT_HIGH = _MODE["output_low"], _MODE["output_high"]
29
+ OPEN_DRAIN_LOW, OPEN_DRAIN_RELEASE = _MODE["open_drain_low"], _MODE["open_drain_release"]
30
+ INPUT_PULLUP_PULLDOWN = _MODE["input_pullup_pulldown"] # both pulls: a weak mid level
31
+
32
+ def __init__(self, hst: h.Host, fn: int | None = None, name: str | None = None):
33
+ super().__init__(hst, name, fn=fn)
34
+
35
+ @staticmethod
36
+ def set_body(pairs: list[tuple[int, int]]) -> bytes:
37
+ return struct.pack("<B", len(pairs)) + b"".join(struct.pack("<HB", ch, mode) for ch, mode in pairs)
38
+
39
+ def set(self, pairs: list[tuple[int, int]]) -> None:
40
+ """[(channel, mode)], applied in order."""
41
+ self._call(self.SET, self.set_body(pairs))
42
+
43
+ def configure(self, channel: int, mode: int) -> None:
44
+ self.set([(channel, mode)])
45
+
46
+ def read(self, channels: list[int]) -> list[int]:
47
+ """-> one level (0 / 1) per channel. Lock-free."""
48
+ rd = m.Reader(self._call(self.READ, struct.pack("<B", len(channels))
49
+ + b"".join(struct.pack("<H", c) for c in channels), locked=False).payload)
50
+ levels = list(rd.bytes(len(channels)))
51
+ rd.tail()
52
+ return levels
53
+
54
+ def pull_low(self, channel: int) -> None:
55
+ self.set([(channel, self.OPEN_DRAIN_LOW)])
56
+
57
+ def release(self, channel: int) -> None:
58
+ self.set([(channel, self.OPEN_DRAIN_RELEASE)])
59
+
60
+ def request_release(self, channel: int) -> tuple[int, int, bytes]:
61
+ """The release as a raw request, to pipeline with whatever must follow it at once."""
62
+ return self.request(self.SET, self.set_body([(channel, self.OPEN_DRAIN_RELEASE)]))
63
+
64
+ def pulse_low(self, channel: int, low_s: float = 0.02) -> None:
65
+ """Open-drain low, then released to Hi-Z (never driven high)."""
66
+ self.pull_low(channel)
67
+ try:
68
+ time.sleep(low_s)
69
+ finally:
70
+ self.release(channel)
71
+
72
+
73
+ class FixtureUart(PositionStream):
74
+ """oep.fixture.uart: one position stream per fn, like the console without a stream byte. Received bytes are kept
75
+ from configure until plan_release, whatever the session; reads are lock-free and do not consume. TX idles high
76
+ before configure and after plan_release."""
77
+ NAME = "oep.fixture.uart"
78
+ REVISION = 1
79
+ CONFIGURE = _UART.op["configure"]
80
+ TAG_FORMAT = _UART.tlv["configure"]["format"]
81
+ # format bits: data bits (0 = 8, 1 = 7), parity (0 none, 1 even, 2 odd) << 2, stop bits (0 = 1, 1 = 2) << 4
82
+ EIGHT_N_1 = 0x00
83
+
84
+ def __init__(self, hst: h.Host, fn: int | None = None, name: str | None = None):
85
+ super().__init__(hst, name, fn=fn)
86
+
87
+ @staticmethod
88
+ def format_byte(data_bits: int = 8, parity: str = "N", stop_bits: int = 1) -> int:
89
+ return ({8: 0, 7: 1}[data_bits] | {"N": 0, "E": 1, "O": 2}[parity.upper()] << 2 | {1: 0, 2: 1}[stop_bits] << 4)
90
+
91
+ def configure(self, baud: int, fmt: int | None = None) -> int:
92
+ """-> the actual baud. fmt (format_byte()) goes as a critical TLV: a probe that cannot set it refuses rather
93
+ than running 8N1; None leaves the default 8N1."""
94
+ body = struct.pack("<I", baud)
95
+ if fmt is not None:
96
+ body += m.tlv(self.TAG_FORMAT, bytes([fmt]), critical=True)
97
+ rd = m.Reader(self._call(self.CONFIGURE, body).payload)
98
+ actual = rd.u32()
99
+ rd.tail()
100
+ return actual
101
+
102
+
103
+ class FixtureUartIO(StreamIO):
104
+ """oep.fixture.uart as a plain byte stream, after a plan gave it RX / TX: configure() starts reading from the
105
+ stream's position at that moment (earlier bytes are skipped); write() splits and waits for the UART."""
106
+ MAX_READ, MAX_WRITE = 480, 256 # fit the smallest probe frame in use (V003: 512 bytes)
107
+
108
+ def __init__(self, hst: h.Host, fn: int | None = None, name: str | None = None):
109
+ self.uart = FixtureUart(hst, fn, name)
110
+ self.source, self.position, self.lost = self.uart, None, 0
111
+ self.baud = 0
112
+
113
+ def configure(self, baud: int, fmt: int | None = None) -> int:
114
+ self.baud = self.uart.configure(baud, fmt)
115
+ self.position = self.uart.read(PositionStream.FROM_NOW, 0, 0).start
116
+ return self.baud
117
+
118
+ def read(self, n: int = 512) -> bytes:
119
+ if self.position is None: # not configured here: read from the oldest byte kept
120
+ self.position = self.uart.read(PositionStream.FROM_OLDEST, 0, 0).start
121
+ return super().read(n)
@@ -0,0 +1,91 @@
1
+ """Length-prefixed frames on a reliable byte stream (USB CDC, USB-Serial/JTAG, USB vendor bulk): u16 length, then the
2
+ message (oep-spec oep-core §3). No CRC: a length that cannot be right, or a frame that stops half way, means
3
+ the boundaries are lost - FramingLost, and the link resyncs."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import struct
8
+ import time
9
+
10
+ from . import registry as reg
11
+
12
+ STALL_S = reg.TIMING["probe_frame_gap_ms"] / 1000 # a frame whose bytes stop this long is not coming
13
+
14
+
15
+ class FramingLost(ConnectionError):
16
+ """The frame boundaries are lost: an impossible length, a frame that stalled half way, a result for another
17
+ request. The link reads and discards until the input is quiet, then confirms (oep-core §5.1)."""
18
+
19
+
20
+ class LengthFrames:
21
+ def __init__(self, stream, max_frame: int = 0xFFFF):
22
+ self._stream = stream
23
+ self._buffer = bytearray()
24
+ self._last_rx = time.monotonic()
25
+ self.max_frame = max_frame
26
+
27
+ def send_many(self, messages) -> None:
28
+ """Several frames in one write (each frame whole in one write: the probe restarts its reader when a frame
29
+ pauses, and separate writes over usbipd can be 100 ms apart)."""
30
+ chunk = bytearray()
31
+ for message in messages:
32
+ if not message or len(message) > self.max_frame:
33
+ raise ValueError(f"message length {len(message)} outside 1..{self.max_frame}")
34
+ chunk += struct.pack("<H", len(message)) + message
35
+ if chunk:
36
+ self._stream.write(bytes(chunk))
37
+
38
+ def _fill(self, deadline: float) -> bool:
39
+ remaining = deadline - time.monotonic()
40
+ if remaining <= 0:
41
+ return False
42
+ self._stream.timeout = min(remaining, 0.05)
43
+ data = self._stream.read(1)
44
+ if not data:
45
+ return False
46
+ waiting = getattr(self._stream, "in_waiting", 0)
47
+ if waiting:
48
+ data += self._stream.read(waiting)
49
+ self._buffer += data
50
+ self._last_rx = time.monotonic()
51
+ return True
52
+
53
+ def recv(self, timeout: float) -> bytes | None:
54
+ """The next message, or None when nothing arrived by the timeout. Raises FramingLost for a length above
55
+ max_frame or a frame whose bytes stopped for STALL_S (length 0 is the reserved keepalive: skipped)."""
56
+ deadline = time.monotonic() + timeout
57
+ while True:
58
+ if len(self._buffer) >= 2:
59
+ length = self._buffer[0] | (self._buffer[1] << 8)
60
+ if length == 0:
61
+ del self._buffer[:2]
62
+ continue
63
+ if length > self.max_frame:
64
+ raise FramingLost(f"frame length {length} exceeds {self.max_frame}")
65
+ if len(self._buffer) >= 2 + length:
66
+ message = bytes(self._buffer[2:2 + length])
67
+ del self._buffer[:2 + length]
68
+ return message
69
+ if not self._fill(deadline):
70
+ if self._buffer and time.monotonic() - self._last_rx >= STALL_S:
71
+ raise FramingLost(f"a frame stopped after {len(self._buffer)} bytes")
72
+ if time.monotonic() >= deadline:
73
+ if self._buffer:
74
+ raise FramingLost(f"a frame stopped after {len(self._buffer)} bytes")
75
+ return None
76
+
77
+ def discard_until_quiet(self, quiet_s: float, limit_s: float) -> bool:
78
+ """Read and throw away until nothing arrives for quiet_s. -> False if the input never went quiet in limit_s."""
79
+ self._buffer.clear()
80
+ end = time.monotonic() + limit_s
81
+ while time.monotonic() < end:
82
+ self._stream.timeout = quiet_s
83
+ if not self._stream.read(max(1, getattr(self._stream, "in_waiting", 0))):
84
+ return True
85
+ return False
86
+
87
+ def discard_input(self) -> None:
88
+ self._buffer.clear()
89
+ reset = getattr(self._stream, "reset_input_buffer", None)
90
+ if reset:
91
+ reset()