Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 47 additions & 8 deletions ebuild/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -3074,7 +3074,8 @@ def _resolve_test_runner(

@cli.command()
@click.option("--port", default=None,
help="Serial device (e.g. /dev/ttyUSB0). Auto-detected if omitted.")
help="Serial device (e.g. /dev/ttyUSB0 or COM3). "
"Auto-detected if omitted.")
@click.option("--baud", default=115200, type=int, help="Baud rate.")
@click.pass_obj
def monitor(log: Logger, port: Optional[str], baud: int) -> None:
Expand All @@ -3086,12 +3087,30 @@ def monitor(log: Logger, port: Optional[str], baud: int) -> None:
log.header("ebuild — Monitor")
log.info(f"Board: {_selected_board()}")

# On Windows, COM ports can only be found through pyserial, so a missing
# pyserial is reported before auto-detection there — otherwise it would
# be misreported as "No serial device found". On POSIX, detection is by
# glob and does not need pyserial, so there the missing-pyserial report
# stays after selection (the #83 contract: no device and no pyserial
# still reports the missing device).
try:
import serial # type: ignore[import-untyped]
except ImportError:
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if sys.platform.startswith("win"):
log.error(
"pyserial is not installed, so the monitor cannot open the port.\n"
" Install it with: pip install pyserial"
)
raise SystemExit(1)
serial = None # type: ignore[assignment]

if port is None:
candidates = _serial_ports()
if not candidates:
log.error(
"No serial device found.\n"
" Looked for /dev/ttyUSB*, /dev/ttyACM*, /dev/tty.usb*.\n"
" Looked for /dev/ttyUSB*, /dev/ttyACM*, /dev/tty.usb*, "
"and USB COM ports.\n"
" Connect the board, or name the device with --port."
)
raise SystemExit(1)
Expand All @@ -3106,17 +3125,15 @@ def monitor(log: Logger, port: Optional[str], baud: int) -> None:
port = candidates[0]
log.info(f"Auto-detected {port}")

log.step(f"Opening {port} at {baud} baud... (Ctrl-C to exit)")

try:
import serial # type: ignore[import-untyped]
except ImportError:
if serial is None:
log.error(
"pyserial is not installed, so the monitor cannot open the port.\n"
" Install it with: pip install pyserial"
)
raise SystemExit(1)

log.step(f"Opening {port} at {baud} baud... (Ctrl-C to exit)")

try:
with serial.Serial(port, baud, timeout=0.2) as conn:
while True:
Expand All @@ -3135,8 +3152,30 @@ def monitor(log: Logger, port: Optional[str], baud: int) -> None:
def _serial_ports() -> List[str]:
"""Serial devices that look like an attached development board."""
found: List[str] = []
for pattern in ("/dev/ttyUSB*", "/dev/ttyACM*", "/dev/tty.usb*"):

for pattern in (
"/dev/ttyUSB*",
"/dev/ttyACM*",
"/dev/tty.usb*",
):
found.extend(sorted(glob.glob(pattern)))

# Windows names boards COMn, and only pyserial can enumerate them. Keep
# USB devices only (those with a vendor ID): built-in COM1 and Bluetooth
# ports would otherwise make auto-detection ambiguous, which is the same
# reason /dev/ttyS* is not searched above.
try:
from serial.tools import list_ports
except ImportError:
return found

com_ports = [
port.device
for port in list_ports.comports()
if port.device.upper().startswith("COM") and port.vid is not None
]
# Sort by length first so COM2 precedes COM10.
found.extend(sorted(com_ports, key=lambda d: (len(d), d)))
return found


Expand Down
158 changes: 158 additions & 0 deletions tests/unit/cli/test_serial_ports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 EoS Project

import sys
import types

from click.testing import CliRunner
import pytest

import ebuild.cli.commands as commands
from ebuild.cli.commands import _serial_ports, monitor


class RecordingLogger:
def __init__(self):
self.errors = []
self.infos = []

def header(self, msg):
pass

def step(self, msg):
pass

def info(self, msg):
self.infos.append(msg)

def success(self, msg):
pass

def error(self, msg):
self.errors.append(msg)

def warning(self, msg):
pass

def debug(self, msg):
Comment on lines +15 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Low · New public functions lack type hints

When the new test module is added, the added public test functions, fixture, and class methods omit parameter and return annotations, so the new Python code does not meet the repository’s type-hint requirement.

Add parameter and return type annotations to the new public functions and methods.

Also at tests/unit/cli/test_serial_ports.py:67, tests/unit/cli/test_serial_ports.py:71, tests/unit/cli/test_serial_ports.py:80, tests/unit/cli/test_serial_ports.py:89, tests/unit/cli/test_serial_ports.py:99, tests/unit/cli/test_serial_ports.py:108, tests/unit/cli/test_serial_ports.py:118, tests/unit/cli/test_serial_ports.py:122-132, tests/unit/cli/test_serial_ports.py:147.

Prompt for AI agents
In `tests/unit/cli/test_serial_ports.py` at lines 15-37:

**New public functions lack type hints**

When the new test module is added, the added public test functions, fixture, and class methods omit parameter and return annotations, so the new Python code does not meet the repository’s type-hint requirement.

Add parameter and return type annotations to the new public functions and methods.

Also at `tests/unit/cli/test_serial_ports.py:67`, `tests/unit/cli/test_serial_ports.py:71`, `tests/unit/cli/test_serial_ports.py:80`, `tests/unit/cli/test_serial_ports.py:89`, `tests/unit/cli/test_serial_ports.py:99`, `tests/unit/cli/test_serial_ports.py:108`, `tests/unit/cli/test_serial_ports.py:118`, `tests/unit/cli/test_serial_ports.py:122-132`, `tests/unit/cli/test_serial_ports.py:147`.

From CONTRIBUTING.md L41, CONTRIBUTING.md L43:

### Python (PEP 8)

pass


def _port(device, vid):
return types.SimpleNamespace(device=device, vid=vid)


def _install_fake_pyserial(monkeypatch, comports, serial_cls=None):
"""Put a fake `serial` package in sys.modules; pyserial is optional."""
list_ports = types.ModuleType("serial.tools.list_ports")
list_ports.comports = lambda: comports
tools = types.ModuleType("serial.tools")
tools.list_ports = list_ports
serial = types.ModuleType("serial")
serial.tools = tools
if serial_cls is not None:
serial.Serial = serial_cls
monkeypatch.setitem(sys.modules, "serial", serial)
monkeypatch.setitem(sys.modules, "serial.tools", tools)
monkeypatch.setitem(sys.modules, "serial.tools.list_ports", list_ports)


def _remove_pyserial(monkeypatch):
# A None entry in sys.modules makes the import raise ImportError.
for name in ("serial", "serial.tools", "serial.tools.list_ports"):
monkeypatch.setitem(sys.modules, name, None)


@pytest.fixture
def no_posix_ports(monkeypatch):
monkeypatch.setattr(commands.glob, "glob", lambda pattern: [])


def test_com_ports_keep_only_usb_devices(monkeypatch, no_posix_ports):
_install_fake_pyserial(monkeypatch, [
_port("COM1", None), # built-in port
_port("COM4", None), # Bluetooth serial port
_port("COM3", 0x10C4),
])
assert _serial_ports() == ["COM3"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ Low · Tests depend on a private helper

When the serial-port unit tests run, the new tests call _serial_ports() directly to verify filtering and ordering instead of exercising the public monitor command, so those behaviors are coupled to a private implementation detail.

Verify these behaviors through the public monitor command rather than calling _serial_ports() directly.

Also at tests/unit/cli/test_serial_ports.py:86, tests/unit/cli/test_serial_ports.py:96, tests/unit/cli/test_serial_ports.py:105.

Prompt for AI agents
In `tests/unit/cli/test_serial_ports.py` at line 77:

**Tests depend on a private helper**

When the serial-port unit tests run, the new tests call `_serial_ports()` directly to verify filtering and ordering instead of exercising the public monitor command, so those behaviors are coupled to a private implementation detail.

Verify these behaviors through the public `monitor` command rather than calling `_serial_ports()` directly.

Also at `tests/unit/cli/test_serial_ports.py:86`, `tests/unit/cli/test_serial_ports.py:96`, `tests/unit/cli/test_serial_ports.py:105`.

From TESTING.md L45-47:

Test behaviour through the public interface, not private internals.



def test_com_ports_sort_numerically(monkeypatch, no_posix_ports):
_install_fake_pyserial(monkeypatch, [
_port("COM10", 0x0403),
_port("COM2", 0x0403),
_port("COM3", 0x0403),
])
assert _serial_ports() == ["COM2", "COM3", "COM10"]


def test_non_com_devices_from_pyserial_are_ignored(monkeypatch):
# On Linux pyserial reports /dev/ttyUSB0 too; the glob already found it.
monkeypatch.setattr(
commands.glob, "glob",
lambda pattern: ["/dev/ttyUSB0"] if pattern == "/dev/ttyUSB*" else [],
)
_install_fake_pyserial(monkeypatch, [_port("/dev/ttyUSB0", 0x10C4)])
assert _serial_ports() == ["/dev/ttyUSB0"]


def test_posix_ports_found_without_pyserial(monkeypatch):
monkeypatch.setattr(
commands.glob, "glob",
lambda pattern: ["/dev/ttyACM0"] if pattern == "/dev/ttyACM*" else [],
)
_remove_pyserial(monkeypatch)
assert _serial_ports() == ["/dev/ttyACM0"]


def test_monitor_reports_missing_pyserial_before_detection(
monkeypatch, no_posix_ports):
# The before-detection check is Windows-only: on POSIX, glob detection
# does not need pyserial (see the #83 contract tests in
# tests/unit/test_monitor_command.py).
monkeypatch.setattr(sys, "platform", "win32")
_remove_pyserial(monkeypatch)
log = RecordingLogger()
result = CliRunner().invoke(monitor, [], obj=log)
assert result.exit_code == 1
assert len(log.errors) == 1
assert "pyserial is not installed" in log.errors[0]


def test_monitor_auto_detects_single_usb_com_port(
monkeypatch, no_posix_ports):
opened = []

class FakeSerial:
def __init__(self, port, baud, timeout):
opened.append(port)

def __enter__(self):
return self

def __exit__(self, *exc):
return False

def read(self, size):
raise KeyboardInterrupt

_install_fake_pyserial(
monkeypatch,
[_port("COM1", None), _port("COM5", 0x2341)],
serial_cls=FakeSerial,
)
log = RecordingLogger()
result = CliRunner().invoke(monitor, [], obj=log)
assert result.exit_code == 0, log.errors
assert opened == ["COM5"]
assert "Auto-detected COM5" in log.infos


def test_monitor_refuses_to_guess_between_usb_com_ports(
monkeypatch, no_posix_ports):
_install_fake_pyserial(
monkeypatch, [_port("COM3", 0x10C4), _port("COM7", 0x0403)])
log = RecordingLogger()
result = CliRunner().invoke(monitor, [], obj=log)
assert result.exit_code == 1
assert "More than one serial device" in log.errors[0]
13 changes: 13 additions & 0 deletions tests/unit/test_monitor_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"""

import glob
import importlib.util
import sys

import pytest
Expand Down Expand Up @@ -39,12 +40,24 @@ def test_monitor_is_a_command(self):


class TestMonitorWithoutHardware:
@pytest.mark.skipif(
sys.platform.startswith("win") and importlib.util.find_spec("serial") is None,
reason="on Windows without pyserial, COM ports cannot be enumerated at all, "
"so 'no device present' cannot be established \u2014 the monitor correctly "
"reports the missing pyserial first (covered by test_serial_ports.py)",
)
Comment on lines +43 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. A windows test can open a real board 🐞 Bug ☼ Reliability

The no_serial_devices fixture patches only glob.glob, while the new Windows skip condition
checks only whether pyserial is installed. On Windows with pyserial and an attached USB COM device,
_serial_ports() finds that device, causing the test to open the real port and enter the monitor
loop instead of reporting no device.
Agent Prompt
## Issue description
The Windows skip condition allows `test_no_device_fails_cleanly` to run whenever pyserial is installed, but its fixture patches only POSIX glob enumeration. A connected USB COM device is then discovered through `list_ports.comports()`, potentially opening a real board and hanging the test.

## Fix Focus Areas
- tests/unit/test_monitor_command.py[23-26]
- tests/unit/test_monitor_command.py[43-48]
- ebuild/cli/commands.py[3167-3178]

## Recommended Fix
Extend the test fixture or add a Windows-specific fixture that mocks `serial.tools.list_ports.comports()` to return no ports, while preserving the existing POSIX glob mock. Ensure the no-device test controls both enumeration mechanisms rather than skipping only when pyserial is unavailable.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗

def test_no_device_fails_cleanly(self, no_serial_devices):
result = invoke()
assert result.exit_code == 1
assert "No serial device found" in result.output
assert "--port" in result.output

@pytest.mark.skipif(
sys.platform.startswith("win") and importlib.util.find_spec("serial") is None,
reason="on Windows without pyserial, COM ports cannot be enumerated at all, "
"so 'no device present' cannot be established \u2014 the monitor correctly "
"reports the missing pyserial first (covered by test_serial_ports.py)",
)
def test_multiple_devices_refuses_to_guess(self, monkeypatch):
monkeypatch.setattr(
glob, "glob", lambda pattern: ["/dev/ttyUSB0", "/dev/ttyUSB1"]
Expand Down
Loading