Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
1673d57
make reformat_text private
jcollins1983 Aug 14, 2024
ba6066a
beginnings of (hopefully) improving the UI logic
jcollins1983 Aug 18, 2024
55af830
switch to the more descriptive UserInputError from the Fixate excepti…
jcollins1983 Aug 18, 2024
844682c
switch to new user_serial from _ui.py
jcollins1983 Aug 18, 2024
43c0b69
add user_yes_no and _user_abort_retry
jcollins1983 Aug 18, 2024
8cdc998
add _user_choices_
jcollins1983 Aug 18, 2024
aac14da
add space after choices string
jcollins1983 Aug 18, 2024
bfa1c55
make get_user_input private
jcollins1983 Aug 19, 2024
02b228f
gui for user choices done
jcollins1983 Aug 19, 2024
b56c71a
add some colour to the user_info_important call
jcollins1983 Aug 19, 2024
f4bfb2b
make a bit easier to follow
jcollins1983 Aug 19, 2024
0bef7a3
add user_info and user_info_important
jcollins1983 Aug 19, 2024
0ae712a
add user_ok
jcollins1983 Aug 19, 2024
a9833b8
add user_action
jcollins1983 Aug 23, 2024
7ed5d93
add some missing type hints
jcollins1983 Aug 23, 2024
7da8de8
add _user_image so indicate that an image would have been displayed
jcollins1983 Aug 25, 2024
97affa8
make information standout more
jcollins1983 Aug 25, 2024
3c790bc
fix typo
jcollins1983 Aug 25, 2024
aca4d91
add user image and gif functionality
jcollins1983 Aug 25, 2024
1e0247f
add doc strings
jcollins1983 Aug 26, 2024
b4d2b5e
include message RE use of GIFs in ccommand line
jcollins1983 Aug 26, 2024
2adc228
add post sequence display functions
jcollins1983 Aug 26, 2024
62f7355
move logic out of cmd and qt UIs into ui.py
jcollins1983 Aug 27, 2024
c08f417
adjust tests to account for movement of logic from ui to ui controlle…
jcollins1983 Aug 30, 2024
5aef5e7
fix a whoopsie
jcollins1983 Aug 30, 2024
5eca4af
fix docstring
jcollins1983 Aug 30, 2024
12301bd
probably don't need anything other than int or string for serial numbers
jcollins1983 Aug 31, 2024
382f2b5
add tests for new _ui.py
jcollins1983 Aug 31, 2024
4e7428c
fix tests
jcollins1983 Aug 31, 2024
46f3f47
add a bit more flexibility for the user_info_important colours in the…
jcollins1983 Sep 1, 2024
5c92d03
add test to ensure Issue #213 is not repeated
jcollins1983 Sep 1, 2024
cc7fe83
Merge branch 'PyFixate:main' into revamp-ui
jcollins1983 Oct 24, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/fixate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,25 @@
generate_relay_matrix_pin_list as generate_relay_matrix_pin_list,
)

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,
user_yes_no as user_yes_no,
user_info as user_info,
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,
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

__version__ = "0.6.3"
341 changes: 341 additions & 0 deletions src/fixate/_ui.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,341 @@
"""
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 enum import StrEnum
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

StrEnum requires python >=3.11.

import time
from pubsub import pub

# 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


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
Comment on lines +19 to +44
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can use generics to improve matching types between call and init.

T = TypeVar("T")

class Validator(Generic[T]):
    """
    Defines a validator object that can be used to validate user input.
    """

    def __init__(self, func: Callable[[T], 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: T) -> bool:
        """
        Args:
            resp (T): 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

An untyped callable will count as Any as before

def hello(arg) ->bool:
    ...

v = Validator(hello)

v(1)
v("st")

A typed callable will warn if the wrong type is passed in

def hello(arg:int) ->bool:
    ...

v = Validator(hello)

v(1)
v("st") # literal not assignable to int



class UiColour(StrEnum):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above for import error

class UiColour(str, Enum):

I believe there are some subtle differences betweeen this and StrEnum but I doubt they really affect the way this is being used. I think this mixin does all the magic interpret as string that we want.
https://docs.python.org/3/library/enum.html#enum.StrEnum
https://docs.python.org/3/library/enum.html#notes

RED = "red"
GREEN = "green"
BLUE = "blue"
YELLOW = "yellow"
WHITE = "white"
BLACK = "black"
CYAN = "cyan"
MAGENTA = "magenta"
GREY = "grey"


def _user_request_input(msg: str):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should annotate return as str if the public function that wraps this always expects this to return 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:
UserInputError: 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 UserInputError("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: int | str = int,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

breaks python 3.8
use

    return_type: Union[Type[str],  Type[int]] = int,

Additionally can use typevars to match the input and the return type if you want to go overboard.

Does this give much more advantage over just always returning a string and then manually converting to an int if required? Or is it more of compatibility thing

attempts: int = 5,
) -> Any:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return type should be Union[int,str] to match return_type

"""
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 (int | str): 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 UserInputError("User failed to enter the correct format serial number")


def _user_req_choices(msg: str, choices: tuple):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add str as return type

# TODO - do we really need this check since this is a private function and any callers should be calling correctly
if len(choices) < 2:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should just be an assert that catches broken bits during unit tests, since this exception basically requires another release of fixate to fix.

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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

breaks python 3.8

def _choice_from_response(choices: tuple, resp: str) -> Union[str, Literal[False]]:

Using bool rather than the literal confuses the type checker later on when you check if choice: as a way to filter out errors.

Additionally can use Tuple[str,...] for variable length tuple of strings. (I assume every choice has to be a string as we call string methods on them)

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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume empty string should not count as a 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)


def user_info(msg: str):
pub.sendMessage("UI_display", 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):
"""
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")


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:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth defining this in a way that the cmdline and qt gui modules can use so we don't have to ctrl + f to find all the weird coupled definitions of these callbacks?

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()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mypy gives error for calling get_nowait on None

if assigning the queue is assumed guaranteed behaviour then possibly can add an assert to make this error go away.

Otherwise do we need another exception handler?

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()


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():
"""
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)


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")
Loading
Loading