pytest-embedded-serial 2.3.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Espressif Systems (Shanghai) Co. Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,37 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-embedded-serial
3
+ Version: 2.3.0
4
+ Summary: Make pytest-embedded plugin work with Serial.
5
+ Author-email: Fu Hanxi <fuhanxi@espressif.com>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Framework :: Pytest
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python
20
+ Classifier: Topic :: Software Development :: Testing
21
+ License-File: LICENSE
22
+ Requires-Dist: pytest-embedded~=2.3.0
23
+ Requires-Dist: pyserial~=3.0
24
+ Project-URL: changelog, https://github.com/espressif/pytest-embedded/blob/main/CHANGELOG.md
25
+ Project-URL: documentation, https://docs.espressif.com/projects/pytest-embedded/en/latest/
26
+ Project-URL: homepage, https://github.com/espressif/pytest-embedded
27
+ Project-URL: repository, https://github.com/espressif/pytest-embedded
28
+
29
+ ### pytest-embedded-serial
30
+
31
+ pytest embedded service for testing via serial ports
32
+
33
+ Extra Functionalities:
34
+
35
+ - `serial`: enable the fixture
36
+ - `dut`: duplicate the `serial` output to `pexpect_proc`.
37
+
@@ -0,0 +1,8 @@
1
+ ### pytest-embedded-serial
2
+
3
+ pytest embedded service for testing via serial ports
4
+
5
+ Extra Functionalities:
6
+
7
+ - `serial`: enable the fixture
8
+ - `dut`: duplicate the `serial` output to `pexpect_proc`.
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["flit_core >=3.2,<4"]
3
+ build-backend = "flit_core.buildapi"
4
+
5
+ [project]
6
+ name = "pytest-embedded-serial"
7
+ authors = [
8
+ {name = "Fu Hanxi", email = "fuhanxi@espressif.com"},
9
+ ]
10
+ readme = "README.md"
11
+ license = {file = "LICENSE"}
12
+ classifiers = [
13
+ "Development Status :: 5 - Production/Stable",
14
+ "Framework :: Pytest",
15
+ "Intended Audience :: Developers",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3 :: Only",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python",
25
+ "Topic :: Software Development :: Testing",
26
+ ]
27
+ dynamic = ["version", "description"]
28
+ requires-python = ">=3.10"
29
+
30
+ dependencies = [
31
+ "pytest-embedded~=2.3.0",
32
+ "pyserial~=3.0",
33
+ ]
34
+
35
+ [project.urls]
36
+ homepage = "https://github.com/espressif/pytest-embedded"
37
+ repository = "https://github.com/espressif/pytest-embedded"
38
+ documentation = "https://docs.espressif.com/projects/pytest-embedded/en/latest/"
39
+ changelog = "https://github.com/espressif/pytest-embedded/blob/main/CHANGELOG.md"
@@ -0,0 +1,11 @@
1
+ """Make pytest-embedded plugin work with Serial."""
2
+
3
+ from .dut import SerialDut
4
+ from .serial import Serial
5
+
6
+ __all__ = [
7
+ 'Serial',
8
+ 'SerialDut',
9
+ ]
10
+
11
+ __version__ = '2.3.0'
@@ -0,0 +1,46 @@
1
+ from typing import TYPE_CHECKING, AnyStr, Optional
2
+
3
+ from pytest_embedded.dut import Dut
4
+ from pytest_embedded.utils import to_bytes
5
+
6
+ from .serial import Serial
7
+
8
+ if TYPE_CHECKING:
9
+ from pytest_embedded_jtag import Gdb, OpenOcd, Telnet
10
+
11
+
12
+ class SerialDut(Dut):
13
+ """
14
+ Dut class for serial ports
15
+
16
+ Attributes:
17
+ serial (Serial): `Serial` instance
18
+ openocd (OpenOcd): `OpenOcd` instance, applied only when `jtag` service is activated
19
+ gdb (Gdb): `Gdb` instance, applied only when `jtag` service is activated
20
+ telnet (Telnet): `Telnet` instance, applied only when `jtag` service is activated
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ serial: Serial,
26
+ openocd: Optional['OpenOcd'] = None,
27
+ gdb: Optional['Gdb'] = None,
28
+ telnet: Optional['Telnet'] = None,
29
+ **kwargs,
30
+ ) -> None:
31
+ super().__init__(**kwargs)
32
+
33
+ self.serial = serial
34
+ self.openocd = openocd
35
+ self.gdb = gdb
36
+ self.telnet = telnet
37
+
38
+ self.setup_jtag()
39
+
40
+ def write(self, data: AnyStr) -> None:
41
+ self.serial.proc.write(to_bytes(data, '\n'))
42
+
43
+ def setup_jtag(self):
44
+ if self.gdb:
45
+ self.gdb.write('set remotetimeout 10')
46
+ self.gdb.write(f'target extended-remote :{self.openocd.gdb_port}')
@@ -0,0 +1,267 @@
1
+ import contextlib
2
+ import copy
3
+ import logging
4
+ import multiprocessing
5
+ import queue
6
+ import threading
7
+ import time
8
+ from typing import Any, ClassVar
9
+
10
+ import serial as pyserial
11
+ from pytest_embedded.log import MessageQueue
12
+ from pytest_embedded.utils import Meta
13
+ from serial.tools import list_ports
14
+
15
+
16
+ class Serial:
17
+ """
18
+ Custom serial class
19
+
20
+ Attributes:
21
+ port (str): port address
22
+ baud (int): baud rate
23
+ proc (pyserial.Serial): process created by `serial.serial_for_url()`
24
+
25
+ Warning:
26
+ - make sure this `Serial.__init__()` run the last in MRO, it would create and start the redirect serial process
27
+ """
28
+
29
+ DEFAULT_BAUDRATE = 115200
30
+
31
+ DEFAULT_PORT_CONFIG: ClassVar[dict[str, Any]] = {
32
+ 'baudrate': DEFAULT_BAUDRATE,
33
+ 'bytesize': pyserial.EIGHTBITS,
34
+ 'parity': pyserial.PARITY_NONE,
35
+ 'stopbits': pyserial.STOPBITS_ONE,
36
+ 'timeout': 0.05, # read timeout
37
+ 'xonxoff': False,
38
+ 'rtscts': False,
39
+ }
40
+
41
+ occupied_ports: ClassVar[dict[str, None]] = dict()
42
+
43
+ def __init__(
44
+ self,
45
+ msg_queue: MessageQueue,
46
+ port: str | None = None,
47
+ port_location: str | None = None,
48
+ baud: int = DEFAULT_BAUDRATE,
49
+ meta: Meta | None = None,
50
+ stop_after_init: bool = False,
51
+ ports_to_occupy: list[str] = (),
52
+ **kwargs,
53
+ ):
54
+ self._q = msg_queue
55
+ self._meta = meta
56
+ self._redirect_thread: _SerialRedirectThread = None # type: ignore
57
+
58
+ self.baud = baud
59
+ self.ports_to_occupy = ports_to_occupy if ports_to_occupy else []
60
+
61
+ if isinstance(port, pyserial.SerialBase):
62
+ self.proc = port
63
+ self.proc.timeout = self.DEFAULT_PORT_CONFIG['timeout'] # set read timeout
64
+ self.port = self.proc.port
65
+ else:
66
+ # Need to detect or create instance
67
+ if port_location:
68
+ for _port in list_ports.comports():
69
+ if _port.device in self.occupied_ports:
70
+ continue
71
+ if _port.location == port_location:
72
+ if port and _port.device != port:
73
+ raise ValueError(
74
+ f'The specified location {port_location} binds with port {_port.device}, not {port}'
75
+ )
76
+
77
+ self.port = _port.device
78
+ break
79
+ else:
80
+ raise ValueError(f'The specified location {port_location} cannot be found.')
81
+ elif port:
82
+ self.port = port
83
+ else:
84
+ raise ValueError('Please specify port or provide the port location')
85
+
86
+ self.port = port
87
+ port_config = copy.deepcopy(self.DEFAULT_PORT_CONFIG)
88
+ port_config['baudrate'] = baud
89
+ port_config.update(**kwargs)
90
+ self.proc = pyserial.serial_for_url(self.port, **port_config)
91
+
92
+ self.ports_to_occupy.append(self.port)
93
+ self._post_init()
94
+ try:
95
+ self._start()
96
+ except Exception as e:
97
+ self.close()
98
+ raise e
99
+ self._finalize_init()
100
+ if not stop_after_init:
101
+ self.start_redirect_thread()
102
+ else:
103
+ self.close()
104
+
105
+ def start_redirect_thread(self) -> None:
106
+ if self._redirect_thread and self._redirect_thread.is_alive():
107
+ return
108
+
109
+ # Here the reason why we're still using thread is,
110
+ # the `pyserial` object can't be pickled when using multiprocessing.Process
111
+ self._redirect_thread = _SerialRedirectThread(self._q, self.proc)
112
+ self._redirect_thread.start()
113
+
114
+ def stop_redirect_thread(self) -> bool:
115
+ killed = False
116
+ if self._redirect_thread and self._redirect_thread.is_alive():
117
+ self._redirect_thread.terminate()
118
+ killed = True
119
+
120
+ return killed
121
+
122
+ def _post_init(self):
123
+ pass
124
+
125
+ def _start(self):
126
+ pass
127
+
128
+ def _finalize_init(self):
129
+ occupied_ports = []
130
+ for port in self.ports_to_occupy:
131
+ if port not in self.occupied_ports:
132
+ self.occupied_ports[port] = None
133
+ occupied_ports.append(port)
134
+ logging.debug(f'occupied {port}')
135
+ else:
136
+ logging.warning(f'port {port} is already occupied')
137
+ self.ports_to_occupy = occupied_ports
138
+
139
+ def close(self):
140
+ self.stop_redirect_thread()
141
+ self.proc.close()
142
+ for port in self.ports_to_occupy:
143
+ self.occupied_ports.pop(port, None)
144
+ logging.debug(f'released {port}')
145
+
146
+ @contextlib.contextmanager
147
+ def disable_redirect_thread(self) -> bool:
148
+ """
149
+ kill the redirect thread, and start a new one after got yield back
150
+
151
+ Yields:
152
+ True if redirect serial thread has been terminated
153
+ """
154
+ killed = self.stop_redirect_thread()
155
+
156
+ yield killed
157
+
158
+ if killed:
159
+ self.start_redirect_thread()
160
+
161
+
162
+ class _SerialRedirectThread(threading.Thread):
163
+ """
164
+ Redirect serial thread
165
+ """
166
+
167
+ def __init__(self, msg_queue: MessageQueue, s: pyserial.Serial):
168
+ self._q = msg_queue
169
+ self._event_q = multiprocessing.Queue()
170
+ self._s = s
171
+
172
+ self._block_reading = False
173
+
174
+ super().__init__(target=self._event_loop, daemon=True) # killed by the main process
175
+
176
+ def _event_loop(self):
177
+ """
178
+ Since pyserial.Serial instance can't be serialized, we pass the `_serial` as an reference of the object
179
+ defined in _forward_io. The pyserial.Serial methods are mocked to send an event to the queue, and the real
180
+ method is running here.
181
+ """
182
+ while True:
183
+ try:
184
+ _e = self._event_q.get_nowait()
185
+ except queue.Empty:
186
+ _e = 'read'
187
+ except OSError:
188
+ return
189
+
190
+ if _e == 'read':
191
+ if self._block_reading:
192
+ continue
193
+
194
+ try:
195
+ s = self._s.read_all()
196
+ except OSError as e:
197
+ logging.error(f'OSError detected: {e}. Serial connection may be lost.')
198
+ if self._s.closed:
199
+ logging.error('Serial port is already closed. Exiting event loop.')
200
+ return
201
+
202
+ port = self._s.port
203
+ port_config = {
204
+ 'baudrate': self._s.baudrate,
205
+ 'bytesize': self._s.bytesize,
206
+ 'parity': self._s.parity,
207
+ 'stopbits': self._s.stopbits,
208
+ 'timeout': self._s.timeout,
209
+ 'xonxoff': self._s.xonxoff,
210
+ 'rtscts': self._s.rtscts,
211
+ }
212
+ for attempt in range(1, 4):
213
+ delay = attempt * 1.5
214
+ logging.warning(
215
+ f'Attempting to reconnect to serial port {port} (try {attempt}/3) after {delay}s...'
216
+ )
217
+ time.sleep(delay)
218
+ try:
219
+ self._s.close()
220
+ self._s = pyserial.serial_for_url(port, **port_config)
221
+ logging.info(f'Successfully reconnected to serial port {port}.')
222
+ break
223
+ except Exception as e:
224
+ logging.warning(f'Reconnection attempt {attempt} failed: {e}')
225
+ else:
226
+ logging.error(
227
+ f'Failed to reconnect to serial port {port} after 3 attempts. Exiting event loop.'
228
+ )
229
+ return
230
+
231
+ continue
232
+
233
+ except Exception as e:
234
+ logging.warning(
235
+ 'unknown error: %s.\nRecommend to close the serial process by `dut.serial.close()`', str(e)
236
+ )
237
+ return
238
+
239
+ try:
240
+ self._q.put(s)
241
+ except OSError as e:
242
+ logging.warning(f'OSError. Error msg: {e}')
243
+ return
244
+ except Exception as e:
245
+ logging.warning(
246
+ 'unknown error: %s.\nRecommend to close the serial process by `dut.serial.close()`', str(e)
247
+ )
248
+ return
249
+
250
+ elif _e == 'stop':
251
+ self._block_reading = True
252
+ elif _e == 'start':
253
+ self._block_reading = False
254
+ elif _e == 'end':
255
+ return
256
+
257
+ time.sleep(0.05) # set interval
258
+
259
+ def stop_reading(self):
260
+ self._event_q.put('stop')
261
+
262
+ def start_reading(self):
263
+ self._event_q.put('start')
264
+
265
+ def terminate(self):
266
+ self._event_q.put('end')
267
+ self.join()
@@ -0,0 +1,117 @@
1
+ import sys
2
+
3
+ import pytest
4
+
5
+
6
+ def test_custom_serial_device(testdir):
7
+ testdir.makepyfile(r"""
8
+ import pytest
9
+
10
+ def test_serial_mixed(dut):
11
+ from pytest_embedded.dut_factory import DutFactory
12
+ assert len(dut)==2
13
+ another_dut = DutFactory.create()
14
+ st = set(
15
+ (
16
+ dut[0].serial.port,
17
+ dut[1].serial.port,
18
+ another_dut.serial.port
19
+ )
20
+ )
21
+ assert len(st) == 3
22
+
23
+ def test_custom_dut():
24
+ from pytest_embedded.dut_factory import DutFactory
25
+ another_dut = DutFactory.create(embedded_services='esp,serial')
26
+ """)
27
+
28
+ result = testdir.runpytest(
29
+ '-s',
30
+ '--embedded-services',
31
+ 'esp,serial',
32
+ '--count',
33
+ 2,
34
+ )
35
+ result.assert_outcomes(passed=2, errors=0)
36
+
37
+
38
+ def test_custom_serial_device_dut_count_1(testdir):
39
+ testdir.makepyfile(r"""
40
+ import pytest
41
+
42
+ def test_serial_device_created_dut_count_1(dut):
43
+ from pytest_embedded.dut_factory import DutFactory
44
+ another_dut = DutFactory.create()
45
+ another_dut2 = DutFactory.create()
46
+ st = set(
47
+ (
48
+ dut.serial.port,
49
+ another_dut.serial.port,
50
+ another_dut2.serial.port
51
+ )
52
+ )
53
+ assert len(st) == 3
54
+
55
+
56
+ """)
57
+
58
+ result = testdir.runpytest(
59
+ '-s',
60
+ '--embedded-services',
61
+ 'esp,serial',
62
+ '--count',
63
+ 1,
64
+ )
65
+ result.assert_outcomes(passed=1, errors=0)
66
+
67
+
68
+ @pytest.mark.skipif(sys.platform == 'win32', reason='No socat support on windows')
69
+ @pytest.mark.flaky(reruns=3, reruns_delay=2)
70
+ def test_serial_port(testdir):
71
+ testdir.makepyfile(r"""
72
+ import pytest
73
+ import subprocess
74
+
75
+ @pytest.fixture(autouse=True)
76
+ def open_tcp_port():
77
+ proc = subprocess.Popen('socat TCP4-LISTEN:9876,fork EXEC:cat', shell=True)
78
+ yield
79
+ proc.terminate()
80
+
81
+ def test_serial_port(dut):
82
+ dut.write(b'hello world\n')
83
+ dut.expect('hello world')
84
+ """)
85
+
86
+ result = testdir.runpytest(
87
+ '-s',
88
+ '--embedded-services',
89
+ 'serial',
90
+ '--port',
91
+ 'socket://localhost:9876',
92
+ )
93
+
94
+ result.assert_outcomes(passed=1)
95
+
96
+
97
+ def test_teardown_called_for_multi_dut(testdir):
98
+ testdir.makepyfile(r"""
99
+ import pytest
100
+
101
+ @pytest.mark.parametrize('count, embedded_services, port', [
102
+ ('3', 'serial', '/dev/ttyUSB0|/dev/ttyUSB1|/dev/ttyUSB100'), # set up failure
103
+ ], indirect=True)
104
+ def test_teardown_called_for_multi_dut_fail(dut):
105
+ assert len(dut) == 3
106
+
107
+ @pytest.mark.parametrize('count, embedded_services, port', [
108
+ ('3', 'serial', '/dev/ttyUSB0|/dev/ttyUSB1|/dev/ttyUSB2'), # set up succeeded
109
+ ], indirect=True)
110
+ def test_teardown_called_for_multi_dut_succeeded(dut):
111
+ assert dut[0].serial.port == '/dev/ttyUSB0'
112
+ assert dut[1].serial.port == '/dev/ttyUSB1'
113
+ assert dut[2].serial.port == '/dev/ttyUSB2'
114
+ """)
115
+
116
+ result = testdir.runpytest()
117
+ result.assert_outcomes(passed=1, errors=1)