Skip to content

Commit 76b1079

Browse files
authored
Avoid rebinding passed parameters (#98)
* Do not create a new error if `connection_lost(None)` is called * Mark broken even with no exception * Create a custom check to forbid parameter reassignment * Fix current parameter reassignments * Test: fix Linux tests? * Poll for an active connection in socket relay fixture
1 parent 9f7e16a commit 76b1079

16 files changed

Lines changed: 338 additions & 110 deletions

prek.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ types_or = ["python", "pyi"]
7070
exclude = '^tests/esphome/external_components/'
7171
require_serial = true
7272

73+
[[repos.hooks]]
74+
id = "pylint"
75+
name = "pylint"
76+
entry = "uv run --frozen pylint serialx/"
77+
language = "system"
78+
types = ["python"]
79+
pass_filenames = false
80+
require_serial = true
81+
7382
[[repos.hooks]]
7483
id = "cargo-fmt"
7584
name = "Cargo fmt"

pylint/plugins/pylint_serialx.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""Pylint plugin for serialx."""
2+
3+
from __future__ import annotations
4+
5+
from astroid.nodes import Arguments, AssignName, FunctionDef
6+
from pylint.checkers import BaseChecker
7+
from pylint.lint import PyLinter
8+
9+
10+
class ParamReassignChecker(BaseChecker):
11+
"""Flag rebinding a function parameter to a new value."""
12+
13+
name = "serialx-param-reassign"
14+
msgs = {
15+
# serialx reserves pylint message base 90: codes are {C,W,E,R}90xx.
16+
"W9001": (
17+
"Reassigning function parameter %r; bind a new local instead",
18+
"serialx-reassigned-parameter",
19+
"Rebinding a parameter overloads one name with two meanings and lets "
20+
"a rewritten value leak into later uses of the original.",
21+
),
22+
}
23+
24+
def visit_assignname(self, node: AssignName) -> None:
25+
"""Flag an assignment target that rebinds an enclosing function parameter."""
26+
# Skip the parameter *definition* itself (its target lives under Arguments).
27+
if isinstance(node.parent, Arguments):
28+
return
29+
30+
scope = node.scope()
31+
if not isinstance(scope, FunctionDef):
32+
return
33+
34+
args = scope.args
35+
names: set[str] = set()
36+
for group in (args.posonlyargs, args.args, args.kwonlyargs):
37+
names.update(arg.name for arg in group or [])
38+
if args.vararg:
39+
names.add(args.vararg)
40+
if args.kwarg:
41+
names.add(args.kwarg)
42+
43+
if node.name in names:
44+
self.add_message(
45+
"serialx-reassigned-parameter", node=node, args=(node.name,)
46+
)
47+
48+
49+
def register(linter: PyLinter) -> None:
50+
"""Register the serialx checkers with pylint."""
51+
linter.register_checker(ParamReassignChecker(linter))

pyproject.toml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ dev = [
3030
"uv>=0.11.14",
3131
"ruff>=0.14.6",
3232
"mypy>=2.1.0",
33+
"pylint>=3.3.0",
3334
"codespell>=2.4.2",
3435
"tomli>=2.3.0 ; python_version < '3.11'",
3536
"pytest>=9.0.1",
@@ -177,6 +178,10 @@ disable_error_code = [
177178
"untyped-decorator",
178179
]
179180

181+
[[tool.mypy.overrides]]
182+
module = ["astroid.*"]
183+
ignore_missing_imports = true
184+
180185

181186
[tool.coverage.run]
182187
source = ["serialx"]
@@ -185,6 +190,14 @@ relative_files = true
185190
[tool.coverage.paths]
186191
source = ["serialx/", "serialx\\"]
187192

193+
[tool.pylint.MAIN]
194+
init-hook = "import sys; sys.path.append('pylint/plugins')"
195+
load-plugins = ["pylint_serialx"]
196+
197+
[tool.pylint."MESSAGES CONTROL"]
198+
disable = ["all"]
199+
enable = ["serialx-reassigned-parameter"]
200+
188201
[tool.ruff]
189202
target-version = "py310"
190203

@@ -323,6 +336,7 @@ max-complexity = 25
323336

324337
[tool.codespell]
325338
skip = "tests/data/*"
339+
ignore-words-list = "astroid"
326340

327341
[tool.coverage.report]
328342
show_missing = true

serialx/async_serial.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -273,18 +273,18 @@ async def create_serial_connection(
273273
**kwargs: Any,
274274
) -> tuple[BaseSerialTransport, asyncio.Protocol]:
275275
"""Create a serial port connection with asyncio."""
276-
if transport_cls is None:
277-
if url is None:
278-
raise ValueError("One of `url` or `transport_cls` must be provided.")
279-
276+
if transport_cls is not None:
277+
resolved_cls = transport_cls
278+
elif url is None:
279+
raise ValueError("One of `url` or `transport_cls` must be provided.")
280+
else:
280281
handler = await asyncio.get_running_loop().run_in_executor(
281282
None, get_uri_handler, url
282283
)
283-
transport_cls = handler.async_transport_cls
284+
resolved_cls = handler.async_transport_cls
284285

285-
assert transport_cls is not None
286286
protocol = protocol_factory()
287-
transport = transport_cls(loop=loop, protocol=protocol)
287+
transport = resolved_cls(loop=loop, protocol=protocol)
288288

289289
await transport.connect(
290290
path=url,

serialx/common.py

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -339,21 +339,24 @@ def __init__(
339339
"""Initialize serial port configuration."""
340340
super().__init__()
341341

342-
if not isinstance(stopbits, StopBits):
343-
stopbits = StopBits(stopbits)
342+
normalized_stopbits = (
343+
stopbits if isinstance(stopbits, StopBits) else StopBits(stopbits)
344+
)
344345

345346
if parity is None:
346-
parity = Parity.NONE
347-
elif not isinstance(parity, Parity):
348-
parity = Parity(parity)
347+
normalized_parity = Parity.NONE
348+
elif isinstance(parity, Parity):
349+
normalized_parity = parity
350+
else:
351+
normalized_parity = Parity(parity)
349352

350353
self._path = path
351354
self._baudrate = baudrate
352-
self._stopbits = stopbits
355+
self._stopbits = normalized_stopbits
353356
self._xonxoff = xonxoff
354357
self._rtscts = rtscts
355358
self._dsrdtr = dsrdtr
356-
self._parity = parity
359+
self._parity = normalized_parity
357360
self._byte_size = byte_size
358361
self._exclusive = exclusive
359362
self._read_timeout = read_timeout
@@ -397,9 +400,12 @@ def _check_broken(self) -> None:
397400
def from_url(cls, url: str, *args: Any, **kwargs: Any) -> BaseSerial:
398401
"""Create the appropriate serial port subclass for the given URL."""
399402
handler = get_uri_handler(url)
403+
target = url
400404
if handler.strip_uri_scheme:
401-
url = url.removeprefix(handler.scheme).removeprefix(handler.unique_scheme)
402-
return handler.sync_cls(url, *args, **kwargs)
405+
target = url.removeprefix(handler.scheme).removeprefix(
406+
handler.unique_scheme
407+
)
408+
return handler.sync_cls(target, *args, **kwargs)
403409

404410
@maybe_wrap_exceptions
405411
def open(self) -> None:
@@ -471,8 +477,9 @@ def set_modem_pins(
471477
"""Set modem control bits."""
472478
self._check_broken()
473479

474-
if modem_pins is None:
475-
modem_pins = ModemPins(
480+
pins = modem_pins
481+
if pins is None:
482+
pins = ModemPins(
476483
le=PinState.convert(le),
477484
dtr=PinState.convert(dtr),
478485
rts=PinState.convert(rts),
@@ -484,7 +491,7 @@ def set_modem_pins(
484491
dsr=PinState.convert(dsr),
485492
)
486493

487-
return self._set_modem_pins(modem_pins)
494+
return self._set_modem_pins(pins)
488495

489496
@abstractmethod
490497
def _get_modem_pins(self) -> ModemPins:
@@ -500,8 +507,8 @@ def _set_modem_pins(self, modem_pins: ModemPins) -> None:
500507
def readinto(self, b: Buffer, *, timeout: float | None = None) -> int:
501508
"""Read bytes from serial port into buffer."""
502509
self._check_broken()
503-
timeout = self._read_timeout if timeout is None else timeout
504-
return self._readinto(b, timeout=timeout)
510+
effective_timeout = self._read_timeout if timeout is None else timeout
511+
return self._readinto(b, timeout=effective_timeout)
505512

506513
@abstractmethod
507514
def _readinto(self, b: Buffer, *, timeout: float | None) -> int:
@@ -512,8 +519,8 @@ def _readinto(self, b: Buffer, *, timeout: float | None) -> int:
512519
def write(self, data: Buffer, *, timeout: float | None = None) -> int:
513520
"""Write bytes to serial port."""
514521
self._check_broken()
515-
timeout = self._write_timeout if timeout is None else timeout
516-
return self._write(data, timeout=timeout)
522+
effective_timeout = self._write_timeout if timeout is None else timeout
523+
return self._write(data, timeout=effective_timeout)
517524

518525
@abstractmethod
519526
def _write(self, data: Buffer, *, timeout: float | None) -> int:
@@ -581,14 +588,14 @@ def readexactly(self, n: int, *, timeout: float | None = None) -> bytes:
581588
buffer = bytearray(n)
582589
view = memoryview(buffer)
583590
remaining = n
584-
timeout = self.read_timeout if timeout is None else timeout
591+
remaining_timeout = self.read_timeout if timeout is None else timeout
585592

586593
while remaining > 0:
587594
with measure_time() as get_elapsed:
588-
read = self.readinto(view, timeout=timeout)
595+
read = self.readinto(view, timeout=remaining_timeout)
589596

590-
if timeout is not None:
591-
timeout -= get_elapsed()
597+
if remaining_timeout is not None:
598+
remaining_timeout -= get_elapsed()
592599

593600
view = view[read:]
594601
remaining -= read
@@ -611,14 +618,14 @@ def read_until(
611618
"""Read until the expected sequence is found."""
612619
buffer = bytearray()
613620
expected_len = len(expected)
614-
timeout = self.read_timeout if timeout is None else timeout
621+
remaining_timeout = self.read_timeout if timeout is None else timeout
615622

616623
while True:
617624
with measure_time() as get_elapsed:
618-
byte = self.readexactly(1, timeout=timeout)
625+
byte = self.readexactly(1, timeout=remaining_timeout)
619626

620-
if timeout is not None:
621-
timeout -= get_elapsed()
627+
if remaining_timeout is not None:
628+
remaining_timeout -= get_elapsed()
622629

623630
if not byte:
624631
break
@@ -1009,15 +1016,16 @@ async def connect(
10091016
**kwargs: Unpack[ConnectKwargs],
10101017
) -> None:
10111018
"""Connect to serial port."""
1012-
if path is not None:
1013-
handler = get_uri_handler(path)
1019+
target = path
1020+
if target is not None:
1021+
handler = get_uri_handler(target)
10141022
if handler.strip_uri_scheme:
1015-
path = path.removeprefix(handler.scheme).removeprefix(
1023+
target = target.removeprefix(handler.scheme).removeprefix(
10161024
handler.unique_scheme
10171025
)
10181026

10191027
try:
1020-
await self._connect(path=path, **kwargs)
1028+
await self._connect(path=target, **kwargs)
10211029
except BaseException:
10221030
# Intentionally catch cancellation too: callers should only observe
10231031
# connect failure/cancel after transport resources are released.
@@ -1058,8 +1066,9 @@ async def set_modem_pins(
10581066
"""Set modem control bits."""
10591067
self._check_broken()
10601068

1061-
if modem_pins is None:
1062-
modem_pins = ModemPins(
1069+
pins = modem_pins
1070+
if pins is None:
1071+
pins = ModemPins(
10631072
le=PinState.convert(le),
10641073
dtr=PinState.convert(dtr),
10651074
rts=PinState.convert(rts),
@@ -1071,7 +1080,7 @@ async def set_modem_pins(
10711080
dsr=PinState.convert(dsr),
10721081
)
10731082

1074-
return await self._set_modem_pins(modem_pins)
1083+
return await self._set_modem_pins(pins)
10751084

10761085
async def flush(self) -> None:
10771086
"""Flush write buffers, waiting until all data is written."""

serialx/descriptor_transport.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ def get_write_buffer_limits(self) -> tuple[int, int]:
212212
def _set_write_buffer_limits(
213213
self, high: int | None = None, low: int | None = None
214214
) -> None:
215+
# pylint: disable=serialx-reassigned-parameter
215216
if high is None:
216217
if low is None: # noqa: SIM108
217218
high = 64 * 1024
@@ -257,9 +258,10 @@ def write(self, data: bytes | bytearray | memoryview) -> None:
257258

258259
self._check_broken()
259260

260-
if isinstance(data, bytearray):
261-
data = memoryview(data)
262-
if not data:
261+
buf: bytes | memoryview = (
262+
memoryview(data) if isinstance(data, bytearray) else data
263+
)
264+
if not buf:
263265
return
264266

265267
if self._closing or self._conn_lost_count > 0:
@@ -273,7 +275,7 @@ def write(self, data: bytes | bytearray | memoryview) -> None:
273275
if not self._buffer:
274276
# Attempt to send it right away first.
275277
try:
276-
n = os.write(self._fileno, data)
278+
n = os.write(self._fileno, buf)
277279
except (BlockingIOError, InterruptedError):
278280
n = 0
279281
except (SystemExit, KeyboardInterrupt):
@@ -288,17 +290,17 @@ def write(self, data: bytes | bytearray | memoryview) -> None:
288290
)
289291
return
290292

291-
len_data = len(data)
293+
len_data = len(buf)
292294
LOGGER.debug("Sent %d of %d bytes", n, len_data)
293295

294296
if n == len_data:
295297
return
296298
elif n > 0:
297-
data = memoryview(data)[n:]
299+
buf = memoryview(buf)[n:]
298300
self._loop.add_writer(self._fileno, self._write_ready)
299301

300-
LOGGER.debug("Buffering %r", data)
301-
self._buffer += data
302+
LOGGER.debug("Buffering %r", buf)
303+
self._buffer += buf
302304
self._maybe_pause_protocol()
303305

304306
def _write_ready(self) -> None:

serialx/platforms/serial_pyodide/__init__.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -214,24 +214,23 @@ async def _connect( # type: ignore[override]
214214
else:
215215
raise UnsupportedSetting(f"Unsupported byte_size: {byte_size!r}")
216216

217-
if js_port is None:
218-
js_port = _REGISTERED_JS_PORTS.get(path)
217+
port = js_port if js_port is not None else _REGISTERED_JS_PORTS.get(path)
219218

220-
if js_port is None:
219+
if port is None:
221220
raise SerialException(
222221
f"No JS serial port registered for {path!r}; call "
223222
f"`register_js_port(path, js_port)` or pass `js_port=` to `connect`"
224223
)
225224

226-
await js_port.open(
225+
await port.open(
227226
baudRate=self._serial.baudrate,
228227
dataBits=data_bits,
229228
flowControl=flow_control,
230229
parity=_PARITY_MAP[self._serial.parity],
231230
stopBits=_STOPBITS_MAP[self._serial.stopbits],
232231
)
233232

234-
self._js_port = js_port
233+
self._js_port = port
235234
assert self._js_port is not None
236235

237236
if self._serial.rtsdtr_on_open is not PinState.UNDEFINED:

0 commit comments

Comments
 (0)