diff --git a/prek.toml b/prek.toml index aaf37f6..6da45bd 100644 --- a/prek.toml +++ b/prek.toml @@ -70,6 +70,15 @@ types_or = ["python", "pyi"] exclude = '^tests/esphome/external_components/' require_serial = true +[[repos.hooks]] +id = "pylint" +name = "pylint" +entry = "uv run --frozen pylint serialx/" +language = "system" +types = ["python"] +pass_filenames = false +require_serial = true + [[repos.hooks]] id = "cargo-fmt" name = "Cargo fmt" diff --git a/pylint/plugins/pylint_serialx.py b/pylint/plugins/pylint_serialx.py new file mode 100644 index 0000000..cb15c12 --- /dev/null +++ b/pylint/plugins/pylint_serialx.py @@ -0,0 +1,51 @@ +"""Pylint plugin for serialx.""" + +from __future__ import annotations + +from astroid.nodes import Arguments, AssignName, FunctionDef +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + + +class ParamReassignChecker(BaseChecker): + """Flag rebinding a function parameter to a new value.""" + + name = "serialx-param-reassign" + msgs = { + # serialx reserves pylint message base 90: codes are {C,W,E,R}90xx. + "W9001": ( + "Reassigning function parameter %r; bind a new local instead", + "serialx-reassigned-parameter", + "Rebinding a parameter overloads one name with two meanings and lets " + "a rewritten value leak into later uses of the original.", + ), + } + + def visit_assignname(self, node: AssignName) -> None: + """Flag an assignment target that rebinds an enclosing function parameter.""" + # Skip the parameter *definition* itself (its target lives under Arguments). + if isinstance(node.parent, Arguments): + return + + scope = node.scope() + if not isinstance(scope, FunctionDef): + return + + args = scope.args + names: set[str] = set() + for group in (args.posonlyargs, args.args, args.kwonlyargs): + names.update(arg.name for arg in group or []) + if args.vararg: + names.add(args.vararg) + if args.kwarg: + names.add(args.kwarg) + + if node.name in names: + self.add_message( + "serialx-reassigned-parameter", node=node, args=(node.name,) + ) + + +def register(linter: PyLinter) -> None: + """Register the serialx checkers with pylint.""" + linter.register_checker(ParamReassignChecker(linter)) diff --git a/pyproject.toml b/pyproject.toml index 573f21e..7bafffb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ dev = [ "uv>=0.11.14", "ruff>=0.14.6", "mypy>=2.1.0", + "pylint>=3.3.0", "codespell>=2.4.2", "tomli>=2.3.0 ; python_version < '3.11'", "pytest>=9.0.1", @@ -173,6 +174,10 @@ disable_error_code = [ "untyped-decorator", ] +[[tool.mypy.overrides]] +module = ["astroid.*"] +ignore_missing_imports = true + [tool.coverage.run] source = ["serialx"] @@ -181,6 +186,14 @@ relative_files = true [tool.coverage.paths] source = ["serialx/", "serialx\\"] +[tool.pylint.MAIN] +init-hook = "import sys; sys.path.append('pylint/plugins')" +load-plugins = ["pylint_serialx"] + +[tool.pylint."MESSAGES CONTROL"] +disable = ["all"] +enable = ["serialx-reassigned-parameter"] + [tool.ruff] target-version = "py310" @@ -319,6 +332,7 @@ max-complexity = 25 [tool.codespell] skip = "tests/data/*" +ignore-words-list = "astroid" [tool.coverage.report] show_missing = true diff --git a/serialx/async_serial.py b/serialx/async_serial.py index c94d4e1..f284aa8 100644 --- a/serialx/async_serial.py +++ b/serialx/async_serial.py @@ -273,18 +273,18 @@ async def create_serial_connection( **kwargs: Any, ) -> tuple[BaseSerialTransport, asyncio.Protocol]: """Create a serial port connection with asyncio.""" - if transport_cls is None: - if url is None: - raise ValueError("One of `url` or `transport_cls` must be provided.") - + if transport_cls is not None: + resolved_cls = transport_cls + elif url is None: + raise ValueError("One of `url` or `transport_cls` must be provided.") + else: handler = await asyncio.get_running_loop().run_in_executor( None, get_uri_handler, url ) - transport_cls = handler.async_transport_cls + resolved_cls = handler.async_transport_cls - assert transport_cls is not None protocol = protocol_factory() - transport = transport_cls(loop=loop, protocol=protocol) + transport = resolved_cls(loop=loop, protocol=protocol) await transport.connect( path=url, diff --git a/serialx/common.py b/serialx/common.py index c029c1d..d866ed3 100644 --- a/serialx/common.py +++ b/serialx/common.py @@ -339,21 +339,24 @@ def __init__( """Initialize serial port configuration.""" super().__init__() - if not isinstance(stopbits, StopBits): - stopbits = StopBits(stopbits) + normalized_stopbits = ( + stopbits if isinstance(stopbits, StopBits) else StopBits(stopbits) + ) if parity is None: - parity = Parity.NONE - elif not isinstance(parity, Parity): - parity = Parity(parity) + normalized_parity = Parity.NONE + elif isinstance(parity, Parity): + normalized_parity = parity + else: + normalized_parity = Parity(parity) self._path = path self._baudrate = baudrate - self._stopbits = stopbits + self._stopbits = normalized_stopbits self._xonxoff = xonxoff self._rtscts = rtscts self._dsrdtr = dsrdtr - self._parity = parity + self._parity = normalized_parity self._byte_size = byte_size self._exclusive = exclusive self._read_timeout = read_timeout @@ -397,9 +400,12 @@ def _check_broken(self) -> None: def from_url(cls, url: str, *args: Any, **kwargs: Any) -> BaseSerial: """Create the appropriate serial port subclass for the given URL.""" handler = get_uri_handler(url) + target = url if handler.strip_uri_scheme: - url = url.removeprefix(handler.scheme).removeprefix(handler.unique_scheme) - return handler.sync_cls(url, *args, **kwargs) + target = url.removeprefix(handler.scheme).removeprefix( + handler.unique_scheme + ) + return handler.sync_cls(target, *args, **kwargs) @maybe_wrap_exceptions def open(self) -> None: @@ -471,8 +477,9 @@ def set_modem_pins( """Set modem control bits.""" self._check_broken() - if modem_pins is None: - modem_pins = ModemPins( + pins = modem_pins + if pins is None: + pins = ModemPins( le=PinState.convert(le), dtr=PinState.convert(dtr), rts=PinState.convert(rts), @@ -484,7 +491,7 @@ def set_modem_pins( dsr=PinState.convert(dsr), ) - return self._set_modem_pins(modem_pins) + return self._set_modem_pins(pins) @abstractmethod def _get_modem_pins(self) -> ModemPins: @@ -500,8 +507,8 @@ def _set_modem_pins(self, modem_pins: ModemPins) -> None: def readinto(self, b: Buffer, *, timeout: float | None = None) -> int: """Read bytes from serial port into buffer.""" self._check_broken() - timeout = self._read_timeout if timeout is None else timeout - return self._readinto(b, timeout=timeout) + effective_timeout = self._read_timeout if timeout is None else timeout + return self._readinto(b, timeout=effective_timeout) @abstractmethod def _readinto(self, b: Buffer, *, timeout: float | None) -> int: @@ -512,8 +519,8 @@ def _readinto(self, b: Buffer, *, timeout: float | None) -> int: def write(self, data: Buffer, *, timeout: float | None = None) -> int: """Write bytes to serial port.""" self._check_broken() - timeout = self._write_timeout if timeout is None else timeout - return self._write(data, timeout=timeout) + effective_timeout = self._write_timeout if timeout is None else timeout + return self._write(data, timeout=effective_timeout) @abstractmethod def _write(self, data: Buffer, *, timeout: float | None) -> int: @@ -581,14 +588,14 @@ def readexactly(self, n: int, *, timeout: float | None = None) -> bytes: buffer = bytearray(n) view = memoryview(buffer) remaining = n - timeout = self.read_timeout if timeout is None else timeout + remaining_timeout = self.read_timeout if timeout is None else timeout while remaining > 0: with measure_time() as get_elapsed: - read = self.readinto(view, timeout=timeout) + read = self.readinto(view, timeout=remaining_timeout) - if timeout is not None: - timeout -= get_elapsed() + if remaining_timeout is not None: + remaining_timeout -= get_elapsed() view = view[read:] remaining -= read @@ -611,14 +618,14 @@ def read_until( """Read until the expected sequence is found.""" buffer = bytearray() expected_len = len(expected) - timeout = self.read_timeout if timeout is None else timeout + remaining_timeout = self.read_timeout if timeout is None else timeout while True: with measure_time() as get_elapsed: - byte = self.readexactly(1, timeout=timeout) + byte = self.readexactly(1, timeout=remaining_timeout) - if timeout is not None: - timeout -= get_elapsed() + if remaining_timeout is not None: + remaining_timeout -= get_elapsed() if not byte: break @@ -1009,15 +1016,16 @@ async def connect( **kwargs: Unpack[ConnectKwargs], ) -> None: """Connect to serial port.""" - if path is not None: - handler = get_uri_handler(path) + target = path + if target is not None: + handler = get_uri_handler(target) if handler.strip_uri_scheme: - path = path.removeprefix(handler.scheme).removeprefix( + target = target.removeprefix(handler.scheme).removeprefix( handler.unique_scheme ) try: - await self._connect(path=path, **kwargs) + await self._connect(path=target, **kwargs) except BaseException: # Intentionally catch cancellation too: callers should only observe # connect failure/cancel after transport resources are released. @@ -1058,8 +1066,9 @@ async def set_modem_pins( """Set modem control bits.""" self._check_broken() - if modem_pins is None: - modem_pins = ModemPins( + pins = modem_pins + if pins is None: + pins = ModemPins( le=PinState.convert(le), dtr=PinState.convert(dtr), rts=PinState.convert(rts), @@ -1071,7 +1080,7 @@ async def set_modem_pins( dsr=PinState.convert(dsr), ) - return await self._set_modem_pins(modem_pins) + return await self._set_modem_pins(pins) async def flush(self) -> None: """Flush write buffers, waiting until all data is written.""" diff --git a/serialx/descriptor_transport.py b/serialx/descriptor_transport.py index 850992a..3bcf86a 100644 --- a/serialx/descriptor_transport.py +++ b/serialx/descriptor_transport.py @@ -212,6 +212,7 @@ def get_write_buffer_limits(self) -> tuple[int, int]: def _set_write_buffer_limits( self, high: int | None = None, low: int | None = None ) -> None: + # pylint: disable=serialx-reassigned-parameter if high is None: if low is None: # noqa: SIM108 high = 64 * 1024 @@ -257,9 +258,10 @@ def write(self, data: bytes | bytearray | memoryview) -> None: self._check_broken() - if isinstance(data, bytearray): - data = memoryview(data) - if not data: + buf: bytes | memoryview = ( + memoryview(data) if isinstance(data, bytearray) else data + ) + if not buf: return if self._closing or self._conn_lost_count > 0: @@ -273,7 +275,7 @@ def write(self, data: bytes | bytearray | memoryview) -> None: if not self._buffer: # Attempt to send it right away first. try: - n = os.write(self._fileno, data) + n = os.write(self._fileno, buf) except (BlockingIOError, InterruptedError): n = 0 except (SystemExit, KeyboardInterrupt): @@ -288,17 +290,17 @@ def write(self, data: bytes | bytearray | memoryview) -> None: ) return - len_data = len(data) + len_data = len(buf) LOGGER.debug("Sent %d of %d bytes", n, len_data) if n == len_data: return elif n > 0: - data = memoryview(data)[n:] + buf = memoryview(buf)[n:] self._loop.add_writer(self._fileno, self._write_ready) - LOGGER.debug("Buffering %r", data) - self._buffer += data + LOGGER.debug("Buffering %r", buf) + self._buffer += buf self._maybe_pause_protocol() def _write_ready(self) -> None: diff --git a/serialx/platforms/serial_pyodide/__init__.py b/serialx/platforms/serial_pyodide/__init__.py index 14cd514..893785a 100644 --- a/serialx/platforms/serial_pyodide/__init__.py +++ b/serialx/platforms/serial_pyodide/__init__.py @@ -214,16 +214,15 @@ async def _connect( # type: ignore[override] else: raise UnsupportedSetting(f"Unsupported byte_size: {byte_size!r}") - if js_port is None: - js_port = _REGISTERED_JS_PORTS.get(path) + port = js_port if js_port is not None else _REGISTERED_JS_PORTS.get(path) - if js_port is None: + if port is None: raise SerialException( f"No JS serial port registered for {path!r}; call " f"`register_js_port(path, js_port)` or pass `js_port=` to `connect`" ) - await js_port.open( + await port.open( baudRate=self._serial.baudrate, dataBits=data_bits, flowControl=flow_control, @@ -231,7 +230,7 @@ async def _connect( # type: ignore[override] stopBits=_STOPBITS_MAP[self._serial.stopbits], ) - self._js_port = js_port + self._js_port = port assert self._js_port is not None if self._serial.rtsdtr_on_open is not PinState.UNDEFINED: diff --git a/serialx/platforms/serial_rfc2217/__init__.py b/serialx/platforms/serial_rfc2217/__init__.py index 8a7d06b..c05f04d 100644 --- a/serialx/platforms/serial_rfc2217/__init__.py +++ b/serialx/platforms/serial_rfc2217/__init__.py @@ -622,19 +622,20 @@ def _readinto(self, b: Buffer, *, timeout: float | None) -> int: # Individual RFC2217 socket reads may not always translate into real serial data # we need to loop until it actually produces some, or we hit the timeout limit + remaining_timeout = timeout while True: - if timeout is not None and timeout <= 0: + if remaining_timeout is not None and remaining_timeout <= 0: return 0 with measure_time() as get_elapsed: try: - with self._socket_timeout(timeout): + with self._socket_timeout(remaining_timeout): n = self._socket.recv_into(buf) except TimeoutError: return 0 - if timeout is not None: - timeout -= get_elapsed() + if remaining_timeout is not None: + remaining_timeout -= get_elapsed() if n == 0: self._mark_broken( @@ -969,9 +970,11 @@ def _tcp_connection_lost(self, exc: Exception | None) -> None: self._tcp_transport = None if not self._user_initiated_close: - if exc is None: - exc = OSError(errno.EIO, "RFC 2217 connection closed by server") - self._mark_broken(exc) + self._mark_broken( + exc + if exc is not None + else OSError(errno.EIO, "RFC 2217 connection closed by server") + ) # Pending in-protocol waiters can't resolve cleanly mid-handshake, so # always fail them with *some* exception even on a user-initiated close. diff --git a/serialx/platforms/serial_socket.py b/serialx/platforms/serial_socket.py index 9a8fa37..9283c0d 100644 --- a/serialx/platforms/serial_socket.py +++ b/serialx/platforms/serial_socket.py @@ -285,9 +285,9 @@ def _connection_lost(self, exc: Exception | None) -> None: self._closing = True self._tcp_transport = None if not self._user_initiated_close: - if exc is None: - exc = OSError(errno.EIO, "socket closed by peer") - self._mark_broken(exc) + self._mark_broken( + exc if exc is not None else OSError(errno.EIO, "socket closed by peer") + ) self._call_protocol_connection_lost(exc) def _tcp_connection_lost(self) -> None: diff --git a/serialx/platforms/serial_win32.py b/serialx/platforms/serial_win32.py index 0aaf567..8be1306 100644 --- a/serialx/platforms/serial_win32.py +++ b/serialx/platforms/serial_win32.py @@ -110,13 +110,13 @@ def _normalize_windows_port_path(path: os.PathLike[str] | str) -> str: """Normalize a Windows serial device path for CreateFile.""" - path = str(path) + normalized = str(path) # COM ports >= 10 require the \\.\ prefix for CreateFile - if not path.startswith("\\\\.\\"): - path = "\\\\.\\" + path + if not normalized.startswith("\\\\.\\"): + normalized = "\\\\.\\" + normalized - return path + return normalized def _safe_close_handle(handle: int) -> None: diff --git a/tests/common.py b/tests/common.py index 95ededf..af088b0 100644 --- a/tests/common.py +++ b/tests/common.py @@ -75,7 +75,6 @@ class SerialQuirk(str, enum.Enum): NO_BUFFER_CONTROL = "no-buffer-control" NO_PAUSE_WRITING_CALLBACKS = "no-pause-writing-callbacks" NO_EXCLUSIVITY = "no-exclusivity" - NO_UNPLUG = "no-unplug" SERIAL_PAIR_DEFAULT_QUIRKS: dict[SerialBackend, frozenset[SerialQuirk]] = { @@ -116,7 +115,6 @@ class SerialQuirk(str, enum.Enum): SerialQuirk.NO_DTR_DSR, SerialQuirk.NO_RTS_CTS, SerialQuirk.NO_EXCLUSIVITY, - SerialQuirk.NO_UNPLUG, } ), SerialBackend.RFC2217: frozenset( @@ -131,11 +129,7 @@ class SerialQuirk(str, enum.Enum): ), SerialBackend.SER2NET: frozenset({}), SerialBackend.HUB4COM: frozenset({}), - SerialBackend.ADAPTER: frozenset( - { - SerialQuirk.NO_UNPLUG, - } - ), + SerialBackend.ADAPTER: frozenset(), SerialBackend.PYODIDE: frozenset( { # Web Serial reports *input* signals only; output signals (RTS/DTR/BRK) @@ -149,7 +143,6 @@ class SerialQuirk(str, enum.Enum): SerialQuirk.NO_BUFFER_CONTROL, SerialQuirk.NO_WRITE_LIMITS, SerialQuirk.NO_EXCLUSIVITY, - SerialQuirk.NO_UNPLUG, } ), } @@ -203,8 +196,9 @@ class SerialPair(UnresolvedSerialPair): uri_scheme: str - unplug_left: Callable[[], None] | None = None - unplug_right: Callable[[], None] | None = None + # Drop the left connection; None when the backend can't produce that flavor. + unplug_left_graceful: Callable[[], None] | None = None + unplug_left_abrupt: Callable[[], None] | None = None def _snapshot_fds() -> set[int]: @@ -387,7 +381,7 @@ def create_esphome_pair( @contextlib.contextmanager def create_socat_pair() -> Iterator[ - tuple[str, str, Callable[[], None], Callable[[], None]] + tuple[str, str, Callable[[], None] | None, Callable[[], None] | None] ]: """Create a bridged pair of virtual PTYs using two socat processes. @@ -443,11 +437,13 @@ def _kill(proc: subprocess.Popen[Any]) -> None: proc.wait() try: + # Killing socat tears down the PTY; the client read hangs up with + # EIO, an inherently abrupt disconnect. There is no clean-FIN form. yield ( left_tty, right_tty, + None, lambda: _kill(left_proc), - lambda: _kill(right_proc), ) finally: for proc in (left_proc, right_proc): @@ -513,7 +509,7 @@ async def async_create_socat_pair() -> AsyncIterator[tuple[str, str]]: @contextlib.contextmanager def create_ser2net_pair( left_adapter: str, right_adapter: str -) -> Iterator[tuple[str, str, Callable[[], None], Callable[[], None]]]: +) -> Iterator[tuple[str, str, Callable[[], None] | None, Callable[[], None] | None]]: """Create a pair of independent RFC2217 sockets using ser2net.""" # fmt: off @@ -557,12 +553,13 @@ def _kill() -> None: left, right = _get_listening_ports(proc.pid) - # ser2net serves both adapters from one process + # ser2net serves both adapters from one process. Killing it closes the + # client socket with a graceful FIN; there is no abrupt-reset form. yield ( f"rfc2217://127.0.0.1:{left}", f"rfc2217://127.0.0.1:{right}", _kill, - _kill, + None, ) finally: if proc.returncode is None: diff --git a/tests/conftest.py b/tests/conftest.py index 154e6a1..b5afa3e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -254,22 +254,22 @@ def serial_pair(request: pytest.FixtureRequest) -> Generator[SerialPair]: stack = contextlib.ExitStack() left = spec.left right = spec.right - unplug_left: Callable[[], None] | None = None - unplug_right: Callable[[], None] | None = None + unplug_left_graceful: Callable[[], None] | None = None + unplug_left_abrupt: Callable[[], None] | None = None for backend in spec.backends[::-1]: match backend: # Synthetic backends don't have an underlying serial port case SerialBackend.SOCAT: assert left is None and right is None - left, right, unplug_left, unplug_right = stack.enter_context( - create_socat_pair() + left, right, unplug_left_graceful, unplug_left_abrupt = ( + stack.enter_context(create_socat_pair()) ) case SerialBackend.SOCKET: assert left is None and right is None - left, right, unplug_left, unplug_right = stack.enter_context( - create_socket_pair() + left, right, unplug_left_graceful, unplug_left_abrupt = ( + stack.enter_context(create_socket_pair()) ) case SerialBackend.PYODIDE: @@ -280,11 +280,15 @@ def serial_pair(request: pytest.FixtureRequest) -> Generator[SerialPair]: case SerialBackend.ESPHOME_HOST: assert left is not None and right is not None left, right = stack.enter_context(create_esphome_pair(left, right)) + # The esphome daemon sits between the client and the inner PTY, + # so an inner unplug doesn't surface as a client disconnect. + unplug_left_graceful = None + unplug_left_abrupt = None case SerialBackend.SER2NET: assert left is not None and right is not None - left, right, unplug_left, unplug_right = stack.enter_context( - create_ser2net_pair(left, right) + left, right, unplug_left_graceful, unplug_left_abrupt = ( + stack.enter_context(create_ser2net_pair(left, right)) ) case SerialBackend.HUB4COM: @@ -333,8 +337,8 @@ def serial_pair(request: pytest.FixtureRequest) -> Generator[SerialPair]: backends=spec.backends, quirks=spec.quirks, uri_scheme=effective_scheme, - unplug_left=unplug_left, - unplug_right=unplug_right, + unplug_left_graceful=unplug_left_graceful, + unplug_left_abrupt=unplug_left_abrupt, ) finally: stack.close() diff --git a/tests/socket_relay.py b/tests/socket_relay.py index 78eb7c1..2241099 100644 --- a/tests/socket_relay.py +++ b/tests/socket_relay.py @@ -7,6 +7,7 @@ import logging import queue import socket +import struct import threading import time @@ -38,6 +39,22 @@ def _close_socket(sock: socket.socket | None) -> None: with contextlib.suppress(OSError): sock.close() + @staticmethod + def _reset_socket(sock: socket.socket | None) -> None: + """Abruptly drop a connection with a RST, simulating a yanked link.""" + if sock is None: + return + + with contextlib.suppress(OSError): + sock.setsockopt( + socket.SOL_SOCKET, + socket.SO_LINGER, + struct.pack("ii", 1, 0), + ) + + with contextlib.suppress(OSError): + sock.close() + @staticmethod def _make_server() -> socket.socket: server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -71,8 +88,15 @@ def _reader_loop( peer_side: str, ) -> None: try: + # Bounded recv so we don't deadlock or pin the FD + conn.settimeout(0.5) + while not self.stop_event.is_set(): - data = conn.recv(4096) + try: + data = conn.recv(4096) + except TimeoutError: + continue # Ignore timeouts + if not data: LOGGER.debug("%s client reached EOF", side) return @@ -188,11 +212,24 @@ def start(self) -> None: for relay_thread in self.relay_threads: relay_thread.start() - def disconnect_side(self, side: str) -> None: - with self.active_lock: - conn = self.active_connections[side] - self.active_connections[side] = None - if conn is not None: + def disconnect_side(self, side: str, *, abrupt: bool) -> None: + # The client's connect() returns once the kernel completes the + # handshake, which can be before the accept loop has handed us the + # socket. Wait for it rather than no-op: it is guaranteed to arrive. + deadline = time.monotonic() + 5.0 + conn = self._get_active_connection(side) + while conn is None and time.monotonic() < deadline: + time.sleep(0.005) + conn = self._get_active_connection(side) + + if conn is None: + return + + self._clear_active_connection(side, conn) + + if abrupt: + self._reset_socket(conn) + else: self._close_socket(conn) def close(self) -> None: @@ -283,15 +320,19 @@ def accept_loop() -> None: def create_socket_pair() -> Iterator[ tuple[str, str, Callable[[], None], Callable[[], None]] ]: - """Create two socket:// endpoints backed by a bidirectional relay.""" + """Create two socket:// endpoints backed by a bidirectional relay. + + The relay can drop the left connection either gracefully (FIN) or abruptly + (RST), so both unplug flavors are available. + """ relay = _SocketPairRelay() relay.start() try: yield ( relay.left_url, relay.right_url, - lambda: relay.disconnect_side("left"), - lambda: relay.disconnect_side("right"), + lambda: relay.disconnect_side("left", abrupt=False), + lambda: relay.disconnect_side("left", abrupt=True), ) finally: relay.close() diff --git a/tests/test_async_transports.py b/tests/test_async_transports.py index a68a615..6e7dcf7 100644 --- a/tests/test_async_transports.py +++ b/tests/test_async_transports.py @@ -1295,10 +1295,10 @@ def connection_lost(self, exc: Exception | None) -> None: assert transport.is_closing() -@pytest.mark.skip_quirks(SerialQuirk.NO_UNPLUG) async def test_async_unplug_raises(serial_pair: SerialPair) -> None: - """Each operation on an unplugged port raises rather than silently EOFing.""" - assert serial_pair.unplug_left is not None + """Each operation on an abruptly-unplugged port raises rather than EOFing.""" + if serial_pair.unplug_left_abrupt is None: + pytest.skip("backend cannot simulate an abrupt disconnect") async with async_create_serial_pair( serial_pair.left, serial_pair.right, baudrate=115200 @@ -1307,7 +1307,7 @@ async def test_async_unplug_raises(serial_pair: SerialPair) -> None: await right.drain() assert await left.readline() == b"ping\n" - serial_pair.unplug_left() + serial_pair.unplug_left_abrupt() with pytest.raises(OSError): await left.read(1) @@ -1325,12 +1325,12 @@ async def test_async_unplug_raises(serial_pair: SerialPair) -> None: await left.set_modem_pins(rts=True) -@pytest.mark.skip_quirks(SerialQuirk.NO_UNPLUG) async def test_async_unplug_raises_on_streamreader_readline( serial_pair: SerialPair, ) -> None: - """`reader.readline()` after unplug must raise, not silently return b''.""" - assert serial_pair.unplug_left is not None + """`reader.readline()` after an abrupt unplug must raise, not return b''.""" + if serial_pair.unplug_left_abrupt is None: + pytest.skip("backend cannot simulate an abrupt disconnect") reader, writer = await open_serial_connection(serial_pair.left, baudrate=115200) try: @@ -1341,7 +1341,7 @@ async def test_async_unplug_raises_on_streamreader_readline( await right.drain() assert await reader.readline() == b"ping\n" - serial_pair.unplug_left() + serial_pair.unplug_left_abrupt() with pytest.raises(OSError): await reader.readline() @@ -1349,3 +1349,23 @@ async def test_async_unplug_raises_on_streamreader_readline( writer.close() with contextlib.suppress(OSError): await writer.wait_closed() + + +async def test_async_graceful_peer_close_does_not_raise( + serial_pair: SerialPair, +) -> None: + """A clean peer FIN reads as EOF and wait_closed() must not raise.""" + if serial_pair.unplug_left_graceful is None: + pytest.skip("backend cannot simulate a graceful peer close") + + reader, writer = await open_serial_connection(serial_pair.left, baudrate=115200) + + try: + serial_pair.unplug_left_graceful() + assert await reader.read() == b"" + + with pytest.raises(OSError): + writer.write(b"foo") + finally: + writer.close() + await writer.wait_closed() diff --git a/tests/test_sync_transports.py b/tests/test_sync_transports.py index 7a9e8ec..6cabfbe 100644 --- a/tests/test_sync_transports.py +++ b/tests/test_sync_transports.py @@ -1091,10 +1091,11 @@ def test_deassert_on_open_with_rtscts( assert left.get_modem_pins().cts is expected_state -@pytest.mark.skip_quirks(SerialQuirk.NO_UNPLUG) def test_sync_unplug_raises(serial_pair: SerialPair) -> None: """Each operation on an unplugged port raises rather than silently EOFing.""" - assert serial_pair.unplug_left is not None + unplug_left = serial_pair.unplug_left_graceful or serial_pair.unplug_left_abrupt + if unplug_left is None: + pytest.skip("backend cannot simulate a disconnect") with ( Serial.from_url(serial_pair.left, baudrate=115200, timeout=2.0) as left, @@ -1104,7 +1105,7 @@ def test_sync_unplug_raises(serial_pair: SerialPair) -> None: right.flush() assert left.readline() == b"ping\n" - serial_pair.unplug_left() + unplug_left() with pytest.raises(OSError): left.read(1) diff --git a/uv.lock b/uv.lock index 5601822..c2be4b0 100644 --- a/uv.lock +++ b/uv.lock @@ -180,6 +180,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" }, ] +[[package]] +name = "astroid" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/63/0adf26577da5eff6eb7a177876c1cfa213856be9926a000f65c4add9692b/astroid-4.0.4.tar.gz", hash = "sha256:986fed8bcf79fb82c78b18a53352a0b287a73817d6dbcfba3162da36667c49a0", size = 406358, upload-time = "2026-02-07T23:35:07.509Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/cf/1c5f42b110e57bc5502eb80dbc3b03d256926062519224835ef08134f1f9/astroid-4.0.4-py3-none-any.whl", hash = "sha256:52f39653876c7dec3e3afd4c2696920e05c83832b9737afc21928f2d2eb7a753", size = 276445, upload-time = "2026-02-07T23:35:05.344Z" }, +] + [[package]] name = "async-interrupt" version = "1.2.2" @@ -526,6 +538,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/ca/7e8365deec19afb2b2c7be7c1c0aa8f99633b54e90c570999acda93260fc/cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a", size = 3739536, upload-time = "2026-05-04T22:59:29.61Z" }, ] +[[package]] +name = "dill" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/e1/56027a71e31b02ddc53c7d65b01e68edf64dea2932122fe7746a516f75d5/dill-0.4.1.tar.gz", hash = "sha256:423092df4182177d4d8ba8290c8a5b640c66ab35ec7da59ccfa00f6fa3eea5fa", size = 187315, upload-time = "2026-01-19T02:36:56.85Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" }, +] + [[package]] name = "docutils" version = "0.21.2" @@ -636,6 +657,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isort" +version = "8.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/7c/ec4ab396d31b3b395e2e999c8f46dec78c5e29209fac49d1f4dace04041d/isort-8.0.1.tar.gz", hash = "sha256:171ac4ff559cdc060bcfff550bc8404a486fee0caab245679c2abe7cb253c78d", size = 769592, upload-time = "2026-02-28T10:08:20.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/95/c7c34aa53c16353c56d0b802fba48d5f5caa2cdee7958acbcb795c830416/isort-8.0.1-py3-none-any.whl", hash = "sha256:28b89bc70f751b559aeca209e6120393d43fbe2490de0559662be7a9787e3d75", size = 89733, upload-time = "2026-02-28T10:08:19.466Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -850,6 +880,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + [[package]] name = "mdit-py-plugins" version = "0.6.1" @@ -1013,6 +1052,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -1107,6 +1155,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pylint" +version = "4.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/b6/74d9a8a68b8067efce8d07707fe6a236324ee1e7808d2eb3646ec8517c7d/pylint-4.0.5.tar.gz", hash = "sha256:8cd6a618df75deb013bd7eb98327a95f02a6fb839205a6bbf5456ef96afb317c", size = 1572474, upload-time = "2026-02-20T09:07:33.621Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -1316,6 +1383,7 @@ dev = [ { name = "mypy" }, { name = "prek" }, { name = "psutil" }, + { name = "pylint" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-timeout" }, @@ -1356,6 +1424,7 @@ requires-dist = [ { name = "myst-parser", marker = "python_full_version < '3.11' and extra == 'docs'", specifier = ">=4.0.1,<5" }, { name = "prek", marker = "extra == 'dev'", specifier = ">=0.3.11" }, { name = "psutil", marker = "extra == 'dev'", specifier = ">=7.2.2" }, + { name = "pylint", marker = "extra == 'dev'", specifier = ">=3.3.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.1" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0" }, { name = "pytest-timeout", marker = "extra == 'dev'", specifier = ">=2.4.0" }, @@ -1699,6 +1768,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tomlkit" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, +] + [[package]] name = "types-psutil" version = "7.2.2.20260508"