From 1673d577cdd8c6cb995ba936fbeb53e91ee68efb Mon Sep 17 00:00:00 2001 From: jcollins1983 Date: Wed, 14 Aug 2024 19:49:37 +1000 Subject: [PATCH 01/31] make reformat_text private --- src/fixate/ui_cmdline/cmd_line.py | 34 +++++++++++++++---------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/fixate/ui_cmdline/cmd_line.py b/src/fixate/ui_cmdline/cmd_line.py index dabf0384..cc2b8733 100644 --- a/src/fixate/ui_cmdline/cmd_line.py +++ b/src/fixate/ui_cmdline/cmd_line.py @@ -105,7 +105,7 @@ def unregister_cmd_line(): return -def reformat_text(text_str, first_line_fill="", subsequent_line_fill=""): +def _reformat_text(text_str, first_line_fill="", subsequent_line_fill=""): lines = [] wrapper.initial_indent = first_line_fill wrapper.subsequent_indent = subsequent_line_fill @@ -133,7 +133,7 @@ def _user_action(msg, callback_obj): None """ print("\a") - print(reformat_text(msg)) + print(_reformat_text(msg)) print('Press escape or "f" to fail') cancel_queue = Queue() callback_obj.set_user_cancel_queue(cancel_queue) @@ -153,7 +153,7 @@ def _user_ok(msg, q): The result queue of type queue.Queue :return: """ - msg = reformat_text(msg + "\n\nPress Enter to continue...") + msg = _reformat_text(msg + "\n\nPress Enter to continue...") print("\a") input(msg) q.put("Result", None) @@ -182,7 +182,7 @@ def _user_choices(msg, q, choices, target, attempts=5): for _ in range(attempts): # This will change based on the interface print("\a") - ret_val = input(reformat_text(msg + choicesstr)) + ret_val = input(_reformat_text(msg + choicesstr)) ret_val = target(ret_val, choices) if ret_val: q.put(("Result", ret_val)) @@ -216,7 +216,7 @@ def _user_input(msg, q, target=None, attempts=5, kwargs=None): subsequent_indent = " " # additional space added due to wrapper.drop_white_space being True, need to # drop white spaces, but keep the white space to separate the cursor from input message - msg = reformat_text(msg, initial_indent, subsequent_indent) + "\n>>> " + msg = _reformat_text(msg, initial_indent, subsequent_indent) + "\n>>> " wrapper.initial_indent = "" wrapper.subsequent_indent = "" for _ in range(attempts): @@ -242,7 +242,7 @@ def _user_display(msg): :param important: creates a line of "!" either side of the message :return: """ - print(reformat_text(msg)) + print(_reformat_text(msg)) def _user_display_important(msg): @@ -254,14 +254,14 @@ def _user_display_important(msg): print("") print("!" * wrapper.width) print("") - print(reformat_text(msg)) + print(_reformat_text(msg)) print("") print("!" * wrapper.width) def _print_sequence_end(status, passed, failed, error, skipped, sequence_status): print("#" * wrapper.width) - print(reformat_text("Sequence {}".format(sequence_status))) + print(_reformat_text("Sequence {}".format(sequence_status))) # print("Sequence {}".format(sequence_status)) post_sequence_info = RESOURCES["SEQUENCER"].context_data.get( "_post_sequence_info", {} @@ -272,13 +272,13 @@ def _print_sequence_end(status, passed, failed, error, skipped, sequence_status) for msg, state in post_sequence_info.items(): if status == "PASSED": if state == "PASSED" or state == "ALL": - print(reformat_text(msg)) + print(_reformat_text(msg)) elif state != "PASSED": - print(reformat_text(msg)) + print(_reformat_text(msg)) print("-" * wrapper.width) # reformat_text - print(reformat_text("Status: {}".format(status))) + print(_reformat_text("Status: {}".format(status))) # print("Status: {}".format(status)) print("#" * wrapper.width) print("\a") @@ -286,7 +286,7 @@ def _print_sequence_end(status, passed, failed, error, skipped, sequence_status) def _print_test_start(data, test_index): print("*" * wrapper.width) - print(reformat_text("Test {}: {}".format(test_index, data.test_desc))) + print(_reformat_text("Test {}: {}".format(test_index, data.test_desc))) # print("Test {}: {}".format(test_index, data.test_desc)) print("-" * wrapper.width) @@ -295,14 +295,14 @@ def _print_test_complete(data, test_index, status): sequencer = RESOURCES["SEQUENCER"] print("-" * wrapper.width) print( - reformat_text( + _reformat_text( "Checks passed: {}, Checks failed: {}".format( sequencer.chk_pass, sequencer.chk_fail ) ) ) # print("Checks passed: {}, Checks failed: {}".format(sequencer.chk_pass, sequencer.chk_fail)) - print(reformat_text("Test {}: {}".format(test_index, status.upper()))) + print(_reformat_text("Test {}: {}".format(test_index, status.upper()))) # print("Test {}: {}".format(test_index, status.upper())) print("-" * wrapper.width) @@ -312,14 +312,14 @@ def _print_test_skip(data, test_index): def _print_test_retry(data, test_index): - print(reformat_text("\nTest {}: Retry".format(test_index))) + print(_reformat_text("\nTest {}: Retry".format(test_index))) def _print_errors(exception, test_index): print("") print("!" * wrapper.width) print( - reformat_text( + _reformat_text( "Test {}: Exception Occurred, {} {}".format( test_index, type(exception), exception ) @@ -334,4 +334,4 @@ def _print_errors(exception, test_index): def _print_comparisons(passes: bool, chk: CheckResult, chk_cnt: int, context: str): msg = f"\nCheck {chk_cnt}: " + chk.check_string - print(reformat_text(msg)) + print(_reformat_text(msg)) From ba6066a32f109e31ff1ba89b210464e64621c285 Mon Sep 17 00:00:00 2001 From: jcollins1983 Date: Sun, 18 Aug 2024 16:26:06 +1000 Subject: [PATCH 02/31] beginnings of (hopefully) improving the UI logic --- src/fixate/__init__.py | 7 ++ src/fixate/_ui.py | 126 ++++++++++++++++++++++++++++++ src/fixate/ui_cmdline/cmd_line.py | 19 ++++- src/fixate/ui_gui_qt/ui_gui_qt.py | 6 ++ 4 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 src/fixate/_ui.py diff --git a/src/fixate/__init__.py b/src/fixate/__init__.py index a59531f5..3309d4ee 100644 --- a/src/fixate/__init__.py +++ b/src/fixate/__init__.py @@ -30,6 +30,13 @@ generate_relay_matrix_pin_list as generate_relay_matrix_pin_list, ) +from fixate._ui import ( + user_input as user_input, + user_input_float as user_input_float, + user_serial as user_serial, + Validator as Validator, +) + from fixate.main import run_main_program as run __version__ = "0.6.3" diff --git a/src/fixate/_ui.py b/src/fixate/_ui.py new file mode 100644 index 00000000..479652ca --- /dev/null +++ b/src/fixate/_ui.py @@ -0,0 +1,126 @@ +""" +This module provides the user interface for fixate. It is agnostic of the +actual implementation of the UI and provides a standard set of functions used +to obtain or display information from/to the user. +""" + +from typing import Callable, Any +from queue import Queue, Empty +from pubsub import pub + +# going to honour the post sequence info display from `ui.py` +from fixate.config import RESOURCES +from collections import OrderedDict + + +class Validator: + """ + Defines a validator object that can be used to validate user input. + """ + + def __init__(self, func: Callable[[Any], bool], errror_msg: str = "Invalid input"): + """ + Args: + func (function): The function to validate the input + error_msg (str): The message to display if the input is invalid + """ + self.func = func + self.error_msg = errror_msg + + def __call__(self, resp: Any) -> bool: + """ + Args: + resp (Any): The response to validate + + Returns: + bool: True if the response is valid, False otherwise + """ + return self.func(resp) + + def __str__(self) -> str: + return self.error_msg + + +def _user_request_input(msg: str): + q = Queue() + pub.sendMessage("UI_block_start") + pub.sendMessage("UI_req_input_", msg=msg, q=q) + resp = q.get() + pub.sendMessage("UI_block_end") + return resp + + +def user_input(msg: str) -> str: + """ + A blocking function that asks the UI to ask the user for raw input. + + Args: + msg (str): A message that will be shown to the user + + Returns: + resp (str): The user response from the UI + """ + return _user_request_input(msg) + + +def user_input_float(msg: str, attempts: int = 5) -> float: + """ + A blocking function that asks the UI to ask the user for input and converts the response to a float. + + Args: + msg (str): A message that will be shown to the user + attempts (int): Number of attempts the user has to get the input right + + Returns: + resp (float): The converted user response from the UI + + Raises: + ValueError: If the user fails to enter a number after the specified number of attempts + """ + resp = _user_request_input(msg) + for _ in range(attempts): + try: + return float(resp) + except ValueError: + pub.sendMessage( + "UI_display_important", msg="Invalid input, please enter a number" + ) + resp = _user_request_input(msg) + raise ValueError("User failed to enter a number") + + +def _ten_digit_int_serial(serial: str) -> bool: + return len(serial) == 10 and serial.isdigit() + + +_ten_digit_int_serial_v = Validator( + _ten_digit_int_serial, "Please enter a 10 digit serial number" +) + + +def user_serial( + msg: str, + validator: Validator = _ten_digit_int_serial_v, + return_type: Any = int, + attempts: int = 5, +) -> Any: + """ + A blocking function that asks the UI to ask the user for a serial number. + + Args: + msg (str): A message that will be shown to the user + validator (Validator): An optional function to validate the serial number, + defaults to checking for a 10 digit integer. This function shall return + True if the serial number is valid, False otherwise + return_type (Any): The type to return the serial number as, defaults to int + + Returns: + resp (str): The user response from the UI + """ + resp = _user_request_input(msg) + for _ in range(attempts): + if validator(resp): + return return_type(resp) + pub.sendMessage("UI_display_important", msg=f"Invalid input: {validator}") + resp = _user_request_input(msg) + raise ValueError("User failed to enter the correct format serial number") diff --git a/src/fixate/ui_cmdline/cmd_line.py b/src/fixate/ui_cmdline/cmd_line.py index cc2b8733..2c4891ab 100644 --- a/src/fixate/ui_cmdline/cmd_line.py +++ b/src/fixate/ui_cmdline/cmd_line.py @@ -90,6 +90,7 @@ def register_cmd_line(): pub.subscribe(_user_ok, "UI_req") pub.subscribe(_user_choices, "UI_req_choices") pub.subscribe(_user_input, "UI_req_input") + pub.subscribe(_user_input_, "UI_req_input_") pub.subscribe(_user_display, "UI_display") pub.subscribe(_user_display_important, "UI_display_important") pub.subscribe(_print_test_skip, "Test_Skip") @@ -107,12 +108,17 @@ def unregister_cmd_line(): def _reformat_text(text_str, first_line_fill="", subsequent_line_fill=""): lines = [] + _wrapper_initial_indent = wrapper.initial_indent + _wrapper_subsequent_indent = wrapper.subsequent_indent wrapper.initial_indent = first_line_fill wrapper.subsequent_indent = subsequent_line_fill for ind, line in enumerate(text_str.splitlines()): if ind != 0: wrapper.initial_indent = subsequent_line_fill lines.append(wrapper.fill(line)) + # reset the indents, calls to this method should not affect the global state + wrapper.initial_indent = _wrapper_initial_indent + wrapper.subsequent_indent = _wrapper_subsequent_indent return "\n".join(lines) @@ -193,6 +199,17 @@ def _user_choices(msg, q, choices, target, attempts=5): ) +def _user_input_(msg, q): + """ + Get raw user input and put in on the queue. + """ + initial_indent = ">>> " + subsequent_indent = " " # TODO - determine is this is needed + print("\a") + resp = input(_reformat_text(msg, initial_indent, subsequent_indent) + "\n>>> ") + q.put(resp) + + def _user_input(msg, q, target=None, attempts=5, kwargs=None): """ This can be replaced anywhere in the project that needs to implement the user driver @@ -217,8 +234,6 @@ def _user_input(msg, q, target=None, attempts=5, kwargs=None): # additional space added due to wrapper.drop_white_space being True, need to # drop white spaces, but keep the white space to separate the cursor from input message msg = _reformat_text(msg, initial_indent, subsequent_indent) + "\n>>> " - wrapper.initial_indent = "" - wrapper.subsequent_indent = "" for _ in range(attempts): # This will change based on the interface print("\a") diff --git a/src/fixate/ui_gui_qt/ui_gui_qt.py b/src/fixate/ui_gui_qt/ui_gui_qt.py index a5e03564..a8a42802 100644 --- a/src/fixate/ui_gui_qt/ui_gui_qt.py +++ b/src/fixate/ui_gui_qt/ui_gui_qt.py @@ -192,6 +192,7 @@ def register_events(self): pub.subscribe(self._topic_UI_req, "UI_req") pub.subscribe(self._topic_UI_req_choices, "UI_req_choices") pub.subscribe(self._topic_UI_req_input, "UI_req_input") + pub.subscribe(self._topic_UI_req_input_, "UI_req_input_") pub.subscribe(self._topic_UI_display, "UI_display") pub.subscribe(self._topic_UI_display_important, "UI_display_important") pub.subscribe(self._topic_UI_action, "UI_action") @@ -788,6 +789,11 @@ def _topic_UI_req_choices(self, msg, q, choices, target, attempts=5): UserInputError("Maximum number of attempts {} reached".format(attempts)), ) + def _topic_UI_req_input_(self, msg, q): + msg = self.reformat_text(msg) + ret_val = self.gui_user_input(msg, None) + q.put(ret_val) + def _topic_UI_req_input(self, msg, q, target=None, attempts=5, kwargs=None): """ This can be replaced anywhere in the project that needs to implement the user driver From 55af8302515f04330dd1c0209b9f48c1ce7189ed Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Sun, 18 Aug 2024 19:17:34 +1000 Subject: [PATCH 03/31] switch to the more descriptive UserInputError from the Fixate exceptions module --- src/fixate/_ui.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/fixate/_ui.py b/src/fixate/_ui.py index 479652ca..77787854 100644 --- a/src/fixate/_ui.py +++ b/src/fixate/_ui.py @@ -10,6 +10,7 @@ # going to honour the post sequence info display from `ui.py` from fixate.config import RESOURCES +from fixate.core.exceptions import UserInputError from collections import OrderedDict @@ -86,7 +87,7 @@ def user_input_float(msg: str, attempts: int = 5) -> float: "UI_display_important", msg="Invalid input, please enter a number" ) resp = _user_request_input(msg) - raise ValueError("User failed to enter a number") + raise UserInputError("User failed to enter a number") def _ten_digit_int_serial(serial: str) -> bool: @@ -123,4 +124,4 @@ def user_serial( return return_type(resp) pub.sendMessage("UI_display_important", msg=f"Invalid input: {validator}") resp = _user_request_input(msg) - raise ValueError("User failed to enter the correct format serial number") + raise UserInputError("User failed to enter the correct format serial number") From 844682c006e62d42e476b7758e60c79a9ef68959 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Sun, 18 Aug 2024 19:20:01 +1000 Subject: [PATCH 04/31] switch to new user_serial from _ui.py --- src/fixate/main.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/fixate/main.py b/src/fixate/main.py index d5db95a8..a13928a0 100644 --- a/src/fixate/main.py +++ b/src/fixate/main.py @@ -11,7 +11,8 @@ from pathlib import Path import fixate.config from fixate.core.exceptions import SequenceAbort -from fixate.core.ui import user_info_important, user_serial, user_ok +from fixate.core.ui import user_info_important, user_ok +from fixate import user_serial from fixate.reporting import register_csv, unregister_csv from fixate.ui_cmdline import register_cmd_line, unregister_cmd_line import fixate.sequencer @@ -276,13 +277,10 @@ def ui_run(self): if self.args.serial_number is None: serial_response = user_serial("Please enter serial number") if serial_response == "ABORT_FORCE": + # ABORT_FORCE will only ever come from the GUI. return ReturnCodes.ABORTED - elif serial_response[0] == "Exception": - # Should be tuple: ("Exception", ) - raise serial_response[1] else: - # Should be tuple: ("Result", serial_number) - serial_number = serial_response[1] + serial_number = serial_response self.sequencer.context_data["serial_number"] = serial_number else: serial_number = self.args.serial_number From 43c0b696225887415d281edfd028cdc1852e0fd6 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Sun, 18 Aug 2024 20:12:16 +1000 Subject: [PATCH 05/31] add user_yes_no and _user_abort_retry --- src/fixate/__init__.py | 1 + src/fixate/_ui.py | 52 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/src/fixate/__init__.py b/src/fixate/__init__.py index 3309d4ee..51e96fff 100644 --- a/src/fixate/__init__.py +++ b/src/fixate/__init__.py @@ -35,6 +35,7 @@ user_input_float as user_input_float, user_serial as user_serial, Validator as Validator, + user_yes_no as user_yes_no, ) from fixate.main import run_main_program as run diff --git a/src/fixate/_ui.py b/src/fixate/_ui.py index 77787854..2ab87926 100644 --- a/src/fixate/_ui.py +++ b/src/fixate/_ui.py @@ -125,3 +125,55 @@ def user_serial( pub.sendMessage("UI_display_important", msg=f"Invalid input: {validator}") resp = _user_request_input(msg) raise UserInputError("User failed to enter the correct format serial number") + + +def _user_req_choices(msg: str, choices: tuple): + # TODO - do we really need this check since this is a private function and any callers should be calling correctly + if len(choices) < 2: + raise ValueError(f"Requires at least two choices to work, {choices} provided") + q = Queue() + pub.sendMessage("UI_block_start") + pub.sendMessage("UI_req_choices_", msg=msg, q=q, choices=choices) + resp = q.get() + pub.sendMessage("UI_block_end") + return resp + + +def _choice_from_response(choices: tuple, resp: str) -> str | bool: + for choice in choices: + if resp.startswith(choice[0]): + return choice + return False + + +def _user_choices(msg: str, choices: tuple, attempts: int = 5) -> str: + resp = _user_req_choices(msg, choices).upper() + for _ in range(attempts): + choice = _choice_from_response(choices, resp) + if choice: + return choice + pub.sendMessage( + "UI_display_important", + msg="Invalid input, please enter a valid choice; first letter or full word", + ) + resp = _user_req_choices(msg, choices).upper() + raise UserInputError("User failed to enter a valid response") + + +def user_yes_no(msg: str, attempts: int = 1) -> str: + """ + A blocking function that asks the UI to ask the user for a yes or no response. + + Args: + msg (str): A message that will be shown to the user + + Returns: + resp (str): 'YES' or 'NO' + """ + CHOICES = ("YES", "NO") + return _user_choices(msg, CHOICES, attempts) + + +def _user_retry_abort_fail(msg: str, attempts: int = 1) -> str: + CHOICES = ("RETRY", "ABORT", "FAIL") + return _user_choices(msg, CHOICES, attempts) From 8cdc9989300f2f32e9e00948ddfb72ddadc70780 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Sun, 18 Aug 2024 20:27:48 +1000 Subject: [PATCH 06/31] add _user_choices_ --- src/fixate/ui_cmdline/cmd_line.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/fixate/ui_cmdline/cmd_line.py b/src/fixate/ui_cmdline/cmd_line.py index 2c4891ab..d84b678c 100644 --- a/src/fixate/ui_cmdline/cmd_line.py +++ b/src/fixate/ui_cmdline/cmd_line.py @@ -89,6 +89,7 @@ def register_cmd_line(): pub.subscribe(_print_sequence_end, "Sequence_Complete") pub.subscribe(_user_ok, "UI_req") pub.subscribe(_user_choices, "UI_req_choices") + pub.subscribe(_user_choices_, "UI_req_choices_") pub.subscribe(_user_input, "UI_req_input") pub.subscribe(_user_input_, "UI_req_input_") pub.subscribe(_user_display, "UI_display") @@ -165,6 +166,13 @@ def _user_ok(msg, q): q.put("Result", None) +def _user_choices_(msg, q, choices): + choicesstr = "\n" + ", ".join(choices[:-1]) + " or " + choices[-1] + " " + print("\a") + ret_val = input(_reformat_text(msg + choicesstr)) + q.put(ret_val) + + def _user_choices(msg, q, choices, target, attempts=5): """ This can be replaced anywhere in the project that needs to implement the user driver From aac14da58707f23a4eff85d5cba384a1233a13d5 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Sun, 18 Aug 2024 20:29:58 +1000 Subject: [PATCH 07/31] add space after choices string --- src/fixate/ui_cmdline/cmd_line.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fixate/ui_cmdline/cmd_line.py b/src/fixate/ui_cmdline/cmd_line.py index d84b678c..3f1b3bf8 100644 --- a/src/fixate/ui_cmdline/cmd_line.py +++ b/src/fixate/ui_cmdline/cmd_line.py @@ -167,9 +167,9 @@ def _user_ok(msg, q): def _user_choices_(msg, q, choices): - choicesstr = "\n" + ", ".join(choices[:-1]) + " or " + choices[-1] + " " + choicesstr = "\n" + ", ".join(choices[:-1]) + " or " + choices[-1] print("\a") - ret_val = input(_reformat_text(msg + choicesstr)) + ret_val = input(_reformat_text(msg + choicesstr) + " ") q.put(ret_val) From bfa1c55238d92b08964b5d7e3449fea6d4298de0 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Mon, 19 Aug 2024 16:19:37 +1000 Subject: [PATCH 08/31] make get_user_input private --- src/fixate/ui_gui_qt/ui_gui_qt.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/fixate/ui_gui_qt/ui_gui_qt.py b/src/fixate/ui_gui_qt/ui_gui_qt.py index a8a42802..c1187875 100644 --- a/src/fixate/ui_gui_qt/ui_gui_qt.py +++ b/src/fixate/ui_gui_qt/ui_gui_qt.py @@ -635,7 +635,7 @@ def on_finish(self): """User IO handlers, emit signals to trigger main thread updates via slots. These are run in the sequencer thread""" - def gui_user_input(self, message, choices=None): + def _gui_user_input(self, message, choices=None): if choices is not None: # Button Prompt self.sig_choices_input.emit(message, choices) else: # Text Prompt @@ -753,7 +753,7 @@ def _topic_UI_req(self, msg, q): if self.closing: q.put("Result", None) return - self.gui_user_input(msg, ("Continue",)) + self._gui_user_input(msg, ("Continue",)) q.put("Result", None) def _topic_UI_req_choices(self, msg, q, choices, target, attempts=5): @@ -779,7 +779,7 @@ def _topic_UI_req_choices(self, msg, q, choices, target, attempts=5): for _ in range(attempts): # This will change based on the interface - ret_val = self.gui_user_input(self.reformat_text(msg), choices) + ret_val = self._gui_user_input(self.reformat_text(msg), choices) ret_val = target(ret_val, choices) if ret_val: q.put(("Result", ret_val)) @@ -791,7 +791,7 @@ def _topic_UI_req_choices(self, msg, q, choices, target, attempts=5): def _topic_UI_req_input_(self, msg, q): msg = self.reformat_text(msg) - ret_val = self.gui_user_input(msg, None) + ret_val = self._gui_user_input(msg, None) q.put(ret_val) def _topic_UI_req_input(self, msg, q, target=None, attempts=5, kwargs=None): @@ -822,7 +822,7 @@ def _topic_UI_req_input(self, msg, q, target=None, attempts=5, kwargs=None): wrapper.subsequent_indent = "" for _ in range(attempts): # This will change based on the interface - ret_val = self.gui_user_input(msg, None) + ret_val = self._gui_user_input(msg, None) if target is None or ret_val == "ABORT_FORCE": q.put(ret_val) return From 02b228fb2818a0e3dcd5ab3d21ad0e6609f0bfe6 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Mon, 19 Aug 2024 16:39:10 +1000 Subject: [PATCH 09/31] gui for user choices done --- src/fixate/sequencer.py | 4 ++-- src/fixate/ui_gui_qt/ui_gui_qt.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/fixate/sequencer.py b/src/fixate/sequencer.py index 9e4b3436..416f7899 100644 --- a/src/fixate/sequencer.py +++ b/src/fixate/sequencer.py @@ -4,7 +4,7 @@ from pubsub import pub from fixate.core.common import TestList, TestClass from fixate.core.exceptions import SequenceAbort, CheckFail -from fixate.core.ui import user_retry_abort_fail +from fixate._ui import _user_retry_abort_fail from fixate.core.checks import CheckResult STATUS_STATES = ["Idle", "Running", "Paused", "Finished", "Restart", "Aborted"] @@ -377,7 +377,7 @@ def retry_prompt(self): if self.non_interactive: return False - status, resp = user_retry_abort_fail(msg="") + resp = _user_retry_abort_fail(msg="") if resp == "ABORT": raise SequenceAbort("Sequence Aborted By User") else: diff --git a/src/fixate/ui_gui_qt/ui_gui_qt.py b/src/fixate/ui_gui_qt/ui_gui_qt.py index c1187875..474bfc4c 100644 --- a/src/fixate/ui_gui_qt/ui_gui_qt.py +++ b/src/fixate/ui_gui_qt/ui_gui_qt.py @@ -191,6 +191,7 @@ def register_events(self): pub.subscribe(self._topic_Sequence_Abort, "Sequence_Abort") pub.subscribe(self._topic_UI_req, "UI_req") pub.subscribe(self._topic_UI_req_choices, "UI_req_choices") + pub.subscribe(self._topic_UI_req_choices_, "UI_req_choices_") pub.subscribe(self._topic_UI_req_input, "UI_req_input") pub.subscribe(self._topic_UI_req_input_, "UI_req_input_") pub.subscribe(self._topic_UI_display, "UI_display") @@ -756,6 +757,16 @@ def _topic_UI_req(self, msg, q): self._gui_user_input(msg, ("Continue",)) q.put("Result", None) + def _topic_UI_req_choices_(self, msg, q, choices): + if self.closing: + # I don't think the result of this code ever gets used, nothing looking for "ABORT_FORCE" + # unpacks from a tuple. This can probably be deleted. + q.put(("Result", "ABORT_FORCE")) + return + + ret_val = self._gui_user_input(msg, choices) + q.put(ret_val) + def _topic_UI_req_choices(self, msg, q, choices, target, attempts=5): """ This can be replaced anywhere in the project that needs to implement the user driver From b56c71a79f9dce0d0eda3e249a8e2c7d66870fee Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Mon, 19 Aug 2024 19:00:26 +1000 Subject: [PATCH 10/31] add some colour to the user_info_important call --- src/fixate/ui_gui_qt/ui_gui_qt.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/fixate/ui_gui_qt/ui_gui_qt.py b/src/fixate/ui_gui_qt/ui_gui_qt.py index 474bfc4c..b2bbfd65 100644 --- a/src/fixate/ui_gui_qt/ui_gui_qt.py +++ b/src/fixate/ui_gui_qt/ui_gui_qt.py @@ -866,13 +866,14 @@ def _topic_UI_display_important(self, msg): self.sig_history_update.emit("") self.sig_history_update.emit("!" * wrapper.width) - self.sig_active_update.emit("!" * wrapper.width) + txt = "!" * wrapper.width + self.sig_active_update.emit(f"{txt}") self.sig_history_update.emit("") self.sig_history_update.emit(self.reformat_text(msg)) self.sig_active_update.emit(self.reformat_text(msg)) self.sig_history_update.emit("") self.sig_history_update.emit("!" * wrapper.width) - self.sig_active_update.emit("!" * wrapper.width) + self.sig_active_update.emit(f"{txt}") def _topic_Sequence_Complete( self, status, passed, failed, error, skipped, sequence_status From f4bfb2b41143d7e648c2b93df69f306b8489f660 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Mon, 19 Aug 2024 19:08:26 +1000 Subject: [PATCH 11/31] make a bit easier to follow --- src/fixate/ui_gui_qt/ui_gui_qt.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/fixate/ui_gui_qt/ui_gui_qt.py b/src/fixate/ui_gui_qt/ui_gui_qt.py index b2bbfd65..b4ad127b 100644 --- a/src/fixate/ui_gui_qt/ui_gui_qt.py +++ b/src/fixate/ui_gui_qt/ui_gui_qt.py @@ -864,16 +864,20 @@ def _topic_UI_display_important(self, msg): if self.closing: return + txt_fill = "!" * wrapper.width + + # Hisotry window self.sig_history_update.emit("") - self.sig_history_update.emit("!" * wrapper.width) - txt = "!" * wrapper.width - self.sig_active_update.emit(f"{txt}") + self.sig_history_update.emit(txt_fill) self.sig_history_update.emit("") self.sig_history_update.emit(self.reformat_text(msg)) - self.sig_active_update.emit(self.reformat_text(msg)) self.sig_history_update.emit("") - self.sig_history_update.emit("!" * wrapper.width) - self.sig_active_update.emit(f"{txt}") + self.sig_history_update.emit(txt_fill) + + # Active window + self.sig_active_update.emit(f"{txt_fill}") + self.sig_active_update.emit(self.reformat_text(msg)) + self.sig_active_update.emit(f"{txt_fill}") def _topic_Sequence_Complete( self, status, passed, failed, error, skipped, sequence_status From 0bef7a316944db41e45927e68da0cb5444a850e6 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Mon, 19 Aug 2024 19:09:40 +1000 Subject: [PATCH 12/31] add user_info and user_info_important --- src/fixate/__init__.py | 2 ++ src/fixate/_ui.py | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/src/fixate/__init__.py b/src/fixate/__init__.py index 51e96fff..eda96431 100644 --- a/src/fixate/__init__.py +++ b/src/fixate/__init__.py @@ -36,6 +36,8 @@ user_serial as user_serial, Validator as Validator, user_yes_no as user_yes_no, + user_info as user_info, + user_info_important as user_info_important, ) from fixate.main import run_main_program as run diff --git a/src/fixate/_ui.py b/src/fixate/_ui.py index 2ab87926..db96359e 100644 --- a/src/fixate/_ui.py +++ b/src/fixate/_ui.py @@ -177,3 +177,11 @@ def user_yes_no(msg: str, attempts: int = 1) -> str: def _user_retry_abort_fail(msg: str, attempts: int = 1) -> str: CHOICES = ("RETRY", "ABORT", "FAIL") return _user_choices(msg, CHOICES, attempts) + + +def user_info(msg): + pub.sendMessage("UI_display", msg=msg) + + +def user_info_important(msg): + pub.sendMessage("UI_display_important", msg=msg) From 0ae712ae20e84cdf5d65062164f4cddd5a260e88 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Mon, 19 Aug 2024 19:40:23 +1000 Subject: [PATCH 13/31] add user_ok --- src/fixate/__init__.py | 1 + src/fixate/_ui.py | 9 +++++++++ src/fixate/ui_cmdline/cmd_line.py | 7 +++++++ src/fixate/ui_gui_qt/ui_gui_qt.py | 7 +++++++ 4 files changed, 24 insertions(+) diff --git a/src/fixate/__init__.py b/src/fixate/__init__.py index eda96431..db5091ce 100644 --- a/src/fixate/__init__.py +++ b/src/fixate/__init__.py @@ -38,6 +38,7 @@ user_yes_no as user_yes_no, user_info as user_info, user_info_important as user_info_important, + user_ok as user_ok, ) from fixate.main import run_main_program as run diff --git a/src/fixate/_ui.py b/src/fixate/_ui.py index db96359e..a18c7e98 100644 --- a/src/fixate/_ui.py +++ b/src/fixate/_ui.py @@ -185,3 +185,12 @@ def user_info(msg): def user_info_important(msg): pub.sendMessage("UI_display_important", msg=msg) + + +def user_ok(msg): + """ + A blocking function that asks the UI to display a message and waits for the user to press OK/Enter. + """ + pub.sendMessage("UI_block_start") + pub.sendMessage("UI_req_", msg=msg) + pub.sendMessage("UI_block_end") diff --git a/src/fixate/ui_cmdline/cmd_line.py b/src/fixate/ui_cmdline/cmd_line.py index 3f1b3bf8..e0b19f69 100644 --- a/src/fixate/ui_cmdline/cmd_line.py +++ b/src/fixate/ui_cmdline/cmd_line.py @@ -88,6 +88,7 @@ def register_cmd_line(): pub.subscribe(_print_errors, "Test_Exception") pub.subscribe(_print_sequence_end, "Sequence_Complete") pub.subscribe(_user_ok, "UI_req") + pub.subscribe(_user_ok_, "UI_req_") pub.subscribe(_user_choices, "UI_req_choices") pub.subscribe(_user_choices_, "UI_req_choices_") pub.subscribe(_user_input, "UI_req_input") @@ -149,6 +150,12 @@ def _user_action(msg, callback_obj): key_hook.start_monitor(cancel_queue, {b"\x1b": False, b"f": False}) +def _user_ok_(msg): + msg = _reformat_text(msg + "\n\nPress Enter to continue...") + print("\a") + input(msg) + + def _user_ok(msg, q): """ This can be replaced anywhere in the project that needs to implement the user driver diff --git a/src/fixate/ui_gui_qt/ui_gui_qt.py b/src/fixate/ui_gui_qt/ui_gui_qt.py index b4ad127b..cbe3fc56 100644 --- a/src/fixate/ui_gui_qt/ui_gui_qt.py +++ b/src/fixate/ui_gui_qt/ui_gui_qt.py @@ -190,6 +190,7 @@ def bind_qt_signals(self): def register_events(self): pub.subscribe(self._topic_Sequence_Abort, "Sequence_Abort") pub.subscribe(self._topic_UI_req, "UI_req") + pub.subscribe(self._topic_UI_req_, "UI_req_") pub.subscribe(self._topic_UI_req_choices, "UI_req_choices") pub.subscribe(self._topic_UI_req_choices_, "UI_req_choices_") pub.subscribe(self._topic_UI_req_input, "UI_req_input") @@ -740,6 +741,12 @@ def _topic_UI_action(self, msg, callback_obj): callback_obj.set_target_finished_callback(self.sig_button_reset.emit) self.sig_choices_input.emit(msg, ("Fail",)) + def _topic_UI_req_(self, msg): + if self.closing: + return + + self._gui_user_input(msg, ("Continue",)) + def _topic_UI_req(self, msg, q): """ This can be replaced anywhere in the project that needs to implement the user driver From a9833b8d4bde3ec656b48617652f0876b4537e9c Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Fri, 23 Aug 2024 20:07:30 +1000 Subject: [PATCH 14/31] add user_action --- src/fixate/__init__.py | 1 + src/fixate/_ui.py | 55 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/fixate/__init__.py b/src/fixate/__init__.py index db5091ce..81e1a3c1 100644 --- a/src/fixate/__init__.py +++ b/src/fixate/__init__.py @@ -39,6 +39,7 @@ user_info as user_info, user_info_important as user_info_important, user_ok as user_ok, + user_action as user_action, ) from fixate.main import run_main_program as run diff --git a/src/fixate/_ui.py b/src/fixate/_ui.py index a18c7e98..e0676b5e 100644 --- a/src/fixate/_ui.py +++ b/src/fixate/_ui.py @@ -6,6 +6,7 @@ from typing import Callable, Any from queue import Queue, Empty +import time from pubsub import pub # going to honour the post sequence info display from `ui.py` @@ -194,3 +195,57 @@ def user_ok(msg): pub.sendMessage("UI_block_start") pub.sendMessage("UI_req_", msg=msg) pub.sendMessage("UI_block_end") + + +def user_action(msg: str, action_monitor: Callable[[], bool]) -> bool: + """ + Prompts the user to complete an action. + Actively monitors the target infinitely until the event is detected or a user fail event occurs + + Args: + msg (str): Message to display to the user + action_monitor (function): A function that will be called until the user action is cancelled. The function + should return False if it hasn't completed. If the action is finished return True. + + Returns: + bool: True if the action is finished, False otherwise + """ + # UserActionCallback is used to handle the cancellation of the action either by the user or by the action itself + class UserActionCallback: + def __init__(self): + # The UI implementation must provide queue.Queue object. We + # monitor that object. If it is non-empty, we get the message + # in the q and cancel the target call. + self.user_cancel_queue = None + + # In the case that the target exists the user action instead + # of the user, we need to tell the UI to do any clean up that + # might be required. (e.g. return GUI buttons to the default state + # Does not need to be implemented by the UI. + # Function takes no args and should return None. + self.target_finished_callback = lambda: None + + def set_user_cancel_queue(self, cancel_queue): + self.user_cancel_queue = cancel_queue + + def set_target_finished_callback(self, callback): + self.target_finished_callback = callback + + callback_obj = UserActionCallback() + pub.sendMessage("UI_action", msg=msg, callback_obj=callback_obj) + try: + while True: + try: + callback_obj.user_cancel_queue.get_nowait() + return False + except Empty: + pass + + if action_monitor(): + return True + + # Yield control for other threads but don't slow down target + time.sleep(0) + finally: + # No matter what, if we exit, we want to reset the UI + callback_obj.target_finished_callback() From 7ed5d935a172221547f68cbf8daaf8b313337e4a Mon Sep 17 00:00:00 2001 From: jcollins1983 Date: Fri, 23 Aug 2024 20:08:56 +1000 Subject: [PATCH 15/31] add some missing type hints --- src/fixate/_ui.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fixate/_ui.py b/src/fixate/_ui.py index e0676b5e..86f9fd31 100644 --- a/src/fixate/_ui.py +++ b/src/fixate/_ui.py @@ -180,15 +180,15 @@ def _user_retry_abort_fail(msg: str, attempts: int = 1) -> str: return _user_choices(msg, CHOICES, attempts) -def user_info(msg): +def user_info(msg: str): pub.sendMessage("UI_display", msg=msg) -def user_info_important(msg): +def user_info_important(msg: str): pub.sendMessage("UI_display_important", msg=msg) -def user_ok(msg): +def user_ok(msg: str): """ A blocking function that asks the UI to display a message and waits for the user to press OK/Enter. """ From 7da8de86f95c50ab3100e9fad1bc7e344beb4b3d Mon Sep 17 00:00:00 2001 From: jcollins1983 Date: Sun, 25 Aug 2024 15:02:07 +1000 Subject: [PATCH 16/31] add _user_image so indicate that an image would have been displayed --- src/fixate/ui_cmdline/cmd_line.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/fixate/ui_cmdline/cmd_line.py b/src/fixate/ui_cmdline/cmd_line.py index e0b19f69..cab428ae 100644 --- a/src/fixate/ui_cmdline/cmd_line.py +++ b/src/fixate/ui_cmdline/cmd_line.py @@ -98,6 +98,7 @@ def register_cmd_line(): pub.subscribe(_print_test_skip, "Test_Skip") pub.subscribe(_print_test_retry, "Test_Retry") pub.subscribe(_user_action, "UI_action") + pub.subscribe(_user_image, "UI_image") key_hook.install() return @@ -289,6 +290,12 @@ def _user_display_important(msg): print("!" * wrapper.width) +def _user_image(path): + print("\a") + print("Image display not supported in command line") + print(_reformat_text(f"This image would have been displayed in the GUI: {path}")) + + def _print_sequence_end(status, passed, failed, error, skipped, sequence_status): print("#" * wrapper.width) print(_reformat_text("Sequence {}".format(sequence_status))) From 97affa8ba5f5487173b809a5f615d18de11f9f21 Mon Sep 17 00:00:00 2001 From: jcollins1983 Date: Sun, 25 Aug 2024 15:34:08 +1000 Subject: [PATCH 17/31] make information standout more --- src/fixate/ui_cmdline/cmd_line.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fixate/ui_cmdline/cmd_line.py b/src/fixate/ui_cmdline/cmd_line.py index cab428ae..4f20c33c 100644 --- a/src/fixate/ui_cmdline/cmd_line.py +++ b/src/fixate/ui_cmdline/cmd_line.py @@ -292,7 +292,7 @@ def _user_display_important(msg): def _user_image(path): print("\a") - print("Image display not supported in command line") + _user_display_important("Image display not supported in command line") print(_reformat_text(f"This image would have been displayed in the GUI: {path}")) From 3c790bc4524a39c4a378e2ff1ce2970d42fbbec8 Mon Sep 17 00:00:00 2001 From: jcollins1983 Date: Sun, 25 Aug 2024 15:34:58 +1000 Subject: [PATCH 18/31] fix typo --- src/fixate/ui_gui_qt/ui_gui_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fixate/ui_gui_qt/ui_gui_qt.py b/src/fixate/ui_gui_qt/ui_gui_qt.py index cbe3fc56..5b436233 100644 --- a/src/fixate/ui_gui_qt/ui_gui_qt.py +++ b/src/fixate/ui_gui_qt/ui_gui_qt.py @@ -272,7 +272,7 @@ def on_image_update(self, path): except (FileNotFoundError, OSError): # When running direct from the file system, if an image isn't found we # get FileNotFoundError. When running from a zip file, we get OSError - logger.exception("Image path specific in the test script was invalid") + logger.exception("Image path specified in the test script was invalid") # message dialog so the user knows the image didn't load self.file_not_found(path) else: From aca4d9194370d33c8c4f923b0d5c95546da5d882 Mon Sep 17 00:00:00 2001 From: jcollins1983 Date: Sun, 25 Aug 2024 15:41:35 +1000 Subject: [PATCH 19/31] add user image and gif functionality --- src/fixate/__init__.py | 3 +++ src/fixate/_ui.py | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/src/fixate/__init__.py b/src/fixate/__init__.py index 81e1a3c1..0b804348 100644 --- a/src/fixate/__init__.py +++ b/src/fixate/__init__.py @@ -40,6 +40,9 @@ user_info_important as user_info_important, user_ok as user_ok, user_action as user_action, + user_image as user_image, + user_image_clear as user_image_clear, + user_gif as user_gif, ) from fixate.main import run_main_program as run diff --git a/src/fixate/_ui.py b/src/fixate/_ui.py index 86f9fd31..b4abec24 100644 --- a/src/fixate/_ui.py +++ b/src/fixate/_ui.py @@ -249,3 +249,21 @@ def set_target_finished_callback(self, callback): finally: # No matter what, if we exit, we want to reset the UI callback_obj.target_finished_callback() + + +def user_image(path: str): + """ + Display an image to the user + + Args: + path (str): The path to the image file. The underlying library does not take a pathlib.Path object. + """ + pub.sendMessage("UI_image", path=path) + + +def user_image_clear(): + pub.sendMessage("UI_image_clear") + + +def user_gif(path: str): + pub.sendMessage("UI_gif", path=path) From 1e0247ffd98fe6e9f2ed42a99903210dbb977a38 Mon Sep 17 00:00:00 2001 From: jcollins1983 Date: Mon, 26 Aug 2024 19:00:06 +1000 Subject: [PATCH 20/31] add doc strings --- src/fixate/_ui.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/fixate/_ui.py b/src/fixate/_ui.py index b4abec24..b4d6c27c 100644 --- a/src/fixate/_ui.py +++ b/src/fixate/_ui.py @@ -262,8 +262,17 @@ def user_image(path: str): def user_image_clear(): + """ + Clear the image canvas + """ pub.sendMessage("UI_image_clear") def user_gif(path: str): + """ + Display a gif to the user + + Args: + path (str): The path to the gif file. The underlying library does not take a pathlib.Path object. + """ pub.sendMessage("UI_gif", path=path) From b4d2b5e413c3989359ddb11fa1fa48be420eb8ff Mon Sep 17 00:00:00 2001 From: jcollins1983 Date: Mon, 26 Aug 2024 19:11:34 +1000 Subject: [PATCH 21/31] include message RE use of GIFs in ccommand line --- src/fixate/ui_cmdline/cmd_line.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/fixate/ui_cmdline/cmd_line.py b/src/fixate/ui_cmdline/cmd_line.py index 4f20c33c..e7481700 100644 --- a/src/fixate/ui_cmdline/cmd_line.py +++ b/src/fixate/ui_cmdline/cmd_line.py @@ -98,7 +98,8 @@ def register_cmd_line(): pub.subscribe(_print_test_skip, "Test_Skip") pub.subscribe(_print_test_retry, "Test_Retry") pub.subscribe(_user_action, "UI_action") - pub.subscribe(_user_image, "UI_image") + pub.subscribe(_user_image_or_gif, "UI_image", caller="UI_image") + pub.subscribe(_user_image_or_gif, "UI_gif", caller="UI_gif") key_hook.install() return @@ -290,10 +291,16 @@ def _user_display_important(msg): print("!" * wrapper.width) -def _user_image(path): +def _user_image_or_gif(path, caller): + if caller == "UI_image": + disp_str = "image" + else: + disp_str = "GIF" print("\a") - _user_display_important("Image display not supported in command line") - print(_reformat_text(f"This image would have been displayed in the GUI: {path}")) + _user_display_important(f"Oh oh, {disp_str} display not supported in command line") + print( + _reformat_text(f"This {disp_str} would have been displayed in the GUI: {path}") + ) def _print_sequence_end(status, passed, failed, error, skipped, sequence_status): From 2adc228dddb89f1563ba566fa32a9876d34e62c3 Mon Sep 17 00:00:00 2001 From: jcollins1983 Date: Mon, 26 Aug 2024 19:37:34 +1000 Subject: [PATCH 22/31] add post sequence display functions --- src/fixate/__init__.py | 3 +++ src/fixate/_ui.py | 48 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/fixate/__init__.py b/src/fixate/__init__.py index 0b804348..64efbd22 100644 --- a/src/fixate/__init__.py +++ b/src/fixate/__init__.py @@ -43,6 +43,9 @@ user_image as user_image, user_image_clear as user_image_clear, user_gif as user_gif, + user_post_sequence_info_pass as user_post_sequence_info_pass, + user_post_sequence_info_fail as user_post_sequence_info_fail, + user_post_sequence_info as user_post_sequence_info, ) from fixate.main import run_main_program as run diff --git a/src/fixate/_ui.py b/src/fixate/_ui.py index b4d6c27c..58512544 100644 --- a/src/fixate/_ui.py +++ b/src/fixate/_ui.py @@ -276,3 +276,51 @@ def user_gif(path: str): path (str): The path to the gif file. The underlying library does not take a pathlib.Path object. """ pub.sendMessage("UI_gif", path=path) + + +def _user_post_sequence_info(msg: str, status: str): + if "_post_sequence_info" not in RESOURCES["SEQUENCER"].context_data: + RESOURCES["SEQUENCER"].context_data["_post_sequence_info"] = OrderedDict() + RESOURCES["SEQUENCER"].context_data["_post_sequence_info"][msg] = status + + +def user_post_sequence_info_pass(msg: str): + """ + Adds information to be displayed to the user at the end if the sequence passes + This information will be displayed in the order that this function is called. + Multiple calls with the same message will result in the previous being overwritten. + + This is useful for providing a summary of the sequence to the user at the end. + + Args: + msg (str): The message to display. + """ + _user_post_sequence_info(msg, "PASSED") + + +def user_post_sequence_info_fail(msg: str): + """ + Adds information to be displayed to the user at the end if the sequence fails. + This information will be displayed in the order that this function is called. + Multiple calls with the same message will result in the previous being overwritten. + + This is useful for providing a summary of the sequence to the user at the end. + + Args: + msg (str): The message to display. + """ + _user_post_sequence_info(msg, "FAILED") + + +def user_post_sequence_info(msg: str): + """ + Adds information to be displayed to the user at the end of the sequence. + This information will be displayed in the order that this function is called. + Multiple calls with the same message will result in the previous being overwritten. + + This is useful for providing a summary of the sequence to the user at the end. + + Args: + msg (str): The message to display. + """ + _user_post_sequence_info(msg, "ALL") From 62f7355f9489aacf83747f36f6d755324b1e9272 Mon Sep 17 00:00:00 2001 From: jcollins1983 Date: Tue, 27 Aug 2024 19:37:34 +1000 Subject: [PATCH 23/31] move logic out of cmd and qt UIs into ui.py --- src/fixate/_ui.py | 6 +- src/fixate/core/ui.py | 63 ++++++++++++------- src/fixate/ui_cmdline/cmd_line.py | 101 +----------------------------- src/fixate/ui_gui_qt/ui_gui_qt.py | 98 ++--------------------------- 4 files changed, 53 insertions(+), 215 deletions(-) diff --git a/src/fixate/_ui.py b/src/fixate/_ui.py index 58512544..2e101541 100644 --- a/src/fixate/_ui.py +++ b/src/fixate/_ui.py @@ -46,7 +46,7 @@ def __str__(self) -> str: def _user_request_input(msg: str): q = Queue() pub.sendMessage("UI_block_start") - pub.sendMessage("UI_req_input_", msg=msg, q=q) + pub.sendMessage("UI_req_input", msg=msg, q=q) resp = q.get() pub.sendMessage("UI_block_end") return resp @@ -134,7 +134,7 @@ def _user_req_choices(msg: str, choices: tuple): raise ValueError(f"Requires at least two choices to work, {choices} provided") q = Queue() pub.sendMessage("UI_block_start") - pub.sendMessage("UI_req_choices_", msg=msg, q=q, choices=choices) + pub.sendMessage("UI_req_choices", msg=msg, q=q, choices=choices) resp = q.get() pub.sendMessage("UI_block_end") return resp @@ -193,7 +193,7 @@ def user_ok(msg: str): A blocking function that asks the UI to display a message and waits for the user to press OK/Enter. """ pub.sendMessage("UI_block_start") - pub.sendMessage("UI_req_", msg=msg) + pub.sendMessage("UI_req", msg=msg) pub.sendMessage("UI_block_end") diff --git a/src/fixate/core/ui.py b/src/fixate/core/ui.py index 602f38cc..da279639 100644 --- a/src/fixate/core/ui.py +++ b/src/fixate/core/ui.py @@ -3,9 +3,10 @@ """ import time from queue import Queue, Empty +from collections import OrderedDict from pubsub import pub from fixate.config import RESOURCES -from collections import OrderedDict +from fixate.core.exceptions import UserInputError USER_YES_NO = ("YES", "NO") USER_RETRY_ABORT_FAIL = ("RETRY", "ABORT", "FAIL") @@ -27,12 +28,26 @@ def _user_req_input(msg, target=None, attempts=5, **kwargs): """ q = Queue() pub.sendMessage("UI_block_start") - pub.sendMessage( - "UI_req_input", msg=msg, q=q, target=target, attempts=attempts, kwargs=kwargs - ) - resp = q.get() + for _ in range(attempts): + pub.sendMessage("UI_req_input", msg=msg, q=q) + resp = q.get() + if target is None: + return resp + ret_val = target(resp, **kwargs) + # check for not False so that if ret_val is an empty string or 0 it not result in error + # see Issue #213: https://github.com/PyFixate/Fixate/issues/213 + if ret_val is not False: + pub.sendMessage("UI_block_end") + # live up to the original contract + return ("Result", ret_val) + + # if we get here we have exhausted attempts + # so return the same response that the original implementation did pub.sendMessage("UI_block_end") - return resp + return ( + "Exception", + UserInputError("Maximum number of attempts {} reached".format(attempts)), + ) def _user_req_choices(msg, choices, target=None, attempts=5): @@ -55,17 +70,24 @@ def _user_req_choices(msg, choices, target=None, attempts=5): ) q = Queue() pub.sendMessage("UI_block_start") - pub.sendMessage( - "UI_req_choices", - msg=msg, - q=q, - choices=choices, - target=target, - attempts=attempts, - ) - resp = q.get() + for _ in range(attempts): + pub.sendMessage("UI_req_choices", msg=msg, q=q, choices=choices) + resp = q.get() + ret_val = target(resp, choices) + # check for not False so that if ret_val is an empty string or 0 it not result in error + # see Issue #213: https://github.com/PyFixate/Fixate/issues/213 + if ret_val is not False: + pub.sendMessage("UI_block_end") + # live up to the original contract + return ("Result", ret_val) + + # if we get here we have exhausted attempts + # so return the same response that the original implementation did pub.sendMessage("UI_block_end") - return resp + return ( + "Exception", + UserInputError("Maximum number of attempts {} reached".format(attempts)), + ) def user_info(msg): @@ -93,7 +115,7 @@ def _float_validate(entry): try: return float(entry) except ValueError: - user_info("Please enter a number") + user_info("Please enter a number!") return False @@ -168,12 +190,11 @@ def user_ok(msg): :param msg: A message that will be shown to the user """ - q = Queue() pub.sendMessage("UI_block_start") - pub.sendMessage("UI_req", msg=msg, q=q) - resp = q.get() + pub.sendMessage("UI_req", msg=msg) pub.sendMessage("UI_block_end") - return resp + # Moved the logic to the UI layer, both cmd and gui used to put None on the queue. + return None def user_image(path): diff --git a/src/fixate/ui_cmdline/cmd_line.py b/src/fixate/ui_cmdline/cmd_line.py index e7481700..a7e666aa 100644 --- a/src/fixate/ui_cmdline/cmd_line.py +++ b/src/fixate/ui_cmdline/cmd_line.py @@ -88,11 +88,8 @@ def register_cmd_line(): pub.subscribe(_print_errors, "Test_Exception") pub.subscribe(_print_sequence_end, "Sequence_Complete") pub.subscribe(_user_ok, "UI_req") - pub.subscribe(_user_ok_, "UI_req_") pub.subscribe(_user_choices, "UI_req_choices") - pub.subscribe(_user_choices_, "UI_req_choices_") pub.subscribe(_user_input, "UI_req_input") - pub.subscribe(_user_input_, "UI_req_input_") pub.subscribe(_user_display, "UI_display") pub.subscribe(_user_display_important, "UI_display_important") pub.subscribe(_print_test_skip, "Test_Skip") @@ -152,71 +149,20 @@ def _user_action(msg, callback_obj): key_hook.start_monitor(cancel_queue, {b"\x1b": False, b"f": False}) -def _user_ok_(msg): +def _user_ok(msg): msg = _reformat_text(msg + "\n\nPress Enter to continue...") print("\a") input(msg) -def _user_ok(msg, q): - """ - This can be replaced anywhere in the project that needs to implement the user driver - The result needs to be put in the queue with the first part of the tuple as 'Exception' or 'Result' and the second - part is the exception object or response object - :param msg: - Message for the user to understand what to do - :param q: - The result queue of type queue.Queue - :return: - """ - msg = _reformat_text(msg + "\n\nPress Enter to continue...") - print("\a") - input(msg) - q.put("Result", None) - - -def _user_choices_(msg, q, choices): +def _user_choices(msg, q, choices): choicesstr = "\n" + ", ".join(choices[:-1]) + " or " + choices[-1] print("\a") ret_val = input(_reformat_text(msg + choicesstr) + " ") q.put(ret_val) -def _user_choices(msg, q, choices, target, attempts=5): - """ - This can be replaced anywhere in the project that needs to implement the user driver - Temporarily a simple input function. - The result needs to be put in the queue with the first part of the tuple as 'Exception' or 'Result' and the second - part is the exception object or response object - This needs to be compatible with forced exit. Look to user action for how it handles a forced exit - :param msg: - Message for the user to understand what to input - :param q: - The result queue of type queue.Queue - :param target: - Optional - Validation function to check if the user response is valid - :param attempts: - :param args: - :param kwargs: - :return: - """ - choicesstr = "\n" + ", ".join(choices[:-1]) + " or " + choices[-1] + " " - for _ in range(attempts): - # This will change based on the interface - print("\a") - ret_val = input(_reformat_text(msg + choicesstr)) - ret_val = target(ret_val, choices) - if ret_val: - q.put(("Result", ret_val)) - return - q.put( - "Exception", - UserInputError("Maximum number of attempts {} reached".format(attempts)), - ) - - -def _user_input_(msg, q): +def _user_input(msg, q): """ Get raw user input and put in on the queue. """ @@ -227,47 +173,6 @@ def _user_input_(msg, q): q.put(resp) -def _user_input(msg, q, target=None, attempts=5, kwargs=None): - """ - This can be replaced anywhere in the project that needs to implement the user driver - Temporarily a simple input function. - The result needs to be put in the queue with the first part of the tuple as 'Exception' or 'Result' and the second - part is the exception object or response object - This needs to be compatible with forced exit. Look to user action for how it handles a forced exit - :param msg: - Message for the user to understand what to input - :param q: - The result queue of type queue.Queue - :param target: - Optional - Validation function to check if the user response is valid - :param attempts: - :param args: - :param kwargs: - :return: - """ - initial_indent = ">>> " - subsequent_indent = " " - # additional space added due to wrapper.drop_white_space being True, need to - # drop white spaces, but keep the white space to separate the cursor from input message - msg = _reformat_text(msg, initial_indent, subsequent_indent) + "\n>>> " - for _ in range(attempts): - # This will change based on the interface - print("\a") - ret_val = input(msg) - if target is None: - q.put(ret_val) - return - ret_val = target(ret_val, **kwargs) - if ret_val: - q.put(("Result", ret_val)) - return - # Display failure of target and send exception - error_str = f"Maximum number of attempts {attempts} reached. {target.__doc__}" - _user_display(error_str) - q.put(("Exception", UserInputError(error_str))) - - def _user_display(msg): """ :param msg: diff --git a/src/fixate/ui_gui_qt/ui_gui_qt.py b/src/fixate/ui_gui_qt/ui_gui_qt.py index 5b436233..8eae88af 100644 --- a/src/fixate/ui_gui_qt/ui_gui_qt.py +++ b/src/fixate/ui_gui_qt/ui_gui_qt.py @@ -190,11 +190,8 @@ def bind_qt_signals(self): def register_events(self): pub.subscribe(self._topic_Sequence_Abort, "Sequence_Abort") pub.subscribe(self._topic_UI_req, "UI_req") - pub.subscribe(self._topic_UI_req_, "UI_req_") pub.subscribe(self._topic_UI_req_choices, "UI_req_choices") - pub.subscribe(self._topic_UI_req_choices_, "UI_req_choices_") pub.subscribe(self._topic_UI_req_input, "UI_req_input") - pub.subscribe(self._topic_UI_req_input_, "UI_req_input_") pub.subscribe(self._topic_UI_display, "UI_display") pub.subscribe(self._topic_UI_display_important, "UI_display_important") pub.subscribe(self._topic_UI_action, "UI_action") @@ -741,30 +738,13 @@ def _topic_UI_action(self, msg, callback_obj): callback_obj.set_target_finished_callback(self.sig_button_reset.emit) self.sig_choices_input.emit(msg, ("Fail",)) - def _topic_UI_req_(self, msg): + def _topic_UI_req(self, msg): if self.closing: return self._gui_user_input(msg, ("Continue",)) - def _topic_UI_req(self, msg, q): - """ - This can be replaced anywhere in the project that needs to implement the user driver - The result needs to be put in the queue with the first part of the tuple as 'Exception' or 'Result' and the - second part is the exception object or response object - :param msg: - Message for the user to understand what to do - :param q: - The result queue of type queue.Queue - :return: - """ - if self.closing: - q.put("Result", None) - return - self._gui_user_input(msg, ("Continue",)) - q.put("Result", None) - - def _topic_UI_req_choices_(self, msg, q, choices): + def _topic_UI_req_choices(self, msg, q, choices): if self.closing: # I don't think the result of this code ever gets used, nothing looking for "ABORT_FORCE" # unpacks from a tuple. This can probably be deleted. @@ -774,85 +754,17 @@ def _topic_UI_req_choices_(self, msg, q, choices): ret_val = self._gui_user_input(msg, choices) q.put(ret_val) - def _topic_UI_req_choices(self, msg, q, choices, target, attempts=5): - """ - This can be replaced anywhere in the project that needs to implement the user driver - Temporarily a simple input function. - The result needs to be put in the queue with the first part of the tuple as 'Exception' or 'Result' and the - second part is the exception object or response object - This needs to be compatible with forced exit. Look to user action for how it handles a forced exit - :param msg: - Message for the user to understand what to input - :param q: - The result queue of type queue.Queue - :param target: - Optional - Validation function to check if the user response is valid - :param attempts: - :return: - """ + def _topic_UI_req_input(self, msg, q): if self.closing: + # I don't think the result of this code ever gets used, nothing looking for "ABORT_FORCE" + # unpacks from a tuple. This can probably be deleted. q.put(("Result", "ABORT_FORCE")) return - for _ in range(attempts): - # This will change based on the interface - ret_val = self._gui_user_input(self.reformat_text(msg), choices) - ret_val = target(ret_val, choices) - if ret_val: - q.put(("Result", ret_val)) - return - q.put( - "Exception", - UserInputError("Maximum number of attempts {} reached".format(attempts)), - ) - - def _topic_UI_req_input_(self, msg, q): msg = self.reformat_text(msg) ret_val = self._gui_user_input(msg, None) q.put(ret_val) - def _topic_UI_req_input(self, msg, q, target=None, attempts=5, kwargs=None): - """ - This can be replaced anywhere in the project that needs to implement the user driver - Temporarily a simple input function. - The result needs to be put in the queue with the first part of the tuple as 'Exception' or 'Result' and the - second part is the exception object or response object - This needs to be compatible with forced exit. Look to user action for how it handles a forced exit - :param msg: - Message for the user to understand what to input - :param q: - The result queue of type queue.Queue - :param target: - Optional - Validation function to check if the user response is valid - :param attempts: - - :param kwargs: - :return: - """ - if self.closing: - q.put(("Result", "ABORT_FORCE")) - return - - msg = self.reformat_text(msg) - wrapper.initial_indent = "" - wrapper.subsequent_indent = "" - for _ in range(attempts): - # This will change based on the interface - ret_val = self._gui_user_input(msg, None) - if target is None or ret_val == "ABORT_FORCE": - q.put(ret_val) - return - ret_val = target(ret_val, **kwargs) - if ret_val: - q.put(("Result", ret_val)) - return - # Display failure of target and send exception - error_str = f"Maximum number of attempts {attempts} reached. {target.__doc__}" - self._topic_UI_display(error_str) - q.put(("Exception", UserInputError(error_str))) - def _topic_UI_display(self, msg): """ :param msg: From c08f41704ab7990e097a2496b780f883f98cbb44 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Fri, 30 Aug 2024 19:30:58 +1000 Subject: [PATCH 24/31] adjust tests to account for movement of logic from ui to ui controller (ui.py) --- test/core/test_ui_requests.py | 49 +++++++++++++++++++---------------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/test/core/test_ui_requests.py b/test/core/test_ui_requests.py index 25a3cd2e..fbd0eed6 100644 --- a/test/core/test_ui_requests.py +++ b/test/core/test_ui_requests.py @@ -1,22 +1,19 @@ import unittest from unittest.mock import MagicMock from pubsub import pub -from fixate.core.ui import _user_req_input, _float_validate, user_serial +from fixate.core.ui import ( + _user_req_input, + _float_validate, + user_serial, + user_input_float, + _ten_digit_serial, +) +from fixate.core.exceptions import UserInputError class MockUserDriver(MagicMock): - def execute_target(self, msg, q, target=None, attempts=5, kwargs=None): - if target: - try: - if self.test_value is None: - ret_val = target(**kwargs) - else: - ret_val = target(self.test_value, **kwargs) - q.put(("Result", ret_val)) - except Exception as e: - q.put(("Exception", e)) - else: - q.put(("Result", self.test_value)) + def execute_target(self, msg, q): + q.put(self.test_value) class TestUserRequest(unittest.TestCase): @@ -36,28 +33,34 @@ def test_target_check(self): self.mock.return_value = "World" self.assertEqual(self.test_method("HI", target=self.mock), ("Result", "World")) + def test_float_validate_fails(self): + # _float_validate is tested implicitly by user_input_float, but because of how the + # failures are done at that level, we should check the failure case here + self.assertFalse(_float_validate("abc")) + def test_target_float(self): self.mock.test_value = "1.23" - resp = self.test_method("message", target=_float_validate) + self.test_method = user_input_float + resp = self.test_method("message") self.assertAlmostEqual(resp[1], float(self.mock.test_value)) def test_target_float_fails(self): self.mock.test_value = "abc" - resp = self.test_method("message", target=_float_validate) - self.assertFalse(resp[1]) + self.test_method = user_input_float + resp = self.test_method("message") + self.assertTrue(isinstance(resp[1], UserInputError)) def test_user_serial(self): self.mock.test_value = "1234567890" resp = user_serial("message") self.assertEqual(resp[1], int(self.mock.test_value)) + def test_ten_digit_serial_fails(self): + # _ten_digit_serial is tested implicitly by user_serial since it's the default, but because of how the + # failures are done at that level, we should check the failure case here + self.assertEqual(_ten_digit_serial("123456789"), False) + def test_user_serial_fail(self): self.mock.test_value = "123456789" # < 10 digits resp = user_serial("message") - self.assertFalse(resp[1]) - - def test_user_serial_no_target(self): - # Not really meaningful test? - self.mock.test_value = 123 - resp = user_serial("message", None) - self.assertEqual(resp[1], self.mock.test_value) + self.assertTrue(isinstance(resp[1], UserInputError)) From 5aef5e73ea06c50251baceb3b8f527686d300a71 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Fri, 30 Aug 2024 19:34:09 +1000 Subject: [PATCH 25/31] fix a whoopsie --- test/core/test_ui_requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/core/test_ui_requests.py b/test/core/test_ui_requests.py index fbd0eed6..b5f0f756 100644 --- a/test/core/test_ui_requests.py +++ b/test/core/test_ui_requests.py @@ -27,7 +27,7 @@ def tearDown(self): def test_read_from_queue(self): self.mock.test_value = "World" - self.assertEqual(self.test_method("message"), ("Result", "World")) + self.assertEqual(self.test_method("message"), "World") def test_target_check(self): self.mock.return_value = "World" From 5eca4afdee115ed27fe16b1f38f361d335d44c18 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Fri, 30 Aug 2024 20:07:32 +1000 Subject: [PATCH 26/31] fix docstring --- src/fixate/_ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/fixate/_ui.py b/src/fixate/_ui.py index 2e101541..67747b08 100644 --- a/src/fixate/_ui.py +++ b/src/fixate/_ui.py @@ -77,7 +77,7 @@ def user_input_float(msg: str, attempts: int = 5) -> float: resp (float): The converted user response from the UI Raises: - ValueError: If the user fails to enter a number after the specified number of attempts + UserInputError: If the user fails to enter a number after the specified number of attempts """ resp = _user_request_input(msg) for _ in range(attempts): From 12301bda3caefc0a386f854c807f8bba96116cba Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Sat, 31 Aug 2024 19:45:18 +1000 Subject: [PATCH 27/31] probably don't need anything other than int or string for serial numbers --- src/fixate/_ui.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/fixate/_ui.py b/src/fixate/_ui.py index 67747b08..7960fe82 100644 --- a/src/fixate/_ui.py +++ b/src/fixate/_ui.py @@ -103,7 +103,7 @@ def _ten_digit_int_serial(serial: str) -> bool: def user_serial( msg: str, validator: Validator = _ten_digit_int_serial_v, - return_type: Any = int, + return_type: int | str = int, attempts: int = 5, ) -> Any: """ @@ -113,8 +113,8 @@ def user_serial( msg (str): A message that will be shown to the user validator (Validator): An optional function to validate the serial number, defaults to checking for a 10 digit integer. This function shall return - True if the serial number is valid, False otherwise - return_type (Any): The type to return the serial number as, defaults to int + True if the serial number is valid, False otherwise. + return_type (int | str): The type to return the serial number as, defaults to int Returns: resp (str): The user response from the UI From 382f2b508809da4b71cdec014d0120248be9caf7 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Sat, 31 Aug 2024 20:09:06 +1000 Subject: [PATCH 28/31] add tests for new _ui.py --- test/test_ui.py | 119 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 test/test_ui.py diff --git a/test/test_ui.py b/test/test_ui.py new file mode 100644 index 00000000..22c00b85 --- /dev/null +++ b/test/test_ui.py @@ -0,0 +1,119 @@ +from unittest import mock +import pytest +import pubsub + +from fixate.core.exceptions import UserInputError +from fixate import ( + user_input, + user_input_float, + user_serial, + Validator, + user_yes_no, +) + +# Mock the UI interface +class MockUserInterface(mock.MagicMock): + def execute_target(self, msg, q): + q.put(self.test_value) + + +@pytest.fixture +def mock_user_interface(): + mock = MockUserInterface() + pubsub.pub.subscribe(mock.execute_target, "UI_req_input") + return mock + + +class MockUserInterfaceChoices(mock.MagicMock): + def execute_target(self, msg, q, choices): + q.put(self.test_value) + + +@pytest.fixture +def mock_user_interface_choices(): + mock = MockUserInterfaceChoices() + pubsub.pub.subscribe(mock.execute_target, "UI_req_choices") + return mock + + +def test_user_input(mock_user_interface): + mock_user_interface.test_value = "Hello" + resp = user_input("message") + assert resp == "Hello" + + +def test_user_input_float(mock_user_interface): + mock_user_interface.test_value = "1.23" + resp = user_input_float("message") + assert resp == 1.23 + + +def test_user_input_float_fails(mock_user_interface): + mock_user_interface.test_value = "abc" + with pytest.raises(UserInputError): + user_input_float("message") + + +def test_user_serial(mock_user_interface): + mock_user_interface.test_value = "1234567890" + resp = user_serial("message") + assert resp == 1234567890 + + +def test_user_serial_fails(mock_user_interface): + mock_user_interface.test_value = "abc" + with pytest.raises(UserInputError): + user_serial("message") + + +def test_user_serial_custom_validator(mock_user_interface): + mock_user_interface.test_value = "240712345" + serial_validator = Validator( + lambda x: x.startswith("2407"), "Serial must be from July 2024 - 2407" + ) + resp = user_serial("message", validator=serial_validator) + assert resp == 240712345 + + +def test_user_serial_custom_validator_fails(mock_user_interface): + mock_user_interface.test_value = "240612345" + serial_validator = Validator( + lambda x: x.startswith("2407"), "Serial must be from July 2024 - 2407" + ) + with pytest.raises(UserInputError): + user_serial("message", validator=serial_validator) + + +def test_user_serial_str(mock_user_interface): + mock_user_interface.test_value = "abcdefgh" + serial_validator = Validator( + lambda x: x.startswith("abc"), "Serial must start with 'abc'" + ) + resp = user_serial("message", validator=serial_validator, return_type=str) + assert resp == "abcdefgh" + + +# the user_yes_no tests implicitly test the _user_choices function, so no need +# to test the _user_retry_abort_fail function +def test_user_yes_no_yes(mock_user_interface_choices): + mock_user_interface_choices.test_value = "yes" + resp = user_yes_no("message") + assert resp == "YES" + + +def test_user_yes_no_y(mock_user_interface_choices): + mock_user_interface_choices.test_value = "y" + resp = user_yes_no("message") + assert resp == "YES" + + +def test_user_yes_no_no(mock_user_interface_choices): + mock_user_interface_choices.test_value = "no" + resp = user_yes_no("message") + assert resp == "NO" + + +def test_user_yes_no_fails(mock_user_interface_choices): + mock_user_interface_choices.test_value = "maybe" + with pytest.raises(UserInputError): + user_yes_no("message") From 4e7428c73b7b1da4779ee0f6802dbbfba627d92e Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Sat, 31 Aug 2024 20:27:49 +1000 Subject: [PATCH 29/31] fix tests --- test/test_ui.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/test_ui.py b/test/test_ui.py index 22c00b85..bf11697f 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -1,4 +1,3 @@ -from unittest import mock import pytest import pubsub @@ -12,7 +11,7 @@ ) # Mock the UI interface -class MockUserInterface(mock.MagicMock): +class MockUserInterface: def execute_target(self, msg, q): q.put(self.test_value) @@ -24,7 +23,7 @@ def mock_user_interface(): return mock -class MockUserInterfaceChoices(mock.MagicMock): +class MockUserInterfaceChoices: def execute_target(self, msg, q, choices): q.put(self.test_value) From 46f3f470075f32621be7a19559d41005a697b5d3 Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Sun, 1 Sep 2024 10:34:39 +1000 Subject: [PATCH 30/31] add a bit more flexibility for the user_info_important colours in the GUI --- src/fixate/__init__.py | 3 ++- src/fixate/_ui.py | 19 +++++++++++++++++-- src/fixate/ui_cmdline/cmd_line.py | 3 ++- src/fixate/ui_gui_qt/ui_gui_qt.py | 10 +++++++--- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/fixate/__init__.py b/src/fixate/__init__.py index 64efbd22..c8463c65 100644 --- a/src/fixate/__init__.py +++ b/src/fixate/__init__.py @@ -31,10 +31,11 @@ ) from fixate._ui import ( + Validator as Validator, + UiColour as UiColour, user_input as user_input, user_input_float as user_input_float, user_serial as user_serial, - Validator as Validator, user_yes_no as user_yes_no, user_info as user_info, user_info_important as user_info_important, diff --git a/src/fixate/_ui.py b/src/fixate/_ui.py index 7960fe82..b6d70f8b 100644 --- a/src/fixate/_ui.py +++ b/src/fixate/_ui.py @@ -6,6 +6,7 @@ from typing import Callable, Any from queue import Queue, Empty +from enum import StrEnum import time from pubsub import pub @@ -43,6 +44,18 @@ def __str__(self) -> str: return self.error_msg +class UiColour(StrEnum): + RED = "red" + GREEN = "green" + BLUE = "blue" + YELLOW = "yellow" + WHITE = "white" + BLACK = "black" + CYAN = "cyan" + MAGENTA = "magenta" + GREY = "grey" + + def _user_request_input(msg: str): q = Queue() pub.sendMessage("UI_block_start") @@ -184,8 +197,10 @@ def user_info(msg: str): pub.sendMessage("UI_display", msg=msg) -def user_info_important(msg: str): - pub.sendMessage("UI_display_important", msg=msg) +def user_info_important( + msg: str, colour: UiColour = UiColour.RED, bg_colour: UiColour = UiColour.WHITE +): + pub.sendMessage("UI_display_important", msg=msg, colour=colour, bg_colour=bg_colour) def user_ok(msg: str): diff --git a/src/fixate/ui_cmdline/cmd_line.py b/src/fixate/ui_cmdline/cmd_line.py index a7e666aa..af0b580f 100644 --- a/src/fixate/ui_cmdline/cmd_line.py +++ b/src/fixate/ui_cmdline/cmd_line.py @@ -182,12 +182,13 @@ def _user_display(msg): print(_reformat_text(msg)) -def _user_display_important(msg): +def _user_display_important(msg, colour=None, bg_colour=None): """ :param msg: :param important: creates a line of "!" either side of the message :return: """ + # ignore the colours that are used in the GUI print("") print("!" * wrapper.width) print("") diff --git a/src/fixate/ui_gui_qt/ui_gui_qt.py b/src/fixate/ui_gui_qt/ui_gui_qt.py index 8eae88af..bba32280 100644 --- a/src/fixate/ui_gui_qt/ui_gui_qt.py +++ b/src/fixate/ui_gui_qt/ui_gui_qt.py @@ -775,7 +775,7 @@ def _topic_UI_display(self, msg): self.sig_active_update.emit(self.reformat_text(msg)) self.sig_history_update.emit(self.reformat_text(msg)) - def _topic_UI_display_important(self, msg): + def _topic_UI_display_important(self, msg, colour="red", bg_colour="white"): """ :param msg: :return: @@ -794,9 +794,13 @@ def _topic_UI_display_important(self, msg): self.sig_history_update.emit(txt_fill) # Active window - self.sig_active_update.emit(f"{txt_fill}") + self.sig_active_update.emit( + f"{txt_fill}" + ) self.sig_active_update.emit(self.reformat_text(msg)) - self.sig_active_update.emit(f"{txt_fill}") + self.sig_active_update.emit( + f"{txt_fill}" + ) def _topic_Sequence_Complete( self, status, passed, failed, error, skipped, sequence_status From 5c92d03ca45bfd9f249ab1768d6fed9c8b812fda Mon Sep 17 00:00:00 2001 From: Jason Collins Date: Sun, 1 Sep 2024 17:22:06 +1000 Subject: [PATCH 31/31] add test to ensure Issue #213 is not repeated --- test/test_ui.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/test_ui.py b/test/test_ui.py index bf11697f..9ba83a85 100644 --- a/test/test_ui.py +++ b/test/test_ui.py @@ -47,6 +47,13 @@ def test_user_input_float(mock_user_interface): assert resp == 1.23 +def test_user_input_float_0(mock_user_interface): + # test that 0 is a valid float, prevent issue #213 from recurring + mock_user_interface.test_value = "0" + resp = user_input_float("message") + assert resp == 0.0 + + def test_user_input_float_fails(mock_user_interface): mock_user_interface.test_value = "abc" with pytest.raises(UserInputError):