From 5204a722c066aaa09f4149eb8a73d893ef75e89b Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 24 Feb 2025 10:11:13 +0100 Subject: [PATCH 1/4] chore: added support of procedure version --- tofupilot/client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tofupilot/client.py b/tofupilot/client.py index b6f91d6..b44b8c3 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -65,6 +65,7 @@ def create_run( # pylint: disable=too-many-arguments,too-many-locals run_passed: bool, procedure_id: Optional[str] = None, procedure_name: Optional[str] = None, + procedure_version: Optional[str] = None, steps: Optional[List[Step]] = None, phases: Optional[List[Phase]] = None, started_at: Optional[datetime] = None, @@ -85,6 +86,8 @@ def create_run( # pylint: disable=too-many-arguments,too-many-locals The unique identifier of the procedure to which the test run belongs. Required if several procedures exists with the same procedure_name. procedure_name (str, optional): The name of the procedure to which the test run belongs. A new procedure will be created if none was found with this name. + procedure_version (str, optional): + The version of the procedure to which the test run belongs. started_at (datetime, optional): The datetime at which the test started. Default is None. duration (timedelta, optional): @@ -117,6 +120,7 @@ def create_run( # pylint: disable=too-many-arguments,too-many-locals "run_passed": run_passed, "procedure_id": procedure_id, "procedure_name": procedure_name, + "procedure_version": procedure_version, "client": "Python", "client_version": self._current_version, } From ff59296deda23acc82f835cc58a1cd756dcae824 Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 24 Feb 2025 11:21:49 +0100 Subject: [PATCH 2/4] fix: client.create_run_from_openhtf_report was not uploading attachments --- tofupilot/client.py | 167 +++++++++++++++++++++++++---------- tofupilot/openhtf/upload.py | 12 ++- tofupilot/utils/__init__.py | 2 + tofupilot/utils/exception.py | 6 ++ tofupilot/utils/network.py | 22 ++--- 5 files changed, 141 insertions(+), 68 deletions(-) create mode 100644 tofupilot/utils/exception.py diff --git a/tofupilot/client.py b/tofupilot/client.py index b44b8c3..56e7427 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -7,6 +7,8 @@ from datetime import datetime, timedelta from importlib.metadata import version +import json +import base64 import requests from .constants import ( @@ -27,6 +29,7 @@ handle_response, handle_http_error, handle_network_error, + notify_server, ) @@ -173,10 +176,7 @@ def create_run( # pylint: disable=too-many-arguments,too-many-locals except requests.RequestException as e: return handle_network_error(self._logger, e) - def create_run_from_openhtf_report( - self, - file_path: str, - ) -> dict: + def create_run_from_openhtf_report(self, file_path: str): """ Creates a run on TofuPilot from an OpenHTF JSON report. @@ -191,49 +191,70 @@ def create_run_from_openhtf_report( References: https://www.tofupilot.com/docs/api#create-a-run-from-a-file """ - self._logger.info("Starting run creation...") - - # Validate report - validate_files( - self._logger, [file_path], self._max_attachments, self._max_file_size - ) - - # Upload report - try: - upload_id = upload_file(self._headers, self._url, file_path) - 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) - - payload = { - "upload_id": upload_id, - "importer": "OPENHTF", - "client": "Python", - "client_version": self._current_version, - } + # Upload report and create run from file_path + run_id = self.upload_and_create_from_openhtf_report(file_path) - 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 - - 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) + 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.") + except json.JSONDecodeError: + print(f"Error: The file '{file_path}' contains invalid JSON.") + except PermissionError: + print(f"Error: Insufficient permissions to read '{file_path}'.") + except Exception as e: + print(f"Unexpected error: {e}") + + if run_id and test_record: + number_of_attachments = 0 + for phase in test_record.get("phases"): + # 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, + ) + break + 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, + ) def get_runs(self, serial_number: str) -> dict: """ @@ -388,6 +409,62 @@ def delete_unit(self, serial_number: str) -> dict: except requests.RequestException as e: return handle_network_error(self._logger, e) + def upload_and_create_from_openhtf_report( + self, + file_path: str, + ) -> str: + """ + Takes a path to an OpenHTF JSON file report, uploads it and creates a run from it. + + Returns: + str: + Id of the newly created run + """ + + self._logger.info("Starting run creation...") + + # Validate report + validate_files( + self._logger, [file_path], self._max_attachments, self._max_file_size + ) + + # Upload report + try: + upload_id = upload_file(self._headers, self._url, file_path) + 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) + + payload = { + "upload_id": upload_id, + "importer": "OPENHTF", + "client": "Python", + "client_version": self._current_version, + } + + 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 + + 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 get_websocket_url(self) -> dict: """ Fetches websocket connection url associated with API Key. diff --git a/tofupilot/openhtf/upload.py b/tofupilot/openhtf/upload.py index f225c4f..42a3a93 100644 --- a/tofupilot/openhtf/upload.py +++ b/tofupilot/openhtf/upload.py @@ -22,13 +22,13 @@ class upload: # pylint: disable=invalid-name OpenHTF output callback to automatically upload the test report to TofuPilot upon test completion. This function behaves similarly to manually parsing the OpenHTF JSON test report and calling - `TofuPilotClient().create_run()` with the parsed data, streamlining the process for automatic uploads. + `TofuPilotClient().create_run()` with the parsed data. ### Usage Example: ```python from openhtf import test - import tofupilot + from tofupilot.openhtf import upload # ... @@ -36,7 +36,7 @@ def main(): test = Test(*your_phases, procedure_id="FVT1") # Add TofuPilot's upload callback to automatically send the test report upon completion - test.add_output_callback(tofupilot.upload()) + test.add_output_callback(upload()) test.execute(lambda: "SN15") ``` @@ -69,9 +69,7 @@ def __call__(self, test_record: TestRecord): ) # 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 - ] # Use underscores for time, slice for milliseconds precision + start_time_formatted = start_time.strftime("%Y-%m-%d_%H-%M-%S-%f")[:-3] temp_dir = tempfile.gettempdir() @@ -95,7 +93,7 @@ def __call__(self, test_record: TestRecord): try: # Call create_run_from_report with the generated file path - run_id = self.client.create_run_from_openhtf_report(filename) + run_id = self.client.upload_and_create_from_openhtf_report(filename) finally: # Ensure the file is deleted after processing if os.path.exists(filename): diff --git a/tofupilot/utils/__init__.py b/tofupilot/utils/__init__.py index 7fecdd2..e79ee30 100644 --- a/tofupilot/utils/__init__.py +++ b/tofupilot/utils/__init__.py @@ -18,6 +18,7 @@ handle_http_error, handle_network_error, ) +from .exception import RunCreationError __all__ = [ "setup_logger", @@ -34,4 +35,5 @@ "handle_response", "handle_http_error", "handle_network_error", + "RunCreationError", ] diff --git a/tofupilot/utils/exception.py b/tofupilot/utils/exception.py new file mode 100644 index 0000000..480b4f3 --- /dev/null +++ b/tofupilot/utils/exception.py @@ -0,0 +1,6 @@ +class RunCreationError(Exception): + def __init__(self, message: str, warnings: list = None, status_code: int = None): + self.message = message + self.warnings = warnings + self.status_code = status_code + super().__init__(message) diff --git a/tofupilot/utils/network.py b/tofupilot/utils/network.py index 5adae43..8fd0b24 100644 --- a/tofupilot/utils/network.py +++ b/tofupilot/utils/network.py @@ -2,6 +2,8 @@ import requests +from .exception import RunCreationError + def parse_error_message(response: requests.Response) -> str: try: @@ -62,23 +64,11 @@ def handle_http_error( error_message = http_err logger.error(error_message) - - return { - "success": False, - "message": None, - "warnings": warnings, - "status_code": http_err.response.status_code, - "error": {"message": error_message}, - } + raise RunCreationError(error_message, warnings, http_err.response.status_code) def handle_network_error(logger, e: requests.RequestException) -> Dict[str, Any]: """Handles network errors and logs them.""" - logger.error(f"Network error: {e}") - return { - "success": False, - "message": None, - "warnings": None, - "status_code": None, - "error": {"message": str(e)}, - } + error_message = f"Network error: {e}" + logger.error(error_message) + raise RunCreationError(error_message) From 8e72da1dcdca5cce5af1c6b89ae4c29cffc07a3c Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 24 Feb 2025 11:24:40 +0100 Subject: [PATCH 3/4] chore: simplified methods signatures --- tofupilot/client.py | 10 ++++++---- tofupilot/utils/network.py | 6 ++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/tofupilot/client.py b/tofupilot/client.py index 56e7427..f47ba3e 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -432,9 +432,9 @@ def upload_and_create_from_openhtf_report( try: upload_id = upload_file(self._headers, self._url, file_path) except requests.exceptions.HTTPError as http_err: - return handle_http_error(self._logger, http_err) + handle_http_error(self._logger, http_err) except requests.RequestException as e: - return handle_network_error(self._logger, e) + handle_network_error(self._logger, e) payload = { "upload_id": upload_id, @@ -461,9 +461,11 @@ def upload_and_create_from_openhtf_report( return run_id except requests.exceptions.HTTPError as http_err: - return handle_http_error(self._logger, http_err) + handle_http_error(self._logger, http_err) except requests.RequestException as e: - return handle_network_error(self._logger, e) + handle_network_error(self._logger, e) + + return "" def get_websocket_url(self) -> dict: """ diff --git a/tofupilot/utils/network.py b/tofupilot/utils/network.py index 8fd0b24..eb279af 100644 --- a/tofupilot/utils/network.py +++ b/tofupilot/utils/network.py @@ -40,9 +40,7 @@ def handle_response( return data -def handle_http_error( - logger, http_err: requests.exceptions.HTTPError -) -> Dict[str, Any]: +def handle_http_error(logger, http_err: requests.exceptions.HTTPError): """Handles HTTP errors and logs them.""" warnings = None # Initialize warnings to None @@ -67,7 +65,7 @@ def handle_http_error( raise RunCreationError(error_message, warnings, http_err.response.status_code) -def handle_network_error(logger, e: requests.RequestException) -> Dict[str, Any]: +def handle_network_error(logger, e: requests.RequestException): """Handles network errors and logs them.""" error_message = f"Network error: {e}" logger.error(error_message) From a353c536a59a9753ae603a9a14aca7d30b0b352e Mon Sep 17 00:00:00 2001 From: Felix Date: Mon, 24 Feb 2025 12:14:57 +0100 Subject: [PATCH 4/4] chore: removed custom exception throwing --- tofupilot/client.py | 10 ++++------ tofupilot/utils/__init__.py | 2 -- tofupilot/utils/exception.py | 6 ------ tofupilot/utils/network.py | 28 ++++++++++++++++++++-------- 4 files changed, 24 insertions(+), 22 deletions(-) delete mode 100644 tofupilot/utils/exception.py diff --git a/tofupilot/client.py b/tofupilot/client.py index f47ba3e..56e7427 100644 --- a/tofupilot/client.py +++ b/tofupilot/client.py @@ -432,9 +432,9 @@ def upload_and_create_from_openhtf_report( try: upload_id = upload_file(self._headers, self._url, file_path) except requests.exceptions.HTTPError as http_err: - handle_http_error(self._logger, http_err) + return handle_http_error(self._logger, http_err) except requests.RequestException as e: - handle_network_error(self._logger, e) + return handle_network_error(self._logger, e) payload = { "upload_id": upload_id, @@ -461,11 +461,9 @@ def upload_and_create_from_openhtf_report( return run_id except requests.exceptions.HTTPError as http_err: - handle_http_error(self._logger, http_err) + return handle_http_error(self._logger, http_err) except requests.RequestException as e: - handle_network_error(self._logger, e) - - return "" + return handle_network_error(self._logger, e) def get_websocket_url(self) -> dict: """ diff --git a/tofupilot/utils/__init__.py b/tofupilot/utils/__init__.py index e79ee30..7fecdd2 100644 --- a/tofupilot/utils/__init__.py +++ b/tofupilot/utils/__init__.py @@ -18,7 +18,6 @@ handle_http_error, handle_network_error, ) -from .exception import RunCreationError __all__ = [ "setup_logger", @@ -35,5 +34,4 @@ "handle_response", "handle_http_error", "handle_network_error", - "RunCreationError", ] diff --git a/tofupilot/utils/exception.py b/tofupilot/utils/exception.py deleted file mode 100644 index 480b4f3..0000000 --- a/tofupilot/utils/exception.py +++ /dev/null @@ -1,6 +0,0 @@ -class RunCreationError(Exception): - def __init__(self, message: str, warnings: list = None, status_code: int = None): - self.message = message - self.warnings = warnings - self.status_code = status_code - super().__init__(message) diff --git a/tofupilot/utils/network.py b/tofupilot/utils/network.py index eb279af..5adae43 100644 --- a/tofupilot/utils/network.py +++ b/tofupilot/utils/network.py @@ -2,8 +2,6 @@ import requests -from .exception import RunCreationError - def parse_error_message(response: requests.Response) -> str: try: @@ -40,7 +38,9 @@ def handle_response( return data -def handle_http_error(logger, http_err: requests.exceptions.HTTPError): +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 @@ -62,11 +62,23 @@ def handle_http_error(logger, http_err: requests.exceptions.HTTPError): error_message = http_err logger.error(error_message) - raise RunCreationError(error_message, warnings, http_err.response.status_code) + + return { + "success": False, + "message": None, + "warnings": warnings, + "status_code": http_err.response.status_code, + "error": {"message": error_message}, + } -def handle_network_error(logger, e: requests.RequestException): +def handle_network_error(logger, e: requests.RequestException) -> Dict[str, Any]: """Handles network errors and logs them.""" - error_message = f"Network error: {e}" - logger.error(error_message) - raise RunCreationError(error_message) + logger.error(f"Network error: {e}") + return { + "success": False, + "message": None, + "warnings": None, + "status_code": None, + "error": {"message": str(e)}, + }