From 02870f6a25d59b7feb9d2bc7c16fedf62ed41e78 Mon Sep 17 00:00:00 2001 From: CSSFrancis Date: Thu, 2 Apr 2026 13:59:59 -0500 Subject: [PATCH 1/5] Improve camera handling and response mapping in server. Get Tests passing again. --- deapi/client.py | 38 ++++++++++----------- deapi/simulated_server/fake_server.py | 8 ++--- deapi/simulated_server/initialize_server.py | 13 +++---- 3 files changed, 28 insertions(+), 31 deletions(-) diff --git a/deapi/client.py b/deapi/client.py index 7aed6d1..902ae30 100644 --- a/deapi/client.py +++ b/deapi/client.py @@ -185,7 +185,7 @@ def connect(self, host: str = "127.0.0.1", port: int = 13240, read_only=False): response = self._sendCommand(command) if response != False: self.camera = self.__getParameters(response.acknowledge[0])[0] - + if logLevel == logging.DEBUG: log.debug("Camera: %s", self.camera) @@ -284,7 +284,7 @@ def get_virtual_mask(self, index): _, ) = self.get_result(mask_name, DataType.DE8u, attributes=a) return res - + def get_current_camera(self) -> str: """ Get the current camera on the server. @@ -293,7 +293,7 @@ def get_current_camera(self) -> str: return "No current camera" else: return self.camera - + @write_only def set_current_camera(self, camera_name: str = None): """ @@ -342,7 +342,7 @@ def list_registers(self, options=None, search=None): if search is not None: available_registers = [p for p in available_registers if search in p] return available_registers - + @deprecated_argument( name="propertyName", since="5.2.0", alternative="property_name" ) @@ -510,7 +510,7 @@ def get_property(self, property_name: str): ) return ret - + def get_register(self, register_name: str): """ Get the value of a register of the camera on DE-Server @@ -641,7 +641,7 @@ def set_property_and_get_changed_properties(self, name, value, changed_propertie ) return ret - + @write_only def set_register(self, name: str, value): @@ -2488,14 +2488,14 @@ def ensure_get_event_supported(self): """ if not sys.platform.startswith("win"): raise NotImplementedError("get_event functionality is only available on Windows platforms.") - + def enable_get_event(self): """ Enable event retrieval from the server. - + Creates a Windows semaphore to handle event notifications and enables the getEventEnabled flag. This must be called before get_event() can be used. - + Returns ------- bool @@ -2512,12 +2512,12 @@ def enable_get_event(self): self.sdkEventSemaphore = win32event.CreateSemaphore(None, 0, 999, semaphoreName) else: return False - + self.getEventEnabled = True return True else: return False - + def get_event(self): """ Retrieve the next event from the server. @@ -2555,7 +2555,7 @@ def get_event(self): with self.eventMutex: if not self.connected or not self.getEventEnabled: return [] - + command = self._addSingleCommand(self.GET_EVENT, None, None) response = self._sendCommand(command) @@ -2570,15 +2570,15 @@ def get_event(self): eventSpec.append(values[4]) return eventSpec - + def disable_get_event(self): """ Disable event retrieval from the server. - + Releases and closes the Windows semaphore used for event notification, and disables the getEventEnabled flag. After calling this, get_event() will no longer function. - + Returns ------- bool @@ -2598,7 +2598,7 @@ def disable_get_event(self): return True else: return False - + def is_get_event_enabled(self): """ Check if event retrieval from the server is currently enabled. @@ -2691,7 +2691,7 @@ def _sendCommand(self, command: pb.DEPacket = None): if command is None: return False - + if len(command.camera_name) == 0: command.camera_name = ( self.camera @@ -2898,7 +2898,7 @@ def ParseChangedProperties(self, changedProperties, response): GetProperty = get_property SetProperty = set_property SetPropertyAndGetChangedProperties = set_property_and_get_changed_properties - GetRegister = get_register + GetRegister = get_register SetRegister = set_register ListRegisters = list_registers setEngMode = set_engineering_mode @@ -2983,7 +2983,7 @@ def ParseChangedProperties(self, changedProperties, response): DISABLE_GET_EVENT = 37 GET_REGISTER = 38 SET_REGISTER = 39 - LIST_REGISTERS = 40 + LIST_REGISTERS = 40 MMF_DATA_HEADER_SIZE = 24 diff --git a/deapi/simulated_server/fake_server.py b/deapi/simulated_server/fake_server.py index dbf0bc5..c2e7b14 100644 --- a/deapi/simulated_server/fake_server.py +++ b/deapi/simulated_server/fake_server.py @@ -19,15 +19,15 @@ def add_parameter(ack, value): Add a parameter to a protobuffer """ param = ack.parameter.add() - if isinstance(value, str): + if isinstance(value, bool): # must be before int — bool is a subclass of int + param.type = pb.AnyParameter.P_BOOL + param.p_bool = value + elif isinstance(value, str): param.type = pb.AnyParameter.P_STRING param.p_string = value elif isinstance(value, int): param.type = pb.AnyParameter.P_INT param.p_int = value - elif isinstance(value, bool): - param.type = pb.AnyParameter.P_BOOL - param.p_bool = value elif isinstance(value, float): param.type = pb.AnyParameter.P_FLOAT param.p_float = value diff --git a/deapi/simulated_server/initialize_server.py b/deapi/simulated_server/initialize_server.py index 8194857..e903fd3 100644 --- a/deapi/simulated_server/initialize_server.py +++ b/deapi/simulated_server/initialize_server.py @@ -12,20 +12,17 @@ def main(port=13240): parser = argparse.ArgumentParser() parser.add_argument("--port", type=int, help="Port to listen on") - try: - args = parser.parse_args() - if args.port: - port = args.port - except: - pass + args, _ = parser.parse_known_args() + if args.port: + port = args.port HOST = "127.0.0.1" # Standard loopback interface address (localhost) PORT = port # Port to listen on (non-privileged ports are > 1023) with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket: server_socket.bind((HOST, PORT)) server_socket.listen() - sys.stderr.write("started .... \n\n") - sys.stderr.flush() + sys.stdout.write("started .... \n\n") + sys.stdout.flush() sys.stderr.write( "Waiting for a Connection to: \n" f" Host: {HOST}\n" From 158435236d0fb17717fd072516202521586d38f6 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Thu, 2 Apr 2026 21:50:54 -0500 Subject: [PATCH 2/5] BugFix: Enhance error handling and improve code clarity in server and client interactions --- deapi/data_types.py | 5 +++++ deapi/simulated_server/fake_server.py | 3 +++ deapi/simulated_server/initialize_server.py | 5 +++-- deapi/tests/original_tests/test_legacy.py | 3 ++- deapi/tests/test_client.py | 2 +- deapi/tests/test_fake_server/test_server.py | 3 ++- .../test_file_saving/test_scan_pattern_saving.py | 12 ++++++------ 7 files changed, 22 insertions(+), 11 deletions(-) diff --git a/deapi/data_types.py b/deapi/data_types.py index 876eb6a..ba34f21 100644 --- a/deapi/data_types.py +++ b/deapi/data_types.py @@ -698,6 +698,11 @@ def __init__(self, client, index): def __getitem__(self, item): full_img = self.client.get_virtual_mask(self.index) + if full_img is None: + raise RuntimeError( + f"get_virtual_mask({self.index}) returned None — " + "the server may have dropped the connection or encountered an error." + ) return full_img[item] def __setitem__(self, key, value): diff --git a/deapi/simulated_server/fake_server.py b/deapi/simulated_server/fake_server.py index c2e7b14..f6460b4 100644 --- a/deapi/simulated_server/fake_server.py +++ b/deapi/simulated_server/fake_server.py @@ -714,6 +714,9 @@ def _fake_get_result(self, command): if histo_min == 0 and histo_max == 0: histo_min = np.min(image) histo_max = np.max(image) + # np.histogram requires max > min; pad when image is uniform + if histo_min == histo_max: + histo_max = histo_min + 1 image_hist, bins = np.histogram( image.flatten(), bins=histo_bins, range=(histo_min, histo_max) ) diff --git a/deapi/simulated_server/initialize_server.py b/deapi/simulated_server/initialize_server.py index e903fd3..412953e 100644 --- a/deapi/simulated_server/initialize_server.py +++ b/deapi/simulated_server/initialize_server.py @@ -1,10 +1,10 @@ import sys +import traceback from deapi.simulated_server.fake_server import FakeServer import socket import struct from deapi.buffer_protocols import pb -import sys import argparse @@ -50,7 +50,8 @@ def main(port=13240): conn.send(packet) else: conn.sendall(r) - except: + except Exception: + traceback.print_exc(file=sys.stderr) connected = False diff --git a/deapi/tests/original_tests/test_legacy.py b/deapi/tests/original_tests/test_legacy.py index 0fa5091..ef8f461 100644 --- a/deapi/tests/original_tests/test_legacy.py +++ b/deapi/tests/original_tests/test_legacy.py @@ -125,5 +125,6 @@ def test_set_mask(self, client): deClient.SetVirtualMask(maskID, 1024, 1024, mask) # Generate and check the first image - mask = deClient.virtual_masks[1][:] + # virtual_masks uses 0-based indexing where index 0 maps to mask ID 1 + mask = deClient.virtual_masks[0][:] assert mask.shape == (1024, 1024) diff --git a/deapi/tests/test_client.py b/deapi/tests/test_client.py index b120744..9b194e9 100644 --- a/deapi/tests/test_client.py +++ b/deapi/tests/test_client.py @@ -247,7 +247,7 @@ def test_image_size(self, client, bin, offsetx, size, bin_sw): def test_stream_data(self, client): client["Frames Per Second"] = 5 client.scan(size_x=10, size_y=10, enable="On") - client.start_acquisition(1, requestMovieBuffer=True) + client.start_acquisition(1, request_movie_buffer=True) numberFrames = 0 index = 0 status = MovieBufferStatus.OK diff --git a/deapi/tests/test_fake_server/test_server.py b/deapi/tests/test_fake_server/test_server.py index a716b61..f8f495a 100644 --- a/deapi/tests/test_fake_server/test_server.py +++ b/deapi/tests/test_fake_server/test_server.py @@ -28,7 +28,8 @@ def test_get_linked_properties(self, fake_server): def test_set_virtual_image_calculation(self, fake_server): assert fake_server["Scan - Virtual Detector 1 Calculation"] == "Sum" - fake_server["Scan - Virtual Detector 1 Calculation"] = "Susd" + with pytest.warns(UserWarning, match="not in options"): + fake_server["Scan - Virtual Detector 1 Calculation"] = "Susd" assert fake_server["Scan - Virtual Detector 1 Calculation"] == "Sum" def test_server_software_version(self, fake_server): diff --git a/deapi/tests/test_file_saving/test_scan_pattern_saving.py b/deapi/tests/test_file_saving/test_scan_pattern_saving.py index 85ae3c5..5cfe855 100644 --- a/deapi/tests/test_file_saving/test_scan_pattern_saving.py +++ b/deapi/tests/test_file_saving/test_scan_pattern_saving.py @@ -22,9 +22,9 @@ def test_save_scans(self, client, scan_type, buffer, file_format): i = 16 num_pos = i * i - if not os.path.exists("D:\Temp"): - os.mkdir("D:\Temp") - temp_dir = "D:\Temp" + if not os.path.exists(r"D:\Temp"): + os.mkdir(r"D:\Temp") + temp_dir = r"D:\Temp" if scan_type == "Serpentine": frame_num_order = np.arange(num_pos) @@ -91,9 +91,9 @@ class TestSavingVirtual: def test_save_scans(self, client, scan_type, buffer): i = 8 num_pos = i * i - if not os.path.exists("D:\Temp"): - os.mkdir("D:\Temp") - temp_dir = "D:\Temp" + if not os.path.exists(r"D:\Temp"): + os.mkdir(r"D:\Temp") + temp_dir = r"D:\Temp" if scan_type == "Serpentine": frame_num_order = np.arange(num_pos) From ea2d15e231e6b57f465500d64e4f724ed4edb3da Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 3 Apr 2026 12:52:49 +0000 Subject: [PATCH 3/5] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- deapi/client.py | 28 +++++++++++++------ .../original_tests/10_imageStatistics.py | 27 ++++++++++++++---- deapi/version.py | 1 - 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/deapi/client.py b/deapi/client.py index 902ae30..3ca438b 100644 --- a/deapi/client.py +++ b/deapi/client.py @@ -57,6 +57,7 @@ logging.basicConfig(format="%(asctime)s DE %(levelname)-8s %(message)s", level=logLevel) log = logging.getLogger("DECameraClientLib") + def print_info(): log.info(f"DEAPI Version: {version} (Command Version: {commandVersion})") log.info("Python : " + sys.version.split("(")[0]) @@ -64,6 +65,7 @@ def print_info(): log.info("CommandVer: " + str(commandVersion)) log.info("logLevel : " + str(logging.getLevelName(logLevel))) + class Client: """A class for connecting to the DE-Server @@ -642,7 +644,6 @@ def set_property_and_get_changed_properties(self, name, value, changed_propertie return ret - @write_only def set_register(self, name: str, value): """ @@ -676,7 +677,6 @@ def set_register(self, name: str, value): return ret - @write_only def set_engineering_mode(self, enable, password): """ @@ -1002,8 +1002,12 @@ def set_binning(self, bin_x, bin_y, use_hw=True): if commandVersion >= 13: retval = self.SetProperty("Server Normalize Properties", "Off") - retval &= self.SetProperty("Hardware Binning X", 2 if bin_x >= 2 and use_hw else 1) - retval &= self.SetProperty("Hardware Binning Y", 2 if bin_y >= 2 and use_hw else 1) + retval &= self.SetProperty( + "Hardware Binning X", 2 if bin_x >= 2 and use_hw else 1 + ) + retval &= self.SetProperty( + "Hardware Binning Y", 2 if bin_y >= 2 and use_hw else 1 + ) prop_hw_bin_x = self.GetProperty("Hardware Binning X") prop_hw_bin_y = self.GetProperty("Hardware Binning Y") @@ -1895,7 +1899,9 @@ def get_movie_buffer( f"expected: {totalBytes}, received: {movieBufferSize}" ) else: - log.info(f"reading movie buffer {totalBytes}", ) + log.info( + f"reading movie buffer {totalBytes}", + ) movieBuffer = self._recvFromSocket(self.socket, totalBytes) log.info("Done reading movie buffer") else: @@ -2381,7 +2387,9 @@ def take_trial_gain_reference( self.SetProperty("Exposure Time (seconds)", prevExposureTime) num_el = np.max([attr.eppixpf * frame_rate, attr.eppixps]) - log.info(f"The number of electrons per pixel per second (eppixps): {num_el:.2f}") + log.info( + f"The number of electrons per pixel per second (eppixps): {num_el:.2f}" + ) if attr.saturation > 0.0001: # Nothing should be saturated in a gain image. raise ValueError( @@ -2487,7 +2495,9 @@ def ensure_get_event_supported(self): If called on a non-Windows platform. """ if not sys.platform.startswith("win"): - raise NotImplementedError("get_event functionality is only available on Windows platforms.") + raise NotImplementedError( + "get_event functionality is only available on Windows platforms." + ) def enable_get_event(self): """ @@ -2509,7 +2519,9 @@ def enable_get_event(self): if response != False: semaphoreName = self.__getParameters(response.acknowledge[0])[0] if semaphoreName is not None: - self.sdkEventSemaphore = win32event.CreateSemaphore(None, 0, 999, semaphoreName) + self.sdkEventSemaphore = win32event.CreateSemaphore( + None, 0, 999, semaphoreName + ) else: return False diff --git a/deapi/tests/original_tests/10_imageStatistics.py b/deapi/tests/original_tests/10_imageStatistics.py index 53dca2e..f09d1c2 100644 --- a/deapi/tests/original_tests/10_imageStatistics.py +++ b/deapi/tests/original_tests/10_imageStatistics.py @@ -163,9 +163,18 @@ def statisticsValueCheck(imageProcessingMode, correctionMode): print(stats.eppix) print(stats.epa2) # precision issue, we will compare the float number with a tolerance value. The tolerance value is set to 10**-numPrecision * frameCount for e-/pix, 10**-numPrecision * fps for e-/pix/s and 10**-numPrecision * fps * numPhysicalPixels for e-/s. - ret &= math.isclose(stats.eppix, attributes.eppix, rel_tol=0, abs_tol=10**-numPrecision * frameCount) - ret &= math.isclose(stats.eppixps, attributes.eppixps, rel_tol=0, abs_tol=10**-numPrecision * fps) - ret &= math.isclose(stats.eps, attributes.eps, rel_tol=0, abs_tol=10**-numPrecision * fps * numPhysicalPixels) + ret &= math.isclose( + stats.eppix, attributes.eppix, rel_tol=0, abs_tol=10**-numPrecision * frameCount + ) + ret &= math.isclose( + stats.eppixps, attributes.eppixps, rel_tol=0, abs_tol=10**-numPrecision * fps + ) + ret &= math.isclose( + stats.eps, + attributes.eps, + rel_tol=0, + abs_tol=10**-numPrecision * fps * numPhysicalPixels, + ) ret &= func.compare2FloatValue(stats.epa2, attributes.epa2, numPrecision, "e-/a^2") return ret @@ -212,10 +221,16 @@ def compareBin1Bin2(imageProcessingMode, correctionMode, swBinningFactor): swBinY, ) - ret &= func.compare2FloatValue(statsBin1.eppix, statsBin2.eppix, numPrecision, "e-/pix") - ret &= func.compare2FloatValue(statsBin1.eppixps, statsBin2.eppixps, numPrecision, "e-/pix/s") + ret &= func.compare2FloatValue( + statsBin1.eppix, statsBin2.eppix, numPrecision, "e-/pix" + ) + ret &= func.compare2FloatValue( + statsBin1.eppixps, statsBin2.eppixps, numPrecision, "e-/pix/s" + ) ret &= func.compare2FloatValue(statsBin1.eps, statsBin2.eps, numPrecision, "e-/s") - ret &= func.compare2FloatValue(statsBin1.epa2, statsBin2.epa2, numPrecision, "e-/a^2") + ret &= func.compare2FloatValue( + statsBin1.epa2, statsBin2.epa2, numPrecision, "e-/a^2" + ) return ret diff --git a/deapi/version.py b/deapi/version.py index 40941eb..95ed8fb 100644 --- a/deapi/version.py +++ b/deapi/version.py @@ -1,4 +1,3 @@ version = "5.3.0" versionInfo = list(map(int, version.split("."))) commandVersion = (versionInfo[0] - 4) * 10 + versionInfo[1] + 2 - From b89a3a57789312974a9b748774f7474c64800465 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 3 Apr 2026 08:04:21 -0500 Subject: [PATCH 4/5] Update Python version matrix in build configuration to include 3.13 and 3.14 --- .github/workflows/build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 1424c87..6093a8b 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -17,7 +17,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.11", "3.12"] + python-version: ["3.12", "3.13", "3.14"] include: - os: ubuntu-latest python-version: 3.11 From b09aa0261de22007b7aafd6b5877a276e4ff7d96 Mon Sep 17 00:00:00 2001 From: Carter Francis Date: Fri, 3 Apr 2026 08:14:31 -0500 Subject: [PATCH 5/5] Enhance socket handling in client and server --- deapi/client.py | 15 ++++++++--- deapi/simulated_server/fake_server.py | 10 +++++-- deapi/simulated_server/initialize_server.py | 29 ++++++++++++++++++--- deapi/tests/conftest.py | 2 +- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/deapi/client.py b/deapi/client.py index 3ca438b..94447f8 100644 --- a/deapi/client.py +++ b/deapi/client.py @@ -2807,7 +2807,17 @@ def _recvFromSocket(self, sock, bytes): packet_size = upper_lim loopTime = self.GetTime() try: - buffer += sock.recv(packet_size) + chunk = sock.recv(packet_size) + if not chunk: + # recv() returns b'' when the remote end has closed the + # connection. Without this check the loop would spin + # forever because b'' never raises an exception and never + # advances total_len — the primary hang on macOS / Py 3.11+. + raise ConnectionResetError( + f"Server closed the connection after {total_len} " + f"of {bytes} expected bytes" + ) + buffer += chunk except socket.timeout: log.debug( @@ -2821,8 +2831,7 @@ def _recvFromSocket(self, sock, bytes): else: pass # continue further except socket.error as e: - raise e("Error receiving %d bytes: %s", bytes, e) - break + raise ConnectionResetError(f"Error receiving {bytes} bytes: {e}") from e total_len = len(buffer) totalTimeMs = (self.GetTime() - startTime) * 1000 diff --git a/deapi/simulated_server/fake_server.py b/deapi/simulated_server/fake_server.py index f6460b4..abda011 100644 --- a/deapi/simulated_server/fake_server.py +++ b/deapi/simulated_server/fake_server.py @@ -1,3 +1,4 @@ +import socket import time import warnings @@ -346,9 +347,14 @@ def _fake_set_virtual_mask(self, command): if total_len < total_bytes: while total_len < total_bytes: try: - buffer += self.socket.recv(total_bytes) + chunk = self.socket.recv(total_bytes - total_len) + if not chunk: + raise ConnectionResetError( + "Connection closed while reading virtual mask" + ) + buffer += chunk total_len = len(buffer) - except self.socket.timeout: + except socket.timeout: raise ValueError("Socket timed out") buffer = buffer mask = np.frombuffer(buffer, dtype=np.int8).reshape((w, h)) diff --git a/deapi/simulated_server/initialize_server.py b/deapi/simulated_server/initialize_server.py index 412953e..2483c17 100644 --- a/deapi/simulated_server/initialize_server.py +++ b/deapi/simulated_server/initialize_server.py @@ -8,6 +8,26 @@ import argparse +def _recv_exact(conn, n): + """Read exactly *n* bytes from *conn*, looping over partial reads. + + Returns the complete byte string, or raises ``ConnectionResetError`` if + the peer closes the connection before all bytes arrive. This is necessary + because TCP is a stream protocol: a single ``recv(n)`` call may legally + return anywhere from 1 to n bytes, and on macOS the loopback interface + fragments packets far more aggressively than Linux does. + """ + buf = b"" + while len(buf) < n: + chunk = conn.recv(n - len(buf)) + if not chunk: + raise ConnectionResetError( + f"Connection closed after {len(buf)} of {n} expected bytes" + ) + buf += chunk + return buf + + # Defining main function def main(port=13240): parser = argparse.ArgumentParser() @@ -19,6 +39,9 @@ def main(port=13240): HOST = "127.0.0.1" # Standard loopback interface address (localhost) PORT = port # Port to listen on (non-privileged ports are > 1023) with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket: + # Allow the port to be reused immediately after the process exits + # (avoids "Address already in use" / TIME_WAIT failures between test runs) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server_socket.bind((HOST, PORT)) server_socket.listen() sys.stdout.write("started .... \n\n") @@ -35,9 +58,9 @@ def main(port=13240): connected = True while connected: try: - totallen = conn.recv(4) + totallen = _recv_exact(conn, 4) totallenRecv = struct.unpack("I", totallen)[0] - message = conn.recv(totallenRecv) + message = _recv_exact(conn, totallenRecv) message_packet = pb.DEPacket() message_packet.ParseFromString(message) response = server._respond_to_command(message_packet) @@ -47,7 +70,7 @@ def main(port=13240): packet = ( struct.pack("I", r.ByteSize()) + r.SerializeToString() ) - conn.send(packet) + conn.sendall(packet) else: conn.sendall(r) except Exception: diff --git a/deapi/tests/conftest.py b/deapi/tests/conftest.py index 9c782bd..c057170 100644 --- a/deapi/tests/conftest.py +++ b/deapi/tests/conftest.py @@ -111,7 +111,7 @@ def server(xprocess, request): curdir = pathlib.Path(__file__).parent.parent class Starter(ProcessStarter): - timeout = 10 + timeout = 60 pattern = "started" args = [ sys.executable,