From 74bf8d91403ce309bdd34b75e94149f14c070b06 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Thu, 17 Apr 2025 16:12:25 +0200 Subject: [PATCH 01/48] First working version --- requirements.txt | 3 ++- tofupilot/openhtf/tofupilot.py | 49 +++++++++++++++++++--------------- 2 files changed, 29 insertions(+), 23 deletions(-) diff --git a/requirements.txt b/requirements.txt index 37b92e6..9d4ea4a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ requests setuptools packaging pytest -websockets \ No newline at end of file +websockets +paho-mqtt \ No newline at end of file diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 649bf06..9659ec5 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -8,6 +8,7 @@ from openhtf.util import data from websockets import connect, ConnectionClosedError, InvalidURI +import paho.mqtt.client as mqtt from .upload import upload from ..client import TofuPilotClient @@ -172,9 +173,11 @@ async def setup(self): self.update_task = asyncio.create_task(self.process_updates()) async def process_updates(self): - """ - Sends the current state of the test to the WebSocket server. - """ + + mqttc = mqtt.Client(transport="websockets") + + mqttc.tls_set() + try: url = self.client.get_websocket_url() @@ -187,25 +190,27 @@ async def process_updates(self): while retry_count < max_retries and not self.shutdown_event.is_set(): try: - async with connect(url) as websocket: - while not self.shutdown_event.is_set(): - try: - # Fetch state update from the queue (with timeout to avoid blocking indefinitely) - state_update = await asyncio.wait_for( - self.update_queue.get(), timeout=1.0 - ) - # Send the state update to the WebSocket server - await websocket.send( - json.dumps( - {"action": "send", "message": state_update} - ) - ) - except asyncio.TimeoutError: - continue # Timeout waiting for an update; loop back - except asyncio.CancelledError: - return # Exit cleanly on task cancellation - except Exception: # pylint: disable=broad-exception-caught - break # Exit WebSocket loop on unexpected errors + mqttc.connect("emqx-fly.fly.dev", 8084) # /mqtt ? + mqttc.loop_start() + while not self.shutdown_event.is_set(): + try: + # Fetch state update from the queue (with timeout to avoid blocking indefinitely) + state_update = await asyncio.wait_for( + self.update_queue.get(), timeout=1.0 + ) + # Send the state update to the WebSocket server + + mqttc.publish("test", + json.dumps( + {"action": "send", "message": state_update} + )) + except asyncio.TimeoutError: + continue # Timeout waiting for an update; loop back + except asyncio.CancelledError: + return # Exit cleanly on task cancellation + except Exception: # pylint: disable=broad-exception-caught + break # Exit WebSocket loop on unexpected errors + mqttc.loop_stop() except (ConnectionClosedError, OSError, InvalidURI): retry_count += 1 await asyncio.sleep( From 04a722911c40942bf0039b836dbfc0f846a27af6 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 23 Apr 2025 14:47:52 +0200 Subject: [PATCH 02/48] Update streaming logic to mqtt --- tofupilot/client.py | 14 ++++++-------- tofupilot/openhtf/tofupilot.py | 16 +++++++++++----- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/tofupilot/client.py b/tofupilot/client.py index 56e7427..dcba995 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -464,14 +464,14 @@ def upload_and_create_from_openhtf_report( return handle_http_error(self._logger, http_err) except requests.RequestException as e: return handle_network_error(self._logger, e) - - def get_websocket_url(self) -> dict: + + def get_connection_credentials(self) -> dict: """ - Fetches websocket connection url associated with API Key. + Fetches credentials required to livestream test results. Returns: - str: - Websocket connection URL. + values: + a dict containing the emqx server url, the topic to connect to, and the JWT token required to connect """ try: @@ -482,15 +482,13 @@ def get_websocket_url(self) -> dict: ) response.raise_for_status() values = handle_response(self._logger, response) - url = values.get("url") - return url + return values except requests.exceptions.HTTPError as http_err: return handle_http_error(self._logger, http_err) except requests.RequestException as e: return handle_network_error(self._logger, e) - def print_version_banner(current_version: str): """Prints current version of client""" banner = f""" diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 9659ec5..50d1e2f 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -179,10 +179,16 @@ async def process_updates(self): mqttc.tls_set() try: - url = self.client.get_websocket_url() - if not url: - return # Exit gracefully if no URL is provided + cred = self.client.get_connection_credentials() + + url, topic, token = cred.get("url"), cred.get("topic"), cred.get("token") + + if not url or not topic or not token: + print(f"Missing credential(s): {cred}") + return # Exit gracefully if some credential is missing + + mqttc.username_pw_set("pythonClient", token) retry_count = 0 max_retries = 5 @@ -190,7 +196,7 @@ async def process_updates(self): while retry_count < max_retries and not self.shutdown_event.is_set(): try: - mqttc.connect("emqx-fly.fly.dev", 8084) # /mqtt ? + mqttc.connect(url, 8084) mqttc.loop_start() while not self.shutdown_event.is_set(): try: @@ -200,7 +206,7 @@ async def process_updates(self): ) # Send the state update to the WebSocket server - mqttc.publish("test", + mqttc.publish(topic, json.dumps( {"action": "send", "message": state_update} )) From fe76fa992019b40a4495780554ee648581e3c240 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 25 Apr 2025 09:03:15 +0200 Subject: [PATCH 03/48] First working round-trip --- tofupilot/openhtf/tofupilot.py | 65 +++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 50d1e2f..6a021d8 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -1,3 +1,4 @@ +import types from typing import Optional from time import sleep import threading @@ -73,6 +74,62 @@ def run(self): def stop(self): self.stop_event.set() +def on_message(client, userdata, message): + # userdata is the structure we choose to provide, here it's a list() + #print(f"received: {message.payload}") + parsed = json.loads(message.payload) + print(f"{parsed = }") + + if parsed["source"] == "web": + handle_answer(parsed["message"]["plug_name"], parsed["message"]["method_name"], parsed["message"]["args"]) + else: + print(f"received self-sent: {parsed["message"]}") + +def handle_answer(plug_name, method_name, args): +#def post(test_uid, plug_name): + #_, test_state = self.get_test(test_uid) + _, test_state = _get_executing_test() + + if test_state is None: + return + + # Find the plug matching `plug_name`. + plug = test_state.plug_manager.get_plug_by_class_path(plug_name) + if plug is None: + #self.write('Unknown plug %s' % plug_name) + #self.set_status(404) + return + + """ + try: + #request = json.loads(self.request.body.decode('utf-8')) + method_name = request['method'] + args = request['args'] + except (KeyError, ValueError): + #self.write('Malformed JSON request.') + #self.set_status(400) + return + """ + + method = getattr(plug, method_name, None) + + if not (plug.enable_remote and isinstance(method, types.MethodType) and + not method_name.startswith('_') and + method_name not in plug.disable_remote_attrs): + #self.write('Cannot access method %s of plug %s.' % + # (method_name, plug_name)) + #self.set_status(400) + return + + try: + response = json.dumps(method(*args)) # calls userInput.respond(*args) ! + except Exception as e: # pylint: disable=broad-except + "" + #self.write('Plug error: %s' % repr(e)) + #self.set_status(500) + else: + "" + #self.write(response) class TofuPilot: """ @@ -184,11 +241,15 @@ async def process_updates(self): url, topic, token = cred.get("url"), cred.get("topic"), cred.get("token") + #print(topic) + if not url or not topic or not token: print(f"Missing credential(s): {cred}") return # Exit gracefully if some credential is missing mqttc.username_pw_set("pythonClient", token) + + mqttc.on_message = on_message retry_count = 0 max_retries = 5 @@ -197,6 +258,7 @@ async def process_updates(self): while retry_count < max_retries and not self.shutdown_event.is_set(): try: mqttc.connect(url, 8084) + mqttc.subscribe(topic) mqttc.loop_start() while not self.shutdown_event.is_set(): try: @@ -208,8 +270,9 @@ async def process_updates(self): mqttc.publish(topic, json.dumps( - {"action": "send", "message": state_update} + {"action": "send", "source": "python", "message": state_update} )) + print("Data sent") except asyncio.TimeoutError: continue # Timeout waiting for an update; loop back except asyncio.CancelledError: From ce3f4347e142d78295992594aafa4e0a5bd61388 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 25 Apr 2025 11:16:48 +0200 Subject: [PATCH 04/48] Update schema for EMQX authentication Syncronised with commit of same name in the web app --- tofupilot/openhtf/tofupilot.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 6a021d8..36752ef 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -76,14 +76,10 @@ def stop(self): def on_message(client, userdata, message): # userdata is the structure we choose to provide, here it's a list() - #print(f"received: {message.payload}") parsed = json.loads(message.payload) - print(f"{parsed = }") if parsed["source"] == "web": - handle_answer(parsed["message"]["plug_name"], parsed["message"]["method_name"], parsed["message"]["args"]) - else: - print(f"received self-sent: {parsed["message"]}") + handle_answer(**parsed["message"]) def handle_answer(plug_name, method_name, args): #def post(test_uid, plug_name): @@ -239,11 +235,14 @@ async def process_updates(self): cred = self.client.get_connection_credentials() - url, topic, token = cred.get("url"), cred.get("topic"), cred.get("token") + url = cred["url"] + token = cred["token"] + publishOptions = cred["publishOptions"] + subscribeOptions = cred["subscribeOptions"] #print(topic) - if not url or not topic or not token: + if not url or not token or not publishOptions or not subscribeOptions: print(f"Missing credential(s): {cred}") return # Exit gracefully if some credential is missing @@ -258,7 +257,7 @@ async def process_updates(self): while retry_count < max_retries and not self.shutdown_event.is_set(): try: mqttc.connect(url, 8084) - mqttc.subscribe(topic) + mqttc.subscribe(**subscribeOptions) mqttc.loop_start() while not self.shutdown_event.is_set(): try: @@ -268,10 +267,12 @@ async def process_updates(self): ) # Send the state update to the WebSocket server - mqttc.publish(topic, - json.dumps( + mqttc.publish( + payload=json.dumps( {"action": "send", "source": "python", "message": state_update} - )) + ), + **publishOptions + ) print("Data sent") except asyncio.TimeoutError: continue # Timeout waiting for an update; loop back From 4cee955f56487a956af32ff5b317244ebb60b82c Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 25 Apr 2025 12:12:41 +0200 Subject: [PATCH 05/48] Remove debug print --- tofupilot/openhtf/tofupilot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 36752ef..008b462 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -273,7 +273,6 @@ async def process_updates(self): ), **publishOptions ) - print("Data sent") except asyncio.TimeoutError: continue # Timeout waiting for an update; loop back except asyncio.CancelledError: From 293153d674a5b96c73da0fbed1bf0d32f4cb93f5 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 25 Apr 2025 12:17:11 +0200 Subject: [PATCH 06/48] Rename operator ui related endpoint/apis/methods to ...streaming... Syncronised with commit of same name in the python client --- tofupilot/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tofupilot/client.py b/tofupilot/client.py index dcba995..12fd813 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -476,7 +476,7 @@ def get_connection_credentials(self) -> dict: try: response = requests.get( - f"{self._url}/rooms", + f"{self._url}/streaming", headers=self._headers, timeout=SECONDS_BEFORE_TIMEOUT, ) From 1256783d76ee1918fd7b464a3888c5a5b0041d9e Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Fri, 25 Apr 2025 16:10:23 +0200 Subject: [PATCH 07/48] Cleanup connection logic and Finalize transition to parameters being server-defined Syncronised with commit of same name in the web app --- tofupilot/client.py | 6 +- tofupilot/openhtf/tofupilot.py | 176 +++++++++++---------------------- 2 files changed, 64 insertions(+), 118 deletions(-) diff --git a/tofupilot/client.py b/tofupilot/client.py index 12fd813..f80e25f 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -485,9 +485,11 @@ def get_connection_credentials(self) -> dict: return values except requests.exceptions.HTTPError as http_err: - return handle_http_error(self._logger, http_err) + handle_http_error(self._logger, http_err) + return None except requests.RequestException as e: - return handle_network_error(self._logger, e) + handle_network_error(self._logger, e) + return None def print_version_banner(current_version: str): """Prints current version of client""" diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 008b462..ae9e24d 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -7,9 +7,9 @@ import json from openhtf import Test from openhtf.util import data -from websockets import connect, ConnectionClosedError, InvalidURI import paho.mqtt.client as mqtt +from paho.mqtt.enums import CallbackAPIVersion from .upload import upload from ..client import TofuPilotClient @@ -81,6 +81,14 @@ def on_message(client, userdata, message): if parsed["source"] == "web": handle_answer(**parsed["message"]) +def on_disconnect(client, userdata, disconnect_flags, reason_code, properties): + if reason_code != mqtt.MQTT_ERR_SUCCESS: + print(f"Unexpected disconnection from the streaming server: {reason_code}") + +def on_unsubscribe(client, userdata, mid, reason_code_list, properties): + if any(reason_code != mqtt.MQTT_ERR_SUCCESS for reason_code in reason_code_list): + print(f"Unexpected partial disconnection from the streaming server: {reason_code_list}") + def handle_answer(plug_name, method_name, args): #def post(test_uid, plug_name): #_, test_state = self.get_test(test_uid) @@ -162,12 +170,11 @@ def __init__( self.client = TofuPilotClient(api_key=api_key, url=url) self.api_key = api_key self.url = url - self.loop = None - self.update_queue = None - self.event_loop_thread = None self.watcher = None self.shutdown_event = threading.Event() self.update_task = None + self.mqttClient = None + self.publishOptions = None def __enter__(self): # Initialize a thread-safe asyncio.Queue @@ -176,21 +183,48 @@ def __enter__(self): ) if self.stream: - self.update_queue = asyncio.Queue() - - # Start the event loop in a separate thread - self.event_loop_thread = threading.Thread( - target=self.run_event_loop, daemon=True - ) - self.event_loop_thread.start() - - # Wait until the event loop is ready - while self.loop is None: - sleep(0.1) # Start the SimpleStationWatcher with a callback to send updates self.watcher = SimpleStationWatcher(self.send_update) self.watcher.start() + + cred = self.client.get_connection_credentials() + + if not cred: + print("Failed to connect to the authn server") + return self + + # Since we control the server, we know these will be set + token = cred["token"] + clientOptions = cred["clientOptions"] + connectOptions = cred["connectOptions"] + self.publishOptions = cred["publishOptions"] + subscribeOptions = cred["subscribeOptions"] + + self.mqttClient = mqtt.Client(callback_api_version=CallbackAPIVersion.VERSION2, **clientOptions) + + self.mqttClient.tls_set() + + self.mqttClient.username_pw_set("pythonClient", token) + + self.mqttClient.on_message = on_message + self.mqttClient.on_disconnect = on_disconnect + self.mqttClient.on_unsubscribe = on_unsubscribe + + connect_error_code = self.mqttClient.connect(**connectOptions) + if(connect_error_code != mqtt.MQTT_ERR_SUCCESS): + print(f"failed to connect with the streaming server {connect_error_code}") + return self + + subscribe_error_code, messageId = self.mqttClient.subscribe(**subscribeOptions) + if(subscribe_error_code != mqtt.MQTT_ERR_SUCCESS): + print(f"failed to connect with the streaming server {subscribe_error_code}") + return self + + self.mqttClient.loop_start() + + #print(f"Streaming connection established: ") + return self @@ -200,107 +234,17 @@ def __exit__(self, exc_type, exc_value, traceback): self.watcher.stop() self.watcher.join() - # Schedule the shutdown coroutine - if self.loop and not self.loop.is_closed(): - asyncio.run_coroutine_threadsafe(self.shutdown(), self.loop) - - # Wait for the event loop thread to finish - if self.event_loop_thread: - self.event_loop_thread.join() + if self.mqttClient: # Doesnt wait for publish or other to stop ! + # Doesn't wait for publish operation to stop, this is fine since __exit__ is only called after the run was imported + self.mqttClient.loop_stop() + self.mqttClient.disconnect() + self.mqttClient = None def send_update(self, message): """Thread-safe method to send a message to the event loop.""" - if self.loop and not self.loop.is_closed(): - asyncio.run_coroutine_threadsafe(self.update_queue.put(message), self.loop) - - def run_event_loop(self): - """Runs the asyncio event loop in a separate thread.""" - self.loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.loop) - self.loop.run_until_complete(self.setup()) - self.loop.run_forever() - - async def setup(self): - """Starts the update processor.""" - # Start the coroutine that processes updates - self.update_task = asyncio.create_task(self.process_updates()) - - async def process_updates(self): - - mqttc = mqtt.Client(transport="websockets") - - mqttc.tls_set() - - try: - - cred = self.client.get_connection_credentials() - - url = cred["url"] - token = cred["token"] - publishOptions = cred["publishOptions"] - subscribeOptions = cred["subscribeOptions"] - - #print(topic) - - if not url or not token or not publishOptions or not subscribeOptions: - print(f"Missing credential(s): {cred}") - return # Exit gracefully if some credential is missing - - mqttc.username_pw_set("pythonClient", token) - - mqttc.on_message = on_message - - retry_count = 0 - max_retries = 5 - backoff_factor = 2 # Exponential backoff base - - while retry_count < max_retries and not self.shutdown_event.is_set(): - try: - mqttc.connect(url, 8084) - mqttc.subscribe(**subscribeOptions) - mqttc.loop_start() - while not self.shutdown_event.is_set(): - try: - # Fetch state update from the queue (with timeout to avoid blocking indefinitely) - state_update = await asyncio.wait_for( - self.update_queue.get(), timeout=1.0 - ) - # Send the state update to the WebSocket server - - mqttc.publish( - payload=json.dumps( - {"action": "send", "source": "python", "message": state_update} - ), - **publishOptions - ) - except asyncio.TimeoutError: - continue # Timeout waiting for an update; loop back - except asyncio.CancelledError: - return # Exit cleanly on task cancellation - except Exception: # pylint: disable=broad-exception-caught - break # Exit WebSocket loop on unexpected errors - mqttc.loop_stop() - except (ConnectionClosedError, OSError, InvalidURI): - retry_count += 1 - await asyncio.sleep( - backoff_factor**retry_count - ) # Exponential backoff - except asyncio.CancelledError: - return # Exit cleanly on task cancellation - except Exception: # pylint: disable=broad-exception-caught - break # Exit gracefully on unexpected errors - except Exception: # pylint: disable=broad-exception-caught - pass # Catch all remaining exceptions to ensure robustness - - async def shutdown(self): - """Cleans up resources and stops the event loop.""" - # Cancel the update task - if self.update_task is not None: - self.update_task.cancel() - try: - await self.update_task - except asyncio.CancelledError: - pass - - # Stop the event loop - self.loop.stop() + self.mqttClient.publish( + payload=json.dumps( + {"action": "send", "source": "python", "message": message} + ), + **self.publishOptions + ) \ No newline at end of file From 5e1f764e69c2be82b75bc5b1f0ff98947c124de1 Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Fri, 25 Apr 2025 18:24:49 +0200 Subject: [PATCH 08/48] Prints test streaming URL to console Adds a user-friendly message to print the URL for viewing the test stream in a browser. Includes formatting for easier visibility. --- tofupilot/openhtf/tofupilot.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index ae9e24d..9f7fcb9 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -222,8 +222,14 @@ def __enter__(self): return self self.mqttClient.loop_start() - - #print(f"Streaming connection established: ") + + # Print the streaming room topic with colors and full URL + topic = subscribeOptions.get('topic', '') if subscribeOptions.get('topic') else 'unknown' + # Extract base URL without trailing slashes + url_base = self.client._url.split('/api/v1')[0].rstrip('/') + room_id = topic.split('/')[-1] if '/' in topic else topic + streaming_url = f"{url_base}/test/streaming/{room_id}" + print(f"\033[1;36mView test in browser: \033[1;32m{streaming_url}\033[0m") return self From b000ea0b4b4d19058b8a51f1f8e814882944fdf4 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Mon, 28 Apr 2025 11:55:56 +0200 Subject: [PATCH 09/48] Get operatorPage url from server Syncronised with commit of same name in the web app --- tofupilot/openhtf/tofupilot.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 9f7fcb9..5e3e7d7 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -196,6 +196,7 @@ def __enter__(self): # Since we control the server, we know these will be set token = cred["token"] + operatorPage = cred["operatorPage"] clientOptions = cred["clientOptions"] connectOptions = cred["connectOptions"] self.publishOptions = cred["publishOptions"] @@ -222,15 +223,8 @@ def __enter__(self): return self self.mqttClient.loop_start() - - # Print the streaming room topic with colors and full URL - topic = subscribeOptions.get('topic', '') if subscribeOptions.get('topic') else 'unknown' - # Extract base URL without trailing slashes - url_base = self.client._url.split('/api/v1')[0].rstrip('/') - room_id = topic.split('/')[-1] if '/' in topic else topic - streaming_url = f"{url_base}/test/streaming/{room_id}" - print(f"\033[1;36mView test in browser: \033[1;32m{streaming_url}\033[0m") + print(f"\033[1;36mView test in browser: \033[1;32m{operatorPage}\033[0m") return self From 309e9f5b1ae4ffa86f42a8a4dcc4d71859a04e11 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Mon, 28 Apr 2025 11:57:50 +0200 Subject: [PATCH 10/48] Add standard logging for streaming --- tofupilot/openhtf/tofupilot.py | 197 +++++++++++++++------------------ 1 file changed, 90 insertions(+), 107 deletions(-) diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 5e3e7d7..6ad812f 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -74,67 +74,6 @@ def run(self): def stop(self): self.stop_event.set() -def on_message(client, userdata, message): - # userdata is the structure we choose to provide, here it's a list() - parsed = json.loads(message.payload) - - if parsed["source"] == "web": - handle_answer(**parsed["message"]) - -def on_disconnect(client, userdata, disconnect_flags, reason_code, properties): - if reason_code != mqtt.MQTT_ERR_SUCCESS: - print(f"Unexpected disconnection from the streaming server: {reason_code}") - -def on_unsubscribe(client, userdata, mid, reason_code_list, properties): - if any(reason_code != mqtt.MQTT_ERR_SUCCESS for reason_code in reason_code_list): - print(f"Unexpected partial disconnection from the streaming server: {reason_code_list}") - -def handle_answer(plug_name, method_name, args): -#def post(test_uid, plug_name): - #_, test_state = self.get_test(test_uid) - _, test_state = _get_executing_test() - - if test_state is None: - return - - # Find the plug matching `plug_name`. - plug = test_state.plug_manager.get_plug_by_class_path(plug_name) - if plug is None: - #self.write('Unknown plug %s' % plug_name) - #self.set_status(404) - return - - """ - try: - #request = json.loads(self.request.body.decode('utf-8')) - method_name = request['method'] - args = request['args'] - except (KeyError, ValueError): - #self.write('Malformed JSON request.') - #self.set_status(400) - return - """ - - method = getattr(plug, method_name, None) - - if not (plug.enable_remote and isinstance(method, types.MethodType) and - not method_name.startswith('_') and - method_name not in plug.disable_remote_attrs): - #self.write('Cannot access method %s of plug %s.' % - # (method_name, plug_name)) - #self.set_status(400) - return - - try: - response = json.dumps(method(*args)) # calls userInput.respond(*args) ! - except Exception as e: # pylint: disable=broad-except - "" - #self.write('Plug error: %s' % repr(e)) - #self.set_status(500) - else: - "" - #self.write(response) - class TofuPilot: """ Context manager to automatically add an output callback to the running OpenHTF test @@ -175,56 +114,60 @@ def __init__( self.update_task = None self.mqttClient = None self.publishOptions = None + self._logger = self.client._logger def __enter__(self): - # Initialize a thread-safe asyncio.Queue self.test.add_output_callbacks( upload(api_key=self.api_key, url=self.url, client=self.client) ) if self.stream: - - # Start the SimpleStationWatcher with a callback to send updates - self.watcher = SimpleStationWatcher(self.send_update) - self.watcher.start() - - cred = self.client.get_connection_credentials() - - if not cred: - print("Failed to connect to the authn server") - return self - - # Since we control the server, we know these will be set - token = cred["token"] - operatorPage = cred["operatorPage"] - clientOptions = cred["clientOptions"] - connectOptions = cred["connectOptions"] - self.publishOptions = cred["publishOptions"] - subscribeOptions = cred["subscribeOptions"] - - self.mqttClient = mqtt.Client(callback_api_version=CallbackAPIVersion.VERSION2, **clientOptions) - - self.mqttClient.tls_set() + try: + # Start the SimpleStationWatcher with a callback to send updates + self.watcher = SimpleStationWatcher(self._send_update) + self.watcher.start() + + cred = self.client.get_connection_credentials() + + if not cred: + self._logger.warning("Streaming: Failed to connect to the authn server") + return self + + # Since we control the server, we know these will be set + token = cred["token"] + operatorPage = cred["operatorPage"] + clientOptions = cred["clientOptions"] + connectOptions = cred["connectOptions"] + self.publishOptions = cred["publishOptions"] + subscribeOptions = cred["subscribeOptions"] + + self.mqttClient = mqtt.Client(callback_api_version=CallbackAPIVersion.VERSION2, **clientOptions) + + self.mqttClient.tls_set() + + self.mqttClient.username_pw_set("pythonClient", token) + + self.mqttClient.on_message = self._on_message + self.mqttClient.on_disconnect = self._on_disconnect + self.mqttClient.on_unsubscribe = self._on_unsubscribe + + connect_error_code = self.mqttClient.connect(**connectOptions) + if(connect_error_code != mqtt.MQTT_ERR_SUCCESS): + self._logger.warning(f"Streaming: Failed to connect with the streaming server {connect_error_code}") + return self + + subscribe_error_code, messageId = self.mqttClient.subscribe(**subscribeOptions) + if(subscribe_error_code != mqtt.MQTT_ERR_SUCCESS): + self._logger.warning(f"Streaming: Failed to connect with the streaming server {subscribe_error_code}") + return self + + self.mqttClient.loop_start() + + self._logger.success(f"Streaming: Interctive stream successfully started at:\n{operatorPage}") + + except Exception as e: + self._logger.warning(f"Streaming: Error thrown during setup: {e}") - self.mqttClient.username_pw_set("pythonClient", token) - - self.mqttClient.on_message = on_message - self.mqttClient.on_disconnect = on_disconnect - self.mqttClient.on_unsubscribe = on_unsubscribe - - connect_error_code = self.mqttClient.connect(**connectOptions) - if(connect_error_code != mqtt.MQTT_ERR_SUCCESS): - print(f"failed to connect with the streaming server {connect_error_code}") - return self - - subscribe_error_code, messageId = self.mqttClient.subscribe(**subscribeOptions) - if(subscribe_error_code != mqtt.MQTT_ERR_SUCCESS): - print(f"failed to connect with the streaming server {subscribe_error_code}") - return self - - self.mqttClient.loop_start() - - print(f"\033[1;36mView test in browser: \033[1;32m{operatorPage}\033[0m") return self @@ -234,17 +177,57 @@ def __exit__(self, exc_type, exc_value, traceback): self.watcher.stop() self.watcher.join() - if self.mqttClient: # Doesnt wait for publish or other to stop ! + if self.mqttClient: # Doesn't wait for publish operation to stop, this is fine since __exit__ is only called after the run was imported self.mqttClient.loop_stop() self.mqttClient.disconnect() self.mqttClient = None - def send_update(self, message): - """Thread-safe method to send a message to the event loop.""" + def _send_update(self, message): self.mqttClient.publish( payload=json.dumps( {"action": "send", "source": "python", "message": message} ), **self.publishOptions - ) \ No newline at end of file + ) + + def _handle_answer(self, plug_name, method_name, args): + _, test_state = _get_executing_test() + + if test_state is None: + self._logger.warning(f"Streaming: Failed to find running test") + return + + # Find the plug matching `plug_name`. + plug = test_state.plug_manager.get_plug_by_class_path(plug_name) + if plug is None: + self._logger.warning(f"Streaming: Failed to find plug: {plug_name}") + return + + method = getattr(plug, method_name, None) + + if not (plug.enable_remote and isinstance(method, types.MethodType) and + not method_name.startswith('_') and + method_name not in plug.disable_remote_attrs): + self._logger.warning(f"Streaming: Failed to find method \"{method_name}\" of plug \"{plug_name}\"") + return + + try: + # side-effecting ! + method(*args) + except Exception as e: # pylint: disable=broad-except + self._logger.warning(f"Streaming: Call to {method_name}({', '.join(args)}) threw exception: {e}") + + def _on_message(self, client, userdata, message): + parsed = json.loads(message.payload) + + if parsed["source"] == "web": + self._handle_answer(**parsed["message"]) + + def _on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties): + if reason_code != mqtt.MQTT_ERR_SUCCESS: + self._logger.warning(f"Streaming: Unexpected disconnection from the streaming server: {reason_code}") + + def _on_unsubscribe(self, client, userdata, mid, reason_code_list, properties): + if any(reason_code != mqtt.MQTT_ERR_SUCCESS for reason_code in reason_code_list): + self._logger.warning(f"Unexpected partial disconnection from the streaming server: {reason_code_list}") \ No newline at end of file From 70d4c98ce77411f3b52b42cb64b222e3904cc446 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Mon, 28 Apr 2025 13:19:25 +0200 Subject: [PATCH 11/48] Improve contributin onboarding --- CONTRIBUTING.md | 7 ++++--- requirements.txt | 4 +++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1227cad..7ad8d44 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,21 +92,22 @@ When the release PR is merged, and a new release is detected by the “Python pu If you need to test a new version of the Python client before making an official release, you can publish it to TestPyPI, a sandbox version of PyPI used for testing package distributions. -1. Build the package locally using: +1. If a previous test package with the exact same version was released, update the version in `setup.py`. For instance, change version="X.Y.Z.dev0" to version="X.Y.Z.dev1". +2. Build the package locally using: ```sh rm -rf dist/* python -m build ``` This will generate distribution files in the dist/ directory. -2. If a previous test package with the exact same version was released, update the version in `setup.py`. For instance, change version="X.Y.Z.dev0" to version="X.Y.Z.dev1". +3. Get a testpypi API key, for example ask your managment if they have one. Then run: ```sh twine upload --repository testpypi dist/ ``` -3. To install the new test package, run: +4. To install the new test package, run: ```sh pip install -i https://test.pypi.org/simple/ --extra-index-url https://pypi.org/simple/ tofupilot== ``` diff --git a/requirements.txt b/requirements.txt index 9d4ea4a..e4d394a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,6 @@ setuptools packaging pytest websockets -paho-mqtt \ No newline at end of file +paho-mqtt +build +twine \ No newline at end of file From 657437359480e806cda5fd66e5115071d6533573 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Mon, 28 Apr 2025 13:26:36 +0200 Subject: [PATCH 12/48] Fix twine command --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ad8d44..5bd9002 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -104,7 +104,7 @@ If you need to test a new version of the Python client before making an official Then run: ```sh - twine upload --repository testpypi dist/ + twine upload --repository testpypi dist/* ``` 4. To install the new test package, run: From 1c4767acc3539a035fa3519de1800de5c5a44224 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Tue, 29 Apr 2025 11:27:24 +0200 Subject: [PATCH 13/48] Add will to python client Syncronised with commit of same name in the web app --- tofupilot/openhtf/tofupilot.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 6ad812f..356fd33 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -137,6 +137,7 @@ def __enter__(self): token = cred["token"] operatorPage = cred["operatorPage"] clientOptions = cred["clientOptions"] + willOptions = cred["willOptions"] connectOptions = cred["connectOptions"] self.publishOptions = cred["publishOptions"] subscribeOptions = cred["subscribeOptions"] @@ -144,6 +145,8 @@ def __enter__(self): self.mqttClient = mqtt.Client(callback_api_version=CallbackAPIVersion.VERSION2, **clientOptions) self.mqttClient.tls_set() + + self.mqttClient.will_set(**willOptions) self.mqttClient.username_pw_set("pythonClient", token) From 4f42a216f173be765583644cef322fc55d1b8fb4 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 30 Apr 2025 09:44:40 +0200 Subject: [PATCH 14/48] Fix dependency missing from package --- setup.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 2cc13e2..9020af9 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,14 @@ name="tofupilot", version="1.9.4", packages=find_packages(), - install_requires=["requests", "setuptools", "packaging", "pytest", "websockets"], + install_requires=[ + "requests", + "setuptools", + "packaging", + "pytest", + "websockets", + "paho-mqtt", + ], entry_points={ "pytest11": [ "tofupilot = tofupilot.plugin", # Registering the pytest plugin From 0735b830d9891a72e8131356f3700bfdc21979c6 Mon Sep 17 00:00:00 2001 From: Quentin Bernet Date: Wed, 30 Apr 2025 16:10:08 +0200 Subject: [PATCH 15/48] Fix confusing error message when the api key is incorrect The callback on change was sent whether or not the mqtt server could connect, which lead to the issue --- tofupilot/openhtf/tofupilot.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 356fd33..41e96c3 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -123,10 +123,6 @@ def __enter__(self): if self.stream: try: - # Start the SimpleStationWatcher with a callback to send updates - self.watcher = SimpleStationWatcher(self._send_update) - self.watcher.start() - cred = self.client.get_connection_credentials() if not cred: @@ -165,6 +161,9 @@ def __enter__(self): return self self.mqttClient.loop_start() + + self.watcher = SimpleStationWatcher(self._send_update) + self.watcher.start() self._logger.success(f"Streaming: Interctive stream successfully started at:\n{operatorPage}") From ae61b66d49d23029c2a5abfa91732984e39a82c6 Mon Sep 17 00:00:00 2001 From: mrousse Date: Fri, 2 May 2025 16:19:02 +0200 Subject: [PATCH 16/48] Fix SSL certificate verification issues Automatically set SSL_CERT_FILE to use certifi's certificate bundle to solve SSL verification failures. This addresses the [SSL: CERTIFICATE_VERIFY_FAILED] error that occurs when the Python installation doesn't have access to trusted CA certificates. - Add certifi dependency to requirements.txt - Implement automatic SSL certificate configuration in client - Enhance error handling to provide better guidance for SSL errors --- requirements.txt | 3 ++- tofupilot/client.py | 17 ++++++++++++++++- tofupilot/utils/network.py | 15 +++++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/requirements.txt b/requirements.txt index e4d394a..b4e5b37 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ pytest websockets paho-mqtt build -twine \ No newline at end of file +twine +certifi>=2023.7.22 \ No newline at end of file diff --git a/tofupilot/client.py b/tofupilot/client.py index f80e25f..fc06c12 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -10,6 +10,7 @@ import json import base64 import requests +import certifi from .constants import ( ENDPOINT, @@ -41,6 +42,9 @@ def __init__(self, api_key: Optional[str] = None, url: Optional[str] = None): print_version_banner(self._current_version) self._logger = setup_logger(logging.INFO) + # Configure SSL certificate validation + self._setup_ssl_certificates() + self._api_key = api_key or os.environ.get("TOFUPILOT_API_KEY") if self._api_key is None: error = "Please set TOFUPILOT_API_KEY environment variable. For more information on how to find or generate a valid API key, visit https://tofupilot.com/docs/user-management#api-key." # pylint: disable=line-too-long @@ -55,6 +59,17 @@ def __init__(self, api_key: Optional[str] = None, url: Optional[str] = None): self._max_attachments = CLIENT_MAX_ATTACHMENTS self._max_file_size = FILE_MAX_SIZE check_latest_version(self._logger, self._current_version, "tofupilot") + + def _setup_ssl_certificates(self): + """Configure SSL certificate validation using certifi if needed.""" + # Check if SSL_CERT_FILE is already set to a valid path + cert_file = os.environ.get('SSL_CERT_FILE') + if not cert_file or not os.path.isfile(cert_file): + # Use certifi's certificate bundle + certifi_path = certifi.where() + if os.path.isfile(certifi_path): + os.environ['SSL_CERT_FILE'] = certifi_path + self._logger.debug(f"Set SSL_CERT_FILE to certifi's path: {certifi_path}") def _log_request(self, method: str, endpoint: str, payload: Optional[dict] = None): """Logs the details of the HTTP request.""" @@ -496,4 +511,4 @@ def print_version_banner(current_version: str): banner = f""" TofuPilot Python Client {current_version} """ - print(banner.strip()) + print(banner.strip()) \ No newline at end of file diff --git a/tofupilot/utils/network.py b/tofupilot/utils/network.py index 5adae43..bd5d1cd 100644 --- a/tofupilot/utils/network.py +++ b/tofupilot/utils/network.py @@ -74,11 +74,22 @@ def handle_http_error( def handle_network_error(logger, e: requests.RequestException) -> Dict[str, Any]: """Handles network errors and logs them.""" - logger.error(f"Network error: {e}") + error_message = str(e) + logger.error(f"Network error: {error_message}") + + # Provide specific guidance for SSL certificate errors + if isinstance(e, requests.exceptions.SSLError) or "SSL" in error_message or "certificate verify failed" in error_message: + logger.warning("SSL certificate verification error detected") + logger.warning("This is typically caused by missing or invalid SSL certificates") + logger.warning("Try the following solutions:") + logger.warning("1. Ensure the certifi package is installed: pip install certifi") + logger.warning("2. If you're on macOS, run: /Applications/Python*/Install Certificates.command") + logger.warning("3. You can manually set the SSL_CERT_FILE environment variable: export SSL_CERT_FILE=/path/to/cacert.pem") + return { "success": False, "message": None, "warnings": None, "status_code": None, - "error": {"message": str(e)}, + "error": {"message": error_message}, } From 34981a8644b217580e82f9040ae8a42319654701 Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Mon, 5 May 2025 18:54:41 +0200 Subject: [PATCH 17/48] Improves the logger experience --- tofupilot/utils/logger.py | 192 +++++++++++++++++++++++++++++++++----- 1 file changed, 168 insertions(+), 24 deletions(-) diff --git a/tofupilot/utils/logger.py b/tofupilot/utils/logger.py index 4cc2efd..e2db8a4 100644 --- a/tofupilot/utils/logger.py +++ b/tofupilot/utils/logger.py @@ -1,5 +1,9 @@ import logging import sys +import os +import time +import threading +from datetime import datetime # Define a custom log level for success messages SUCCESS_LEVEL_NUM = 25 @@ -7,7 +11,7 @@ def success(self, message, *args, **kws): - """Log a success message.""" + """Log success at custom level.""" if self.isEnabledFor(SUCCESS_LEVEL_NUM): self._log(SUCCESS_LEVEL_NUM, message, args, **kws) @@ -15,24 +19,105 @@ def success(self, message, *args, **kws): logging.Logger.success = success +class TofupilotFormatter(logging.Formatter): + """Dev-friendly formatter with colors, timing, and thread info.""" + + # ANSI color codes + RESET = "\033[0m" + BLUE = "\033[0;34m" + GREEN = "\033[0;32m" + YELLOW = "\033[0;33m" + RED = "\033[0;31m" + RED_BG = "\033[1;41m" + GRAY = "\033[0;37m" + BOLD = "\033[1m" + + # Log level name mapping + LEVEL_NAMES = { + logging.DEBUG: "DBG", + logging.INFO: "INF", + logging.WARNING: "WRN", + logging.ERROR: "ERR", + logging.CRITICAL: "CRT", + SUCCESS_LEVEL_NUM: "OK!", + } + + # Color mapping for levels + LEVEL_COLORS = { + logging.DEBUG: GRAY, + logging.INFO: BLUE, + logging.WARNING: YELLOW, + logging.ERROR: RED, + logging.CRITICAL: RED_BG, + SUCCESS_LEVEL_NUM: GREEN, + } + + def __init__(self): + """Init with timing trackers.""" + super().__init__() + self.start_time = time.time() + self.lock = threading.Lock() + # Thread local storage for timing information + self.local = threading.local() + self.local.last_time = self.start_time + + def format(self, record): + """Format log with timing, colors and thread info.""" + # Calculate timing information - thread safe + current_time = time.time() + + # Initialize thread-local storage for first access + if not hasattr(self.local, 'last_time'): + self.local.last_time = self.start_time + + # Calculate elapsed times + elapsed_total = current_time - self.start_time + elapsed_since_last = current_time - self.local.last_time + self.local.last_time = current_time + + # Get log level color and short name + level_color = self.LEVEL_COLORS.get(record.levelno, self.RESET) + level_name = self.LEVEL_NAMES.get(record.levelno, "???") + + # Get thread name/id for concurrent operations + thread_info = "" + if threading.active_count() > 1: + current_thread = threading.current_thread() + if current_thread.name != "MainThread": + thread_info = f"[{current_thread.name}] " + + # Format time as HH:MM:SS.mmm + time_str = datetime.fromtimestamp(record.created).strftime("%H:%M:%S.%f")[:-3] + + # Construct log message with contextual information + elapsed_str = f"+{elapsed_since_last:.3f}s" + prefix = f"{level_color}{time_str} {self.BOLD}TP{self.RESET}{level_color}:{level_name} {elapsed_str} {thread_info}" + + # Add log message + message = record.getMessage() + formatted_message = f"{prefix}{message}{self.RESET}" + + # Add exception info if present + if record.exc_info: + exc_text = self.formatException(record.exc_info) + formatted_message += f"\n{exc_text}" + + return formatted_message + + +# Legacy formatter for backward compatibility class CustomFormatter(logging.Formatter): - """Custom formatter to add symbols and colors to the log messages.""" + """Custom formatter with minimal styling.""" reset_code = "\033[0m" format_dict = { - logging.DEBUG: "\033[0;37m%(asctime)s - DEBUG: %(message)s" - + reset_code, # White - logging.INFO: "\033[0;34m%(asctime)s - ℹ️ %(message)s" - + reset_code, # Blue with info symbol - logging.WARNING: "\033[0;33m%(asctime)s - ⚠️ %(message)s" - + reset_code, # Yellow with warning symbol - logging.ERROR: "\033[0;31m%(asctime)s - ❌ %(message)s" - + reset_code, # Red with cross mark - logging.CRITICAL: "\033[1;41m%(asctime)s - 🚨 %(message)s" - + reset_code, # White on red background with alarm symbol - SUCCESS_LEVEL_NUM: "\033[0;32m%(asctime)s - ✅ %(message)s" - + reset_code, # Green with checkmark + logging.DEBUG: "\033[0;37m%(asctime)s - DEBUG: %(message)s" + reset_code, + logging.INFO: "\033[0;34m%(asctime)s - INFO: %(message)s" + reset_code, + logging.WARNING: "\033[0;33m%(asctime)s - WARN: %(message)s" + reset_code, + logging.ERROR: "\033[0;31m%(asctime)s - ERROR: %(message)s" + reset_code, + logging.CRITICAL: "\033[1;41m%(asctime)s - CRIT: %(message)s" + reset_code, + SUCCESS_LEVEL_NUM: "\033[0;32m%(asctime)s - SUCCESS: %(message)s" + reset_code, } def format(self, record): @@ -42,14 +127,73 @@ def format(self, record): return formatter.format(record) -def setup_logger(log_level: int): - """Set up the logger with a custom formatter and stream handler.""" - logger = logging.getLogger(__name__) - logger.setLevel(log_level) - handler = logging.StreamHandler(sys.stdout) - handler.setLevel(log_level) - handler.setFormatter(CustomFormatter()) - if not logger.handlers: - logger.addHandler(handler) +class LogLevelFilter(logging.Filter): + """Sets log level from environment variable.""" + + def __init__(self, env_var='TOFUPILOT_LOG_LEVEL'): + """Init with env var name.""" + super().__init__() + self.env_var = env_var + self.level = self._get_level_from_env() + + def _get_level_from_env(self): + """Get level from env var.""" + level_str = os.environ.get(self.env_var, 'INFO').upper() + level_map = { + 'DEBUG': logging.DEBUG, + 'INFO': logging.INFO, + 'SUCCESS': SUCCESS_LEVEL_NUM, + 'WARNING': logging.WARNING, + 'WARN': logging.WARNING, + 'ERROR': logging.ERROR, + 'CRITICAL': logging.CRITICAL + } + return level_map.get(level_str, logging.INFO) + + def filter(self, record): + """Apply dynamic level filtering.""" + # Reload level from env for each record to allow runtime changes + self.level = self._get_level_from_env() + return record.levelno >= self.level - return logger + +def setup_logger(log_level=None, advanced_format=True): + """Configure logger with timing, thread tracking and color support. + + Args: + log_level: Override env var TOFUPILOT_LOG_LEVEL + advanced_format: Use advanced formatting (default True) + + Returns: + Configured logger instance + """ + # Maintain backward compatibility with __name__ + logger_name = "tofupilot" + logger = logging.getLogger(logger_name) + + # Clear existing handlers + if logger.handlers: + logger.handlers.clear() + + # Set level from arg or environment + level_filter = LogLevelFilter() + if log_level is not None: + logger.setLevel(log_level) + else: + logger.setLevel(level_filter.level) + + # Create stdout handler + handler = logging.StreamHandler(sys.stdout) + + # Choose formatter based on preference + if advanced_format: + handler.setFormatter(TofupilotFormatter()) + else: + handler.setFormatter(CustomFormatter()) + + handler.addFilter(level_filter) + + # Add handler to logger + logger.addHandler(handler) + + return logger \ No newline at end of file From efa7e9d4e94ead23d6f8f8416cf7e77d384baf3e Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Mon, 5 May 2025 18:56:20 +0200 Subject: [PATCH 18/48] Fixed attachments uploads when openhtf import fails --- tofupilot/client.py | 121 +++++++++++++++++++++++++++++--------------- 1 file changed, 79 insertions(+), 42 deletions(-) diff --git a/tofupilot/client.py b/tofupilot/client.py index fc06c12..6b9528e 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -208,22 +208,40 @@ def create_run_from_openhtf_report(self, file_path: str): """ # Upload report and create run from file_path run_id = self.upload_and_create_from_openhtf_report(file_path) + + # If run_id is not a string, it's an error response dictionary + if not isinstance(run_id, str): + self._logger.error("Failed to create run from OpenHTF report") + return run_id + # Only continue with attachment upload if run_id is valid + test_record = None try: with open(file_path, "r", encoding="utf-8") as file: test_record = json.load(file) except FileNotFoundError: - print(f"Error: The file '{file_path}' was not found.") + self._logger.error(f"Error: The file '{file_path}' was not found.") + return run_id except json.JSONDecodeError: - print(f"Error: The file '{file_path}' contains invalid JSON.") + self._logger.error(f"Error: The file '{file_path}' contains invalid JSON.") + return run_id except PermissionError: - print(f"Error: Insufficient permissions to read '{file_path}'.") + self._logger.error(f"Error: Insufficient permissions to read '{file_path}'.") + return run_id except Exception as e: - print(f"Unexpected error: {e}") + self._logger.error(f"Unexpected error: {e}") + return run_id - if run_id and test_record: + # Now safely proceed with attachment upload + if run_id and test_record and "phases" in test_record: + self._logger.info("Run created successfully, uploading attachments...") number_of_attachments = 0 - for phase in test_record.get("phases"): + + for phase in test_record.get("phases", []): + # Skip if phase has no attachments + if not phase.get("attachments"): + continue + # Keep only max number of attachments if number_of_attachments >= self._max_attachments: self._logger.warning( @@ -231,45 +249,64 @@ def create_run_from_openhtf_report(self, file_path: str): self._max_attachments, ) break - for attachment_name, attachment in phase.get("attachments").items(): + + for attachment_name, attachment in phase.get("attachments", {}).items(): number_of_attachments += 1 - self._logger.info("Uploading %s...", attachment_name) - # Upload initialization - initialize_url = f"{self._url}/uploads/initialize" - payload = {"name": attachment_name} - - response = requests.post( - initialize_url, - data=json.dumps(payload), - headers=self._headers, - timeout=SECONDS_BEFORE_TIMEOUT, - ) - - response.raise_for_status() - response_json = response.json() - upload_url = response_json.get("uploadUrl") - upload_id = response_json.get("id") - - data = base64.b64decode(attachment["data"]) - - requests.put( - upload_url, - data=data, - headers={ - "Content-Type": attachment["mimetype"] - or "application/octet-stream", # Default to binary if mimetype is missing - }, - timeout=SECONDS_BEFORE_TIMEOUT, - ) - - notify_server(self._headers, self._url, upload_id, run_id) - - self._logger.success( - "Attachment %s successfully uploaded and linked to run.", - attachment_name, - ) + try: + # Upload initialization + initialize_url = f"{self._url}/uploads/initialize" + payload = {"name": attachment_name} + + response = requests.post( + initialize_url, + data=json.dumps(payload), + headers=self._headers, + timeout=SECONDS_BEFORE_TIMEOUT, + ) + + response.raise_for_status() + response_json = response.json() + upload_url = response_json.get("uploadUrl") + upload_id = response_json.get("id") + + # Ensure attachment data exists and is valid + if not attachment.get("data"): + self._logger.warning(f"Attachment {attachment_name} has no data, skipping") + continue + + data = base64.b64decode(attachment["data"]) + + # Upload attachment data + upload_response = requests.put( + upload_url, + data=data, + headers={ + "Content-Type": attachment.get("mimetype", "application/octet-stream") + }, + timeout=SECONDS_BEFORE_TIMEOUT, + ) + upload_response.raise_for_status() + + # Notify server to link attachment to run + notify_server(self._headers, self._url, upload_id, run_id) + + self._logger.success( + "Attachment %s successfully uploaded and linked to run.", + attachment_name, + ) + except requests.exceptions.RequestException as e: + self._logger.error(f"Failed to upload attachment {attachment_name}: {str(e)}") + # Continue with other attachments even if one fails + continue + else: + if not test_record: + self._logger.error("Test record could not be loaded") + elif "phases" not in test_record: + self._logger.error("Test record has no phases") + + return run_id def get_runs(self, serial_number: str) -> dict: """ From ed4cdcd23024ee55bc34131db9487a31289b71bc Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Mon, 5 May 2025 19:04:51 +0200 Subject: [PATCH 19/48] Uniformizes the log messages --- tofupilot/client.py | 51 ++++++++++++++---------------- tofupilot/openhtf/tofupilot.py | 22 ++++++------- tofupilot/openhtf/upload.py | 15 ++++----- tofupilot/utils/files.py | 6 ++-- tofupilot/utils/version_checker.py | 4 +-- 5 files changed, 44 insertions(+), 54 deletions(-) diff --git a/tofupilot/client.py b/tofupilot/client.py index 6b9528e..2927f21 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -48,7 +48,7 @@ def __init__(self, api_key: Optional[str] = None, url: Optional[str] = None): self._api_key = api_key or os.environ.get("TOFUPILOT_API_KEY") if self._api_key is None: error = "Please set TOFUPILOT_API_KEY environment variable. For more information on how to find or generate a valid API key, visit https://tofupilot.com/docs/user-management#api-key." # pylint: disable=line-too-long - self._logger.error(error) + self._logger.error(f"API key error: {error}") sys.exit(1) self._url = f"{url or os.environ.get('TOFUPILOT_URL') or ENDPOINT}/api/v1" @@ -69,12 +69,12 @@ def _setup_ssl_certificates(self): certifi_path = certifi.where() if os.path.isfile(certifi_path): os.environ['SSL_CERT_FILE'] = certifi_path - self._logger.debug(f"Set SSL_CERT_FILE to certifi's path: {certifi_path}") + self._logger.debug(f"SSL: Using certifi path {certifi_path}") def _log_request(self, method: str, endpoint: str, payload: Optional[dict] = None): """Logs the details of the HTTP request.""" self._logger.debug( - "%s %s%s with payload: %s", method, self._url, endpoint, payload + "Request: %s %s%s payload=%s", method, self._url, endpoint, payload ) def create_run( # pylint: disable=too-many-arguments,too-many-locals @@ -126,7 +126,7 @@ def create_run( # pylint: disable=too-many-arguments,too-many-locals References: https://www.tofupilot.com/docs/api#create-a-run """ - self._logger.info("Starting run creation...") + self._logger.info("Creating run...") if attachments is not None: validate_files( @@ -211,7 +211,7 @@ def create_run_from_openhtf_report(self, file_path: str): # If run_id is not a string, it's an error response dictionary if not isinstance(run_id, str): - self._logger.error("Failed to create run from OpenHTF report") + self._logger.error("OpenHTF import failed") return run_id # Only continue with attachment upload if run_id is valid @@ -220,21 +220,21 @@ def create_run_from_openhtf_report(self, file_path: str): with open(file_path, "r", encoding="utf-8") as file: test_record = json.load(file) except FileNotFoundError: - self._logger.error(f"Error: The file '{file_path}' was not found.") + self._logger.error(f"File not found: {file_path}") return run_id except json.JSONDecodeError: - self._logger.error(f"Error: The file '{file_path}' contains invalid JSON.") + self._logger.error(f"Invalid JSON: {file_path}") return run_id except PermissionError: - self._logger.error(f"Error: Insufficient permissions to read '{file_path}'.") + self._logger.error(f"Permission denied: {file_path}") return run_id except Exception as e: - self._logger.error(f"Unexpected error: {e}") + self._logger.error(f"Error: {e}") return run_id # Now safely proceed with attachment upload if run_id and test_record and "phases" in test_record: - self._logger.info("Run created successfully, uploading attachments...") + self._logger.info("Run created, uploading attachments") number_of_attachments = 0 for phase in test_record.get("phases", []): @@ -245,14 +245,14 @@ def create_run_from_openhtf_report(self, file_path: str): # Keep only max number of attachments if number_of_attachments >= self._max_attachments: self._logger.warning( - "Too many attachments, trimming to %d attachments.", - self._max_attachments, + "Attachment limit (%d) reached", + self._max_attachments ) break for attachment_name, attachment in phase.get("attachments", {}).items(): number_of_attachments += 1 - self._logger.info("Uploading %s...", attachment_name) + self._logger.info("Uploading: %s", attachment_name) try: # Upload initialization @@ -273,7 +273,7 @@ def create_run_from_openhtf_report(self, file_path: str): # Ensure attachment data exists and is valid if not attachment.get("data"): - self._logger.warning(f"Attachment {attachment_name} has no data, skipping") + self._logger.warning(f"No data in: {attachment_name}") continue data = base64.b64decode(attachment["data"]) @@ -292,19 +292,16 @@ def create_run_from_openhtf_report(self, file_path: str): # Notify server to link attachment to run notify_server(self._headers, self._url, upload_id, run_id) - self._logger.success( - "Attachment %s successfully uploaded and linked to run.", - attachment_name, - ) + self._logger.success("Uploaded: %s", attachment_name) except requests.exceptions.RequestException as e: - self._logger.error(f"Failed to upload attachment {attachment_name}: {str(e)}") + self._logger.error(f"Upload failed: {attachment_name} - {str(e)}") # Continue with other attachments even if one fails continue else: if not test_record: - self._logger.error("Test record could not be loaded") + self._logger.error("Test record load failed") elif "phases" not in test_record: - self._logger.error("Test record has no phases") + self._logger.error("No phases in test record") return run_id @@ -334,9 +331,7 @@ def get_runs(self, serial_number: str) -> dict: "error": {"message": error_message}, } - self._logger.info( - "Fetching runs for unit with serial number %s...", serial_number - ) + self._logger.info("Fetching runs for: %s", serial_number) params = {"serial_number": serial_number} self._log_request("GET", "/runs", params) @@ -370,7 +365,7 @@ def delete_run(self, run_id: str) -> dict: References: https://www.tofupilot.com/docs/api#delete-a-run """ - self._logger.info('Starting deletion of run "%s"...', run_id) + self._logger.info('Deleting run: %s', run_id) self._log_request("DELETE", f"/runs/{run_id}") @@ -407,7 +402,7 @@ def update_unit( References: https://www.tofupilot.com/docs/api#update-a-unit """ - self._logger.info('Starting update of unit "%s"...', serial_number) + self._logger.info('Updating unit: %s', serial_number) payload = {"sub_units": sub_units} @@ -443,7 +438,7 @@ def delete_unit(self, serial_number: str) -> dict: References: https://www.tofupilot.com/docs/api#delete-a-unit """ - self._logger.info('Starting deletion of unit "%s"...', serial_number) + self._logger.info('Deleting unit: %s', serial_number) self._log_request("DELETE", f"/units/{serial_number}") @@ -473,7 +468,7 @@ def upload_and_create_from_openhtf_report( Id of the newly created run """ - self._logger.info("Starting run creation...") + self._logger.info("Creating run...") # Validate report validate_files( diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 41e96c3..4f1b934 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -126,7 +126,7 @@ def __enter__(self): cred = self.client.get_connection_credentials() if not cred: - self._logger.warning("Streaming: Failed to connect to the authn server") + self._logger.warning("Stream: Auth server connection failed") return self # Since we control the server, we know these will be set @@ -152,12 +152,12 @@ def __enter__(self): connect_error_code = self.mqttClient.connect(**connectOptions) if(connect_error_code != mqtt.MQTT_ERR_SUCCESS): - self._logger.warning(f"Streaming: Failed to connect with the streaming server {connect_error_code}") + self._logger.warning(f"Stream: Connect failed (code {connect_error_code})") return self subscribe_error_code, messageId = self.mqttClient.subscribe(**subscribeOptions) if(subscribe_error_code != mqtt.MQTT_ERR_SUCCESS): - self._logger.warning(f"Streaming: Failed to connect with the streaming server {subscribe_error_code}") + self._logger.warning(f"Stream: Subscribe failed (code {subscribe_error_code})") return self self.mqttClient.loop_start() @@ -165,10 +165,10 @@ def __enter__(self): self.watcher = SimpleStationWatcher(self._send_update) self.watcher.start() - self._logger.success(f"Streaming: Interctive stream successfully started at:\n{operatorPage}") + self._logger.success(f"Stream: Started at:\n{operatorPage}") except Exception as e: - self._logger.warning(f"Streaming: Error thrown during setup: {e}") + self._logger.warning(f"Stream: Setup error - {e}") return self @@ -197,13 +197,13 @@ def _handle_answer(self, plug_name, method_name, args): _, test_state = _get_executing_test() if test_state is None: - self._logger.warning(f"Streaming: Failed to find running test") + self._logger.warning("Stream: No running test found") return # Find the plug matching `plug_name`. plug = test_state.plug_manager.get_plug_by_class_path(plug_name) if plug is None: - self._logger.warning(f"Streaming: Failed to find plug: {plug_name}") + self._logger.warning(f"Stream: Plug not found - {plug_name}") return method = getattr(plug, method_name, None) @@ -211,14 +211,14 @@ def _handle_answer(self, plug_name, method_name, args): if not (plug.enable_remote and isinstance(method, types.MethodType) and not method_name.startswith('_') and method_name not in plug.disable_remote_attrs): - self._logger.warning(f"Streaming: Failed to find method \"{method_name}\" of plug \"{plug_name}\"") + self._logger.warning(f"Stream: Method not found - {plug_name}.{method_name}") return try: # side-effecting ! method(*args) except Exception as e: # pylint: disable=broad-except - self._logger.warning(f"Streaming: Call to {method_name}({', '.join(args)}) threw exception: {e}") + self._logger.warning(f"Stream: Method call failed - {method_name}({', '.join(args)}) - {e}") def _on_message(self, client, userdata, message): parsed = json.loads(message.payload) @@ -228,8 +228,8 @@ def _on_message(self, client, userdata, message): def _on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties): if reason_code != mqtt.MQTT_ERR_SUCCESS: - self._logger.warning(f"Streaming: Unexpected disconnection from the streaming server: {reason_code}") + self._logger.warning(f"Stream: Unexpected disconnect (code {reason_code})") def _on_unsubscribe(self, client, userdata, mid, reason_code_list, properties): if any(reason_code != mqtt.MQTT_ERR_SUCCESS for reason_code in reason_code_list): - self._logger.warning(f"Unexpected partial disconnection from the streaming server: {reason_code_list}") \ No newline at end of file + self._logger.warning(f"Stream: Partial disconnect (codes {reason_code_list})") \ No newline at end of file diff --git a/tofupilot/openhtf/upload.py b/tofupilot/openhtf/upload.py index 42a3a93..e7b724f 100644 --- a/tofupilot/openhtf/upload.py +++ b/tofupilot/openhtf/upload.py @@ -105,17 +105,17 @@ def __call__(self, test_record: TestRecord): # Keep only max number of attachments if number_of_attachments >= self._max_attachments: self._logger.warning( - "Too many attachments, trimming to %d attachments.", - self._max_attachments, + "Attachment limit (%d) reached", + self._max_attachments ) break for attachment_name, attachment in phase.attachments.items(): # Remove attachments that exceed the max file size if attachment.size > self._max_file_size: self._logger.warning( - "File size exceeds the maximum allowed size of %d bytes: %s", + "File too large (%d bytes): %s", self._max_file_size, - attachment.name, + attachment.name ) continue if number_of_attachments >= self._max_attachments: @@ -123,7 +123,7 @@ def __call__(self, test_record: TestRecord): number_of_attachments += 1 - self._logger.info("Uploading %s...", attachment_name) + self._logger.info("Uploading: %s", attachment_name) # Upload initialization initialize_url = f"{self._url}/uploads/initialize" @@ -150,7 +150,4 @@ def __call__(self, test_record: TestRecord): notify_server(self._headers, self._url, upload_id, run_id) - self._logger.success( - "Attachment %s successfully uploaded and linked to run.", - attachment_name, - ) + self._logger.success("Uploaded: %s", attachment_name) diff --git a/tofupilot/utils/files.py b/tofupilot/utils/files.py index e8d3cf1..f8cedee 100644 --- a/tofupilot/utils/files.py +++ b/tofupilot/utils/files.py @@ -102,11 +102,9 @@ def upload_attachments( ): """Creates one upload per file and stores them into TofuPilot""" for file_path in paths: - logger.info("Uploading %s...", file_path) + logger.info("Uploading: %s", file_path) upload_id = upload_file(headers, url, file_path) notify_server(headers, url, upload_id, run_id) - logger.success( - f"Attachment {file_path} successfully uploaded and linked to run." - ) + logger.success(f"Uploaded: {file_path}") diff --git a/tofupilot/utils/version_checker.py b/tofupilot/utils/version_checker.py index b017acc..b823519 100644 --- a/tofupilot/utils/version_checker.py +++ b/tofupilot/utils/version_checker.py @@ -22,7 +22,7 @@ def check_latest_version(logger, current_version, package_name: str): ) logger.warning(warning_message) except PackageNotFoundError: - logger.info(f"Package {package_name} is not installed.") + logger.info(f"Package not installed: {package_name}") except requests.RequestException as e: - logger.warning(f"Error checking the latest version: {e}") + logger.warning(f"Version check failed: {e}") From 4eefdf201ebb500f6a171d239f4fce52fca8937d Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Mon, 5 May 2025 19:13:40 +0200 Subject: [PATCH 20/48] Adds TofuPilot banner to client call --- tofupilot/client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tofupilot/client.py b/tofupilot/client.py index 2927f21..8c14218 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -539,8 +539,9 @@ def get_connection_credentials(self) -> dict: return None def print_version_banner(current_version: str): - """Prints current version of client""" + """Prints current version of client with tofu art""" banner = f""" - TofuPilot Python Client {current_version} + ╭ ✈ ╮ + [•ᴗ•] TofuPilot Python Client {current_version} """ print(banner.strip()) \ No newline at end of file From 2322bcc482b80997c1623cc89aac2c12700014a7 Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Mon, 5 May 2025 19:21:17 +0200 Subject: [PATCH 21/48] WIP: custom openhtf prompts --- tofupilot/openhtf/__init__.py | 16 +++- tofupilot/openhtf/custom_prompt.py | 118 +++++++++++++++++++++++++++++ tofupilot/openhtf/tofupilot.py | 4 + 3 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 tofupilot/openhtf/custom_prompt.py diff --git a/tofupilot/openhtf/__init__.py b/tofupilot/openhtf/__init__.py index 8f9ff42..9503910 100644 --- a/tofupilot/openhtf/__init__.py +++ b/tofupilot/openhtf/__init__.py @@ -1,10 +1,24 @@ """ This module handles all TofuPilot methods related to integration with OpenHTF. -It provides two main classes: +It provides the following functionality: 1. tofupilot.upload(): A way to interface with OpenHTF test scripts to automatically upload test results to the TofuPilot server. 2. tofupilot.TofuPilot(): A way to stream real-time execution data of OpenHTF tests to TofuPilot for live monitoring. +3. Enhanced prompts: Automatically include TofuPilot URLs in OpenHTF prompts, making it easy for test operators to access the live view. """ from .upload import upload from .tofupilot import TofuPilot +from .custom_prompt import ( + patch_openhtf_prompts, + prompt_with_tofupilot_url, + enhanced_prompt_for_test_start, +) + +__all__ = [ + 'TofuPilot', + 'upload', + 'patch_openhtf_prompts', + 'prompt_with_tofupilot_url', + 'enhanced_prompt_for_test_start', +] diff --git a/tofupilot/openhtf/custom_prompt.py b/tofupilot/openhtf/custom_prompt.py new file mode 100644 index 0000000..074e4d5 --- /dev/null +++ b/tofupilot/openhtf/custom_prompt.py @@ -0,0 +1,118 @@ +"""Module for custom OpenHTF user prompt integration with TofuPilot.""" + +import functools +import logging +import os +from typing import Text, Optional, Union, Callable + +import openhtf +from openhtf import plugs +from openhtf.plugs import user_input +from openhtf.plugs.user_input import UserInput + +_LOG = logging.getLogger(__name__) + +def prompt_with_tofupilot_url( + message: Text, + operator_page_url: Optional[Text] = None, + text_input: bool = False, + timeout_s: Union[int, float, None] = None, + cli_color: Text = '', + image_url: Optional[Text] = None) -> Text: + """Enhanced prompt that includes TofuPilot URL in the message. + + Args: + message: A string to be presented to the user. + operator_page_url: URL to the TofuPilot operator page. + text_input: A boolean indicating whether the user must respond with text. + timeout_s: Seconds to wait before raising a PromptUnansweredError. + cli_color: An ANSI color code, or the empty string. + image_url: Optional image URL to display or None. + + Returns: + A string response, or the empty string if text_input was False. + """ + # Get the UserInput plug instance + prompts = plugs.get_plug_instance(UserInput) + + # Add TofuPilot URL to the message + enhanced_message = message + if operator_page_url: + enhanced_message = f"{message}\n\n🔍 Live test view: {operator_page_url}" + + # Call the standard prompt method with enhanced message + return prompts.prompt( + enhanced_message, + text_input=text_input, + timeout_s=timeout_s, + cli_color=cli_color, + image_url=image_url + ) + + +def enhanced_prompt_for_test_start( + operator_page_url: Optional[Text] = None, + message: Text = 'Enter a DUT ID in order to start the test.', + timeout_s: Union[int, float, None] = 60 * 60 * 24, + validator: Callable[[Text], Text] = lambda sn: sn, + cli_color: Text = '') -> openhtf.PhaseDescriptor: + """Returns an OpenHTF phase that includes TofuPilot URL in the prompt. + + Args: + operator_page_url: URL to the TofuPilot operator page. + message: The message to display to the user. + timeout_s: Seconds to wait before raising a PromptUnansweredError. + validator: Function used to validate or modify the serial number. + cli_color: An ANSI color code, or the empty string. + """ + + @openhtf.PhaseOptions(timeout_s=timeout_s) + @plugs.plug(prompts=UserInput) + def trigger_phase(test: openhtf.TestApi, prompts: UserInput) -> None: + """Test start trigger with TofuPilot URL in prompt.""" + enhanced_message = message + if operator_page_url: + enhanced_message = f"{message}\n\n🔍 Live test view: {operator_page_url}" + + dut_id = prompts.prompt( + enhanced_message, + text_input=True, + timeout_s=timeout_s, + cli_color=cli_color + ) + test.test_record.dut_id = validator(dut_id) + + return trigger_phase + + +# Monkey-patching the original UserInput prompt method to include TofuPilot URL +original_prompt = UserInput.prompt + +def patched_prompt(self, message, text_input=False, timeout_s=None, cli_color='', image_url=None, + tofupilot_url=None): + """Patched prompt method that includes TofuPilot URL in the message.""" + enhanced_message = message + if tofupilot_url: + enhanced_message = f"{message}\n\n🔍 Live test view: {tofupilot_url}" + + return original_prompt(self, enhanced_message, text_input, timeout_s, cli_color, image_url) + + +def patch_openhtf_prompts(tofupilot_url=None): + """Monkey-patch OpenHTF's UserInput class to include TofuPilot URL in all prompts. + + This function should be called early in your application to ensure all prompts + show the TofuPilot URL. + + Args: + tofupilot_url: URL to the TofuPilot operator page. + """ + if tofupilot_url: + # Store URL in UserInput class for access by all instances + UserInput.tofupilot_url = tofupilot_url + + # Monkey-patch the prompt method + UserInput.prompt = lambda self, message, text_input=False, timeout_s=None, cli_color='', image_url=None: \ + patched_prompt(self, message, text_input, timeout_s, cli_color, image_url, tofupilot_url) + + _LOG.info(f"Enhanced OpenHTF prompts with TofuPilot URL: {tofupilot_url}") \ No newline at end of file diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 4f1b934..89974eb 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -12,6 +12,7 @@ from paho.mqtt.enums import CallbackAPIVersion from .upload import upload +from .custom_prompt import patch_openhtf_prompts from ..client import TofuPilotClient @@ -165,6 +166,9 @@ def __enter__(self): self.watcher = SimpleStationWatcher(self._send_update) self.watcher.start() + # Apply the patch to OpenHTF prompts to include the TofuPilot URL + patch_openhtf_prompts(operatorPage) + self._logger.success(f"Stream: Started at:\n{operatorPage}") except Exception as e: From f356c9584fe1426f8e31cb19796f4dba1c186fdf Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Mon, 5 May 2025 19:26:33 +0200 Subject: [PATCH 22/48] Adds color to tofu cap --- tofupilot/client.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tofupilot/client.py b/tofupilot/client.py index 8c14218..5e9fcb2 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -540,8 +540,13 @@ def get_connection_credentials(self) -> dict: def print_version_banner(current_version: str): """Prints current version of client with tofu art""" - banner = f""" - ╭ ✈ ╮ - [•ᴗ•] TofuPilot Python Client {current_version} + # Colors for the tofu art + yellow = "\033[33m" # Yellow for the plane + blue = "\033[34m" # Blue for the cap border + reset = "\033[0m" # Reset color + + banner = f"""{blue}╭{reset} {yellow}✈{reset} {blue}╮{reset} +│•ᴗ•│ TofuPilot Python Client {current_version} +╰───╯ """ print(banner.strip()) \ No newline at end of file From 1e9039c4bc3d6cbecbb9e27a68dfe7a3f2c9edc9 Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 09:59:14 +0200 Subject: [PATCH 23/48] Improves tofu banner --- tofupilot/client.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tofupilot/client.py b/tofupilot/client.py index 5e9fcb2..9cd46f3 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -544,9 +544,11 @@ def print_version_banner(current_version: str): yellow = "\033[33m" # Yellow for the plane blue = "\033[34m" # Blue for the cap border reset = "\033[0m" # Reset color - - banner = f"""{blue}╭{reset} {yellow}✈{reset} {blue}╮{reset} -│•ᴗ•│ TofuPilot Python Client {current_version} -╰───╯ - """ - print(banner.strip()) \ No newline at end of file + + banner = ( + f"{blue}╭{reset} {yellow}✈{reset} {blue}╮{reset}\n" + f"[•ᴗ•] TofuPilot Python Client {current_version}\n" + "\n" + ) + + print(banner, end="") \ No newline at end of file From 9537c63e87912c739a0b70e7b8e183524d54028e Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 09:59:57 +0200 Subject: [PATCH 24/48] Add ability to stop openhtf tests --- tofupilot/openhtf/__init__.py | 4 +- tofupilot/openhtf/custom_prompt.py | 50 ++++++++--------- tofupilot/openhtf/tofupilot.py | 87 +++++++++++++++++++++++++++--- 3 files changed, 109 insertions(+), 32 deletions(-) diff --git a/tofupilot/openhtf/__init__.py b/tofupilot/openhtf/__init__.py index 9503910..9618578 100644 --- a/tofupilot/openhtf/__init__.py +++ b/tofupilot/openhtf/__init__.py @@ -5,10 +5,11 @@ 1. tofupilot.upload(): A way to interface with OpenHTF test scripts to automatically upload test results to the TofuPilot server. 2. tofupilot.TofuPilot(): A way to stream real-time execution data of OpenHTF tests to TofuPilot for live monitoring. 3. Enhanced prompts: Automatically include TofuPilot URLs in OpenHTF prompts, making it easy for test operators to access the live view. +4. execute_with_graceful_exit(): A helper function to execute OpenHTF tests with proper Ctrl+C handling. """ from .upload import upload -from .tofupilot import TofuPilot +from .tofupilot import TofuPilot, execute_with_graceful_exit from .custom_prompt import ( patch_openhtf_prompts, prompt_with_tofupilot_url, @@ -21,4 +22,5 @@ 'patch_openhtf_prompts', 'prompt_with_tofupilot_url', 'enhanced_prompt_for_test_start', + 'execute_with_graceful_exit', ] diff --git a/tofupilot/openhtf/custom_prompt.py b/tofupilot/openhtf/custom_prompt.py index 074e4d5..80df0f3 100644 --- a/tofupilot/openhtf/custom_prompt.py +++ b/tofupilot/openhtf/custom_prompt.py @@ -1,13 +1,10 @@ """Module for custom OpenHTF user prompt integration with TofuPilot.""" -import functools import logging -import os from typing import Text, Optional, Union, Callable import openhtf from openhtf import plugs -from openhtf.plugs import user_input from openhtf.plugs.user_input import UserInput _LOG = logging.getLogger(__name__) @@ -19,7 +16,7 @@ def prompt_with_tofupilot_url( timeout_s: Union[int, float, None] = None, cli_color: Text = '', image_url: Optional[Text] = None) -> Text: - """Enhanced prompt that includes TofuPilot URL in the message. + """Enhanced prompt that displays TofuPilot URL in the console. Args: message: A string to be presented to the user. @@ -35,15 +32,14 @@ def prompt_with_tofupilot_url( # Get the UserInput plug instance prompts = plugs.get_plug_instance(UserInput) - # Add TofuPilot URL to the message - enhanced_message = message + # Log the operator page URL to the console with clean formatting if operator_page_url: - enhanced_message = f"{message}\n\n🔍 Live test view: {operator_page_url}" + print(f"\n📱 View live test results: {operator_page_url}") - # Call the standard prompt method with enhanced message + # Use the standard prompt method with original message return prompts.prompt( - enhanced_message, - text_input=text_input, + message, + text_input=text_input, timeout_s=timeout_s, cli_color=cli_color, image_url=image_url @@ -56,7 +52,7 @@ def enhanced_prompt_for_test_start( timeout_s: Union[int, float, None] = 60 * 60 * 24, validator: Callable[[Text], Text] = lambda sn: sn, cli_color: Text = '') -> openhtf.PhaseDescriptor: - """Returns an OpenHTF phase that includes TofuPilot URL in the prompt. + """Returns an OpenHTF phase that displays TofuPilot URL in the console. Args: operator_page_url: URL to the TofuPilot operator page. @@ -69,40 +65,44 @@ def enhanced_prompt_for_test_start( @openhtf.PhaseOptions(timeout_s=timeout_s) @plugs.plug(prompts=UserInput) def trigger_phase(test: openhtf.TestApi, prompts: UserInput) -> None: - """Test start trigger with TofuPilot URL in prompt.""" - enhanced_message = message + """Test start trigger with TofuPilot URL displayed in console.""" + # Log the operator page URL to the console if operator_page_url: - enhanced_message = f"{message}\n\n🔍 Live test view: {operator_page_url}" - + print(f"\n📱 View live test results: {operator_page_url}") + + # Standard OpenHTF prompt dut_id = prompts.prompt( - enhanced_message, + message, text_input=True, timeout_s=timeout_s, cli_color=cli_color ) + + # Apply validator and set DUT ID test.test_record.dut_id = validator(dut_id) return trigger_phase -# Monkey-patching the original UserInput prompt method to include TofuPilot URL +# Monkey-patching function to include TofuPilot URL in prompts original_prompt = UserInput.prompt def patched_prompt(self, message, text_input=False, timeout_s=None, cli_color='', image_url=None, tofupilot_url=None): - """Patched prompt method that includes TofuPilot URL in the message.""" - enhanced_message = message + """Patched prompt method that displays TofuPilot URL in console.""" + # Display TofuPilot URL if available if tofupilot_url: - enhanced_message = f"{message}\n\n🔍 Live test view: {tofupilot_url}" + print(f"\n📱 View live test results: {tofupilot_url}") - return original_prompt(self, enhanced_message, text_input, timeout_s, cli_color, image_url) + # Call the original prompt method + return original_prompt(self, message, text_input, timeout_s, cli_color, image_url) def patch_openhtf_prompts(tofupilot_url=None): - """Monkey-patch OpenHTF's UserInput class to include TofuPilot URL in all prompts. + """Monkey-patch OpenHTF's UserInput class to display TofuPilot URL. This function should be called early in your application to ensure all prompts - show the TofuPilot URL. + show the TofuPilot URL in the console (not in the prompt text itself). Args: tofupilot_url: URL to the TofuPilot operator page. @@ -115,4 +115,6 @@ def patch_openhtf_prompts(tofupilot_url=None): UserInput.prompt = lambda self, message, text_input=False, timeout_s=None, cli_color='', image_url=None: \ patched_prompt(self, message, text_input, timeout_s, cli_color, image_url, tofupilot_url) - _LOG.info(f"Enhanced OpenHTF prompts with TofuPilot URL: {tofupilot_url}") \ No newline at end of file + _LOG.info(f"Enhanced OpenHTF prompts with TofuPilot URL: {tofupilot_url}") + else: + _LOG.warning("No TofuPilot URL provided for prompt enhancement") \ No newline at end of file diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 89974eb..a6e993f 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -75,6 +75,50 @@ def run(self): def stop(self): self.stop_event.set() +def execute_with_graceful_exit(test, test_start=None): + """Execute a test with graceful handling of KeyboardInterrupt. + + This is a helper function that wraps the OpenHTF test.execute method + to ensure clean termination when Ctrl+C is pressed. + + Args: + test: The OpenHTF test to execute + test_start: The test_start parameter to pass to test.execute + + Returns: + The test result from test.execute, or None if interrupted + """ + try: + # Set up Ctrl+C handler to show message immediately + import signal + + def immediate_interrupt_handler(sig, frame): + print("\nTest execution interrupted by user.") + print("Test was interrupted. Exiting gracefully.") + # Let the KeyboardInterrupt propagate + raise KeyboardInterrupt() + + # Store the original handler to restore later + original_handler = signal.getsignal(signal.SIGINT) + # Set our immediate message handler + signal.signal(signal.SIGINT, immediate_interrupt_handler) + + try: + return test.execute(test_start=test_start) + finally: + # Restore the original handler + signal.signal(signal.SIGINT, original_handler) + except KeyboardInterrupt: + # KeyboardInterrupt has already been handled with immediate message + return None + except AttributeError as e: + if "'NoneType' object has no attribute 'name'" in str(e): + # This happens when KeyboardInterrupt is caught by OpenHTF + # but the test state isn't properly set + return None + raise # Re-raise any other AttributeError + + class TofuPilot: """ Context manager to automatically add an output callback to the running OpenHTF test @@ -94,7 +138,11 @@ def main(): # Stream real-time test execution data to TofuPilot with TofuPilot(test): - test.execute(lambda: "SN15") + # For more reliable Ctrl+C handling, use the helper function: + execute_with_graceful_exit(test, test_start=lambda: "SN15") + + # Or use the standard method (may show errors on Ctrl+C): + # test.execute(lambda: "SN15") ``` """ @@ -178,16 +226,41 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_value, traceback): + """Clean up resources when exiting the context manager. + + This method handles proper cleanup even in the case of KeyboardInterrupt + or other exceptions to ensure resources are released properly. + """ + # Log the exit reason if it's due to an exception + if exc_type is not None: + self._logger.info(f"Exiting TofuPilot context due to {exc_type.__name__}") + + # Handle KeyboardInterrupt specifically + if exc_type is KeyboardInterrupt: + self._logger.info("Test execution interrupted by user (Ctrl+C)") + # Stop the StationWatcher if self.watcher: - self.watcher.stop() - self.watcher.join() + try: + self.watcher.stop() + self.watcher.join(timeout=2.0) # Add timeout to prevent hanging + except Exception as e: + self._logger.warning(f"Error stopping watcher: {e}") + # Clean up MQTT connection if self.mqttClient: - # Doesn't wait for publish operation to stop, this is fine since __exit__ is only called after the run was imported - self.mqttClient.loop_stop() - self.mqttClient.disconnect() - self.mqttClient = None + try: + # Doesn't wait for publish operation to stop, this is fine since __exit__ is only called after the run was imported + self.mqttClient.loop_stop() + self.mqttClient.disconnect() + except Exception as e: + self._logger.warning(f"Error disconnecting MQTT client: {e}") + finally: + self.mqttClient = None + + # Return False to allow any exception to propagate, unless it's a KeyboardInterrupt + # In case of KeyboardInterrupt, return True to suppress the exception + return exc_type is KeyboardInterrupt def _send_update(self, message): self.mqttClient.publish( From 1087694d8af485a645904f84512989af6489f1f9 Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 10:50:16 +0200 Subject: [PATCH 25/48] Improves user prompt with link to operator ui --- tofupilot/openhtf/__init__.py | 17 +++-- tofupilot/openhtf/custom_prompt.py | 105 +++++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 20 deletions(-) diff --git a/tofupilot/openhtf/__init__.py b/tofupilot/openhtf/__init__.py index 9618578..cd912b2 100644 --- a/tofupilot/openhtf/__init__.py +++ b/tofupilot/openhtf/__init__.py @@ -1,11 +1,14 @@ -""" -This module handles all TofuPilot methods related to integration with OpenHTF. +"""TofuPilot integration with OpenHTF. -It provides the following functionality: -1. tofupilot.upload(): A way to interface with OpenHTF test scripts to automatically upload test results to the TofuPilot server. -2. tofupilot.TofuPilot(): A way to stream real-time execution data of OpenHTF tests to TofuPilot for live monitoring. -3. Enhanced prompts: Automatically include TofuPilot URLs in OpenHTF prompts, making it easy for test operators to access the live view. -4. execute_with_graceful_exit(): A helper function to execute OpenHTF tests with proper Ctrl+C handling. +Core functionality: +1. upload(): Upload OpenHTF test results to TofuPilot +2. TofuPilot(): Stream real-time test execution data for monitoring +3. Enhanced prompts: Display interactive TofuPilot prompts in terminal + - Bold question text with [User Input] prefix + - Clickable TofuPilot URLs in the terminal + - Instructions for terminal and web UI input options + - Graceful Ctrl+C handling with result upload +4. execute_with_graceful_exit(): Run tests with clean interrupt handling """ from .upload import upload diff --git a/tofupilot/openhtf/custom_prompt.py b/tofupilot/openhtf/custom_prompt.py index 80df0f3..5f8f09b 100644 --- a/tofupilot/openhtf/custom_prompt.py +++ b/tofupilot/openhtf/custom_prompt.py @@ -1,4 +1,19 @@ -"""Module for custom OpenHTF user prompt integration with TofuPilot.""" +"""Custom OpenHTF prompt integration with TofuPilot. + +Enhances OpenHTF prompts with TofuPilot features: +- Clickable URLs in the console +- Bold question text +- Consistent visual formatting +- Compatible with TofuPilot web UI streaming + +Prompt format: +- [User Input] QUESTION TEXT (bold) +- Waiting for user input on TofuPilot Operator UI (clickable) or in terminal below. +- Press Ctrl+C to cancel and upload results. (muted) +- Standard OpenHTF prompt (-->) + +Note: The message appears twice in the console to ensure web UI compatibility. +""" import logging from typing import Text, Optional, Union, Callable @@ -29,16 +44,36 @@ def prompt_with_tofupilot_url( Returns: A string response, or the empty string if text_input was False. """ + import sys + # Get the UserInput plug instance prompts = plugs.get_plug_instance(UserInput) - # Log the operator page URL to the console with clean formatting + # Print URL and instructions directly to console before the prompt + # This way they won't appear in the web UI if operator_page_url: - print(f"\n📱 View live test results: {operator_page_url}") + # Create clickable URL if in terminal that supports it + try: + # Create clickable URL with ANSI escape sequences + clickable_text = f"\033]8;;{operator_page_url}\033\\Operator UI\033]8;;\033\\" + sys.stdout.write(f"\n[User Input] \033[1m{message}\033[0m\n") + sys.stdout.write(f"Waiting for user input on TofuPilot {clickable_text} or in terminal below.\n") + sys.stdout.write("\033[2mPress Ctrl+C to cancel and upload results.\033[0m\n\n") + sys.stdout.flush() + except: + # Fallback if terminal doesn't support ANSI sequences + print(f"\n[User Input] {message}") + print(f"Waiting for user input on TofuPilot Operator UI: {operator_page_url[:30]}... or in terminal below.") + print("Press Ctrl+C to cancel and upload results.\n") + + # Store original message and use it for web UI compatibility + original_msg = message - # Use the standard prompt method with original message + # Use dim/muted text for the prompt + if not cli_color: + cli_color = '\033[2m' return prompts.prompt( - message, + original_msg, text_input=text_input, timeout_s=timeout_s, cli_color=cli_color, @@ -66,13 +101,33 @@ def enhanced_prompt_for_test_start( @plugs.plug(prompts=UserInput) def trigger_phase(test: openhtf.TestApi, prompts: UserInput) -> None: """Test start trigger with TofuPilot URL displayed in console.""" - # Log the operator page URL to the console + import sys + + # Print URL and instructions directly to console before the prompt + # This way they won't appear in the web UI if operator_page_url: - print(f"\n📱 View live test results: {operator_page_url}") + # Create clickable URL if in terminal that supports it + try: + # Create clickable URL with ANSI escape sequences + clickable_text = f"\033]8;;{operator_page_url}\033\\Operator UI\033]8;;\033\\" + sys.stdout.write(f"\n[User Input] \033[1m{message}\033[0m\n") + sys.stdout.write(f"Waiting for user input on TofuPilot {clickable_text} or in terminal below.\n") + sys.stdout.write("\033[2mPress Ctrl+C to cancel and upload results.\033[0m\n\n") + sys.stdout.flush() + except: + # Fallback if terminal doesn't support ANSI sequences + print(f"\n[User Input] {message}") + print(f"Waiting for user input on TofuPilot Operator UI: {operator_page_url[:30]}... or in terminal below.") + print("Press Ctrl+C to cancel and upload results.\n") + + # Store original message and use it for web UI compatibility + original_msg = message - # Standard OpenHTF prompt + # Use dim/muted text for the prompt + if not cli_color: + cli_color = '\033[2m' dut_id = prompts.prompt( - message, + original_msg, text_input=True, timeout_s=timeout_s, cli_color=cli_color @@ -90,12 +145,36 @@ def trigger_phase(test: openhtf.TestApi, prompts: UserInput) -> None: def patched_prompt(self, message, text_input=False, timeout_s=None, cli_color='', image_url=None, tofupilot_url=None): """Patched prompt method that displays TofuPilot URL in console.""" - # Display TofuPilot URL if available + import sys + + # Store original message to use in web UI + original_msg = message + + # Print URL and instructions directly to console before the prompt + # This way they won't appear in the web UI if tofupilot_url: - print(f"\n📱 View live test results: {tofupilot_url}") + # Create clickable URL if in terminal that supports it + try: + # Create clickable URL with ANSI escape sequences + clickable_text = f"\033]8;;{tofupilot_url}\033\\Operator UI\033]8;;\033\\" + sys.stdout.write(f"\n[User Input] \033[1m{message}\033[0m\n") + sys.stdout.write(f"Waiting for user input on TofuPilot {clickable_text} or in terminal below.\n") + sys.stdout.write("\033[2mPress Ctrl+C to stop and upload run.\033[0m\n\n") + sys.stdout.flush() + + except: + # Fallback if terminal doesn't support ANSI sequences + print(f"\n[User Input] {message}") + print(f"Waiting for user input on TofuPilot Operator UI: {tofupilot_url[:30]}... or in terminal below.") + print("Press Ctrl+C to cancel and upload results.\n") + + # Override cli_color to make the OpenHTF prompt appear dimmed + # This works because the cli_color is applied to the prompt arrow + if not cli_color: + cli_color = '\033[2m' # Use dim/muted text if no color specified - # Call the original prompt method - return original_prompt(self, message, text_input, timeout_s, cli_color, image_url) + # Use original message for web UI compatibility + return original_prompt(self, original_msg, text_input, timeout_s, cli_color, image_url) def patch_openhtf_prompts(tofupilot_url=None): From 0bafc276c55e0c2b281e2c2784eb5d80f219ebc5 Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 10:59:14 +0200 Subject: [PATCH 26/48] Simplifies logger --- tofupilot/openhtf/custom_prompt.py | 25 +++++++------ tofupilot/openhtf/tofupilot.py | 11 +++++- tofupilot/utils/logger.py | 59 ++++++++++-------------------- 3 files changed, 42 insertions(+), 53 deletions(-) diff --git a/tofupilot/openhtf/custom_prompt.py b/tofupilot/openhtf/custom_prompt.py index 5f8f09b..c9cd779 100644 --- a/tofupilot/openhtf/custom_prompt.py +++ b/tofupilot/openhtf/custom_prompt.py @@ -55,15 +55,16 @@ def prompt_with_tofupilot_url( # Create clickable URL if in terminal that supports it try: # Create clickable URL with ANSI escape sequences - clickable_text = f"\033]8;;{operator_page_url}\033\\Operator UI\033]8;;\033\\" + # Make both "TofuPilot Operator UI" clickable + clickable_text = f"\033]8;;{operator_page_url}\033\\TofuPilot Operator UI\033]8;;\033\\" sys.stdout.write(f"\n[User Input] \033[1m{message}\033[0m\n") - sys.stdout.write(f"Waiting for user input on TofuPilot {clickable_text} or in terminal below.\n") + sys.stdout.write(f"Waiting for user input on {clickable_text} or in terminal below.\n") sys.stdout.write("\033[2mPress Ctrl+C to cancel and upload results.\033[0m\n\n") sys.stdout.flush() except: # Fallback if terminal doesn't support ANSI sequences print(f"\n[User Input] {message}") - print(f"Waiting for user input on TofuPilot Operator UI: {operator_page_url[:30]}... or in terminal below.") + print(f"Waiting for user input on TofuPilot Operator UI or in terminal below.") print("Press Ctrl+C to cancel and upload results.\n") # Store original message and use it for web UI compatibility @@ -109,15 +110,16 @@ def trigger_phase(test: openhtf.TestApi, prompts: UserInput) -> None: # Create clickable URL if in terminal that supports it try: # Create clickable URL with ANSI escape sequences - clickable_text = f"\033]8;;{operator_page_url}\033\\Operator UI\033]8;;\033\\" + # Make both "TofuPilot Operator UI" clickable + clickable_text = f"\033]8;;{operator_page_url}\033\\TofuPilot Operator UI\033]8;;\033\\" sys.stdout.write(f"\n[User Input] \033[1m{message}\033[0m\n") - sys.stdout.write(f"Waiting for user input on TofuPilot {clickable_text} or in terminal below.\n") + sys.stdout.write(f"Waiting for user input on {clickable_text} or in terminal below.\n") sys.stdout.write("\033[2mPress Ctrl+C to cancel and upload results.\033[0m\n\n") sys.stdout.flush() except: # Fallback if terminal doesn't support ANSI sequences print(f"\n[User Input] {message}") - print(f"Waiting for user input on TofuPilot Operator UI: {operator_page_url[:30]}... or in terminal below.") + print(f"Waiting for user input on TofuPilot Operator UI or in terminal below.") print("Press Ctrl+C to cancel and upload results.\n") # Store original message and use it for web UI compatibility @@ -156,16 +158,17 @@ def patched_prompt(self, message, text_input=False, timeout_s=None, cli_color='' # Create clickable URL if in terminal that supports it try: # Create clickable URL with ANSI escape sequences - clickable_text = f"\033]8;;{tofupilot_url}\033\\Operator UI\033]8;;\033\\" + # Make both "TofuPilot Operator UI" clickable + clickable_text = f"\033]8;;{tofupilot_url}\033\\TofuPilot Operator UI\033]8;;\033\\" sys.stdout.write(f"\n[User Input] \033[1m{message}\033[0m\n") - sys.stdout.write(f"Waiting for user input on TofuPilot {clickable_text} or in terminal below.\n") + sys.stdout.write(f"Waiting for user input on {clickable_text} or in terminal below.\n") sys.stdout.write("\033[2mPress Ctrl+C to stop and upload run.\033[0m\n\n") sys.stdout.flush() except: # Fallback if terminal doesn't support ANSI sequences print(f"\n[User Input] {message}") - print(f"Waiting for user input on TofuPilot Operator UI: {tofupilot_url[:30]}... or in terminal below.") + print(f"Waiting for user input on TofuPilot Operator UI or in terminal below.") print("Press Ctrl+C to cancel and upload results.\n") # Override cli_color to make the OpenHTF prompt appear dimmed @@ -193,7 +196,5 @@ def patch_openhtf_prompts(tofupilot_url=None): # Monkey-patch the prompt method UserInput.prompt = lambda self, message, text_input=False, timeout_s=None, cli_color='', image_url=None: \ patched_prompt(self, message, text_input, timeout_s, cli_color, image_url, tofupilot_url) - - _LOG.info(f"Enhanced OpenHTF prompts with TofuPilot URL: {tofupilot_url}") else: - _LOG.warning("No TofuPilot URL provided for prompt enhancement") \ No newline at end of file + _LOG.debug("No TofuPilot URL provided for prompt enhancement") \ No newline at end of file diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index a6e993f..dbbf29f 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -217,7 +217,16 @@ def __enter__(self): # Apply the patch to OpenHTF prompts to include the TofuPilot URL patch_openhtf_prompts(operatorPage) - self._logger.success(f"Stream: Started at:\n{operatorPage}") + # Create clickable URL similar to the prompt format + import sys + try: + # Use ANSI escape sequence for clickable link + clickable_url = f"\033]8;;{operatorPage}\033\\TofuPilot Operator UI\033]8;;\033\\" + sys.stdout.write(f"\033[0;32mConnected to {clickable_url}\033[0m\n") + sys.stdout.flush() + except: + # Fallback for terminals that don't support ANSI + self._logger.success(f"Connected to TofuPilot: {operatorPage}") except Exception as e: self._logger.warning(f"Stream: Setup error - {e}") diff --git a/tofupilot/utils/logger.py b/tofupilot/utils/logger.py index e2db8a4..9886774 100644 --- a/tofupilot/utils/logger.py +++ b/tofupilot/utils/logger.py @@ -20,7 +20,7 @@ def success(self, message, *args, **kws): class TofupilotFormatter(logging.Formatter): - """Dev-friendly formatter with colors, timing, and thread info.""" + """Minimal formatter with colors and no timestamp.""" # ANSI color codes RESET = "\033[0m" @@ -53,47 +53,26 @@ class TofupilotFormatter(logging.Formatter): } def __init__(self): - """Init with timing trackers.""" + """Initialize formatter.""" super().__init__() - self.start_time = time.time() - self.lock = threading.Lock() - # Thread local storage for timing information - self.local = threading.local() - self.local.last_time = self.start_time def format(self, record): - """Format log with timing, colors and thread info.""" - # Calculate timing information - thread safe - current_time = time.time() - - # Initialize thread-local storage for first access - if not hasattr(self.local, 'last_time'): - self.local.last_time = self.start_time - - # Calculate elapsed times - elapsed_total = current_time - self.start_time - elapsed_since_last = current_time - self.local.last_time - self.local.last_time = current_time - + """Format log with minimal prefix and colors.""" # Get log level color and short name level_color = self.LEVEL_COLORS.get(record.levelno, self.RESET) level_name = self.LEVEL_NAMES.get(record.levelno, "???") - # Get thread name/id for concurrent operations + # Get thread name for concurrent operations (only non-main threads) thread_info = "" if threading.active_count() > 1: current_thread = threading.current_thread() if current_thread.name != "MainThread": thread_info = f"[{current_thread.name}] " - # Format time as HH:MM:SS.mmm - time_str = datetime.fromtimestamp(record.created).strftime("%H:%M:%S.%f")[:-3] - - # Construct log message with contextual information - elapsed_str = f"+{elapsed_since_last:.3f}s" - prefix = f"{level_color}{time_str} {self.BOLD}TP{self.RESET}{level_color}:{level_name} {elapsed_str} {thread_info}" + # Create minimal prefix with no timestamp + prefix = f"{level_color}{self.BOLD}TP{self.RESET}{level_color}:{level_name} {thread_info}" - # Add log message + # Add log message with color message = record.getMessage() formatted_message = f"{prefix}{message}{self.RESET}" @@ -105,25 +84,25 @@ def format(self, record): return formatted_message -# Legacy formatter for backward compatibility +# Simple formatter for backward compatibility class CustomFormatter(logging.Formatter): - """Custom formatter with minimal styling.""" + """Simple formatter with no timestamp.""" reset_code = "\033[0m" format_dict = { - logging.DEBUG: "\033[0;37m%(asctime)s - DEBUG: %(message)s" + reset_code, - logging.INFO: "\033[0;34m%(asctime)s - INFO: %(message)s" + reset_code, - logging.WARNING: "\033[0;33m%(asctime)s - WARN: %(message)s" + reset_code, - logging.ERROR: "\033[0;31m%(asctime)s - ERROR: %(message)s" + reset_code, - logging.CRITICAL: "\033[1;41m%(asctime)s - CRIT: %(message)s" + reset_code, - SUCCESS_LEVEL_NUM: "\033[0;32m%(asctime)s - SUCCESS: %(message)s" + reset_code, + logging.DEBUG: "\033[0;37mDEBUG: %(message)s" + reset_code, + logging.INFO: "\033[0;34mINFO: %(message)s" + reset_code, + logging.WARNING: "\033[0;33mWARN: %(message)s" + reset_code, + logging.ERROR: "\033[0;31mERROR: %(message)s" + reset_code, + logging.CRITICAL: "\033[1;41mCRIT: %(message)s" + reset_code, + SUCCESS_LEVEL_NUM: "\033[0;32mSUCCESS: %(message)s" + reset_code, } def format(self, record): - """Format the specified record as text.""" + """Format record with minimal prefix.""" log_fmt = self.format_dict.get(record.levelno, self._fmt) - formatter = logging.Formatter(log_fmt, datefmt="%Y-%m-%d %H:%M:%S") + formatter = logging.Formatter(log_fmt) return formatter.format(record) @@ -158,11 +137,11 @@ def filter(self, record): def setup_logger(log_level=None, advanced_format=True): - """Configure logger with timing, thread tracking and color support. + """Configure logger with minimal formatting and color support. Args: log_level: Override env var TOFUPILOT_LOG_LEVEL - advanced_format: Use advanced formatting (default True) + advanced_format: Use TofupilotFormatter (default) or CustomFormatter Returns: Configured logger instance From 60777a6f6793d54752ace0a8b63ac97dfebf3d02 Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 11:24:39 +0200 Subject: [PATCH 27/48] Renames streaming to Operator UI --- tofupilot/openhtf/tofupilot.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index dbbf29f..212ad11 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -122,7 +122,7 @@ def immediate_interrupt_handler(sig, frame): class TofuPilot: """ Context manager to automatically add an output callback to the running OpenHTF test - and live stream it's execution. + and live stream it's execution to the Operator UI. ### Usage Example: @@ -136,7 +136,7 @@ class TofuPilot: def main(): test = Test(*your_phases, procedure_id="FVT1") - # Stream real-time test execution data to TofuPilot + # Stream real-time test execution data to TofuPilot Operator UI with TofuPilot(test): # For more reliable Ctrl+C handling, use the helper function: execute_with_graceful_exit(test, test_start=lambda: "SN15") @@ -149,7 +149,7 @@ def main(): def __init__( self, test: Test, - stream: Optional[bool] = True, + stream: Optional[bool] = True, # Controls connection to Operator UI api_key: Optional[str] = None, url: Optional[str] = None, ): @@ -175,7 +175,7 @@ def __enter__(self): cred = self.client.get_connection_credentials() if not cred: - self._logger.warning("Stream: Auth server connection failed") + self._logger.warning("Operator UI: Auth server connection failed") return self # Since we control the server, we know these will be set @@ -201,12 +201,12 @@ def __enter__(self): connect_error_code = self.mqttClient.connect(**connectOptions) if(connect_error_code != mqtt.MQTT_ERR_SUCCESS): - self._logger.warning(f"Stream: Connect failed (code {connect_error_code})") + self._logger.warning(f"Operator UI: Connect failed (code {connect_error_code})") return self subscribe_error_code, messageId = self.mqttClient.subscribe(**subscribeOptions) if(subscribe_error_code != mqtt.MQTT_ERR_SUCCESS): - self._logger.warning(f"Stream: Subscribe failed (code {subscribe_error_code})") + self._logger.warning(f"Operator UI: Subscribe failed (code {subscribe_error_code})") return self self.mqttClient.loop_start() @@ -229,7 +229,7 @@ def __enter__(self): self._logger.success(f"Connected to TofuPilot: {operatorPage}") except Exception as e: - self._logger.warning(f"Stream: Setup error - {e}") + self._logger.warning(f"Operator UI: Setup error - {e}") return self @@ -283,13 +283,13 @@ def _handle_answer(self, plug_name, method_name, args): _, test_state = _get_executing_test() if test_state is None: - self._logger.warning("Stream: No running test found") + self._logger.warning("Operator UI: No running test found") return # Find the plug matching `plug_name`. plug = test_state.plug_manager.get_plug_by_class_path(plug_name) if plug is None: - self._logger.warning(f"Stream: Plug not found - {plug_name}") + self._logger.warning(f"Operator UI: Plug not found - {plug_name}") return method = getattr(plug, method_name, None) @@ -297,14 +297,14 @@ def _handle_answer(self, plug_name, method_name, args): if not (plug.enable_remote and isinstance(method, types.MethodType) and not method_name.startswith('_') and method_name not in plug.disable_remote_attrs): - self._logger.warning(f"Stream: Method not found - {plug_name}.{method_name}") + self._logger.warning(f"Operator UI: Method not found - {plug_name}.{method_name}") return try: # side-effecting ! method(*args) except Exception as e: # pylint: disable=broad-except - self._logger.warning(f"Stream: Method call failed - {method_name}({', '.join(args)}) - {e}") + self._logger.warning(f"Operator UI: Method call failed - {method_name}({', '.join(args)}) - {e}") def _on_message(self, client, userdata, message): parsed = json.loads(message.payload) @@ -314,8 +314,8 @@ def _on_message(self, client, userdata, message): def _on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties): if reason_code != mqtt.MQTT_ERR_SUCCESS: - self._logger.warning(f"Stream: Unexpected disconnect (code {reason_code})") + self._logger.warning(f"Operator UI: Unexpected disconnect (code {reason_code})") def _on_unsubscribe(self, client, userdata, mid, reason_code_list, properties): if any(reason_code != mqtt.MQTT_ERR_SUCCESS for reason_code in reason_code_list): - self._logger.warning(f"Stream: Partial disconnect (codes {reason_code_list})") \ No newline at end of file + self._logger.warning(f"Operator UI: Partial disconnect (codes {reason_code_list})") \ No newline at end of file From 492546f40e5082da814490818615b70dd55addef Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 11:38:17 +0200 Subject: [PATCH 28/48] Add pausable logging to support cleaner UI during streaming connections --- tofupilot/utils/logger.py | 91 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 2 deletions(-) diff --git a/tofupilot/utils/logger.py b/tofupilot/utils/logger.py index 9886774..1636fe4 100644 --- a/tofupilot/utils/logger.py +++ b/tofupilot/utils/logger.py @@ -3,6 +3,7 @@ import os import time import threading +import queue from datetime import datetime # Define a custom log level for success messages @@ -19,6 +20,87 @@ def success(self, message, *args, **kws): logging.Logger.success = success +class PausableHandler(logging.Handler): + """Handler that can pause and buffer logging messages.""" + + def __init__(self, handler: logging.Handler): + super().__init__() + self._wrapped = handler + self._paused = False + self._buffer = queue.Queue() + + def emit(self, record): + if self._paused: + self._buffer.put(record) + else: + self._wrapped.emit(record) + + def pause(self): + """Pause logging by buffering messages.""" + self._paused = True + + def resume(self): + """Resume logging and flush buffered messages.""" + self._paused = False + while not self._buffer.empty(): + self._wrapped.emit(self._buffer.get()) + + def close(self): + self._wrapped.close() + super().close() + + @property + def level(self): + return self._wrapped.level + + @property + def name(self): + return self._wrapped.name + + @name.setter + def name(self, value): + self._wrapped.name = value + + def createLock(self): + self._wrapped.createLock() + + def acquire(self): + self._wrapped.acquire() + + def release(self): + self._wrapped.release() + + def setLevel(self, level): + self._wrapped.setLevel(level) + + def format(self, record): + return self._wrapped.format(record) + + def handle(self, record): + return self._wrapped.handle(record) + + def setFormatter(self, fmt): + self._wrapped.setFormatter(fmt) + + def flush(self): + self._wrapped.flush() + + def handleError(self, record): + self._wrapped.handleError(record) + + def __repr__(self): + return repr(self._wrapped) + + def addFilter(self, filter): + self._wrapped.addFilter(filter) + + def removeFilter(self, filter): + self._wrapped.removeFilter(filter) + + def filter(self, record): + return self._wrapped.filter(record) + + class TofupilotFormatter(logging.Formatter): """Minimal formatter with colors and no timestamp.""" @@ -161,8 +243,9 @@ def setup_logger(log_level=None, advanced_format=True): else: logger.setLevel(level_filter.level) - # Create stdout handler - handler = logging.StreamHandler(sys.stdout) + # Create stdout handler with pausable wrapper + base_handler = logging.StreamHandler(sys.stdout) + handler = PausableHandler(base_handler) # Choose formatter based on preference if advanced_format: @@ -175,4 +258,8 @@ def setup_logger(log_level=None, advanced_format=True): # Add handler to logger logger.addHandler(handler) + # Add pause/resume methods to logger + logger.pause = handler.pause + logger.resume = handler.resume + return logger \ No newline at end of file From ef3fd07624a5d4a4f7023d6e32aaded774730fae Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 11:39:08 +0200 Subject: [PATCH 29/48] Integrate threading and error handling improvements from quentin/app-3111 branch while maintaining Operator UI naming --- tofupilot/openhtf/README.md | 116 ++++++++++++++++++ tofupilot/openhtf/tofupilot.py | 212 +++++++++++++++++++++------------ 2 files changed, 249 insertions(+), 79 deletions(-) create mode 100644 tofupilot/openhtf/README.md diff --git a/tofupilot/openhtf/README.md b/tofupilot/openhtf/README.md new file mode 100644 index 0000000..d56dad5 --- /dev/null +++ b/tofupilot/openhtf/README.md @@ -0,0 +1,116 @@ +# TofuPilot OpenHTF Integration + +This module provides integration between OpenHTF and TofuPilot, enhancing the testing experience with features like: + +- Automatic uploading of test results to TofuPilot +- Real-time streaming of test execution +- Enhanced user prompts with TofuPilot operator URLs displayed in the console +- Graceful error handling for test interruptions + +## Quick Start + +```python +from openhtf import Test +from tofupilot.openhtf import TofuPilot, execute_with_graceful_exit + +def main(): + test = Test(*your_phases, procedure_id="FVT1") + + # Stream real-time test execution data to TofuPilot + with TofuPilot(test): + # Use helper function for graceful Ctrl+C handling + result = execute_with_graceful_exit(test, test_start=lambda: "SN15") + + if result is None: + print("Test was interrupted. Exiting gracefully.") + else: + print(f"Test completed with outcome: {result.outcome.name}") +``` + +## Enhanced Prompt Functionality + +TofuPilot enhances OpenHTF's prompt system by displaying the TofuPilot URL before each prompt. + +### URL Display + +The TofuPilot URL is displayed clearly in the console before each prompt: +``` +📱 View live test results: https://tofupilot.example.com/test/123 +Enter a DUT ID in order to start the test. +``` + +This URL display is kept separate from the actual prompt text to maintain clean prompts in the web UI. + +### Using Enhanced Prompts + +There are two main ways to use the enhanced prompts: + +1. **Use the provided prompt functions**: + ```python + from tofupilot.openhtf import prompt_with_tofupilot_url + + response = prompt_with_tofupilot_url( + "Enter calibration value:", + operator_page_url="https://tofupilot.example.com/test/123" + ) + ``` + +2. **Use the `patch_openhtf_prompts` function** to enhance all OpenHTF prompts: + ```python + from tofupilot.openhtf import patch_openhtf_prompts + + # Call this early in your application + patch_openhtf_prompts(tofupilot_url="https://tofupilot.example.com/test/123") + ``` + +## Graceful Error Handling + +TofuPilot provides a helper function to gracefully handle interruptions during test execution. + +### Using execute_with_graceful_exit + +```python +from tofupilot.openhtf import execute_with_graceful_exit + +# Inside your with TofuPilot(test) block: +result = execute_with_graceful_exit(test, test_start=your_test_start_fn) + +# Only show success message if test wasn't interrupted +if result is not None: + print(f"Test completed with outcome: {result.outcome.name}") +``` + +This helper: +- Shows immediate feedback when Ctrl+C is pressed +- Displays "Test execution interrupted by user. Test was interrupted. Exiting gracefully." +- Properly handles KeyboardInterrupt exceptions +- Returns None if the test was interrupted +- Ensures clean resource release +- Prevents stack traces from appearing when the user presses Ctrl+C + +## OpenHTF Output Callbacks + +By default, the TofuPilot context manager automatically adds an output callback to upload test results to TofuPilot upon test completion: + +```python +with TofuPilot(test): + # This will automatically upload test results when complete + test.execute(test_start=lambda: "SN15") +``` + +If you want to manually add the callback: + +```python +from tofupilot.openhtf import upload + +test = Test(*your_phases) +test.add_output_callbacks(upload()) +test.execute(test_start=lambda: "SN15") +``` + +## Important Notes + +1. TofuPilot URL information is displayed in the console log, not in the prompt itself. +2. When using `execute_with_graceful_exit`, interrupted tests will return `None` instead of a test result. +3. The TofuPilot context manager handles automatic upload of test results. +4. For OpenHTF tests that are interrupted, the standard OpenHTF output callbacks will still run. \ No newline at end of file diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 212ad11..04e08e8 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -10,6 +10,8 @@ import paho.mqtt.client as mqtt from paho.mqtt.enums import CallbackAPIVersion +from openhtf.core.test_record import TestRecord +from openhtf.core.test_state import TestState from .upload import upload from .custom_prompt import patch_openhtf_prompts @@ -35,7 +37,7 @@ def _get_executing_test(): return test, test_state -def _to_dict_with_event(test_state): +def _to_dict_with_event(test_state: TestState): """Process a test state into the format we want to send to the frontend.""" original_dict, event = test_state.asdict_with_event() @@ -164,90 +166,34 @@ def __init__( self.mqttClient = None self.publishOptions = None self._logger = self.client._logger + self._streaming_setup_thread = None def __enter__(self): self.test.add_output_callbacks( - upload(api_key=self.api_key, url=self.url, client=self.client) + upload(api_key=self.api_key, url=self.url, client=self.client), + self._final_update, ) if self.stream: - try: - cred = self.client.get_connection_credentials() - - if not cred: - self._logger.warning("Operator UI: Auth server connection failed") - return self - - # Since we control the server, we know these will be set - token = cred["token"] - operatorPage = cred["operatorPage"] - clientOptions = cred["clientOptions"] - willOptions = cred["willOptions"] - connectOptions = cred["connectOptions"] - self.publishOptions = cred["publishOptions"] - subscribeOptions = cred["subscribeOptions"] - - self.mqttClient = mqtt.Client(callback_api_version=CallbackAPIVersion.VERSION2, **clientOptions) - - self.mqttClient.tls_set() - - self.mqttClient.will_set(**willOptions) - - self.mqttClient.username_pw_set("pythonClient", token) - - self.mqttClient.on_message = self._on_message - self.mqttClient.on_disconnect = self._on_disconnect - self.mqttClient.on_unsubscribe = self._on_unsubscribe - - connect_error_code = self.mqttClient.connect(**connectOptions) - if(connect_error_code != mqtt.MQTT_ERR_SUCCESS): - self._logger.warning(f"Operator UI: Connect failed (code {connect_error_code})") - return self - - subscribe_error_code, messageId = self.mqttClient.subscribe(**subscribeOptions) - if(subscribe_error_code != mqtt.MQTT_ERR_SUCCESS): - self._logger.warning(f"Operator UI: Subscribe failed (code {subscribe_error_code})") - return self - - self.mqttClient.loop_start() - - self.watcher = SimpleStationWatcher(self._send_update) - self.watcher.start() - - # Apply the patch to OpenHTF prompts to include the TofuPilot URL - patch_openhtf_prompts(operatorPage) - - # Create clickable URL similar to the prompt format - import sys - try: - # Use ANSI escape sequence for clickable link - clickable_url = f"\033]8;;{operatorPage}\033\\TofuPilot Operator UI\033]8;;\033\\" - sys.stdout.write(f"\033[0;32mConnected to {clickable_url}\033[0m\n") - sys.stdout.flush() - except: - # Fallback for terminals that don't support ANSI - self._logger.success(f"Connected to TofuPilot: {operatorPage}") - - except Exception as e: - self._logger.warning(f"Operator UI: Setup error - {e}") - - + self._streaming_setup_thread = threading.Thread(target=self._setup_streaming) + self._streaming_setup_thread.start() + self._streaming_setup_thread.join(1) + + self._logger.pause() return self - + def __exit__(self, exc_type, exc_value, traceback): """Clean up resources when exiting the context manager. This method handles proper cleanup even in the case of KeyboardInterrupt or other exceptions to ensure resources are released properly. """ - # Log the exit reason if it's due to an exception - if exc_type is not None: - self._logger.info(f"Exiting TofuPilot context due to {exc_type.__name__}") - - # Handle KeyboardInterrupt specifically - if exc_type is KeyboardInterrupt: - self._logger.info("Test execution interrupted by user (Ctrl+C)") - + self._logger.resume() + + if self._streaming_setup_thread and self._streaming_setup_thread.is_alive(): + self._logger.warning(f"Operator UI: Setup still ongoing, waiting for it to time-out (max 10s)") + self._streaming_setup_thread.join() + # Stop the StationWatcher if self.watcher: try: @@ -271,13 +217,99 @@ def __exit__(self, exc_type, exc_value, traceback): # In case of KeyboardInterrupt, return True to suppress the exception return exc_type is KeyboardInterrupt + # Operator UI-related methods + + def _setup_streaming(self): + try: + try: + cred = self.client.get_connection_credentials() + except Exception as e: + self._logger.warning(f"Operator UI: JWT error: {e}") + return + + if not cred: + self._logger.warning("Operator UI: Auth server connection failed") + return self + + # Since we control the server, we know these will be set + token = cred["token"] + operatorPage = cred["operatorPage"] + clientOptions = cred["clientOptions"] + willOptions = cred["willOptions"] + connectOptions = cred["connectOptions"] + self.publishOptions = cred["publishOptions"] + subscribeOptions = cred["subscribeOptions"] + + self.mqttClient = mqtt.Client(callback_api_version=CallbackAPIVersion.VERSION2, **clientOptions) + + # This is not 100% reliable, hence the need to put the setup in the background + # See https://github.com/eclipse-paho/paho.mqtt.python/issues/890 + self.mqttClient.connect_timeout = 1.0 + + self.mqttClient.tls_set() + + self.mqttClient.will_set(**willOptions) + + self.mqttClient.username_pw_set("pythonClient", token) + + self.mqttClient.on_message = self._on_message + self.mqttClient.on_disconnect = self._on_disconnect + self.mqttClient.on_unsubscribe = self._on_unsubscribe + + try: + connect_error_code = self.mqttClient.connect(**connectOptions) + except Exception as e: + self._logger.warning(f"Operator UI: Failed to connect with server (exception): {e}") + return + + if(connect_error_code != mqtt.MQTT_ERR_SUCCESS): + self._logger.warning(f"Operator UI: Failed to connect with server (error code): {connect_error_code}") + return self + + try: + subscribe_error_code, messageId = self.mqttClient.subscribe(**subscribeOptions) + except Exception as e: + self._logger.warning(f"Operator UI: Failed to subscribe to server (exception): {e}") + return + + if(subscribe_error_code != mqtt.MQTT_ERR_SUCCESS): + self._logger.warning(f"Operator UI: Failed to subscribe to server (error code): {subscribe_error_code}") + return self + + self.mqttClient.loop_start() + + self.watcher = SimpleStationWatcher(self._send_update) + self.watcher.start() + + # Apply the patch to OpenHTF prompts to include the TofuPilot URL + patch_openhtf_prompts(operatorPage) + + # Create clickable URL similar to the prompt format + import sys + try: + # Use ANSI escape sequence for clickable link + clickable_url = f"\033]8;;{operatorPage}\033\\TofuPilot Operator UI\033]8;;\033\\" + sys.stdout.write(f"\033[0;32mConnected to {clickable_url}\033[0m\n") + sys.stdout.flush() + except: + # Fallback for terminals that don't support ANSI + self._logger.success(f"Connected to TofuPilot: {operatorPage}") + + except Exception as e: + self._logger.warning(f"Operator UI: Setup error - {e}") + + def _send_update(self, message): - self.mqttClient.publish( - payload=json.dumps( - {"action": "send", "source": "python", "message": message} - ), - **self.publishOptions - ) + try: + self.mqttClient.publish( + payload=json.dumps( + {"action": "send", "source": "python", "message": message} + ), + **self.publishOptions + ) + except Exception as e: + self._logger.warning(f"Operator UI: Failed to publish to server (exception): {e}") + return def _handle_answer(self, plug_name, method_name, args): _, test_state = _get_executing_test() @@ -305,7 +337,29 @@ def _handle_answer(self, plug_name, method_name, args): method(*args) except Exception as e: # pylint: disable=broad-except self._logger.warning(f"Operator UI: Method call failed - {method_name}({', '.join(args)}) - {e}") - + + def _final_update(self, testRecord: TestRecord): + """ + If the test is fast enough, the watcher never triggers, to avoid the UI being out of sync, + we force send at least once at the very end of the test + """ + + if not self.stream: + return + + test_record_dict = testRecord.as_base_types() + + test_state_dict = { + 'status': 'COMPLETED', + 'test_record': test_record_dict, + 'plugs': {'plug_states': {}}, + 'running_phase_state': {}, + } + + self._send_update(test_state_dict) + + # Operator UI-related callbacks + def _on_message(self, client, userdata, message): parsed = json.loads(message.payload) From e23632cd579b67dd9a2c9096733873b2ac25991b Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 11:42:34 +0200 Subject: [PATCH 30/48] Remove execute_with_graceful_exit function as it's no longer needed with TofuPilot's improved handling of interruptions --- main.py | 83 ++++++++++++++++++++++++++++++++++ tofupilot/openhtf/__init__.py | 4 +- tofupilot/openhtf/tofupilot.py | 42 ----------------- 3 files changed, 84 insertions(+), 45 deletions(-) create mode 100644 main.py diff --git a/main.py b/main.py new file mode 100644 index 0000000..7113206 --- /dev/null +++ b/main.py @@ -0,0 +1,83 @@ +from openhtf import Test, PhaseResult +from openhtf.plugs import user_input +import time +import sys + + +import openhtf as htf +from openhtf.plugs.user_input import UserInput +from tofupilot.openhtf import TofuPilot + + +def power_on(test): + time.sleep(0.1) + return PhaseResult.CONTINUE + + +def log_1(test): + time.sleep(0.1) + return PhaseResult.CONTINUE + + +def log_2(test): + time.sleep(0.1) + return PhaseResult.CONTINUE + + +def log_3(test): + time.sleep(0.1) + return PhaseResult.CONTINUE + + +def log_4(test): + time.sleep(0.1) + return PhaseResult.CONTINUE + + +@htf.measures( + htf.Measurement("led_color").with_validator( + lambda color: color in ["Red", "Green", "Blue"] + ) +) +@htf.plug(user_input=UserInput) +def prompt_operator_led_color(test, user_input): + led_color = user_input.prompt(message="What is the LED color? (Red/Green/Blue)") + test.measurements.led_color = led_color + + +def main(): + test = Test( + power_on, + log_1, + log_2, + log_3, + log_4, + prompt_operator_led_color, + procedure_id="FVT1", + part_number="AAA", + ) + + try: + # Add TofuPilot context manager + with TofuPilot(test, url="http://localhost:3000"): + # Execute the test with standard OpenHTF execution + result = test.execute( + test_start=user_input.prompt_for_test_start() + ) + + # Print the test name and outcome in the exact format requested + test_name = test.metadata.get('test_name', 'openhtf_test') + print("\n======================= test: {0} outcome: {1} =======================\n".format( + test_name, result.outcome.name)) + # Will continue with TofuPilot upload messages (already handled by the callback) + except KeyboardInterrupt: + # This catches Ctrl+C outside of the test execution + print("\nTest setup interrupted. Exiting.") + sys.exit(0) + except Exception as e: + print(f"Error during test execution: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tofupilot/openhtf/__init__.py b/tofupilot/openhtf/__init__.py index cd912b2..446ace8 100644 --- a/tofupilot/openhtf/__init__.py +++ b/tofupilot/openhtf/__init__.py @@ -8,11 +8,10 @@ - Clickable TofuPilot URLs in the terminal - Instructions for terminal and web UI input options - Graceful Ctrl+C handling with result upload -4. execute_with_graceful_exit(): Run tests with clean interrupt handling """ from .upload import upload -from .tofupilot import TofuPilot, execute_with_graceful_exit +from .tofupilot import TofuPilot from .custom_prompt import ( patch_openhtf_prompts, prompt_with_tofupilot_url, @@ -25,5 +24,4 @@ 'patch_openhtf_prompts', 'prompt_with_tofupilot_url', 'enhanced_prompt_for_test_start', - 'execute_with_graceful_exit', ] diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 04e08e8..711f579 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -77,48 +77,6 @@ def run(self): def stop(self): self.stop_event.set() -def execute_with_graceful_exit(test, test_start=None): - """Execute a test with graceful handling of KeyboardInterrupt. - - This is a helper function that wraps the OpenHTF test.execute method - to ensure clean termination when Ctrl+C is pressed. - - Args: - test: The OpenHTF test to execute - test_start: The test_start parameter to pass to test.execute - - Returns: - The test result from test.execute, or None if interrupted - """ - try: - # Set up Ctrl+C handler to show message immediately - import signal - - def immediate_interrupt_handler(sig, frame): - print("\nTest execution interrupted by user.") - print("Test was interrupted. Exiting gracefully.") - # Let the KeyboardInterrupt propagate - raise KeyboardInterrupt() - - # Store the original handler to restore later - original_handler = signal.getsignal(signal.SIGINT) - # Set our immediate message handler - signal.signal(signal.SIGINT, immediate_interrupt_handler) - - try: - return test.execute(test_start=test_start) - finally: - # Restore the original handler - signal.signal(signal.SIGINT, original_handler) - except KeyboardInterrupt: - # KeyboardInterrupt has already been handled with immediate message - return None - except AttributeError as e: - if "'NoneType' object has no attribute 'name'" in str(e): - # This happens when KeyboardInterrupt is caught by OpenHTF - # but the test state isn't properly set - return None - raise # Re-raise any other AttributeError class TofuPilot: From c7a43485a209e66d4177ad4644329a41b897b0b1 Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 11:43:46 +0200 Subject: [PATCH 31/48] Fix PausableHandler implementation to properly inherit from logging.Handler --- tofupilot/utils/logger.py | 60 +++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 31 deletions(-) diff --git a/tofupilot/utils/logger.py b/tofupilot/utils/logger.py index 1636fe4..93c4a33 100644 --- a/tofupilot/utils/logger.py +++ b/tofupilot/utils/logger.py @@ -28,6 +28,8 @@ def __init__(self, handler: logging.Handler): self._wrapped = handler self._paused = False self._buffer = queue.Queue() + # Copy initial level from wrapped handler + self.setLevel(handler.level) def emit(self, record): if self._paused: @@ -49,18 +51,6 @@ def close(self): self._wrapped.close() super().close() - @property - def level(self): - return self._wrapped.level - - @property - def name(self): - return self._wrapped.name - - @name.setter - def name(self, value): - self._wrapped.name = value - def createLock(self): self._wrapped.createLock() @@ -71,16 +61,18 @@ def release(self): self._wrapped.release() def setLevel(self, level): - self._wrapped.setLevel(level) + super().setLevel(level) # Set level on self + self._wrapped.setLevel(level) # Also set level on wrapped handler + + def setFormatter(self, fmt): + self._wrapped.setFormatter(fmt) def format(self, record): return self._wrapped.format(record) def handle(self, record): - return self._wrapped.handle(record) - - def setFormatter(self, fmt): - self._wrapped.setFormatter(fmt) + super().handle(record) # Use parent's handle which calls emit + return True def flush(self): self._wrapped.flush() @@ -89,15 +81,20 @@ def handleError(self, record): self._wrapped.handleError(record) def __repr__(self): - return repr(self._wrapped) + return f"PausableHandler({repr(self._wrapped)})" def addFilter(self, filter): - self._wrapped.addFilter(filter) + super().addFilter(filter) # Add to parent handler + self._wrapped.addFilter(filter) # Add to wrapped handler def removeFilter(self, filter): - self._wrapped.removeFilter(filter) + super().removeFilter(filter) # Remove from parent + self._wrapped.removeFilter(filter) # Remove from wrapped handler def filter(self, record): + # Use both parent and wrapped handler's filters + if not super().filter(record): + return False return self._wrapped.filter(record) @@ -238,28 +235,29 @@ def setup_logger(log_level=None, advanced_format=True): # Set level from arg or environment level_filter = LogLevelFilter() - if log_level is not None: - logger.setLevel(log_level) - else: - logger.setLevel(level_filter.level) + log_level = log_level or level_filter.level + logger.setLevel(log_level) # Create stdout handler with pausable wrapper base_handler = logging.StreamHandler(sys.stdout) - handler = PausableHandler(base_handler) + base_handler.setLevel(log_level) # Set level on base handler # Choose formatter based on preference if advanced_format: - handler.setFormatter(TofupilotFormatter()) + base_handler.setFormatter(TofupilotFormatter()) else: - handler.setFormatter(CustomFormatter()) + base_handler.setFormatter(CustomFormatter()) - handler.addFilter(level_filter) + base_handler.addFilter(level_filter) + + # Create pausable wrapper + handler = PausableHandler(base_handler) # Add handler to logger logger.addHandler(handler) - # Add pause/resume methods to logger - logger.pause = handler.pause - logger.resume = handler.resume + # Add pause/resume methods to logger as attributes + setattr(logger, 'pause', handler.pause) + setattr(logger, 'resume', handler.resume) return logger \ No newline at end of file From 06325edd696a522fbef46bbd9450d3300008533b Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 11:44:52 +0200 Subject: [PATCH 32/48] Simplify logging pause/resume implementation to be more robust --- tofupilot/utils/logger.py | 123 +++++++++++--------------------------- 1 file changed, 36 insertions(+), 87 deletions(-) diff --git a/tofupilot/utils/logger.py b/tofupilot/utils/logger.py index 93c4a33..ad53951 100644 --- a/tofupilot/utils/logger.py +++ b/tofupilot/utils/logger.py @@ -20,82 +20,35 @@ def success(self, message, *args, **kws): logging.Logger.success = success -class PausableHandler(logging.Handler): - """Handler that can pause and buffer logging messages.""" - - def __init__(self, handler: logging.Handler): - super().__init__() - self._wrapped = handler - self._paused = False - self._buffer = queue.Queue() - # Copy initial level from wrapped handler - self.setLevel(handler.level) - - def emit(self, record): - if self._paused: - self._buffer.put(record) - else: - self._wrapped.emit(record) - - def pause(self): - """Pause logging by buffering messages.""" - self._paused = True - - def resume(self): - """Resume logging and flush buffered messages.""" - self._paused = False - while not self._buffer.empty(): - self._wrapped.emit(self._buffer.get()) - - def close(self): - self._wrapped.close() - super().close() - - def createLock(self): - self._wrapped.createLock() - - def acquire(self): - self._wrapped.acquire() - - def release(self): - self._wrapped.release() - - def setLevel(self, level): - super().setLevel(level) # Set level on self - self._wrapped.setLevel(level) # Also set level on wrapped handler - - def setFormatter(self, fmt): - self._wrapped.setFormatter(fmt) - - def format(self, record): - return self._wrapped.format(record) - - def handle(self, record): - super().handle(record) # Use parent's handle which calls emit - return True - - def flush(self): - self._wrapped.flush() - - def handleError(self, record): - self._wrapped.handleError(record) - - def __repr__(self): - return f"PausableHandler({repr(self._wrapped)})" +def add_pause_methods_to_logger(logger): + """ + Simple function to add pause/resume functionality to a logger without modifying handlers. - def addFilter(self, filter): - super().addFilter(filter) # Add to parent handler - self._wrapped.addFilter(filter) # Add to wrapped handler - - def removeFilter(self, filter): - super().removeFilter(filter) # Remove from parent - self._wrapped.removeFilter(filter) # Remove from wrapped handler - - def filter(self, record): - # Use both parent and wrapped handler's filters - if not super().filter(record): - return False - return self._wrapped.filter(record) + Args: + logger: The logger to enhance + """ + # Create buffer for storing messages while paused + message_buffer = queue.Queue() + original_handlers = list(logger.handlers) + + # Save the original handlers' handles and emits + def pause(): + """Temporarily disable all handlers to pause logging""" + for handler in logger.handlers: + logger.removeHandler(handler) + + def resume(): + """Re-enable handlers and process any buffered messages""" + # Restore original handlers + for handler in original_handlers: + if handler not in logger.handlers: + logger.addHandler(handler) + + # Add methods to logger + logger.pause = pause + logger.resume = resume + + return logger class TofupilotFormatter(logging.Formatter): @@ -238,26 +191,22 @@ def setup_logger(log_level=None, advanced_format=True): log_level = log_level or level_filter.level logger.setLevel(log_level) - # Create stdout handler with pausable wrapper - base_handler = logging.StreamHandler(sys.stdout) - base_handler.setLevel(log_level) # Set level on base handler + # Create handler + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(log_level) # Choose formatter based on preference if advanced_format: - base_handler.setFormatter(TofupilotFormatter()) + handler.setFormatter(TofupilotFormatter()) else: - base_handler.setFormatter(CustomFormatter()) + handler.setFormatter(CustomFormatter()) - base_handler.addFilter(level_filter) - - # Create pausable wrapper - handler = PausableHandler(base_handler) + handler.addFilter(level_filter) # Add handler to logger logger.addHandler(handler) - # Add pause/resume methods to logger as attributes - setattr(logger, 'pause', handler.pause) - setattr(logger, 'resume', handler.resume) + # Add pause/resume functionality + logger = add_pause_methods_to_logger(logger) return logger \ No newline at end of file From c193e5d7388aca2c9ed68df0c5aa5c5152ad94df Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 11:45:28 +0200 Subject: [PATCH 33/48] Remove thread name from log output --- tofupilot/utils/logger.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/tofupilot/utils/logger.py b/tofupilot/utils/logger.py index ad53951..a835ea5 100644 --- a/tofupilot/utils/logger.py +++ b/tofupilot/utils/logger.py @@ -94,15 +94,8 @@ def format(self, record): level_color = self.LEVEL_COLORS.get(record.levelno, self.RESET) level_name = self.LEVEL_NAMES.get(record.levelno, "???") - # Get thread name for concurrent operations (only non-main threads) - thread_info = "" - if threading.active_count() > 1: - current_thread = threading.current_thread() - if current_thread.name != "MainThread": - thread_info = f"[{current_thread.name}] " - # Create minimal prefix with no timestamp - prefix = f"{level_color}{self.BOLD}TP{self.RESET}{level_color}:{level_name} {thread_info}" + prefix = f"{level_color}{self.BOLD}TP{self.RESET}{level_color}:{level_name} " # Add log message with color message = record.getMessage() From 4f6db1a0641198f7cb788afb4de9025dbc9b10d7 Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 11:47:15 +0200 Subject: [PATCH 34/48] Disable streaming when auth or connection fails to prevent further errors --- tofupilot/openhtf/tofupilot.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 711f579..547402e 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -183,10 +183,12 @@ def _setup_streaming(self): cred = self.client.get_connection_credentials() except Exception as e: self._logger.warning(f"Operator UI: JWT error: {e}") + self.stream = False # Disable streaming on auth failure return if not cred: self._logger.warning("Operator UI: Auth server connection failed") + self.stream = False # Disable streaming on auth failure return self # Since we control the server, we know these will be set @@ -218,20 +220,24 @@ def _setup_streaming(self): connect_error_code = self.mqttClient.connect(**connectOptions) except Exception as e: self._logger.warning(f"Operator UI: Failed to connect with server (exception): {e}") + self.stream = False # Disable streaming on connection failure return if(connect_error_code != mqtt.MQTT_ERR_SUCCESS): self._logger.warning(f"Operator UI: Failed to connect with server (error code): {connect_error_code}") + self.stream = False # Disable streaming on connection failure return self try: subscribe_error_code, messageId = self.mqttClient.subscribe(**subscribeOptions) except Exception as e: self._logger.warning(f"Operator UI: Failed to subscribe to server (exception): {e}") + self.stream = False # Disable streaming on subscription failure return if(subscribe_error_code != mqtt.MQTT_ERR_SUCCESS): self._logger.warning(f"Operator UI: Failed to subscribe to server (error code): {subscribe_error_code}") + self.stream = False # Disable streaming on subscription failure return self self.mqttClient.loop_start() @@ -255,9 +261,14 @@ def _setup_streaming(self): except Exception as e: self._logger.warning(f"Operator UI: Setup error - {e}") + self.stream = False # Disable streaming on any setup error def _send_update(self, message): + # Skip publishing if streaming is disabled or client is None + if not self.stream or self.mqttClient is None: + return + try: self.mqttClient.publish( payload=json.dumps( @@ -267,6 +278,7 @@ def _send_update(self, message): ) except Exception as e: self._logger.warning(f"Operator UI: Failed to publish to server (exception): {e}") + self.stream = False # Disable streaming on publish failure return def _handle_answer(self, plug_name, method_name, args): @@ -302,7 +314,8 @@ def _final_update(self, testRecord: TestRecord): we force send at least once at the very end of the test """ - if not self.stream: + # Skip if streaming is disabled or MQTT client doesn't exist + if not self.stream or self.mqttClient is None: return test_record_dict = testRecord.as_base_types() From 9a99343510bedaab0160d888cfa4dd02828a59fd Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 11:48:42 +0200 Subject: [PATCH 35/48] Improve upload error handling to properly log API key errors --- tofupilot/openhtf/upload.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tofupilot/openhtf/upload.py b/tofupilot/openhtf/upload.py index e7b724f..ae44e9c 100644 --- a/tofupilot/openhtf/upload.py +++ b/tofupilot/openhtf/upload.py @@ -94,14 +94,28 @@ def __call__(self, test_record: TestRecord): try: # Call create_run_from_report with the generated file path run_id = self.client.upload_and_create_from_openhtf_report(filename) + + # Check if run_id is actually an error response (returned as a dict) + if isinstance(run_id, dict) and not run_id.get('success', True): + error_msg = run_id.get('error', {}).get('message') + if error_msg: + self._logger.error(f"Upload failed: {error_msg}") + return + + except Exception as e: + self._logger.error(f"Upload failed: {str(e)}") + return finally: # Ensure the file is deleted after processing if os.path.exists(filename): os.remove(filename) - if run_id: - number_of_attachments = 0 - for phase in test_record.phases: + # Skip attachment upload if run_id is not a valid string + if not run_id or not isinstance(run_id, str): + return + + number_of_attachments = 0 + for phase in test_record.phases: # Keep only max number of attachments if number_of_attachments >= self._max_attachments: self._logger.warning( From bdb4dd0408ca3b6ec5719c7769ed7d0403d86aee Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 11:50:17 +0200 Subject: [PATCH 36/48] Fix API key error handling to use TofuPilot logger consistently --- tofupilot/client.py | 8 ++++- tofupilot/openhtf/upload.py | 6 ++-- tofupilot/utils/files.py | 58 +++++++++++++++++++++++-------------- 3 files changed, 45 insertions(+), 27 deletions(-) diff --git a/tofupilot/client.py b/tofupilot/client.py index 9cd46f3..22c96de 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -477,11 +477,17 @@ def upload_and_create_from_openhtf_report( # Upload report try: - upload_id = upload_file(self._headers, self._url, file_path) + upload_id = upload_file(self._logger, self._headers, self._url, file_path) except requests.exceptions.HTTPError as http_err: + # HTTP errors like Invalid API key have already been logged by upload_file return handle_http_error(self._logger, http_err) except requests.RequestException as e: + # Network errors have already been logged by upload_file return handle_network_error(self._logger, e) + except Exception as e: + # Catch any other exceptions + self._logger.error(f"Unexpected error: {str(e)}") + return {"success": False, "error": {"message": str(e)}} payload = { "upload_id": upload_id, diff --git a/tofupilot/openhtf/upload.py b/tofupilot/openhtf/upload.py index ae44e9c..a2f7dbf 100644 --- a/tofupilot/openhtf/upload.py +++ b/tofupilot/openhtf/upload.py @@ -97,13 +97,11 @@ def __call__(self, test_record: TestRecord): # Check if run_id is actually an error response (returned as a dict) if isinstance(run_id, dict) and not run_id.get('success', True): - error_msg = run_id.get('error', {}).get('message') - if error_msg: - self._logger.error(f"Upload failed: {error_msg}") + # Error already logged by client methods return except Exception as e: - self._logger.error(f"Upload failed: {str(e)}") + # Error already logged by client methods return finally: # Ensure the file is deleted after processing diff --git a/tofupilot/utils/files.py b/tofupilot/utils/files.py index f8cedee..5154aab 100644 --- a/tofupilot/utils/files.py +++ b/tofupilot/utils/files.py @@ -3,7 +3,7 @@ from logging import Logger import os import sys -from typing import List, Dict, Optional +from typing import List, Dict, Optional, Union import requests from ..constants.requests import SECONDS_BEFORE_TIMEOUT @@ -43,39 +43,53 @@ def validate_files( def upload_file( + logger: Logger, headers: dict, url: str, file_path: str, -) -> bool: +) -> str: """Initializes an upload and stores file in it""" # Upload initialization initialize_url = f"{url}/uploads/initialize" file_name = os.path.basename(file_path) payload = {"name": file_name} - response = requests.post( - initialize_url, - data=json.dumps(payload), - headers=headers, - timeout=SECONDS_BEFORE_TIMEOUT, - ) - - response.raise_for_status() - response_json = response.json() - upload_url = response_json.get("uploadUrl") - upload_id = response_json.get("id") - - # File storing - with open(file_path, "rb") as file: - content_type, _ = mimetypes.guess_type(file_path) or "application/octet-stream" - requests.put( - upload_url, - data=file, - headers={"Content-Type": content_type}, + try: + response = requests.post( + initialize_url, + data=json.dumps(payload), + headers=headers, timeout=SECONDS_BEFORE_TIMEOUT, ) + + # Check for API key errors before raising for status + if response.status_code == 401: + error_data = response.json() + error_message = error_data.get("error", {}).get("message", "Authentication failed") + # Use the logger directly here instead of throwing an exception + logger.error(f"API key error: {error_message}") + raise requests.exceptions.HTTPError(response=response) + + response.raise_for_status() + response_json = response.json() + upload_url = response_json.get("uploadUrl") + upload_id = response_json.get("id") + + # File storing + with open(file_path, "rb") as file: + content_type, _ = mimetypes.guess_type(file_path) or "application/octet-stream" + requests.put( + upload_url, + data=file, + headers={"Content-Type": content_type}, + timeout=SECONDS_BEFORE_TIMEOUT, + ) - return upload_id + return upload_id + except Exception as e: + # Log exception but let it propagate for the caller to handle + logger.error(f"Upload failed: {str(e)}") + raise def notify_server(headers: dict, url: str, upload_id: str, run_id: str) -> bool: From 13fe97de2f0921aad6ff7f9958980ca863db478e Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 11:51:39 +0200 Subject: [PATCH 37/48] Fix API key error logging to ensure proper formatting with TP:ERR prefix --- tofupilot/client.py | 14 +++++++++++--- tofupilot/utils/files.py | 7 ++++--- tofupilot/utils/network.py | 9 ++++++++- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/tofupilot/client.py b/tofupilot/client.py index 22c96de..1923b3e 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -477,15 +477,23 @@ def upload_and_create_from_openhtf_report( # Upload report try: + # First, check if we have a valid API key directly (avoids cryptic errors) + if not self._api_key or len(self._api_key) < 10: + self._logger.error("API key error: Invalid API key format.") + return {"success": False, "error": {"message": "Invalid API key format."}} + upload_id = upload_file(self._logger, self._headers, self._url, file_path) except requests.exceptions.HTTPError as http_err: - # HTTP errors like Invalid API key have already been logged by upload_file + # Make sure API key errors are properly logged + if http_err.response.status_code == 401: + error_data = http_err.response.json() + error_message = error_data.get("error", {}).get("message", "Authentication failed") + self._logger.error(f"API key error: {error_message}") + return {"success": False, "error": {"message": error_message}} return handle_http_error(self._logger, http_err) except requests.RequestException as e: - # Network errors have already been logged by upload_file return handle_network_error(self._logger, e) except Exception as e: - # Catch any other exceptions self._logger.error(f"Unexpected error: {str(e)}") return {"success": False, "error": {"message": str(e)}} diff --git a/tofupilot/utils/files.py b/tofupilot/utils/files.py index 5154aab..108195e 100644 --- a/tofupilot/utils/files.py +++ b/tofupilot/utils/files.py @@ -66,9 +66,10 @@ def upload_file( if response.status_code == 401: error_data = response.json() error_message = error_data.get("error", {}).get("message", "Authentication failed") - # Use the logger directly here instead of throwing an exception - logger.error(f"API key error: {error_message}") - raise requests.exceptions.HTTPError(response=response) + # Create a proper HTTPError with the response + http_error = requests.exceptions.HTTPError(response=response) + http_error.response = response + raise http_error response.raise_for_status() response_json = response.json() diff --git a/tofupilot/utils/network.py b/tofupilot/utils/network.py index bd5d1cd..13ccbb8 100644 --- a/tofupilot/utils/network.py +++ b/tofupilot/utils/network.py @@ -56,11 +56,18 @@ def handle_http_error( if warnings is not None: for warning in warnings: logger.warning(warning) + + # Get the error message error_message = parse_error_message(http_err.response) + + # Special handling for auth errors + if http_err.response.status_code == 401: + error_message = f"API key error: {error_message}" else: # Handle cases where response is empty or non-JSON - error_message = http_err + error_message = str(http_err) + # Use the logger to log the error message logger.error(error_message) return { From 57feeb4d598c8fb20efa0d836324c1380a66a406 Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 12:46:34 +0200 Subject: [PATCH 38/48] Reduces code base --- tofupilot/client.py | 271 +++++++++++------------------------- tofupilot/openhtf/upload.py | 162 +++++++++------------ tofupilot/utils/__init__.py | 9 +- tofupilot/utils/files.py | 268 ++++++++++++++++++++++++++++------- tofupilot/utils/logger.py | 35 +++-- tofupilot/utils/network.py | 82 ++++++----- 6 files changed, 441 insertions(+), 386 deletions(-) diff --git a/tofupilot/client.py b/tofupilot/client.py index 1923b3e..1e47055 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -31,6 +31,9 @@ handle_http_error, handle_network_error, notify_server, + api_request, + upload_attachment_data, + process_openhtf_attachments, ) @@ -47,8 +50,8 @@ def __init__(self, api_key: Optional[str] = None, url: Optional[str] = None): self._api_key = api_key or os.environ.get("TOFUPILOT_API_KEY") if self._api_key is None: - error = "Please set TOFUPILOT_API_KEY environment variable. For more information on how to find or generate a valid API key, visit https://tofupilot.com/docs/user-management#api-key." # pylint: disable=line-too-long - self._logger.error(f"API key error: {error}") + error = "Please set TOFUPILOT_API_KEY environment variable. For more information on how to find or generate a valid API key, visit https://tofupilot.com/docs/user-management#api-key." + self._logger.error(error) sys.exit(1) self._url = f"{url or os.environ.get('TOFUPILOT_URL') or ENDPOINT}/api/v1" @@ -167,29 +170,22 @@ def create_run( # pylint: disable=too-many-arguments,too-many-locals payload["report_variables"] = report_variables self._log_request("POST", "/runs", payload) + result = api_request( + self._logger, + "POST", + f"{self._url}/runs", + self._headers, + data=payload + ) - try: - response = requests.post( - f"{self._url}/runs", - json=payload, - headers=self._headers, - timeout=SECONDS_BEFORE_TIMEOUT, + # Upload attachments if run was created successfully + run_id = result.get("id") + if run_id and attachments and result.get("success", False) is not False: + upload_attachments( + self._logger, self._headers, self._url, attachments, run_id ) - response.raise_for_status() - result = handle_response(self._logger, response) - run_id = result.get("id") - if run_id and attachments: - upload_attachments( - self._logger, self._headers, self._url, attachments, run_id - ) - - return result - - except requests.exceptions.HTTPError as http_err: - return handle_http_error(self._logger, http_err) - except requests.RequestException as e: - return handle_network_error(self._logger, e) + return result def create_run_from_openhtf_report(self, file_path: str): """ @@ -234,69 +230,17 @@ def create_run_from_openhtf_report(self, file_path: str): # Now safely proceed with attachment upload if run_id and test_record and "phases" in test_record: - self._logger.info("Run created, uploading attachments") - number_of_attachments = 0 - - for phase in test_record.get("phases", []): - # Skip if phase has no attachments - if not phase.get("attachments"): - continue - - # Keep only max number of attachments - if number_of_attachments >= self._max_attachments: - self._logger.warning( - "Attachment limit (%d) reached", - self._max_attachments - ) - break - - for attachment_name, attachment in phase.get("attachments", {}).items(): - number_of_attachments += 1 - self._logger.info("Uploading: %s", attachment_name) - - try: - # Upload initialization - initialize_url = f"{self._url}/uploads/initialize" - payload = {"name": attachment_name} - - response = requests.post( - initialize_url, - data=json.dumps(payload), - headers=self._headers, - timeout=SECONDS_BEFORE_TIMEOUT, - ) - - response.raise_for_status() - response_json = response.json() - upload_url = response_json.get("uploadUrl") - upload_id = response_json.get("id") - - # Ensure attachment data exists and is valid - if not attachment.get("data"): - self._logger.warning(f"No data in: {attachment_name}") - continue - - data = base64.b64decode(attachment["data"]) - - # Upload attachment data - upload_response = requests.put( - upload_url, - data=data, - headers={ - "Content-Type": attachment.get("mimetype", "application/octet-stream") - }, - timeout=SECONDS_BEFORE_TIMEOUT, - ) - upload_response.raise_for_status() - - # Notify server to link attachment to run - notify_server(self._headers, self._url, upload_id, run_id) - - self._logger.success("Uploaded: %s", attachment_name) - except requests.exceptions.RequestException as e: - self._logger.error(f"Upload failed: {attachment_name} - {str(e)}") - # Continue with other attachments even if one fails - continue + # Use the centralized function to process all attachments + process_openhtf_attachments( + self._logger, + self._headers, + self._url, + test_record, + run_id, + self._max_attachments, + self._max_file_size, + needs_base64_decode=True # JSON attachments need base64 decoding + ) else: if not test_record: self._logger.error("Test record load failed") @@ -333,23 +277,14 @@ def get_runs(self, serial_number: str) -> dict: self._logger.info("Fetching runs for: %s", serial_number) params = {"serial_number": serial_number} - self._log_request("GET", "/runs", params) - - try: - response = requests.get( - f"{self._url}/runs", - headers=self._headers, - params=params, - timeout=SECONDS_BEFORE_TIMEOUT, - ) - response.raise_for_status() - return handle_response(self._logger, response) - - except requests.exceptions.HTTPError as http_err: - return handle_http_error(self._logger, http_err) - except requests.RequestException as e: - return handle_network_error(self._logger, e) + return api_request( + self._logger, + "GET", + f"{self._url}/runs", + self._headers, + params=params + ) def delete_run(self, run_id: str) -> dict: """ @@ -366,22 +301,13 @@ def delete_run(self, run_id: str) -> dict: https://www.tofupilot.com/docs/api#delete-a-run """ self._logger.info('Deleting run: %s', run_id) - self._log_request("DELETE", f"/runs/{run_id}") - - try: - response = requests.delete( - f"{self._url}/runs/{run_id}", - headers=self._headers, - timeout=SECONDS_BEFORE_TIMEOUT, - ) - response.raise_for_status() - return handle_response(self._logger, response) - - except requests.exceptions.HTTPError as http_err: - return handle_http_error(self._logger, http_err) - except requests.RequestException as e: - return handle_network_error(self._logger, e) + return api_request( + self._logger, + "DELETE", + f"{self._url}/runs/{run_id}", + self._headers + ) def update_unit( self, serial_number: str, sub_units: Optional[List[SubUnit]] = None @@ -403,25 +329,15 @@ def update_unit( https://www.tofupilot.com/docs/api#update-a-unit """ self._logger.info('Updating unit: %s', serial_number) - payload = {"sub_units": sub_units} - self._log_request("PATCH", f"/units/{serial_number}", payload) - - try: - response = requests.patch( - f"{self._url}/units/{serial_number}", - json=payload, - headers=self._headers, - timeout=SECONDS_BEFORE_TIMEOUT, - ) - response.raise_for_status() - return handle_response(self._logger, response) - - except requests.exceptions.HTTPError as http_err: - return handle_http_error(self._logger, http_err) - except requests.RequestException as e: - return handle_network_error(self._logger, e) + return api_request( + self._logger, + "PATCH", + f"{self._url}/units/{serial_number}", + self._headers, + data=payload + ) def delete_unit(self, serial_number: str) -> dict: """ @@ -439,22 +355,13 @@ def delete_unit(self, serial_number: str) -> dict: https://www.tofupilot.com/docs/api#delete-a-unit """ self._logger.info('Deleting unit: %s', serial_number) - self._log_request("DELETE", f"/units/{serial_number}") - - try: - response = requests.delete( - f"{self._url}/units/{serial_number}", - headers=self._headers, - timeout=SECONDS_BEFORE_TIMEOUT, - ) - response.raise_for_status() - return handle_response(self._logger, response) - - except requests.exceptions.HTTPError as http_err: - return handle_http_error(self._logger, http_err) - except requests.RequestException as e: - return handle_network_error(self._logger, e) + return api_request( + self._logger, + "DELETE", + f"{self._url}/units/{serial_number}", + self._headers + ) def upload_and_create_from_openhtf_report( self, @@ -477,25 +384,15 @@ def upload_and_create_from_openhtf_report( # Upload report try: - # First, check if we have a valid API key directly (avoids cryptic errors) - if not self._api_key or len(self._api_key) < 10: - self._logger.error("API key error: Invalid API key format.") - return {"success": False, "error": {"message": "Invalid API key format."}} - - upload_id = upload_file(self._logger, self._headers, self._url, file_path) + upload_id = upload_file(self._headers, self._url, file_path) except requests.exceptions.HTTPError as http_err: - # Make sure API key errors are properly logged - if http_err.response.status_code == 401: - error_data = http_err.response.json() - error_message = error_data.get("error", {}).get("message", "Authentication failed") - self._logger.error(f"API key error: {error_message}") - return {"success": False, "error": {"message": error_message}} - return handle_http_error(self._logger, http_err) + error_info = handle_http_error(self._logger, http_err) + # Error already logged by handle_http_error + return error_info except requests.RequestException as e: - return handle_network_error(self._logger, e) - except Exception as e: - self._logger.error(f"Unexpected error: {str(e)}") - return {"success": False, "error": {"message": str(e)}} + error_info = handle_network_error(self._logger, e) + # Error already logged by handle_network_error + return error_info payload = { "upload_id": upload_id, @@ -506,25 +403,20 @@ def upload_and_create_from_openhtf_report( self._log_request("POST", "/import", payload) - # Create run from file - try: - response = requests.post( - f"{self._url}/import", - json=payload, - headers=self._headers, - timeout=SECONDS_BEFORE_TIMEOUT, - ) - response.raise_for_status() - result = handle_response(self._logger, response) - - run_id = result.get("id") - - return run_id + # Create run from file using unified API request handler + result = api_request( + self._logger, + "POST", + f"{self._url}/import", + self._headers, + data=payload + ) - except requests.exceptions.HTTPError as http_err: - return handle_http_error(self._logger, http_err) - except requests.RequestException as e: - return handle_network_error(self._logger, e) + # Return only the ID if successful, otherwise return the full result + if result.get("success", False) is not False: + return result.get("id") + else: + return result def get_connection_credentials(self) -> dict: """ @@ -534,8 +426,8 @@ def get_connection_credentials(self) -> dict: values: a dict containing the emqx server url, the topic to connect to, and the JWT token required to connect """ - try: + # Using direct request instead of api_request to match original behavior response = requests.get( f"{self._url}/streaming", headers=self._headers, @@ -544,13 +436,10 @@ def get_connection_credentials(self) -> dict: response.raise_for_status() values = handle_response(self._logger, response) return values - - except requests.exceptions.HTTPError as http_err: - handle_http_error(self._logger, http_err) - return None - except requests.RequestException as e: - handle_network_error(self._logger, e) - return None + except Exception: + # Catch any error but don't return None - matching original behavior + # Handle errors but let execution continue + return {} def print_version_banner(current_version: str): """Prints current version of client with tofu art""" diff --git a/tofupilot/openhtf/upload.py b/tofupilot/openhtf/upload.py index a2f7dbf..98e9d0e 100644 --- a/tofupilot/openhtf/upload.py +++ b/tofupilot/openhtf/upload.py @@ -14,6 +14,9 @@ ) from ..utils import ( notify_server, + LoggerStateManager, + upload_attachment_data, + process_openhtf_attachments, ) @@ -58,108 +61,67 @@ def __init__( self._max_file_size = self.client._max_file_size def __call__(self, test_record: TestRecord): - - # Extract relevant details from the test record - dut_id = test_record.dut_id - test_name = test_record.metadata.get("test_name") - - # Convert milliseconds to a datetime object - start_time = datetime.datetime.fromtimestamp( - test_record.start_time_millis / 1000.0 - ) - - # Format the timestamp as YYYY-MM-DD_HH_MM_SS_SSS - start_time_formatted = start_time.strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3] - - temp_dir = tempfile.gettempdir() - - # Craft system-agnostic temporary filename - filename = os.path.join( - temp_dir, f"{dut_id}.{test_name}.{start_time_formatted}.json" - ) - - # Use the existing OutputToJSON callback to write to the custom file - output_callback = json_factory.OutputToJSON( - filename, - inline_attachments=False, # Exclude raw attachments - allow_nan=self.allow_nan, - indent=4, - ) - - # Open the custom file and write serialized test record to it - with open(filename, "w", encoding="utf-8") as file: - for json_line in output_callback.serialize_test_record(test_record): - file.write(json_line) - - try: - # Call create_run_from_report with the generated file path - run_id = self.client.upload_and_create_from_openhtf_report(filename) + # Use context manager to handle logger state + with LoggerStateManager(self._logger): + # Extract test metadata + dut_id = test_record.dut_id + test_name = test_record.metadata.get("test_name") - # Check if run_id is actually an error response (returned as a dict) - if isinstance(run_id, dict) and not run_id.get('success', True): - # Error already logged by client methods - return + # Create timestamp for filename + start_time = datetime.datetime.fromtimestamp( + test_record.start_time_millis / 1000.0 + ) + start_time_formatted = start_time.strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3] + + # Create temp file for the report + fd, filename = tempfile.mkstemp( + suffix=".json", + prefix=f"{dut_id}.{test_name}.{start_time_formatted}.", + dir=tempfile.gettempdir() + ) + try: + os.close(fd) # Close file descriptor - except Exception as e: - # Error already logged by client methods - return - finally: - # Ensure the file is deleted after processing - if os.path.exists(filename): - os.remove(filename) - - # Skip attachment upload if run_id is not a valid string + # Use OpenHTF JSON callback to serialize test record + output_callback = json_factory.OutputToJSON( + filename, + inline_attachments=False, + allow_nan=self.allow_nan, + indent=4, + ) + + # Write test record to file + with open(filename, "w", encoding="utf-8") as file: + for json_line in output_callback.serialize_test_record(test_record): + file.write(json_line) + + # Upload report to server + run_id = self.client.upload_and_create_from_openhtf_report(filename) + + # Check if response indicates error + if isinstance(run_id, dict) and not run_id.get('success', True): + return + except Exception as e: + self._logger.error(str(e)) + return + finally: + # Clean up temp file + if os.path.exists(filename): + os.remove(filename) + + # Skip attachment upload if run_id is invalid if not run_id or not isinstance(run_id, str): return - number_of_attachments = 0 - for phase in test_record.phases: - # Keep only max number of attachments - if number_of_attachments >= self._max_attachments: - self._logger.warning( - "Attachment limit (%d) reached", - self._max_attachments - ) - break - for attachment_name, attachment in phase.attachments.items(): - # Remove attachments that exceed the max file size - if attachment.size > self._max_file_size: - self._logger.warning( - "File too large (%d bytes): %s", - self._max_file_size, - attachment.name - ) - continue - if number_of_attachments >= self._max_attachments: - break - - number_of_attachments += 1 - - self._logger.info("Uploading: %s", attachment_name) - - # Upload initialization - initialize_url = f"{self._url}/uploads/initialize" - payload = {"name": attachment_name} - - response = requests.post( - initialize_url, - data=json.dumps(payload), - headers=self._headers, - timeout=SECONDS_BEFORE_TIMEOUT, - ) - - response.raise_for_status() - response_json = response.json() - upload_url = response_json.get("uploadUrl") - upload_id = response_json.get("id") - - requests.put( - upload_url, - data=attachment.data, - headers={"Content-Type": attachment.mimetype}, - timeout=SECONDS_BEFORE_TIMEOUT, - ) - - notify_server(self._headers, self._url, upload_id, run_id) - - self._logger.success("Uploaded: %s", attachment_name) + # Use the centralized function to process all attachments + # OpenHTF test record is directly passed as an object, not JSON + process_openhtf_attachments( + self._logger, + self._headers, + self._url, + test_record, + run_id, + self._max_attachments, + self._max_file_size, + needs_base64_decode=False # Direct object attachments don't need base64 decoding + ) diff --git a/tofupilot/utils/__init__.py b/tofupilot/utils/__init__.py index 7fecdd2..4038c17 100644 --- a/tofupilot/utils/__init__.py +++ b/tofupilot/utils/__init__.py @@ -1,10 +1,12 @@ -from .logger import setup_logger +from .logger import setup_logger, LoggerStateManager from .version_checker import check_latest_version from .files import ( validate_files, upload_file, notify_server, upload_attachments, + upload_attachment_data, + process_openhtf_attachments, log_and_raise, ) from .dates import ( @@ -17,15 +19,19 @@ handle_response, handle_http_error, handle_network_error, + api_request, ) __all__ = [ "setup_logger", + "LoggerStateManager", "check_latest_version", "validate_files", "upload_file", "notify_server", "upload_attachments", + "upload_attachment_data", + "process_openhtf_attachments", "parse_error_message", "timedelta_to_iso", "duration_to_iso", @@ -34,4 +40,5 @@ "handle_response", "handle_http_error", "handle_network_error", + "api_request", ] diff --git a/tofupilot/utils/files.py b/tofupilot/utils/files.py index 108195e..a157230 100644 --- a/tofupilot/utils/files.py +++ b/tofupilot/utils/files.py @@ -43,7 +43,6 @@ def validate_files( def upload_file( - logger: Logger, headers: dict, url: str, file_path: str, @@ -54,72 +53,243 @@ def upload_file( file_name = os.path.basename(file_path) payload = {"name": file_name} + response = requests.post( + initialize_url, + data=json.dumps(payload), + headers=headers, + timeout=SECONDS_BEFORE_TIMEOUT, + ) + + response.raise_for_status() + response_json = response.json() + upload_url = response_json.get("uploadUrl") + upload_id = response_json.get("id") + + # File storing + with open(file_path, "rb") as file: + content_type, _ = mimetypes.guess_type(file_path) or "application/octet-stream" + requests.put( + upload_url, + data=file, + headers={"Content-Type": content_type}, + timeout=SECONDS_BEFORE_TIMEOUT, + ) + + return upload_id + + +def notify_server(headers: dict, url: str, upload_id: str, run_id: str, logger=None) -> bool: + """Tells TP server to sync upload with newly created run""" + sync_url = f"{url}/uploads/sync" + sync_payload = {"upload_id": upload_id, "run_id": run_id} + + try: + response = requests.post( + sync_url, + data=json.dumps(sync_payload), + headers=headers, + timeout=SECONDS_BEFORE_TIMEOUT, + ) + response.raise_for_status() + return True + except Exception as e: + # If logger is available, log the error properly + if logger: + logger.error(str(e)) + return False + + +def upload_attachment_data( + logger: Logger, + headers: dict, + url: str, + name: str, + data, + mimetype: str, + run_id: str +) -> bool: + """Uploads binary data as an attachment and links it to a run""" try: + # Initialize upload + logger.info(f"Uploading: {name}") + initialize_url = f"{url}/uploads/initialize" + payload = {"name": name} + response = requests.post( initialize_url, data=json.dumps(payload), headers=headers, timeout=SECONDS_BEFORE_TIMEOUT, ) - - # Check for API key errors before raising for status - if response.status_code == 401: - error_data = response.json() - error_message = error_data.get("error", {}).get("message", "Authentication failed") - # Create a proper HTTPError with the response - http_error = requests.exceptions.HTTPError(response=response) - http_error.response = response - raise http_error - response.raise_for_status() + + # Get upload details response_json = response.json() upload_url = response_json.get("uploadUrl") upload_id = response_json.get("id") - - # File storing - with open(file_path, "rb") as file: - content_type, _ = mimetypes.guess_type(file_path) or "application/octet-stream" - requests.put( - upload_url, - data=file, - headers={"Content-Type": content_type}, - timeout=SECONDS_BEFORE_TIMEOUT, - ) - - return upload_id + + # Upload the actual data + content_type = mimetype or "application/octet-stream" + upload_response = requests.put( + upload_url, + data=data, + headers={"Content-Type": content_type}, + timeout=SECONDS_BEFORE_TIMEOUT, + ) + upload_response.raise_for_status() + + # Link attachment to run + notify_server(headers, url, upload_id, run_id, logger) + + logger.success(f"Uploaded: {name}") + return True except Exception as e: - # Log exception but let it propagate for the caller to handle - logger.error(f"Upload failed: {str(e)}") - raise - - -def notify_server(headers: dict, url: str, upload_id: str, run_id: str) -> bool: - """Tells TP server to sync upload with newly created run""" - sync_url = f"{url}/uploads/sync" - sync_payload = {"upload_id": upload_id, "run_id": run_id} - - response = requests.post( - sync_url, - data=json.dumps(sync_payload), - headers=headers, - timeout=SECONDS_BEFORE_TIMEOUT, - ) - - return response.status_code == 200 + logger.error(f"Upload failed: {name} - {str(e)}") + return False def upload_attachments( logger: Logger, headers: dict, url: str, - paths: List[Dict[str, Optional[str]]], + paths: List[str], run_id: str, ): - """Creates one upload per file and stores them into TofuPilot""" + """Creates one upload per file path and stores them into TofuPilot""" for file_path in paths: - logger.info("Uploading: %s", file_path) - - upload_id = upload_file(headers, url, file_path) - notify_server(headers, url, upload_id, run_id) - - logger.success(f"Uploaded: {file_path}") + logger.info(f"Uploading: {file_path}") + + try: + # Open file and prepare for upload + with open(file_path, "rb") as file: + name = os.path.basename(file_path) + data = file.read() + content_type, _ = mimetypes.guess_type(file_path) or "application/octet-stream" + + # Use shared upload function + upload_attachment_data(logger, headers, url, name, data, content_type, run_id) + except Exception as e: + logger.error(f"Upload failed: {file_path} - {str(e)}") + continue + + +def process_openhtf_attachments( + logger: Logger, + headers: dict, + url: str, + test_record: Union[Dict, object], + run_id: str, + max_attachments: int, + max_file_size: int, + needs_base64_decode: bool = True, +) -> None: + """ + Process attachments from an OpenHTF test record and upload them. + + This function centralizes the attachment processing logic used in both the + direct TofuPilotClient.create_run_from_openhtf_report and the OpenHTF output callback. + + Args: + logger: Logger for output messages + headers: HTTP headers for API authentication + url: Base API URL + test_record: OpenHTF test record (either as dict or object) + run_id: ID of the run to attach files to + max_attachments: Maximum number of attachments to process + max_file_size: Maximum size per attachment + needs_base64_decode: Whether attachment data is base64 encoded (true for dict format) + """ + # Resume logger if it was paused + was_resumed = False + if hasattr(logger, 'resume'): + logger.resume() + was_resumed = True + + try: + logger.info("Processing attachments") + attachment_count = 0 + + # Extract phases from test record based on type + if isinstance(test_record, dict): + phases = test_record.get("phases", []) + else: + phases = getattr(test_record, "phases", []) + + # Iterate through phases and their attachments + for phase in phases: + # Skip if we've reached attachment limit + if attachment_count >= max_attachments: + logger.warning(f"Attachment limit ({max_attachments}) reached") + break + + # Get attachments based on record type + if isinstance(test_record, dict): + phase_attachments = phase.get("attachments", {}) + else: + phase_attachments = getattr(phase, "attachments", {}) + + # Skip if phase has no attachments + if not phase_attachments: + continue + + # Process each attachment in the phase + for name, attachment in phase_attachments.items(): + # Skip if we've reached attachment limit + if attachment_count >= max_attachments: + break + + # Get attachment data and size based on record type + if isinstance(test_record, dict): + # Dict format (from JSON file) + attachment_data = attachment.get("data", "") + if not attachment_data: + logger.warning(f"No data in: {name}") + continue + + try: + if needs_base64_decode: + import base64 + data = base64.b64decode(attachment_data) + else: + data = attachment_data + + attachment_size = len(data) + mimetype = attachment.get("mimetype", "application/octet-stream") + except Exception as e: + logger.error(f"Failed to process attachment data: {name} - {str(e)}") + continue + else: + # Object format (from callback) + attachment_data = getattr(attachment, "data", None) + if attachment_data is None: + logger.warning(f"No data in: {name}") + continue + + data = attachment_data + attachment_size = getattr(attachment, "size", len(data)) + mimetype = getattr(attachment, "mimetype", "application/octet-stream") + + # Skip oversized attachments + if attachment_size > max_file_size: + logger.warning(f"File too large: {name}") + continue + + # Increment counter and process the attachment + attachment_count += 1 + logger.info(f"Uploading: {name}") + + # Use unified attachment upload function + upload_attachment_data( + logger, + headers, + url, + name, + data, + mimetype, + run_id + ) + # Continue with other attachments regardless of success/failure + finally: + # If we resumed the logger and it has a pause method, pause it again + if was_resumed and hasattr(logger, 'pause'): + logger.pause() \ No newline at end of file diff --git a/tofupilot/utils/logger.py b/tofupilot/utils/logger.py index a835ea5..2cc5294 100644 --- a/tofupilot/utils/logger.py +++ b/tofupilot/utils/logger.py @@ -21,25 +21,16 @@ def success(self, message, *args, **kws): def add_pause_methods_to_logger(logger): - """ - Simple function to add pause/resume functionality to a logger without modifying handlers. - - Args: - logger: The logger to enhance - """ - # Create buffer for storing messages while paused - message_buffer = queue.Queue() + """Add pause/resume functionality to a logger""" original_handlers = list(logger.handlers) - # Save the original handlers' handles and emits def pause(): - """Temporarily disable all handlers to pause logging""" + """Temporarily disable all handlers""" for handler in logger.handlers: logger.removeHandler(handler) def resume(): - """Re-enable handlers and process any buffered messages""" - # Restore original handlers + """Re-enable handlers""" for handler in original_handlers: if handler not in logger.handlers: logger.addHandler(handler) @@ -51,6 +42,26 @@ def resume(): return logger +class LoggerStateManager: + """Context manager for temporarily ensuring logger is active""" + + def __init__(self, logger): + self.logger = logger + self.was_resumed = False + + def __enter__(self): + # Ensure logger is active for this block + if hasattr(self.logger, 'resume'): + self.logger.resume() + self.was_resumed = True + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + # If we resumed the logger, restore to paused state + if self.was_resumed and hasattr(self.logger, 'pause'): + self.logger.pause() + + class TofupilotFormatter(logging.Formatter): """Minimal formatter with colors and no timestamp.""" diff --git a/tofupilot/utils/network.py b/tofupilot/utils/network.py index 13ccbb8..1cb0385 100644 --- a/tofupilot/utils/network.py +++ b/tofupilot/utils/network.py @@ -1,9 +1,11 @@ -from typing import Dict, List, Optional, Any +from typing import Dict, List, Optional, Any, Union import requests +from ..constants.requests import SECONDS_BEFORE_TIMEOUT def parse_error_message(response: requests.Response) -> str: + """Extract error message from response""" try: error_data = response.json() return error_data.get("error", {}).get( @@ -13,6 +15,29 @@ def parse_error_message(response: requests.Response) -> str: return f"HTTP error occurred: {response.text}" +def api_request( + logger, method: str, url: str, headers: Dict, + data: Optional[Dict] = None, + params: Optional[Dict] = None, + timeout: int = SECONDS_BEFORE_TIMEOUT +) -> Dict: + """Unified API request handler with consistent error handling""" + try: + response = requests.request( + method, url, + json=data, + headers=headers, + params=params, + timeout=timeout + ) + response.raise_for_status() + return handle_response(logger, response) + except requests.exceptions.HTTPError as http_err: + return handle_http_error(logger, http_err) + except requests.RequestException as e: + return handle_network_error(logger, e) + + def handle_response( logger, response: requests.Response, @@ -42,34 +67,27 @@ def handle_http_error( logger, http_err: requests.exceptions.HTTPError ) -> Dict[str, Any]: """Handles HTTP errors and logs them.""" - - warnings = None # Initialize warnings to None - - # Check if the response body is not empty and Content-Type is application/json - if ( - http_err.response.text.strip() - and http_err.response.headers.get("Content-Type") == "application/json" - ): - # Parse JSON safely - response_json = http_err.response.json() - warnings = response_json.get("warnings") - if warnings is not None: - for warning in warnings: - logger.warning(warning) - - # Get the error message - error_message = parse_error_message(http_err.response) - - # Special handling for auth errors - if http_err.response.status_code == 401: - error_message = f"API key error: {error_message}" + warnings = None + + # Extract error details from JSON response when available + if (http_err.response.text.strip() and + http_err.response.headers.get("Content-Type") == "application/json"): + try: + response_json = http_err.response.json() + warnings = response_json.get("warnings") + if warnings: + for warning in warnings: + logger.warning(warning) + error_message = parse_error_message(http_err.response) + except ValueError: + error_message = str(http_err) else: - # Handle cases where response is empty or non-JSON error_message = str(http_err) - # Use the logger to log the error message + # Log the error through the logger for proper formatting logger.error(error_message) + # Return structured error info return { "success": False, "message": None, @@ -81,22 +99,20 @@ def handle_http_error( def handle_network_error(logger, e: requests.RequestException) -> Dict[str, Any]: """Handles network errors and logs them.""" - error_message = str(e) - logger.error(f"Network error: {error_message}") + error_message = f"Network error: {str(e)}" + logger.error(error_message) - # Provide specific guidance for SSL certificate errors - if isinstance(e, requests.exceptions.SSLError) or "SSL" in error_message or "certificate verify failed" in error_message: + # Provide SSL-specific guidance + if isinstance(e, requests.exceptions.SSLError) or "SSL" in str(e) or "certificate verify failed" in str(e): logger.warning("SSL certificate verification error detected") logger.warning("This is typically caused by missing or invalid SSL certificates") - logger.warning("Try the following solutions:") - logger.warning("1. Ensure the certifi package is installed: pip install certifi") - logger.warning("2. If you're on macOS, run: /Applications/Python*/Install Certificates.command") - logger.warning("3. You can manually set the SSL_CERT_FILE environment variable: export SSL_CERT_FILE=/path/to/cacert.pem") + logger.warning("Try: 1) pip install certifi 2) /Applications/Python*/Install Certificates.command") + # Return structured error info return { "success": False, "message": None, "warnings": None, "status_code": None, - "error": {"message": error_message}, + "error": {"message": str(e)}, } From 2bc2853b486f19d522048d2b2e7a1f2b56ec5641 Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 13:00:58 +0200 Subject: [PATCH 39/48] Disabled Ctrl+C prompt for now --- tofupilot/openhtf/custom_prompt.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tofupilot/openhtf/custom_prompt.py b/tofupilot/openhtf/custom_prompt.py index c9cd779..bb7d436 100644 --- a/tofupilot/openhtf/custom_prompt.py +++ b/tofupilot/openhtf/custom_prompt.py @@ -59,13 +59,13 @@ def prompt_with_tofupilot_url( clickable_text = f"\033]8;;{operator_page_url}\033\\TofuPilot Operator UI\033]8;;\033\\" sys.stdout.write(f"\n[User Input] \033[1m{message}\033[0m\n") sys.stdout.write(f"Waiting for user input on {clickable_text} or in terminal below.\n") - sys.stdout.write("\033[2mPress Ctrl+C to cancel and upload results.\033[0m\n\n") + # sys.stdout.write("\033[2mPress Ctrl+C to cancel and upload results.\033[0m\n\n") sys.stdout.flush() except: # Fallback if terminal doesn't support ANSI sequences print(f"\n[User Input] {message}") print(f"Waiting for user input on TofuPilot Operator UI or in terminal below.") - print("Press Ctrl+C to cancel and upload results.\n") + # print("Press Ctrl+C to cancel and upload results.\n") # Store original message and use it for web UI compatibility original_msg = message @@ -120,7 +120,7 @@ def trigger_phase(test: openhtf.TestApi, prompts: UserInput) -> None: # Fallback if terminal doesn't support ANSI sequences print(f"\n[User Input] {message}") print(f"Waiting for user input on TofuPilot Operator UI or in terminal below.") - print("Press Ctrl+C to cancel and upload results.\n") + # print("Press Ctrl+C to cancel and upload results.\n") # Store original message and use it for web UI compatibility original_msg = message @@ -169,7 +169,7 @@ def patched_prompt(self, message, text_input=False, timeout_s=None, cli_color='' # Fallback if terminal doesn't support ANSI sequences print(f"\n[User Input] {message}") print(f"Waiting for user input on TofuPilot Operator UI or in terminal below.") - print("Press Ctrl+C to cancel and upload results.\n") + # print("Press Ctrl+C to cancel and upload results.\n") # Override cli_color to make the OpenHTF prompt appear dimmed # This works because the cli_color is applied to the prompt arrow From bd7d02b493eaf097c0768bd15c3585d88f41dfd1 Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Tue, 6 May 2025 13:23:14 +0200 Subject: [PATCH 40/48] Cleans unused imports --- tofupilot/client.py | 14 ++++++-------- tofupilot/openhtf/custom_prompt.py | 5 ++--- tofupilot/openhtf/tofupilot.py | 1 - tofupilot/openhtf/upload.py | 7 ------- 4 files changed, 8 insertions(+), 19 deletions(-) diff --git a/tofupilot/client.py b/tofupilot/client.py index 1e47055..26ba425 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -8,7 +8,6 @@ from importlib.metadata import version import json -import base64 import requests import certifi @@ -30,9 +29,7 @@ handle_response, handle_http_error, handle_network_error, - notify_server, api_request, - upload_attachment_data, process_openhtf_attachments, ) @@ -427,7 +424,6 @@ def get_connection_credentials(self) -> dict: a dict containing the emqx server url, the topic to connect to, and the JWT token required to connect """ try: - # Using direct request instead of api_request to match original behavior response = requests.get( f"{self._url}/streaming", headers=self._headers, @@ -436,10 +432,12 @@ def get_connection_credentials(self) -> dict: response.raise_for_status() values = handle_response(self._logger, response) return values - except Exception: - # Catch any error but don't return None - matching original behavior - # Handle errors but let execution continue - return {} + except requests.exceptions.HTTPError as http_err: + handle_http_error(self._logger, http_err) + return None + except requests.RequestException as e: + handle_network_error(self._logger, e) + return None def print_version_banner(current_version: str): """Prints current version of client with tofu art""" diff --git a/tofupilot/openhtf/custom_prompt.py b/tofupilot/openhtf/custom_prompt.py index bb7d436..9e25b0c 100644 --- a/tofupilot/openhtf/custom_prompt.py +++ b/tofupilot/openhtf/custom_prompt.py @@ -86,8 +86,7 @@ def enhanced_prompt_for_test_start( operator_page_url: Optional[Text] = None, message: Text = 'Enter a DUT ID in order to start the test.', timeout_s: Union[int, float, None] = 60 * 60 * 24, - validator: Callable[[Text], Text] = lambda sn: sn, - cli_color: Text = '') -> openhtf.PhaseDescriptor: + validator: Callable[[Text], Text] = lambda sn: sn) -> openhtf.PhaseDescriptor: """Returns an OpenHTF phase that displays TofuPilot URL in the console. Args: @@ -162,7 +161,7 @@ def patched_prompt(self, message, text_input=False, timeout_s=None, cli_color='' clickable_text = f"\033]8;;{tofupilot_url}\033\\TofuPilot Operator UI\033]8;;\033\\" sys.stdout.write(f"\n[User Input] \033[1m{message}\033[0m\n") sys.stdout.write(f"Waiting for user input on {clickable_text} or in terminal below.\n") - sys.stdout.write("\033[2mPress Ctrl+C to stop and upload run.\033[0m\n\n") + # sys.stdout.write("\033[2mPress Ctrl+C to stop and upload run.\033[0m\n\n") sys.stdout.flush() except: diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 547402e..7d350ea 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -2,7 +2,6 @@ from typing import Optional from time import sleep import threading -import asyncio import json from openhtf import Test diff --git a/tofupilot/openhtf/upload.py b/tofupilot/openhtf/upload.py index 98e9d0e..7c07e5c 100644 --- a/tofupilot/openhtf/upload.py +++ b/tofupilot/openhtf/upload.py @@ -1,21 +1,14 @@ import os -import json import datetime import tempfile from typing import Optional from openhtf.core.test_record import TestRecord from openhtf.output.callbacks import json_factory -import requests from ..client import TofuPilotClient -from ..constants import ( - SECONDS_BEFORE_TIMEOUT, -) from ..utils import ( - notify_server, LoggerStateManager, - upload_attachment_data, process_openhtf_attachments, ) From 386bd35da5803369bd8f3fe3754fd7f1ef9c894c Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Wed, 7 May 2025 15:11:32 +0200 Subject: [PATCH 41/48] Fix run upload attachments --- setup.py | 3 +- tofupilot/openhtf/tofupilot.py | 4 + tofupilot/openhtf/upload.py | 212 +++++++++++++++++++++++---------- tofupilot/utils/files.py | 84 ++++++++++--- 4 files changed, 227 insertions(+), 76 deletions(-) diff --git a/setup.py b/setup.py index 9020af9..0bc784c 100644 --- a/setup.py +++ b/setup.py @@ -5,14 +5,13 @@ setup( name="tofupilot", - version="1.9.4", + version="1.11.0.dev0", packages=find_packages(), install_requires=[ "requests", "setuptools", "packaging", "pytest", - "websockets", "paho-mqtt", ], entry_points={ diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 7d350ea..410262c 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -126,16 +126,20 @@ def __init__( self._streaming_setup_thread = None def __enter__(self): + # Add upload callback without pausing the logger yet self.test.add_output_callbacks( upload(api_key=self.api_key, url=self.url, client=self.client), self._final_update, ) + # Start streaming setup before pausing the logger if self.stream: self._streaming_setup_thread = threading.Thread(target=self._setup_streaming) self._streaming_setup_thread.start() + # Give the streaming setup a chance to connect and log its messages self._streaming_setup_thread.join(1) + # Now pause the logger - this happens after MQTT setup has started self._logger.pause() return self diff --git a/tofupilot/openhtf/upload.py b/tofupilot/openhtf/upload.py index 7c07e5c..d993b25 100644 --- a/tofupilot/openhtf/upload.py +++ b/tofupilot/openhtf/upload.py @@ -1,15 +1,19 @@ import os +import json import datetime import tempfile from typing import Optional from openhtf.core.test_record import TestRecord from openhtf.output.callbacks import json_factory +import requests from ..client import TofuPilotClient +from ..constants import ( + SECONDS_BEFORE_TIMEOUT, +) from ..utils import ( - LoggerStateManager, - process_openhtf_attachments, + notify_server, ) @@ -20,6 +24,14 @@ class upload: # pylint: disable=invalid-name This function behaves similarly to manually parsing the OpenHTF JSON test report and calling `TofuPilotClient().create_run()` with the parsed data. + Args: + api_key (Optional[str]): API key for authentication with TofuPilot's API. + allow_nan (Optional[bool]): Whether to allow NaN values in JSON serialization. + url (Optional[str]): Base URL for TofuPilot's API. + client (Optional[TofuPilotClient]): An existing TofuPilot client instance to use. + verify (Optional[str]): Path to a CA bundle file to verify TofuPilot's server certificate. + Useful for connecting to instances with custom/self-signed certificates. + ### Usage Example: ```python @@ -44,77 +56,157 @@ def __init__( allow_nan: Optional[bool] = False, url: Optional[str] = None, client: Optional[TofuPilotClient] = None, + verify: Optional[str] = None, ): self.allow_nan = allow_nan self.client = client or TofuPilotClient(api_key=api_key, url=url) self._logger = self.client._logger self._url = self.client._url self._headers = self.client._headers + self._verify = verify # Kept for backward compatibility self._max_attachments = self.client._max_attachments self._max_file_size = self.client._max_file_size def __call__(self, test_record: TestRecord): - # Use context manager to handle logger state - with LoggerStateManager(self._logger): - # Extract test metadata - dut_id = test_record.dut_id - test_name = test_record.metadata.get("test_name") + + # Extract relevant details from the test record + dut_id = test_record.dut_id + test_name = test_record.metadata.get("test_name") + + # Convert milliseconds to a datetime object + start_time = datetime.datetime.fromtimestamp( + test_record.start_time_millis / 1000.0 + ) + + # Format the timestamp as YYYY-MM-DD_HH_MM_SS_SSS + start_time_formatted = start_time.strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3] + + temp_dir = tempfile.gettempdir() + + # Craft system-agnostic temporary filename + filename = os.path.join( + temp_dir, f"{dut_id}.{test_name}.{start_time_formatted}.json" + ) + + # Use the existing OutputToJSON callback to write to the custom file + output_callback = json_factory.OutputToJSON( + filename, + inline_attachments=False, # Exclude raw attachments + allow_nan=self.allow_nan, + indent=4, + ) + + # Open the custom file and write serialized test record to it + with open(filename, "w", encoding="utf-8") as file: + for json_line in output_callback.serialize_test_record(test_record): + file.write(json_line) + + try: + # Call create_run_from_report with the generated file path + result = self.client.upload_and_create_from_openhtf_report(filename) - # Create timestamp for filename - start_time = datetime.datetime.fromtimestamp( - test_record.start_time_millis / 1000.0 - ) - start_time_formatted = start_time.strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3] - - # Create temp file for the report - fd, filename = tempfile.mkstemp( - suffix=".json", - prefix=f"{dut_id}.{test_name}.{start_time_formatted}.", - dir=tempfile.gettempdir() - ) - try: - os.close(fd) # Close file descriptor - - # Use OpenHTF JSON callback to serialize test record - output_callback = json_factory.OutputToJSON( - filename, - inline_attachments=False, - allow_nan=self.allow_nan, - indent=4, - ) + # Extract run_id from response - it could be a string (id) or a dict (result with id field) + run_id = None + + if isinstance(result, dict): + # It's a dictionary response + if not result.get('success', True): + self._logger.error("Run creation failed, skipping attachments") + return + + # Try to get the ID from the dictionary + run_id = result.get('id') + else: + # Direct ID string + run_id = result - # Write test record to file - with open(filename, "w", encoding="utf-8") as file: - for json_line in output_callback.serialize_test_record(test_record): - file.write(json_line) + # Final validation of run_id + if not run_id or not isinstance(run_id, str): + self._logger.error(f"Invalid run ID received: {run_id}, skipping attachments") + return - # Upload report to server - run_id = self.client.upload_and_create_from_openhtf_report(filename) + self._logger.info(f"Successfully created run with ID: {run_id}") - # Check if response indicates error - if isinstance(run_id, dict) and not run_id.get('success', True): - return - except Exception as e: - self._logger.error(str(e)) - return - finally: - # Clean up temp file - if os.path.exists(filename): - os.remove(filename) - - # Skip attachment upload if run_id is invalid - if not run_id or not isinstance(run_id, str): + except Exception as e: + self._logger.error(f"Error creating run: {str(e)}") return + finally: + # Ensure the file is deleted after processing + if os.path.exists(filename): + os.remove(filename) + + # Process attachments + number_of_attachments = 0 + for phase in test_record.phases: + # Keep only max number of attachments + if number_of_attachments >= self._max_attachments: + self._logger.warning(f"Attachment limit ({self._max_attachments}) reached") + break - # Use the centralized function to process all attachments - # OpenHTF test record is directly passed as an object, not JSON - process_openhtf_attachments( - self._logger, - self._headers, - self._url, - test_record, - run_id, - self._max_attachments, - self._max_file_size, - needs_base64_decode=False # Direct object attachments don't need base64 decoding - ) + # Process each attachment in the phase + for attachment_name, attachment in phase.attachments.items(): + # Remove attachments that exceed the max file size + if attachment.size > self._max_file_size: + self._logger.warning(f"File too large: {attachment_name}") + continue + if number_of_attachments >= self._max_attachments: + break + + number_of_attachments += 1 + + self._logger.info(f"Uploading: {attachment_name}") + + # Upload initialization + initialize_url = f"{self._url}/uploads/initialize" + payload = {"name": attachment_name} + + try: + response = requests.post( + initialize_url, + data=json.dumps(payload), + headers=self._headers, + verify=self._verify, + timeout=SECONDS_BEFORE_TIMEOUT, + ) + + response.raise_for_status() + response_json = response.json() + upload_url = response_json.get("uploadUrl") + upload_id = response_json.get("id") + + # Handle file attachments created with test.attach_from_file + try: + attachment_data = attachment.data + + # Some OpenHTF implementations have file path in the attachment object + if hasattr(attachment, "file_path") and getattr(attachment, "file_path"): + try: + with open(getattr(attachment, "file_path"), "rb") as f: + attachment_data = f.read() + self._logger.info(f"Read file data from {attachment.file_path}") + except Exception as e: + self._logger.warning(f"Could not read from file_path: {str(e)}") + # Continue with attachment.data + + requests.put( + upload_url, + data=attachment_data, + headers={"Content-Type": attachment.mimetype}, + timeout=SECONDS_BEFORE_TIMEOUT, + ) + except Exception as e: + self._logger.error(f"Error uploading data: {str(e)}") + continue + + notify_server( + self._headers, + self._url, + upload_id, + run_id, + logger=self._logger, + ) + + self._logger.success(f"Uploaded: {attachment_name}") + except Exception as e: + self._logger.error(f"Failed to process attachment: {str(e)}") + continue diff --git a/tofupilot/utils/files.py b/tofupilot/utils/files.py index a157230..81fafd5 100644 --- a/tofupilot/utils/files.py +++ b/tofupilot/utils/files.py @@ -164,10 +164,10 @@ def upload_attachments( with open(file_path, "rb") as file: name = os.path.basename(file_path) data = file.read() - content_type, _ = mimetypes.guess_type(file_path) or "application/octet-stream" + mimetype, _ = mimetypes.guess_type(file_path) or "application/octet-stream" # Use shared upload function - upload_attachment_data(logger, headers, url, name, data, content_type, run_id) + upload_attachment_data(logger, headers, url, name, data, mimetype, run_id) except Exception as e: logger.error(f"Upload failed: {file_path} - {str(e)}") continue @@ -212,11 +212,13 @@ def process_openhtf_attachments( # Extract phases from test record based on type if isinstance(test_record, dict): phases = test_record.get("phases", []) + logger.info(f"Found {len(phases)} phases in JSON test record") else: phases = getattr(test_record, "phases", []) + logger.info(f"Found {len(phases)} phases in object test record") # Iterate through phases and their attachments - for phase in phases: + for i, phase in enumerate(phases): # Skip if we've reached attachment limit if attachment_count >= max_attachments: logger.warning(f"Attachment limit ({max_attachments}) reached") @@ -225,19 +227,30 @@ def process_openhtf_attachments( # Get attachments based on record type if isinstance(test_record, dict): phase_attachments = phase.get("attachments", {}) + phase_name = phase.get("name", f"Phase {i}") else: phase_attachments = getattr(phase, "attachments", {}) - + phase_name = getattr(phase, "name", f"Phase {i}") + # Skip if phase has no attachments if not phase_attachments: continue + logger.info(f"Processing {len(phase_attachments)} attachments in {phase_name}") + # Process each attachment in the phase for name, attachment in phase_attachments.items(): # Skip if we've reached attachment limit if attachment_count >= max_attachments: break + # Log attachment details + if isinstance(test_record, dict): + logger.info(f"Attachment: {name}, Type: JSON format") + else: + attrs = [attr for attr in dir(attachment) if not attr.startswith('_')] + logger.info(f"Attachment: {name}, Type: Object, Attributes: {attrs}") + # Get attachment data and size based on record type if isinstance(test_record, dict): # Dict format (from JSON file) @@ -261,11 +274,46 @@ def process_openhtf_attachments( else: # Object format (from callback) attachment_data = getattr(attachment, "data", None) + + # Handle different attachment types in OpenHTF if attachment_data is None: logger.warning(f"No data in: {name}") continue + + # Handle file-based attachments in different formats + data = None + + # Option 1: Check for direct file_path attribute + if hasattr(attachment, "file_path") and getattr(attachment, "file_path"): + try: + file_path = getattr(attachment, "file_path") + logger.info(f"Found file_path attribute: {file_path}") + with open(file_path, "rb") as f: + data = f.read() + except Exception as e: + logger.error(f"Failed to read from file_path: {str(e)}") + + # Option 2: Check for filename attribute (used in some OpenHTF versions) + elif hasattr(attachment, "filename") and getattr(attachment, "filename"): + try: + file_path = getattr(attachment, "filename") + logger.info(f"Found filename attribute: {file_path}") + with open(file_path, "rb") as f: + data = f.read() + except Exception as e: + logger.error(f"Failed to read from filename: {str(e)}") + + # Option 3: Use the data attribute directly + else: + logger.info("Using data attribute directly") + data = attachment_data - data = attachment_data + # Verify we have valid data + if data is None: + logger.error(f"No valid data found for attachment: {name}") + continue + + # Get size from attribute or calculate it attachment_size = getattr(attachment, "size", len(data)) mimetype = getattr(attachment, "mimetype", "application/octet-stream") @@ -279,15 +327,23 @@ def process_openhtf_attachments( logger.info(f"Uploading: {name}") # Use unified attachment upload function - upload_attachment_data( - logger, - headers, - url, - name, - data, - mimetype, - run_id - ) + try: + success = upload_attachment_data( + logger, + headers, + url, + name, + data, + mimetype, + run_id + ) + + if success: + logger.success(f"Successfully uploaded attachment: {name}") + else: + logger.error(f"Failed to upload attachment: {name}") + except Exception as e: + logger.error(f"Exception during attachment upload: {name} - {str(e)}") # Continue with other attachments regardless of success/failure finally: # If we resumed the logger and it has a pause method, pause it again From c5270f84c0e14ce950c59aa95e128dbd39238837 Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Wed, 7 May 2025 15:29:39 +0200 Subject: [PATCH 42/48] Improves logging messages --- tofupilot/client.py | 13 +- tofupilot/openhtf/tofupilot.py | 19 ++- tofupilot/openhtf/upload.py | 265 +++++++++++++++-------------- tofupilot/utils/network.py | 116 +++++++++---- tofupilot/utils/version_checker.py | 15 +- 5 files changed, 256 insertions(+), 172 deletions(-) diff --git a/tofupilot/client.py b/tofupilot/client.py index 26ba425..113f780 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -372,7 +372,7 @@ def upload_and_create_from_openhtf_report( Id of the newly created run """ - self._logger.info("Creating run...") + self._logger.info("Importing run...") # Validate report validate_files( @@ -411,7 +411,16 @@ def upload_and_create_from_openhtf_report( # Return only the ID if successful, otherwise return the full result if result.get("success", False) is not False: - return result.get("id") + run_id = result.get("id") + run_url = result.get("url") + + # Explicitly log success with URL if available + if run_url: + self._logger.success(f"Run imported successfully: {run_url}") + elif run_id: + self._logger.success(f"Run imported successfully with ID: {run_id}") + + return run_id else: return result diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 410262c..9da917a 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -251,16 +251,25 @@ def _setup_streaming(self): # Apply the patch to OpenHTF prompts to include the TofuPilot URL patch_openhtf_prompts(operatorPage) - # Create clickable URL similar to the prompt format + # Create clickable URL with improved message format import sys try: - # Use ANSI escape sequence for clickable link - clickable_url = f"\033]8;;{operatorPage}\033\\TofuPilot Operator UI\033]8;;\033\\" - sys.stdout.write(f"\033[0;32mConnected to {clickable_url}\033[0m\n") + # Use ANSI escape sequence for clickable link with improved message + green = "\033[0;32m" + bold = "\033[1m" + reset = "\033[0m" + + # Create clickable URL + clickable_url = f"\033]8;;{operatorPage}\033\\{operatorPage}\033]8;;\033\\" + + # Print connection status and URL on separate lines + sys.stdout.write(f"\n{green}Connected and authenticated to TofuPilot real-time server{reset}\n") + sys.stdout.write(f"{green}Open Operator UI: {bold}{clickable_url}{reset}\n\n") sys.stdout.flush() except: # Fallback for terminals that don't support ANSI - self._logger.success(f"Connected to TofuPilot: {operatorPage}") + self._logger.success(f"Connected to TofuPilot real-time server") + self._logger.success(f"Open Operator UI: {operatorPage}") except Exception as e: self._logger.warning(f"Operator UI: Setup error - {e}") diff --git a/tofupilot/openhtf/upload.py b/tofupilot/openhtf/upload.py index d993b25..f051fae 100644 --- a/tofupilot/openhtf/upload.py +++ b/tofupilot/openhtf/upload.py @@ -68,145 +68,156 @@ def __init__( self._max_file_size = self.client._max_file_size def __call__(self, test_record: TestRecord): - - # Extract relevant details from the test record - dut_id = test_record.dut_id - test_name = test_record.metadata.get("test_name") - - # Convert milliseconds to a datetime object - start_time = datetime.datetime.fromtimestamp( - test_record.start_time_millis / 1000.0 - ) - - # Format the timestamp as YYYY-MM-DD_HH_MM_SS_SSS - start_time_formatted = start_time.strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3] - - temp_dir = tempfile.gettempdir() - - # Craft system-agnostic temporary filename - filename = os.path.join( - temp_dir, f"{dut_id}.{test_name}.{start_time_formatted}.json" - ) - - # Use the existing OutputToJSON callback to write to the custom file - output_callback = json_factory.OutputToJSON( - filename, - inline_attachments=False, # Exclude raw attachments - allow_nan=self.allow_nan, - indent=4, - ) - - # Open the custom file and write serialized test record to it - with open(filename, "w", encoding="utf-8") as file: - for json_line in output_callback.serialize_test_record(test_record): - file.write(json_line) - + # Make sure logger is active + was_logger_resumed = False + if hasattr(self._logger, 'resume'): + self._logger.resume() + was_logger_resumed = True + try: - # Call create_run_from_report with the generated file path - result = self.client.upload_and_create_from_openhtf_report(filename) - - # Extract run_id from response - it could be a string (id) or a dict (result with id field) - run_id = None - - if isinstance(result, dict): - # It's a dictionary response - if not result.get('success', True): - self._logger.error("Run creation failed, skipping attachments") + + # Extract relevant details from the test record + dut_id = test_record.dut_id + test_name = test_record.metadata.get("test_name") + + # Convert milliseconds to a datetime object + start_time = datetime.datetime.fromtimestamp( + test_record.start_time_millis / 1000.0 + ) + + # Format the timestamp as YYYY-MM-DD_HH_MM_SS_SSS + start_time_formatted = start_time.strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3] + + temp_dir = tempfile.gettempdir() + + # Craft system-agnostic temporary filename + filename = os.path.join( + temp_dir, f"{dut_id}.{test_name}.{start_time_formatted}.json" + ) + + # Use the existing OutputToJSON callback to write to the custom file + output_callback = json_factory.OutputToJSON( + filename, + inline_attachments=False, # Exclude raw attachments + allow_nan=self.allow_nan, + indent=4, + ) + + # Open the custom file and write serialized test record to it + with open(filename, "w", encoding="utf-8") as file: + for json_line in output_callback.serialize_test_record(test_record): + file.write(json_line) + + try: + # Call create_run_from_report with the generated file path + result = self.client.upload_and_create_from_openhtf_report(filename) + + # Extract run_id from response - it could be a string (id) or a dict (result with id field) + run_id = None + + if isinstance(result, dict): + # It's a dictionary response + if not result.get('success', True): + self._logger.error("Run creation failed, skipping attachments") + return + + # Try to get the ID from the dictionary + run_id = result.get('id') + else: + # Direct ID string + run_id = result + + # Final validation of run_id + if not run_id or not isinstance(run_id, str): + self._logger.error(f"Invalid run ID received: {run_id}, skipping attachments") return - # Try to get the ID from the dictionary - run_id = result.get('id') - else: - # Direct ID string - run_id = result - - # Final validation of run_id - if not run_id or not isinstance(run_id, str): - self._logger.error(f"Invalid run ID received: {run_id}, skipping attachments") + # We don't need to log anything here as client.py will log the success message + + except Exception as e: + self._logger.error(f"Error creating run: {str(e)}") return - - self._logger.info(f"Successfully created run with ID: {run_id}") - - except Exception as e: - self._logger.error(f"Error creating run: {str(e)}") - return - finally: - # Ensure the file is deleted after processing - if os.path.exists(filename): - os.remove(filename) - - # Process attachments - number_of_attachments = 0 - for phase in test_record.phases: - # Keep only max number of attachments - if number_of_attachments >= self._max_attachments: - self._logger.warning(f"Attachment limit ({self._max_attachments}) reached") - break - - # Process each attachment in the phase - for attachment_name, attachment in phase.attachments.items(): - # Remove attachments that exceed the max file size - if attachment.size > self._max_file_size: - self._logger.warning(f"File too large: {attachment_name}") - continue + finally: + # Ensure the file is deleted after processing + if os.path.exists(filename): + os.remove(filename) + + # Process attachments + number_of_attachments = 0 + for phase in test_record.phases: + # Keep only max number of attachments if number_of_attachments >= self._max_attachments: + self._logger.warning(f"Attachment limit ({self._max_attachments}) reached") break + + # Process each attachment in the phase + for attachment_name, attachment in phase.attachments.items(): + # Remove attachments that exceed the max file size + if attachment.size > self._max_file_size: + self._logger.warning(f"File too large: {attachment_name}") + continue + if number_of_attachments >= self._max_attachments: + break - number_of_attachments += 1 - - self._logger.info(f"Uploading: {attachment_name}") - - # Upload initialization - initialize_url = f"{self._url}/uploads/initialize" - payload = {"name": attachment_name} + number_of_attachments += 1 - try: - response = requests.post( - initialize_url, - data=json.dumps(payload), - headers=self._headers, - verify=self._verify, - timeout=SECONDS_BEFORE_TIMEOUT, - ) + self._logger.info(f"Uploading: {attachment_name}") - response.raise_for_status() - response_json = response.json() - upload_url = response_json.get("uploadUrl") - upload_id = response_json.get("id") + # Upload initialization + initialize_url = f"{self._url}/uploads/initialize" + payload = {"name": attachment_name} - # Handle file attachments created with test.attach_from_file try: - attachment_data = attachment.data - - # Some OpenHTF implementations have file path in the attachment object - if hasattr(attachment, "file_path") and getattr(attachment, "file_path"): - try: - with open(getattr(attachment, "file_path"), "rb") as f: - attachment_data = f.read() - self._logger.info(f"Read file data from {attachment.file_path}") - except Exception as e: - self._logger.warning(f"Could not read from file_path: {str(e)}") - # Continue with attachment.data - - requests.put( - upload_url, - data=attachment_data, - headers={"Content-Type": attachment.mimetype}, + response = requests.post( + initialize_url, + data=json.dumps(payload), + headers=self._headers, + verify=self._verify, timeout=SECONDS_BEFORE_TIMEOUT, ) + + response.raise_for_status() + response_json = response.json() + upload_url = response_json.get("uploadUrl") + upload_id = response_json.get("id") + + # Handle file attachments created with test.attach_from_file + try: + attachment_data = attachment.data + + # Some OpenHTF implementations have file path in the attachment object + if hasattr(attachment, "file_path") and getattr(attachment, "file_path"): + try: + with open(getattr(attachment, "file_path"), "rb") as f: + attachment_data = f.read() + self._logger.info(f"Read file data from {attachment.file_path}") + except Exception as e: + self._logger.warning(f"Could not read from file_path: {str(e)}") + # Continue with attachment.data + + requests.put( + upload_url, + data=attachment_data, + headers={"Content-Type": attachment.mimetype}, + timeout=SECONDS_BEFORE_TIMEOUT, + ) + except Exception as e: + self._logger.error(f"Error uploading data: {str(e)}") + continue + + notify_server( + self._headers, + self._url, + upload_id, + run_id, + logger=self._logger, + ) + + self._logger.success(f"Uploaded: {attachment_name}") except Exception as e: - self._logger.error(f"Error uploading data: {str(e)}") + self._logger.error(f"Failed to process attachment: {str(e)}") continue - - notify_server( - self._headers, - self._url, - upload_id, - run_id, - logger=self._logger, - ) - - self._logger.success(f"Uploaded: {attachment_name}") - except Exception as e: - self._logger.error(f"Failed to process attachment: {str(e)}") - continue + finally: + # Restore logger state if it was resumed + if was_logger_resumed and hasattr(self._logger, 'pause'): + self._logger.pause() diff --git a/tofupilot/utils/network.py b/tofupilot/utils/network.py index 1cb0385..0c3059b 100644 --- a/tofupilot/utils/network.py +++ b/tofupilot/utils/network.py @@ -48,16 +48,42 @@ def handle_response( """ data = response.json() - # Logging warnings if present - warnings: Optional[List[str]] = data.get("warnings") - if warnings is not None: - for warning in warnings: - logger.warning(warning) - - # Logging success message if the JSON has one - message = data.get("message") - if message is not None: - logger.success(message) + # Ensure logger is active to process messages + was_resumed = False + if hasattr(logger, 'resume'): + logger.resume() + was_resumed = True + + try: + # Process warnings + warnings: Optional[List[str]] = data.get("warnings") + if warnings is not None: + for warning in warnings: + logger.warning(warning) + + # Process errors + errors = data.get("errors") or data.get("error") + if errors: + # Handle both array and single object formats + if isinstance(errors, list): + for error in errors: + if isinstance(error, dict) and "message" in error: + logger.error(error["message"]) + else: + logger.error(str(error)) + elif isinstance(errors, dict) and "message" in errors: + logger.error(errors["message"]) + elif isinstance(errors, str): + logger.error(errors) + + # Process success message + message = data.get("message") + if message is not None: + logger.success(message) + finally: + # Restore logger state if needed + if was_resumed and hasattr(logger, 'pause'): + logger.pause() # Returning the parsed JSON to the caller return data @@ -69,23 +95,38 @@ def handle_http_error( """Handles HTTP errors and logs them.""" warnings = None - # Extract error details from JSON response when available - if (http_err.response.text.strip() and - http_err.response.headers.get("Content-Type") == "application/json"): - try: - response_json = http_err.response.json() - warnings = response_json.get("warnings") - if warnings: - for warning in warnings: - logger.warning(warning) - error_message = parse_error_message(http_err.response) - except ValueError: + # Ensure logger is active to process messages + was_resumed = False + if hasattr(logger, 'resume'): + logger.resume() + was_resumed = True + + try: + # Extract error details from JSON response when available + if (http_err.response.text.strip() and + http_err.response.headers.get("Content-Type") == "application/json"): + try: + response_json = http_err.response.json() + + # Process warnings if present + warnings = response_json.get("warnings") + if warnings: + for warning in warnings: + logger.warning(warning) + + # Get error message + error_message = parse_error_message(http_err.response) + except ValueError: + error_message = str(http_err) + else: error_message = str(http_err) - else: - error_message = str(http_err) - # Log the error through the logger for proper formatting - logger.error(error_message) + # Log the error through the logger for proper formatting + logger.error(error_message) + finally: + # Restore logger state if needed + if was_resumed and hasattr(logger, 'pause'): + logger.pause() # Return structured error info return { @@ -99,14 +140,25 @@ def handle_http_error( def handle_network_error(logger, e: requests.RequestException) -> Dict[str, Any]: """Handles network errors and logs them.""" - error_message = f"Network error: {str(e)}" - logger.error(error_message) + # Ensure logger is active to process messages + was_resumed = False + if hasattr(logger, 'resume'): + logger.resume() + was_resumed = True - # Provide SSL-specific guidance - if isinstance(e, requests.exceptions.SSLError) or "SSL" in str(e) or "certificate verify failed" in str(e): - logger.warning("SSL certificate verification error detected") - logger.warning("This is typically caused by missing or invalid SSL certificates") - logger.warning("Try: 1) pip install certifi 2) /Applications/Python*/Install Certificates.command") + try: + error_message = f"Network error: {str(e)}" + logger.error(error_message) + + # Provide SSL-specific guidance + if isinstance(e, requests.exceptions.SSLError) or "SSL" in str(e) or "certificate verify failed" in str(e): + logger.warning("SSL certificate verification error detected") + logger.warning("This is typically caused by missing or invalid SSL certificates") + logger.warning("Try: 1) pip install certifi 2) /Applications/Python*/Install Certificates.command") + finally: + # Restore logger state if needed + if was_resumed and hasattr(logger, 'pause'): + logger.pause() # Return structured error info return { diff --git a/tofupilot/utils/version_checker.py b/tofupilot/utils/version_checker.py index b823519..42c7aa1 100644 --- a/tofupilot/utils/version_checker.py +++ b/tofupilot/utils/version_checker.py @@ -16,13 +16,16 @@ def check_latest_version(logger, current_version, package_name: str): try: if version.parse(current_version) < version.parse(latest_version): - warning_message = ( - f"You are using {package_name} version {current_version}, however version {latest_version} is available. " - f'You should consider upgrading via the "pip install --upgrade {package_name}" command.' - ) - logger.warning(warning_message) + # Direct printing with warning color (yellow) but without the TP:WRN prefix + yellow = "\033[0;33m" + reset = "\033[0m" + print(f"\n{yellow}Update available: {package_name} {current_version} → {latest_version}{reset}") + print(f"{yellow}Run: pip install --upgrade {package_name}{reset}\n") + + # We don't use logger.warning here to avoid the colored TP:WRN prefix except PackageNotFoundError: logger.info(f"Package not installed: {package_name}") except requests.RequestException as e: - logger.warning(f"Version check failed: {e}") + # Use info level so it's not as prominent + logger.info(f"Version check skipped: {e}") From 4a58539e79adfdb5ae71f1685bbded93924f9c0d Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Wed, 7 May 2025 16:09:20 +0200 Subject: [PATCH 43/48] Unpauses the logger after openhtf run execution --- setup.py | 1 + tofupilot/client.py | 67 ++++------ tofupilot/openhtf/tofupilot.py | 201 ++++++++++++++++++++--------- tofupilot/openhtf/upload.py | 82 +++++++----- tofupilot/utils/files.py | 25 ++-- tofupilot/utils/version_checker.py | 9 +- 6 files changed, 239 insertions(+), 146 deletions(-) diff --git a/setup.py b/setup.py index 0bc784c..fd4cad6 100644 --- a/setup.py +++ b/setup.py @@ -13,6 +13,7 @@ "packaging", "pytest", "paho-mqtt", + "sentry-sdk", ], entry_points={ "pytest11": [ diff --git a/tofupilot/client.py b/tofupilot/client.py index 113f780..4b7027a 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -59,16 +59,16 @@ def __init__(self, api_key: Optional[str] = None, url: Optional[str] = None): self._max_attachments = CLIENT_MAX_ATTACHMENTS self._max_file_size = FILE_MAX_SIZE check_latest_version(self._logger, self._current_version, "tofupilot") - + def _setup_ssl_certificates(self): """Configure SSL certificate validation using certifi if needed.""" # Check if SSL_CERT_FILE is already set to a valid path - cert_file = os.environ.get('SSL_CERT_FILE') + cert_file = os.environ.get("SSL_CERT_FILE") if not cert_file or not os.path.isfile(cert_file): # Use certifi's certificate bundle certifi_path = certifi.where() if os.path.isfile(certifi_path): - os.environ['SSL_CERT_FILE'] = certifi_path + os.environ["SSL_CERT_FILE"] = certifi_path self._logger.debug(f"SSL: Using certifi path {certifi_path}") def _log_request(self, method: str, endpoint: str, payload: Optional[dict] = None): @@ -126,6 +126,7 @@ def create_run( # pylint: disable=too-many-arguments,too-many-locals References: https://www.tofupilot.com/docs/api#create-a-run """ + print("") self._logger.info("Creating run...") if attachments is not None: @@ -168,11 +169,7 @@ def create_run( # pylint: disable=too-many-arguments,too-many-locals self._log_request("POST", "/runs", payload) result = api_request( - self._logger, - "POST", - f"{self._url}/runs", - self._headers, - data=payload + self._logger, "POST", f"{self._url}/runs", self._headers, data=payload ) # Upload attachments if run was created successfully @@ -201,7 +198,7 @@ def create_run_from_openhtf_report(self, file_path: str): """ # Upload report and create run from file_path run_id = self.upload_and_create_from_openhtf_report(file_path) - + # If run_id is not a string, it's an error response dictionary if not isinstance(run_id, str): self._logger.error("OpenHTF import failed") @@ -227,6 +224,10 @@ def create_run_from_openhtf_report(self, file_path: str): # Now safely proceed with attachment upload if run_id and test_record and "phases" in test_record: + # Add a visual separator after the run success message + print("") + self._logger.info("Processing attachments from OpenHTF test record") + # Use the centralized function to process all attachments process_openhtf_attachments( self._logger, @@ -236,14 +237,14 @@ def create_run_from_openhtf_report(self, file_path: str): run_id, self._max_attachments, self._max_file_size, - needs_base64_decode=True # JSON attachments need base64 decoding + needs_base64_decode=True, # JSON attachments need base64 decoding ) else: if not test_record: self._logger.error("Test record load failed") elif "phases" not in test_record: self._logger.error("No phases in test record") - + return run_id def get_runs(self, serial_number: str) -> dict: @@ -276,11 +277,7 @@ def get_runs(self, serial_number: str) -> dict: params = {"serial_number": serial_number} self._log_request("GET", "/runs", params) return api_request( - self._logger, - "GET", - f"{self._url}/runs", - self._headers, - params=params + self._logger, "GET", f"{self._url}/runs", self._headers, params=params ) def delete_run(self, run_id: str) -> dict: @@ -297,13 +294,10 @@ def delete_run(self, run_id: str) -> dict: References: https://www.tofupilot.com/docs/api#delete-a-run """ - self._logger.info('Deleting run: %s', run_id) + self._logger.info("Deleting run: %s", run_id) self._log_request("DELETE", f"/runs/{run_id}") return api_request( - self._logger, - "DELETE", - f"{self._url}/runs/{run_id}", - self._headers + self._logger, "DELETE", f"{self._url}/runs/{run_id}", self._headers ) def update_unit( @@ -325,7 +319,7 @@ def update_unit( References: https://www.tofupilot.com/docs/api#update-a-unit """ - self._logger.info('Updating unit: %s', serial_number) + self._logger.info("Updating unit: %s", serial_number) payload = {"sub_units": sub_units} self._log_request("PATCH", f"/units/{serial_number}", payload) return api_request( @@ -333,7 +327,7 @@ def update_unit( "PATCH", f"{self._url}/units/{serial_number}", self._headers, - data=payload + data=payload, ) def delete_unit(self, serial_number: str) -> dict: @@ -351,13 +345,10 @@ def delete_unit(self, serial_number: str) -> dict: References: https://www.tofupilot.com/docs/api#delete-a-unit """ - self._logger.info('Deleting unit: %s', serial_number) + self._logger.info("Deleting unit: %s", serial_number) self._log_request("DELETE", f"/units/{serial_number}") return api_request( - self._logger, - "DELETE", - f"{self._url}/units/{serial_number}", - self._headers + self._logger, "DELETE", f"{self._url}/units/{serial_number}", self._headers ) def upload_and_create_from_openhtf_report( @@ -372,6 +363,7 @@ def upload_and_create_from_openhtf_report( Id of the newly created run """ + print("") self._logger.info("Importing run...") # Validate report @@ -402,28 +394,24 @@ def upload_and_create_from_openhtf_report( # Create run from file using unified API request handler result = api_request( - self._logger, - "POST", - f"{self._url}/import", - self._headers, - data=payload + self._logger, "POST", f"{self._url}/import", self._headers, data=payload ) # Return only the ID if successful, otherwise return the full result if result.get("success", False) is not False: run_id = result.get("id") run_url = result.get("url") - + # Explicitly log success with URL if available if run_url: self._logger.success(f"Run imported successfully: {run_url}") elif run_id: self._logger.success(f"Run imported successfully with ID: {run_id}") - + return run_id else: return result - + def get_connection_credentials(self) -> dict: """ Fetches credentials required to livestream test results. @@ -448,12 +436,13 @@ def get_connection_credentials(self) -> dict: handle_network_error(self._logger, e) return None + def print_version_banner(current_version: str): """Prints current version of client with tofu art""" # Colors for the tofu art yellow = "\033[33m" # Yellow for the plane - blue = "\033[34m" # Blue for the cap border - reset = "\033[0m" # Reset color + blue = "\033[34m" # Blue for the cap border + reset = "\033[0m" # Reset color banner = ( f"{blue}╭{reset} {yellow}✈{reset} {blue}╮{reset}\n" @@ -461,4 +450,4 @@ def print_version_banner(current_version: str): "\n" ) - print(banner, end="") \ No newline at end of file + print(banner, end="") diff --git a/tofupilot/openhtf/tofupilot.py b/tofupilot/openhtf/tofupilot.py index 9da917a..d75510f 100644 --- a/tofupilot/openhtf/tofupilot.py +++ b/tofupilot/openhtf/tofupilot.py @@ -77,7 +77,6 @@ def stop(self): self.stop_event.set() - class TofuPilot: """ Context manager to automatically add an output callback to the running OpenHTF test @@ -99,7 +98,7 @@ def main(): with TofuPilot(test): # For more reliable Ctrl+C handling, use the helper function: execute_with_graceful_exit(test, test_start=lambda: "SN15") - + # Or use the standard method (may show errors on Ctrl+C): # test.execute(lambda: "SN15") ``` @@ -134,26 +133,37 @@ def __enter__(self): # Start streaming setup before pausing the logger if self.stream: - self._streaming_setup_thread = threading.Thread(target=self._setup_streaming) + self.connection_completed = False + + # Start connection in a separate thread with 1s timeout + self._streaming_setup_thread = threading.Thread( + target=self._setup_streaming_with_state, daemon=True + ) self._streaming_setup_thread.start() - # Give the streaming setup a chance to connect and log its messages - self._streaming_setup_thread.join(1) - - # Now pause the logger - this happens after MQTT setup has started + self._streaming_setup_thread.join(1.0) + + # Pause logger after connection attempt is either completed or timed out self._logger.pause() return self - + def __exit__(self, exc_type, exc_value, traceback): """Clean up resources when exiting the context manager. - + This method handles proper cleanup even in the case of KeyboardInterrupt or other exceptions to ensure resources are released properly. """ self._logger.resume() + # Handle ongoing connection attempt if self._streaming_setup_thread and self._streaming_setup_thread.is_alive(): - self._logger.warning(f"Operator UI: Setup still ongoing, waiting for it to time-out (max 10s)") - self._streaming_setup_thread.join() + if ( + not hasattr(self, "connection_completed") + or not self.connection_completed + ): + self._logger.warning(f"Operator UI: Connection still in progress") + self._streaming_setup_thread.join(timeout=3.0) + if self._streaming_setup_thread.is_alive(): + self._logger.warning(f"Operator UI: Connection timed out") # Stop the StationWatcher if self.watcher: @@ -173,24 +183,41 @@ def __exit__(self, exc_type, exc_value, traceback): self._logger.warning(f"Error disconnecting MQTT client: {e}") finally: self.mqttClient = None - + # Return False to allow any exception to propagate, unless it's a KeyboardInterrupt # In case of KeyboardInterrupt, return True to suppress the exception return exc_type is KeyboardInterrupt # Operator UI-related methods + def _setup_streaming_with_state(self): + """Run the streaming setup and track connection completion state.""" + self._setup_streaming() + self.connection_completed = True + def _setup_streaming(self): try: try: cred = self.client.get_connection_credentials() except Exception as e: self._logger.warning(f"Operator UI: JWT error: {e}") + # Print with yellow color for consistency with warnings + yellow = "\033[0;33m" + reset = "\033[0m" + print( + f"{yellow}To disable Operator UI streaming, use Test(..., stream=False) in your script{reset}" + ) self.stream = False # Disable streaming on auth failure return if not cred: self._logger.warning("Operator UI: Auth server connection failed") + # Print with yellow color for consistency with warnings + yellow = "\033[0;33m" + reset = "\033[0m" + print( + f"{yellow}To disable Operator UI streaming, use Test(..., stream=False) in your script{reset}" + ) self.stream = False # Disable streaming on auth failure return self @@ -203,7 +230,9 @@ def _setup_streaming(self): self.publishOptions = cred["publishOptions"] subscribeOptions = cred["subscribeOptions"] - self.mqttClient = mqtt.Client(callback_api_version=CallbackAPIVersion.VERSION2, **clientOptions) + self.mqttClient = mqtt.Client( + callback_api_version=CallbackAPIVersion.VERSION2, **clientOptions + ) # This is not 100% reliable, hence the need to put the setup in the background # See https://github.com/eclipse-paho/paho.mqtt.python/issues/890 @@ -212,34 +241,68 @@ def _setup_streaming(self): self.mqttClient.tls_set() self.mqttClient.will_set(**willOptions) - + self.mqttClient.username_pw_set("pythonClient", token) - + self.mqttClient.on_message = self._on_message self.mqttClient.on_disconnect = self._on_disconnect self.mqttClient.on_unsubscribe = self._on_unsubscribe - + try: connect_error_code = self.mqttClient.connect(**connectOptions) except Exception as e: - self._logger.warning(f"Operator UI: Failed to connect with server (exception): {e}") + self._logger.warning( + f"Operator UI: Failed to connect with server (exception): {e}" + ) + # Print with yellow color for consistency with warnings + yellow = "\033[0;33m" + reset = "\033[0m" + print( + f"{yellow}To disable Operator UI streaming, use Test(..., stream=False) in your script{reset}" + ) self.stream = False # Disable streaming on connection failure return - - if(connect_error_code != mqtt.MQTT_ERR_SUCCESS): - self._logger.warning(f"Operator UI: Failed to connect with server (error code): {connect_error_code}") + + if connect_error_code != mqtt.MQTT_ERR_SUCCESS: + self._logger.warning( + f"Operator UI: Failed to connect with server (error code): {connect_error_code}" + ) + # Print with yellow color for consistency with warnings + yellow = "\033[0;33m" + reset = "\033[0m" + print( + f"{yellow}To disable Operator UI streaming, use Test(..., stream=False) in your script{reset}" + ) self.stream = False # Disable streaming on connection failure return self - + try: - subscribe_error_code, messageId = self.mqttClient.subscribe(**subscribeOptions) + subscribe_error_code, messageId = self.mqttClient.subscribe( + **subscribeOptions + ) except Exception as e: - self._logger.warning(f"Operator UI: Failed to subscribe to server (exception): {e}") + self._logger.warning( + f"Operator UI: Failed to subscribe to server (exception): {e}" + ) + # Print with yellow color for consistency with warnings + yellow = "\033[0;33m" + reset = "\033[0m" + print( + f"{yellow}To disable Operator UI streaming, use Test(..., stream=False) in your script{reset}" + ) self.stream = False # Disable streaming on subscription failure return - - if(subscribe_error_code != mqtt.MQTT_ERR_SUCCESS): - self._logger.warning(f"Operator UI: Failed to subscribe to server (error code): {subscribe_error_code}") + + if subscribe_error_code != mqtt.MQTT_ERR_SUCCESS: + self._logger.warning( + f"Operator UI: Failed to subscribe to server (error code): {subscribe_error_code}" + ) + # Print with yellow color for consistency with warnings + yellow = "\033[0;33m" + reset = "\033[0m" + print( + f"{yellow}To disable Operator UI streaming, use Test(..., stream=False) in your script{reset}" + ) self.stream = False # Disable streaming on subscription failure return self @@ -247,52 +310,55 @@ def _setup_streaming(self): self.watcher = SimpleStationWatcher(self._send_update) self.watcher.start() - + # Apply the patch to OpenHTF prompts to include the TofuPilot URL patch_openhtf_prompts(operatorPage) - - # Create clickable URL with improved message format - import sys + + # Show connection status message with URL try: - # Use ANSI escape sequence for clickable link with improved message + # Use ANSI escape sequence for clickable link green = "\033[0;32m" - bold = "\033[1m" reset = "\033[0m" - + # Create clickable URL - clickable_url = f"\033]8;;{operatorPage}\033\\{operatorPage}\033]8;;\033\\" - - # Print connection status and URL on separate lines - sys.stdout.write(f"\n{green}Connected and authenticated to TofuPilot real-time server{reset}\n") - sys.stdout.write(f"{green}Open Operator UI: {bold}{clickable_url}{reset}\n\n") - sys.stdout.flush() + clickable_url = ( + f"\033]8;;{operatorPage}\033\\{operatorPage}\033]8;;\033\\" + ) + + # Print single line connection message with URL + print(f"\n{green}Connected to TofuPilot real-time server{reset}") + print(f"{green}Access Operator UI: {clickable_url}{reset}\n") except: # Fallback for terminals that don't support ANSI self._logger.success(f"Connected to TofuPilot real-time server") - self._logger.success(f"Open Operator UI: {operatorPage}") - + self._logger.success(f"Access Operator UI: {operatorPage}") + except Exception as e: self._logger.warning(f"Operator UI: Setup error - {e}") + print( + "To disable Operator UI streaming, use Test(..., stream=False) in your script" + ) self.stream = False # Disable streaming on any setup error - def _send_update(self, message): # Skip publishing if streaming is disabled or client is None if not self.stream or self.mqttClient is None: return - + try: self.mqttClient.publish( payload=json.dumps( {"action": "send", "source": "python", "message": message} ), - **self.publishOptions + **self.publishOptions, ) except Exception as e: - self._logger.warning(f"Operator UI: Failed to publish to server (exception): {e}") + self._logger.warning( + f"Operator UI: Failed to publish to server (exception): {e}" + ) self.stream = False # Disable streaming on publish failure return - + def _handle_answer(self, plug_name, method_name, args): _, test_state = _get_executing_test() @@ -308,17 +374,24 @@ def _handle_answer(self, plug_name, method_name, args): method = getattr(plug, method_name, None) - if not (plug.enable_remote and isinstance(method, types.MethodType) and - not method_name.startswith('_') and - method_name not in plug.disable_remote_attrs): - self._logger.warning(f"Operator UI: Method not found - {plug_name}.{method_name}") + if not ( + plug.enable_remote + and isinstance(method, types.MethodType) + and not method_name.startswith("_") + and method_name not in plug.disable_remote_attrs + ): + self._logger.warning( + f"Operator UI: Method not found - {plug_name}.{method_name}" + ) return try: # side-effecting ! method(*args) except Exception as e: # pylint: disable=broad-except - self._logger.warning(f"Operator UI: Method call failed - {method_name}({', '.join(args)}) - {e}") + self._logger.warning( + f"Operator UI: Method call failed - {method_name}({', '.join(args)}) - {e}" + ) def _final_update(self, testRecord: TestRecord): """ @@ -333,10 +406,10 @@ def _final_update(self, testRecord: TestRecord): test_record_dict = testRecord.as_base_types() test_state_dict = { - 'status': 'COMPLETED', - 'test_record': test_record_dict, - 'plugs': {'plug_states': {}}, - 'running_phase_state': {}, + "status": "COMPLETED", + "test_record": test_record_dict, + "plugs": {"plug_states": {}}, + "running_phase_state": {}, } self._send_update(test_state_dict) @@ -345,14 +418,22 @@ def _final_update(self, testRecord: TestRecord): def _on_message(self, client, userdata, message): parsed = json.loads(message.payload) - + if parsed["source"] == "web": self._handle_answer(**parsed["message"]) - def _on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties): + def _on_disconnect( + self, client, userdata, disconnect_flags, reason_code, properties + ): if reason_code != mqtt.MQTT_ERR_SUCCESS: - self._logger.warning(f"Operator UI: Unexpected disconnect (code {reason_code})") + self._logger.warning( + f"Operator UI: Unexpected disconnect (code {reason_code})" + ) def _on_unsubscribe(self, client, userdata, mid, reason_code_list, properties): - if any(reason_code != mqtt.MQTT_ERR_SUCCESS for reason_code in reason_code_list): - self._logger.warning(f"Operator UI: Partial disconnect (codes {reason_code_list})") \ No newline at end of file + if any( + reason_code != mqtt.MQTT_ERR_SUCCESS for reason_code in reason_code_list + ): + self._logger.warning( + f"Operator UI: Partial disconnect (codes {reason_code_list})" + ) diff --git a/tofupilot/openhtf/upload.py b/tofupilot/openhtf/upload.py index f051fae..1b4cd74 100644 --- a/tofupilot/openhtf/upload.py +++ b/tofupilot/openhtf/upload.py @@ -15,6 +15,7 @@ from ..utils import ( notify_server, ) +from ..utils.logger import LoggerStateManager class upload: # pylint: disable=invalid-name @@ -68,14 +69,13 @@ def __init__( self._max_file_size = self.client._max_file_size def __call__(self, test_record: TestRecord): - # Make sure logger is active + # Resume logger to ensure it's active during attachment processing was_logger_resumed = False - if hasattr(self._logger, 'resume'): + if hasattr(self._logger, "resume"): self._logger.resume() was_logger_resumed = True - - try: + try: # Extract relevant details from the test record dut_id = test_record.dut_id test_name = test_record.metadata.get("test_name") @@ -111,29 +111,28 @@ def __call__(self, test_record: TestRecord): try: # Call create_run_from_report with the generated file path result = self.client.upload_and_create_from_openhtf_report(filename) - + # Extract run_id from response - it could be a string (id) or a dict (result with id field) run_id = None - + if isinstance(result, dict): # It's a dictionary response - if not result.get('success', True): + if not result.get("success", True): self._logger.error("Run creation failed, skipping attachments") return - + # Try to get the ID from the dictionary - run_id = result.get('id') + run_id = result.get("id") else: # Direct ID string run_id = result - + # Final validation of run_id if not run_id or not isinstance(run_id, str): - self._logger.error(f"Invalid run ID received: {run_id}, skipping attachments") + self._logger.error( + f"Invalid run ID received: {run_id}, skipping attachments" + ) return - - # We don't need to log anything here as client.py will log the success message - except Exception as e: self._logger.error(f"Error creating run: {str(e)}") return @@ -141,15 +140,20 @@ def __call__(self, test_record: TestRecord): # Ensure the file is deleted after processing if os.path.exists(filename): os.remove(filename) - + # Process attachments number_of_attachments = 0 - for phase in test_record.phases: + for phase_idx, phase in enumerate(test_record.phases): + # Count attachments silently + attachment_count = len(phase.attachments) + # Keep only max number of attachments if number_of_attachments >= self._max_attachments: - self._logger.warning(f"Attachment limit ({self._max_attachments}) reached") + self._logger.warning( + f"Attachment limit ({self._max_attachments}) reached" + ) break - + # Process each attachment in the phase for attachment_name, attachment in phase.attachments.items(): # Remove attachments that exceed the max file size @@ -161,7 +165,9 @@ def __call__(self, test_record: TestRecord): number_of_attachments += 1 - self._logger.info(f"Uploading: {attachment_name}") + # Use LoggerStateManager to temporarily activate the logger + with LoggerStateManager(self._logger): + self._logger.info(f"Uploading attachment...") # Upload initialization initialize_url = f"{self._url}/uploads/initialize" @@ -184,17 +190,25 @@ def __call__(self, test_record: TestRecord): # Handle file attachments created with test.attach_from_file try: attachment_data = attachment.data - + # Some OpenHTF implementations have file path in the attachment object - if hasattr(attachment, "file_path") and getattr(attachment, "file_path"): + if hasattr(attachment, "file_path") and getattr( + attachment, "file_path" + ): try: - with open(getattr(attachment, "file_path"), "rb") as f: + with open( + getattr(attachment, "file_path"), "rb" + ) as f: attachment_data = f.read() - self._logger.info(f"Read file data from {attachment.file_path}") + self._logger.info( + f"Read file data from {attachment.file_path}" + ) except Exception as e: - self._logger.warning(f"Could not read from file_path: {str(e)}") + self._logger.warning( + f"Could not read from file_path: {str(e)}" + ) # Continue with attachment.data - + requests.put( upload_url, data=attachment_data, @@ -213,11 +227,19 @@ def __call__(self, test_record: TestRecord): logger=self._logger, ) - self._logger.success(f"Uploaded: {attachment_name}") + # Use LoggerStateManager to temporarily activate the logger + with LoggerStateManager(self._logger): + self._logger.success( + f"Uploaded attachment: {attachment_name}" + ) except Exception as e: - self._logger.error(f"Failed to process attachment: {str(e)}") + # Use LoggerStateManager to temporarily activate the logger + with LoggerStateManager(self._logger): + self._logger.error( + f"Failed to process attachment: {str(e)}" + ) continue finally: - # Restore logger state if it was resumed - if was_logger_resumed and hasattr(self._logger, 'pause'): - self._logger.pause() + # For attachment logs to be visible, we intentionally don't pause the logger here + # Instead, we'll let the TofuPilot class's __exit__ method handle the logger state + pass diff --git a/tofupilot/utils/files.py b/tofupilot/utils/files.py index 81fafd5..135d859 100644 --- a/tofupilot/utils/files.py +++ b/tofupilot/utils/files.py @@ -111,7 +111,8 @@ def upload_attachment_data( """Uploads binary data as an attachment and links it to a run""" try: # Initialize upload - logger.info(f"Uploading: {name}") + # Make this more visible and consistent with other logs + logger.info(f"Uploading attachment: {name}") initialize_url = f"{url}/uploads/initialize" payload = {"name": name} @@ -141,7 +142,7 @@ def upload_attachment_data( # Link attachment to run notify_server(headers, url, upload_id, run_id, logger) - logger.success(f"Uploaded: {name}") + logger.success(f"Uploaded attachment: {name}") return True except Exception as e: logger.error(f"Upload failed: {name} - {str(e)}") @@ -157,7 +158,7 @@ def upload_attachments( ): """Creates one upload per file path and stores them into TofuPilot""" for file_path in paths: - logger.info(f"Uploading: {file_path}") + logger.info(f"Uploading attachment: {file_path}") try: # Open file and prepare for upload @@ -199,11 +200,13 @@ def process_openhtf_attachments( max_file_size: Maximum size per attachment needs_base64_decode: Whether attachment data is base64 encoded (true for dict format) """ - # Resume logger if it was paused + # Resume logger if it was paused - force it to be active for the attachment uploads was_resumed = False if hasattr(logger, 'resume'): + # Resume forcefully to ensure messages are visible logger.resume() was_resumed = True + logger.info("Starting attachment processing - logger resumed") try: logger.info("Processing attachments") @@ -244,12 +247,12 @@ def process_openhtf_attachments( if attachment_count >= max_attachments: break - # Log attachment details + # Debug attachment details (using debug level to avoid cluttering the console) if isinstance(test_record, dict): - logger.info(f"Attachment: {name}, Type: JSON format") + logger.debug(f"Attachment: {name}, Type: JSON format") else: attrs = [attr for attr in dir(attachment) if not attr.startswith('_')] - logger.info(f"Attachment: {name}, Type: Object, Attributes: {attrs}") + logger.debug(f"Attachment: {name}, Type: Object, Attributes: {attrs}") # Get attachment data and size based on record type if isinstance(test_record, dict): @@ -324,9 +327,8 @@ def process_openhtf_attachments( # Increment counter and process the attachment attachment_count += 1 - logger.info(f"Uploading: {name}") - # Use unified attachment upload function + # Use unified attachment upload function - logging is handled inside this function try: success = upload_attachment_data( logger, @@ -338,10 +340,7 @@ def process_openhtf_attachments( run_id ) - if success: - logger.success(f"Successfully uploaded attachment: {name}") - else: - logger.error(f"Failed to upload attachment: {name}") + # Don't log success/failure here as it's already logged in upload_attachment_data except Exception as e: logger.error(f"Exception during attachment upload: {name} - {str(e)}") # Continue with other attachments regardless of success/failure diff --git a/tofupilot/utils/version_checker.py b/tofupilot/utils/version_checker.py index 42c7aa1..26ddcbc 100644 --- a/tofupilot/utils/version_checker.py +++ b/tofupilot/utils/version_checker.py @@ -24,8 +24,9 @@ def check_latest_version(logger, current_version, package_name: str): # We don't use logger.warning here to avoid the colored TP:WRN prefix except PackageNotFoundError: - logger.info(f"Package not installed: {package_name}") + # Silently ignore package not found errors + pass - except requests.RequestException as e: - # Use info level so it's not as prominent - logger.info(f"Version check skipped: {e}") + except requests.RequestException: + # Silently ignore connection errors during version check + pass From e0b97e5c08322fc544febcbf93a78104da26e7fe Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Wed, 7 May 2025 16:15:32 +0200 Subject: [PATCH 44/48] Updates setup.py --- setup.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/setup.py b/setup.py index fd4cad6..c14efe5 100644 --- a/setup.py +++ b/setup.py @@ -5,32 +5,38 @@ setup( name="tofupilot", - version="1.11.0.dev0", + version="1.11.0.dev1", packages=find_packages(), install_requires=[ - "requests", - "setuptools", - "packaging", - "pytest", - "paho-mqtt", - "sentry-sdk", + "requests>=2.25.0", + "setuptools>=50.0.0", + "packaging>=20.0", + "paho-mqtt>=2.0.0", + "sentry-sdk>=1.0.0", + "certifi>=2020.12.5", ], entry_points={ "pytest11": [ "tofupilot = tofupilot.plugin", # Registering the pytest plugin ], }, - author="Félix Berthier", - author_email="felix.berthier@tofupilot.com", - description="The official Python client for the TofuPilot API", + author="TofuPilot Team", + author_email="hello@tofupilot.com", + description="Official Python client for TofuPilot with OpenHTF integration, real-time streaming and file attachment support", license="MIT", - keywords="automatic hardware testing tofupilot", + keywords="automatic hardware testing tofupilot openhtf", long_description=long_description, long_description_content_type="text/markdown", url="https://github.com/tofupilot/python-client", classifiers=[ "Programming Language :: Python :: 3", "Operating System :: OS Independent", + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Developers", + "Intended Audience :: Manufacturing", + "Topic :: Scientific/Engineering", + "Topic :: Software Development :: Testing", + "Topic :: Software Development :: Libraries :: Python Modules", ], python_requires=">=3.9", ) From d1db9ae301a724b4218a672d74d76a94f8f231a7 Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Wed, 7 May 2025 16:16:00 +0200 Subject: [PATCH 45/48] Deletes test files --- data/performance-report.pdf | Bin 1527 -> 0 bytes data/temperature-map.png | Bin 123264 -> 0 bytes main.py | 83 ------------------------------------ 3 files changed, 83 deletions(-) delete mode 100644 data/performance-report.pdf delete mode 100644 data/temperature-map.png delete mode 100644 main.py diff --git a/data/performance-report.pdf b/data/performance-report.pdf deleted file mode 100644 index ce6914bace06f26b8aebc72e582e1cd0591a1857..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1527 zcmbVMe`pg|9Pg^o?r5d{UMG|{mPlP~?niR@VKbrmakf-VO^cNk>8_WTZd++n(nVWq=Eo-q-R=@z%*+seCbZasO0o^P{AcgO7sOo^lt3iqmLJb*l32J~_ zEO?Qs<8=+#$b9Ec3aTN3LZ*2%K~{7a2ehDwI`lyag)sPTs7CZCu-cvY8%>Wxv71T^ zeb91!@7GMlRJP`y(#?*ZDz$I4V)eS9`D)4K8+O*zyP>XiA@%3ztxcv4-*27>_l9RA z>Sp?gsccR9i{g{X(pSv&Mc-CcQ5_>a!bs79lNHy_R{!>gKRq~n^wFU;pDBNDdOy3~ zPBor-p{a6UuJlmw;>>;U>756~mwuQ-r`PjkrD>sSU(>4jSoCIJYOG{rmpZxnli_dn z*GQ?iO4>8W#+!$0oc@ZbOx@jvl&S5(;rE8_+%|3fYIJ%$8JX;ll@CVGw{DzzW47+D zOEOiyy6^2O@6hKjE(~0$JTrg!#ML9Sbzim|wm$AZdf{;?yGxopbKgC%>cg#VH|AcR zEt`lPzn9o}V*A3(U%#g6`R~?k8NYQU{hHW+%`>BAr;C5wKE}d#_Xf9|_gPP!{^{7y zFWtT#O?3P|HgNCU#8Ad_HkJ*hhyC#N%*W-I;QU9%@xe^TFN1Fy3+4MKJJ#-Lus=#Z z%&vV$n=W!kA96&iJoS0ih{`KGpiy*_9MV8LaYzsxq-ZZPh$Sd`4-Cno2PHr|gV%T$ zZ{eK4X=5!;*Foy38Tdk20lS8xJ)#E52+$2s>4my1iM1Z2gebbj=+tu_Bu>PA{3>>h ztVRIcCad+TCNCA1HL*W)ljp;CJTrc6pi_~13>=>4*#`;Mq6Rp8aU``s9kkOprGRdQ z2~1XFHx8yJ|McdcjaZf(mu@#6uz*mG^D1zF@IppbKz0x$jL`=Ion27U0e%@zp9C=;R|XE zga5j)=ETGn)a)#lv7qMoA3WzuJeIMpz++h(mT?JBkBhPbZ1x8P>R<8M?7sj2 diff --git a/data/temperature-map.png b/data/temperature-map.png deleted file mode 100644 index c7eb24b120b34ee7424a40d7cd425dee944a2b01..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 123264 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�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

y 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 JgPL9R{{R+5KQjOT diff --git a/main.py b/main.py deleted file mode 100644 index 7113206..0000000 --- a/main.py +++ /dev/null @@ -1,83 +0,0 @@ -from openhtf import Test, PhaseResult -from openhtf.plugs import user_input -import time -import sys - - -import openhtf as htf -from openhtf.plugs.user_input import UserInput -from tofupilot.openhtf import TofuPilot - - -def power_on(test): - time.sleep(0.1) - return PhaseResult.CONTINUE - - -def log_1(test): - time.sleep(0.1) - return PhaseResult.CONTINUE - - -def log_2(test): - time.sleep(0.1) - return PhaseResult.CONTINUE - - -def log_3(test): - time.sleep(0.1) - return PhaseResult.CONTINUE - - -def log_4(test): - time.sleep(0.1) - return PhaseResult.CONTINUE - - -@htf.measures( - htf.Measurement("led_color").with_validator( - lambda color: color in ["Red", "Green", "Blue"] - ) -) -@htf.plug(user_input=UserInput) -def prompt_operator_led_color(test, user_input): - led_color = user_input.prompt(message="What is the LED color? (Red/Green/Blue)") - test.measurements.led_color = led_color - - -def main(): - test = Test( - power_on, - log_1, - log_2, - log_3, - log_4, - prompt_operator_led_color, - procedure_id="FVT1", - part_number="AAA", - ) - - try: - # Add TofuPilot context manager - with TofuPilot(test, url="http://localhost:3000"): - # Execute the test with standard OpenHTF execution - result = test.execute( - test_start=user_input.prompt_for_test_start() - ) - - # Print the test name and outcome in the exact format requested - test_name = test.metadata.get('test_name', 'openhtf_test') - print("\n======================= test: {0} outcome: {1} =======================\n".format( - test_name, result.outcome.name)) - # Will continue with TofuPilot upload messages (already handled by the callback) - except KeyboardInterrupt: - # This catches Ctrl+C outside of the test execution - print("\nTest setup interrupted. Exiting.") - sys.exit(0) - except Exception as e: - print(f"Error during test execution: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() From 3390101a69aeae400ad96ac4ccc41d0ee4f830ef Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Wed, 7 May 2025 19:25:55 +0200 Subject: [PATCH 46/48] Fixes attachments with create_run() --- tofupilot/client.py | 6 +- tofupilot/utils/files.py | 247 ++++++++++++++++++++++++--------------- 2 files changed, 157 insertions(+), 96 deletions(-) diff --git a/tofupilot/client.py b/tofupilot/client.py index 4b7027a..3448ecc 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -174,7 +174,11 @@ def create_run( # pylint: disable=too-many-arguments,too-many-locals # Upload attachments if run was created successfully run_id = result.get("id") - if run_id and attachments and result.get("success", False) is not False: + if run_id and attachments: + # Ensure logger is active for attachment uploads + if hasattr(self._logger, 'resume'): + self._logger.resume() + upload_attachments( self._logger, self._headers, self._url, attachments, run_id ) diff --git a/tofupilot/utils/files.py b/tofupilot/utils/files.py index 135d859..12efe05 100644 --- a/tofupilot/utils/files.py +++ b/tofupilot/utils/files.py @@ -7,6 +7,7 @@ import requests from ..constants.requests import SECONDS_BEFORE_TIMEOUT +from .logger import LoggerStateManager def log_and_raise(logger: Logger, error_message: str): @@ -59,7 +60,7 @@ def upload_file( headers=headers, timeout=SECONDS_BEFORE_TIMEOUT, ) - + response.raise_for_status() response_json = response.json() upload_url = response_json.get("uploadUrl") @@ -78,7 +79,9 @@ def upload_file( return upload_id -def notify_server(headers: dict, url: str, upload_id: str, run_id: str, logger=None) -> bool: +def notify_server( + headers: dict, url: str, upload_id: str, run_id: str, logger=None +) -> bool: """Tells TP server to sync upload with newly created run""" sync_url = f"{url}/uploads/sync" sync_payload = {"upload_id": upload_id, "run_id": run_id} @@ -91,31 +94,28 @@ def notify_server(headers: dict, url: str, upload_id: str, run_id: str, logger=N timeout=SECONDS_BEFORE_TIMEOUT, ) response.raise_for_status() + return True except Exception as e: # If logger is available, log the error properly if logger: - logger.error(str(e)) + with LoggerStateManager(logger): + logger.error(f"Failed to sync attachment: {str(e)}") return False def upload_attachment_data( - logger: Logger, - headers: dict, - url: str, - name: str, - data, - mimetype: str, - run_id: str + logger: Logger, headers: dict, url: str, name: str, data, mimetype: str, run_id: str ) -> bool: - """Uploads binary data as an attachment and links it to a run""" + """ + Uploads binary data as an attachment and links it to a run + + Uses LoggerStateManager to ensure proper logging, similar to OpenHTF implementation. + """ try: - # Initialize upload - # Make this more visible and consistent with other logs - logger.info(f"Uploading attachment: {name}") initialize_url = f"{url}/uploads/initialize" payload = {"name": name} - + response = requests.post( initialize_url, data=json.dumps(payload), @@ -123,12 +123,12 @@ def upload_attachment_data( timeout=SECONDS_BEFORE_TIMEOUT, ) response.raise_for_status() - + # Get upload details response_json = response.json() upload_url = response_json.get("uploadUrl") upload_id = response_json.get("id") - + # Upload the actual data content_type = mimetype or "application/octet-stream" upload_response = requests.put( @@ -138,14 +138,18 @@ def upload_attachment_data( timeout=SECONDS_BEFORE_TIMEOUT, ) upload_response.raise_for_status() - + # Link attachment to run notify_server(headers, url, upload_id, run_id, logger) - - logger.success(f"Uploaded attachment: {name}") + + # Log success with LoggerStateManager for visibility + with LoggerStateManager(logger): + logger.success(f"Uploaded attachment: {name}") return True except Exception as e: - logger.error(f"Upload failed: {name} - {str(e)}") + # Log error with LoggerStateManager for visibility + with LoggerStateManager(logger): + logger.error(f"Upload failed: {name} - {str(e)}") return False @@ -156,24 +160,46 @@ def upload_attachments( paths: List[str], run_id: str, ): - """Creates one upload per file path and stores them into TofuPilot""" + """ + Creates one upload per file path and stores them into TofuPilot + + Uses LoggerStateManager to ensure logging is properly handled during the upload process, + similar to the OpenHTF implementation. + """ + # Print a visual separator before attachment uploads + print("") + for file_path in paths: - logger.info(f"Uploading attachment: {file_path}") - + # Use LoggerStateManager to ensure logger is active for each file + with LoggerStateManager(logger): + logger.info(f"Uploading attachment: {file_path}") + try: + # Verify file exists + if not os.path.exists(file_path): + with LoggerStateManager(logger): + logger.error(f"File not found: {file_path}") + continue + # Open file and prepare for upload with open(file_path, "rb") as file: name = os.path.basename(file_path) data = file.read() - mimetype, _ = mimetypes.guess_type(file_path) or "application/octet-stream" - + mimetype, _ = ( + mimetypes.guess_type(file_path) or "application/octet-stream" + ) + # Use shared upload function - upload_attachment_data(logger, headers, url, name, data, mimetype, run_id) + upload_attachment_data( + logger, headers, url, name, data, mimetype, run_id + ) except Exception as e: - logger.error(f"Upload failed: {file_path} - {str(e)}") + # Use LoggerStateManager to ensure error is visible + with LoggerStateManager(logger): + logger.error(f"Upload failed: {file_path} - {str(e)}") continue - - + + def process_openhtf_attachments( logger: Logger, headers: dict, @@ -186,10 +212,13 @@ def process_openhtf_attachments( ) -> None: """ Process attachments from an OpenHTF test record and upload them. - - This function centralizes the attachment processing logic used in both the + + This function centralizes the attachment processing logic used in both the direct TofuPilotClient.create_run_from_openhtf_report and the OpenHTF output callback. - + + Uses LoggerStateManager to ensure proper logging visibility throughout the process, + similar to the OpenHTF implementation. + Args: logger: Logger for output messages headers: HTTP headers for API authentication @@ -200,33 +229,34 @@ def process_openhtf_attachments( max_file_size: Maximum size per attachment needs_base64_decode: Whether attachment data is base64 encoded (true for dict format) """ - # Resume logger if it was paused - force it to be active for the attachment uploads - was_resumed = False - if hasattr(logger, 'resume'): - # Resume forcefully to ensure messages are visible - logger.resume() - was_resumed = True - logger.info("Starting attachment processing - logger resumed") - + # Print a visual separator + print("") + + # Use LoggerStateManager instead of directly resuming/pausing + with LoggerStateManager(logger): + logger.info("Processing attachments from test record") + try: - logger.info("Processing attachments") attachment_count = 0 - + # Extract phases from test record based on type if isinstance(test_record, dict): phases = test_record.get("phases", []) - logger.info(f"Found {len(phases)} phases in JSON test record") + with LoggerStateManager(logger): + logger.info(f"Found {len(phases)} phases in JSON test record") else: phases = getattr(test_record, "phases", []) - logger.info(f"Found {len(phases)} phases in object test record") - + with LoggerStateManager(logger): + logger.info(f"Found {len(phases)} phases in object test record") + # Iterate through phases and their attachments for i, phase in enumerate(phases): # Skip if we've reached attachment limit if attachment_count >= max_attachments: - logger.warning(f"Attachment limit ({max_attachments}) reached") + with LoggerStateManager(logger): + logger.warning(f"Attachment limit ({max_attachments}) reached") break - + # Get attachments based on record type if isinstance(test_record, dict): phase_attachments = phase.get("attachments", {}) @@ -234,117 +264,144 @@ def process_openhtf_attachments( else: phase_attachments = getattr(phase, "attachments", {}) phase_name = getattr(phase, "name", f"Phase {i}") - + # Skip if phase has no attachments if not phase_attachments: continue - - logger.info(f"Processing {len(phase_attachments)} attachments in {phase_name}") - + + with LoggerStateManager(logger): + logger.info( + f"Processing {len(phase_attachments)} attachments in {phase_name}" + ) + # Process each attachment in the phase for name, attachment in phase_attachments.items(): # Skip if we've reached attachment limit if attachment_count >= max_attachments: break - + # Debug attachment details (using debug level to avoid cluttering the console) if isinstance(test_record, dict): - logger.debug(f"Attachment: {name}, Type: JSON format") + with LoggerStateManager(logger): + logger.debug(f"Attachment: {name}, Type: JSON format") else: - attrs = [attr for attr in dir(attachment) if not attr.startswith('_')] - logger.debug(f"Attachment: {name}, Type: Object, Attributes: {attrs}") - + attrs = [ + attr for attr in dir(attachment) if not attr.startswith("_") + ] + with LoggerStateManager(logger): + logger.debug( + f"Attachment: {name}, Type: Object, Attributes: {attrs}" + ) + # Get attachment data and size based on record type if isinstance(test_record, dict): # Dict format (from JSON file) attachment_data = attachment.get("data", "") if not attachment_data: - logger.warning(f"No data in: {name}") + with LoggerStateManager(logger): + logger.warning(f"No data in: {name}") continue - + try: if needs_base64_decode: import base64 + data = base64.b64decode(attachment_data) else: data = attachment_data - + attachment_size = len(data) - mimetype = attachment.get("mimetype", "application/octet-stream") + mimetype = attachment.get( + "mimetype", "application/octet-stream" + ) except Exception as e: - logger.error(f"Failed to process attachment data: {name} - {str(e)}") + with LoggerStateManager(logger): + logger.error( + f"Failed to process attachment data: {name} - {str(e)}" + ) continue else: # Object format (from callback) attachment_data = getattr(attachment, "data", None) - + # Handle different attachment types in OpenHTF if attachment_data is None: - logger.warning(f"No data in: {name}") + with LoggerStateManager(logger): + logger.warning(f"No data in: {name}") continue - + # Handle file-based attachments in different formats data = None - + # Option 1: Check for direct file_path attribute - if hasattr(attachment, "file_path") and getattr(attachment, "file_path"): + if hasattr(attachment, "file_path") and getattr( + attachment, "file_path" + ): try: file_path = getattr(attachment, "file_path") - logger.info(f"Found file_path attribute: {file_path}") + with LoggerStateManager(logger): + logger.info(f"Found file_path attribute: {file_path}") with open(file_path, "rb") as f: data = f.read() except Exception as e: - logger.error(f"Failed to read from file_path: {str(e)}") - + with LoggerStateManager(logger): + logger.error(f"Failed to read from file_path: {str(e)}") + # Option 2: Check for filename attribute (used in some OpenHTF versions) - elif hasattr(attachment, "filename") and getattr(attachment, "filename"): + elif hasattr(attachment, "filename") and getattr( + attachment, "filename" + ): try: file_path = getattr(attachment, "filename") - logger.info(f"Found filename attribute: {file_path}") + with LoggerStateManager(logger): + logger.info(f"Found filename attribute: {file_path}") with open(file_path, "rb") as f: data = f.read() except Exception as e: - logger.error(f"Failed to read from filename: {str(e)}") - + with LoggerStateManager(logger): + logger.error(f"Failed to read from filename: {str(e)}") + # Option 3: Use the data attribute directly else: - logger.info("Using data attribute directly") + with LoggerStateManager(logger): + logger.info("Using data attribute directly") data = attachment_data - + # Verify we have valid data if data is None: - logger.error(f"No valid data found for attachment: {name}") + with LoggerStateManager(logger): + logger.error(f"No valid data found for attachment: {name}") continue - + # Get size from attribute or calculate it attachment_size = getattr(attachment, "size", len(data)) - mimetype = getattr(attachment, "mimetype", "application/octet-stream") - + mimetype = getattr( + attachment, "mimetype", "application/octet-stream" + ) + # Skip oversized attachments if attachment_size > max_file_size: - logger.warning(f"File too large: {name}") + with LoggerStateManager(logger): + logger.warning(f"File too large: {name}") continue - + # Increment counter and process the attachment attachment_count += 1 - + # Use unified attachment upload function - logging is handled inside this function try: success = upload_attachment_data( - logger, - headers, - url, - name, - data, - mimetype, - run_id + logger, headers, url, name, data, mimetype, run_id ) - + # Don't log success/failure here as it's already logged in upload_attachment_data except Exception as e: - logger.error(f"Exception during attachment upload: {name} - {str(e)}") + with LoggerStateManager(logger): + logger.error( + f"Exception during attachment upload: {name} - {str(e)}" + ) # Continue with other attachments regardless of success/failure finally: - # If we resumed the logger and it has a pause method, pause it again - if was_resumed and hasattr(logger, 'pause'): - logger.pause() \ No newline at end of file + # We intentionally don't pause the logger here, as in the OpenHTF implementation + # This allows any final log messages to be visible + pass From 180fc133ce9d63519746f5ecd6764ac0c5b9a89f Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Thu, 8 May 2025 09:46:37 +0200 Subject: [PATCH 47/48] Reverts version checker changes --- tofupilot/utils/version_checker.py | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/tofupilot/utils/version_checker.py b/tofupilot/utils/version_checker.py index 26ddcbc..b823519 100644 --- a/tofupilot/utils/version_checker.py +++ b/tofupilot/utils/version_checker.py @@ -16,17 +16,13 @@ def check_latest_version(logger, current_version, package_name: str): try: if version.parse(current_version) < version.parse(latest_version): - # Direct printing with warning color (yellow) but without the TP:WRN prefix - yellow = "\033[0;33m" - reset = "\033[0m" - print(f"\n{yellow}Update available: {package_name} {current_version} → {latest_version}{reset}") - print(f"{yellow}Run: pip install --upgrade {package_name}{reset}\n") - - # We don't use logger.warning here to avoid the colored TP:WRN prefix + warning_message = ( + f"You are using {package_name} version {current_version}, however version {latest_version} is available. " + f'You should consider upgrading via the "pip install --upgrade {package_name}" command.' + ) + logger.warning(warning_message) except PackageNotFoundError: - # Silently ignore package not found errors - pass + logger.info(f"Package not installed: {package_name}") - except requests.RequestException: - # Silently ignore connection errors during version check - pass + except requests.RequestException as e: + logger.warning(f"Version check failed: {e}") From ee5cd75749f63800ba9df236b933f55eb8aac5cd Mon Sep 17 00:00:00 2001 From: Julien Buteau Date: Thu, 8 May 2025 09:48:59 +0200 Subject: [PATCH 48/48] Updates version --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index c14efe5..f5fb9e4 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ setup( name="tofupilot", - version="1.11.0.dev1", + version="1.11.1", packages=find_packages(), install_requires=[ "requests>=2.25.0",