From b25c109c48d2ca83d314a1563348a968037684e0 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Thu, 13 Jun 2019 14:29:49 -0500 Subject: [PATCH 01/30] Initial commit --- apptools/feedback/feedbackbot/__init__.py | 0 apptools/feedback/feedbackbot/model.py | 138 ++++++ apptools/feedback/feedbackbot/utils.py | 54 ++ apptools/feedback/feedbackbot/view.py | 94 ++++ .../validating_dialog_with_feedback.py | 461 ++++++++++++++++++ 5 files changed, 747 insertions(+) create mode 100644 apptools/feedback/feedbackbot/__init__.py create mode 100644 apptools/feedback/feedbackbot/model.py create mode 100644 apptools/feedback/feedbackbot/utils.py create mode 100644 apptools/feedback/feedbackbot/view.py create mode 100644 apptools/feedback/validating_dialog_with_feedback.py diff --git a/apptools/feedback/feedbackbot/__init__.py b/apptools/feedback/feedbackbot/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py new file mode 100644 index 000000000..fe2e31432 --- /dev/null +++ b/apptools/feedback/feedbackbot/model.py @@ -0,0 +1,138 @@ +""" +This module implements a class that provides logic for a simple plugin +for sending messages to a developer team's slack channel. +""" + +import os +import io + +import numpy as np +import slack +import aiohttp +from PIL import Image +from traits.api import ( + HasTraits, Str, Property, + Int, Array, Bytes, String, + cached_property, on_trait_change) + + +class FeedbackMessage(HasTraits): + """Model for the feedback message. + + Notes + ----- + The user-developer must specify the slack channel that the message must be + sent to, as well as provide raw screenshot data. + + TODO: + Add service that reveals OAUTH token. + """ + + first_name = Str(msg_meta=True) + + last_name = Str(msg_meta=True) + + #: Name of the client organization. + organization = Str(msg_meta=True) + + # TODO: Slack supports some markdown in messages, provide + # some details here. + #: Main body of the feedback message. + description = Str(msg_meta=True) + + #: The target slack channel that the bot will post to, must start with #. + channels = String(minlen=2, regex='#.*') + + #: The final slack message that will be posted. + msg = Str + + #: The screenshot pixel data in raw bytes. + img_bytes = Bytes + + #: The screenshot RGB data as a numeric array. + img_data = Property(Array) + + #: The screenshot width in pixels. + img_w = Int + + #: The screenshot height in pixels. + img_h = Int + + @cached_property + def _get_img_data(self): + """ Compute RGB data from raw image bytes. + + Returns + ------- + numpy.ndarray + RGB values in a numpy ndarray of shape + (self.img_h, self.img_w, 3). + + """ + + # TODO: assume RGB ordering and provide helper functions to change the + # order if necessary + # [2::-1] is required to order the channels as RGB + return np.frombuffer( + self.img_bytes, dtype=np.uint8).reshape(( + self.img_h, + self.img_w, -1))[..., 2::-1] + + @on_trait_change('+msg_meta') + def _update_msg(self): + + feedback_template = 'Name: {first} {last}\n' \ + + 'Organization: {org}\nDescription: {desc}' + + self.msg = feedback_template.format( + first=self.first_name, + last=self.last_name, + org=self.organization, + desc=self.description) + + def send(self): + """ Send feedback message and screenshot to slack. """ + + # TODO: OAuth token should be revealed after client + # user credentials are presented + # Initialize slack client + client = slack.WebClient(token=os.environ['FEEDBACKBOT_OAUTH_TOKEN'], + timeout=10, + ssl=True) + + # Compress image into PNG format using an in-memory buffer. + img = Image.fromarray(self.img_data, mode='RGB') + + buf = io.BytesIO() + + img.save(buf, 'PNG') + buf.seek(0) + + try: + + # Send message. + response = client.files_upload( + channels=self.channels, + initial_comment=self.msg, + filetype='png', + filename='screenshot.png', + file=buf) + + except slack.errors.SlackApiError as error: + + print( + 'Message sent successfully,' + + ' but received the following error from Slack:') + print(error) + raise + + except aiohttp.client_exceptions.ClientConnectorError as error: + + print('Message not sent.') + print(error) + raise + + else: + + print('Message sent successfully!') + diff --git a/apptools/feedback/feedbackbot/utils.py b/apptools/feedback/feedbackbot/utils.py new file mode 100644 index 000000000..971764546 --- /dev/null +++ b/apptools/feedback/feedbackbot/utils.py @@ -0,0 +1,54 @@ +""" +This module provides some helper functions for the feedback plugin. These +functions are designed to be used by the user-developer so that the feedback +plugin can be used in their application. +""" + +from PyQt4.QtGui import QPixmap + +def take_screenshot_qimg(info): + """ Take screenshot of an active GUI widget. + + Parameters + ---------- + info: traitsui.UIInfo + The UIInfo instance which contains the active widget. + + Returns: + -------- + qimg: PyQt4.QtGui.QImage + Screenshot image as PyQt4.QtGui.Qimage instance. + + """ + + pixmap = QPixmap.grabWidget(info.ui.control) + + qimg = pixmap.toImage() + + return qimg + +def get_raw_qimg_data(qimg): + """ Get raw image data (BGR[A] values, and size in pixels). + + Parameters: + qimg: PyQt4.QtGui.Qimage instance + + Returns: + -------- + bytes + Raw bytes ordered as BGR[A]. Alpha channel is included if available. + int + Image height in pixels. + int + Image width in pixels. + + """ + + qbits = qimg.bits() + + num_channels = qimg.depth() // 8 + + qbits.setsize(qimg.width() * qimg.height() * num_channels) + + return [qbits.asstring(), qimg.height(), qimg.width()] + diff --git a/apptools/feedback/feedbackbot/view.py b/apptools/feedback/feedbackbot/view.py new file mode 100644 index 000000000..ad7070062 --- /dev/null +++ b/apptools/feedback/feedbackbot/view.py @@ -0,0 +1,94 @@ +""" +This model implements UI classes and logic for a plugin that enables +clients to send feedback messages to a developer team's slack channel. +""" + +from traits.api import Property, Instance +from traitsui.api import ( + View, Group, Item, Action, + Label, Controller) +from traitsui.menu import CancelButton +from chaco.api import Plot, ArrayPlotData +from enable.api import ComponentEditor + +from .model import FeedbackMessage + + + +# ---------------------------------------------------------------------------- +# TraitsUI Actions +# ---------------------------------------------------------------------------- + +send_button = Action(name='Send', action='send', + enabled_when='controller._send_enabled') + +# ---------------------------------------------------------------------------- +# TraitsUI Views +# ---------------------------------------------------------------------------- + +#: Primary view for the feedback message. +feedback_msg_view = View( + Label('Enter feedback here. All fields are mandatory.'), + Group( + Group( + Item('first_name'), + Item('last_name'), + Item('organization', + tooltip='Enter the name of your organization.'), + Item('description', + tooltip='Enter feedback.', + height=200, + springy=True)), + Group( + Item('controller.screenshot_plot', + editor=ComponentEditor(), + show_label=False)), + orientation='horizontal'), + buttons=[CancelButton, send_button], + width=800, + resizable=True) + + +# ---------------------------------------------------------------------------- +# TraitsUI Handler +# ---------------------------------------------------------------------------- + +class FeedbackController(Controller): + """Controller for FeedbackMessage. + + The Controller allows the client user to specify the feedback and preview + the screenshot. + + """ + + model = Instance(FeedbackMessage) + + #: Chaco plot to display the screenshot. + screenshot_plot = Instance(Plot) + + #: Property that decides whether the state of the message is valid + # for sending. + _send_enabled = Property(depends_on='[+msg_meta]') + + trait_view = feedback_msg_view + + def _screenshot_plot_default(self): + """ Plots screenshot in Chaco from RGB data. """ + + # Reverse rows of model.img_data so that the img_plot looks right + plotdata = ArrayPlotData(img_data=self.model.img_data[::-1, ...]) + plot = Plot(plotdata) + plot.img_plot('img_data', hide_grids=True) + + plot.border_visible = False + plot.x_axis = None + plot.y_axis = None + + return plot + + def _get__send_enabled(self): + """ Logic to check if message is valid for sending. """ + + return self.model.first_name and self.model.last_name \ + and self.model.organization and self.model.description + diff --git a/apptools/feedback/validating_dialog_with_feedback.py b/apptools/feedback/validating_dialog_with_feedback.py new file mode 100644 index 000000000..b4b76f53d --- /dev/null +++ b/apptools/feedback/validating_dialog_with_feedback.py @@ -0,0 +1,461 @@ +""" +Validating Dialog Example +========================= + +This example shows how to dynamically validate a user's entries in a +TraitsUI dialog. The example shows four things: + +* how to enable/disable the 'OK' dialog button by tracking various error + states and incrementing/decrementing the :py:attr:`ui.errors` count. + +* how to perform additional checks when the user clicks the 'OK' dialog + button, displaying an appropriate error message and returning control + to the dialog on failure. + +* setting an editor's 'invalid' state via a trait, which colors the + textbox background to the error color. + +* displaying feedback to the user in a number of additional ways, such as + text explanations, alert icons, and icons with varying feedback levels. + + +""" + +try: + from zxcvbn import zxcvbn as test_strength +except ImportError: + import warnings + warnings.warn("zxcvbn package not installed, using dummy strength tester") + + def test_strength(password): + """ A dummy password strength tester. """ + if password == "12345": + return { + 'score': 0, + 'feedback': { + 'suggestions': [ + "12345? Amazing, I have the same combination on my luggage" + ] + } + } + elif len(password) < 16: + return { + 'score': len(password) // 4, + 'feedback': { + 'suggestions': ["Type more characters"] + } + } + else: + return { + 'score': 4, + 'feedback': {'suggestions': []} + } + + +from traits.etsconfig.api import ETSConfig +from pyface.api import ImageResource, MessageDialog +from traits.api import ( + Bool, HasStrictTraits, HTML, Instance, Password, Property, Range, Unicode, + cached_property, on_trait_change +) +from traitsui.api import ( + Action, Handler, HGroup, Image, ImageEditor, Item, Menu, MenuBar, + ModelView, OKCancelButtons, TextEditor, VGrid, VGroup, View +) + +if ETSConfig.toolkit in {'qt4', 'qt'}: + from traitsui.qt4.constants import WindowColor + background_color = '#{0:x}{1:x}{2:x}'.format(*WindowColor.getRgb()) +elif ETSConfig.toolkit == 'wx': + from traitsui.wx.constants import WindowColor + background_color = '#{0:x}{1:x}{2:x}'.format(*WindowColor.GetRGB()) + +from feedbackbot.model import FeedbackMessage +from feedbackbot.view import FeedbackController +from feedbackbot.utils import take_screenshot_qimg, get_raw_qimg_data + +#: A map of password strength values to icons. +strength_map = { + i: ImageResource('squares_{}'.format(i + 1)) + for i in range(5) +} + + +#: Enough CSS so it looks like it at least belongs in this millenium +css = """ +* {{ + background-color: {background_color}; +}} +h1 {{ + font-family: "Open Sans", "Ariel", sans; + font-size: 16px; + font-weight: bold; +}} +p {{ + font-family: "Open Sans", "Ariel", sans; + font-size: 12px; +}} +""".format(background_color=background_color) + + +#: A simple HTML template to give feedback. +explanation_template = """ + + + + + +

Enter your username and password.

+ +

{text}

+ + +""" + + +class Credentials(HasStrictTraits): + """ A class that holds a user's credentials. + """ + + #: The user's id. + username = Unicode + + #: The user's password. + password = Password + + def login(self): + """ Dummy login method. """ + if self.password == '12345': + return True, 'Amazing, I have the same combination on my luggage!' + else: + return False, 'Incorrect password or unknown user.' + + def create_account(self): + """ Dummy account creation method. """ + if self.username in {'alice', 'bob'}: + return False, "Username already exists." + return True, 'Account created' + + +class NewAccountView(ModelView): + """ Account creation dialog example. + """ + + #: Text explaining the dialog. + explanation = Property(HTML, depends_on=['_password_suggestions', + '+view_error']) + + #: The user's password entered a second time. + password = Password + + #: The user's password strength. + password_strength = Range(0, 4) + + #: Alert icon for username error. + password_strength_icon = Property(Image, depends_on='password_strength') + + #: Alert icon for username error. + username_icon = Image('@std:alert16') + + #: Alert icon for second password error. + password_match_icon = Image('@std:alert16') + + # private traits --------------------------------------------------------- + + #: The suggestions for a stronger password. + _password_suggestions = Unicode + + #: Whether there is anything entered for the username. + _username_error = Bool(False, view_error=True) + + #: Whether the password is strong enough. + _password_strength_error = Bool(False, view_error=True) + + #: Whether the two entered passwords match. + _password_match_error = Bool(False, view_error=True) + + # ------------------------------------------------------------------------ + # Handler interface + # ------------------------------------------------------------------------ + + def init(self, info): + """ Initialize the error state of the object. """ + obj = info.object + model = info.model + + # check for initial error states + obj._check_username(model.username) + obj._check_password_strength(model.password) + obj._check_password_match(model.password) + + super(NewAccountView, self).init(info) + + def close(self, info, is_ok): + """ Handles the user attempting to close the dialog. + + If it is via the OK dialog button, try to create an account before + closing. If this fails, display an error message and veto the close + by returning false. + """ + if is_ok: + success, message = info.model.create_account() + if not success: + dlg = MessageDialog( + message="Cannot create account", + informative=message, + severity='error' + ) + dlg.open() + return False + + return True + + # UI change handlers ----------------------------------------------------- + + def model_username_changed(self, ui_info): + """ Set error condition if the model's username is empty. """ + if ui_info.initialized: + ui_info.object._username_error = (ui_info.model.username == '') + + def model_password_changed(self, ui_info): + """ Check the quality of the password that the user entered. """ + if ui_info.initialized: + obj = ui_info.object + password = ui_info.model.password + + obj._check_password_strength(password) + obj._check_password_match(password) + + def object_password_changed(self, ui_info): + """ Check if the re-enteredpassword matches the original. """ + if ui_info.initialized: + obj = ui_info.object + password = ui_info.model.password + + obj._check_password_match(password) + + # ------------------------------------------------------------------------ + # private interface + # ------------------------------------------------------------------------ + + def _check_username(self, username): + """ Check whether the passwords match. """ + self._username_error = (username == '') + + def _check_password_strength(self, password): + """ Check the strength of the password + + This sets the password strength, suggestions for making a better + password and an error state if the password is not strong enough. + """ + if password: + password_check = test_strength(password) + self.password_strength = password_check['score'] + feedback = password_check.get('feedback', {}) + if feedback.get('warnings'): + warnings = '{} '.format(feedback['warnings']) + else: + warnings = '' + suggestions = feedback.get('suggestions', []) + self._password_suggestions = warnings + ' '.join(suggestions) + else: + self.password_strength = 0 + self._password_suggestions = 'The password cannot be empty.' + + self._password_strength_error = (self.password_strength < 3) + + def _check_password_match(self, password): + """ Check whether the passwords match. """ + self._password_match_error = (not password or password != self.password) + + # Trait change handlers -------------------------------------------------- + + @on_trait_change("+view_error") + def _view_error_updated(self, new_error): + """ One of the error traits changed: update the error count. """ + if self.info and self.info.ui: + if new_error: + self.info.ui.errors += 1 + else: + self.info.ui.errors -= 1 + + # Traits property handlers ----------------------------------------------- + + @cached_property + def _get_password_strength_icon(self): + """ Get the icon for password strength. """ + return strength_map[self.password_strength] + + @cached_property + def _get_explanation(self): + """ Get the explanatory HTML. """ + text = '' + if self._username_error: + text += 'The username cannot be empty. ' + if self._password_match_error: + text += 'The passwords must match. ' + if self._password_suggestions: + text += self._password_suggestions + if not text: + text = ("The username is valid, the password is strong and both " + + "password fields match.") + return explanation_template.format(css=css, text=text) + + # TraitsUI view ---------------------------------------------------------- + + view = View( + VGroup( + Item('explanation',show_label=False), + VGrid( + Item( + 'model.username', + tooltip='The username to use when logging in.', + editor=TextEditor(invalid='_username_error') + ), + Item( + 'username_icon', + editor=ImageEditor(), + show_label=False, + visible_when='_username_error', + tooltip='User name must not be empty.', + ), + Item( + 'model.password', + tooltip='The password to use when logging in.', + editor=TextEditor( + invalid='_password_strength_error', + password=True, + ) + ), + Item( + 'password_strength_icon', + editor=ImageEditor(), + show_label=False, + ), + Item( + 'password', + label='Re-enter Password:', + tooltip='Enter the password a second time.', + editor=TextEditor( + invalid='_password_match_error', + password=True, + ) + ), + Item( + 'password_match_icon', + editor=ImageEditor(), + show_label=False, + visible_when='_password_match_error', + tooltip='Passwords must match.', + ), + columns=2, + show_border=True, + ), + ), + title='Create User Account', + buttons=OKCancelButtons, + width=480, + height=280, + ) + + +class MainApp(HasStrictTraits): + """ A dummy main app to show the demo. """ + + #: Information about the example. + information = HTML() + + #: Information about the example. + credentials = Instance(Credentials, ()) + + def _information_default(self): + return """ + + + + + +

Validating Dialog Example

+ +

This example shows how to dynamically validate a user's entries in a + TraitsUI dialog. The example shows four things:

+ + + + """.format(css=css) + + +class MainAppHandler(Handler): + """ A handler to invoke the dialog. """ + + def create_account(self, ui_info): + + credentials = Credentials(username='alice') + modelview = NewAccountView(model=credentials) + success = modelview.edit_traits(kind='livemodal') + print("Logged in:", success) + print("Username:", credentials.username) + print("Password:", credentials.password) + + if success: + ui_info.object.credentials = credentials + + def create_feedback_dialog(self, ui_info): + + img_bytes, img_h, img_w = get_raw_qimg_data( + take_screenshot_qimg(ui_info)) + + msg = FeedbackMessage(img_bytes=img_bytes, img_h=img_h, + img_w=img_w, channels='#general') + + msg_controller = FeedbackController(model=msg) + msg_controller.configure_traits() + + + +#: A view for the main app which displays an explanation and the username. +app_view = View( + Item('information', style='readonly', show_label=False), + HGroup( + Item('object.credentials.username', style='readonly') + ), + menubar=MenuBar( + Menu( + Action(name='Create Account', action='create_account'), + name='File', + ), + Menu( + Action(name='Feedback/Bugs', action='create_feedback_dialog'), + name='Help',), + ), + buttons=[ + Action(name='Create Account', action='create_account'), + 'OK', + ], + width=480, + height=320, + resizable=True, +) + +if __name__ == '__main__': + app = MainApp() + app.configure_traits(view=app_view, handler=MainAppHandler()) From faf06fc46f99ce5a0f1f2655fe9f388f044ab803 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Thu, 13 Jun 2019 16:04:58 -0500 Subject: [PATCH 02/30] Don't use relative imports --- apptools/feedback/validating_dialog_with_feedback.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apptools/feedback/validating_dialog_with_feedback.py b/apptools/feedback/validating_dialog_with_feedback.py index b4b76f53d..33bd23aaa 100644 --- a/apptools/feedback/validating_dialog_with_feedback.py +++ b/apptools/feedback/validating_dialog_with_feedback.py @@ -70,9 +70,10 @@ def test_strength(password): from traitsui.wx.constants import WindowColor background_color = '#{0:x}{1:x}{2:x}'.format(*WindowColor.GetRGB()) -from feedbackbot.model import FeedbackMessage -from feedbackbot.view import FeedbackController -from feedbackbot.utils import take_screenshot_qimg, get_raw_qimg_data +from apptools.feedback.feedbackbot.model import FeedbackMessage +from apptools.feedback.feedbackbot.view import FeedbackController +from apptools.feedback.feedbackbot.utils import take_screenshot_qimg, \ + get_raw_qimg_data #: A map of password strength values to icons. strength_map = { From 644b52a299099f37a44cb4206867c0088a3a9c2b Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Thu, 13 Jun 2019 16:54:39 -0500 Subject: [PATCH 03/30] Specifying bot OAuth token should be user-developer's concern --- apptools/feedback/feedbackbot/model.py | 13 +++++-------- .../feedback/validating_dialog_with_feedback.py | 5 ++++- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py index fe2e31432..10ea4d45d 100644 --- a/apptools/feedback/feedbackbot/model.py +++ b/apptools/feedback/feedbackbot/model.py @@ -3,7 +3,6 @@ for sending messages to a developer team's slack channel. """ -import os import io import numpy as np @@ -24,8 +23,6 @@ class FeedbackMessage(HasTraits): The user-developer must specify the slack channel that the message must be sent to, as well as provide raw screenshot data. - TODO: - Add service that reveals OAUTH token. """ first_name = Str(msg_meta=True) @@ -43,6 +40,9 @@ class FeedbackMessage(HasTraits): #: The target slack channel that the bot will post to, must start with #. channels = String(minlen=2, regex='#.*') + #: OAuth token for the slackbot, must be provided by the user-developer. + token = Str + #: The final slack message that will be posted. msg = Str @@ -93,11 +93,8 @@ def _update_msg(self): def send(self): """ Send feedback message and screenshot to slack. """ - # TODO: OAuth token should be revealed after client - # user credentials are presented - # Initialize slack client - client = slack.WebClient(token=os.environ['FEEDBACKBOT_OAUTH_TOKEN'], - timeout=10, + client = slack.WebClient(token=self.token, + timeout=5, ssl=True) # Compress image into PNG format using an in-memory buffer. diff --git a/apptools/feedback/validating_dialog_with_feedback.py b/apptools/feedback/validating_dialog_with_feedback.py index 33bd23aaa..1a7586a95 100644 --- a/apptools/feedback/validating_dialog_with_feedback.py +++ b/apptools/feedback/validating_dialog_with_feedback.py @@ -21,6 +21,8 @@ """ +import os + try: from zxcvbn import zxcvbn as test_strength except ImportError: @@ -426,7 +428,8 @@ def create_feedback_dialog(self, ui_info): take_screenshot_qimg(ui_info)) msg = FeedbackMessage(img_bytes=img_bytes, img_h=img_h, - img_w=img_w, channels='#general') + img_w=img_w, channels='#general', + token=os.environ['FEEDBACKBOT_OAUTH_TOKEN']) msg_controller = FeedbackController(model=msg) msg_controller.configure_traits() From 9dc512203214eb9f35dc68409c8cf05faf072480 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Thu, 13 Jun 2019 18:09:35 -0500 Subject: [PATCH 04/30] Unify image interface --- apptools/feedback/feedbackbot/model.py | 28 +------- apptools/feedback/feedbackbot/utils.py | 65 ++++++++++++++++++- apptools/feedback/feedbackbot/view.py | 9 ++- .../validating_dialog_with_feedback.py | 11 ++-- 4 files changed, 80 insertions(+), 33 deletions(-) diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py index 10ea4d45d..280c68611 100644 --- a/apptools/feedback/feedbackbot/model.py +++ b/apptools/feedback/feedbackbot/model.py @@ -46,38 +46,15 @@ class FeedbackMessage(HasTraits): #: The final slack message that will be posted. msg = Str - #: The screenshot pixel data in raw bytes. + #: The screenshot pixel data in raw bytes. Note that RGB[A] ordering is assumed. img_bytes = Bytes - #: The screenshot RGB data as a numeric array. - img_data = Property(Array) - #: The screenshot width in pixels. img_w = Int #: The screenshot height in pixels. img_h = Int - @cached_property - def _get_img_data(self): - """ Compute RGB data from raw image bytes. - - Returns - ------- - numpy.ndarray - RGB values in a numpy ndarray of shape - (self.img_h, self.img_w, 3). - - """ - - # TODO: assume RGB ordering and provide helper functions to change the - # order if necessary - # [2::-1] is required to order the channels as RGB - return np.frombuffer( - self.img_bytes, dtype=np.uint8).reshape(( - self.img_h, - self.img_w, -1))[..., 2::-1] - @on_trait_change('+msg_meta') def _update_msg(self): @@ -98,7 +75,8 @@ def send(self): ssl=True) # Compress image into PNG format using an in-memory buffer. - img = Image.fromarray(self.img_data, mode='RGB') + #img = Image.fromarray(self.img_data, mode='RGB') + img = Image.frombytes('RGBA', (self.img_w, self.img_h), self.img_bytes) buf = io.BytesIO() diff --git a/apptools/feedback/feedbackbot/utils.py b/apptools/feedback/feedbackbot/utils.py index 971764546..bdf4581e5 100644 --- a/apptools/feedback/feedbackbot/utils.py +++ b/apptools/feedback/feedbackbot/utils.py @@ -5,6 +5,7 @@ """ from PyQt4.QtGui import QPixmap +import numpy as np def take_screenshot_qimg(info): """ Take screenshot of an active GUI widget. @@ -17,7 +18,7 @@ def take_screenshot_qimg(info): Returns: -------- qimg: PyQt4.QtGui.QImage - Screenshot image as PyQt4.QtGui.Qimage instance. + Screenshot image as PyQt4.QtGui.QImage instance. """ @@ -52,3 +53,65 @@ def get_raw_qimg_data(qimg): return [qbits.asstring(), qimg.height(), qimg.width()] +def bgr_bytes_to_rgb_bytes(bgr_bytes, height, width): + """ Convert BGR[A] bytestring to RGB[A] bytestring. + + Note + ---- + This function is designed to convert the BGR[A]-ordered + bytestring of a PyQt4.QtGui.QImage into RGB[A] ordering. + An alpha-channel is not necessary, but will be handled if provided. + + Parameters + ---------- + bgr_bytes: bytes + BGR[A]-ordered bytestring. + + height: int + Height of image in pixels. + + width: int + Height of image in pixels. + + Returns + ------- + bytes + RGB[A]-ordered bytes + + """ + + bgr_mat = bytes_to_matrix(bgr_bytes, height, width) + + num_channels = bgr_mat.shape[2] + + if num_channels == 3: + + new_channel_idx = [2, 1, 0] + + elif num_channels == 4: + + new_channel_idx = [2, 1, 0, 3] + + else: + + raise ValueError( + "Image has {} channels. Expected 3 or 4.".format(num_channels)) + + return bgr_mat[..., new_channel_idx].tobytes() + +def bytes_to_matrix(bytes_str, height, width): + + return np.ascontiguousarray(np.frombuffer( + bytes_str, dtype=np.uint8).reshape(height, width, -1)) + +def bytes_to_buffer(bytes_str, height, width, fmt): + + img = Image.frombytes('RGBA', (width, height), bytes_str) + + buf = io.BytesIO() + + img.save(buf, fmt) + buf.seek(0) + + return buf + diff --git a/apptools/feedback/feedbackbot/view.py b/apptools/feedback/feedbackbot/view.py index ad7070062..6e75527aa 100644 --- a/apptools/feedback/feedbackbot/view.py +++ b/apptools/feedback/feedbackbot/view.py @@ -12,8 +12,7 @@ from enable.api import ComponentEditor from .model import FeedbackMessage - - +from .utils import bytes_to_matrix # ---------------------------------------------------------------------------- # TraitsUI Actions @@ -76,7 +75,11 @@ def _screenshot_plot_default(self): """ Plots screenshot in Chaco from RGB data. """ # Reverse rows of model.img_data so that the img_plot looks right - plotdata = ArrayPlotData(img_data=self.model.img_data[::-1, ...]) + + img_data = bytes_to_matrix( + self.model.img_bytes, self.model.img_h, self.model.img_w) + + plotdata = ArrayPlotData(img_data=img_data[::-1, ...]) plot = Plot(plotdata) plot.img_plot('img_data', hide_grids=True) diff --git a/apptools/feedback/validating_dialog_with_feedback.py b/apptools/feedback/validating_dialog_with_feedback.py index 1a7586a95..710736a54 100644 --- a/apptools/feedback/validating_dialog_with_feedback.py +++ b/apptools/feedback/validating_dialog_with_feedback.py @@ -74,8 +74,8 @@ def test_strength(password): from apptools.feedback.feedbackbot.model import FeedbackMessage from apptools.feedback.feedbackbot.view import FeedbackController -from apptools.feedback.feedbackbot.utils import take_screenshot_qimg, \ - get_raw_qimg_data +from apptools.feedback.feedbackbot.utils import ( + take_screenshot_qimg, get_raw_qimg_data, bgr_bytes_to_rgb_bytes) #: A map of password strength values to icons. strength_map = { @@ -424,10 +424,13 @@ def create_account(self, ui_info): def create_feedback_dialog(self, ui_info): - img_bytes, img_h, img_w = get_raw_qimg_data( + img_bgr_bytes, img_h, img_w = get_raw_qimg_data( take_screenshot_qimg(ui_info)) - msg = FeedbackMessage(img_bytes=img_bytes, img_h=img_h, + img_rgb_bytes = bgr_bytes_to_rgb_bytes( + img_bgr_bytes, img_h, img_w) + + msg = FeedbackMessage(img_bytes=img_rgb_bytes, img_h=img_h, img_w=img_w, channels='#general', token=os.environ['FEEDBACKBOT_OAUTH_TOKEN']) From 8e9dcc8206a9bb1af31700f395dacb5d480290b7 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Fri, 14 Jun 2019 17:46:16 -0500 Subject: [PATCH 05/30] Further simplify image interface --- apptools/feedback/feedbackbot/model.py | 18 +-- apptools/feedback/feedbackbot/utils.py | 112 ++++-------------- apptools/feedback/feedbackbot/view.py | 26 +--- .../validating_dialog_with_feedback.py | 19 +-- 4 files changed, 36 insertions(+), 139 deletions(-) diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py index 280c68611..71c997cdd 100644 --- a/apptools/feedback/feedbackbot/model.py +++ b/apptools/feedback/feedbackbot/model.py @@ -9,10 +9,11 @@ import slack import aiohttp from PIL import Image + from traits.api import ( HasTraits, Str, Property, Int, Array, Bytes, String, - cached_property, on_trait_change) + Any, cached_property, on_trait_change) class FeedbackMessage(HasTraits): @@ -46,14 +47,7 @@ class FeedbackMessage(HasTraits): #: The final slack message that will be posted. msg = Str - #: The screenshot pixel data in raw bytes. Note that RGB[A] ordering is assumed. - img_bytes = Bytes - - #: The screenshot width in pixels. - img_w = Int - - #: The screenshot height in pixels. - img_h = Int + img_data = Array(shape=(None, None, 3), dtype='uint8') @on_trait_change('+msg_meta') def _update_msg(self): @@ -75,12 +69,8 @@ def send(self): ssl=True) # Compress image into PNG format using an in-memory buffer. - #img = Image.fromarray(self.img_data, mode='RGB') - img = Image.frombytes('RGBA', (self.img_w, self.img_h), self.img_bytes) - buf = io.BytesIO() - - img.save(buf, 'PNG') + Image.fromarray(self.img_data).save(buf, 'PNG') buf.seek(0) try: diff --git a/apptools/feedback/feedbackbot/utils.py b/apptools/feedback/feedbackbot/utils.py index bdf4581e5..fa53589e2 100644 --- a/apptools/feedback/feedbackbot/utils.py +++ b/apptools/feedback/feedbackbot/utils.py @@ -4,17 +4,23 @@ plugin can be used in their application. """ -from PyQt4.QtGui import QPixmap +import io + +from pyface.qt.QtGui import QPixmap +from pyface.qt.QtCore import QBuffer +from PIL import Image import numpy as np -def take_screenshot_qimg(info): +from .model import FeedbackMessage +from .view import FeedbackController + +def take_screenshot_qimage(control): """ Take screenshot of an active GUI widget. Parameters ---------- - info: traitsui.UIInfo - The UIInfo instance which contains the active widget. - + control: TODO + Returns: -------- qimg: PyQt4.QtGui.QImage @@ -22,96 +28,24 @@ def take_screenshot_qimg(info): """ - pixmap = QPixmap.grabWidget(info.ui.control) - - qimg = pixmap.toImage() - - return qimg - -def get_raw_qimg_data(qimg): - """ Get raw image data (BGR[A] values, and size in pixels). - - Parameters: - qimg: PyQt4.QtGui.Qimage instance - - Returns: - -------- - bytes - Raw bytes ordered as BGR[A]. Alpha channel is included if available. - int - Image height in pixels. - int - Image width in pixels. - - """ - - qbits = qimg.bits() - - num_channels = qimg.depth() // 8 - - qbits.setsize(qimg.width() * qimg.height() * num_channels) - - return [qbits.asstring(), qimg.height(), qimg.width()] - -def bgr_bytes_to_rgb_bytes(bgr_bytes, height, width): - """ Convert BGR[A] bytestring to RGB[A] bytestring. - - Note - ---- - This function is designed to convert the BGR[A]-ordered - bytestring of a PyQt4.QtGui.QImage into RGB[A] ordering. - An alpha-channel is not necessary, but will be handled if provided. - - Parameters - ---------- - bgr_bytes: bytes - BGR[A]-ordered bytestring. - - height: int - Height of image in pixels. - - width: int - Height of image in pixels. - - Returns - ------- - bytes - RGB[A]-ordered bytes - - """ - - bgr_mat = bytes_to_matrix(bgr_bytes, height, width) - - num_channels = bgr_mat.shape[2] - - if num_channels == 3: - - new_channel_idx = [2, 1, 0] - - elif num_channels == 4: - - new_channel_idx = [2, 1, 0, 3] - - else: - - raise ValueError( - "Image has {} channels. Expected 3 or 4.".format(num_channels)) + return QPixmap.grabWidget(control).toImage() - return bgr_mat[..., new_channel_idx].tobytes() +def qimage_to_pillow(qimg, fmt='PNG'): -def bytes_to_matrix(bytes_str, height, width): + qbuf = QBuffer() + qbuf.open(QBuffer.WriteOnly) + qimg.save(qbuf, fmt) - return np.ascontiguousarray(np.frombuffer( - bytes_str, dtype=np.uint8).reshape(height, width, -1)) + return Image.open(io.BytesIO(qbuf.data())) -def bytes_to_buffer(bytes_str, height, width, fmt): +def initiate_feedback_dialog(control, token, channels): - img = Image.frombytes('RGBA', (width, height), bytes_str) + img_data = np.array( + qimage_to_pillow(take_screenshot_qimage(control)))[..., :3] - buf = io.BytesIO() + msg = FeedbackMessage(img_data=img_data, channels=channels, token=token) - img.save(buf, fmt) - buf.seek(0) + msg_controller = FeedbackController(model=msg) + msg_controller.configure_traits() - return buf diff --git a/apptools/feedback/feedbackbot/view.py b/apptools/feedback/feedbackbot/view.py index 6e75527aa..e302febb3 100644 --- a/apptools/feedback/feedbackbot/view.py +++ b/apptools/feedback/feedbackbot/view.py @@ -3,6 +3,7 @@ clients to send feedback messages to a developer team's slack channel. """ +import numpy as np from traits.api import Property, Instance from traitsui.api import ( View, Group, Item, Action, @@ -10,9 +11,9 @@ from traitsui.menu import CancelButton from chaco.api import Plot, ArrayPlotData from enable.api import ComponentEditor +from enable.primitives.image import Image from .model import FeedbackMessage -from .utils import bytes_to_matrix # ---------------------------------------------------------------------------- # TraitsUI Actions @@ -39,7 +40,7 @@ height=200, springy=True)), Group( - Item('controller.screenshot_plot', + Item('controller.image_component', editor=ComponentEditor(), show_label=False)), orientation='horizontal'), @@ -62,8 +63,7 @@ class FeedbackController(Controller): model = Instance(FeedbackMessage) - #: Chaco plot to display the screenshot. - screenshot_plot = Instance(Plot) + image_component = Instance(Image) #: Property that decides whether the state of the message is valid # for sending. @@ -71,23 +71,9 @@ class FeedbackController(Controller): trait_view = feedback_msg_view - def _screenshot_plot_default(self): - """ Plots screenshot in Chaco from RGB data. """ + def _image_component_default(self): - # Reverse rows of model.img_data so that the img_plot looks right - - img_data = bytes_to_matrix( - self.model.img_bytes, self.model.img_h, self.model.img_w) - - plotdata = ArrayPlotData(img_data=img_data[::-1, ...]) - plot = Plot(plotdata) - plot.img_plot('img_data', hide_grids=True) - - plot.border_visible = False - plot.x_axis = None - plot.y_axis = None - - return plot + return Image(data=self.model.img_data) def _get__send_enabled(self): """ Logic to check if message is valid for sending. """ diff --git a/apptools/feedback/validating_dialog_with_feedback.py b/apptools/feedback/validating_dialog_with_feedback.py index 710736a54..32d93597e 100644 --- a/apptools/feedback/validating_dialog_with_feedback.py +++ b/apptools/feedback/validating_dialog_with_feedback.py @@ -74,8 +74,7 @@ def test_strength(password): from apptools.feedback.feedbackbot.model import FeedbackMessage from apptools.feedback.feedbackbot.view import FeedbackController -from apptools.feedback.feedbackbot.utils import ( - take_screenshot_qimg, get_raw_qimg_data, bgr_bytes_to_rgb_bytes) +from apptools.feedback.feedbackbot.utils import initiate_feedback_dialog #: A map of password strength values to icons. strength_map = { @@ -424,20 +423,8 @@ def create_account(self, ui_info): def create_feedback_dialog(self, ui_info): - img_bgr_bytes, img_h, img_w = get_raw_qimg_data( - take_screenshot_qimg(ui_info)) - - img_rgb_bytes = bgr_bytes_to_rgb_bytes( - img_bgr_bytes, img_h, img_w) - - msg = FeedbackMessage(img_bytes=img_rgb_bytes, img_h=img_h, - img_w=img_w, channels='#general', - token=os.environ['FEEDBACKBOT_OAUTH_TOKEN']) - - msg_controller = FeedbackController(model=msg) - msg_controller.configure_traits() - - + initiate_feedback_dialog(ui_info.ui.control, + os.environ['FEEDBACKBOT_OAUTH_TOKEN'], '#general') #: A view for the main app which displays an explanation and the username. app_view = View( From f2fb60b0a9a28dedece9cb208b0dde425a7ea3ac Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Mon, 17 Jun 2019 09:39:58 -0500 Subject: [PATCH 06/30] Modify import --- apptools/feedback/feedbackbot/view.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/apptools/feedback/feedbackbot/view.py b/apptools/feedback/feedbackbot/view.py index e302febb3..69d72636e 100644 --- a/apptools/feedback/feedbackbot/view.py +++ b/apptools/feedback/feedbackbot/view.py @@ -11,7 +11,7 @@ from traitsui.menu import CancelButton from chaco.api import Plot, ArrayPlotData from enable.api import ComponentEditor -from enable.primitives.image import Image +from enable.primitives.image import Image as ImageComponent from .model import FeedbackMessage @@ -63,7 +63,7 @@ class FeedbackController(Controller): model = Instance(FeedbackMessage) - image_component = Instance(Image) + image_component = Instance(ImageComponent) #: Property that decides whether the state of the message is valid # for sending. @@ -71,10 +71,12 @@ class FeedbackController(Controller): trait_view = feedback_msg_view + #TODO: this doesn't look right, CODE SMELL CODE SMELLg + # However, Property(ImageComponent) doesn't work. def _image_component_default(self): - return Image(data=self.model.img_data) - + return ImageComponent(data=self.model.img_data) + def _get__send_enabled(self): """ Logic to check if message is valid for sending. """ From fc6e14662c08eb31c54668e2c2bb73c15c06f321 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Mon, 17 Jun 2019 09:50:27 -0500 Subject: [PATCH 07/30] Use single name field and use property for message --- apptools/feedback/feedbackbot/model.py | 16 +++++++--------- apptools/feedback/feedbackbot/view.py | 8 +++----- 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py index 71c997cdd..46756c717 100644 --- a/apptools/feedback/feedbackbot/model.py +++ b/apptools/feedback/feedbackbot/model.py @@ -26,9 +26,8 @@ class FeedbackMessage(HasTraits): """ - first_name = Str(msg_meta=True) - - last_name = Str(msg_meta=True) + #: Name of the client user + name = Str(msg_meta=True) #: Name of the client organization. organization = Str(msg_meta=True) @@ -45,19 +44,18 @@ class FeedbackMessage(HasTraits): token = Str #: The final slack message that will be posted. - msg = Str + msg = Property(Str, depends_on='msg_meta') img_data = Array(shape=(None, None, 3), dtype='uint8') - @on_trait_change('+msg_meta') - def _update_msg(self): + + def _get_msg(self): - feedback_template = 'Name: {first} {last}\n' \ + feedback_template = 'Name: {name}\n' \ + 'Organization: {org}\nDescription: {desc}' self.msg = feedback_template.format( - first=self.first_name, - last=self.last_name, + name=self.name, org=self.organization, desc=self.description) diff --git a/apptools/feedback/feedbackbot/view.py b/apptools/feedback/feedbackbot/view.py index 69d72636e..354b46833 100644 --- a/apptools/feedback/feedbackbot/view.py +++ b/apptools/feedback/feedbackbot/view.py @@ -31,8 +31,7 @@ Label('Enter feedback here. All fields are mandatory.'), Group( Group( - Item('first_name'), - Item('last_name'), + Item('name'), Item('organization', tooltip='Enter the name of your organization.'), Item('description', @@ -69,10 +68,9 @@ class FeedbackController(Controller): # for sending. _send_enabled = Property(depends_on='[+msg_meta]') + # Default view for this controller. trait_view = feedback_msg_view - #TODO: this doesn't look right, CODE SMELL CODE SMELLg - # However, Property(ImageComponent) doesn't work. def _image_component_default(self): return ImageComponent(data=self.model.img_data) @@ -80,6 +78,6 @@ def _image_component_default(self): def _get__send_enabled(self): """ Logic to check if message is valid for sending. """ - return self.model.first_name and self.model.last_name \ + return self.model.name \ and self.model.organization and self.model.description From 4057d6dbc166a99d2c21e440db4541b680d18df0 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Mon, 17 Jun 2019 09:55:10 -0500 Subject: [PATCH 08/30] Fix bug in msg getter --- apptools/feedback/feedbackbot/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py index 46756c717..96d950e36 100644 --- a/apptools/feedback/feedbackbot/model.py +++ b/apptools/feedback/feedbackbot/model.py @@ -54,7 +54,7 @@ def _get_msg(self): feedback_template = 'Name: {name}\n' \ + 'Organization: {org}\nDescription: {desc}' - self.msg = feedback_template.format( + return feedback_template.format( name=self.name, org=self.organization, desc=self.description) From 2afabb7f564bb91b3ff0a383298d71581f85e38f Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Mon, 17 Jun 2019 15:34:16 -0500 Subject: [PATCH 09/30] Added comments --- apptools/feedback/feedbackbot/model.py | 5 +- apptools/feedback/feedbackbot/utils.py | 68 ++++++++++++++++++++++---- apptools/feedback/feedbackbot/view.py | 2 + 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py index 96d950e36..647eaac69 100644 --- a/apptools/feedback/feedbackbot/model.py +++ b/apptools/feedback/feedbackbot/model.py @@ -37,7 +37,8 @@ class FeedbackMessage(HasTraits): #: Main body of the feedback message. description = Str(msg_meta=True) - #: The target slack channel that the bot will post to, must start with #. + #: The target slack channel that the bot will post to, must start with # + # and must be provided by the user-developer. channels = String(minlen=2, regex='#.*') #: OAuth token for the slackbot, must be provided by the user-developer. @@ -46,8 +47,8 @@ class FeedbackMessage(HasTraits): #: The final slack message that will be posted. msg = Property(Str, depends_on='msg_meta') + #: 3D numpy array to hold three channel (RGB) screenshot pixel data. img_data = Array(shape=(None, None, 3), dtype='uint8') - def _get_msg(self): diff --git a/apptools/feedback/feedbackbot/utils.py b/apptools/feedback/feedbackbot/utils.py index fa53589e2..a9cd4e4ef 100644 --- a/apptools/feedback/feedbackbot/utils.py +++ b/apptools/feedback/feedbackbot/utils.py @@ -19,29 +19,77 @@ def take_screenshot_qimage(control): Parameters ---------- - control: TODO + control : `QtGui.QWidget` + GUI widget that will be grabbed. Returns: -------- - qimg: PyQt4.QtGui.QImage - Screenshot image as PyQt4.QtGui.QImage instance. + qimg : `QtGui.QImage` + The screenshot image. """ return QPixmap.grabWidget(control).toImage() -def qimage_to_pillow(qimg, fmt='PNG'): +def qimage_to_rgb_array(qimg): + """ Converts a `QImage` instance to a numeric RGB array containing pixel data. - qbuf = QBuffer() - qbuf.open(QBuffer.WriteOnly) - qimg.save(qbuf, fmt) + Parameters + ---------- + qimg : `QtGui.QImage` + Image to convert to array. - return Image.open(io.BytesIO(qbuf.data())) + Returns + ------- + img_array : `numpy.ndarray` + A 3D array containing pixel data in RGB format. + + Note + ---- + If an Alpha channel is present in `qimg`, it will be dropped in the array representation. + + """ + + num_channels = qimg.depth() // 8 + + qbits = qimg.bits() + qbits.setsize(qimg.height() * qimg.width() * num_channels) + + img_bytes = qbits.asstring() + + img_array = np.ascontiguousarray( + np.frombuffer(img_bytes, dtype=np.uint8).reshape( + qimg.height(), qimg.width(), -1)[..., 2::-1]) + + return img_array def initiate_feedback_dialog(control, token, channels): + """ Initiate the feedback dialog box. + + This function grabs a screenshot of the active GUI widget + and starts up a feedback dialog box. The dialog box displays a preview of + the screenshot, and allows the client-user to enter their name, + organization, and a message. This message is then sent to + the specified Slack channel. + + Parameters + ---------- + control : `QtGui.QWidget` + GUI widget whose screenshot will be taken. + + token : str + Slack API authentication token. + + channels : list + List of channels where the message will be sent. + + Note + ---- + The authentication `token` must bear the required scopes, i.e., it must have + permissions to send messages to the specified channels. - img_data = np.array( - qimage_to_pillow(take_screenshot_qimage(control)))[..., :3] + """ + img_data = qimage_to_rgb_array(take_screenshot_qimage(control)) msg = FeedbackMessage(img_data=img_data, channels=channels, token=token) diff --git a/apptools/feedback/feedbackbot/view.py b/apptools/feedback/feedbackbot/view.py index 354b46833..8abfc7c60 100644 --- a/apptools/feedback/feedbackbot/view.py +++ b/apptools/feedback/feedbackbot/view.py @@ -60,8 +60,10 @@ class FeedbackController(Controller): """ + #: The underlying model. model = Instance(FeedbackMessage) + #: Enable component to store the screenshot. image_component = Instance(ImageComponent) #: Property that decides whether the state of the message is valid From b94d4891f417c69c888af904b46bfe1b89a71801 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Tue, 18 Jun 2019 09:36:02 -0500 Subject: [PATCH 10/30] Added comment regarding asynchronous functionality. --- apptools/feedback/feedbackbot/model.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py index 647eaac69..5859a0729 100644 --- a/apptools/feedback/feedbackbot/model.py +++ b/apptools/feedback/feedbackbot/model.py @@ -63,9 +63,15 @@ def _get_msg(self): def send(self): """ Send feedback message and screenshot to slack. """ + # Set up object that talks to Slack's API. Note that the run_async + # flag is False. This ensures that each HTTP request is blocking. More + # precisely, the WebClient sets up an event loop with just a single + # HTTP request in it, and ensures that the event loop runs to + # completion before returning. client = slack.WebClient(token=self.token, timeout=5, - ssl=True) + ssl=True, + run_async=False) # Compress image into PNG format using an in-memory buffer. buf = io.BytesIO() From b6e7b01e8e820c4a87654e40abc4028a38196d99 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Wed, 19 Jun 2019 11:26:38 -0500 Subject: [PATCH 11/30] Refactor send function --- apptools/feedback/feedbackbot/model.py | 36 +++++++------------------- 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py index 5859a0729..a45728e27 100644 --- a/apptools/feedback/feedbackbot/model.py +++ b/apptools/feedback/feedbackbot/model.py @@ -71,38 +71,20 @@ def send(self): client = slack.WebClient(token=self.token, timeout=5, ssl=True, - run_async=False) + run_async=False) # Compress image into PNG format using an in-memory buffer. buf = io.BytesIO() Image.fromarray(self.img_data).save(buf, 'PNG') buf.seek(0) - try: + # Send message. + response = client.files_upload( + channels=self.channels, + initial_comment=self.msg, + filetype='png', + filename='screenshot.png', + file=buf) - # Send message. - response = client.files_upload( - channels=self.channels, - initial_comment=self.msg, - filetype='png', - filename='screenshot.png', - file=buf) + return response - except slack.errors.SlackApiError as error: - - print( - 'Message sent successfully,' - + ' but received the following error from Slack:') - print(error) - raise - - except aiohttp.client_exceptions.ClientConnectorError as error: - - print('Message not sent.') - print(error) - raise - - else: - - print('Message sent successfully!') - From 82bd9dc879b9e17ea26afc7c097913c6990e1658 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Wed, 19 Jun 2019 11:27:11 -0500 Subject: [PATCH 12/30] Better error handling and notification dialogs in controller. --- apptools/feedback/feedbackbot/view.py | 76 ++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/apptools/feedback/feedbackbot/view.py b/apptools/feedback/feedbackbot/view.py index 8abfc7c60..21aa816d7 100644 --- a/apptools/feedback/feedbackbot/view.py +++ b/apptools/feedback/feedbackbot/view.py @@ -3,7 +3,12 @@ clients to send feedback messages to a developer team's slack channel. """ +import sys +import traceback + +import slack import numpy as np +import aiohttp from traits.api import Property, Instance from traitsui.api import ( View, Group, Item, Action, @@ -12,6 +17,7 @@ from chaco.api import Plot, ArrayPlotData from enable.api import ComponentEditor from enable.primitives.image import Image as ImageComponent +from pyface.api import confirm, information, warning, error, YES, NO from .model import FeedbackMessage @@ -19,7 +25,7 @@ # TraitsUI Actions # ---------------------------------------------------------------------------- -send_button = Action(name='Send', action='send', +send_button = Action(name='Send', action='_do_send', enabled_when='controller._send_enabled') # ---------------------------------------------------------------------------- @@ -74,6 +80,7 @@ class FeedbackController(Controller): trait_view = feedback_msg_view def _image_component_default(self): + """ Default image to display, this is simply the screenshot.""" return ImageComponent(data=self.model.img_data) @@ -83,3 +90,70 @@ def _get__send_enabled(self): return self.model.name \ and self.model.organization and self.model.description + def _do_send(self, ui_info): + + # Variable to store whether to let the client-user try again if Slack rate limits + # the bot, or if the request takes too long. + retry = False + + try: + + response = self.model.send() + + except slack.errors.SlackApiError as exc: + + # Allow the client-user to try again if rate limited by Slack, + # or if the HTTP request to Slack takes too long. The rate + # limit for this API call is around 20 requests per workspace per + # minute. It is unlikely that this will happen, but no harm in + # handling it. + if exc.response["error"] == "ratelimited": + + retry_time = exc.response.headers["retry-after"] + + err_msg = "Server received too many requests." \ + + " Please try again after {} seconds.".format(retry_time) + + retry = True + + else: + + err_msg = 'Message sent successfully, but received an error' \ + + ' response from Slack.' + + error(ui_info.ui.control, err_msg, detail=str(exc)) + + except aiohttp.ServerTimeoutError as exc: + + err_msg = 'Server took too long to respond. Please try again later.' + + error(ui_info.ui.control, err_msg, detail=str(exc)) + + retry = True + + except aiohttp.ClientConnectionError as exc: + + err_msg = 'An error occured while connecting to the server.' + + error(ui_info.ui.control, err_msg, detail=str(exc)) + + except Exception as exc: + + err_msg = 'Unexpected error: {}'.format(str(exc)) + + detail = ' '.join(traceback.format_tb(exc.__traceback__)) + + error(ui_info.ui.control, err_msg, detail=detail) + + else: + + success_msg = 'Message sent successfully.' + + information(ui_info.ui.control, success_msg) + + finally: + + # Kill the GUI if the user will not retry. + if not retry: + ui_info.ui.dispose() + From 40bcab84a15d9b84604a8255880002a884f792f9 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Thu, 20 Jun 2019 13:45:47 -0500 Subject: [PATCH 13/30] Refactor logic and introduce logging. --- .../example.py} | 0 apptools/feedback/feedbackbot/model.py | 24 +++++-- apptools/feedback/feedbackbot/utils.py | 27 +++++-- apptools/feedback/feedbackbot/view.py | 72 ++++++++++++++----- 4 files changed, 96 insertions(+), 27 deletions(-) rename apptools/feedback/{validating_dialog_with_feedback.py => examples/example.py} (100%) diff --git a/apptools/feedback/validating_dialog_with_feedback.py b/apptools/feedback/examples/example.py similarity index 100% rename from apptools/feedback/validating_dialog_with_feedback.py rename to apptools/feedback/examples/example.py diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py index a45728e27..425804bd8 100644 --- a/apptools/feedback/feedbackbot/model.py +++ b/apptools/feedback/feedbackbot/model.py @@ -4,6 +4,7 @@ """ import io +import logging import numpy as np import slack @@ -15,9 +16,10 @@ Int, Array, Bytes, String, Any, cached_property, on_trait_change) +logger = logging.getLogger(__name__) class FeedbackMessage(HasTraits): - """Model for the feedback message. + """ Model for the feedback message. Notes ----- @@ -49,6 +51,9 @@ class FeedbackMessage(HasTraits): #: 3D numpy array to hold three channel (RGB) screenshot pixel data. img_data = Array(shape=(None, None, 3), dtype='uint8') + + #: In-memory file buffer to store the compressed screenshot. + compressed_img_buf = Any(io.BytesIO()) def _get_msg(self): @@ -60,6 +65,11 @@ def _get_msg(self): org=self.organization, desc=self.description) + def _img_data_changed(self): + + Image.fromarray(self.img_data).save(self.compressed_img_buf, 'PNG') + self.compressed_img_buf.seek(0) + def send(self): """ Send feedback message and screenshot to slack. """ @@ -73,10 +83,8 @@ def send(self): ssl=True, run_async=False) - # Compress image into PNG format using an in-memory buffer. - buf = io.BytesIO() - Image.fromarray(self.img_data).save(buf, 'PNG') - buf.seek(0) + logger.info("Attempting to send message: <%s> to channel: <%s>", + self.msg, self.channels) # Send message. response = client.files_upload( @@ -84,7 +92,11 @@ def send(self): initial_comment=self.msg, filetype='png', filename='screenshot.png', - file=buf) + file=self.compressed_img_buf) + + + logger.info("Message sent." + + " Slack responded with OK : {ok_resp}".format(ok_resp=response['ok'])) return response diff --git a/apptools/feedback/feedbackbot/utils.py b/apptools/feedback/feedbackbot/utils.py index a9cd4e4ef..e4d92ef21 100644 --- a/apptools/feedback/feedbackbot/utils.py +++ b/apptools/feedback/feedbackbot/utils.py @@ -5,6 +5,7 @@ """ import io +import logging from pyface.qt.QtGui import QPixmap from pyface.qt.QtCore import QBuffer @@ -14,6 +15,8 @@ from .model import FeedbackMessage from .view import FeedbackController +logger = logging.getLogger(__name__) + def take_screenshot_qimage(control): """ Take screenshot of an active GUI widget. @@ -27,9 +30,15 @@ def take_screenshot_qimage(control): qimg : `QtGui.QImage` The screenshot image. - """ + """ + + logger.info('Grabbing screenshot of control' + + ' with id <%s> and title <%s>.', + str(control.winId()), control.windowTitle()) + + qpixmap = QPixmap.grabWidget(control).toImage() - return QPixmap.grabWidget(control).toImage() + return qpixmap def qimage_to_rgb_array(qimg): """ Converts a `QImage` instance to a numeric RGB array containing pixel data. @@ -46,7 +55,8 @@ def qimage_to_rgb_array(qimg): Note ---- - If an Alpha channel is present in `qimg`, it will be dropped in the array representation. + If an Alpha channel is present in `qimg`, it will be dropped in the + array representation. """ @@ -57,6 +67,8 @@ def qimage_to_rgb_array(qimg): img_bytes = qbits.asstring() + logger.info('Converting raw screenshot bytes to RGB array.') + img_array = np.ascontiguousarray( np.frombuffer(img_bytes, dtype=np.uint8).reshape( qimg.height(), qimg.width(), -1)[..., 2::-1]) @@ -89,11 +101,16 @@ def initiate_feedback_dialog(control, token, channels): permissions to send messages to the specified channels. """ + + logger.info('Feedback dialog requested on control' + + ' with id <%s> and title <%s>', + str(control.winId()), control.windowTitle()) + img_data = qimage_to_rgb_array(take_screenshot_qimage(control)) msg = FeedbackMessage(img_data=img_data, channels=channels, token=token) msg_controller = FeedbackController(model=msg) - msg_controller.configure_traits() - + logger.info('Launching feedback dialog box.') + msg_controller.configure_traits(kind='livemodal') diff --git a/apptools/feedback/feedbackbot/view.py b/apptools/feedback/feedbackbot/view.py index 21aa816d7..bbe8a3c68 100644 --- a/apptools/feedback/feedbackbot/view.py +++ b/apptools/feedback/feedbackbot/view.py @@ -1,18 +1,20 @@ """ -This model implements UI classes and logic for a plugin that enables +This module implements UI classes and logic for a plugin that enables clients to send feedback messages to a developer team's slack channel. """ import sys +import logging import traceback import slack import numpy as np import aiohttp + from traits.api import Property, Instance from traitsui.api import ( View, Group, Item, Action, - Label, Controller) + Label, Controller, Handler) from traitsui.menu import CancelButton from chaco.api import Plot, ArrayPlotData from enable.api import ComponentEditor @@ -21,6 +23,8 @@ from .model import FeedbackMessage +logger = logging.getLogger(__name__) + # ---------------------------------------------------------------------------- # TraitsUI Actions # ---------------------------------------------------------------------------- @@ -51,7 +55,8 @@ orientation='horizontal'), buttons=[CancelButton, send_button], width=800, - resizable=True) + resizable=True, + title='Feedback Reporter') # ---------------------------------------------------------------------------- @@ -91,9 +96,19 @@ def _get__send_enabled(self): and self.model.organization and self.model.description def _do_send(self, ui_info): - - # Variable to store whether to let the client-user try again if Slack rate limits - # the bot, or if the request takes too long. + """ Actions to perform when the send button is clicked. """ + + logger.info('Send button clicked in feedback dialog box.') + + # Boolean that specifies whether the client-user can try again or not. + # If False, then the feedback dialog box is automatically closed. + # If True, the feedback dialog is kept alive. A possible use case could + # arise when the request to the Slack API takes too long (in which case + # an aiohttp.ServerTimeoutError is raised). In that case, notify the + # user of the error, but keep the dialog box alive. This way, the data + # that the client-user enters persists, allowing them to try sending it + # again without more typing. The other possible use case is when Slack + # rate-limits the app. retry = False try: @@ -102,26 +117,39 @@ def _do_send(self, ui_info): except slack.errors.SlackApiError as exc: - # Allow the client-user to try again if rate limited by Slack, - # or if the HTTP request to Slack takes too long. The rate - # limit for this API call is around 20 requests per workspace per - # minute. It is unlikely that this will happen, but no harm in - # handling it. if exc.response["error"] == "ratelimited": + # Slack has rate-limited the bot. + # The rate limit for this API call is around 20 requests per + # workspace per minute. It is unlikely that this will happen, + # but no harm in handling it. - retry_time = exc.response.headers["retry-after"] + # Slack promises to return a retry-after value in seconds in + # the response headers. + retry_time = exc.response.headers["retry-after"] - err_msg = "Server received too many requests." \ - + " Please try again after {} seconds.".format(retry_time) + err_msg = "Server received too many requests." \ + + " Please try again after {} seconds.".format(retry_time) - retry = True + # Allow the user the opportunity to retry the send operation. + retry = True else: + # All other Slack API errors (invalid_auth, invalid_channel, + # etc.) err_msg = 'Message sent successfully, but received an error' \ + ' response from Slack.' - error(ui_info.ui.control, err_msg, detail=str(exc)) + # Construct the detail message explicitly instead of simply calling + # str(exc), which in some cases can reveal the OAuth token. + detail = 'Slack response: '.format( + ok_resp=exc.response['ok'], err_resp=exc.response['error']) + + error(ui_info.ui.control, err_msg, detail=detail) + + # For the same reason (str(exc) can reveal the OAuth token) + # use logger.error instead of logger.exception + logger.error(err_msg + ' ' + detail) except aiohttp.ServerTimeoutError as exc: @@ -131,13 +159,20 @@ def _do_send(self, ui_info): retry = True + logger.exception(err_msg) + except aiohttp.ClientConnectionError as exc: + # Handle all client-related connection errors raised by aiohttp + # here. err_msg = 'An error occured while connecting to the server.' error(ui_info.ui.control, err_msg, detail=str(exc)) + logger.exception(err_msg) + except Exception as exc: + # Handle all other exceptions here. err_msg = 'Unexpected error: {}'.format(str(exc)) @@ -145,6 +180,8 @@ def _do_send(self, ui_info): error(ui_info.ui.control, err_msg, detail=detail) + logger.exception(err_msg) + else: success_msg = 'Message sent successfully.' @@ -155,5 +192,8 @@ def _do_send(self, ui_info): # Kill the GUI if the user will not retry. if not retry: + ui_info.ui.dispose() + logger.info('Feedback dialog closed automatically.') + From a5471da47db1f17704693501300753a4ee26cffa Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Thu, 20 Jun 2019 14:38:30 -0500 Subject: [PATCH 14/30] Improve comments --- apptools/feedback/examples/example.py | 495 ++++--------------------- apptools/feedback/feedbackbot/model.py | 6 +- apptools/feedback/feedbackbot/utils.py | 2 +- apptools/feedback/feedbackbot/view.py | 2 +- 4 files changed, 73 insertions(+), 432 deletions(-) diff --git a/apptools/feedback/examples/example.py b/apptools/feedback/examples/example.py index 32d93597e..89a7077fd 100644 --- a/apptools/feedback/examples/example.py +++ b/apptools/feedback/examples/example.py @@ -1,455 +1,94 @@ """ -Validating Dialog Example +Feedback Dialog Example ========================= -This example shows how to dynamically validate a user's entries in a -TraitsUI dialog. The example shows four things: - -* how to enable/disable the 'OK' dialog button by tracking various error - states and incrementing/decrementing the :py:attr:`ui.errors` count. - -* how to perform additional checks when the user clicks the 'OK' dialog - button, displaying an appropriate error message and returning control - to the dialog on failure. - -* setting an editor's 'invalid' state via a trait, which colors the - textbox background to the error color. - -* displaying feedback to the user in a number of additional ways, such as - text explanations, alert icons, and icons with varying feedback levels. - +This example shows how the feedback dialog can be included in an application. +The client-user interface is described in the +FeedbackExampleApp.client_user_explanation attribute. All other comments are +aimed at the developer interested in including the feedback dialog in their +app (see especially `feedback_example_view` and the `FeedbackExampleHandler` +class). """ import os +import logging -try: - from zxcvbn import zxcvbn as test_strength -except ImportError: - import warnings - warnings.warn("zxcvbn package not installed, using dummy strength tester") - - def test_strength(password): - """ A dummy password strength tester. """ - if password == "12345": - return { - 'score': 0, - 'feedback': { - 'suggestions': [ - "12345? Amazing, I have the same combination on my luggage" - ] - } - } - elif len(password) < 16: - return { - 'score': len(password) // 4, - 'feedback': { - 'suggestions': ["Type more characters"] - } - } - else: - return { - 'score': 4, - 'feedback': {'suggestions': []} - } - - -from traits.etsconfig.api import ETSConfig -from pyface.api import ImageResource, MessageDialog -from traits.api import ( - Bool, HasStrictTraits, HTML, Instance, Password, Property, Range, Unicode, - cached_property, on_trait_change -) +from traits.api import HasTraits, Str, Instance from traitsui.api import ( - Action, Handler, HGroup, Image, ImageEditor, Item, Menu, MenuBar, - ModelView, OKCancelButtons, TextEditor, VGrid, VGroup, View + Item, Menu, MenuBar, OKCancelButtons, View, Action, Handler ) -if ETSConfig.toolkit in {'qt4', 'qt'}: - from traitsui.qt4.constants import WindowColor - background_color = '#{0:x}{1:x}{2:x}'.format(*WindowColor.getRgb()) -elif ETSConfig.toolkit == 'wx': - from traitsui.wx.constants import WindowColor - background_color = '#{0:x}{1:x}{2:x}'.format(*WindowColor.GetRGB()) - from apptools.feedback.feedbackbot.model import FeedbackMessage from apptools.feedback.feedbackbot.view import FeedbackController -from apptools.feedback.feedbackbot.utils import initiate_feedback_dialog - -#: A map of password strength values to icons. -strength_map = { - i: ImageResource('squares_{}'.format(i + 1)) - for i in range(5) -} - - -#: Enough CSS so it looks like it at least belongs in this millenium -css = """ -* {{ - background-color: {background_color}; -}} -h1 {{ - font-family: "Open Sans", "Ariel", sans; - font-size: 16px; - font-weight: bold; -}} -p {{ - font-family: "Open Sans", "Ariel", sans; - font-size: 12px; -}} -""".format(background_color=background_color) - - -#: A simple HTML template to give feedback. -explanation_template = """ - - - - - -

Enter your username and password.

- -

{text}

- - -""" - - -class Credentials(HasStrictTraits): - """ A class that holds a user's credentials. - """ - - #: The user's id. - username = Unicode - - #: The user's password. - password = Password - - def login(self): - """ Dummy login method. """ - if self.password == '12345': - return True, 'Amazing, I have the same combination on my luggage!' - else: - return False, 'Incorrect password or unknown user.' - - def create_account(self): - """ Dummy account creation method. """ - if self.username in {'alice', 'bob'}: - return False, "Username already exists." - return True, 'Account created' - - -class NewAccountView(ModelView): - """ Account creation dialog example. - """ - - #: Text explaining the dialog. - explanation = Property(HTML, depends_on=['_password_suggestions', - '+view_error']) - - #: The user's password entered a second time. - password = Password - - #: The user's password strength. - password_strength = Range(0, 4) - - #: Alert icon for username error. - password_strength_icon = Property(Image, depends_on='password_strength') - - #: Alert icon for username error. - username_icon = Image('@std:alert16') - - #: Alert icon for second password error. - password_match_icon = Image('@std:alert16') - - # private traits --------------------------------------------------------- - - #: The suggestions for a stronger password. - _password_suggestions = Unicode - - #: Whether there is anything entered for the username. - _username_error = Bool(False, view_error=True) - - #: Whether the password is strong enough. - _password_strength_error = Bool(False, view_error=True) - - #: Whether the two entered passwords match. - _password_match_error = Bool(False, view_error=True) - - # ------------------------------------------------------------------------ - # Handler interface - # ------------------------------------------------------------------------ - - def init(self, info): - """ Initialize the error state of the object. """ - obj = info.object - model = info.model - - # check for initial error states - obj._check_username(model.username) - obj._check_password_strength(model.password) - obj._check_password_match(model.password) - - super(NewAccountView, self).init(info) - - def close(self, info, is_ok): - """ Handles the user attempting to close the dialog. - - If it is via the OK dialog button, try to create an account before - closing. If this fails, display an error message and veto the close - by returning false. - """ - if is_ok: - success, message = info.model.create_account() - if not success: - dlg = MessageDialog( - message="Cannot create account", - informative=message, - severity='error' - ) - dlg.open() - return False +from apptools.feedback.feedbackbot.utils import initiate_feedback_dialog_ - return True +class FeedbackExampleApp(HasTraits): + """ A simple model to demonstrate the feedback dialog.""" - # UI change handlers ----------------------------------------------------- + #: This attribute explains the client-user interface. + client_user_explanation = Str - def model_username_changed(self, ui_info): - """ Set error condition if the model's username is empty. """ - if ui_info.initialized: - ui_info.object._username_error = (ui_info.model.username == '') + def _client_user_explanation_default(self): - def model_password_changed(self, ui_info): - """ Check the quality of the password that the user entered. """ - if ui_info.initialized: - obj = ui_info.object - password = ui_info.model.password - - obj._check_password_strength(password) - obj._check_password_match(password) - - def object_password_changed(self, ui_info): - """ Check if the re-enteredpassword matches the original. """ - if ui_info.initialized: - obj = ui_info.object - password = ui_info.model.password - - obj._check_password_match(password) - - # ------------------------------------------------------------------------ - # private interface - # ------------------------------------------------------------------------ - - def _check_username(self, username): - """ Check whether the passwords match. """ - self._username_error = (username == '') - - def _check_password_strength(self, password): - """ Check the strength of the password - - This sets the password strength, suggestions for making a better - password and an error state if the password is not strong enough. - """ - if password: - password_check = test_strength(password) - self.password_strength = password_check['score'] - feedback = password_check.get('feedback', {}) - if feedback.get('warnings'): - warnings = '{} '.format(feedback['warnings']) - else: - warnings = '' - suggestions = feedback.get('suggestions', []) - self._password_suggestions = warnings + ' '.join(suggestions) - else: - self.password_strength = 0 - self._password_suggestions = 'The password cannot be empty.' - - self._password_strength_error = (self.password_strength < 3) - - def _check_password_match(self, password): - """ Check whether the passwords match. """ - self._password_match_error = (not password or password != self.password) - - # Trait change handlers -------------------------------------------------- - - @on_trait_change("+view_error") - def _view_error_updated(self, new_error): - """ One of the error traits changed: update the error count. """ - if self.info and self.info.ui: - if new_error: - self.info.ui.errors += 1 - else: - self.info.ui.errors -= 1 - - # Traits property handlers ----------------------------------------------- - - @cached_property - def _get_password_strength_icon(self): - """ Get the icon for password strength. """ - return strength_map[self.password_strength] - - @cached_property - def _get_explanation(self): - """ Get the explanatory HTML. """ - text = '' - if self._username_error: - text += 'The username cannot be empty. ' - if self._password_match_error: - text += 'The passwords must match. ' - if self._password_suggestions: - text += self._password_suggestions - if not text: - text = ("The username is valid, the password is strong and both " - + "password fields match.") - return explanation_template.format(css=css, text=text) - - # TraitsUI view ---------------------------------------------------------- - - view = View( - VGroup( - Item('explanation',show_label=False), - VGrid( - Item( - 'model.username', - tooltip='The username to use when logging in.', - editor=TextEditor(invalid='_username_error') - ), - Item( - 'username_icon', - editor=ImageEditor(), - show_label=False, - visible_when='_username_error', - tooltip='User name must not be empty.', - ), - Item( - 'model.password', - tooltip='The password to use when logging in.', - editor=TextEditor( - invalid='_password_strength_error', - password=True, - ) - ), - Item( - 'password_strength_icon', - editor=ImageEditor(), - show_label=False, - ), - Item( - 'password', - label='Re-enter Password:', - tooltip='Enter the password a second time.', - editor=TextEditor( - invalid='_password_match_error', - password=True, - ) - ), - Item( - 'password_match_icon', - editor=ImageEditor(), - show_label=False, - visible_when='_password_match_error', - tooltip='Passwords must match.', - ), - columns=2, - show_border=True, - ), - ), - title='Create User Account', - buttons=OKCancelButtons, - width=480, - height=280, - ) - - -class MainApp(HasStrictTraits): - """ A dummy main app to show the demo. """ - - #: Information about the example. - information = HTML() - - #: Information about the example. - credentials = Instance(Credentials, ()) - - def _information_default(self): return """ - - - - - -

Validating Dialog Example

- -

This example shows how to dynamically validate a user's entries in a - TraitsUI dialog. The example shows four things:

- -
    -
  • how to enable/disable the 'OK' dialog button by tracking various - error states and incrementing/decrementing the ui.errors - count.

  • - -
  • how to perform additional checks when the user clicks the 'OK' - dialog button, displaying an appropriate error message and returning - control to the dialog on failure.

  • - -
  • setting an editor's 'invalid' state via a trait, which colors the - textbox background to the error color.

  • - -
  • displaying feedback to the user in a number of additional ways, - such as text explanations, alert icons, and icons with varying - feedback levels.

  • -
- - """.format(css=css) - - -class MainAppHandler(Handler): - """ A handler to invoke the dialog. """ - - def create_account(self, ui_info): - - credentials = Credentials(username='alice') - modelview = NewAccountView(model=credentials) - success = modelview.edit_traits(kind='livemodal') - print("Logged in:", success) - print("Username:", credentials.username) - print("Password:", credentials.password) - - if success: - ui_info.object.credentials = credentials - - def create_feedback_dialog(self, ui_info): - - initiate_feedback_dialog(ui_info.ui.control, - os.environ['FEEDBACKBOT_OAUTH_TOKEN'], '#general') - -#: A view for the main app which displays an explanation and the username. -app_view = View( - Item('information', style='readonly', show_label=False), - HGroup( - Item('object.credentials.username', style='readonly') - ), + This app demonstrates how to use the feedback dialog. + + To begin, click on Feedback/Bugs in the Help menu. This will + automatically take a screenshot of this app, and launch the feedback + dialog box. You should be able to see a preview of the screenshot in the + dialog box. + + Next, enter your details, and a description of the problem. All fields + are mandatory, and you can't send the message till you type something + in each field. When you're done, click on the Send button. The dialog + is pre-configured by our developers to ensure it reaches the right team. + + The dialog will notify you of successful delivery of the message, or if + any problems occured.""" + +# View for the example app. The feedbackbot module provides a helper function +# `initiate_feedback_dialog_` that launches the feedback dialog box. To include +# the feedback dialog box in the app, simply call this function from an +# appropriate place. In this example, we call it from the Feedback/Bugs menu +# item in the Help menu. +feedback_example_view = View( + Item('explanation', style='readonly', show_label=False), menubar=MenuBar( Menu( - Action(name='Create Account', action='create_account'), - name='File', - ), - Menu( - Action(name='Feedback/Bugs', action='create_feedback_dialog'), - name='Help',), + Action(name='Feedback/Bugs', action='initiate_feedback_dialog'), + name='Help'), ), - buttons=[ - Action(name='Create Account', action='create_account'), - 'OK', - ], + buttons=OKCancelButtons, width=480, height=320, + title='Example App', resizable=True, ) +class FeedbackExampleHandler(Handler): + """ Simple handler for the FeedbackExampleApp. """ + + def initiate_feedback_dialog(self, ui_info): + """ Initiates the feedback dialog. """ + + # As mentioned earlier, the feedback dialog can be initiated by + # invoking the `initiate_feedback_dialog_` function. The first argument + # to this function is the control object whose screenshot will be + # grabbed. The second argument is the OAuth token for the bot (see + # the feedbackbot README for an explanation). In practice, you (the + # user-developer) will have to decide on an appropriate way to + # pass around the token (again, see the README for a discussion on what + # could go wrong if the token gets leaked.). The third argument is the + # channel where you'd like messages from this app to go. The value for + # this argument must start with '#'. + initiate_feedback_dialog_(ui_info.ui.control, + os.environ['FEEDBACKBOT_OAUTH_TOKEN'], '#general') + + if __name__ == '__main__': - app = MainApp() - app.configure_traits(view=app_view, handler=MainAppHandler()) + + app = FeedbackExampleApp() + + app.configure_traits(view=feedback_example_view, + handler=FeedbackExampleHandler()) diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py index 425804bd8..73eebb448 100644 --- a/apptools/feedback/feedbackbot/model.py +++ b/apptools/feedback/feedbackbot/model.py @@ -46,12 +46,14 @@ class FeedbackMessage(HasTraits): #: OAuth token for the slackbot, must be provided by the user-developer. token = Str - #: The final slack message that will be posted. + #: The final message that gets posted to Slack. msg = Property(Str, depends_on='msg_meta') #: 3D numpy array to hold three channel (RGB) screenshot pixel data. img_data = Array(shape=(None, None, 3), dtype='uint8') + # FIXME: Not sure if this the right way to go about initiating a + # non-Trait. #: In-memory file buffer to store the compressed screenshot. compressed_img_buf = Any(io.BytesIO()) @@ -71,7 +73,7 @@ def _img_data_changed(self): self.compressed_img_buf.seek(0) def send(self): - """ Send feedback message and screenshot to slack. """ + """ Send feedback message and screenshot to Slack. """ # Set up object that talks to Slack's API. Note that the run_async # flag is False. This ensures that each HTTP request is blocking. More diff --git a/apptools/feedback/feedbackbot/utils.py b/apptools/feedback/feedbackbot/utils.py index e4d92ef21..407b71b0f 100644 --- a/apptools/feedback/feedbackbot/utils.py +++ b/apptools/feedback/feedbackbot/utils.py @@ -75,7 +75,7 @@ def qimage_to_rgb_array(qimg): return img_array -def initiate_feedback_dialog(control, token, channels): +def initiate_feedback_dialog_(control, token, channels): """ Initiate the feedback dialog box. This function grabs a screenshot of the active GUI widget diff --git a/apptools/feedback/feedbackbot/view.py b/apptools/feedback/feedbackbot/view.py index bbe8a3c68..75e42a3ee 100644 --- a/apptools/feedback/feedbackbot/view.py +++ b/apptools/feedback/feedbackbot/view.py @@ -108,7 +108,7 @@ def _do_send(self, ui_info): # user of the error, but keep the dialog box alive. This way, the data # that the client-user enters persists, allowing them to try sending it # again without more typing. The other possible use case is when Slack - # rate-limits the app. + # rate-limits the bot. retry = False try: From 2a304a8ef97e490409604ffd9929f8e14f86b73e Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Fri, 21 Jun 2019 12:23:50 -0500 Subject: [PATCH 15/30] Use HasRequiredTraits --- apptools/feedback/feedbackbot/model.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py index 73eebb448..fbc1a2558 100644 --- a/apptools/feedback/feedbackbot/model.py +++ b/apptools/feedback/feedbackbot/model.py @@ -12,13 +12,13 @@ from PIL import Image from traits.api import ( - HasTraits, Str, Property, + HasRequiredTraits, Str, Property, Int, Array, Bytes, String, Any, cached_property, on_trait_change) logger = logging.getLogger(__name__) -class FeedbackMessage(HasTraits): +class FeedbackMessage(HasRequiredTraits): """ Model for the feedback message. Notes @@ -41,16 +41,16 @@ class FeedbackMessage(HasTraits): #: The target slack channel that the bot will post to, must start with # # and must be provided by the user-developer. - channels = String(minlen=2, regex='#.*') + channels = String(minlen=2, regex='#.*', required=True) #: OAuth token for the slackbot, must be provided by the user-developer. - token = Str + token = Str(required=True) #: The final message that gets posted to Slack. msg = Property(Str, depends_on='msg_meta') #: 3D numpy array to hold three channel (RGB) screenshot pixel data. - img_data = Array(shape=(None, None, 3), dtype='uint8') + img_data = Array(shape=(None, None, 3), dtype='uint8', required=True) # FIXME: Not sure if this the right way to go about initiating a # non-Trait. @@ -68,7 +68,7 @@ def _get_msg(self): desc=self.description) def _img_data_changed(self): - + Image.fromarray(self.img_data).save(self.compressed_img_buf, 'PNG') self.compressed_img_buf.seek(0) From beb3813a5ae4ba70dee98beb44e80ce532fcdee8 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Fri, 21 Jun 2019 15:47:16 -0500 Subject: [PATCH 16/30] Add tests. --- .../feedback/feedbackbot/tests/test_model.py | 40 +++++ .../feedback/feedbackbot/tests/test_view.py | 167 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 apptools/feedback/feedbackbot/tests/test_model.py create mode 100644 apptools/feedback/feedbackbot/tests/test_view.py diff --git a/apptools/feedback/feedbackbot/tests/test_model.py b/apptools/feedback/feedbackbot/tests/test_model.py new file mode 100644 index 000000000..a6b3f0e63 --- /dev/null +++ b/apptools/feedback/feedbackbot/tests/test_model.py @@ -0,0 +1,40 @@ +""" +Tests for FeedbackMessage model. +""" + +import numpy as np +import unittest + +from traits.api import TraitError +from traits.testing.unittest_tools import UnittestTools + +from apptools.feedback.feedbackbot.model import FeedbackMessage + +class TestFeedbackMessage(unittest.TestCase, UnittestTools): + + def test_invalid_img_data_raises_error(self): + """ Test that setting img_data to an incorrectly shaped array + raises a TraitError. + + """ + + with self.assertRaises(TraitError): + + msg = FeedbackMessage(img_data=np.empty((2,2)), + token='xoxb-123456', channels='#general') + + def test_invalid_channel_raises_error(self): + """ Test that passing a channel name that doesn't begin with + '#' raises TraitError. + + """ + + with self.assertRaises(TraitError): + + msg = FeedbackMessage(img_data=np.empty((1,1,3)), + token='xoxb-123456', channels='general') + + +if __name__ == '__main__': + + unittest.main() diff --git a/apptools/feedback/feedbackbot/tests/test_view.py b/apptools/feedback/feedbackbot/tests/test_view.py new file mode 100644 index 000000000..d9c0a2482 --- /dev/null +++ b/apptools/feedback/feedbackbot/tests/test_view.py @@ -0,0 +1,167 @@ +""" +Tests for FeedbackController. +""" + +from unittest import TestCase +from unittest.mock import patch, MagicMock, create_autospec +import numpy as np +from aiohttp import ClientConnectionError, ServerTimeoutError +from slack.errors import SlackApiError + +from pyface.api import information, error +from traits.testing.unittest_tools import UnittestTools +from traitsui.api import UI, UIInfo + +from apptools.feedback.feedbackbot.model import FeedbackMessage +from apptools.feedback.feedbackbot.view import FeedbackController + +class TestFeedbackController(TestCase, UnittestTools): + + def setUp(self): + + self.msg = FeedbackMessage(img_data=np.empty((2,2,3)), + channels='#dummy', token='xoxb-123456') + + self.msg.name = 'dummy_name' + self.msg.organization = 'dummy_org' + self.msg.description = 'dummy_desc' + + def test__send_enabled_empty_name(self): + """ Test send button is disabled if name is empty. """ + + self.msg.name = '' + + controller_no_name = FeedbackController(model=self.msg) + + self.assertFalse(controller_no_name._send_enabled) + + def test__send_enabled_empty_organization(self): + """ Test send button is disabled if organization is empty. """ + + self.msg.organization = '' + + controller_no_organization = FeedbackController(model=self.msg) + + self.assertFalse(controller_no_organization._send_enabled) + + def test__send_enabled_empty_description(self): + """ Test send button is disabled if description is empty. """ + + self.msg.description = '' + + controller_no_description = FeedbackController(model=self.msg) + + self.assertFalse(controller_no_description._send_enabled) + + def test_error_dialog_is_opened_if_slack_ratelimited_exception_occurs(self): + """ Test that an error dialog box is opened if a SlackApiError occurs + as a result of being rate-limited. + + """ + + # Patch the slack.web.slack_response.SlackResponse object. If + # a rate-limited error occurs, Slack promises it will return + # a retry-after header in the respones, so add that to the mock + # instance as well. + dummy_response = MagicMock( + data=patch.dict({'ok': False, 'error': 'ratelimited'}), + headers={'retry-after': 10}) + + self.msg.__dict__['send'] = MagicMock( + side_effect=SlackApiError('msg', dummy_response)) + + controller = FeedbackController(model=self.msg) + + with patch('apptools.feedback.feedbackbot.view.error') as mock_error: + + controller._do_send(create_autospec(UIInfo())) + + mock_error.assert_called_once() + + def test_error_dialog_is_opened_if_slack_exception_occurs(self): + """ Test that an error dialog box is opened if a SlackApiError occurs, + but not as a result of being rate-limited. + + """ + + dummy_response = MagicMock( + data=patch.dict({'ok': False, 'error': 'dummy_error'})) + + self.msg.__dict__['send'] = MagicMock( + side_effect=SlackApiError('msg', dummy_response)) + + controller = FeedbackController(model=self.msg) + + with patch('apptools.feedback.feedbackbot.view.error') as mock_error: + + controller._do_send(create_autospec(UIInfo())) + + mock_error.assert_called_once() + + def test_error_dialog_is_opened_if_client_connection_exception_occurs(self): + """ Test that an error dialog box is opened if a ClientConnectionError + occurs. + + """ + + self.msg.__dict__['send'] = MagicMock( + side_effect=ClientConnectionError) + + controller = FeedbackController(model=self.msg) + + with patch('apptools.feedback.feedbackbot.view.error') as mock_error: + + controller._do_send(create_autospec(UIInfo())) + + mock_error.assert_called_once() + + def test_error_dialog_is_opened_if_server_timeout_exception_occurs(self): + """ Test that an error dialog box is opened if a ServerTimeoutError + occurs. + + """ + + self.msg.__dict__['send'] = MagicMock( + side_effect=ServerTimeoutError) + + controller = FeedbackController(model=self.msg) + + with patch('apptools.feedback.feedbackbot.view.error') as mock_error: + + controller._do_send(create_autospec(UIInfo())) + + mock_error.assert_called_once() + + def test_error_dialog_is_opened_if_other_exception_occurs(self): + """ Test that an error dialog box is opened if an Exception + occurs. + + """ + + self.msg.__dict__['send'] = MagicMock(side_effect=Exception) + + controller = FeedbackController(model=self.msg) + + with patch('apptools.feedback.feedbackbot.view.error') as mock_error: + + controller._do_send(create_autospec(UIInfo())) + + mock_error.assert_called_once() + + def test_information_dialog_is_opened_if_no_exception_occurs(self): + """ Test that an information dialog box is opened if no Exception occurs. """ + + self.msg.__dict__['send'] = MagicMock() + + controller = FeedbackController(model=self.msg) + + with patch('apptools.feedback.feedbackbot.view.information') \ + as mock_info: + + controller._do_send(create_autospec(UIInfo())) + + mock_info.assert_called_once() + +if __name__ == '__main__': + + unittest.main() From 7c407d76be005c474ccd2cb4858e4ccd32cfb91a Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Fri, 21 Jun 2019 16:13:30 -0500 Subject: [PATCH 17/30] Bugfix in example --- apptools/feedback/examples/example.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apptools/feedback/examples/example.py b/apptools/feedback/examples/example.py index 89a7077fd..bbb3d3313 100644 --- a/apptools/feedback/examples/example.py +++ b/apptools/feedback/examples/example.py @@ -53,7 +53,7 @@ def _client_user_explanation_default(self): # appropriate place. In this example, we call it from the Feedback/Bugs menu # item in the Help menu. feedback_example_view = View( - Item('explanation', style='readonly', show_label=False), + Item('client_user_explanation', style='readonly', show_label=False), menubar=MenuBar( Menu( Action(name='Feedback/Bugs', action='initiate_feedback_dialog'), @@ -70,7 +70,7 @@ class FeedbackExampleHandler(Handler): """ Simple handler for the FeedbackExampleApp. """ def initiate_feedback_dialog(self, ui_info): - """ Initiates the feedback dialog. """ + """ Initiates the feedback dialog. """ # As mentioned earlier, the feedback dialog can be initiated by # invoking the `initiate_feedback_dialog_` function. The first argument From 40f47fd9aae8e949227a459aa62fdfc0ae9e7628 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Fri, 21 Jun 2019 16:14:16 -0500 Subject: [PATCH 18/30] Use custom style for Description field --- apptools/feedback/feedbackbot/view.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apptools/feedback/feedbackbot/view.py b/apptools/feedback/feedbackbot/view.py index 75e42a3ee..da11f313a 100644 --- a/apptools/feedback/feedbackbot/view.py +++ b/apptools/feedback/feedbackbot/view.py @@ -45,6 +45,7 @@ Item('organization', tooltip='Enter the name of your organization.'), Item('description', + style='custom', tooltip='Enter feedback.', height=200, springy=True)), From b7da2c21c914900c1f1e99fcd367b45b3ccafbdf Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Fri, 21 Jun 2019 16:47:39 -0500 Subject: [PATCH 19/30] PEP8 compliance --- apptools/feedback/examples/example.py | 44 ++++++++-------- apptools/feedback/feedbackbot/model.py | 42 +++++++-------- .../feedback/feedbackbot/tests/test_model.py | 17 +++--- .../feedback/feedbackbot/tests/test_view.py | 32 +++++++----- apptools/feedback/feedbackbot/utils.py | 40 +++++++------- apptools/feedback/feedbackbot/view.py | 52 +++++++++---------- 6 files changed, 115 insertions(+), 112 deletions(-) diff --git a/apptools/feedback/examples/example.py b/apptools/feedback/examples/example.py index bbb3d3313..f24f63c1b 100644 --- a/apptools/feedback/examples/example.py +++ b/apptools/feedback/examples/example.py @@ -12,17 +12,15 @@ """ import os -import logging -from traits.api import HasTraits, Str, Instance +from traits.api import HasTraits, Str from traitsui.api import ( Item, Menu, MenuBar, OKCancelButtons, View, Action, Handler ) -from apptools.feedback.feedbackbot.model import FeedbackMessage -from apptools.feedback.feedbackbot.view import FeedbackController from apptools.feedback.feedbackbot.utils import initiate_feedback_dialog_ + class FeedbackExampleApp(HasTraits): """ A simple model to demonstrate the feedback dialog.""" @@ -34,20 +32,22 @@ def _client_user_explanation_default(self): return """ This app demonstrates how to use the feedback dialog. - To begin, click on Feedback/Bugs in the Help menu. This will - automatically take a screenshot of this app, and launch the feedback - dialog box. You should be able to see a preview of the screenshot in the - dialog box. - - Next, enter your details, and a description of the problem. All fields - are mandatory, and you can't send the message till you type something - in each field. When you're done, click on the Send button. The dialog - is pre-configured by our developers to ensure it reaches the right team. - - The dialog will notify you of successful delivery of the message, or if + To begin, click on Feedback/Bugs in the Help menu. This will + automatically take a screenshot of this app, and launch the feedback + dialog box. You should be able to see a preview of the + screenshot in the dialog box. + + Next, enter your details, and a description of the problem. All fields + are mandatory, and you can't send the message till you type something + in each field. When you're done, click on the Send button. The dialog + is pre-configured by our developers to ensure it reaches + the right team. + + The dialog will notify you of successful delivery of the message, or if any problems occured.""" -# View for the example app. The feedbackbot module provides a helper function + +# View for the example app. The feedbackbot module provides a helper function # `initiate_feedback_dialog_` that launches the feedback dialog box. To include # the feedback dialog box in the app, simply call this function from an # appropriate place. In this example, we call it from the Feedback/Bugs menu @@ -66,11 +66,12 @@ def _client_user_explanation_default(self): resizable=True, ) + class FeedbackExampleHandler(Handler): """ Simple handler for the FeedbackExampleApp. """ def initiate_feedback_dialog(self, ui_info): - """ Initiates the feedback dialog. """ + """ Initiates the feedback dialog. """ # As mentioned earlier, the feedback dialog can be initiated by # invoking the `initiate_feedback_dialog_` function. The first argument @@ -81,14 +82,15 @@ def initiate_feedback_dialog(self, ui_info): # pass around the token (again, see the README for a discussion on what # could go wrong if the token gets leaked.). The third argument is the # channel where you'd like messages from this app to go. The value for - # this argument must start with '#'. + # this argument must start with '#'. initiate_feedback_dialog_(ui_info.ui.control, - os.environ['FEEDBACKBOT_OAUTH_TOKEN'], '#general') + os.environ['FEEDBACKBOT_OAUTH_TOKEN'], + '#general') if __name__ == '__main__': app = FeedbackExampleApp() - app.configure_traits(view=feedback_example_view, - handler=FeedbackExampleHandler()) + app.configure_traits(view=feedback_example_view, + handler=FeedbackExampleHandler()) diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py index fbc1a2558..35c4d9883 100644 --- a/apptools/feedback/feedbackbot/model.py +++ b/apptools/feedback/feedbackbot/model.py @@ -6,18 +6,15 @@ import io import logging -import numpy as np import slack -import aiohttp from PIL import Image from traits.api import ( - HasRequiredTraits, Str, Property, - Int, Array, Bytes, String, - Any, cached_property, on_trait_change) + HasRequiredTraits, Str, Property, Array, String, Any) logger = logging.getLogger(__name__) + class FeedbackMessage(HasRequiredTraits): """ Model for the feedback message. @@ -39,7 +36,7 @@ class FeedbackMessage(HasRequiredTraits): #: Main body of the feedback message. description = Str(msg_meta=True) - #: The target slack channel that the bot will post to, must start with # + #: The target slack channel that the bot will post to, must start with # # and must be provided by the user-developer. channels = String(minlen=2, regex='#.*', required=True) @@ -52,19 +49,19 @@ class FeedbackMessage(HasRequiredTraits): #: 3D numpy array to hold three channel (RGB) screenshot pixel data. img_data = Array(shape=(None, None, 3), dtype='uint8', required=True) - # FIXME: Not sure if this the right way to go about initiating a - # non-Trait. + # FIXME: Not sure if this the right way to go about initiating a + # non-Trait. #: In-memory file buffer to store the compressed screenshot. compressed_img_buf = Any(io.BytesIO()) - + def _get_msg(self): feedback_template = 'Name: {name}\n' \ + 'Organization: {org}\nDescription: {desc}' - return feedback_template.format( - name=self.name, - org=self.organization, + return feedback_template.format( + name=self.name, + org=self.organization, desc=self.description) def _img_data_changed(self): @@ -75,18 +72,18 @@ def _img_data_changed(self): def send(self): """ Send feedback message and screenshot to Slack. """ - # Set up object that talks to Slack's API. Note that the run_async + # Set up object that talks to Slack's API. Note that the run_async # flag is False. This ensures that each HTTP request is blocking. More # precisely, the WebClient sets up an event loop with just a single # HTTP request in it, and ensures that the event loop runs to - # completion before returning. + # completion before returning. client = slack.WebClient(token=self.token, timeout=5, - ssl=True, - run_async=False) + ssl=True, + run_async=False) - logger.info("Attempting to send message: <%s> to channel: <%s>", - self.msg, self.channels) + logger.info("Attempting to send message: <%s> to channel: <%s>", + self.msg, self.channels) # Send message. response = client.files_upload( @@ -95,10 +92,9 @@ def send(self): filetype='png', filename='screenshot.png', file=self.compressed_img_buf) - - - logger.info("Message sent." - + " Slack responded with OK : {ok_resp}".format(ok_resp=response['ok'])) - return response + logger.info("Message sent." + + " Slack responded with OK : {ok_resp}".format( + ok_resp=response['ok'])) + return response diff --git a/apptools/feedback/feedbackbot/tests/test_model.py b/apptools/feedback/feedbackbot/tests/test_model.py index a6b3f0e63..6070ce2a0 100644 --- a/apptools/feedback/feedbackbot/tests/test_model.py +++ b/apptools/feedback/feedbackbot/tests/test_model.py @@ -10,29 +10,32 @@ from apptools.feedback.feedbackbot.model import FeedbackMessage + class TestFeedbackMessage(unittest.TestCase, UnittestTools): def test_invalid_img_data_raises_error(self): - """ Test that setting img_data to an incorrectly shaped array + """ Test that setting img_data to an incorrectly shaped array raises a TraitError. """ with self.assertRaises(TraitError): - msg = FeedbackMessage(img_data=np.empty((2,2)), - token='xoxb-123456', channels='#general') + FeedbackMessage(img_data=np.empty((2, 2)), + token='xoxb-123456', + channels='#general') def test_invalid_channel_raises_error(self): - """ Test that passing a channel name that doesn't begin with + """ Test that passing a channel name that doesn't begin with '#' raises TraitError. """ with self.assertRaises(TraitError): - - msg = FeedbackMessage(img_data=np.empty((1,1,3)), - token='xoxb-123456', channels='general') + + FeedbackMessage(img_data=np.empty((1, 1, 3)), + token='xoxb-123456', + channels='general') if __name__ == '__main__': diff --git a/apptools/feedback/feedbackbot/tests/test_view.py b/apptools/feedback/feedbackbot/tests/test_view.py index d9c0a2482..e1cac7c61 100644 --- a/apptools/feedback/feedbackbot/tests/test_view.py +++ b/apptools/feedback/feedbackbot/tests/test_view.py @@ -2,25 +2,27 @@ Tests for FeedbackController. """ -from unittest import TestCase +import unittest from unittest.mock import patch, MagicMock, create_autospec + import numpy as np from aiohttp import ClientConnectionError, ServerTimeoutError from slack.errors import SlackApiError -from pyface.api import information, error from traits.testing.unittest_tools import UnittestTools -from traitsui.api import UI, UIInfo +from traitsui.api import UIInfo from apptools.feedback.feedbackbot.model import FeedbackMessage from apptools.feedback.feedbackbot.view import FeedbackController -class TestFeedbackController(TestCase, UnittestTools): + +class TestFeedbackController(unittest.TestCase, UnittestTools): def setUp(self): - self.msg = FeedbackMessage(img_data=np.empty((2,2,3)), - channels='#dummy', token='xoxb-123456') + self.msg = FeedbackMessage(img_data=np.empty((2, 2, 3)), + channels='#dummy', + token='xoxb-123456') self.msg.name = 'dummy_name' self.msg.organization = 'dummy_org' @@ -53,7 +55,7 @@ def test__send_enabled_empty_description(self): self.assertFalse(controller_no_description._send_enabled) - def test_error_dialog_is_opened_if_slack_ratelimited_exception_occurs(self): + def test_error_dialog_opened_if_slack_ratelimited_exception_occurs(self): """ Test that an error dialog box is opened if a SlackApiError occurs as a result of being rate-limited. @@ -78,7 +80,7 @@ def test_error_dialog_is_opened_if_slack_ratelimited_exception_occurs(self): mock_error.assert_called_once() - def test_error_dialog_is_opened_if_slack_exception_occurs(self): + def test_error_dialog_opened_if_slack_exception_occurs(self): """ Test that an error dialog box is opened if a SlackApiError occurs, but not as a result of being rate-limited. @@ -98,7 +100,7 @@ def test_error_dialog_is_opened_if_slack_exception_occurs(self): mock_error.assert_called_once() - def test_error_dialog_is_opened_if_client_connection_exception_occurs(self): + def test_error_dialog_opened_if_client_connection_exception_occurs(self): """ Test that an error dialog box is opened if a ClientConnectionError occurs. @@ -115,7 +117,7 @@ def test_error_dialog_is_opened_if_client_connection_exception_occurs(self): mock_error.assert_called_once() - def test_error_dialog_is_opened_if_server_timeout_exception_occurs(self): + def test_error_dialog_opened_if_server_timeout_exception_occurs(self): """ Test that an error dialog box is opened if a ServerTimeoutError occurs. @@ -132,7 +134,7 @@ def test_error_dialog_is_opened_if_server_timeout_exception_occurs(self): mock_error.assert_called_once() - def test_error_dialog_is_opened_if_other_exception_occurs(self): + def test_error_dialog_opened_if_other_exception_occurs(self): """ Test that an error dialog box is opened if an Exception occurs. @@ -148,8 +150,11 @@ def test_error_dialog_is_opened_if_other_exception_occurs(self): mock_error.assert_called_once() - def test_information_dialog_is_opened_if_no_exception_occurs(self): - """ Test that an information dialog box is opened if no Exception occurs. """ + def test_information_dialog_opened_if_no_exception_occurs(self): + """ Test that an information dialog box is opened + if no Exception occurs. + + """ self.msg.__dict__['send'] = MagicMock() @@ -162,6 +167,7 @@ def test_information_dialog_is_opened_if_no_exception_occurs(self): mock_info.assert_called_once() + if __name__ == '__main__': unittest.main() diff --git a/apptools/feedback/feedbackbot/utils.py b/apptools/feedback/feedbackbot/utils.py index 407b71b0f..e56ad2064 100644 --- a/apptools/feedback/feedbackbot/utils.py +++ b/apptools/feedback/feedbackbot/utils.py @@ -1,15 +1,12 @@ -""" +""" This module provides some helper functions for the feedback plugin. These functions are designed to be used by the user-developer so that the feedback -plugin can be used in their application. +plugin can be used in their application. """ -import io import logging from pyface.qt.QtGui import QPixmap -from pyface.qt.QtCore import QBuffer -from PIL import Image import numpy as np from .model import FeedbackMessage @@ -17,8 +14,9 @@ logger = logging.getLogger(__name__) + def take_screenshot_qimage(control): - """ Take screenshot of an active GUI widget. + """ Take screenshot of an active GUI widget. Parameters ---------- @@ -33,19 +31,20 @@ def take_screenshot_qimage(control): """ logger.info('Grabbing screenshot of control' - + ' with id <%s> and title <%s>.', - str(control.winId()), control.windowTitle()) + + ' with id <%s> and title <%s>.', + str(control.winId()), control.windowTitle()) - qpixmap = QPixmap.grabWidget(control).toImage() + qpixmap = QPixmap.grabWidget(control).toImage() return qpixmap + def qimage_to_rgb_array(qimg): """ Converts a `QImage` instance to a numeric RGB array containing pixel data. Parameters ---------- - qimg : `QtGui.QImage` + qimg : `QtGui.QImage` Image to convert to array. Returns @@ -55,7 +54,7 @@ def qimage_to_rgb_array(qimg): Note ---- - If an Alpha channel is present in `qimg`, it will be dropped in the + If an Alpha channel is present in `qimg`, it will be dropped in the array representation. """ @@ -68,19 +67,20 @@ def qimage_to_rgb_array(qimg): img_bytes = qbits.asstring() logger.info('Converting raw screenshot bytes to RGB array.') - + img_array = np.ascontiguousarray( np.frombuffer(img_bytes, dtype=np.uint8).reshape( qimg.height(), qimg.width(), -1)[..., 2::-1]) return img_array + def initiate_feedback_dialog_(control, token, channels): """ Initiate the feedback dialog box. - This function grabs a screenshot of the active GUI widget - and starts up a feedback dialog box. The dialog box displays a preview of - the screenshot, and allows the client-user to enter their name, + This function grabs a screenshot of the active GUI widget + and starts up a feedback dialog box. The dialog box displays a preview of + the screenshot, and allows the client-user to enter their name, organization, and a message. This message is then sent to the specified Slack channel. @@ -90,21 +90,21 @@ def initiate_feedback_dialog_(control, token, channels): GUI widget whose screenshot will be taken. token : str - Slack API authentication token. + Slack API authentication token. channels : list List of channels where the message will be sent. Note ---- - The authentication `token` must bear the required scopes, i.e., it must have - permissions to send messages to the specified channels. + The authentication `token` must bear the required scopes, + i.e., it must have permissions to send messages to the specified channels. """ logger.info('Feedback dialog requested on control' - + ' with id <%s> and title <%s>', - str(control.winId()), control.windowTitle()) + + ' with id <%s> and title <%s>', + str(control.winId()), control.windowTitle()) img_data = qimage_to_rgb_array(take_screenshot_qimage(control)) diff --git a/apptools/feedback/feedbackbot/view.py b/apptools/feedback/feedbackbot/view.py index da11f313a..e8416f99e 100644 --- a/apptools/feedback/feedbackbot/view.py +++ b/apptools/feedback/feedbackbot/view.py @@ -1,36 +1,32 @@ """ -This module implements UI classes and logic for a plugin that enables +This module implements UI classes and logic for a plugin that enable clients to send feedback messages to a developer team's slack channel. """ -import sys import logging import traceback import slack -import numpy as np import aiohttp from traits.api import Property, Instance from traitsui.api import ( - View, Group, Item, Action, - Label, Controller, Handler) -from traitsui.menu import CancelButton -from chaco.api import Plot, ArrayPlotData + View, Group, Item, Action, Label, Controller) +from traitsui.menu import CancelButton from enable.api import ComponentEditor from enable.primitives.image import Image as ImageComponent -from pyface.api import confirm, information, warning, error, YES, NO +from pyface.api import information, error from .model import FeedbackMessage logger = logging.getLogger(__name__) # ---------------------------------------------------------------------------- -# TraitsUI Actions +# TraitsUI Actions # ---------------------------------------------------------------------------- -send_button = Action(name='Send', action='_do_send', - enabled_when='controller._send_enabled') +send_button = Action(name='Send', action='_do_send', + enabled_when='controller._send_enabled') # ---------------------------------------------------------------------------- # TraitsUI Views @@ -42,9 +38,9 @@ Group( Group( Item('name'), - Item('organization', + Item('organization', tooltip='Enter the name of your organization.'), - Item('description', + Item('description', style='custom', tooltip='Enter feedback.', height=200, @@ -67,7 +63,7 @@ class FeedbackController(Controller): """Controller for FeedbackMessage. - The Controller allows the client user to specify the feedback and preview + The Controller allows the client user to specify the feedback and preview the screenshot. """ @@ -78,7 +74,7 @@ class FeedbackController(Controller): #: Enable component to store the screenshot. image_component = Instance(ImageComponent) - #: Property that decides whether the state of the message is valid + #: Property that decides whether the state of the message is valid # for sending. _send_enabled = Property(depends_on='[+msg_meta]') @@ -89,12 +85,12 @@ def _image_component_default(self): """ Default image to display, this is simply the screenshot.""" return ImageComponent(data=self.model.img_data) - + def _get__send_enabled(self): """ Logic to check if message is valid for sending. """ return self.model.name \ - and self.model.organization and self.model.description + and self.model.organization and self.model.description def _do_send(self, ui_info): """ Actions to perform when the send button is clicked. """ @@ -102,7 +98,7 @@ def _do_send(self, ui_info): logger.info('Send button clicked in feedback dialog box.') # Boolean that specifies whether the client-user can try again or not. - # If False, then the feedback dialog box is automatically closed. + # If False, then the feedback dialog box is automatically closed. # If True, the feedback dialog is kept alive. A possible use case could # arise when the request to the Slack API takes too long (in which case # an aiohttp.ServerTimeoutError is raised). In that case, notify the @@ -114,14 +110,14 @@ def _do_send(self, ui_info): try: - response = self.model.send() + self.model.send() except slack.errors.SlackApiError as exc: if exc.response["error"] == "ratelimited": # Slack has rate-limited the bot. - # The rate limit for this API call is around 20 requests per - # workspace per minute. It is unlikely that this will happen, + # The rate limit for this API call is around 20 requests per + # workspace per minute. It is unlikely that this will happen, # but no harm in handling it. # Slack promises to return a retry-after value in seconds in @@ -140,21 +136,22 @@ def _do_send(self, ui_info): err_msg = 'Message sent successfully, but received an error' \ + ' response from Slack.' - + # Construct the detail message explicitly instead of simply calling # str(exc), which in some cases can reveal the OAuth token. - detail = 'Slack response: '.format( - ok_resp=exc.response['ok'], err_resp=exc.response['error']) - + detail = 'Slack response: '.format( + ok=exc.response['ok'], err=exc.response['error']) + error(ui_info.ui.control, err_msg, detail=detail) # For the same reason (str(exc) can reveal the OAuth token) # use logger.error instead of logger.exception - logger.error(err_msg + ' ' + detail) + logger.error(err_msg + ' ' + detail) except aiohttp.ServerTimeoutError as exc: - err_msg = 'Server took too long to respond. Please try again later.' + err_msg = 'Server took too long to respond.' \ + + ' Please try again later.' error(ui_info.ui.control, err_msg, detail=str(exc)) @@ -197,4 +194,3 @@ def _do_send(self, ui_info): ui_info.ui.dispose() logger.info('Feedback dialog closed automatically.') - From 0db10aa55f3b73c4fc6fefe50003b61ded1533a0 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Mon, 24 Jun 2019 09:20:44 -0500 Subject: [PATCH 20/30] Put error dialog test in a helper function. --- .../feedback/feedbackbot/tests/test_view.py | 53 +++++++++---------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/apptools/feedback/feedbackbot/tests/test_view.py b/apptools/feedback/feedbackbot/tests/test_view.py index e1cac7c61..18e41bb0b 100644 --- a/apptools/feedback/feedbackbot/tests/test_view.py +++ b/apptools/feedback/feedbackbot/tests/test_view.py @@ -55,6 +55,20 @@ def test__send_enabled_empty_description(self): self.assertFalse(controller_no_description._send_enabled) + def _test_error_called_once(self, controller): + """ Helper function to test if error dialog box is opened. """ + + with patch('apptools.feedback.feedbackbot.view.error') as mock_error: + # Use a mocked version of the function that creates the error + # dialog. + + # Mock the UIInfo object that is passed to the send button. + mock_ui_info = create_autospec(UIInfo()) + + controller._do_send(mock_ui_info) + + mock_error.assert_called_once() + def test_error_dialog_opened_if_slack_ratelimited_exception_occurs(self): """ Test that an error dialog box is opened if a SlackApiError occurs as a result of being rate-limited. @@ -69,16 +83,13 @@ def test_error_dialog_opened_if_slack_ratelimited_exception_occurs(self): data=patch.dict({'ok': False, 'error': 'ratelimited'}), headers={'retry-after': 10}) + # Mock the send method of the controller's model. self.msg.__dict__['send'] = MagicMock( - side_effect=SlackApiError('msg', dummy_response)) + side_effect=SlackApiError('dummy_err_msg', dummy_response)) controller = FeedbackController(model=self.msg) - with patch('apptools.feedback.feedbackbot.view.error') as mock_error: - - controller._do_send(create_autospec(UIInfo())) - - mock_error.assert_called_once() + self._test_error_called_once(controller) def test_error_dialog_opened_if_slack_exception_occurs(self): """ Test that an error dialog box is opened if a SlackApiError occurs, @@ -90,15 +101,11 @@ def test_error_dialog_opened_if_slack_exception_occurs(self): data=patch.dict({'ok': False, 'error': 'dummy_error'})) self.msg.__dict__['send'] = MagicMock( - side_effect=SlackApiError('msg', dummy_response)) + side_effect=SlackApiError('dummy_err_msg', dummy_response)) controller = FeedbackController(model=self.msg) - with patch('apptools.feedback.feedbackbot.view.error') as mock_error: - - controller._do_send(create_autospec(UIInfo())) - - mock_error.assert_called_once() + self._test_error_called_once(controller) def test_error_dialog_opened_if_client_connection_exception_occurs(self): """ Test that an error dialog box is opened if a ClientConnectionError @@ -111,11 +118,7 @@ def test_error_dialog_opened_if_client_connection_exception_occurs(self): controller = FeedbackController(model=self.msg) - with patch('apptools.feedback.feedbackbot.view.error') as mock_error: - - controller._do_send(create_autospec(UIInfo())) - - mock_error.assert_called_once() + self._test_error_called_once(controller) def test_error_dialog_opened_if_server_timeout_exception_occurs(self): """ Test that an error dialog box is opened if a ServerTimeoutError @@ -128,11 +131,7 @@ def test_error_dialog_opened_if_server_timeout_exception_occurs(self): controller = FeedbackController(model=self.msg) - with patch('apptools.feedback.feedbackbot.view.error') as mock_error: - - controller._do_send(create_autospec(UIInfo())) - - mock_error.assert_called_once() + self._test_error_called_once(controller) def test_error_dialog_opened_if_other_exception_occurs(self): """ Test that an error dialog box is opened if an Exception @@ -144,11 +143,7 @@ def test_error_dialog_opened_if_other_exception_occurs(self): controller = FeedbackController(model=self.msg) - with patch('apptools.feedback.feedbackbot.view.error') as mock_error: - - controller._do_send(create_autospec(UIInfo())) - - mock_error.assert_called_once() + self._test_error_called_once(controller) def test_information_dialog_opened_if_no_exception_occurs(self): """ Test that an information dialog box is opened @@ -163,7 +158,9 @@ def test_information_dialog_opened_if_no_exception_occurs(self): with patch('apptools.feedback.feedbackbot.view.information') \ as mock_info: - controller._do_send(create_autospec(UIInfo())) + mock_ui_info = create_autospec(UIInfo()) + + controller._do_send(mock_ui_info) mock_info.assert_called_once() From f60992cba75e81288f76daafb82fa848c6a1d7cc Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Tue, 25 Jun 2019 11:16:48 -0500 Subject: [PATCH 21/30] Compress image inside send function. --- apptools/feedback/feedbackbot/model.py | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py index 35c4d9883..c8fd93eaf 100644 --- a/apptools/feedback/feedbackbot/model.py +++ b/apptools/feedback/feedbackbot/model.py @@ -10,7 +10,7 @@ from PIL import Image from traits.api import ( - HasRequiredTraits, Str, Property, Array, String, Any) + HasRequiredTraits, Str, Property, Array, String) logger = logging.getLogger(__name__) @@ -49,11 +49,6 @@ class FeedbackMessage(HasRequiredTraits): #: 3D numpy array to hold three channel (RGB) screenshot pixel data. img_data = Array(shape=(None, None, 3), dtype='uint8', required=True) - # FIXME: Not sure if this the right way to go about initiating a - # non-Trait. - #: In-memory file buffer to store the compressed screenshot. - compressed_img_buf = Any(io.BytesIO()) - def _get_msg(self): feedback_template = 'Name: {name}\n' \ @@ -64,11 +59,6 @@ def _get_msg(self): org=self.organization, desc=self.description) - def _img_data_changed(self): - - Image.fromarray(self.img_data).save(self.compressed_img_buf, 'PNG') - self.compressed_img_buf.seek(0) - def send(self): """ Send feedback message and screenshot to Slack. """ @@ -85,13 +75,20 @@ def send(self): logger.info("Attempting to send message: <%s> to channel: <%s>", self.msg, self.channels) + # Compress screenshot into PNG format using an in-memory buffer. + compressed_img_buf = io.BytesIO() + + Image.fromarray(self.img_data).save(compressed_img_buf, 'PNG') + + compressed_img_buf.seek(0) + # Send message. response = client.files_upload( channels=self.channels, initial_comment=self.msg, filetype='png', filename='screenshot.png', - file=self.compressed_img_buf) + file=compressed_img_buf) logger.info("Message sent." + " Slack responded with OK : {ok_resp}".format( From 1a61255910f74a6f6c3a8aac7e56cc4eb7ddee9e Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Tue, 25 Jun 2019 11:20:22 -0500 Subject: [PATCH 22/30] Fix incorrect syntax for metadata dependence. --- apptools/feedback/feedbackbot/model.py | 2 +- apptools/feedback/feedbackbot/view.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apptools/feedback/feedbackbot/model.py b/apptools/feedback/feedbackbot/model.py index c8fd93eaf..ca2483f54 100644 --- a/apptools/feedback/feedbackbot/model.py +++ b/apptools/feedback/feedbackbot/model.py @@ -44,7 +44,7 @@ class FeedbackMessage(HasRequiredTraits): token = Str(required=True) #: The final message that gets posted to Slack. - msg = Property(Str, depends_on='msg_meta') + msg = Property(Str, depends_on='+msg_meta') #: 3D numpy array to hold three channel (RGB) screenshot pixel data. img_data = Array(shape=(None, None, 3), dtype='uint8', required=True) diff --git a/apptools/feedback/feedbackbot/view.py b/apptools/feedback/feedbackbot/view.py index e8416f99e..586193733 100644 --- a/apptools/feedback/feedbackbot/view.py +++ b/apptools/feedback/feedbackbot/view.py @@ -76,7 +76,7 @@ class FeedbackController(Controller): #: Property that decides whether the state of the message is valid # for sending. - _send_enabled = Property(depends_on='[+msg_meta]') + _send_enabled = Property(depends_on='+msg_meta') # Default view for this controller. trait_view = feedback_msg_view From b34a76838b926b03d2c7f145f1d3bc8e3c083c9c Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Thu, 27 Jun 2019 14:53:18 -0500 Subject: [PATCH 23/30] Add test to ensure files_upload is called correctly --- .../feedback/feedbackbot/tests/test_model.py | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/apptools/feedback/feedbackbot/tests/test_model.py b/apptools/feedback/feedbackbot/tests/test_model.py index 6070ce2a0..ea1fbf4a8 100644 --- a/apptools/feedback/feedbackbot/tests/test_model.py +++ b/apptools/feedback/feedbackbot/tests/test_model.py @@ -4,6 +4,8 @@ import numpy as np import unittest +from unittest.mock import patch +from PIL import Image from traits.api import TraitError from traits.testing.unittest_tools import UnittestTools @@ -37,6 +39,68 @@ def test_invalid_channel_raises_error(self): token='xoxb-123456', channels='general') + def test_send(self): + + img_data = np.array([[[1, 2, 3]]], dtype=np.uint8) + + token = 'xoxb-123456' + + channels = '#general' + + msg = FeedbackMessage(img_data=img_data, + token=token, + channels=channels) + + msg.name = 'Tom Riddle' + msg.organization = 'Death Eather, Inc' + msg.description = 'No one calls me Voldy.' + + expected_msg = \ + 'Name: {}\nOrganization: {}\nDescription: {}'.format( + msg.name, msg.organization, msg.description) + + files_upload_found = False + + with patch('apptools.feedback.feedbackbot.model.slack'): + + with patch('apptools.feedback.feedbackbot.model.slack.WebClient') \ + as mock_client: + + msg.send() + + for call_ in mock_client.mock_calls: + # Loop over all calls made to mock_client, including nested + # function calls. + + # Glean function name, provided positional and keyword + # arguemts in call_ + name, args, kwargs = call_ + + if name == '().files_upload': + + files_upload_found = True + + # The following lines ensure that is + # called with the correct arguments. + + # There shouldn't be any positional arguments. + self.assertTupleEqual((), args) + + # The following lines check keyword arguments were + # passed correctly. + np.testing.assert_almost_equal( + img_data, np.array(Image.open(kwargs['file']))) + + self.assertEqual(channels, kwargs['channels']) + + self.assertEqual( + expected_msg, kwargs['initial_comment']) + + if not files_upload_found: + + self.fail( + "Call to Slack API method not found.") + if __name__ == '__main__': From ef1a643cde38ef31f27756604d1d4a61c6d61894 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Thu, 27 Jun 2019 14:53:39 -0500 Subject: [PATCH 24/30] Remove trailing _ --- apptools/feedback/feedbackbot/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apptools/feedback/feedbackbot/utils.py b/apptools/feedback/feedbackbot/utils.py index e56ad2064..44aa67d47 100644 --- a/apptools/feedback/feedbackbot/utils.py +++ b/apptools/feedback/feedbackbot/utils.py @@ -75,7 +75,7 @@ def qimage_to_rgb_array(qimg): return img_array -def initiate_feedback_dialog_(control, token, channels): +def initiate_feedback_dialog(control, token, channels): """ Initiate the feedback dialog box. This function grabs a screenshot of the active GUI widget From 1ee8f74e7c0bf58a997f0308d51e2304f08759dd Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Thu, 27 Jun 2019 14:53:55 -0500 Subject: [PATCH 25/30] Remove trailing _ --- apptools/feedback/examples/example.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apptools/feedback/examples/example.py b/apptools/feedback/examples/example.py index f24f63c1b..7421b7161 100644 --- a/apptools/feedback/examples/example.py +++ b/apptools/feedback/examples/example.py @@ -18,7 +18,7 @@ Item, Menu, MenuBar, OKCancelButtons, View, Action, Handler ) -from apptools.feedback.feedbackbot.utils import initiate_feedback_dialog_ +from apptools.feedback.feedbackbot.utils import initiate_feedback_dialog class FeedbackExampleApp(HasTraits): @@ -83,7 +83,7 @@ def initiate_feedback_dialog(self, ui_info): # could go wrong if the token gets leaked.). The third argument is the # channel where you'd like messages from this app to go. The value for # this argument must start with '#'. - initiate_feedback_dialog_(ui_info.ui.control, + initiate_feedback_dialog(ui_info.ui.control, os.environ['FEEDBACKBOT_OAUTH_TOKEN'], '#general') From d7ffe075e505e4a8dab4ba4882a4bb6019d7f430 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Thu, 27 Jun 2019 15:02:43 -0500 Subject: [PATCH 26/30] Move example file --- {apptools/feedback/examples => examples/feedback}/example.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {apptools/feedback/examples => examples/feedback}/example.py (100%) diff --git a/apptools/feedback/examples/example.py b/examples/feedback/example.py similarity index 100% rename from apptools/feedback/examples/example.py rename to examples/feedback/example.py From e659e4985e4019aa7611710de8490ca0dc030399 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Thu, 27 Jun 2019 16:01:29 -0500 Subject: [PATCH 27/30] Add comments + fix typos --- apptools/feedback/feedbackbot/tests/test_model.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apptools/feedback/feedbackbot/tests/test_model.py b/apptools/feedback/feedbackbot/tests/test_model.py index ea1fbf4a8..e32204316 100644 --- a/apptools/feedback/feedbackbot/tests/test_model.py +++ b/apptools/feedback/feedbackbot/tests/test_model.py @@ -40,6 +40,9 @@ def test_invalid_channel_raises_error(self): channels='general') def test_send(self): + """ Test that the slack client call happens with the correct arguments. + + """ img_data = np.array([[[1, 2, 3]]], dtype=np.uint8) @@ -55,9 +58,8 @@ def test_send(self): msg.organization = 'Death Eather, Inc' msg.description = 'No one calls me Voldy.' - expected_msg = \ - 'Name: {}\nOrganization: {}\nDescription: {}'.format( - msg.name, msg.organization, msg.description) + expected_msg = 'Name: {}\nOrganization: {}\nDescription: {}'.format( + msg.name, msg.organization, msg.description) files_upload_found = False @@ -73,7 +75,7 @@ def test_send(self): # function calls. # Glean function name, provided positional and keyword - # arguemts in call_ + # arguments in call_ name, args, kwargs = call_ if name == '().files_upload': @@ -86,8 +88,8 @@ def test_send(self): # There shouldn't be any positional arguments. self.assertTupleEqual((), args) - # The following lines check keyword arguments were - # passed correctly. + # The following lines check whether keyword arguments + # were passed correctly. np.testing.assert_almost_equal( img_data, np.array(Image.open(kwargs['file']))) From 133a35f1a42ebe30b6606df64f636169c93fd122 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Fri, 28 Jun 2019 13:25:07 -0500 Subject: [PATCH 28/30] Add README --- apptools/feedback/feedbackbot/README.md | 33 +++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 apptools/feedback/feedbackbot/README.md diff --git a/apptools/feedback/feedbackbot/README.md b/apptools/feedback/feedbackbot/README.md new file mode 100644 index 000000000..d57fcf3c5 --- /dev/null +++ b/apptools/feedback/feedbackbot/README.md @@ -0,0 +1,33 @@ +Model and GUI logic for a Feedback/Bugs dialog box. The comments in this +[example app](https://github.com/enthought/apptools/tree/master/examples/feedback) +demonstrate how the dialog box can be incorporated in any TraitsUI app. + +### Requirements: +- python3 +- numpy +- PIL +- python-slackclient + +### Slack setup + +Create a Slack app and install it to the Slack workspace. Then, add a bot user +to the app (detailed instructions are provided +[here](https://api.slack.com/bot-users). + +#### Tokens, authentication, and security + +Requests to the Slack API have to be authenticated with an authorization token. +A token for the bot-user will be created automatically when the bot is added to +the Slack app. This token must be provided to the model class when an instance +is created. + +The bearer of a bot token has a pretty broad set of permissions. For instance, +they can upload files to a channel, lookup a user with an email address, and +even get the entire conversation history of a channel (see this +[link](https://api.slack.com/bot-users#methods) for a full list of functions +accessible with a bot token). Needless to say, tokens must be secured and never +revealed publicly. The Slack developers blog likens it to sharing passwords +online. The responsibility of transmitting tokens securely lies with the +developer of the app incorporating this dialog box. Refer to the [Slack API +documentation](https://api.slack.com/docs/oauth-safety) +for security best-practices. From 12691b609f35d63cf762ed9df552cf1220455937 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Fri, 28 Jun 2019 13:28:09 -0500 Subject: [PATCH 29/30] Fix typos in README --- apptools/feedback/feedbackbot/README.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apptools/feedback/feedbackbot/README.md b/apptools/feedback/feedbackbot/README.md index d57fcf3c5..847507451 100644 --- a/apptools/feedback/feedbackbot/README.md +++ b/apptools/feedback/feedbackbot/README.md @@ -12,7 +12,7 @@ demonstrate how the dialog box can be incorporated in any TraitsUI app. Create a Slack app and install it to the Slack workspace. Then, add a bot user to the app (detailed instructions are provided -[here](https://api.slack.com/bot-users). +[here](https://api.slack.com/bot-users)). #### Tokens, authentication, and security @@ -26,8 +26,7 @@ they can upload files to a channel, lookup a user with an email address, and even get the entire conversation history of a channel (see this [link](https://api.slack.com/bot-users#methods) for a full list of functions accessible with a bot token). Needless to say, tokens must be secured and never -revealed publicly. The Slack developers blog likens it to sharing passwords -online. The responsibility of transmitting tokens securely lies with the +revealed publicly. The responsibility of transmitting tokens securely lies with the developer of the app incorporating this dialog box. Refer to the [Slack API documentation](https://api.slack.com/docs/oauth-safety) for security best-practices. From d651bf84c451c09f3d4b211b30944656b64768b0 Mon Sep 17 00:00:00 2001 From: Siddhant Wahal Date: Fri, 28 Jun 2019 15:41:42 -0500 Subject: [PATCH 30/30] Fixed typo in function name --- examples/feedback/example.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/feedback/example.py b/examples/feedback/example.py index 7421b7161..3b4925ed7 100644 --- a/examples/feedback/example.py +++ b/examples/feedback/example.py @@ -48,7 +48,7 @@ def _client_user_explanation_default(self): # View for the example app. The feedbackbot module provides a helper function -# `initiate_feedback_dialog_` that launches the feedback dialog box. To include +# `initiate_feedback_dialog` that launches the feedback dialog box. To include # the feedback dialog box in the app, simply call this function from an # appropriate place. In this example, we call it from the Feedback/Bugs menu # item in the Help menu.