pytest-embedded 2.7.0__tar.gz → 2.8.1__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytest-embedded
3
- Version: 2.7.0
3
+ Version: 2.8.1
4
4
  Summary: A pytest plugin that designed for embedded testing.
5
5
  Author-email: Fu Hanxi <fuhanxi@espressif.com>
6
6
  Requires-Python: >=3.10
@@ -6,4 +6,4 @@ from .dut_factory import DutFactory
6
6
 
7
7
  __all__ = ['App', 'Dut', 'DutFactory']
8
8
 
9
- __version__ = '2.7.0'
9
+ __version__ = '2.8.1'
@@ -1,3 +1,4 @@
1
+ import contextlib
1
2
  import datetime
2
3
  import gc
3
4
  import io
@@ -42,8 +43,19 @@ DUT_GLOBAL_INDEX = 0
42
43
  PARAMETRIZED_FIXTURES_CACHE = {}
43
44
 
44
45
 
45
- def _listen(q: MessageQueue, filepath: str, with_timestamp: bool = True, count: int = 1, total: int = 1) -> None:
46
+ _STDOUT_LOCK = None
47
+
48
+
49
+ def set_stdout_lock(lock) -> None:
50
+ global _STDOUT_LOCK
51
+ _STDOUT_LOCK = lock
52
+
53
+
54
+ def _listen(
55
+ q: MessageQueue, filepath: str, with_timestamp: bool = True, count: int = 1, total: int = 1, _stdout_lock=None
56
+ ) -> None:
46
57
  shall_add_prefix = True
58
+ _pending = ''
47
59
  while True:
48
60
  msg = q.get()
49
61
  if not msg:
@@ -71,20 +83,25 @@ def _listen(q: MessageQueue, filepath: str, with_timestamp: bool = True, count:
71
83
  if _s.endswith('\n'): # complete line
72
84
  shall_add_prefix = True
73
85
  _s = _s[:-1].replace('\n', '\n' + prefix) + '\n'
86
+ with _stdout_lock if _stdout_lock else contextlib.nullcontext():
87
+ _stdout.write(_pending + _s)
88
+ _stdout.flush()
89
+ _pending = ''
74
90
  else:
75
91
  shall_add_prefix = False
76
92
  _s = _s.replace('\n', '\n' + prefix)
77
-
78
- _stdout.write(_s)
79
- _stdout.flush()
93
+ _pending += _s
80
94
 
81
95
 
82
- def _listener_gn(msg_queue, _pexpect_logfile, with_timestamp, dut_index, dut_total) -> multiprocessing.Process:
96
+ def _listener_gn(
97
+ msg_queue, _pexpect_logfile, with_timestamp, dut_index, dut_total, _stdout_lock=None
98
+ ) -> multiprocessing.Process:
83
99
  os.makedirs(os.path.dirname(_pexpect_logfile), exist_ok=True)
84
100
  kwargs = {
85
101
  'with_timestamp': with_timestamp,
86
102
  'count': dut_index,
87
103
  'total': dut_total,
104
+ '_stdout_lock': _stdout_lock,
88
105
  }
89
106
 
90
107
  return _ctx.Process(
@@ -124,6 +141,7 @@ def _fixture_classes_and_options_fn(
124
141
  erase_all,
125
142
  esptool_baud,
126
143
  esp_flash_force,
144
+ no_fast_flash,
127
145
  part_tool,
128
146
  confirm_target_elf_sha256,
129
147
  erase_nvs,
@@ -140,6 +158,7 @@ def _fixture_classes_and_options_fn(
140
158
  qemu_extra_args,
141
159
  qemu_efuse_path,
142
160
  wokwi_diagram,
161
+ wokwi_usb_serial_jtag,
143
162
  skip_regenerate_image,
144
163
  encrypt,
145
164
  keyfile,
@@ -236,6 +255,7 @@ def _fixture_classes_and_options_fn(
236
255
  kwargs[fixture].update(
237
256
  {
238
257
  'app': None,
258
+ 'fast_flash': not no_fast_flash if no_fast_flash is not None else True,
239
259
  }
240
260
  )
241
261
  elif 'nuttx' in _services:
@@ -318,6 +338,7 @@ def _fixture_classes_and_options_fn(
318
338
  kwargs[fixture].update(
319
339
  {
320
340
  'wokwi_diagram': wokwi_diagram,
341
+ 'wokwi_usb_serial_jtag': wokwi_usb_serial_jtag,
321
342
  'msg_queue': msg_queue,
322
343
  'app': None,
323
344
  'meta': _meta,
@@ -663,6 +684,7 @@ class DutFactory:
663
684
  erase_all: bool | None = None,
664
685
  esptool_baud: int | None = None,
665
686
  esp_flash_force: bool | None = False,
687
+ no_fast_flash: bool | None = None,
666
688
  part_tool: str | None = None,
667
689
  confirm_target_elf_sha256: bool | None = None,
668
690
  erase_nvs: bool | None = None,
@@ -679,6 +701,7 @@ class DutFactory:
679
701
  qemu_extra_args: str | None = None,
680
702
  qemu_efuse_path: str | None = None,
681
703
  wokwi_diagram: str | None = None,
704
+ wokwi_usb_serial_jtag: bool | None = None,
682
705
  skip_regenerate_image: bool | None = None,
683
706
  encrypt: bool | None = None,
684
707
  keyfile: str | None = None,
@@ -726,6 +749,7 @@ class DutFactory:
726
749
  qemu_extra_args: Additional QEMU arguments.
727
750
  qemu_efuse_path: Efuse binary path.
728
751
  wokwi_diagram: Wokwi diagram path.
752
+ wokwi_usb_serial_jtag: Use USB Serial JTAG instead of UART for Wokwi serial communication.
729
753
  skip_regenerate_image: Skip image regeneration flag.
730
754
  encrypt: Encryption flag.
731
755
  keyfile: Keyfile for encryption.
@@ -753,7 +777,9 @@ class DutFactory:
753
777
  )
754
778
  logging.debug('You can get your custom DUT log file at the following path: %s.', _pexpect_logfile)
755
779
 
756
- _listener = _listener_gn(msg_queue, _pexpect_logfile, True, DUT_GLOBAL_INDEX, DUT_GLOBAL_INDEX + 1)
780
+ _listener = _listener_gn(
781
+ msg_queue, _pexpect_logfile, True, DUT_GLOBAL_INDEX, DUT_GLOBAL_INDEX + 1, _stdout_lock=_STDOUT_LOCK
782
+ )
757
783
  layout.append(_listener)
758
784
 
759
785
  _pexpect_fr = _pexpect_fr_gn(_pexpect_logfile, _listener)
@@ -778,6 +804,7 @@ class DutFactory:
778
804
  'erase_all': erase_all,
779
805
  'esptool_baud': esptool_baud,
780
806
  'esp_flash_force': esp_flash_force,
807
+ 'no_fast_flash': no_fast_flash,
781
808
  'part_tool': part_tool,
782
809
  'confirm_target_elf_sha256': confirm_target_elf_sha256,
783
810
  'erase_nvs': erase_nvs,
@@ -794,6 +821,7 @@ class DutFactory:
794
821
  'qemu_extra_args': qemu_extra_args,
795
822
  'qemu_efuse_path': qemu_efuse_path,
796
823
  'wokwi_diagram': wokwi_diagram,
824
+ 'wokwi_usb_serial_jtag': wokwi_usb_serial_jtag,
797
825
  'skip_regenerate_image': skip_regenerate_image,
798
826
  'encrypt': encrypt,
799
827
  'keyfile': keyfile,
@@ -30,6 +30,7 @@ from .app import App
30
30
  from .dut import Dut
31
31
  from .dut_factory import (
32
32
  DutFactory,
33
+ _ctx,
33
34
  _fixture_classes_and_options_fn,
34
35
  _listener_gn,
35
36
  _pexpect_fr_gn,
@@ -41,6 +42,7 @@ from .dut_factory import (
41
42
  qemu_gn,
42
43
  serial_gn,
43
44
  set_parametrized_fixtures_cache,
45
+ set_stdout_lock,
44
46
  wokwi_gn,
45
47
  )
46
48
  from .log import MessageQueue, MessageQueueManager, PexpectProcess
@@ -207,6 +209,17 @@ def pytest_addoption(parser):
207
209
  action='store_true',
208
210
  help='force mode for esptool',
209
211
  )
212
+ arduino_group = parser.getgroup('embedded-arduino')
213
+ arduino_group.addoption(
214
+ '--no-fast-flash',
215
+ help=(
216
+ 'y/yes/true for True and n/no/false for False. '
217
+ 'Set to True to disable fast reflashing (--diff-with) for Arduino. '
218
+ 'Useful when flash state is unknown, e.g. after OTA updates. '
219
+ '(Default: False)'
220
+ ),
221
+ )
222
+
210
223
  idf_group = parser.getgroup('embedded-idf')
211
224
  idf_group.addoption(
212
225
  '--supported-targets', help='Comma-separated list of supported targets for the test case. (Default: None)'
@@ -296,6 +309,13 @@ def pytest_addoption(parser):
296
309
  '--wokwi-diagram',
297
310
  help='Path to the wokwi diagram file (Default: None)',
298
311
  )
312
+ wokwi_group.addoption(
313
+ '--wokwi-usb-serial-jtag',
314
+ help='y/yes/true for True and n/no/false for False. '
315
+ 'Use USB Serial JTAG instead of UART for serial communication in the Wokwi diagram. '
316
+ 'When enabled, the diagram will use the USB_SERIAL_JTAG interface and remove $serialMonitor connections. '
317
+ '(Default: False)',
318
+ )
299
319
 
300
320
 
301
321
  ###########
@@ -695,6 +715,23 @@ def _mp_manager():
695
715
  manager.shutdown()
696
716
 
697
717
 
718
+ @pytest.fixture(scope='session', autouse=True)
719
+ def _stdout_lock():
720
+ """
721
+ A session-scoped multiprocessing lock used to serialize stdout writes across
722
+ all DUT listener processes, preventing garbled output when multiple DUTs
723
+ print to stdout simultaneously.
724
+
725
+ It is marked ``autouse=True`` so that the lock is created and registered
726
+ globally (via ``set_stdout_lock``) before any DUT fixture is instantiated,
727
+ ensuring every listener process receives a valid lock reference regardless
728
+ of test ordering.
729
+ """
730
+ lock = _ctx.Lock()
731
+ set_stdout_lock(lock)
732
+ yield lock
733
+
734
+
698
735
  @pytest.fixture
699
736
  def test_case_tempdir(test_case_name: str, session_tempdir: str) -> str:
700
737
  """Function scoped temp dir for pytest-embedded"""
@@ -746,13 +783,17 @@ def with_timestamp(request: FixtureRequest) -> bool:
746
783
 
747
784
  @pytest.fixture
748
785
  @multi_dut_generator_fixture
749
- def _listener(msg_queue, _pexpect_logfile, with_timestamp, dut_index, dut_total) -> multiprocessing.Process:
786
+ def _listener(
787
+ msg_queue, _pexpect_logfile, with_timestamp, dut_index, dut_total, _stdout_lock
788
+ ) -> multiprocessing.Process:
750
789
  """
751
790
  The listener would create a `_listen` process. The `_listen` process would get the string from the message queue,
752
791
  and do two things together:
753
792
 
754
793
  1. print the string to `sys.stdout`
755
794
  2. write the string to `_pexpect_logfile`
795
+
796
+ A shared lock (_stdout_lock) is used to prevent interleaved output when multiple DUTs print simultaneously.
756
797
  """
757
798
  return _listener_gn(**locals())
758
799
 
@@ -819,6 +860,13 @@ def esp_flash_force(request: FixtureRequest) -> str | None:
819
860
  return _request_param_or_config_option_or_default(request, 'esp_flash_force', False)
820
861
 
821
862
 
863
+ @pytest.fixture
864
+ @multi_dut_argument
865
+ def no_fast_flash(request: FixtureRequest) -> bool | None:
866
+ """Enable parametrization for the same cli option"""
867
+ return _request_param_or_config_option_or_default(request, 'no_fast_flash', None)
868
+
869
+
822
870
  @pytest.fixture
823
871
  @multi_dut_argument
824
872
  def build_dir(request: FixtureRequest) -> str | None:
@@ -1053,6 +1101,13 @@ def wokwi_diagram(request: FixtureRequest) -> str | None:
1053
1101
  return _request_param_or_config_option_or_default(request, 'wokwi_diagram', None)
1054
1102
 
1055
1103
 
1104
+ @pytest.fixture
1105
+ @multi_dut_argument
1106
+ def wokwi_usb_serial_jtag(request: FixtureRequest) -> bool | None:
1107
+ """Enable parametrization for the same cli option"""
1108
+ return _request_param_or_config_option_or_default(request, 'wokwi_usb_serial_jtag', None)
1109
+
1110
+
1056
1111
  ####################
1057
1112
  # Private Fixtures #
1058
1113
  ####################
@@ -1095,6 +1150,7 @@ def parametrize_fixtures(
1095
1150
  erase_all,
1096
1151
  esptool_baud,
1097
1152
  esp_flash_force,
1153
+ no_fast_flash,
1098
1154
  part_tool,
1099
1155
  confirm_target_elf_sha256,
1100
1156
  erase_nvs,
@@ -1111,6 +1167,7 @@ def parametrize_fixtures(
1111
1167
  qemu_extra_args,
1112
1168
  qemu_efuse_path,
1113
1169
  wokwi_diagram,
1170
+ wokwi_usb_serial_jtag,
1114
1171
  skip_regenerate_image,
1115
1172
  encrypt,
1116
1173
  keyfile,
@@ -821,3 +821,192 @@ def test_log_metric_without_path(pytester):
821
821
 
822
822
  result = pytester.runpytest()
823
823
  result.assert_outcomes(passed=1)
824
+
825
+
826
+ # ---------------------------------------------------------------------------
827
+ # Tests for the stdout-lock feature (_stdout_lock / set_stdout_lock / _listen)
828
+ # ---------------------------------------------------------------------------
829
+
830
+
831
+ def test_set_stdout_lock():
832
+ """set_stdout_lock updates the module-level _STDOUT_LOCK variable."""
833
+ import pytest_embedded.dut_factory as m
834
+ from pytest_embedded.dut_factory import set_stdout_lock
835
+
836
+ original = m._STDOUT_LOCK
837
+ try:
838
+ sentinel = object()
839
+ set_stdout_lock(sentinel)
840
+ assert m._STDOUT_LOCK is sentinel
841
+
842
+ set_stdout_lock(None)
843
+ assert m._STDOUT_LOCK is None
844
+ finally:
845
+ set_stdout_lock(original)
846
+
847
+
848
+ def test_listen_no_data_loss_without_lock(tmp_path):
849
+ """_listen writes every queued message to the logfile when no lock is used."""
850
+ import time
851
+
852
+ from pytest_embedded.dut_factory import _ctx, _listen
853
+ from pytest_embedded.log import MessageQueue
854
+
855
+ logfile = str(tmp_path / 'test.log')
856
+ q = MessageQueue()
857
+ messages = [f'line_{i}\n'.encode() for i in range(20)]
858
+
859
+ p = _ctx.Process(target=_listen, args=(q, logfile), kwargs={'with_timestamp': False})
860
+ p.start()
861
+ try:
862
+ for msg in messages:
863
+ q.put(msg)
864
+
865
+ deadline = time.monotonic() + 10
866
+ while time.monotonic() < deadline:
867
+ try:
868
+ content = open(logfile, 'rb').read()
869
+ if all(msg in content for msg in messages):
870
+ break
871
+ except OSError:
872
+ pass
873
+ time.sleep(0.05)
874
+ finally:
875
+ p.terminate()
876
+ p.join(timeout=5)
877
+ assert p.exitcode is not None, 'listener process did not terminate'
878
+
879
+ content = open(logfile, 'rb').read()
880
+ for msg in messages:
881
+ assert msg in content, f'{msg!r} missing from logfile'
882
+
883
+
884
+ def test_listen_no_data_loss_with_lock(tmp_path):
885
+ """_listen writes every queued message to the logfile when a Manager lock is used."""
886
+ import multiprocessing
887
+ import time
888
+
889
+ from pytest_embedded.dut_factory import _ctx, _listen
890
+ from pytest_embedded.log import MessageQueue
891
+
892
+ logfile = str(tmp_path / 'test.log')
893
+ q = MessageQueue()
894
+ messages = [f'line_{i}\n'.encode() for i in range(20)]
895
+
896
+ manager = multiprocessing.Manager()
897
+ try:
898
+ lock = manager.Lock()
899
+ p = _ctx.Process(
900
+ target=_listen,
901
+ args=(q, logfile),
902
+ kwargs={'with_timestamp': False, '_stdout_lock': lock},
903
+ )
904
+ p.start()
905
+ try:
906
+ for msg in messages:
907
+ q.put(msg)
908
+
909
+ deadline = time.monotonic() + 10
910
+ while time.monotonic() < deadline:
911
+ try:
912
+ content = open(logfile, 'rb').read()
913
+ if all(msg in content for msg in messages):
914
+ break
915
+ except OSError:
916
+ pass
917
+ time.sleep(0.05)
918
+ finally:
919
+ p.terminate()
920
+ p.join(timeout=5)
921
+ assert p.exitcode is not None, 'listener process did not terminate'
922
+ finally:
923
+ manager.shutdown()
924
+
925
+ content = open(logfile, 'rb').read()
926
+ for msg in messages:
927
+ assert msg in content, f'{msg!r} missing from logfile'
928
+
929
+
930
+ def test_stdout_lock_concurrent_no_data_loss(tmp_path):
931
+ """Two concurrent _listen processes sharing a Manager lock both preserve all data."""
932
+ import multiprocessing
933
+ import time
934
+
935
+ from pytest_embedded.dut_factory import _ctx, _listen
936
+ from pytest_embedded.log import MessageQueue
937
+
938
+ logfile0 = str(tmp_path / 'dut0.log')
939
+ logfile1 = str(tmp_path / 'dut1.log')
940
+ q0 = MessageQueue()
941
+ q1 = MessageQueue()
942
+ messages0 = [f'dut0_line_{i}\n'.encode() for i in range(20)]
943
+ messages1 = [f'dut1_line_{i}\n'.encode() for i in range(20)]
944
+
945
+ manager = multiprocessing.Manager()
946
+ try:
947
+ lock = manager.Lock()
948
+ p0 = _ctx.Process(
949
+ target=_listen,
950
+ args=(q0, logfile0),
951
+ kwargs={'with_timestamp': False, 'count': 1, 'total': 2, '_stdout_lock': lock},
952
+ )
953
+ p1 = _ctx.Process(
954
+ target=_listen,
955
+ args=(q1, logfile1),
956
+ kwargs={'with_timestamp': False, 'count': 2, 'total': 2, '_stdout_lock': lock},
957
+ )
958
+ p0.start()
959
+ p1.start()
960
+ try:
961
+ # interleave writes from both DUTs to maximize lock contention
962
+ for msg0, msg1 in zip(messages0, messages1):
963
+ q0.put(msg0)
964
+ q1.put(msg1)
965
+
966
+ deadline = time.monotonic() + 15
967
+ while time.monotonic() < deadline:
968
+ try:
969
+ c0 = open(logfile0, 'rb').read()
970
+ c1 = open(logfile1, 'rb').read()
971
+ if all(m in c0 for m in messages0) and all(m in c1 for m in messages1):
972
+ break
973
+ except OSError:
974
+ pass
975
+ time.sleep(0.05)
976
+ finally:
977
+ p0.terminate()
978
+ p1.terminate()
979
+ p0.join(timeout=5)
980
+ p1.join(timeout=5)
981
+ assert p0.exitcode is not None, 'dut0 listener process did not terminate'
982
+ assert p1.exitcode is not None, 'dut1 listener process did not terminate'
983
+ finally:
984
+ manager.shutdown()
985
+
986
+ c0 = open(logfile0, 'rb').read()
987
+ c1 = open(logfile1, 'rb').read()
988
+ for msg in messages0:
989
+ assert msg in c0, f'{msg!r} missing from dut0 logfile'
990
+ for msg in messages1:
991
+ assert msg in c1, f'{msg!r} missing from dut1 logfile'
992
+
993
+
994
+ def test_multi_dut_no_data_loss(testdir):
995
+ """In a 2-DUT test, all messages written by each DUT can be expected - nothing is dropped."""
996
+ testdir.makepyfile(r"""
997
+ import pytest
998
+
999
+ @pytest.mark.parametrize('count', [2], indirect=True)
1000
+ def test_concurrent_dut_writes(dut):
1001
+ n = 15
1002
+ for i in range(n):
1003
+ dut[0].write(f'dut0_msg_{i}')
1004
+ dut[1].write(f'dut1_msg_{i}')
1005
+
1006
+ for i in range(n):
1007
+ dut[0].expect_exact(f'dut0_msg_{i}')
1008
+ dut[1].expect_exact(f'dut1_msg_{i}')
1009
+ """)
1010
+
1011
+ result = testdir.runpytest()
1012
+ result.assert_outcomes(passed=1)
File without changes