pymodaq_plugins_utils 5.0.4__tar.gz → 5.0.5__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.
Files changed (31) hide show
  1. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/.github/workflows/Testbase.yml +2 -2
  2. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/.github/workflows/compatibility.yml +3 -3
  3. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/.github/workflows/python-publish.yml +2 -2
  4. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/.github/workflows/updater.yml +1 -1
  5. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/PKG-INFO +2 -1
  6. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/pyproject.toml +1 -0
  7. pymodaq_plugins_utils-5.0.5/src/pymodaq_plugins_utils/hardware/__init__.py +33 -0
  8. pymodaq_plugins_utils-5.0.5/src/pymodaq_plugins_utils/hardware/base.py +59 -0
  9. pymodaq_plugins_utils-5.0.5/src/pymodaq_plugins_utils/hardware/serial_ports.py +63 -0
  10. pymodaq_plugins_utils-5.0.5/src/pymodaq_plugins_utils/hardware/visa.py +84 -0
  11. pymodaq_plugins_utils-5.0.5/tests/hardware_test.py +249 -0
  12. pymodaq_plugins_utils-5.0.4/src/pymodaq_plugins_utils/resources/__init__.py +0 -0
  13. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/.gitattributes +0 -0
  14. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/.github/workflows/Test.yml +0 -0
  15. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/.gitignore +0 -0
  16. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/LICENSE +0 -0
  17. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/README.rst +0 -0
  18. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/hatch_build.py +0 -0
  19. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/icon.ico +0 -0
  20. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/__init__.py +0 -0
  21. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/app/__init__.py +0 -0
  22. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/exporters/__init__.py +0 -0
  23. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/extensions/__init__.py +0 -0
  24. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/extensions/custom_extension_template.py +0 -0
  25. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/hardware/camera_base_pylablib.py +0 -0
  26. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/models/__init__.py +0 -0
  27. {pymodaq_plugins_utils-5.0.4/src/pymodaq_plugins_utils/hardware → pymodaq_plugins_utils-5.0.5/src/pymodaq_plugins_utils/resources}/__init__.py +0 -0
  28. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/resources/config_template.toml +0 -0
  29. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/scanners/__init__.py +0 -0
  30. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/src/pymodaq_plugins_utils/utils.py +0 -0
  31. {pymodaq_plugins_utils-5.0.4 → pymodaq_plugins_utils-5.0.5}/tests/test_plugin_package_structure.py +0 -0
@@ -18,9 +18,9 @@ jobs:
18
18
  QT_DEBUG_PLUGINS: 1
19
19
  steps:
20
20
  - name: Set up Python ${{ inputs.python }}
21
- uses: actions/checkout@v5.0.0
21
+ uses: actions/checkout@v6.0.3
22
22
  - name: Install dependencies
23
- uses: actions/setup-python@v6.0.0
23
+ uses: actions/setup-python@v6.2.0
24
24
  with:
25
25
  python-version: ${{ inputs.python }}
26
26
  - name: Install package
@@ -36,10 +36,10 @@ jobs:
36
36
  echo "plugin_name=$(echo '${{ github.repository }}' | cut -d'/' -f2)" >> $GITHUB_ENV
37
37
 
38
38
  - name: Checkout the repo
39
- uses: actions/checkout@v5.0.0
39
+ uses: actions/checkout@v6.0.3
40
40
 
41
41
  - name: Set up Python ${{ matrix.python-version }}
42
- uses: actions/setup-python@v6.0.0
42
+ uses: actions/setup-python@v6.2.0
43
43
  with:
44
44
  python-version: ${{ matrix.python-version }}
45
45
 
@@ -69,7 +69,7 @@ jobs:
69
69
 
70
70
  - name: Upload compatibility report
71
71
  if: failure()
72
- uses: actions/upload-artifact@v4.6.2
72
+ uses: actions/upload-artifact@v7.0.1
73
73
  with:
74
74
  name:
75
75
  path: 'import_report_tests_${{ env.plugin_name }}_None.txt'
@@ -13,9 +13,9 @@ jobs:
13
13
  runs-on: ubuntu-latest
14
14
 
15
15
  steps:
16
- - uses: actions/checkout@v5.0.0
16
+ - uses: actions/checkout@v6.0.3
17
17
  - name: Set up Python
18
- uses: actions/setup-python@v6.0.0
18
+ uses: actions/setup-python@v6.2.0
19
19
  with:
20
20
  python-version: '3.13'
21
21
  - name: Install dependencies
@@ -12,7 +12,7 @@ jobs:
12
12
  runs-on: ubuntu-latest
13
13
 
14
14
  steps:
15
- - uses: actions/checkout@v5.0.0
15
+ - uses: actions/checkout@v6.0.3
16
16
  with:
17
17
  # [Required] Access token with `workflow` scope.
18
18
  token: ${{ secrets.WORKFLOW_SECRET }}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pymodaq_plugins_utils
3
- Version: 5.0.4
3
+ Version: 5.0.5
4
4
  Summary: Set of utility methods and classes to interact with instruments
5
5
  Project-URL: Homepage, https://pymodaq.cnrs.fr
6
6
  Project-URL: Documentation , https://pymodaq.cnrs.fr
@@ -45,6 +45,7 @@ Classifier: Topic :: Software Development :: User Interfaces
45
45
  Requires-Python: >=3.8
46
46
  Requires-Dist: pymodaq>=5.0.0
47
47
  Provides-Extra: serial
48
+ Requires-Dist: pyserial; extra == 'serial'
48
49
  Requires-Dist: pyvisa; extra == 'serial'
49
50
  Description-Content-Type: text/x-rst
50
51
 
@@ -11,6 +11,7 @@ package-url = 'https://github.com/PyMoDAQ/pymodaq_plugins_utils'
11
11
  [project.optional-dependencies]
12
12
  serial = [
13
13
  "pyvisa",
14
+ "pyserial",
14
15
  ]
15
16
 
16
17
  [project]
@@ -0,0 +1,33 @@
1
+ """Hardware discovery caches for PyMoDAQ plugins.
2
+
3
+ Each backend (:mod:`~pymodaq_utils.hardware.visa`,
4
+ :mod:`~pymodaq_utils.hardware.serial_ports`) queries the OS exactly once per
5
+ process. Subsequent calls reuse the cached result, so plugin startup cost is
6
+ paid at most once regardless of how many plugins share the same backend.
7
+
8
+ Quick reference::
9
+
10
+ # VISA-based plugin (Newport, Thorlabs, PI, ...)
11
+ from pymodaq_utils.hardware.visa import list_serial_resources
12
+ ports = list_serial_resources()
13
+
14
+ # pyserial-based plugin (Arduino, Ocean Optics, ...)
15
+ from pymodaq_utils.hardware.serial_ports import list_resources
16
+ ports = list_resources()
17
+
18
+ # After hot-plugging a device
19
+ from pymodaq_utils.hardware import invalidate_all_caches
20
+ invalidate_all_caches()
21
+ """
22
+ from .visa import invalidate_cache as _invalidate_visa
23
+ from .serial_ports import invalidate_cache as _invalidate_serial
24
+
25
+
26
+ def invalidate_all_caches() -> None:
27
+ """Clear both the VISA and serial discovery caches.
28
+
29
+ Call this after hot-plugging a device so the next call to any
30
+ ``list_*`` function re-discovers the current set of instruments.
31
+ """
32
+ _invalidate_visa()
33
+ _invalidate_serial()
@@ -0,0 +1,59 @@
1
+
2
+ class HardwareCache:
3
+ """Base class for process-lifetime hardware discovery caches.
4
+
5
+ Each subclass calls its backend (pyvisa, pyserial, …) exactly once per
6
+ process. The result is stored as a class variable and reused by every
7
+ caller, regardless of which plugin package triggered the first call.
8
+
9
+ Subclasses must override :meth:`_fetch` and :meth:`list_resources`.
10
+ Call :meth:`invalidate_cache` to force re-discovery, for example after
11
+ hot-plugging a device.
12
+
13
+ Example — defining a new backend::
14
+
15
+ class MyCache(HardwareCache):
16
+ _cache = None
17
+
18
+ @classmethod
19
+ def _fetch(cls):
20
+ return some_expensive_os_call()
21
+
22
+ @classmethod
23
+ def list_resources(cls) -> list[str]:
24
+ return [item.id for item in cls._get_cache()]
25
+ """
26
+
27
+ _cache = None
28
+
29
+ @classmethod
30
+ def _fetch(cls):
31
+ """Perform the actual hardware discovery.
32
+
33
+ Called at most once per process. Must return a value that can be
34
+ stored and reused (list, dict, …). Should catch all exceptions and
35
+ return an empty container so that callers never need to guard against
36
+ missing backends.
37
+ """
38
+ raise NotImplementedError
39
+
40
+ @classmethod
41
+ def _get_cache(cls):
42
+ """Return the cached discovery result, populating it on first call."""
43
+ if cls._cache is None:
44
+ cls._cache = cls._fetch()
45
+ return cls._cache
46
+
47
+ @classmethod
48
+ def invalidate_cache(cls) -> None:
49
+ """Clear the cache so the next call to any list_* method re-discovers.
50
+
51
+ Use this after hot-plugging a device or when the set of available
52
+ instruments may have changed since process startup.
53
+ """
54
+ cls._cache = None
55
+
56
+ @classmethod
57
+ def list_resources(cls) -> list[str]:
58
+ """Return a list of connectable resource strings for this backend."""
59
+ raise NotImplementedError
@@ -0,0 +1,63 @@
1
+ """pyserial hardware discovery cache.
2
+
3
+ Wraps :mod:`serial.tools.list_ports` to enumerate available serial ports
4
+ exactly once per process. ``pyserial`` is an optional dependency: if it is
5
+ not installed, all functions return empty lists and a warning is logged.
6
+
7
+ Typical usage in a plugin::
8
+
9
+ from pymodaq_utils.hardware.serial_ports import list_resources
10
+
11
+ ports = list_resources() # e.g. ['/dev/ttyUSB0', 'COM3']
12
+
13
+ After hot-plugging a device, refresh the cache with::
14
+
15
+ from pymodaq_utils.hardware.serial_ports import invalidate_cache
16
+ invalidate_cache()
17
+ """
18
+ import pymodaq_utils.logger as logger_module
19
+ from .base import HardwareCache
20
+
21
+ logger = logger_module.set_logger(logger_module.get_module_name(__file__))
22
+
23
+
24
+ class SerialPortsCache(HardwareCache):
25
+ _cache = None
26
+
27
+ @classmethod
28
+ def _fetch(cls) -> list:
29
+ try:
30
+ from serial.tools.list_ports import comports
31
+ return list(comports())
32
+ except ImportError:
33
+ logger.warning('pyserial is not installed — serial port discovery unavailable. '
34
+ 'Install it with: pip install pyserial')
35
+ return []
36
+ except Exception as e:
37
+ logger.warning(f'Serial port discovery failed: {e}')
38
+ return []
39
+
40
+ @classmethod
41
+ def list_resources(cls) -> list[str]:
42
+ """Serial port device strings (e.g. '/dev/ttyUSB0', 'COM3')."""
43
+ return [p.device for p in cls._get_cache()]
44
+
45
+ @classmethod
46
+ def list_port_descriptions(cls) -> list[str]:
47
+ """Human-readable descriptions for each serial port."""
48
+ return [p.description for p in cls._get_cache()]
49
+
50
+
51
+ def list_resources() -> list[str]:
52
+ """Serial port device strings (e.g. '/dev/ttyUSB0', 'COM3')."""
53
+ return SerialPortsCache.list_resources()
54
+
55
+
56
+ def list_port_descriptions() -> list[str]:
57
+ """Human-readable descriptions for each serial port."""
58
+ return SerialPortsCache.list_port_descriptions()
59
+
60
+
61
+ def invalidate_cache() -> None:
62
+ """Clear the serial port cache so the next call re-discovers."""
63
+ SerialPortsCache.invalidate_cache()
@@ -0,0 +1,84 @@
1
+ """VISA hardware discovery cache.
2
+
3
+ Wraps :mod:`pyvisa` to enumerate available VISA resources exactly once per
4
+ process. ``pyvisa`` is an optional dependency: if it is not installed, or no
5
+ VISA backend is found, all functions return empty lists and a warning is logged.
6
+
7
+ Typical usage in a plugin::
8
+
9
+ from pymodaq_utils.hardware.visa import list_serial_resources
10
+
11
+ ports = list_serial_resources() # e.g. ['ASRL/dev/ttyUSB0::INSTR']
12
+
13
+ After hot-plugging a device, refresh the cache with::
14
+
15
+ from pymodaq_utils.hardware.visa import invalidate_cache
16
+ invalidate_cache()
17
+ """
18
+ import pymodaq_utils.logger as logger_module
19
+ from .base import HardwareCache
20
+
21
+ logger = logger_module.set_logger(logger_module.get_module_name(__file__))
22
+
23
+
24
+ class VisaCache(HardwareCache):
25
+ _cache = None
26
+
27
+ @classmethod
28
+ def _fetch(cls) -> dict:
29
+ try:
30
+ import pyvisa
31
+ rm = pyvisa.ResourceManager()
32
+ info = dict(rm.list_resources_info())
33
+ rm.close()
34
+ return info
35
+ except ImportError:
36
+ logger.warning('pyvisa is not installed — VISA resource discovery unavailable. '
37
+ 'Install it with: pip install pyvisa pyvisa-py')
38
+ return {}
39
+ except Exception as e:
40
+ logger.warning(f'VISA resource discovery failed: {e}')
41
+ return {}
42
+
43
+ @classmethod
44
+ def list_resources(cls) -> list[str]:
45
+ """All available VISA resource strings (e.g. 'GPIB0::5::INSTR')."""
46
+ return list(cls._get_cache().keys())
47
+
48
+ @classmethod
49
+ def list_serial_resources(cls) -> list[str]:
50
+ """ASRL (serial-over-VISA) resource strings only.
51
+
52
+ Linux: 'ASRL/dev/ttyUSB0::INSTR'
53
+ Windows: 'ASRL3::INSTR'
54
+ """
55
+ return [r for r in cls._get_cache() if r.startswith('ASRL')]
56
+
57
+ @classmethod
58
+ def list_resource_aliases(cls) -> list[str]:
59
+ """Human-readable aliases where available (e.g. 'COM3' on Windows)."""
60
+ return [i.alias for i in cls._get_cache().values() if i.alias]
61
+
62
+
63
+ def list_resources() -> list[str]:
64
+ """All available VISA resource strings (e.g. 'GPIB0::5::INSTR', 'TCPIP0::...')."""
65
+ return VisaCache.list_resources()
66
+
67
+
68
+ def list_serial_resources() -> list[str]:
69
+ """ASRL (serial-over-VISA) resource strings only.
70
+
71
+ Linux: ``'ASRL/dev/ttyUSB0::INSTR'``
72
+ Windows: ``'ASRL3::INSTR'``
73
+ """
74
+ return VisaCache.list_serial_resources()
75
+
76
+
77
+ def list_resource_aliases() -> list[str]:
78
+ """Human-readable aliases where available (e.g. ``'COM3'`` on Windows)."""
79
+ return VisaCache.list_resource_aliases()
80
+
81
+
82
+ def invalidate_cache() -> None:
83
+ """Clear the VISA resource cache so the next call re-discovers."""
84
+ VisaCache.invalidate_cache()
@@ -0,0 +1,249 @@
1
+ import pytest
2
+
3
+ from pymodaq_plugins_utils.hardware.base import HardwareCache
4
+ from pymodaq_plugins_utils.hardware import visa, serial_ports, invalidate_all_caches
5
+
6
+
7
+ # ---------------------------------------------------------------------------
8
+ # Helpers
9
+ # ---------------------------------------------------------------------------
10
+
11
+ class _CountingCache(HardwareCache):
12
+ """Minimal concrete subclass used to test base-class caching logic."""
13
+ _cache = None
14
+ fetch_count = 0
15
+
16
+ @classmethod
17
+ def _fetch(cls):
18
+ cls.fetch_count += 1
19
+ return ['resource_a', 'resource_b']
20
+
21
+ @classmethod
22
+ def list_resources(cls):
23
+ return list(cls._get_cache())
24
+
25
+ @classmethod
26
+ def reset(cls):
27
+ cls._cache = None
28
+ cls.fetch_count = 0
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # HardwareCache base behaviour
33
+ # ---------------------------------------------------------------------------
34
+
35
+ class TestHardwareCacheBase:
36
+
37
+ def setup_method(self):
38
+ _CountingCache.reset()
39
+
40
+ def test_fetch_called_once(self):
41
+ # Core guarantee: no matter how many times list_resources() is called,
42
+ # the expensive OS-level _fetch() runs exactly once per process lifetime.
43
+ _CountingCache.list_resources()
44
+ _CountingCache.list_resources()
45
+ assert _CountingCache.fetch_count == 1
46
+
47
+ def test_returns_correct_data(self):
48
+ assert _CountingCache.list_resources() == ['resource_a', 'resource_b']
49
+
50
+ def test_invalidate_triggers_refetch(self):
51
+ # After invalidation the cache is empty, so the next call must
52
+ # re-run _fetch() — this is the hot-plug refresh path.
53
+ _CountingCache.list_resources()
54
+ _CountingCache.invalidate_cache()
55
+ _CountingCache.list_resources()
56
+ assert _CountingCache.fetch_count == 2
57
+
58
+ def test_cache_is_none_after_invalidate(self):
59
+ # Validates the internal reset so _get_cache() knows to call _fetch()
60
+ # again on the next access (tested separately in test_invalidate_triggers_refetch).
61
+ _CountingCache.list_resources()
62
+ _CountingCache.invalidate_cache()
63
+ assert _CountingCache._cache is None
64
+
65
+ def test_subclasses_have_independent_caches(self):
66
+ # Each subclass stores its result in its own _cache class variable.
67
+ # Invalidating one must not affect the other — otherwise a Newport
68
+ # plugin resetting its cache would silently clear an Arduino plugin's cache.
69
+ class CacheA(HardwareCache):
70
+ _cache = None
71
+
72
+ @classmethod
73
+ def _fetch(cls):
74
+ return ['a']
75
+
76
+ @classmethod
77
+ def list_resources(cls):
78
+ return list(cls._get_cache())
79
+
80
+ class CacheB(HardwareCache):
81
+ _cache = None
82
+
83
+ @classmethod
84
+ def _fetch(cls):
85
+ return ['b']
86
+
87
+ @classmethod
88
+ def list_resources(cls):
89
+ return list(cls._get_cache())
90
+
91
+ assert CacheA.list_resources() == ['a']
92
+ assert CacheB.list_resources() == ['b']
93
+ CacheA.invalidate_cache()
94
+ assert CacheA._cache is None
95
+ assert CacheB._cache is not None # CacheB untouched
96
+
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # visa module
100
+ # ---------------------------------------------------------------------------
101
+
102
+ class TestVisaModule:
103
+
104
+ def setup_method(self):
105
+ # Start each test with a clean cache so tests are isolated.
106
+ visa.VisaCache.invalidate_cache()
107
+
108
+ def test_list_resources_returns_list(self):
109
+ # The module must never raise, even when pyvisa is not installed.
110
+ assert isinstance(visa.list_resources(), list)
111
+
112
+ def test_warns_when_pyvisa_absent(self, monkeypatch):
113
+ # When pyvisa is missing the user should get an explicit warning,
114
+ # not a silent empty list that looks like "no devices connected".
115
+ import builtins
116
+ real_import = builtins.__import__
117
+
118
+ def block_pyvisa(name, *args, **kwargs):
119
+ if name == 'pyvisa':
120
+ raise ImportError('pyvisa blocked for test')
121
+ return real_import(name, *args, **kwargs)
122
+
123
+ warned = []
124
+ monkeypatch.setattr(builtins, '__import__', block_pyvisa)
125
+ monkeypatch.setattr(visa.logger, 'warning', lambda msg: warned.append(msg))
126
+ visa.VisaCache.invalidate_cache()
127
+
128
+ visa.list_resources()
129
+ assert any('pyvisa' in msg for msg in warned)
130
+
131
+ def test_list_serial_resources_returns_list(self):
132
+ assert isinstance(visa.list_serial_resources(), list)
133
+
134
+ def test_list_resource_aliases_returns_list(self):
135
+ assert isinstance(visa.list_resource_aliases(), list)
136
+
137
+ def test_serial_resources_are_subset_of_all(self):
138
+ # list_serial_resources() is a filtered view of list_resources();
139
+ # every ASRL entry must also appear in the full resource list.
140
+ # Skipped when pyvisa is absent: an empty list would make this pass
141
+ # vacuously without testing anything.
142
+ pytest.importorskip('pyvisa')
143
+ all_r = visa.list_resources()
144
+ serial_r = visa.list_serial_resources()
145
+ assert all(r in all_r for r in serial_r)
146
+
147
+ def test_serial_resources_start_with_asrl(self):
148
+ # VISA serial resources always begin with 'ASRL' by the VISA standard.
149
+ # Skipped when pyvisa is absent for the same reason as above.
150
+ pytest.importorskip('pyvisa')
151
+ for r in visa.list_serial_resources():
152
+ assert r.startswith('ASRL')
153
+
154
+ def test_fetch_called_once_across_multiple_functions(self, monkeypatch):
155
+ # Calling list_resources(), list_serial_resources(), and
156
+ # list_resource_aliases() in sequence must trigger only one backend
157
+ # query — the whole point of this module.
158
+ call_count = []
159
+ original_fetch = visa.VisaCache._fetch.__func__
160
+
161
+ @classmethod
162
+ def counting_fetch(cls):
163
+ call_count.append(1)
164
+ return original_fetch(cls)
165
+
166
+ monkeypatch.setattr(visa.VisaCache, '_fetch', counting_fetch)
167
+ visa.VisaCache.invalidate_cache()
168
+
169
+ visa.list_resources()
170
+ visa.list_serial_resources()
171
+ visa.list_resource_aliases()
172
+
173
+ assert len(call_count) == 1
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # serial_ports module
178
+ # ---------------------------------------------------------------------------
179
+
180
+ class TestSerialPortsModule:
181
+
182
+ def setup_method(self):
183
+ serial_ports.SerialPortsCache.invalidate_cache()
184
+
185
+ def test_list_resources_returns_list(self):
186
+ # The module must never raise, even when pyserial is not installed.
187
+ assert isinstance(serial_ports.list_resources(), list)
188
+
189
+ def test_warns_when_pyserial_absent(self, monkeypatch):
190
+ # Same contract as the visa equivalent: missing backend → warning, not silent empty list.
191
+ import builtins
192
+ real_import = builtins.__import__
193
+
194
+ def block_serial(name, *args, **kwargs):
195
+ if name == 'serial.tools.list_ports':
196
+ raise ImportError('pyserial blocked for test')
197
+ return real_import(name, *args, **kwargs)
198
+
199
+ warned = []
200
+ monkeypatch.setattr(builtins, '__import__', block_serial)
201
+ monkeypatch.setattr(serial_ports.logger, 'warning', lambda msg: warned.append(msg))
202
+ serial_ports.SerialPortsCache.invalidate_cache()
203
+
204
+ serial_ports.list_resources()
205
+ assert any('pyserial' in msg for msg in warned)
206
+
207
+ def test_list_port_descriptions_returns_list(self):
208
+ assert isinstance(serial_ports.list_port_descriptions(), list)
209
+
210
+ def test_resources_and_descriptions_same_length(self):
211
+ # Both lists are derived from the same cached port objects, so they
212
+ # must always be parallel (index N in one matches index N in the other).
213
+ # Skipped when pyserial is absent: both would be empty and the assertion
214
+ # passes vacuously without testing anything.
215
+ pytest.importorskip('serial')
216
+ assert len(serial_ports.list_resources()) == len(serial_ports.list_port_descriptions())
217
+
218
+ def test_fetch_called_once_across_multiple_functions(self, monkeypatch):
219
+ # Same single-fetch guarantee as the visa module.
220
+ call_count = []
221
+ original_fetch = serial_ports.SerialPortsCache._fetch.__func__
222
+
223
+ @classmethod
224
+ def counting_fetch(cls):
225
+ call_count.append(1)
226
+ return original_fetch(cls)
227
+
228
+ monkeypatch.setattr(serial_ports.SerialPortsCache, '_fetch', counting_fetch)
229
+ serial_ports.SerialPortsCache.invalidate_cache()
230
+
231
+ serial_ports.list_resources()
232
+ serial_ports.list_port_descriptions()
233
+
234
+ assert len(call_count) == 1
235
+
236
+
237
+ # ---------------------------------------------------------------------------
238
+ # Package-level helper
239
+ # ---------------------------------------------------------------------------
240
+
241
+ def test_invalidate_all_caches_clears_both():
242
+ # Populate both caches first so the assertion is meaningful.
243
+ visa.list_resources()
244
+ serial_ports.list_resources()
245
+
246
+ invalidate_all_caches()
247
+
248
+ assert visa.VisaCache._cache is None
249
+ assert serial_ports.SerialPortsCache._cache is None