Skip to content

Commit 0006540

Browse files
authored
Merge pull request #210 from FitzerIRL/develop
- 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()
2 parents 5fc04de + 2e99a45 commit 0006540

6 files changed

Lines changed: 262 additions & 20 deletions

File tree

framework/core/__init__.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,36 @@
3232

3333
#__all__ = ["testControl", "logModule", "rcCodes", "rackController", "slotInfo" ]
3434
#expose only the components required for the upper classes to operate
35-
from . capture import capture
35+
try:
36+
from . capture import capture
37+
except ModuleNotFoundError as e:
38+
# Only treat known optional image-processing dependencies as optional.
39+
_optional_capture_deps = {"cv2", "pytesseract", "PIL"}
40+
if e.name in _optional_capture_deps:
41+
class _CaptureMissingDependency:
42+
def __call__(self, *args, **kwargs):
43+
raise ImportError(
44+
"Image capture functionality is unavailable because optional "
45+
"dependencies (cv2, pytesseract, PIL) are not installed."
46+
) from e
47+
capture = _CaptureMissingDependency()
48+
else:
49+
# Unexpected missing module (e.g., bug inside capture.py) - do not hide it.
50+
raise
3651
from . commonRemote import commonRemoteClass
3752
from . deviceManager import deviceManager
3853
from . logModule import logModule
3954
from . logModule import DEBUG, INFO, WARNING, ERROR, CRITICAL
4055
from . testControl import testController
41-
from . webpageController import webpageController
56+
try:
57+
from . webpageController import webpageController
58+
except ModuleNotFoundError as e:
59+
if e.name == "selenium":
60+
# selenium not installed (e.g. Docker)
61+
webpageController = None
62+
else:
63+
# Unexpected missing module (e.g., bug inside webpageController.py) - do not hide it.
64+
raise
4265
from . rcCodes import rcCode as rc
4366
from . utilities import utilities
4467

framework/core/commandModules/serialClass.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,5 +200,8 @@ def flush(self) -> bool:
200200
True if can successfully clear the serial console.
201201
"""
202202
self.log.info("Clearing Serial console log")
203-
self.serialCon.reset_input_buffer()
203+
if hasattr(self.serialCon, "reset_input_buffer"):
204+
self.serialCon.reset_input_buffer()
205+
else:
206+
self.serialCon.flushInput()
204207
return True

framework/core/commandModules/telnetClass.py

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,126 @@
3030
#*
3131
#* ******************************************************************************
3232

33-
import telnetlib
33+
try:
34+
import telnetlib
35+
except ModuleNotFoundError:
36+
# telnetlib was removed in Python 3.13, provide a minimal compatibility layer
37+
import socket
38+
import time
39+
40+
class Telnet:
41+
def __init__(self, host=None, port=23, timeout=socket._GLOBAL_DEFAULT_TIMEOUT):
42+
self.host = host
43+
self.port = port
44+
self.timeout = timeout
45+
self.sock = None
46+
if host:
47+
self.open(host, port, timeout)
48+
49+
def open(self, host, port=23, timeout=socket._GLOBAL_DEFAULT_TIMEOUT):
50+
self.host = host
51+
self.port = port
52+
self.sock = socket.create_connection((host, port), timeout)
53+
54+
def close(self):
55+
if self.sock:
56+
self.sock.close()
57+
self.sock = None
58+
59+
def write(self, buffer):
60+
if self.sock:
61+
self.sock.sendall(buffer)
62+
63+
def read_until(self, match, timeout=None):
64+
if not self.sock:
65+
return b''
66+
67+
buffer = b''
68+
start_time = time.time()
69+
while True:
70+
if timeout and (time.time() - start_time) > timeout:
71+
break
72+
try:
73+
data = self.sock.recv(1024)
74+
if not data:
75+
break
76+
buffer += data
77+
if match in buffer:
78+
break
79+
except socket.timeout:
80+
break
81+
except Exception:
82+
break
83+
return buffer
84+
85+
def read_all(self):
86+
if not self.sock:
87+
return b''
88+
buffer = b''
89+
try:
90+
while True:
91+
data = self.sock.recv(1024)
92+
if not data:
93+
break
94+
buffer += data
95+
except Exception:
96+
pass
97+
return buffer
98+
99+
def read_very_eager(self):
100+
"""
101+
Read all data available on the socket without blocking.
102+
This is a minimal approximation of telnetlib.Telnet.read_very_eager().
103+
"""
104+
if not self.sock:
105+
return b''
106+
107+
buffer = b''
108+
# Use non-blocking recv to drain any immediately available data.
109+
self.sock.setblocking(False)
110+
try:
111+
while True:
112+
try:
113+
data = self.sock.recv(1024)
114+
except BlockingIOError:
115+
# No more data available without blocking.
116+
break
117+
if not data:
118+
break
119+
buffer += data
120+
finally:
121+
# Restore blocking mode for subsequent operations.
122+
self.sock.setblocking(True)
123+
return buffer
124+
125+
def read_eager(self):
126+
"""
127+
Read any data available on the socket without significant blocking.
128+
For this compatibility shim, delegate to read_very_eager().
129+
"""
130+
return self.read_very_eager()
131+
132+
def read_some(self):
133+
"""
134+
Read at least some data if available, without blocking if none is ready.
135+
Returns an empty bytes object if no data is immediately available.
136+
"""
137+
if not self.sock:
138+
return b''
139+
140+
self.sock.setblocking(False)
141+
try:
142+
try:
143+
data = self.sock.recv(1024)
144+
except BlockingIOError:
145+
data = b''
146+
finally:
147+
self.sock.setblocking(True)
148+
return data
149+
# Create a mock telnetlib module
150+
class telnetlib:
151+
Telnet = Telnet
152+
34153
import socket
35154

36155
from .consoleInterface import consoleInterface
@@ -153,15 +272,15 @@ def read_until(self,value: str, timeout: int = 10) -> str:
153272
message = value.encode()
154273
result = self.tn.read_until(message,self.timeout)
155274
return result.decode()
156-
275+
157276
def read_all(self) -> str:
158277
"""Read all readily available information displayed in the console.
159278
160279
Returns:
161280
str: Information currently displayed in the console.
162281
"""
163282
return self.read_eager()
164-
283+
165284
def write(self,message:list|str, lineFeed:str="\r\n", wait_for_prompt:bool=False) -> bool:
166285
"""Write a message into the session console.
167286
Optional: waits for prompt.

framework/core/deviceManager.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,11 @@ def __init__(self, log:logModule, logPath:str, configElements:dict):
6767
"""
6868
for element in configElements:
6969
config = configElements.get(element)
70+
# Respect 'enabled: false' in console config — skip disabled consoles
71+
if config.get("enabled") is False:
72+
self.type = None
73+
self.session = None
74+
return
7075
self.type = config.get("type")
7176
self.prompt = config.get("prompt")
7277
# Create a new console since it hasn't been created
@@ -172,7 +177,9 @@ def __init__(self, log:logModule, logPath:str, devices:dict):
172177
consoles = device.get("consoles")
173178
for element in consoles:
174179
for name in element:
175-
self.consoles[name] = consoleClass(log, logPath, element )
180+
console = consoleClass(log, logPath, element)
181+
if console.type is not None: # skip disabled consoles
182+
self.consoles[name] = console
176183
config = device.get("outbound")
177184
if config != None:
178185
self.outBoundClient = outboundClientClass(log, **config)
@@ -218,9 +225,15 @@ def getConsoleSession(self, consoleName:str="default" ):
218225
Returns:
219226
consoleClass: Console class, or None on failure
220227
"""
221-
console = self.consoles[consoleName]
222-
if console == None:
223-
self.log.error("Invalid consoleName [{}]".format(consoleName))
228+
console = self.consoles.get(consoleName)
229+
# If requested console was disabled/missing, fall back to first enabled console
230+
if console is None and self.consoles:
231+
fallback = next(iter(self.consoles))
232+
self.log.info("Console '{}' not available, falling back to '{}'".format(consoleName, fallback))
233+
console = self.consoles[fallback]
234+
if console is None:
235+
self.log.error("No consoles available (all disabled?)")
236+
return None
224237
return console.session
225238

226239
def pingTest(self, logPingTime=False):

framework/core/logModule.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,42 @@ def __init__(self, moduleName, level=INFO):
118118
def __del__(self):
119119
"""Deletes the logger instance.
120120
"""
121-
while self.log.hasHandlers():
122-
self.log.removeHandler(self.log.handlers[0])
123-
121+
# Ensure all handlers are flushed and closed to avoid leaking resources
122+
for handler in list(getattr(self, "log", {}).handlers if getattr(self, "log", None) else []):
123+
try:
124+
# Not all handlers implement flush; guard just in case
125+
if hasattr(handler, "flush"):
126+
handler.flush()
127+
except Exception:
128+
# Best-effort cleanup; ignore flush errors during destruction
129+
pass
130+
try:
131+
handler.close()
132+
except Exception:
133+
# Ignore close errors during destruction
134+
pass
135+
try:
136+
self.log.removeHandler(handler)
137+
except Exception:
138+
# If removal fails, there's nothing more we can safely do here
139+
pass
140+
141+
# Also clean up CSV logger handlers, if this instance created one
142+
if hasattr(self, "csvLogger") and getattr(self, "csvLogger") is not None:
143+
for handler in list(self.csvLogger.handlers):
144+
try:
145+
if hasattr(handler, "flush"):
146+
handler.flush()
147+
except Exception:
148+
pass
149+
try:
150+
handler.close()
151+
except Exception:
152+
pass
153+
try:
154+
self.csvLogger.removeHandler(handler)
155+
except Exception:
156+
pass
124157
def setFilename( self, logPath, logFileName ):
125158
"""
126159
Sets the filename for logging.
@@ -134,11 +167,11 @@ def setFilename( self, logPath, logFileName ):
134167
return
135168
self.logPath = logPath
136169
logFileName = os.path.join(logPath + logFileName)
137-
self.logFile = logging.FileHandler(logFileName)
170+
self.logFile = logging.FileHandler(logFileName, encoding='utf-8')
138171
self.logFile.setFormatter( self.format )
139172
self.log.addHandler( self.logFile )
140173
#Create the CSV Logger module
141-
self.csvLogFile = logging.FileHandler( logFileName+".csv" )
174+
self.csvLogFile = logging.FileHandler( logFileName+".csv", encoding='utf-8' )
142175
self.csvLogger.addHandler( self.csvLogFile )
143176
self.csvLogger.info("QcId, TestName, Result, Failed Step, Failure, Duration [hh:mm:ss]")
144177
self.log.info( "Log File: [{}]".format(logFileName) )

framework/core/testControl.py

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@
3333
import time
3434
import signal
3535
import random
36-
import telnetlib
36+
try:
37+
import telnetlib
38+
except ModuleNotFoundError:
39+
# telnetlib was removed in Python 3.13, this is handled in telnetClass.py
40+
telnetlib = None
3741
import os
3842
import shlex, subprocess
3943
import traceback
@@ -52,8 +56,34 @@
5256
from framework.core.configParser import configParser
5357
from framework.core.utilities import utilities
5458
from framework.core.decodeParams import decodeParams
55-
from framework.core.capture import capture
56-
from framework.core.webpageController import webpageController
59+
try:
60+
from framework.core.capture import capture
61+
except ModuleNotFoundError as e:
62+
# cv2/pytesseract/PIL are optional dependencies (e.g. in Docker images)
63+
if e.name in ("cv2", "pytesseract", "PIL", "PIL.Image"):
64+
def _capture_missing_dependency(*args, **kwargs):
65+
raise ImportError(
66+
"The 'capture' functionality requires optional dependencies "
67+
"(cv2, pytesseract, and Pillow). One or more of these are not "
68+
f"installed (missing module: {e.name!r}). Please install the "
69+
"required packages to use capture-related features."
70+
)
71+
72+
capture = _capture_missing_dependency
73+
else:
74+
raise
75+
try:
76+
from framework.core.webpageController import webpageController
77+
except ModuleNotFoundError as e:
78+
# selenium is an optional dependency (e.g. in Docker images)
79+
if e.name == "selenium":
80+
def _missing_webpage_controller(*args, **kwargs):
81+
raise ImportError(
82+
"webpageController requires the 'selenium' package, which is not installed"
83+
)
84+
webpageController = _missing_webpage_controller
85+
else:
86+
raise
5787
from framework.core.deviceManager import deviceManager
5888

5989
class testController():
@@ -149,9 +179,30 @@ def __init__(self, testName="", qcId="", maxRunTime=TEST_MAX_RUN_TIME, level=log
149179
#Start the rest of the testControl requirements
150180
self.devices = deviceManager(self.slotInfo.config.get("devices"), self.log, self.testLogPath)
151181

152-
# Set up the session from the default console
182+
# Set up the session from the enabled console (not just "default")
153183
self.dut = self.devices.getDevice( "dut" )
154-
self.session = self.dut.getConsoleSession()
184+
185+
# CRITICAL: Select console based on which is enabled, not hardcoded "default"
186+
# Check console configuration to find the enabled one
187+
console_name = "default" # Fallback to default
188+
try:
189+
# Get the raw config to check enabled status
190+
dut_config = self.slotInfo.config.get("devices", [{}])[0].get("dut", {})
191+
consoles_config = dut_config.get("consoles", [])
192+
193+
# Find the enabled console
194+
for console_item in consoles_config:
195+
for name, config in console_item.items():
196+
if config.get("enabled", False):
197+
console_name = name
198+
self.log.info(f"Using enabled console: {console_name} (type: {config.get('type')})")
199+
break
200+
if console_name != "default":
201+
break
202+
except Exception as e:
203+
self.log.warn(f"Failed to detect enabled console, using default: {e}")
204+
205+
self.session = self.dut.getConsoleSession(console_name)
155206
self.outboundClient = self.dut.outBoundClient
156207
self.powerControl = self.dut.powerControl
157208
self.commonRemote = self.dut.remoteController

0 commit comments

Comments
 (0)