Skip to content
This repository was archived by the owner on Jan 23, 2026. It is now read-only.

Commit bc74d62

Browse files
mangelajogithub-actions[bot]
authored andcommitted
pyserial driver: add pipe command on cli
(cherry picked from commit a659e96)
1 parent f95dc68 commit bc74d62

3 files changed

Lines changed: 451 additions & 1 deletion

File tree

packages/jumpstarter-driver-pyserial/README.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,58 @@ export:
3333
| check_present | Check if the serial port exists during exporter initialization, disable if you are connecting to a dynamically created port (i.e. USB from your DUT) | bool | no | True |
3434
| cps | Characters per second throttling limit. When set, data transmission will be throttled to simulate slow typing. Useful for devices that can't handle fast input | float | no | None |
3535
36+
## CLI Commands
37+
38+
The pyserial driver provides two CLI commands for interacting with serial ports:
39+
40+
### start_console
41+
42+
Start an interactive serial console with direct terminal access.
43+
44+
```bash
45+
j serial start-console
46+
```
47+
48+
Exit the console by pressing CTRL+B three times.
49+
50+
### pipe
51+
52+
Pipe serial port data to stdout or a file. Automatically detects if stdin is piped and enables bidirectional mode.
53+
54+
When stdin is used, commands are sent until EOF, then continues monitoring serial output until Ctrl+C.
55+
56+
```bash
57+
# Log serial output to stdout
58+
j serial pipe
59+
60+
# Log serial output to a file
61+
j serial pipe -o serial.log
62+
63+
# Send command to serial, then continue monitoring output
64+
echo "hello" | j serial pipe
65+
66+
# Send commands from file, then continue monitoring output
67+
cat commands.txt | j serial pipe -o serial.log
68+
69+
# Force bidirectional mode (interactive)
70+
j serial pipe -i
71+
72+
# Append to log file instead of overwriting
73+
j serial pipe -o serial.log -a
74+
75+
# Disable stdin input even when piped
76+
cat data.txt | j serial pipe --no-input
77+
```
78+
79+
#### Options
80+
81+
- `-o, --output FILE`: Write serial output to a file instead of stdout
82+
- `-i, --input`: Force enable stdin to serial port (auto-detected if piped)
83+
- `--no-input`: Disable stdin to serial port, even if stdin is piped
84+
- `-a, --append`: Append to output file instead of overwriting
85+
86+
Exit with Ctrl+C.
87+
3688
## API Reference
3789

3890
```{eval-rst}
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
"""
2+
CLI tests for PySerial driver.
3+
4+
Tests the Click CLI interface including the pipe command.
5+
"""
6+
7+
from unittest.mock import patch
8+
9+
import pytest
10+
from click.testing import CliRunner
11+
12+
from .driver import PySerial
13+
from jumpstarter.common.utils import serve
14+
15+
16+
@pytest.fixture
17+
def pyserial_client():
18+
"""Fixture to create a PySerial client with loop:// URL for testing."""
19+
instance = PySerial(url="loop://")
20+
with serve(instance) as client:
21+
yield client
22+
23+
24+
def test_pipe_command_append_requires_output(pyserial_client):
25+
"""Test that --append requires --output."""
26+
runner = CliRunner()
27+
cli = pyserial_client.cli()
28+
29+
# Mock the portal to prevent actual execution
30+
with patch.object(pyserial_client, "portal"):
31+
result = runner.invoke(cli, ["pipe", "--append"])
32+
assert result.exit_code != 0
33+
assert "--append requires --output" in result.output
34+
35+
36+
def test_pipe_command_input_and_no_input_conflict(pyserial_client):
37+
"""Test that --input and --no-input cannot be used together."""
38+
runner = CliRunner()
39+
cli = pyserial_client.cli()
40+
41+
# Mock the portal to prevent actual execution
42+
with patch.object(pyserial_client, "portal"):
43+
result = runner.invoke(cli, ["pipe", "--input", "--no-input"])
44+
assert result.exit_code != 0
45+
assert "Cannot use both --input and --no-input" in result.output
46+
47+
48+
def test_pipe_command_with_output_file(pyserial_client):
49+
"""Test pipe command with output file option."""
50+
runner = CliRunner()
51+
cli = pyserial_client.cli()
52+
53+
with runner.isolated_filesystem():
54+
# Mock the portal.call to prevent actual execution
55+
with patch.object(pyserial_client.portal, "call") as mock_call:
56+
mock_call.side_effect = KeyboardInterrupt # Simulate Ctrl+C to exit
57+
58+
# Use --no-input to explicitly disable input detection
59+
runner.invoke(cli, ["pipe", "-o", "test.log", "--no-input"])
60+
61+
# Should have attempted to call _pipe_serial
62+
assert mock_call.called
63+
# Check the arguments passed
64+
args = mock_call.call_args[0]
65+
assert args[1] == "test.log" # output file
66+
assert args[2] is False # input_enabled
67+
assert args[3] is False # append
68+
69+
70+
def test_pipe_command_with_append(pyserial_client):
71+
"""Test pipe command with append option."""
72+
runner = CliRunner()
73+
cli = pyserial_client.cli()
74+
75+
with runner.isolated_filesystem():
76+
with patch.object(pyserial_client.portal, "call") as mock_call:
77+
mock_call.side_effect = KeyboardInterrupt
78+
79+
runner.invoke(cli, ["pipe", "-o", "test.log", "-a"])
80+
81+
assert mock_call.called
82+
args = mock_call.call_args[0]
83+
assert args[1] == "test.log" # output file
84+
assert args[3] is True # append
85+
86+
87+
def test_pipe_command_with_input_flag(pyserial_client):
88+
"""Test pipe command with --input flag."""
89+
runner = CliRunner()
90+
cli = pyserial_client.cli()
91+
92+
with patch.object(pyserial_client.portal, "call") as mock_call:
93+
mock_call.side_effect = KeyboardInterrupt
94+
95+
runner.invoke(cli, ["pipe", "-i"])
96+
97+
assert mock_call.called
98+
args = mock_call.call_args[0]
99+
assert args[2] is True # input_enabled
100+
101+
102+
def test_pipe_command_with_no_input_flag(pyserial_client):
103+
"""Test pipe command with --no-input flag."""
104+
runner = CliRunner()
105+
cli = pyserial_client.cli()
106+
107+
with patch.object(pyserial_client.portal, "call") as mock_call:
108+
mock_call.side_effect = KeyboardInterrupt
109+
110+
runner.invoke(cli, ["pipe", "--no-input"])
111+
112+
assert mock_call.called
113+
args = mock_call.call_args[0]
114+
assert args[2] is False # input_enabled
115+
116+
117+
def test_pipe_command_stdin_auto_detection(pyserial_client):
118+
"""Test that pipe command auto-detects piped stdin with CliRunner."""
119+
runner = CliRunner()
120+
cli = pyserial_client.cli()
121+
122+
# CliRunner doesn't provide a TTY by default, so stdin.isatty() returns False
123+
# This simulates the behavior when stdin is piped
124+
with patch.object(pyserial_client.portal, "call") as mock_call:
125+
mock_call.side_effect = KeyboardInterrupt
126+
127+
runner.invoke(cli, ["pipe"])
128+
129+
assert mock_call.called
130+
args = mock_call.call_args[0]
131+
# Should auto-enable input when stdin is not a TTY (CliRunner default behavior)
132+
assert args[2] is True # input_enabled
133+
134+
135+
def test_pipe_command_no_auto_detection_with_no_input_flag(pyserial_client):
136+
"""Test that pipe command doesn't enable input with --no-input flag."""
137+
runner = CliRunner()
138+
cli = pyserial_client.cli()
139+
140+
with patch.object(pyserial_client.portal, "call") as mock_call:
141+
mock_call.side_effect = KeyboardInterrupt
142+
143+
runner.invoke(cli, ["pipe", "--no-input"])
144+
145+
assert mock_call.called
146+
args = mock_call.call_args[0]
147+
# Should NOT enable input when --no-input is specified
148+
assert args[2] is False # input_enabled
149+
150+
151+
def test_pipe_command_status_messages(pyserial_client):
152+
"""Test that pipe command prints appropriate status messages."""
153+
runner = CliRunner()
154+
cli = pyserial_client.cli()
155+
156+
with patch.object(pyserial_client.portal, "call") as mock_call:
157+
mock_call.side_effect = KeyboardInterrupt
158+
159+
# Test read-only mode (with --no-input flag)
160+
result = runner.invoke(cli, ["pipe", "--no-input"])
161+
assert "Reading from serial port" in result.output
162+
assert "Ctrl+C to exit" in result.output
163+
164+
# Test bidirectional mode (CliRunner stdin is not a TTY, so it auto-detects)
165+
result = runner.invoke(cli, ["pipe"])
166+
assert "Bidirectional mode" in result.output or "auto-detected" in result.output
167+
168+
169+
def test_pipe_command_with_file_and_input(pyserial_client):
170+
"""Test pipe command with both file output and input."""
171+
runner = CliRunner()
172+
cli = pyserial_client.cli()
173+
174+
with runner.isolated_filesystem():
175+
with patch.object(pyserial_client.portal, "call") as mock_call:
176+
mock_call.side_effect = KeyboardInterrupt
177+
178+
with patch("sys.stdin.isatty", return_value=False):
179+
runner.invoke(cli, ["pipe", "-o", "test.log"])
180+
181+
assert mock_call.called
182+
args = mock_call.call_args[0]
183+
assert args[1] == "test.log" # output file
184+
assert args[2] is True # input_enabled (auto-detected)
185+
assert args[3] is False # append
186+
187+
188+
def test_pipe_command_keyboard_interrupt_handling(pyserial_client):
189+
"""Test that pipe command handles KeyboardInterrupt gracefully."""
190+
runner = CliRunner()
191+
cli = pyserial_client.cli()
192+
193+
with patch.object(pyserial_client.portal, "call") as mock_call:
194+
mock_call.side_effect = KeyboardInterrupt
195+
196+
result = runner.invoke(cli, ["pipe"])
197+
198+
# Should exit cleanly after KeyboardInterrupt
199+
assert "Stopped" in result.output or result.exit_code == 0
200+
201+
202+
def test_pipe_command_mode_descriptions(pyserial_client):
203+
"""Test that pipe command shows correct mode descriptions."""
204+
runner = CliRunner()
205+
cli = pyserial_client.cli()
206+
207+
with patch.object(pyserial_client.portal, "call") as mock_call:
208+
mock_call.side_effect = KeyboardInterrupt
209+
210+
# Test auto-detected mode (CliRunner stdin is not a TTY)
211+
result = runner.invoke(cli, ["pipe"])
212+
assert "auto-detected" in result.output.lower()
213+
214+
# Test forced input mode
215+
result = runner.invoke(cli, ["pipe", "-i"])
216+
assert "forced input" in result.output.lower() or "bidirectional" in result.output.lower()
217+
218+
# Test read-only mode (with --no-input flag)
219+
result = runner.invoke(cli, ["pipe", "--no-input"])
220+
assert "read-only" in result.output.lower()
221+
222+
223+
def test_start_console_command_structure(pyserial_client):
224+
"""Test that start-console command has the correct structure."""
225+
cli = pyserial_client.cli()
226+
227+
# Click converts underscores to hyphens in command names
228+
cmd_name = "start-console" if "start-console" in cli.commands else "start_console"
229+
console_cmd = cli.commands[cmd_name]
230+
231+
assert console_cmd is not None
232+
assert hasattr(console_cmd, "callback")
233+
234+
235+
def test_cli_base_command(pyserial_client):
236+
"""Test that base CLI command works."""
237+
runner = CliRunner()
238+
cli = pyserial_client.cli()
239+
240+
result = runner.invoke(cli, ["--help"])
241+
assert result.exit_code == 0
242+
assert "Serial port client" in result.output or "Commands:" in result.output
243+

0 commit comments

Comments
 (0)