BioImager  3.9.1
A .NET microscopy imaging library. Supports various microscopes by using imported libraries & GUI automation. Supported libraries include PriorĀ® & ZeissĀ® & all devices supported by Micromanager 2.0 and python-microscope.
Loading...
Searching...
No Matches
test_device_server.py
1#!/usr/bin/env python3
2
3## Copyright (C) 2020 David Miguel Susano Pinto <carandraug@gmail.com>
4##
5## This file is part of Microscope.
6##
7## Microscope is free software: you can redistribute it and/or modify
8## it under the terms of the GNU General Public License as published by
9## the Free Software Foundation, either version 3 of the License, or
10## (at your option) any later version.
11##
12## Microscope is distributed in the hope that it will be useful,
13## but WITHOUT ANY WARRANTY; without even the implied warranty of
14## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15## GNU General Public License for more details.
16##
17## You should have received a copy of the GNU General Public License
18## along with Microscope. If not, see <http://www.gnu.org/licenses/>.
19
20import logging
21import multiprocessing
22import os
23import os.path
24import signal
25import tempfile
26import time
27import unittest
28import unittest.mock
29
30import Pyro4
31import Pyro4.errors
32
33import microscope.abc
37 TestCamera,
38 TestDeformableMirror,
39 TestFilterWheel,
40 TestFloatingDevice,
41)
42
43
45 """Test device for testing the device server keep alive."""
46
47 def _do_shutdown(self) -> None:
48 pass
49
50 def get_pid(self) -> int:
51 return os.getpid()
52
53
55 """`DeviceServer` that queues an exception during `run`.
56
57 A `DeviceServer` instance runs on another process so if it fails
58 we can't easily check why. This subclass will put any exception
59 that happens during `run()` into the given queue so that the
60 parent process can check it.
61
62 """
63
64 def __init__(self, queue: multiprocessing.Queue, *args, **kwargs) -> None:
65 super().__init__(*args, **kwargs)
66 self._queue = queue
67
68 def run(self):
69 try:
70 super().run()
71 except Exception as ex:
72 self._queue.put(ex)
73
74
75def _patch_out_device_server_logs(func):
76 """Decorator to run device server without noise from logs.
77
78 The device server redirects the logger to stderr *and* creates
79 files on the current directory. There is no options to control
80 this behaviour so this patches the loggers.
81 """
82
83 def null_logs(*args, **kwargs):
84 return logging.NullHandler()
85
86 no_file = unittest.mock.patch(
87 "microscope.device_server.RotatingFileHandler", null_logs
88 )
89 no_stream = unittest.mock.patch(
90 "microscope.device_server.StreamHandler", null_logs
91 )
92 return no_file(no_stream(func))
93
94
95class BaseTestServeDevices(unittest.TestCase):
96 """Handles start and termination of deviceserver.
97
98 Subclasses may overload class properties defaults as needed.
99
100 Attributes:
101 DEVICES (list): list of :class:`microscope.devices` to initialise.
102 TIMEOUT (number): time given for service to terminate after
103 receiving signal to terminate.
104 p (multiprocessing.Process): device server process.
105 """
106
107 DEVICES = []
108 TIMEOUT = 5
109
110 @_patch_out_device_server_logs
111 def setUp(self):
113 config_fpath="",
114 logging_level=logging.INFO,
115 )
116 self.p = multiprocessing.Process(
117 target=microscope.device_server.serve_devices,
118 args=(self.DEVICES, options),
119 )
120 self.p.start()
121 time.sleep(1)
122
123 def tearDown(self):
124 self.p.terminate()
125 self.p.join(self.TIMEOUT)
126 self.assertFalse(
127 self.p.is_alive(), "deviceserver not dead after SIGTERM"
128 )
129
130
131class BaseTestDeviceServer(unittest.TestCase):
132 """TestCase that starts DeviceServer on separate process.
133
134 Subclasses should define the class attribute `args`, which is used
135 to start the `DeviceServer` and implement `test_*` methods.
136
137 """
138
139 args = [] # args to construct DeviceServer
140 TIMEOUT = 5 # time to wait after join() during tearDown
141
142 @_patch_out_device_server_logs
143 def setUp(self):
144 self.queue = multiprocessing.Queue()
145 self.process = DeviceServerExceptionQueue(self.queue, *self.args)
146 self.process.start()
147 time.sleep(1)
148
149 def tearDown(self):
150 self.process.terminate()
151 self.process.join(self.TIMEOUT)
152 self.assertIsNotNone(
153 self.process.exitcode, "deviceserver not dead after SIGTERM"
154 )
155
156
158 DEVICES = [
160 TestCamera, "127.0.0.1", 8001, {"buffer_length": 0}
161 ),
163 TestFilterWheel, "127.0.0.1", 8003, {"positions": 3}
164 ),
165 ]
166
167 def test_standard(self):
168 """Simplest case, start and exit, given enough time to start all devices"""
169 self.assertTrue(self.p.is_alive(), "service dies at start")
170
172 """Check issues on SIGTERM before starting all devices"""
173 pass
174
175
176class TestInputCheck(BaseTestServeDevices):
178 """Check behaviour if there are no devices."""
179 self.assertTrue(
180 not self.p.is_alive(), "not dying for empty list of devices"
181 )
182
183
185 def __init__(self, port, **kwargs):
186 super().__init__(**kwargs)
187 self._port = port
188
189 @property
190 def port(self):
191 return self._port
192
193 def _do_shutdown(self):
194 pass
195
196
197class TestClashingArguments(BaseTestServeDevices):
198 """Device server and device constructor arguments do not clash"""
199
200 DEVICES = [
202 DeviceWithPort, "127.0.0.1", 8000, {"port": 7000}
203 ),
204 ]
205
206 def test_port_conflict(self):
208 "PYRO:DeviceWithPort@127.0.0.1:8000"
209 )
210 self.assertEqual(client.port, 7000)
211
212
213class TestConfigLoader(unittest.TestCase):
214 def _test_load_source(self, filename):
215 file_contents = "DEVICES = [1,2,3]"
216 with tempfile.TemporaryDirectory() as dirpath:
217 filepath = os.path.join(dirpath, filename)
218 with open(filepath, "w") as fh:
219 fh.write(file_contents)
220
222 self.assertEqual(module.DEVICES, [1, 2, 3])
223
225 """Reading of config file module-like works"""
226 self._test_load_source("foobar.py")
227
229 """Reading of config file does not require .py file extension"""
230 # Test for issue #151. Many importlib functions assume that
231 # the file has importlib.machinery.SOURCE_SUFFIXES extension
232 # so we need a bit of extra work to work with none or .cfg.
233 self._test_load_source("foobar.cfg")
234
236 """Reading of config file does not require file extension"""
237 self._test_load_source("foobar")
238
239
241 DEVICES = [
243 TestFloatingDevice, "127.0.0.1", 8001, {"uid": "foo"}, uid="foo"
244 ),
246 TestFloatingDevice, "127.0.0.1", 8002, {"uid": "bar"}, uid="bar"
247 ),
248 ]
249
250 def test_injection_of_index_kwarg(self):
251 floating_1 = Pyro4.Proxy("PYRO:TestFloatingDevice@127.0.0.1:8001")
252 floating_2 = Pyro4.Proxy("PYRO:TestFloatingDevice@127.0.0.1:8002")
253 self.assertEqual(floating_1.get_index(), 0)
254 self.assertEqual(floating_2.get_index(), 1)
255
256
258 # This test will create a floating device with a UID different
259 # (foo) than what appears on the config (bar). This is what
260 # happens if there are two floating devices on the system (foo and
261 # bar) but the config lists only one of them (bar) but the other
262 # one is served instead (foo). See issue #153.
263 args = [
265 TestFloatingDevice,
266 "127.0.0.1",
267 8001,
268 # The index kwarg is typically injected by serve_devices
269 # but here we're only testing DeviceServer so we need to
270 # do it ourselves.
271 {"uid": "foo", "index": 0},
272 uid="bar",
273 ),
275 config_fpath="",
276 logging_level=logging.INFO,
277 ),
278 {"bar": "127.0.0.1"},
279 {"bar": 8001},
280 multiprocessing.Event(),
281 ]
282
283 def test_fail_with_wrong_uid(self):
284 """DeviceServer fails if it gets a FloatingDevice with another UID"""
285 self.assertFalse(
286 self.process.is_alive(),
287 "expected DeviceServer to have errored and be dead",
288 )
289 self.assertRegex(
290 str(self.queue.get_nowait()),
291 "Host or port not found for device foo",
292 )
293
294
296 # Test that with a function we can specify multiple devices and
297 # they get the expected Pyro URI.
298 args = [
300 lambda **kwargs: {
301 "dm1": TestDeformableMirror(10),
302 "dm2": TestDeformableMirror(20),
303 },
304 "localhost",
305 8001,
306 ),
308 config_fpath="",
309 logging_level=logging.INFO,
310 ),
311 {},
312 {},
313 multiprocessing.Event(),
314 ]
315
317 """Function that constructs multiple devices in device definition"""
318 self.assertTrue(self.process.is_alive())
319 dm1 = Pyro4.Proxy("PYRO:dm1@127.0.0.1:8001")
320 dm2 = Pyro4.Proxy("PYRO:dm2@127.0.0.1:8001")
321 self.assertEqual(dm1.n_actuators, 10)
322 self.assertEqual(dm2.n_actuators, 20)
323
324
326 DEVICES = [
328 ExposePIDDevice, "127.0.0.1", 8001, {}
329 ),
330 ]
331
332 @unittest.skipUnless(
333 hasattr(signal, "SIGKILL"),
334 "can't test if we can't kill subprocess (windows)",
335 )
336 def test_keep_alive(self):
337 device = Pyro4.Proxy("PYRO:ExposePIDDevice@127.0.0.1:8001")
338 initial_pid = device.get_pid()
339
340 os.kill(initial_pid, signal.SIGKILL)
341
342 with self.assertRaises(Pyro4.errors.ConnectionClosedError):
343 device.get_pid()
344
345 # The device server checks every 5 seconds for a crashed
346 # device server so give it 6 seconds.
347 time.sleep(6)
348
349 device._pyroReconnect(tries=1)
350 new_pid = device.get_pid()
351 self.assertNotEqual(initial_pid, new_pid)
352
353
354if __name__ == "__main__":
355 unittest.main()
def device(typing.Callable cls, str host, int port, typing.Mapping[str, typing.Any] conf={}, typing.Optional[str] uid=None)
def _load_source(filepath)