From c466bd6ab22e0e5f9aec43b8765612f24af2ae2d Mon Sep 17 00:00:00 2001 From: hfitzp200_comcast Date: Wed, 18 Mar 2026 15:49:50 -0400 Subject: [PATCH 1/8] Making big modules 'optional' for Docker Optionality for Docker Potential fix for pull request finding Agreed Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Improve compatibility and add console enable/disable support - Add Python 3.13+ compatibility shim for removed telnetlib module - Use flushInput() instead of reset_input_buffer() in serialClass for broader pyserial compatibility - Support 'enabled: false' in console config to skip disabled consoles, with automatic fallback to the first enabled console - Make capture (cv2/pytesseract) and webpageController (selenium) imports optional to support minimal environments (e.g. Docker) - Add UTF-8 encoding to log file handlers - Fix logger cleanup to use self.log.handlers instead of hasHandlers() ModuleNotFoundError is finer grain --- framework/core/__init__.py | 22 +++++- framework/core/commandModules/serialClass.py | 2 +- framework/core/commandModules/telnetClass.py | 75 +++++++++++++++++++- framework/core/deviceManager.py | 21 ++++-- framework/core/logModule.py | 6 +- framework/core/testControl.py | 49 +++++++++++-- 6 files changed, 157 insertions(+), 18 deletions(-) diff --git a/framework/core/__init__.py b/framework/core/__init__.py index 2e44f9d..70b4e63 100644 --- a/framework/core/__init__.py +++ b/framework/core/__init__.py @@ -32,13 +32,31 @@ #__all__ = ["testControl", "logModule", "rcCodes", "rackController", "slotInfo" ] #expose only the components required for the upper classes to operate -from . capture import capture +try: + from . capture import capture +except ModuleNotFoundError as e: + # Only treat known optional image-processing dependencies as optional. + _optional_capture_deps = {"cv2", "pytesseract", "PIL"} + if e.name in _optional_capture_deps: + class _CaptureMissingDependency: + def __call__(self, *args, **kwargs): + raise ImportError( + "Image capture functionality is unavailable because optional " + "dependencies (cv2, pytesseract, PIL) are not installed." + ) from e + capture = _CaptureMissingDependency() + else: + # Unexpected missing module (e.g., bug inside capture.py) - do not hide it. + raise from . commonRemote import commonRemoteClass from . deviceManager import deviceManager from . logModule import logModule from . logModule import DEBUG, INFO, WARNING, ERROR, CRITICAL from . testControl import testController -from . webpageController import webpageController +try: + from . webpageController import webpageController +except ModuleNotFoundError: + webpageController = None # selenium not installed (e.g. Docker) from . rcCodes import rcCode as rc from . utilities import utilities diff --git a/framework/core/commandModules/serialClass.py b/framework/core/commandModules/serialClass.py index 7c617fd..30fbda3 100644 --- a/framework/core/commandModules/serialClass.py +++ b/framework/core/commandModules/serialClass.py @@ -200,5 +200,5 @@ def flush(self) -> bool: True if can successfully clear the serial console. """ self.log.info("Clearing Serial console log") - self.serialCon.reset_input_buffer() + self.serialCon.flushInput() return True diff --git a/framework/core/commandModules/telnetClass.py b/framework/core/commandModules/telnetClass.py index f163386..7aa0a85 100644 --- a/framework/core/commandModules/telnetClass.py +++ b/framework/core/commandModules/telnetClass.py @@ -30,7 +30,76 @@ #* #* ****************************************************************************** -import telnetlib +try: + import telnetlib +except ModuleNotFoundError: + # telnetlib was removed in Python 3.13, provide a minimal compatibility layer + import socket + import time + + class Telnet: + def __init__(self, host=None, port=23, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): + self.host = host + self.port = port + self.timeout = timeout + self.sock = None + if host: + self.open(host, port, timeout) + + def open(self, host, port=23, timeout=socket._GLOBAL_DEFAULT_TIMEOUT): + self.host = host + self.port = port + self.sock = socket.create_connection((host, port), timeout) + + def close(self): + if self.sock: + self.sock.close() + self.sock = None + + def write(self, buffer): + if self.sock: + self.sock.send(buffer) + + def read_until(self, match, timeout=None): + if not self.sock: + return b'' + + buffer = b'' + start_time = time.time() + while True: + if timeout and (time.time() - start_time) > timeout: + break + try: + data = self.sock.recv(1024) + if not data: + break + buffer += data + if match in buffer: + break + except socket.timeout: + break + except Exception: + break + return buffer + + def read_all(self): + if not self.sock: + return b'' + buffer = b'' + try: + while True: + data = self.sock.recv(1024) + if not data: + break + buffer += data + except Exception: + pass + return buffer + + # Create a mock telnetlib module + class telnetlib: + Telnet = Telnet + import socket from .consoleInterface import consoleInterface @@ -153,7 +222,7 @@ def read_until(self,value: str, timeout: int = 10) -> str: message = value.encode() result = self.tn.read_until(message,self.timeout) return result.decode() - + def read_all(self) -> str: """Read all readily available information displayed in the console. @@ -161,7 +230,7 @@ def read_all(self) -> str: str: Information currently displayed in the console. """ return self.read_eager() - + def write(self,message:list|str, lineFeed:str="\r\n", wait_for_prompt:bool=False) -> bool: """Write a message into the session console. Optional: waits for prompt. diff --git a/framework/core/deviceManager.py b/framework/core/deviceManager.py index 5412183..cadf2a1 100644 --- a/framework/core/deviceManager.py +++ b/framework/core/deviceManager.py @@ -67,6 +67,11 @@ def __init__(self, log:logModule, logPath:str, configElements:dict): """ for element in configElements: config = configElements.get(element) + # Respect 'enabled: false' in console config — skip disabled consoles + if config.get("enabled") is False: + self.type = None + self.session = None + return self.type = config.get("type") self.prompt = config.get("prompt") # Create a new console since it hasn't been created @@ -172,7 +177,9 @@ def __init__(self, log:logModule, logPath:str, devices:dict): consoles = device.get("consoles") for element in consoles: for name in element: - self.consoles[name] = consoleClass(log, logPath, element ) + console = consoleClass(log, logPath, element) + if console.type is not None: # skip disabled consoles + self.consoles[name] = console config = device.get("outbound") if config != None: self.outBoundClient = outboundClientClass(log, **config) @@ -218,9 +225,15 @@ def getConsoleSession(self, consoleName:str="default" ): Returns: consoleClass: Console class, or None on failure """ - console = self.consoles[consoleName] - if console == None: - self.log.error("Invalid consoleName [{}]".format(consoleName)) + console = self.consoles.get(consoleName) + # If requested console was disabled/missing, fall back to first enabled console + if console is None and self.consoles: + fallback = next(iter(self.consoles)) + self.log.info("Console '{}' not available, falling back to '{}'".format(consoleName, fallback)) + console = self.consoles[fallback] + if console is None: + self.log.error("No consoles available (all disabled?)") + return None return console.session def pingTest(self, logPingTime=False): diff --git a/framework/core/logModule.py b/framework/core/logModule.py index d1e76ca..5162e8f 100755 --- a/framework/core/logModule.py +++ b/framework/core/logModule.py @@ -118,7 +118,7 @@ def __init__(self, moduleName, level=INFO): def __del__(self): """Deletes the logger instance. """ - while self.log.hasHandlers(): + while self.log.handlers: self.log.removeHandler(self.log.handlers[0]) def setFilename( self, logPath, logFileName ): @@ -134,11 +134,11 @@ def setFilename( self, logPath, logFileName ): return self.logPath = logPath logFileName = os.path.join(logPath + logFileName) - self.logFile = logging.FileHandler(logFileName) + self.logFile = logging.FileHandler(logFileName, encoding='utf-8') self.logFile.setFormatter( self.format ) self.log.addHandler( self.logFile ) #Create the CSV Logger module - self.csvLogFile = logging.FileHandler( logFileName+".csv" ) + self.csvLogFile = logging.FileHandler( logFileName+".csv", encoding='utf-8' ) self.csvLogger.addHandler( self.csvLogFile ) self.csvLogger.info("QcId, TestName, Result, Failed Step, Failure, Duration [hh:mm:ss]") self.log.info( "Log File: [{}]".format(logFileName) ) diff --git a/framework/core/testControl.py b/framework/core/testControl.py index cbe230b..a436775 100644 --- a/framework/core/testControl.py +++ b/framework/core/testControl.py @@ -33,7 +33,11 @@ import time import signal import random -import telnetlib +try: + import telnetlib +except ModuleNotFoundError: + # telnetlib was removed in Python 3.13, this is handled in telnetClass.py + telnetlib = None import os import shlex, subprocess import traceback @@ -52,8 +56,22 @@ from framework.core.configParser import configParser from framework.core.utilities import utilities from framework.core.decodeParams import decodeParams -from framework.core.capture import capture -from framework.core.webpageController import webpageController +try: + from framework.core.capture import capture +except ModuleNotFoundError as e: + # cv2/pytesseract/PIL are optional dependencies (e.g. in Docker images) + if e.name in ("cv2", "pytesseract", "PIL", "PIL.Image"): + capture = None + else: + raise +try: + from framework.core.webpageController import webpageController +except ModuleNotFoundError as e: + # selenium is an optional dependency (e.g. in Docker images) + if e.name == "selenium": + webpageController = None + else: + raise from framework.core.deviceManager import deviceManager class testController(): @@ -149,9 +167,30 @@ def __init__(self, testName="", qcId="", maxRunTime=TEST_MAX_RUN_TIME, level=log #Start the rest of the testControl requirements self.devices = deviceManager(self.slotInfo.config.get("devices"), self.log, self.testLogPath) - # Set up the session from the default console + # Set up the session from the enabled console (not just "default") self.dut = self.devices.getDevice( "dut" ) - self.session = self.dut.getConsoleSession() + + # CRITICAL: Select console based on which is enabled, not hardcoded "default" + # Check console configuration to find the enabled one + console_name = "default" # Fallback to default + try: + # Get the raw config to check enabled status + dut_config = self.slotInfo.config.get("devices", [{}])[0].get("dut", {}) + consoles_config = dut_config.get("consoles", []) + + # Find the enabled console + for console_item in consoles_config: + for name, config in console_item.items(): + if config.get("enabled", False): + console_name = name + self.log.info(f"Using enabled console: {console_name} (type: {config.get('type')})") + break + if console_name != "default": + break + except Exception as e: + self.log.warn(f"Failed to detect enabled console, using default: {e}") + + self.session = self.dut.getConsoleSession(console_name) self.outboundClient = self.dut.outBoundClient self.powerControl = self.dut.powerControl self.commonRemote = self.dut.remoteController From 3167e1a3aba649fd96a30f695fe44f7a79c1a1e5 Mon Sep 17 00:00:00 2001 From: FitzerIRL Date: Wed, 8 Apr 2026 10:03:18 -0400 Subject: [PATCH 2/8] Update framework/core/logModule.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- framework/core/logModule.py | 39 ++++++++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/framework/core/logModule.py b/framework/core/logModule.py index 5162e8f..48d5953 100755 --- a/framework/core/logModule.py +++ b/framework/core/logModule.py @@ -118,9 +118,42 @@ def __init__(self, moduleName, level=INFO): def __del__(self): """Deletes the logger instance. """ - while self.log.handlers: - self.log.removeHandler(self.log.handlers[0]) - + # Ensure all handlers are flushed and closed to avoid leaking resources + for handler in list(getattr(self, "log", {}).handlers if getattr(self, "log", None) else []): + try: + # Not all handlers implement flush; guard just in case + if hasattr(handler, "flush"): + handler.flush() + except Exception: + # Best-effort cleanup; ignore flush errors during destruction + pass + try: + handler.close() + except Exception: + # Ignore close errors during destruction + pass + try: + self.log.removeHandler(handler) + except Exception: + # If removal fails, there's nothing more we can safely do here + pass + + # Also clean up CSV logger handlers, if this instance created one + if hasattr(self, "csvLogger") and getattr(self, "csvLogger") is not None: + for handler in list(self.csvLogger.handlers): + try: + if hasattr(handler, "flush"): + handler.flush() + except Exception: + pass + try: + handler.close() + except Exception: + pass + try: + self.csvLogger.removeHandler(handler) + except Exception: + pass def setFilename( self, logPath, logFileName ): """ Sets the filename for logging. From 0a7aa6f0d59df746034034ea67c64b48ea4313cb Mon Sep 17 00:00:00 2001 From: FitzerIRL Date: Wed, 8 Apr 2026 10:03:36 -0400 Subject: [PATCH 3/8] Update framework/core/commandModules/telnetClass.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- framework/core/commandModules/telnetClass.py | 50 ++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/framework/core/commandModules/telnetClass.py b/framework/core/commandModules/telnetClass.py index 7aa0a85..a992660 100644 --- a/framework/core/commandModules/telnetClass.py +++ b/framework/core/commandModules/telnetClass.py @@ -96,6 +96,56 @@ def read_all(self): pass return buffer + def read_very_eager(self): + """ + Read all data available on the socket without blocking. + This is a minimal approximation of telnetlib.Telnet.read_very_eager(). + """ + if not self.sock: + return b'' + + buffer = b'' + # Use non-blocking recv to drain any immediately available data. + self.sock.setblocking(False) + try: + while True: + try: + data = self.sock.recv(1024) + except BlockingIOError: + # No more data available without blocking. + break + if not data: + break + buffer += data + finally: + # Restore blocking mode for subsequent operations. + self.sock.setblocking(True) + return buffer + + def read_eager(self): + """ + Read any data available on the socket without significant blocking. + For this compatibility shim, delegate to read_very_eager(). + """ + return self.read_very_eager() + + def read_some(self): + """ + Read at least some data if available, without blocking if none is ready. + Returns an empty bytes object if no data is immediately available. + """ + if not self.sock: + return b'' + + self.sock.setblocking(False) + try: + try: + data = self.sock.recv(1024) + except BlockingIOError: + data = b'' + finally: + self.sock.setblocking(True) + return data # Create a mock telnetlib module class telnetlib: Telnet = Telnet From 528d2fca0cb00b162dbfc1b3f6711f1134e7398a Mon Sep 17 00:00:00 2001 From: FitzerIRL Date: Wed, 8 Apr 2026 10:04:24 -0400 Subject: [PATCH 4/8] Update framework/core/commandModules/telnetClass.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- framework/core/commandModules/telnetClass.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/core/commandModules/telnetClass.py b/framework/core/commandModules/telnetClass.py index a992660..0f52135 100644 --- a/framework/core/commandModules/telnetClass.py +++ b/framework/core/commandModules/telnetClass.py @@ -58,7 +58,7 @@ def close(self): def write(self, buffer): if self.sock: - self.sock.send(buffer) + self.sock.sendall(buffer) def read_until(self, match, timeout=None): if not self.sock: From 7e7bdd1924a80743b2ca655b803b0de05cd0f47f Mon Sep 17 00:00:00 2001 From: FitzerIRL Date: Wed, 8 Apr 2026 10:04:47 -0400 Subject: [PATCH 5/8] Update framework/core/commandModules/serialClass.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- framework/core/commandModules/serialClass.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/framework/core/commandModules/serialClass.py b/framework/core/commandModules/serialClass.py index 30fbda3..f094fbd 100644 --- a/framework/core/commandModules/serialClass.py +++ b/framework/core/commandModules/serialClass.py @@ -200,5 +200,8 @@ def flush(self) -> bool: True if can successfully clear the serial console. """ self.log.info("Clearing Serial console log") - self.serialCon.flushInput() + if hasattr(self.serialCon, "reset_input_buffer"): + self.serialCon.reset_input_buffer() + else: + self.serialCon.flushInput() return True From f9a7814403547100ec690cc815c05a2956ebe03d Mon Sep 17 00:00:00 2001 From: FitzerIRL Date: Wed, 8 Apr 2026 10:05:24 -0400 Subject: [PATCH 6/8] Update framework/core/testControl.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- framework/core/testControl.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/framework/core/testControl.py b/framework/core/testControl.py index a436775..a30396a 100644 --- a/framework/core/testControl.py +++ b/framework/core/testControl.py @@ -61,7 +61,15 @@ except ModuleNotFoundError as e: # cv2/pytesseract/PIL are optional dependencies (e.g. in Docker images) if e.name in ("cv2", "pytesseract", "PIL", "PIL.Image"): - capture = None + def _capture_missing_dependency(*args, **kwargs): + raise ImportError( + "The 'capture' functionality requires optional dependencies " + "(cv2, pytesseract, and Pillow). One or more of these are not " + f"installed (missing module: {e.name!r}). Please install the " + "required packages to use capture-related features." + ) + + capture = _capture_missing_dependency else: raise try: From 3d704618a47ba7a60140a9ab6880d4546e57c74d Mon Sep 17 00:00:00 2001 From: FitzerIRL Date: Wed, 8 Apr 2026 10:05:58 -0400 Subject: [PATCH 7/8] Update framework/core/__init__.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- framework/core/__init__.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/framework/core/__init__.py b/framework/core/__init__.py index 70b4e63..1a2fd90 100644 --- a/framework/core/__init__.py +++ b/framework/core/__init__.py @@ -55,8 +55,13 @@ def __call__(self, *args, **kwargs): from . testControl import testController try: from . webpageController import webpageController -except ModuleNotFoundError: - webpageController = None # selenium not installed (e.g. Docker) +except ModuleNotFoundError as e: + if e.name == "selenium": + # selenium not installed (e.g. Docker) + webpageController = None + else: + # Unexpected missing module (e.g., bug inside webpageController.py) - do not hide it. + raise from . rcCodes import rcCode as rc from . utilities import utilities From 2e99a45ad348aba9b9a8f7a8661ee870d6337adb Mon Sep 17 00:00:00 2001 From: FitzerIRL Date: Wed, 8 Apr 2026 10:06:11 -0400 Subject: [PATCH 8/8] Update framework/core/testControl.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- framework/core/testControl.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/framework/core/testControl.py b/framework/core/testControl.py index a30396a..30cba8b 100644 --- a/framework/core/testControl.py +++ b/framework/core/testControl.py @@ -77,7 +77,11 @@ def _capture_missing_dependency(*args, **kwargs): except ModuleNotFoundError as e: # selenium is an optional dependency (e.g. in Docker images) if e.name == "selenium": - webpageController = None + def _missing_webpage_controller(*args, **kwargs): + raise ImportError( + "webpageController requires the 'selenium' package, which is not installed" + ) + webpageController = _missing_webpage_controller else: raise from framework.core.deviceManager import deviceManager